# secureconfig.ai: all guides in one file > Generated from https://github.com/jposluns/secureconfig (CC0 1.0, except the AIQT Guardrails material listed in .aiqt/PIN, Apache-2.0). > Per-guide index: https://secureconfig.ai/llms.txt > Each file below starts with a line such as `==> nginx.md` between two lines of `=` characters. > Every link to a guide or another repository file is an absolute URL in https://github.com/jposluns/secureconfig on the `main` branch, not a page on secureconfig.ai. If GitHub is unreachable, a guide's text is the section below that starts with its file name. ====================================================================== ==> README.md ====================================================================== # secureconfig Keep the world and its information more secure, one deployment at a time. Security guides that tell your AI assistant what to apply, how to verify the result, and how to report what passed, what failed, and what it could not test. ## What this is A library of short, generic, copy-ready guides covering the full deployment surface of a modern project: certificates and tunnels; human and machine identity, MFA, and secrets; web servers and proxies; language runtimes and frameworks; containers and Kubernetes; hosts, cloud firewalls, PaaS, and rented GPU clouds; databases, storage, and messaging; the AI stack itself (model servers, vector databases, MCP servers, agent and chat UIs, LLM observability); and the operational controls around a deployment, from first run through teardown. At the time of writing that is 102 guides across the 12 categories indexed below, plus [controls-reference.md](https://github.com/jposluns/secureconfig/blob/main/controls-reference.md) for any service that has no guide yet. ## The gap and the standard Publicly reachable services keep shipping with the gap on the deployment surface: services listening on plain HTTP, admin consoles with no login, AI tools exposed on their defaults. These guides close that gap, and they hold themselves to the standard they ask of you. Contributions must take every configuration line from the vendor documentation cited in the guide, and Verify steps declare what they rest on: DEMONSTRATED means the check ran against both the exposed and the fixed state, REASONED means it is derived from cited vendor behaviour and says so, and a reasoned step is never presented as verified. Where a tool has no native control, the guide says so plainly instead of inventing one. Most guides also carry machine-readable front matter recording the sources checked and, where known, the exact versions. An automated gate suite under [tools/](https://github.com/jposluns/secureconfig/tree/main/tools/) checks the structure of these declarations on every change, with older steps still being brought in line listed in [tools/verify_marking_baseline.txt](https://github.com/jposluns/secureconfig/blob/main/tools/verify_marking_baseline.txt); whether a demonstration ran or a reasoned step is justified is checked in review, not by the gates. ## Built for AI assistants This repository gives an AI assistant the security steps to apply and a way to report what it verified, what failed, and what it could not test. The guides are written for machine consumption: stable raw URLs, a machine-readable index at [secureconfig.ai/llms.txt](https://secureconfig.ai/llms.txt), every guide in one file at [secureconfig.ai/llms-full.txt](https://secureconfig.ai/llms-full.txt), baseline rules an assistant can apply directly (next section), a decision guide mapping deployments to guides, and a verification checklist that requires reporting what could not be tested rather than asserting a pass. ## The maintainer Jeff Posluns has spent more than 30 years in technology and security and wants to help keep the world and its information more secure. He built this library from that experience and uses his own AI tooling to keep expanding and updating it, with automated checks so it keeps its accuracy as it grows. Everything here is free, and all of it except the AIQT Guardrails material listed in `.aiqt/PIN` and the site template's fonts and third-party marks is in the public domain. The guides are dedicated to the public domain under CC0. The exceptions are the AIQT Guardrails material listed in `.aiqt/PIN` (vendored files and the AIQT sections adapted into `CLAUDE.md` and `AGENTS.md`), which is licensed under the Apache License 2.0, and the site template's fonts and GitHub, LinkedIn and ORCID marks (see [Licence](#licence)). ## Use it - With an AI assistant: tell it to secure your project using the guides at [github.com/jposluns/secureconfig](https://github.com/jposluns/secureconfig), or copy a ready-made task from [secureconfig.ai](https://secureconfig.ai); have it finish with the verification checklist below and report anything it could not test. - In Claude Code: install the skill as a plugin, or add its rules to your project instructions, as [Install in Claude Code](#install-in-claude-code) sets out. - By hand: start with the decision guide below, apply every guide that fits, and run the verification checklist. - To contribute: open a GitHub issue or send a pull request (reaching Jeff any other way also works). To request a guide for an application with no guide yet, add a file to [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md) per its template, one application per pull request. See [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md). The site at [secureconfig.ai](https://secureconfig.ai), served from [site/](https://github.com/jposluns/secureconfig/tree/main/site/) through Cloudflare Pages, is the short public pointer to these guides. ## Instructions for AI development assistants If you are an AI assistant configuring, generating, or reviewing a service, apply these rules: 1. Treat every service as internet-reachable unless you have confirmed otherwise. Bind services to `127.0.0.1` by default and expose them only through a TLS-terminating layer that requires authentication; keep backend, admin, and AI-tool services private. 2. Choose the certificate and access path before configuring exposure. A public DNS name with reachable ports 80 and 443: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md). A Cloudflare-managed domain with no inbound ports: [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). A tailnet with no public domain: [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md). Internal or development use only: [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). 3. Apply every guide that fits the deployment, not just one. Select the service or framework, its host or platform, containers where used, plus identity, secrets, and the operational controls below. For a service with no guide, run the alignment assessment in [controls-reference.md](https://github.com/jposluns/secureconfig/blob/main/controls-reference.md), which distils the control patterns common to every guide, and verify the service's native controls against the vendor documentation; then request a guide by adding a file to the requests directory (https://github.com/jposluns/secureconfig/tree/main/requests) so the service is covered next time. 4. Redirect HTTP to HTTPS, or do not listen on HTTP at all. 5. Require authentication on every non-public endpoint. Never ship default or hardcoded credentials. Add MFA to human logins where viable per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md); where a tool has no native login, put one in front per [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). 6. A successful login is not authorization. Check the returned identity against an allowlist (tenant, hosted domain, group, or explicit users) before granting access. Choose the provider per [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md), wire it per [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), or put login in front of the app per [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md). 7. Keep secrets out of the repository and load them from the environment or a secret manager per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md); give each service and agent its own scoped credentials per [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md). 8. Contain what an exposed or compromised service can reach. Block unneeded outbound access and lock down cloud metadata per [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md), and never serve dotfiles, database dumps, or client secrets per [web-exposure.md](https://github.com/jposluns/secureconfig/blob/main/web-exposure.md). 9. AI and data tools (model servers, vector databases, MCP servers, agent builders, chat and image UIs, MLflow, Ray) vary in their defaults and native controls, and many need authentication and TLS configured explicitly. Keep them on loopback and apply the matching guide before any exposure. 10. Run the verification checklist below and each selected guide's Verify steps before reporting the work as complete. Report any item you could not test instead of asserting that it passed. Fetch guides raw with `https://raw.githubusercontent.com/jposluns/secureconfig//.md` (for example `.../main/nginx.md`). Every guide concatenated into a single file: https://secureconfig.ai/llms-full.txt (also [site/llms-full.txt](https://github.com/jposluns/secureconfig/blob/main/site/llms-full.txt) in this repository); the machine-readable index is https://secureconfig.ai/llms.txt If raw.githubusercontent.com is unreachable, use https://secureconfig.ai/llms-full.txt, which holds every guide; each file in llms-full.txt starts with a line such as `==> nginx.md` between two lines of `=` characters. Every link inside llms-full.txt to a guide or another file in this repository is an absolute GitHub URL on the `main` branch, such as `https://github.com/jposluns/secureconfig/blob/main/mfa.md`, not a page on secureconfig.ai. If GitHub is unreachable too, the same guide is the section of llms-full.txt that starts with that file name (here `==> mfa.md`). ## Install in Claude Code This repository is a Claude Code plugin marketplace: [.claude-plugin/marketplace.json](https://github.com/jposluns/secureconfig/blob/main/.claude-plugin/marketplace.json) at its root lists one plugin, `secureconfig`, whose source is [plugin/](https://github.com/jposluns/secureconfig/tree/main/plugin/) ([plugin marketplaces](https://code.claude.com/docs/en/plugin-marketplaces)). The plugin has no Claude Code manifest of its own, so Claude Code loads its standard layout, the [secureconfig](https://github.com/jposluns/secureconfig/blob/main/plugin/skills/secureconfig/SKILL.md) skill under `plugin/skills/`, and takes the name from the marketplace entry ([plugin manifest](https://code.claude.com/docs/en/plugins/manifest-reference)). In a Claude Code session, add the marketplace and install the plugin ([installing plugins](https://code.claude.com/docs/en/plugins/install)): ```text /plugin marketplace add jposluns/secureconfig /plugin install secureconfig@secureconfig ``` The same from a shell: ```bash claude plugin marketplace add jposluns/secureconfig claude plugin install secureconfig@secureconfig ``` Without a marketplace, copy the skill's folder, [plugin/skills/secureconfig/](https://github.com/jposluns/secureconfig/tree/main/plugin/skills/secureconfig/), to `~/.claude/skills/secureconfig/` for all your projects or to `.claude/skills/secureconfig/` in one project ([skills](https://code.claude.com/docs/en/skills)). Refresh a copied folder by copying it again: its `scripts/update-guides.sh` reads the `plugin.json` two folders above it, which a copied skill folder does not carry, and stops without changing anything. A skill loads when its description matches the task. To have the rules loaded with your project instructions, paste [plugin/always-on.md](https://github.com/jposluns/secureconfig/blob/main/plugin/always-on.md), the skill's rules for every deployment and one line pointing at the skill, into your project's `CLAUDE.md`, which Claude Code reads as project instructions at the start of a session; the [memory](https://code.claude.com/docs/en/memory) page lists the settings that change which files load. If your project keeps its assistant instructions in `AGENTS.md`, you can keep the snippet there and import that file from a `CLAUDE.md` beside it with an `@AGENTS.md` line ([share one file with other coding tools](https://code.claude.com/docs/en/memory#share-one-file-with-other-coding-tools)). Claude Code can also read `AGENTS.md` directly, when there is no `CLAUDE.md` or `CLAUDE.local.md` in the working directory or above it, in supported sessions; the [AGENTS.md section](https://code.claude.com/docs/en/memory#agents-md) of the memory page gives the versions, settings and sessions in which it does. ## Decision guide Choose every route that applies; a deployment usually needs both service-specific guides and shared controls. - Public web app with its own domain: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), then the guide for your web server or proxy, then [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). - App on a home server or behind NAT, with a domain you can put on Cloudflare: [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). The tunnel removes the need for open inbound ports and Access adds login in front of the app. With no domain at all: [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) (Serve for tailnet-only access, Funnel only with the app's own login), or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) for internal use. - Internal tool, staging, or local development: [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md), with authentication still enabled. - Human login or SSO: [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) to choose one and [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md) if you run it yourself, [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) for app integration; [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md) or [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) for login in front; [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) for the second factor, with an explicit access policy. - Service-to-service or agent access: [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md), [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). - AI or agent deployment: the matching model-server, MCP, agent-builder, vector-database, UI, or observability guide, plus [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md) where applicable and [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md) for outbound and metadata. - Containers and clusters: [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md), [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md), [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md), [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md), [self-hosted-ci-runners.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-ci-runners.md), then the host, cloud, PaaS, or GPU guide. - Databases, caches, queues, and model servers: keep them off public interfaces entirely where possible; the per-tool guides cover TLS and authentication for the cases where network exposure is unavoidable. - Databases, storage, and messaging: the service guide, plus [object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md), [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md), [pocketbase.md](https://github.com/jposluns/secureconfig/blob/main/pocketbase.md), or [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md) where access depends on storage or data rules. - A pooler, proxy, or bouncer in front of a database: [connection-poolers.md](https://github.com/jposluns/secureconfig/blob/main/connection-poolers.md). Adding one moves the client-facing TLS and host rules off the database server and onto the pooler, which has its own defaults. - Web application exposure: [web-exposure.md](https://github.com/jposluns/secureconfig/blob/main/web-exposure.md), [headers.md](https://github.com/jposluns/secureconfig/blob/main/headers.md), [cors.md](https://github.com/jposluns/secureconfig/blob/main/cors.md), [realtime-webhooks.md](https://github.com/jposluns/secureconfig/blob/main/realtime-webhooks.md). - First deployment, preview, or teardown: [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md), then [common-mistakes.md](https://github.com/jposluns/secureconfig/blob/main/common-mistakes.md). ## Guide index ### Certificates and tunnels | Guide | Covers | |---|---| | [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) | Free publicly trusted certificates via ACME (Let's Encrypt, ZeroSSL), issuance, and automated renewal | | [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) | OpenSSL and mkcert certificates when a public CA is not an option, plus distributing trust to clients | | [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) | Cloudflare Tunnel and Zero Trust Access: authenticated external access with no open inbound ports | | [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) | Tailscale serve (tailnet-only) and funnel (public) with automatic TLS | | [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md) | frp, WireGuard, and ssh -R, self-hosted tunnels when there is no public IP | ### Identity, authentication, and secrets | Guide | Covers | |---|---| | [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) | Password storage, MFA, API keys, sessions, rate limiting, and secret handling | | [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) | Hosted identity and MFA: Entra ID, Google, Okta, Entra External ID, Firebase and Identity Platform, Auth0, Cognito, Clerk, WorkOS, Supabase Auth, Duo; free tiers and who each fits | | [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md) | Self-hosted Keycloak and authentik: separating the admin surface at the proxy, the first-boot admin window, and the management port | | [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) | OIDC login wiring for Google, Microsoft Entra, GitHub, and Okta: PKCE, redirect URIs, token validation, and the allowlist check | | [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md) | Login in front of the app with no code change: AWS ALB, Google IAP, Azure App Service, Cloudflare Access, ngrok, Vercel | | [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) | oauth2-proxy, Authelia, Pomerium: login and MFA in front of an app that has none | | [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) | MFA options: identity layers with QR-code TOTP enrolment, app libraries, SSH modules, Duo | | [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md) | Machine identity: API keys, client credentials, mutual TLS, workload identity federation, secret managers | | [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) | Secrets: repository hygiene, scanning, rotation after a leak, sops/age | | [vault.md](https://github.com/jposluns/secureconfig/blob/main/vault.md) | HashiCorp Vault: dev mode, listener TLS, seal custody, audit devices, retiring the initial root token, path-scoped ACL policies, bounded AppRole credentials and tokens, response-wrapped secret delivery, and authenticated metrics/profiling | ### Web servers and proxies | Guide | Covers | |---|---| | [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md) | Apache HTTP Server: TLS, redirect, HSTS, basic auth, client certificates | | [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) | nginx: TLS, redirect, HSTS, basic auth, client certificates, reverse proxy | | [lighttpd.md](https://github.com/jposluns/secureconfig/blob/main/lighttpd.md) | lighttpd: TLS via mod_openssl, redirect, basic auth | | [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) | Caddy: automatic HTTPS, internal CA, basic auth | | [haproxy.md](https://github.com/jposluns/secureconfig/blob/main/haproxy.md) | HAProxy: TLS termination, redirect, HSTS, basic auth | | [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md) | Traefik: ACME resolvers, HTTPS redirection, basic auth middleware | ### Languages and frameworks | Guide | Covers | |---|---| | [nodejs.md](https://github.com/jposluns/secureconfig/blob/main/nodejs.md) | Node.js and Express: HTTPS server, security headers, sessions, password hashing | | [python.md](https://github.com/jposluns/secureconfig/blob/main/python.md) | Flask, FastAPI/Uvicorn, Gunicorn, Django: TLS options and secure settings | | [nextjs.md](https://github.com/jposluns/secureconfig/blob/main/nextjs.md) | Next.js: sessions, Route Handlers, Server Actions, Auth.js and Better Auth, Vercel | | [go.md](https://github.com/jposluns/secureconfig/blob/main/go.md) | Go net/http: TLS, proxy layout, bcrypt, cookies, client TLS discipline | | [dotnet.md](https://github.com/jposluns/secureconfig/blob/main/dotnet.md) | ASP.NET Core and Kestrel: HTTPS, HSTS, forwarded headers, Identity, cookies | | [java.md](https://github.com/jposluns/secureconfig/blob/main/java.md) | Spring Boot: server.ssl, forwarded headers, Spring Security, session cookies | | [php.md](https://github.com/jposluns/secureconfig/blob/main/php.md) | PHP and Laravel: password_hash, session cookies, trusted proxies, forced HTTPS | | [ruby.md](https://github.com/jposluns/secureconfig/blob/main/ruby.md) | Rails and Puma: force_ssl, trusted proxies, has_secure_password, credentials | | [frontend-frameworks.md](https://github.com/jposluns/secureconfig/blob/main/frontend-frameworks.md) | SvelteKit, Nuxt, Vite: server-route auth, sessions, public-env prefixes | ### Containers and Kubernetes | Guide | Covers | |---|---| | [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md) | Docker and Compose: safe port publishing, the UFW bypass problem, TLS termination | | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | Gateway API with a maintained controller, cert-manager TLS, entry-point authentication; ingress-nginx is retired | | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | Argo CD and Flux: cluster-admin blast radius, the initial admin secret, RBAC, and the Flux webhook receiver | | [self-hosted-ci-runners.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-ci-runners.md) | GitHub Actions runner and GitLab Runner: untrusted code is remote code execution; ephemeral one-job workers, no privileged executor, tokens as credentials | | [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md) | Non-root, dropped capabilities, read-only root, network segmentation | ### Hosts and cloud platforms | Guide | Covers | |---|---| | [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md) | Host baseline: SSH hardening, firewall default-deny, brute-force protection, updates | | [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) | Security groups and VPC rules: no 0.0.0.0/0 on databases, SSH posture | | [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md) | Render, Fly.io, Vercel, and similar: platform TLS, your auth and secrets | | [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md) | RunPod, Vast.ai, Lambda, Modal: exposed ports and differing firewall and auth defaults per platform | ### Databases, storage, and messaging | Guide | Covers | |---|---| | [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md) | PostgreSQL: server TLS, SCRAM authentication, pg_hba rules, verified client connections, runtime-vs-owner privilege separation, restricted CONNECT and schema CREATE, secure search_path, and connection/DDL audit logging | | [connection-poolers.md](https://github.com/jposluns/secureconfig/blob/main/connection-poolers.md) | PgBouncer and pgpool-II: client TLS that is off by default, a server hop that falls back to plaintext, and the pg_hba rules a pooler stops matching | | [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md) | MySQL and MariaDB: required TLS transport, per-user TLS, modern auth plugins, scoped least-privilege accounts, anonymous/test/remote-root cleanup, local_infile and secure_file_priv restrictions, and password validation/reuse | | [mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md) | MongoDB: requireTLS, authorization, bind address, least-privilege roles with explicit SCRAM-SHA-256, replica/cluster membership auth, per-user source restrictions, and x.509 client identities | | [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md) | Redis: TLS, requirepass, bind/protected-mode, and least-privilege ACLs with durable rules, key/channel scoping, and runtime command lockdown | | [sqlite.md](https://github.com/jposluns/secureconfig/blob/main/sqlite.md) | SQLite in deployment, the file is the exposure; Turso tokens; Litestream | | [sqlite-http-frontends.md](https://github.com/jposluns/secureconfig/blob/main/sqlite-http-frontends.md) | Datasette and sqlite-web: SQLite HTTP exposure, loopback binding, authentication, TLS, and verification | | [surrealdb.md](https://github.com/jposluns/secureconfig/blob/main/surrealdb.md) | SurrealDB, root credentials, bind address, access levels, TLS | | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | InfluxDB, VictoriaMetrics, and QuestDB: default-off auth, default credentials, and fronting | | [elasticsearch.md](https://github.com/jposluns/secureconfig/blob/main/elasticsearch.md) | Elasticsearch and OpenSearch: keep built-in security on, scoped roles and API keys, anonymous/CORS lockdown, audit logging | | [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md) | ClickHouse: user passwords, secure ports, network restrictions, SQL-driven RBAC with table-scoped roles, query/memory limits and quotas, external-source restrictions, and protected query/session logs | | [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | Neo4j: initial password, Bolt and HTTPS TLS, auth stays on, scoped Enterprise roles with label/property grants, procedure/function/load denials, transaction memory and timeout limits, and security-event logging | | [memcached.md](https://github.com/jposluns/secureconfig/blob/main/memcached.md) | Memcached: no auth by default; private binding and UDP off, dedicated unprivileged account, connection and item-memory limits, disabled cache-wide flush and key enumeration, SASL and TLS where built in | | [search-engines.md](https://github.com/jposluns/secureconfig/blob/main/search-engines.md) | Meilisearch and Typesense, master key, scoped search keys, production mode | | [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md) | MinIO (AIStor): root and scoped credentials, TLS, server-side encryption, object locking, and audit logging | | [object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md) | S3, R2, GCS, Azure Blob, Supabase Storage: private by default, scoped credentials, signed URLs | | [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md) | Firebase rules and Supabase RLS: ownership rules across Firestore/RTDB/Storage, Data-API and key hygiene, function/Storage/Realtime authorization, App Check, and signup controls | | [supabase-self-hosted.md](https://github.com/jposluns/secureconfig/blob/main/supabase-self-hosted.md) | The self-hosted Docker stack: replace the demo keys and dashboard password, keep the published ports private | | [pocketbase.md](https://github.com/jposluns/secureconfig/blob/main/pocketbase.md) | PocketBase and Appwrite: API and collection rules are the security, plus auth-record and file-field protection, operator and console lockdown, settings encryption, rate limits, and hostname and upload controls | | [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md) | Strapi, Directus, Hasura, and PostgREST: one control gates the whole datastore | | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | RabbitMQ: users and permissions, TLS listener, guest account, vhost isolation and resource limits, management HTTPS, monitoring/admin role separation, concrete private bindings, and safe definitions import | | [mosquitto.md](https://github.com/jposluns/secureconfig/blob/main/mosquitto.md) | Mosquitto (MQTT): per-device credentials, TLS listener, mutual TLS, topic ACLs with `$SYS` isolation, connection/packet/queue limits, per-listener scoping, bridge security, and runtime hardening | | [kafka.md](https://github.com/jposluns/secureconfig/blob/main/kafka.md) | Apache Kafka: SASL_SSL listeners, SCRAM credentials, ACLs, client/replication/controller listener separation, per-role least-privilege ACLs, user quotas and connection limits, and authorization audit logging | | [nats.md](https://github.com/jposluns/secureconfig/blob/main/nats.md) | NATS and JetStream, auth, TLS, and the unauthenticated monitoring port | ### AI and agent infrastructure | Guide | Covers | |---|---| | [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) | Ollama: no built-in auth or TLS; protect it with a proxy or tunnel | | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | llama.cpp, vLLM, TGI, SGLang, Triton, LM Studio: loopback, API keys, TLS in front | | [litellm.md](https://github.com/jposluns/secureconfig/blob/main/litellm.md) | LiteLLM proxy: master key, per-app virtual keys, per-key spend and rate limits, model-access allowlists, key expiry and revocation, admin/route separation, egress and pass-through limits, and request-content log redaction | | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | Qdrant, Weaviate, Milvus, Chroma, pgvector: API keys, TLS, what has no native auth | | [mcp-servers.md](https://github.com/jposluns/secureconfig/blob/main/mcp-servers.md) | MCP servers: stdio versus Streamable HTTP, loopback, Origin checks, OAuth 2.1 or a fronting layer | | [mcp-clients.md](https://github.com/jposluns/secureconfig/blob/main/mcp-clients.md) | MCP clients: authorization-response iss validation, metadata issuer identity, Client ID Metadata Documents and application_type, refresh-token confidentiality, OAuth-discovery SSRF | | [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md) | Ray: dashboard, Jobs, and Client ports execute code; isolate, token auth, SSH forward | | [mlflow.md](https://github.com/jposluns/secureconfig/blob/main/mlflow.md) | MLflow tracking server: no auth by default; basic-auth app, TLS in front | | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | Langfuse, Phoenix, Helicone, OpenTelemetry Collector: they hold prompts and keys | | [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md) | Dify, Flowise, Langflow, LibreChat: admin setup, API keys, fronting TLS | | [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) | SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0, Onyx: what the run form publishes, and the store published beside the front door | | [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md) | Prefect, Dagster, Airflow, Temporal, Flower, Argo: keep off the public internet and require auth | | [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md) | Chrome DevTools Protocol, Selenium Grid, browserless, Playwright: an exposed browser port is remote code execution | | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | LiveKit and coturn: WebRTC SFU signing keys and the TURN open-relay default, RFC 1918 relay policy, TLS | ### App UIs and dev tools | Guide | Covers | |---|---| | [open-webui.md](https://github.com/jposluns/secureconfig/blob/main/open-webui.md) | Open WebUI: signup control, pending role, fronting TLS, persistent signing key, trusted-proxy and OAuth boundaries, tool and code-execution limits, model and admin-data access control, API-key restrictions, and retrieval/SSRF limits | | [chat-uis.md](https://github.com/jposluns/secureconfig/blob/main/chat-uis.md) | AnythingLLM, LobeChat, Chainlit, OpenHands: open by default; front with login | | [image-gen-uis.md](https://github.com/jposluns/secureconfig/blob/main/image-gen-uis.md) | ComfyUI, A1111, InvokeAI, Fooocus: keep on loopback and add a login before exposing | | [gradio.md](https://github.com/jposluns/secureconfig/blob/main/gradio.md) | Gradio: launch() auth and TLS, share-link risks, file-access and cache isolation, optional-execution-surface and API-visibility controls, queue and resource limits, URL-fetch SSRF limits, external auth, and flagging/telemetry controls | | [streamlit.md](https://github.com/jposluns/secureconfig/blob/main/streamlit.md) | Streamlit: TLS options, native OIDC login, reverse proxy deployment | | [jupyter.md](https://github.com/jposluns/secureconfig/blob/main/jupyter.md) | Jupyter Server, Lab, and Notebook: hashed password and TLS, unprivileged execution, origin/XSRF/DNS-rebinding protection, JupyterHub identities and per-user isolation, and auth by default | | [n8n.md](https://github.com/jposluns/secureconfig/blob/main/n8n.md) | n8n: listen address, native TLS, owner setup, MFA enforcement | | [code-server.md](https://github.com/jposluns/secureconfig/blob/main/code-server.md) | code-server: SSH forwarding first, password auth, TLS | | [self-hosted-error-trackers.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-error-trackers.md) | Sentry and GlitchTip: registration defaults, event-data leakage, reverse-proxy TLS | ### Dashboards and admin consoles | Guide | Covers | |---|---| | [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md) | phpMyAdmin, pgAdmin, mongo-express, Grafana, Prometheus: never public | | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | node_exporter, Alertmanager, Pushgateway, Jaeger, Loki: unauthenticated by default; never public | | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md) | Portainer, Coolify, Dokploy, Nginx Proxy Manager, Vaultwarden, Kubernetes Dashboard, Jenkins, Gitea, Uptime Kuma, Docker API: never public | | [bi-dashboards.md](https://github.com/jposluns/secureconfig/blob/main/bi-dashboards.md) | Metabase, Superset, Redash: never public; least-privilege database user | | [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) | NocoDB, Baserow, Appsmith, Budibase, Windmill: first-run admin, open signup, sample secrets | | [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md) | Cockpit, Webmin, Proxmox VE: root-level host panels on every address, no default second factor | | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | Nomad, Consul: APIs with ACLs off by default, anonymous bootstrap, Consul's allow default | ### Web application controls | Guide | Covers | |---|---| | [cors.md](https://github.com/jposluns/secureconfig/blob/main/cors.md) | CORS: exact origins, never * with credentials | | [headers.md](https://github.com/jposluns/secureconfig/blob/main/headers.md) | Security headers: HSTS, CSP, and companions for your app | | [realtime-webhooks.md](https://github.com/jposluns/secureconfig/blob/main/realtime-webhooks.md) | WebSocket, SSE, and webhook authentication | | [transactional-email-posture.md](https://github.com/jposluns/secureconfig/blob/main/transactional-email-posture.md) | SPF, DKIM, DMARC, SMTP submission, and open-relay posture for the mail a deployment sends | ### Exposure and deployment operations | Guide | Covers | |---|---| | [web-exposure.md](https://github.com/jposluns/secureconfig/blob/main/web-exposure.md) | Files a web server must never serve: dotfiles, .git, dumps, client secrets | | [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md) | Egress control and cloud metadata (IMDSv2), stop an agent exfiltrating credentials | | [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md) | Verify from outside, safe first-run order, previews, and teardown | | [exposure-index.md](https://github.com/jposluns/secureconfig/blob/main/exposure-index.md) | Narrow an open port to the guides worth reading, then identify the process | | [common-mistakes.md](https://github.com/jposluns/secureconfig/blob/main/common-mistakes.md) | The recurring findings, each linked to its fix | ## Verification checklist REASONED: checklist introduction; how to run and record the checks rests on the item declarations below, and the `-sS` error display rests on the curl manual cited in README.sources.md entries 2 and 3, and the fresh-shell advice rests on CONTRIBUTING rule 6; this paragraph records no run of its own. Run the applicable checks below and the Verify steps in every selected guide. Run these commands and every guide's Verify blocks in a fresh shell, such as one started with `bash --norc --noprofile -i`: a variable that your current shell or its startup files marked readonly or integer can abort a block's subshell or silently mis-assign a field. That shell still imports exported functions and turns on any shell options exported through `SHELLOPTS` or `BASHOPTS`, and under a POSIX mode inherited through `POSIXLY_CORRECT` or `SHELLOPTS` it still expands `ENV` and reads the file the result names, an expansion that can itself run commands. Replace `REPLACE_WITH_YOUR_HOST` in every command below with your own host before running anything. The placeholder is deliberate: an earlier version used `example.com`, which resolves and serves HTTPS, so check 3 succeeded for every reader whatever state their deployment was in. The commands use `-sS` so that an unreplaced placeholder prints `curl: (6) Could not resolve host` rather than failing silently. That is a visible failure, not a guarantee: a resolver that answers every name, or a proxy in your environment, can still put a real destination behind the placeholder, so substitute it rather than relying on the error. Record each result as pass, fail, untested, or not applicable, with evidence or a reason; report a control as verified only for the checks that passed. 1. REASONED: public HTTP listener check; local marking review (October 2026, Bash 5.3.9) ran `bash -n` and ShellCheck 0.11.0 on the checklist command samples with exit 0 and no output, and a stubbed `ss -tlnp` printed `stub ss args: -tlnp` (exit 0). A live HTTP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Per README.sources.md entry 1 (ss(8); host.md; common-mistakes.md), an exposed public plain HTTP listener appears in `ss`; in the fixed state `ss` still lists the service's known TLS port, while no public plaintext listener appears except a redirect-only listener. The working controls are an HTTPS answer under item 3 and, where HTTP is offered, the redirect status and HTTPS Location under item 2; `ss` alone cannot establish either behaviour. No exposed or fixed deployment was observed. No plaintext listener on a public interface: `ss -tlnp` (Linux) shows nothing bound to `0.0.0.0` or a public address on a plain HTTP port, except a listener whose only job is to redirect to HTTPS. 2. REASONED: HTTP redirect check; local marking review (October 2026, Bash 5.3.9) ran `bash -n` and ShellCheck 0.11.0 with exit 0 and no output; a curl stub printed the `-sS -o /dev/null -w` arguments and `http://REPLACE_WITH_YOUR_HOST/` (exit 0). A live HTTP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Per README.sources.md entry 2 (RFC 9110, HTTP Semantics; curl manual), the exposed HTTP route serves without an HTTPS redirect; the fixed route returns a redirect status and HTTPS Location. No live response was observed. Where HTTP is offered, the redirect works: `curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://REPLACE_WITH_YOUR_HOST/` returns `301` or `308` (the preferred permanent redirects), or `302`/`307` where a framework issues them, always with a `Location: https://...` header. 3. REASONED: HTTPS certificate check; local marking review (October 2026, Bash 5.3.9) ran `bash -n` and ShellCheck 0.11.0 with exit 0 and no output; a curl stub printed the `-sS -o /dev/null -w` arguments and `https://REPLACE_WITH_YOUR_HOST/` (exit 0). A live HTTPS deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Per README.sources.md entry 3 (curl manual), an exposed invalid certificate makes verified curl fail, while a fixed trusted certificate lets curl succeed without `-k`. No certificate was tested. TLS works: `curl -sS -o /dev/null -w '%{http_code}\n' https://REPLACE_WITH_YOUR_HOST/` succeeds without `-k`. 4. REASONED: TLS 1.1 refusal check; local marking review (October 2026, Bash 5.3.9, OpenSSL 3.5.5) ran `bash -n` and ShellCheck 0.11.0 with exit 0 and no output; a stub printed `stub openssl args: s_client -connect REPLACE_WITH_YOUR_HOST:443 -tls1_1` (exit 0), while local `openssl ciphers -s -tls1_1` exited 0 with only a newline, so that client cannot distinguish server refusal here. A live TLS deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. A client or scanner capable of offering TLS 1.1 and a live TLS 1.2 service control were also unavailable to this review. Per README.sources.md entry 4 (openssl-s_client; RFC 8996, Deprecating TLS 1.0 and TLS 1.1; OpenSSL config), an exposed TLS 1.1 server negotiates when a capable client offers it; a fixed server refuses that offer while a TLS 1.2 control succeeds. Neither state was observed. Old protocols are refused: `openssl s_client -connect REPLACE_WITH_YOUR_HOST:443 -tls1_1` fails to negotiate (TLS 1.2 is the minimum everywhere in these guides). Confirm the refusal came from the server and not from your own client: a local OpenSSL build, or a `MinProtocol` setting in its configuration, can refuse TLS 1.1 before any offer is sent, which looks identical. Check that your client can still offer TLS 1.1 at all, or use an external scanner that does. 5. REASONED: authentication check; rests on README.sources.md entry 5 (RFC 9110 section 15.5.2) and the guarded anonymous probe in the Verify section of [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), which records no live run; no deployment with a non-public path was available, so no request was sent. Authentication is enforced: an unauthenticated request to any non-public path returns `401`, `403`, or a login redirect, never data. Test the API paths as well as the home page: run that guarded block once per non-public path, replacing `/admin` in its `curl -q -g -sS -D - -o /dev/null "https://$1/admin"` line with the path. 6. REASONED: secret scan; rests on README.sources.md entry 6 (gitleaks), the Verify section of [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), which records no exposed or fixed scan, and the fixture runs recorded in the Quick checks of [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md), where gitleaks 8.30.1 reported a planted key and passed a clean control. No exposed or fixed scan of this command is recorded for this item. No default credentials remain, and no secret (password, key, token, certificate private key) is committed to the repository. Scan before pushing: from the repository root, `gitleaks git . && echo clean` (gitleaks 8.19.1 or later) must print `clean`, and any finding fails the check. Default accounts have no generic command: check each service against its guide, and confirm the user store holds no `admin`, `test` or `demo` account with a known or empty password. 7. REASONED: ACME renewal check; rests on README.sources.md entry 7 (Certbot, Renewing certificates; Caddy automatic HTTPS; Traefik ACME) and the Renewal section of [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md); no host with an issued ACME certificate was available, so neither command ran. A passing dry run with nothing scheduled is the exposed state. Where ACME is used, renewal is automated: `sudo certbot renew --dry-run --run-deploy-hooks` passes, testing renewal and the reload hook together, AND something is actually scheduled to run it. The dry run proves the command works, not that it will ever be invoked, so check the scheduler too: `sudo systemctl list-timers | grep -i certbot` lists a certbot timer, or for cron, `/etc/crontab` or `/etc/cron.*/*` contains `certbot renew`. Servers that manage renewal themselves (Caddy, Traefik) need no timer; confirm a completed renewal in their logs instead. 8. REASONED: external TLS scan; rests on README.sources.md entry 8 (Qualys SSL Labs server test; testssl.sh) alone, because no Verify section in the guides carries a scanner command to cite. This item therefore names tools rather than a command and sets no pass threshold, and no scan was run for this marking. For public endpoints: scanned with the [Qualys SSL Labs test](https://www.ssllabs.com/ssltest/) or [testssl.sh](https://github.com/testssl/testssl.sh). 9. REASONED: second-factor check; rests on README.sources.md entry 9 (NIST SP 800-63B-4) and the first Verify item of [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), which names no command; the test is an interactive login; no deployed human-facing login was available for this marking. Human-facing logins carry a second factor where the stack supports one: with only the password, attempt an MFA-protected operation, which must be refused until the second factor is completed. [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) lists the options, and the per-tool guides state what is viable. 10. REASONED: outside-in probe; rests on README.sources.md entry 10 (Shodan; Censys; Docker, Packet filtering and firewalls) and the guarded `nmap -Pn -p- "$1"` block in the Verify section of [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md), which records no run; no deployment or second host outside its network was available for this marking. Probe from outside the deployment network, from a second host: cloud-firewall and Docker-publishing mistakes only show from outside. Run that guarded block, and its IPv6 companion, from the second host: only the intended ports may answer, and a scan that reports the host down or prints nothing is inconclusive. Shodan or Censys can corroborate, but they report what a scanner last observed rather than what is listening now, so a port published since their last pass will not appear; treat them as a second opinion, never as a substitute for a live probe. 11. REASONED: identity allowlist check; rests on README.sources.md entry 11 (OpenID Connect Core 1.0; the Google and Microsoft Entra claim references) and the allowlist item in the Verify section of [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), which names no command; the test is an interactive sign-in; no identity provider or out-of-allowlist account was available for this marking. Where login comes from an identity provider, an authenticated identity outside the allowed tenant, domain, group, or users is denied; test with such an account, not only an anonymous request, and confirm the provider authenticates it while the app refuses it and logs the identity. REASONED: citation pointer; the numbering match between this checklist and README.sources.md is checked by reading, as the Verify section of that file records, and no run applies. Sources for these checks are listed in [README.sources.md](https://github.com/jposluns/secureconfig/blob/main/README.sources.md), numbered to match the items above. ## Scope and currency The guides use placeholders that you must replace. `example.com` and `app.example.com` stand in for a name, and `203.0.113.10` for an address, wherever the value is configuration or illustration. That is usually a wrong value that fails visibly, though not always: an unreplaced certificate SAN generates a perfectly valid certificate for the wrong address. Where leaving the placeholder in place would instead look like a passing check, the guides use a `REPLACE_WITH_A_NAME` form and the check itself rejects the unsubstituted value rather than trusting it to fail. The verification checklist above uses the `REPLACE_WITH_YOUR_HOST` form but carries no guard: an unsubstituted name in checks 2 and 3 normally fails to resolve, subject to the resolver and proxy exceptions that the checklist's opening paragraph names, and check 4 also requires the refusal to come from the server, which a lookup failure does not show. Scope: deployment exposure, including TLS, human and machine authentication, MFA, access restrictions, secrets, and inbound and outbound network access, from first deployment through teardown. General application security, including injection, deserialization, and business logic flaws, belongs to the OWASP resources linked throughout the guides. LLM-specific application risks such as prompt injection are covered by the [OWASP Top 10 for LLM Applications](https://genai.owasp.org/llm-top-10/). To propose a tool or guide, see [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md). ## Licence Everything in this repository (the guides, the configuration samples, and the site) is dedicated to the public domain under [CC0 1.0 Universal](https://github.com/jposluns/secureconfig/blob/main/LICENSE), with the exceptions below, which [NOTICE](https://github.com/jposluns/secureconfig/blob/main/NOTICE) records. Copy and reuse it freely; no attribution is required. The site template's 4 fonts, under `site/assets/baseline/fonts/`, are licensed by their authors under the SIL Open Font License 1.1, and its GitHub, LinkedIn and ORCID marks, under `site/assets/baseline/marks/`, are their owners' trademarks, used under their owners' terms and outside the CC0 grant. The other exception is the AIQT Guardrails material that [`.aiqt/PIN`](https://github.com/jposluns/secureconfig/blob/main/.aiqt/PIN) lists (the files vendored under `.aiqt/` and `tools/`, and the AIQT sections adapted into `CLAUDE.md` and `AGENTS.md`), which is licensed under the Apache License 2.0 (see [`.aiqt/LICENSE`](https://github.com/jposluns/secureconfig/blob/main/.aiqt/LICENSE) and [`.aiqt/NOTICE`](https://github.com/jposluns/secureconfig/blob/main/.aiqt/NOTICE)). ====================================================================== ==> controls-reference.md ====================================================================== # Control patterns for applications not yet covered secureconfig ships service-specific guides, but most deployments run at least one application without one. This document is the bridge: it distils the control patterns that recur across the whole corpus into a single reference, so a human or an AI assistant can run an alignment assessment of any self-hosted application against them. Use it when your application is not in the guides. Every entry states the control, why it matters, and how to check your own app against it. Nothing here is product-specific; every example is a pattern, not a flag. ## 1. Network exposure and binding - **Control:** Bind to loopback (`127.0.0.1`) or a private interface by default. Keep admin and data ports off the open internet entirely. A surface that is deliberately public goes through a TLS-terminating layer, with authentication enforced somewhere: the app's own login, or a fronting layer where the app has none. A service meant to be open to everyone, such as a public site, still terminates TLS but need not add a login. Reach admin surfaces over SSH forwarding, a tailnet, or an identity-aware proxy, not the open internet. - **Why:** A port bound to `0.0.0.0` on an internet-facing host is found by routine scanning within minutes, and direct access to a backend bypasses whatever authentication a fronting layer adds. - **Check:** Find the bind-address setting and its current value. Run `ss -tlnp`: any line showing `0.0.0.0` or `[::]` for this app is reachable from every network the host is on unless a firewall blocks it. From an outside network, connect to each port over IPv4 and IPv6: a port that should be private must be refused or filtered, and a port that is deliberately public must answer only over TLS and enforce whatever authentication that surface requires, the one exception being a plain HTTP port whose only response is a redirect to HTTPS. Confirm the backend cannot be reached directly by IP, skipping the fronting layer. ## 2. Secondary and hidden listeners - **Control:** Inventory every port the application opens, not just the one you configured. Metrics, admin APIs, debug endpoints, clustering, replication, and backing-store ports frequently bind separately, often on all interfaces even when the main port is locked down. Assess each listener independently. - **Why:** A guarded front door means nothing if the backing store, debug console, or metrics endpoint is published beside it. These listeners are forgotten because they are not in the quickstart. - **Check:** Run `ss -tlnp` (and `ss -ulnp` for UDP) before and after starting the app, and diff. On the host this shows host-namespace and published ports only; for a containerized app also run `ss` inside the container's own network namespace (for example `docker exec ss -tlnp`) and read its published-port list, since a port open only inside the container never appears on the host. For each listener, answer: what is it, does it authenticate, and what interface is it bound to? Search the app's docs for "metrics", "admin", "debug", "cluster", and "replication" ports. Treat any listener you cannot explain as exposed until proven otherwise. ## 3. Authentication and default credentials - **Control:** Every surface requires a login. Never run with blank, well-known, or vendor-default credentials. Rotate any auto-generated initial secret immediately after first login. Complete first-run setup before the app is reachable by anyone else. - **Why:** Default and blank credentials are tried by scanners at scale, and an unclaimed first-run installer belongs to whoever reaches it first. - **Check:** Open the UI and API in a fresh session with no credentials: is anything readable or writable? Try the documented default account if the vendor ships one. Confirm the setup wizard has completed and cannot be re-triggered. Confirm the initial or generated credentials no longer work after rotation. ## 4. Registration, tenancy, and visibility - **Control:** Disable self-service sign-up on private deployments. Default new resources to private, not "internal" or "public". Establish exactly what each visibility tier means, including tenant and organization boundaries, before relying on it. - **Why:** Open registration turns "authenticated users only" into public access, and "internal" often means every registered account rather than the intended team. - **Check:** Logged out, is there a working register path in the UI or the API? Create a resource with default settings, then fetch its URL from a logged-out session, from an unrelated low-privilege account, and from another tenant where applicable. ## 5. Multi-factor authentication - **Control:** Enforce MFA on administrative accounts, and on all accounts where the app supports it. When authentication is delegated to SSO, enforce MFA at the identity provider and close alternative local login paths. - **Why:** Credential stuffing and password reuse defeat single-factor login regardless of password policy, and an unprotected local login undermines the identity-provider policy. - **Check:** Is MFA required, or merely available? Log in as an admin with password only and confirm a second factor is demanded. Confirm enforcement covers existing accounts, local login, and recovery paths. If SSO is in use, confirm the provider policy applies to this application. ## 6. Transport security - **Control:** Serve every non-loopback connection over TLS with a valid certificate. Never disable certificate verification in clients, agents, or scripts to make an error go away. Terminate TLS at the reverse proxy or at the app, but somewhere. Protect backend hops that cross untrusted networks. - **Why:** Plaintext transport exposes credentials and session tokens to anyone on the path, and disabled verification silently accepts any interceptor, which is worse than the error it hides. - **Check:** Connect to each endpoint and confirm the certificate is valid for the hostname. Grep deployment configs and scripts for verification-off patterns such as `verify=false`, `insecure`, or `-k`, and justify or remove each hit. Confirm the plain-HTTP port redirects or is closed. Inspect proxy-to-app and app-to-dependency connections, not just the front door. ## 7. File and path access - **Control:** Scope what the app serves or reads to the narrowest directory that works. Keep secret paths (key material, config with credentials, `.git`, home directories, backups) out of every served root entirely, since exclusion is the only control that does not depend on a deny rule. Treat shared caches or artifact stores served by URL as public to anyone who can guess the URL unless access control is verified. - **Why:** Apps that serve files, render previews, or proxy paths will serve whatever lives inside their configured root, including a config file or dotfile, unless a specific deny rule stops them, and cross-user caches leak one user's content to another. - **Check:** Enumerate every setting that names a directory the app serves or indexes, and confirm no secret material lives under those roots. Where the app relies on a deny rule for dotfiles or config, request such a path through the app and confirm refusal; where it does not, confirm the sensitive files are simply not under any served root. Use harmless test files to probe traversal and symlink behaviour. As user B, fetch an object created by user A and confirm access control applies, not just URL obscurity. ## 8. Secret handling - **Control:** Keep secrets out of container images, repositories, and state artifacts (Terraform state, kubeconfigs, CI logs, shell history). Deliver them by environment variable, mounted file, or a secret manager, and restrict read access to the processes and people that need them. Treat unavoidable secret-bearing state as sensitive in its own right. - **Why:** Images and repos are copied, cached, and shared far beyond their original audience. A secret that lands in one is effectively published, and rotation is the only fix. - **Check:** Search the repo and image layers for credential patterns (keys, tokens, connection strings with passwords). List who and what can read the state files, kubeconfigs, and backups the deployment produces. Confirm mounted secret files are owner-only. If a secret was ever committed, confirm it was rotated, not just deleted from the tree. ## 9. API and automation surfaces - **Control:** Treat API keys, webhook endpoints, machine tokens, and service accounts as a separate authentication surface from interactive login. Each authenticates independently, is scoped to least privilege, and is revocable. Webhook receivers verify a signature or shared secret, not just an unguessable URL. Note that auth behaviour on these surfaces often changes between versions. - **Why:** MFA on the login page does not protect an API that accepts a static token, and automation surfaces often bypass MFA and session timeouts, providing persistent access. - **Check:** Enumerate the non-interactive entry points: APIs, webhook receivers, agent registration. For a token-authenticated API, call it without credentials and confirm refusal, then with a valid but under-scoped credential and confirm the scope holds. For a webhook receiver, send a request with a missing or wrong signature and confirm it is rejected before any processing, since these validate a payload signature or shared secret rather than a scoped credential. Confirm revocation works for each. Record the deployed version and check the vendor's notes for auth changes affecting these surfaces. ## 10. Container and host posture - **Control:** Never mount the container runtime socket into a container unless that container is explicitly trusted with control of the host. In the default rootful configuration, socket access is root-equivalent on the host; a rootless runtime narrows the blast radius to that user but does not make it safe. Avoid privileged mode, broad host mounts, and unneeded capabilities, and run as a non-root user. Filter published container ports at the host firewall, and verify the firewall actually applies to them. - **Why:** Container port publishing can bypass or precede host firewall rules depending on the stack, silently exposing ports you believed were filtered. A runtime socket in a compromised container converts app compromise into host compromise. - **Check:** Inspect the container spec for runtime socket mounts (a path ending in a runtime socket such as `docker.sock`), broad host mounts (a host path such as `/`, `/var`, or `/etc` bound into the container), privileged mode, and added capabilities (for example `SYS_ADMIN`). Confirm the app runs as a non-root UID. From an external host, scan the published ports over IPv4 and IPv6 and confirm the firewall behaves as intended for each. ## 11. Verify discipline - **Control:** Every hardening claim ends with a check that proves it. Inventory listeners, probe each surface unauthenticated from an untrusted vantage point, confirm refusal, and pair every negative result with a positive control so a pass is attributable to the control, not to a broken probe. A check claims exactly what it demonstrates, no more. - **Why:** Configuration states intent; only observation states fact. A probe that fails for the wrong reason (typo, wrong port, a filter in between) produces a false pass, and a login page proves nothing about the API beside it. - **Check:** Reconcile every `ss -tlnp` line against your listener inventory. Probe each surface with a fresh client carrying no cookies, tokens, or inherited credentials, and judge refusal by response content, not status code alone. Pair each result with a positive control that fits the claim: for an access check, repeat with valid credentials and confirm success; for a network-isolation check, confirm the surface is reachable from its intended vantage point while refused from outside, so a failure there is the control working and not a broken probe. Record target, version, vantage point, expected result, and observed result for each check. ## How to use this Go control by control. Decide whether each applies to your application; if it does not (no file-serving path, no container, no API), record why and move on. An assessment is complete when every control has one of three dispositions: applied and verified, not applicable with a stated reason, or accepted risk with an owner. Missing evidence is not a pass. When a check fails, fix and re-run it; the verified probe, not the config edit, closes the item. Retest after changes to version, networking, or authentication. If the application sees enough use to deserve its own service-specific guide, request one through the requests directory (https://github.com/jposluns/secureconfig/tree/main/requests) rather than maintaining a private assessment indefinitely. ====================================================================== ==> free-certificates.md ====================================================================== # Free publicly trusted certificates (ACME) ACME is the protocol Let's Encrypt and similar authorities use to issue TLS certificates automatically after a client proves domain control. This guide helps your AI assistant obtain them securely, including a suitable challenge, Certbot or a built-in ACME client, automated renewal, testing against staging first, and CAA records. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/free-certificates.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Let's Encrypt documentation (no recorded version), Certbot reconfigure minimum 2.3.0, Certbot user guide and source v2.3.0, Certbot Cloudflare DNS plugin v2.3.0, Caddy automatic HTTPS v2.11.4, certmagic ACME implementation v0.25.3, and Traefik ACME documentation v3.5, plus 3 more recorded bases; 3 further sources carry no recorded version. Metadata reviewed 2026-10-17; documentation checked September 2026. The guide records 31 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Publicly trusted certificates are free through ACME certificate authorities such as Let's Encrypt and ZeroSSL. Browsers and libraries accept them without any client-side configuration, which is why this guide assesses them as the correct choice for every service with a public DNS name. Use [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) only when no public domain exists; a host that cannot accept inbound connections can still get a publicly trusted certificate through the DNS-01 challenge (below), or serve behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). ## Prerequisites - A public DNS record (`A`/`AAAA` or `CNAME`) pointing at the server, for the HTTP-01 and TLS-ALPN-01 challenges; the DNS-01 challenge instead needs only control of the challenge `TXT` record and can certify a host with no public address record or inbound port. - For the HTTP-01 challenge: inbound port 80 reachable from the internet. - For the TLS-ALPN-01 challenge: inbound port 443. Caddy's ACME issuer enables this challenge when DNS-01 is not configured and TLS-ALPN-01 is not disabled; Traefik also uses it. Caddy can use an alternate local port if port 443 forwards to it. - For the DNS-01 challenge (required for wildcard certificates), automated issuance and renewal need an automated way to update the provider's TXT record; this guide advises using a DNS plugin and scoped API credentials, while Certbot also documents manual authentication hooks for automated challenges. Manual DNS-01 is also possible with `certbot certonly --manual --preferred-challenges dns`, creating the TXT record by hand; the Certbot v2.3.0 guide says certificates obtained this way do not auto-renew without an authentication hook (`--manual-auth-hook`) that automates the challenge. If none of these is possible, use [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) instead. ## Certbot with Let's Encrypt Certbot is the reference ACME client. Install it from your distribution or via snap: ```bash # Debian/Ubuntu sudo apt install certbot python3-certbot-nginx python3-certbot-apache # Any distribution with snapd sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/bin/certbot ``` Issue and install in one step when certbot supports your web server: ```bash sudo certbot --nginx -d example.com -d www.example.com sudo certbot --apache -d example.com -d www.example.com ``` Issue only the certificate when you configure the server yourself, or when no web server is running yet: ```bash # Standalone: certbot binds port 80 itself; stop anything using it first (and for a host whose web server will own :80, switch this certificate to --webroot or the server plugin, or add pre/post hooks that free the port, so unattended renewal can still bind) sudo certbot certonly --standalone -d example.com # Webroot: the existing web server keeps running and serves the challenge files sudo certbot certonly --webroot -w /var/www/html -d example.com ``` Wildcard certificates require the DNS-01 challenge. For automated issuance and renewal, use a DNS plugin as this guide advises (for example `python3-certbot-dns-cloudflare`). Scope provider API credentials to least privilege: use a Cloudflare API token with `Zone:DNS:Edit` on only the required zones; never use the account-wide Global API Key. Keep the credentials file readable only by root (`chmod 600` the file in a `700` directory; Certbot warns when other users can read it): ```bash sudo certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \ -d example.com -d "*.example.com" ``` Certificates land in stable paths that server configuration should reference directly: ``` /etc/letsencrypt/live/example.com/fullchain.pem # certificate plus chain /etc/letsencrypt/live/example.com/privkey.pem # private key ``` ## Renewal Let's Encrypt certificates are valid for 90 days at the time of writing, so renewal must be automated. The Certbot v2.3.0 guide says most installations come with a scheduled `certbot renew` task and says that, on Linux and BSD, you can check cron or systemd timers if unsure. Confirm that your installation (package and snap installs included) has a working scheduled `certbot renew` task, and reload the server after each renewal: ```bash sudo systemctl list-timers | grep -i certbot # a certbot timer must be listed; for cron, /etc/crontab or /etc/cron.*/* must contain certbot renew sudo certbot renew --dry-run --run-deploy-hooks # test renewal and the reload hook together ``` Install the reload hook durably: pass `--deploy-hook "systemctl reload nginx"` on the initial `certonly`/`run` (Certbot saves it to the renewal config only when a certificate is actually obtained or renewed), drop an executable script into `/etc/letsencrypt/renewal-hooks/deploy/` (it runs after every successful renewal), or add it later with `certbot reconfigure` (Certbot 2.3.0 and later). In Certbot v2.3.0, a bare `certbot renew --deploy-hook ...` skips renewal when no certificate is due and does not save that hook to a renewal configuration; use one of the durable methods above. This guide assesses a certificate that issues once and then expires in production as the most common ACME failure, so monitor the served certificate's expiry and alert on renewal failure rather than relying on CA reminder emails; the dry run belongs in your deployment checklist. Lifetimes are getting shorter. Per Let's Encrypt as of September 2026: 6-day short-lived certificates are available now to every subscriber; the default `classic` profile moves to 64-day certificates on 2027-02-10 and to 45-day certificates on 2028-02-16; and industry rules cap publicly trusted certificates at 47 days from 2029-03-15. This guide assesses that any renewal step involving a person will fail at those lifetimes and that the automation above is the only viable path. Let's Encrypt also switched off its OCSP service on 2025-08-06 and publishes revocation only through CRLs, so do not add OCSP stapling directives for Let's Encrypt certificates. ## Rate limits Let's Encrypt enforces per-domain issuance limits. Test against the staging environment (`certbot --staging` or `--test-cert`) until the configuration works, then issue the real certificate. Current limits: https://letsencrypt.org/docs/rate-limits/ ## CAA records A CAA DNS record restricts which certificate authorities may issue for your domain, limiting mis-issuance. Set it to the CA you use, for example `example.com. CAA 0 issue "letsencrypt.org"` for Let's Encrypt. Certificate-transparency monitoring detects mis-issuance after the fact; CAA constrains it beforehand, so treat the two as complementary, not as alternatives. ## Alternatives - **ZeroSSL**: free certificates over ACME. Some clients need External Account Binding (EAB) credentials from the ZeroSSL dashboard; the acme.sh client registers with ZeroSSL by default. - **acme.sh**: a dependency-light shell ACME client supporting many DNS providers, useful where certbot is unavailable. Install from the repository release rather than piping a downloaded script straight into a shell. https://github.com/acmesh-official/acme.sh - **Caddy and Traefik**: obtain and renew certificates themselves with no external client; see [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md). This guide assesses it as the lowest-effort correct option for new deployments. - **Cloudflare origin certificates**: available on Cloudflare's Free plan; Cloudflare's page refers to long-lived origin CA certificates and says the validity period is chosen at creation. Cloudflare says they only encrypt traffic between Cloudflare and the origin server, not traffic from client browsers to the origin, and that visitors may see untrusted certificate errors if Cloudflare is paused or proxying is disabled; use them only for origins that receive traffic only through Cloudflare-proxied records; see [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). ## Verify REASONED: certificate inventory, HTTPS access and served-certificate hostname, trust, issuer and date inspection follow the cited Certbot and Let's Encrypt guidance and the cited curl and OpenSSL documentation. No deployment hostname or issued certificate was supplied for live checks, no live result is recorded, and this guide records no run. The block takes your public host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains, and it exits once with one status for all of its checks. A certificate alone does not protect anything: continue with the server guide for your stack and with [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a check that ends nonzero is recorded in the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOSTNAME' # your deployment's public hostname, not example.com (which serves a live page and would pass spuriously), with no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 3 (inconclusive) outranks 0 sudo certbot certificates || # what is issued and when it expires { echo "certbot certificates exit $?: the certificate list could not be read; inconclusive"; set -- "$1" 3; } curl -q -g -sI "https://$1/" || # succeeds with normal certificate verification { echo "curl exit $?: the HTTPS request, made with certificate verification, failed; inconclusive"; set -- "$1" 3; } set -- "$1" "$2" "$(if openssl s_client -connect "$1:443" -servername "$1" \ -verify_hostname "$1" -verify_return_error - Certbot v2.3.0 Cloudflare DNS credential permissions (checked October 2026): - Cloudflare Origin CA (checked October 2026): https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - le: Let's Encrypt documentation (unknown) - zerossl: ZeroSSL (unknown) - certbot: Certbot documentation (unknown) - reconfigure: Certbot reconfigure minimum (2.3.0) - acme: acme.sh (unknown) - certbot-v230: Certbot user guide and source (v2.3.0) - certbot-dns-cloudflare-v230: Certbot Cloudflare DNS plugin (v2.3.0) - caddy: Caddy automatic HTTPS (v2.11.4) - certmagic: certmagic ACME implementation (v0.25.3) - traefik: Traefik ACME documentation (v3.5) - cloudflare-origin-ca: Cloudflare Origin CA documentation (checked October 2026) - openssl-cli: OpenSSL certificate commands (3.0) - curl-source: curl command documentation (curl-8_12_1) Claims (full source mapping in the JSON block below): - public-trust (REASONED; le, zerossl): ACME CAs such as Let's Encrypt and ZeroSSL issue free publicly trusted certificates; this guide advises preferring them for public DNS names, including private hosts using DNS-01. - http-challenge (REASONED; le): HTTP-01 needs a public A/AAAA or CNAME pointing to the server and inbound internet access to port 80. - alpn-challenge (REASONED; le, caddy, certmagic, traefik): TLS-ALPN-01 needs a public DNS record and inbound port 443; Caddy's ACME issuer enables the TLS-ALPN-01 solver when DNS-01 is not configured and TLS-ALPN-01 is not disabled, and can use an alternate local port if port 443 forwards to it; Traefik also uses TLS-ALPN-01. - dns-challenge (REASONED; le, certbot, certbot-v230): DNS-01 needs control of the challenge TXT record, no public address record or inbound port, and supports wildcards. Automated issuance and renewal need an automated way to update TXT records, and this guide advises a provider DNS plugin and scoped API credentials, while Certbot also supports manual DNS-01 and an authentication hook for automatic renewal. - certbot-install (REASONED; certbot): Install Certbot and nginx/Apache plugins from the distribution, or the classic snap with /usr/bin/certbot pointing to /snap/bin/certbot. - server-plugins (REASONED; certbot): certbot --nginx or --apache issues and installs certificates for the supplied domain names when the server is supported. - standalone (REASONED; certbot): certonly --standalone binds port 80; stop its current owner and arrange webroot, a server plugin or port-freeing hooks for unattended renewal. - webroot (REASONED; certbot): certonly --webroot uses /var/www/html to serve challenge files while the existing web server keeps running. - dns-credentials (REASONED; certbot, certbot-dns-cloudflare-v230): The Cloudflare DNS plugin uses a credentials file and a Zone:DNS:Edit token limited to required zones; this guide requires never using the Global API Key and protecting the file with mode 600 in a 700 directory. - certificate-paths (REASONED; certbot): Configure servers against stable /etc/letsencrypt/live/example.com/fullchain.pem and privkey.pem paths for the chain and private key. - renewal-schedule (REASONED; certbot, certbot-v230): The Certbot v2.3.0 user guide says most installations have a scheduled task running certbot renew and says that, on Linux and BSD, you can check cron or systemd timers if unsure; this guide requires confirming the task and testing renewal plus reload with --dry-run --run-deploy-hooks, including package and snap installations. - deploy-hook (REASONED; certbot, certbot-v230): This guide requires persisting a reload hook during successful initial issuance or successful renewal, or installing an executable in renewal-hooks/deploy; Certbot v2.3.0 saves the renewal-relevant options set on the command line to a lineage after successful renewal, and a renew --deploy-hook invocation with no renewal due does not save that hook. - reconfigure (REASONED; reconfigure): certbot reconfigure can add the reload hook later on Certbot 2.3.0 and later. - expiry-monitoring (REASONED; le, certbot): Monitor the served certificate's expiry and renewal failures instead of relying on CA reminder emails; include a dry run in the deployment checklist. This guide assesses that any renewal step involving a person will fail at those scheduled shorter certificate lifetimes and that unattended automation is the only viable path. - default-lifetime (REASONED; le): At the guide's September 2026 documentation check, Let's Encrypt's default certificate lifetime is 90 days. - short-lifetime (REASONED; le): Optional 6-day short-lived certificates are available to every subscriber as of the guide's September 2026 check. - lifetime-schedule (REASONED; le): The classic profile moves to 64 days on 2027-02-10 and 45 days on 2028-02-16; industry rules cap publicly trusted certificates at 47 days from 2029-03-15. - revocation (REASONED; le): Let's Encrypt ended OCSP on 2025-08-06 and publishes revocation through CRLs only; do not add OCSP stapling directives for its certificates. - staging (REASONED; le, certbot): Let's Encrypt limits per-domain issuance; test with certbot --staging or --test-cert before real issuance. - caa (REASONED; le): CAA restricts issuing CAs, using issue letsencrypt.org for Let's Encrypt; certificate-transparency monitoring detects mis-issuance afterwards and complements CAA. - zerossl-eab (REASONED; zerossl): ZeroSSL offers free ACME certificates; some clients need dashboard EAB credentials. - acme-default (REASONED; acme): acme.sh registers with ZeroSSL by default. - acme-install (REASONED; acme): acme.sh is a shell ACME client supporting many DNS providers; the guide recommends installation from a repository release instead of piping a download into a shell. - server-automation (REASONED; le, caddy, traefik): This guide advises Caddy or Traefik for built-in issuance and renewal without an external client; it assesses them as the lowest-effort correct option for new deployments. - origin-certificates (REASONED; le, cloudflare-origin-ca): Cloudflare lists Origin CA certificates on its Free plan, says they only encrypt traffic between Cloudflare and the origin, and warns that visitors may see untrusted certificate errors when Cloudflare is paused or proxying is disabled. This guide requires using Origin CA certificates only for origins that receive traffic only through Cloudflare-proxied records. - verify-inventory (REASONED; certbot): certbot certificates lists issued certificates and their expiry dates. - verify-https (REASONED; le, curl-source): Use the deployment's public hostname rather than example.com; curl HTTPS should succeed without -k. - verify-certificate (REASONED; le, openssl-cli): The OpenSSL probe targets the hostname on port 443 with SNI, hostname verification and verification errors enabled, then displays issuer and dates. - authentication (REASONED; le): A certificate alone is insufficient protection; continue with server controls and authentication. - dns-automation (REASONED; certbot, certbot-v230): For automated DNS-01 issuance and renewal, this guide advises using a provider DNS plugin and least-privilege API credentials; Certbot also documents manual TXT entry and authentication hooks that automate the challenge. - dns-manual (REASONED; certbot, certbot-v230): certbot certonly --manual --preferred-challenges dns permits hand-created TXT records; the Certbot v2.3.0 guide says certificates created with --manual do not support automatic renewal without a --manual-auth-hook that automates the challenge. ````json { "schema": 1, "checked": "2026-10-17", "documentation_checked": "2026-09", "body_sha256": "bed8dac4fafafa6320e064ee47b8b4c3f811b3643b7ebf7ee12af64394603624", "components": { "le": { "name": "Let's Encrypt documentation", "basis": "unknown", "sources": { "s9ff2d787c618": "https://letsencrypt.org/docs/", "s01388c85c5d6": "https://letsencrypt.org/docs/cert-lifetimes/", "s4406ef210fff": "https://letsencrypt.org/2025/12/02/from-90-to-45", "s21efa5995c48": "https://letsencrypt.org/2025/08/06/ocsp-service-has-reached-end-of-life", "saf908bcd2143": "https://letsencrypt.org/docs/caa/" } }, "zerossl": { "name": "ZeroSSL", "basis": "unknown", "sources": { "sddca28a3381b": "https://zerossl.com/" } }, "certbot": { "name": "Certbot documentation", "basis": "unknown", "sources": { "s44faaf4eb7d7": "https://certbot.eff.org/", "s7b60a00cadf3": "https://eff-certbot.readthedocs.io/en/stable/using.html#manual", "s875be163b4c1": "https://eff-certbot.readthedocs.io/en/stable/using.html#dns-plugins" } }, "reconfigure": { "name": "Certbot reconfigure minimum", "basis": "2.3.0", "sources": { "s0dee7d6d65ba": "https://raw.githubusercontent.com/certbot/certbot/v2.3.0/certbot/docs/using.rst" } }, "acme": { "name": "acme.sh", "basis": "unknown", "sources": { "sa635c50fc1f9": "https://github.com/acmesh-official/acme.sh" } }, "certbot-v230": { "name": "Certbot user guide and source", "basis": "v2.3.0", "sources": { "s725109195132": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L808-L825", "s2f25bc233392": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L632-L640", "s11c7994242fc": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L704-L710", "sbb0c60d635ee": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L659-L684", "s55ec19f37395": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L194-L220", "s5bf4ff9fbe76": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L222-L265", "s7a053488f705": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/cli/cli_utils.py#L227-L231", "sa18c858c4e49": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/renewal.py#L40-L53", "s2e4bd79a7795": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/renewal.py#L509-L534", "s1910987bfd8c": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/storage.py#L295-L312", "s9c6118b72e0a": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/storage.py#L1120-L1128", "s3e22e32a2862": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/storage.py#L1243-L1249" } }, "certbot-dns-cloudflare-v230": { "name": "Certbot Cloudflare DNS plugin", "basis": "v2.3.0", "sources": { "s3f07ef3d544d": "https://github.com/certbot/certbot/blob/v2.3.0/certbot-dns-cloudflare/certbot_dns_cloudflare/__init__.py#L24-L39", "sc3b33fa2c90f": "https://github.com/certbot/certbot/blob/v2.3.0/certbot-dns-cloudflare/certbot_dns_cloudflare/__init__.py#L61-L78" } }, "caddy": { "name": "Caddy automatic HTTPS", "basis": "v2.11.4", "sources": { "s9683cb8d689f": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L62-L63", "s409f9221aec2": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddytls/acmeissuer.go#L280-L296", "s80dbf0751153": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddytls/acmeissuer.go#L245-L249", "s5216789c9341": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddytls/acmeissuer.go#L321-L328" } }, "certmagic": { "name": "certmagic ACME implementation", "basis": "v0.25.3", "sources": { "s61e3cba719df": "https://github.com/caddyserver/caddy/blob/v2.11.4/go.mod#L12-L12", "s8782e006523b": "https://github.com/caddyserver/certmagic/blob/v0.25.3/acmeclient.go#L188-L226", "s465c4ddcc92f": "https://github.com/caddyserver/certmagic/blob/v0.25.3/acmeclient.go#L304-L313", "sc1e2ddefe5ed": "https://github.com/caddyserver/certmagic/blob/v0.25.3/acmeissuer.go#L116-L120", "sd423f53faa1a": "https://github.com/caddyserver/certmagic/blob/v0.25.3/acmeissuer.go#L222-L236", "sf2fbc79f6542": "https://github.com/caddyserver/certmagic/blob/v0.25.3/certmagic.go#L473-L475", "s1c3236df58b9": "https://github.com/caddyserver/certmagic/blob/v0.25.3/acmeissuer.go#L647-L654" } }, "traefik": { "name": "Traefik ACME documentation", "basis": "v3.5", "sources": { "s418a6b1ad09b": "https://doc.traefik.io/traefik/v3.5/reference/install-configuration/tls/certificate-resolvers/acme/" } }, "cloudflare-origin-ca": { "name": "Cloudflare Origin CA documentation", "basis": "checked October 2026", "sources": { "s1e15de5c9f36": "https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" } }, "openssl-cli": { "name": "OpenSSL certificate commands", "basis": "3.0", "sources": { "s00aaf106164f": "https://docs.openssl.org/3.0/man1/openssl-s_client/", "s0856f4a58574": "https://docs.openssl.org/3.0/man1/openssl-x509/" } }, "curl-source": { "name": "curl command documentation", "basis": "curl-8_12_1", "sources": { "s76f69702c9d5": "https://github.com/curl/curl/blob/curl-8_12_1/docs/cmdline-opts/head.md#L21-L22", "s60820cd231e1": "https://github.com/curl/curl/blob/curl-8_12_1/docs/cmdline-opts/insecure.md#L21-L28" } } }, "claims": { "public-trust": {"text": "ACME CAs such as Let's Encrypt and ZeroSSL issue free publicly trusted certificates; this guide advises preferring them for public DNS names, including private hosts using DNS-01.", "components": ["le", "zerossl"], "sources": ["le:s9ff2d787c618", "zerossl:sddca28a3381b"], "status": "REASONED"}, "http-challenge": {"text": "HTTP-01 needs a public A/AAAA or CNAME pointing to the server and inbound internet access to port 80.", "components": ["le"], "sources": ["le:s9ff2d787c618"], "status": "REASONED"}, "alpn-challenge": {"text": "TLS-ALPN-01 needs a public DNS record and inbound port 443; Caddy's ACME issuer enables the TLS-ALPN-01 solver when DNS-01 is not configured and TLS-ALPN-01 is not disabled, and can use an alternate local port if port 443 forwards to it; Traefik also uses TLS-ALPN-01.", "components": ["le", "caddy", "certmagic", "traefik"], "sources": ["le:s9ff2d787c618", "caddy:s409f9221aec2", "caddy:s80dbf0751153", "caddy:s5216789c9341", "certmagic:s61e3cba719df", "certmagic:s8782e006523b", "certmagic:s465c4ddcc92f", "certmagic:sc1e2ddefe5ed", "certmagic:sd423f53faa1a", "certmagic:sf2fbc79f6542", "certmagic:s1c3236df58b9", "traefik:s418a6b1ad09b"], "status": "REASONED"}, "dns-challenge": {"text": "DNS-01 needs control of the challenge TXT record, no public address record or inbound port, and supports wildcards. Automated issuance and renewal need an automated way to update TXT records, and this guide advises a provider DNS plugin and scoped API credentials, while Certbot also supports manual DNS-01 and an authentication hook for automatic renewal.", "components": ["le", "certbot", "certbot-v230"], "sources": ["le:s9ff2d787c618", "certbot:s7b60a00cadf3", "certbot:s875be163b4c1", "certbot-v230:s55ec19f37395", "certbot-v230:s5bf4ff9fbe76"], "status": "REASONED"}, "certbot-install": {"text": "Install Certbot and nginx/Apache plugins from the distribution, or the classic snap with /usr/bin/certbot pointing to /snap/bin/certbot.", "components": ["certbot"], "sources": ["certbot:s44faaf4eb7d7"], "status": "REASONED"}, "server-plugins": {"text": "certbot --nginx or --apache issues and installs certificates for the supplied domain names when the server is supported.", "components": ["certbot"], "sources": ["certbot:s44faaf4eb7d7"], "status": "REASONED"}, "standalone": {"text": "certonly --standalone binds port 80; stop its current owner and arrange webroot, a server plugin or port-freeing hooks for unattended renewal.", "components": ["certbot"], "sources": ["certbot:s44faaf4eb7d7"], "status": "REASONED"}, "webroot": {"text": "certonly --webroot uses /var/www/html to serve challenge files while the existing web server keeps running.", "components": ["certbot"], "sources": ["certbot:s44faaf4eb7d7"], "status": "REASONED"}, "dns-credentials": {"text": "The Cloudflare DNS plugin uses a credentials file and a Zone:DNS:Edit token limited to required zones; this guide requires never using the Global API Key and protecting the file with mode 600 in a 700 directory.", "components": ["certbot", "certbot-dns-cloudflare-v230"], "sources": ["certbot:s44faaf4eb7d7", "certbot-dns-cloudflare-v230:s3f07ef3d544d", "certbot-dns-cloudflare-v230:sc3b33fa2c90f"], "status": "REASONED"}, "certificate-paths": {"text": "Configure servers against stable /etc/letsencrypt/live/example.com/fullchain.pem and privkey.pem paths for the chain and private key.", "components": ["certbot"], "sources": ["certbot:s44faaf4eb7d7"], "status": "REASONED"}, "renewal-schedule": {"text": "The Certbot v2.3.0 user guide says most installations have a scheduled task running certbot renew and says that, on Linux and BSD, you can check cron or systemd timers if unsure; this guide requires confirming the task and testing renewal plus reload with --dry-run --run-deploy-hooks, including package and snap installations.", "components": ["certbot", "certbot-v230"], "sources": ["certbot:s44faaf4eb7d7", "certbot-v230:s725109195132"], "status": "REASONED"}, "deploy-hook": {"text": "This guide requires persisting a reload hook during successful initial issuance or successful renewal, or installing an executable in renewal-hooks/deploy; Certbot v2.3.0 saves the renewal-relevant options set on the command line to a lineage after successful renewal, and a renew --deploy-hook invocation with no renewal due does not save that hook.", "components": ["certbot", "certbot-v230"], "sources": ["certbot:s44faaf4eb7d7", "certbot-v230:s2f25bc233392", "certbot-v230:s11c7994242fc", "certbot-v230:sbb0c60d635ee", "certbot-v230:s7a053488f705", "certbot-v230:sa18c858c4e49", "certbot-v230:s2e4bd79a7795", "certbot-v230:s1910987bfd8c", "certbot-v230:s9c6118b72e0a", "certbot-v230:s3e22e32a2862"], "status": "REASONED"}, "reconfigure": {"text": "certbot reconfigure can add the reload hook later on Certbot 2.3.0 and later.", "components": ["reconfigure"], "sources": ["reconfigure:s0dee7d6d65ba"], "status": "REASONED"}, "expiry-monitoring": {"text": "Monitor the served certificate's expiry and renewal failures instead of relying on CA reminder emails; include a dry run in the deployment checklist. This guide assesses that any renewal step involving a person will fail at those scheduled shorter certificate lifetimes and that unattended automation is the only viable path.", "components": ["le", "certbot"], "sources": ["le:s4406ef210fff", "certbot:s44faaf4eb7d7"], "status": "REASONED"}, "default-lifetime": {"text": "At the guide's September 2026 documentation check, Let's Encrypt's default certificate lifetime is 90 days.", "components": ["le"], "sources": ["le:s01388c85c5d6"], "status": "REASONED"}, "short-lifetime": {"text": "Optional 6-day short-lived certificates are available to every subscriber as of the guide's September 2026 check.", "components": ["le"], "sources": ["le:s01388c85c5d6"], "status": "REASONED"}, "lifetime-schedule": {"text": "The classic profile moves to 64 days on 2027-02-10 and 45 days on 2028-02-16; industry rules cap publicly trusted certificates at 47 days from 2029-03-15.", "components": ["le"], "sources": ["le:s01388c85c5d6", "le:s4406ef210fff"], "status": "REASONED"}, "revocation": {"text": "Let's Encrypt ended OCSP on 2025-08-06 and publishes revocation through CRLs only; do not add OCSP stapling directives for its certificates.", "components": ["le"], "sources": ["le:s21efa5995c48"], "status": "REASONED"}, "staging": {"text": "Let's Encrypt limits per-domain issuance; test with certbot --staging or --test-cert before real issuance.", "components": ["le", "certbot"], "sources": ["le:s9ff2d787c618", "certbot:s44faaf4eb7d7"], "status": "REASONED"}, "caa": {"text": "CAA restricts issuing CAs, using issue letsencrypt.org for Let's Encrypt; certificate-transparency monitoring detects mis-issuance afterwards and complements CAA.", "components": ["le"], "sources": ["le:saf908bcd2143", "le:s9ff2d787c618"], "status": "REASONED"}, "zerossl-eab": {"text": "ZeroSSL offers free ACME certificates; some clients need dashboard EAB credentials.", "components": ["zerossl"], "sources": ["zerossl:sddca28a3381b"], "status": "REASONED"}, "acme-default": {"text": "acme.sh registers with ZeroSSL by default.", "components": ["acme"], "sources": ["acme:sa635c50fc1f9"], "status": "REASONED"}, "acme-install": {"text": "acme.sh is a shell ACME client supporting many DNS providers; the guide recommends installation from a repository release instead of piping a download into a shell.", "components": ["acme"], "sources": ["acme:sa635c50fc1f9"], "status": "REASONED"}, "server-automation": {"text": "This guide advises Caddy or Traefik for built-in issuance and renewal without an external client; it assesses them as the lowest-effort correct option for new deployments.", "components": ["le", "caddy", "traefik"], "sources": ["le:s9ff2d787c618", "caddy:s9683cb8d689f", "traefik:s418a6b1ad09b"], "status": "REASONED"}, "origin-certificates": {"text": "Cloudflare lists Origin CA certificates on its Free plan, says they only encrypt traffic between Cloudflare and the origin, and warns that visitors may see untrusted certificate errors when Cloudflare is paused or proxying is disabled. This guide requires using Origin CA certificates only for origins that receive traffic only through Cloudflare-proxied records.", "components": ["le", "cloudflare-origin-ca"], "sources": ["le:s9ff2d787c618", "cloudflare-origin-ca:s1e15de5c9f36"], "status": "REASONED"}, "verify-inventory": {"text": "certbot certificates lists issued certificates and their expiry dates.", "components": ["certbot"], "sources": ["certbot:s44faaf4eb7d7"], "status": "REASONED", "verify": [1]}, "verify-https": {"text": "Use the deployment's public hostname rather than example.com; curl HTTPS should succeed without -k.", "components": ["le", "curl-source"], "sources": ["le:s9ff2d787c618", "curl-source:s76f69702c9d5", "curl-source:s60820cd231e1"], "status": "REASONED", "verify": [1]}, "verify-certificate": {"text": "The OpenSSL probe targets the hostname on port 443 with SNI, hostname verification and verification errors enabled, then displays issuer and dates.", "components": ["le", "openssl-cli"], "sources": ["le:s9ff2d787c618", "openssl-cli:s00aaf106164f", "openssl-cli:s0856f4a58574"], "status": "REASONED", "verify": [1]}, "authentication": {"text": "A certificate alone is insufficient protection; continue with server controls and authentication.", "components": ["le"], "sources": ["le:s9ff2d787c618"], "status": "REASONED"}, "dns-automation": {"text": "For automated DNS-01 issuance and renewal, this guide advises using a provider DNS plugin and least-privilege API credentials; Certbot also documents manual TXT entry and authentication hooks that automate the challenge.", "components": ["certbot", "certbot-v230"], "sources": ["certbot:s875be163b4c1", "certbot:s7b60a00cadf3", "certbot-v230:s55ec19f37395", "certbot-v230:s5bf4ff9fbe76"], "status": "REASONED"}, "dns-manual": {"text": "certbot certonly --manual --preferred-challenges dns permits hand-created TXT records; the Certbot v2.3.0 guide says certificates created with --manual do not support automatic renewal without a --manual-auth-hook that automates the challenge.", "components": ["certbot", "certbot-v230"], "sources": ["certbot:s7b60a00cadf3", "certbot-v230:s5bf4ff9fbe76"], "status": "REASONED"} } } ```` ====================================================================== ==> self-signed.md ====================================================================== # Self-signed certificates A self-signed certificate is one signed with its own key rather than by a certificate authority browsers already trust. This guide helps your AI assistant use one securely, including creating it with OpenSSL, mkcert only on development machines, a protected private key, and clients that trust it without disabling verification. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/self-signed.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against OpenSSL documentation (rolling) (no recorded version), Requests TLS documentation v2.32.3, Git ignore documentation v2.43.0, curl command documentation curl-8_12_1, Node.js extra CA certificates v22.19.0, and Service identity RFC 9525, plus 5 more recorded bases; 9 further sources are pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 27 claims: 1 demonstrated by a check that ran and 26 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Use a self-signed certificate when the service has no public DNS name, when an ACME CA cannot reach it, and when [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) is not an option: internal tools, lab and development environments, and machine-to-machine links on private networks. For anything a browser user or external party reaches, prefer [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md); self-signed certificates trigger browser warnings and every client must be configured to trust them. Self-signed TLS still matters. It encrypts credentials and data in transit; without it, authentication tokens cross the network in cleartext. ## 1. Generate a certificate with OpenSSL RSA, single command (OpenSSL 3.0 or later): ```bash openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -noenc \ -keyout server.key -out server.crt \ -subj "/CN=app.internal" \ -addext "basicConstraints=critical,CA:FALSE" \ -addext "subjectAltName=DNS:app.internal,DNS:localhost,IP:127.0.0.1,IP:203.0.113.10" ``` ECDSA (smaller and faster; generate the key first, then the certificate): ```bash openssl ecparam -name prime256v1 -genkey -noout -out server.key openssl req -x509 -key server.key -sha256 -days 365 -out server.crt \ -subj "/CN=app.internal" \ -addext "basicConstraints=critical,CA:FALSE" \ -addext "subjectAltName=DNS:app.internal,IP:203.0.113.10" ``` Rules that make the certificate actually work: - The `subjectAltName` list must contain every DNS name and IP address clients will use to reach the service (this guide's requirement, based on the RFC 9525 matching rules that follow). RFC 9525 requires DNS identity matching by comparing labels ('Each label MUST match', except under its wildcard rule) and says an IP-ID reference identifier 'MUST exactly match the value of an iPAddress entry in subjectAltName'. It says the subject Common Name 'MUST NOT be used to identify a service'. If no presented identifier matches, RFC 9525 says an automated client 'SHOULD terminate' and permits a configured exception that 'MUST NOT' disable this security control by default, while a human-facing client 'SHOULD' warn and terminate but may offer advanced users an override. Its ad hoc pinning provision says such pinning 'SHOULD NOT' restrict future connections to only the pinned certificate. Some verifiers, including OpenSSL's default, still fall back to the CN, so confirm in Verify below that the SAN exists rather than trusting a CN. Replace the example `203.0.113.10` (a documentation address from RFC 5737) with your service's real IP, or drop the `IP:` entries if clients reach it only by name. - `basicConstraints=critical,CA:FALSE` marks the certificate a leaf, not a CA. It matters because section 4 installs the certificate into client trust stores: OpenSSL's stock configuration marks a bare `req -x509` certificate `CA:TRUE`, which in a trust store is a signer for any name, using the unencrypted key that `-noenc` leaves on the server. This override relies on OpenSSL 3.0 or later, where a command-line extension replaces the configuration default; the end-of-life 1.1.1 would instead emit a duplicate, invalid extension. - `-noenc` (spelled `-nodes` before OpenSSL 3.0, still accepted but deprecated) leaves the key unencrypted so services can start unattended; protect it with file permissions instead. - Track the `-days` expiry. Nothing renews a self-signed certificate for you; put the date in your calendar or monitoring. ## 2. Protect the private key This guide advises protecting `server.key` with mode `600` and ownership by the account and group your service runs as; `app:app` below is this guide's example, to replace with that account and group. GNU coreutils v9.7 documents `chmod` changing file access permissions and taking new mode bits, and `chown owner:group` changing both owner and group (see Sources); it does not recommend a service account or select one for this key. This guide's reading of `600` is owner read/write and no group or other permissions, based on GNU coreutils v9.7's Numeric Modes definition in `doc/perm.texi` (see Sources). ```bash chmod 600 server.key chown app:app server.key # whichever user your service runs as ``` Never commit a private key to version control. Add `*.key` and `*.pem` to `.gitignore` before generating anything inside a repository, and treat any key that has ever been committed or pasted into a chat as compromised: regenerate it. ## 3. mkcert for local development [mkcert](https://github.com/FiloSottile/mkcert) creates a local CA, installs it into your OS and browser trust stores, and issues certificates that your own machine trusts with no warnings: ```bash mkcert -install mkcert app.test localhost 127.0.0.1 ::1 ``` This is for development machines only. The generated CA can sign for any name, so its key must never leave the developer's machine, and mkcert certificates must never serve real users. ## 4. Make clients trust the certificate; never disable verification Distribute the certificate (or your internal CA certificate) to clients instead of turning verification off: Debian trixie and Ubuntu noble remain cited; the Debian bookworm and bullseye and Ubuntu jammy and focal manpages also document `update-ca-certificates` including `.crt` certificates under `/usr/local/share/ca-certificates` as implicitly trusted (see Sources). The Ubuntu resolute manpage words this slightly differently, saying that such certificates "are also included and implicitly trusted", and tells the reader to copy PEM certificates with the `.crt` extension to `/usr/local/share/ca-certificates` and then run `update-ca-certificates` (see Sources). Use on other Debian and Ubuntu releases remains this guide's assessment, with release-specific citations absent. Documentation reading on 2026-10-24: Debian's releases page gives bullseye's End of LTS as 2026-08-31, now past, although its Status cell still says LTS support (see Sources). Documentation reading on 2026-10-24: Ubuntu's release-cycle page gives focal's standard security maintenance as ending May 2025, now past, with coverage after that only through Ubuntu Pro (see Sources). Both the bullseye and focal manpage citations remain because they still document those releases' commands. RHEL 9 remains cited, and the RHEL 8 shared system certificates page also says to copy a certificate in the simple PEM or DER file formats to `/usr/share/pki/ca-trust-source/anchors/` or `/etc/pki/ca-trust/source/anchors/` and run `update-ca-trust extract` (see Sources). The CentOS Stream 8, 9 and 10 package manuals and the Fedora rawhide, 41, 42, 43, 44 and 45 branch dist-git manuals instruct adding a simple PEM or DER certificate to `/etc/pki/ca-trust/source/anchors/` and running `update-ca-trust extract` (see Sources). The Fedora 41, 42 and 43 branch manuals, at the pinned commits, give this instruction on the same lines 97 to 99 as the cited rawhide manual, with no difference from the rawhide citation there. The CentOS Stream 9 and Fedora 44 and 45 branch manuals, at the pinned commits, also give it on lines 97 to 99. CentOS Stream is not RHEL, rawhide is Fedora's development branch, not a released Fedora, and this guide does not establish whether Fedora 45 has been released; use on other RHEL releases (including 10), other Fedora releases and other CentOS Stream releases remains this guide's assessment, with release-specific citations absent. Documentation reading on 2026-11-04: Red Hat's product life-cycle API response for Red Hat Enterprise Linux, cited here as the API response and not as the rendered errata page, gives each phase of versions 10, 9 and 8 with its dates, quoted below as the JSON gives them (see Sources). For version 10 it gives General availability with start date `N/A` and end date `2025-05-20T00:00:00.000Z`, Full support from `2025-05-20T00:00:00.000Z` to `2030-05-31T00:00:00.000Z`, and Maintenance support from `2030-06-01T00:00:00.000Z` to `2035-05-31T00:00:00.000Z`. Version 10 then has Extended life cycle support (ELS) add-on from `2035-06-01T00:00:00.000Z` to `2039-05-31T00:00:00.000Z` and Extended life phase from `2039-06-01T00:00:00.000Z` to `Ongoing`. For version 9 it gives General availability with start date `N/A` and end date `2022-05-18T00:00:00.000Z`, Full support from `2022-05-18T00:00:00.000Z` to `2027-05-31T00:00:00.000Z`, and Maintenance support from `2027-06-01T00:00:00.000Z` to `2032-05-31T00:00:00.000Z`. Version 9 then has Extended life cycle support (ELS) add-on from `2032-06-01T00:00:00.000Z` to `2036-05-31T00:00:00.000Z` and Extended life phase from `2036-06-01T00:00:00.000Z` to `Ongoing`. For version 8 it gives General availability from `2019-05-07T00:00:00.000Z` to `2019-05-07T00:00:00.000Z`, Full support from `2019-05-07T00:00:00.000Z` to `2024-05-31T00:00:00.000Z`, and Maintenance support from `2024-06-01T00:00:00.000Z` to `2029-05-31T00:00:00.000Z`. Version 8 then has Extended life cycle support (ELS) add-on from `2029-06-01T00:00:00.000Z` to `2033-05-31T00:00:00.000Z` and Extended life phase from `2033-06-01T00:00:00.000Z` to `Ongoing`. The response's `all_phases` text calls the Extended life phase "the post-retirement time period" and says "there are no bug fixes, security fixes, hardware enablement, or root cause analysis available during the Extended Life Phase", when customers "may choose to purchase the Extended Life Cycle Support Add-On". The response carries no date or version of its own, so 2026-11-04 is the date it was read, not a date the response states. This guide's reading of those dates, not a statement in the response, is that on 2026-11-04 RHEL 10 and 9 are in Full support and RHEL 8 is in Maintenance support. The response does label versions 10 and 9 `"type":"Full Support"` and version 8 `"type":"Maintenance Support"`, but it does not define that field or give a date for it. The response gives life-cycle phases, not trust-store instructions, so using the command below on RHEL 10 remains this guide's assessment, with release-specific citations absent. Documentation reading on 2026-11-04: the mirrors.kernel.org Fedora `releases/` index lists `45/`, dated 03-Nov-2026 11:31 in the index, and the `development/` index also lists `45/`, dated 30-Oct-2026 10:02 (see Sources). Neither index states the time zone of its dates. A directory index shows which trees exist, not which releases are supported, so this guide still does not establish whether Fedora 45 has been released. ```bash # Debian trixie/bookworm/bullseye and Ubuntu resolute/noble/jammy/focal system trust store; other releases: this guide's assessment # Documentation reading, 2026-10-24 (see Sources): bullseye past Debian LTS (End of LTS 2026-08-31; Status still says LTS support). # Focal past standard security maintenance (ended May 2025); coverage after that only through Ubuntu Pro. # Both manpage citations still document those releases' commands. sudo cp server.crt /usr/local/share/ca-certificates/app-internal.crt sudo update-ca-certificates # RHEL 8/9 system trust store; CentOS Stream 8/9/10 (not RHEL); Fedora 41/42/43/44; Fedora 45 branch (release status not established); Fedora rawhide (development, not a release) # Other RHEL (including 10), other Fedora and other CentOS Stream: this guide's assessment sudo cp server.crt /etc/pki/ca-trust/source/anchors/app-internal.crt sudo update-ca-trust extract # Per-tool curl -q --cacert server.crt https://app.internal/ export REQUESTS_CA_BUNDLE=/path/to/server.crt # Python requests export NODE_EXTRA_CA_CERTS=/path/to/server.crt # Node.js ``` Do not ship `curl -k`, `verify=False`, `rejectUnauthorized: false`, or `NODE_TLS_REJECT_UNAUTHORIZED=0` in committed code. For curl and Requests, `-k` and `verify=False` disable certificate verification (see their existing Sources entries). Node.js v24.22.0 documents `NODE_TLS_REJECT_UNAUTHORIZED=0` as an environment variable disabling certificate validation for TLS connections in the Node.js process and strongly discourages its use. In `tls.connect`, `rejectUnauthorized: false` disables verification of the server certificate against supplied CAs for that client connection. In `tls.createServer`, it disables rejection of connections unauthorized by supplied CAs; the option only has an effect when `requestCert` is `true`. These Node.js passages establish the documented settings and their scope, not their use in a particular deployment. This guide's assessment is that these bypasses accept attacker-controlled certificates as readily as your own and reliably survive into production. ## 5. Verify DEMONSTRATED: following block; local certificate-output inspection only. On 2026-10-05 in private /dev/shm scratch, OpenSSL 3.5.5 ran the printed x509 command on three disposable one-day RSA certificates. Missing-SAN and CA:TRUE fixtures each exited 0 but lacked the required SAN or leaf constraint; the fixed fixture also exited 0 and printed DNS:app.internal, IP Address:127.0.0.1 and critical CA:FALSE. Subject and validity dates printed for all three. The recorded x509 runs used an earlier form of the block, the bare x509 command at top level, whose status was x509's own; none was re-run on the current form, which exits 1 when the output lacks a subjectAltName or CA:FALSE, 3 when x509 fails, and 0 otherwise. ```bash ( # a subshell, so the exits below end only the subshell, not your shell set -- "$(if openssl x509 -in server.crt -noout -subject -dates -ext subjectAltName,basicConstraints; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" 0 # x509's output, its exit status, then the block's status so far: 1 (a finding) outranks 0 printf '%s' "$1" [ "$2" = 0 ] || { echo "openssl x509 exit $2: it could not read server.crt; inconclusive"; exit 3; } case "$1" in # x509 exits 0 even with no SAN, so read its output, not its status *'X509v3 Subject Alternative Name'*) ;; *) echo "FINDING: server.crt has no subjectAltName, so it cannot cover the names and IPs your clients use"; set -- "$1" "$2" 1 ;; esac case "$1" in *CA:FALSE*) ;; *) echo "FINDING: basicConstraints is not marked CA:FALSE, so the certificate is not limited to a leaf"; set -- "$1" "$2" 1 ;; esac echo "block exit $3; whether the subjectAltName covers every name and IP your clients use, and the subject and dates, are not classified, so judge them yourself" exit "$3" ) ``` REASONED: following block; live TLS identity, failure handling and client-trust checks follow the cited OpenSSL and mkcert documentation. No app.internal service or client trust-store installation was supplied for these checks. The local x509 run above does not demonstrate a handshake or installed trust; expected outcomes and limitations are stated below. ```bash ( # a subshell, so the exits below end only the subshell, not your shell if openssl s_client -connect app.internal:443 -servername app.internal -verify_hostname app.internal -verify_return_error -CAfile server.crt ` to check an IP identity. The first block reports a finding, status 1, when that output has no `subjectAltName` or no `CA:FALSE`, but whether the SAN covers every name and IP is yours to judge. The reason is that `-verify_hostname` falls back to the Common Name whenever the certificate carries no DNS-name SAN entry (even if it has an IP SAN), so a CN-only or IP-only certificate can still pass a hostname check. The `s_client` check supplies trust explicitly with `-CAfile`, so it proves the certificate and name but not that the section-4 trust-store install took effect; confirm that separately from each client with its normal configuration (for the system store, a plain `curl https://app.internal/` with no `--cacert` should fail before `update-ca-certificates` and succeed after). ## Limits to plan around Self-signed certificates have no revocation and no third-party accountability, and trust distribution is manual. When the audience grows beyond a small internal group, move to [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or put the service behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Authentication is still required either way: see [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). ## Sources (checked September 2026) - OpenSSL documentation (rolling documentation, checked September 2026): https://docs.openssl.org/ ; `openssl s_client` (`-servername`, `-verify_return_error`): https://docs.openssl.org/master/man1/openssl-s_client/ ; verification options (`-verify_hostname`): https://docs.openssl.org/master/man1/openssl-verification-options/ - mkcert: https://github.com/FiloSottile/mkcert - Requests v2.32.3 CA-bundle environment variables (checked October 2026): https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L238-L239 - Requests v2.32.3 disabled certificate verification (checked October 2026): https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L246-L250 - Git v2.43.0 ignore rules and already tracked files (checked October 2026): https://github.com/git/git/blob/v2.43.0/Documentation/gitignore.txt#L15-L18 - curl curl-8_12_1 explicit CA trust (checked October 2026): https://github.com/curl/curl/blob/curl-8_12_1/docs/cmdline-opts/cacert.md#L21-L24 - curl curl-8_12_1 normal certificate verification and `--insecure` (checked October 2026): https://github.com/curl/curl/blob/curl-8_12_1/docs/cmdline-opts/insecure.md#L21-L28 - Node.js v22.19.0 extra trusted CA certificates (checked October 2026): https://github.com/nodejs/node/blob/v22.19.0/doc/api/cli.md#L3234-L3238 - RFC 9525 subject Common Name (checked October 2026): https://www.rfc-editor.org/rfc/rfc9525.html#section-2 - RFC 9525 reference identifiers and IP-ID matching (checked October 2026): https://www.rfc-editor.org/rfc/rfc9525.html#section-6.2 - RFC 9525 DNS identity matching (checked October 2026): https://www.rfc-editor.org/rfc/rfc9525.html#section-6.3 - RFC 9525 IP identity matching (checked October 2026): https://www.rfc-editor.org/rfc/rfc9525.html#section-6.4 - RFC 9525 identity mismatch outcome (checked October 2026): https://www.rfc-editor.org/rfc/rfc9525.html#section-6.6 - Debian trixie update-ca-certificates (checked October 2026): https://manpages.debian.org/trixie/ca-certificates/update-ca-certificates.8.en.html#DESCRIPTION - Ubuntu noble update-ca-certificates (checked October 2026): https://manpages.ubuntu.com/manpages/noble/man8/update-ca-certificates.8.html#description - RHEL 9 shared system certificates (checked October 2026): https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/9/html/securing_networks/using-shared-system-certificates_securing-networks#adding-new-certificates_using-shared-system-certificates - Debian bookworm update-ca-certificates (checked October 2026). Sources for this item: https://manpages.debian.org/bookworm/ca-certificates/update-ca-certificates.8.en.html. Quoted text: "Furthermore all certificates with a .crt extension found below /usr/local/share/ca-certificates are also included as implicitly trusted." - Ubuntu jammy update-ca-certificates (checked October 2026). Sources for this item: https://manpages.ubuntu.com/manpages/jammy/man8/update-ca-certificates.8.html. Quoted text: "Furthermore all certificates with a .crt extension found below /usr/local/share/ca-certificates are also included as implicitly trusted." - CentOS Stream 8, not RHEL, ca-certificates commit ad398898923bddb3953d2aa4c8d9d17c901ef3a4, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://gitlab.com/redhat/centos-stream/rpms/ca-certificates/-/blob/ad398898923bddb3953d2aa4c8d9d17c901ef3a4/update-ca-trust.8.txt#L97-L99. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract. - CentOS Stream 10, not RHEL, ca-certificates commit 1ab579df1508321342e9ab244fd4ed397ea89b80, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://gitlab.com/redhat/centos-stream/rpms/ca-certificates/-/blob/1ab579df1508321342e9ab244fd4ed397ea89b80/update-ca-trust.8.txt#L97-L99. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract. - Fedora rawhide development branch, not a released Fedora, ca-certificates commit 725690846ee1ae1cd46e62c3a336c84820390d27, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://src.fedoraproject.org/rpms/ca-certificates/blob/725690846ee1ae1cd46e62c3a336c84820390d27/f/update-ca-trust.8.txt#_97. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract. - Debian bullseye update-ca-certificates (checked October 2026). Sources for this item: https://manpages.debian.org/bullseye/ca-certificates/update-ca-certificates.8.en.html. Quoted text: "Furthermore all certificates with a .crt extension found below /usr/local/share/ca-certificates are also included as implicitly trusted." - Ubuntu focal update-ca-certificates (checked October 2026). Sources for this item: https://manpages.ubuntu.com/manpages/focal/man8/update-ca-certificates.8.html. Quoted text: "Furthermore all certificates with a .crt extension found below /usr/local/share/ca-certificates are also included as implicitly trusted." - Debian releases, Index of releases, bullseye row (checked October 2026). Sources for this item: https://www.debian.org/releases/. Columns read 2026-10-24: End of LTS and Status. - Ubuntu release cycle, Selected product coverage details, Ubuntu 20.04 LTS row (checked October 2026). Sources for this item: https://ubuntu.com/about/release-cycle. Columns read 2026-10-24: Standard security maintenance and Ubuntu Pro. - Fedora 41 branch ca-certificates commit ea529bea8d6f00da8edfa62c87d115bc023b4b67, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://src.fedoraproject.org/rpms/ca-certificates/blob/ea529bea8d6f00da8edfa62c87d115bc023b4b67/f/update-ca-trust.8.txt#_97. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract, as the cited rawhide lines do. - Fedora 42 branch ca-certificates commit f00d2386f44d6dbcabc18becf7843c789584cfbc, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://src.fedoraproject.org/rpms/ca-certificates/blob/f00d2386f44d6dbcabc18becf7843c789584cfbc/f/update-ca-trust.8.txt#_97. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract, as the cited rawhide lines do. - Fedora 43 branch ca-certificates commit e73db0bb0458f65d530ae5901de2be73f7d7fb80, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://src.fedoraproject.org/rpms/ca-certificates/blob/e73db0bb0458f65d530ae5901de2be73f7d7fb80/f/update-ca-trust.8.txt#_97. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract, as the cited rawhide lines do. - Ubuntu resolute update-ca-certificates (checked October 2026). Sources for this item: https://manpages.ubuntu.com/manpages/resolute/man8/update-ca-certificates.8.html. Quoted text: "Furthermore, all certificates with a .crt extension found below /usr/local/share/ca-certificates are also included and implicitly trusted." and "Then run update-ca-certificates to merge the new certificates into the existing machine store at /etc/ssl/certs." - RHEL 8 shared system certificates, section 3.2 "Adding new certificates to the system-wide truststore" (checked October 2026). Sources for this item: https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html/securing_networks/using-shared-system-certificates_securing-networks#adding-new-certificates_using-shared-system-certificates. Quoted text, in the order of the section's steps: "copy the certificate file to the /usr/share/pki/ca-trust-source/anchors/ or /etc/pki/ca-trust/source/anchors/ directory"; "use the update-ca-trust command". The section shows the update-ca-trust command as update-ca-trust extract. - CentOS Stream 9, not RHEL, ca-certificates commit 848a7831862d78a087c8f5d2284ce603f20ed42f, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://gitlab.com/redhat/centos-stream/rpms/ca-certificates/-/blob/848a7831862d78a087c8f5d2284ce603f20ed42f/update-ca-trust.8.txt#L97-L99. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract. - Fedora 44 branch ca-certificates commit 1ccf8e36dcf991db7ae4a4220c1612d1f43d96ec, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://src.fedoraproject.org/rpms/ca-certificates/blob/1ccf8e36dcf991db7ae4a4220c1612d1f43d96ec/f/update-ca-trust.8.txt#_97. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract. - Fedora 45 branch, release status not established by this guide, ca-certificates commit 3e42eeb6c71e1dd7c126a90a4811ccbe5f79fed7, update-ca-trust.8.txt lines 97 to 99 (checked October 2026). Sources for this item: https://src.fedoraproject.org/rpms/ca-certificates/blob/3e42eeb6c71e1dd7c126a90a4811ccbe5f79fed7/f/update-ca-trust.8.txt#_97. QUICK HELP 1 specifies a simple PEM or DER certificate, /etc/pki/ca-trust/source/anchors/ and update-ca-trust extract. - GNU coreutils v9.7 `chmod` syntax (checked October 2026): https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L11937-L11942 - GNU coreutils v9.7 mode argument (checked October 2026): https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L11967-L11968 - GNU coreutils v9.7 `chown` owner and group (checked October 2026): https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L11614-L11622 - GNU coreutils v9.7 numeric mode definition (checked October 2026): https://github.com/coreutils/coreutils/blob/v9.7/doc/perm.texi#L492-L535 - Node.js v24.22.0 `NODE_TLS_REJECT_UNAUTHORIZED` (checked October 2026): https://github.com/nodejs/node/blob/v24.22.0/doc/api/cli.md#L3940-L3944 - Node.js v24.22.0 `tls.connect` client `rejectUnauthorized` (checked October 2026): https://github.com/nodejs/node/blob/v24.22.0/doc/api/tls.md#L1786-L1789 - Node.js v24.22.0 `tls.createServer` `rejectUnauthorized` and `requestCert` (checked October 2026): https://github.com/nodejs/node/blob/v24.22.0/doc/api/tls.md#L2250-L2255 - Red Hat product life-cycle API response for Red Hat Enterprise Linux, cited as the API response and not as the rendered errata page (checked November 2026). Sources for this item: https://access.redhat.com/product-life-cycles/api/v1/products?name=Red%20Hat%20Enterprise%20Linux. Read 2026-11-04: each phase's name, start date and end date for versions 10, 9 and 8. The response carries no date or version of its own. - mirrors.kernel.org Fedora releases index (checked November 2026). Sources for this item: https://mirrors.kernel.org/fedora/releases/. Read 2026-11-04: lists `45/`, dated 03-Nov-2026 11:31 in the index. A directory index shows which trees exist, not which releases are supported. - mirrors.kernel.org Fedora development index (checked November 2026). Sources for this item: https://mirrors.kernel.org/fedora/development/. Read 2026-11-04: lists `45/`, dated 30-Oct-2026 10:02 in the index, beside `rawhide/`. A directory index shows which trees exist, not which releases are supported. ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - openssl: OpenSSL documentation (rolling) (unknown) - mkcert: mkcert (unknown) - requests: Requests TLS documentation (v2.32.3) - git: Git ignore documentation (v2.43.0) - curl-source: curl command documentation (curl-8_12_1) - node: Node.js extra CA certificates (v22.19.0) - rfc9525: Service identity (RFC 9525) - debian-ca: Debian ca-certificates (trixie) - ubuntu-ca: Ubuntu ca-certificates (noble) - rhel-ca: RHEL shared system certificates (RHEL 9) - coreutils: GNU coreutils file permissions (v9.7) - node-v24: Node.js API documentation (v24.22.0) - debian-bookworm: Debian ca-certificates (bookworm) - ubuntu-jammy: Ubuntu ca-certificates (jammy) - centos-stream-8: CentOS Stream 8 ca-certificates (ad398898923bddb3953d2aa4c8d9d17c901ef3a4) - centos-stream-10: CentOS Stream 10 ca-certificates (1ab579df1508321342e9ab244fd4ed397ea89b80) - fedora-rawhide: Fedora rawhide ca-certificates (development branch) (725690846ee1ae1cd46e62c3a336c84820390d27) - debian-bullseye: Debian ca-certificates (bullseye) - ubuntu-focal: Ubuntu ca-certificates (focal) - fedora-41: Fedora 41 ca-certificates (ea529bea8d6f00da8edfa62c87d115bc023b4b67) - fedora-42: Fedora 42 ca-certificates (f00d2386f44d6dbcabc18becf7843c789584cfbc) - fedora-43: Fedora 43 ca-certificates (e73db0bb0458f65d530ae5901de2be73f7d7fb80) - ubuntu-resolute: Ubuntu ca-certificates (resolute) - rhel-8: RHEL shared system certificates (RHEL 8) - centos-stream-9: CentOS Stream 9 ca-certificates (848a7831862d78a087c8f5d2284ce603f20ed42f) - fedora-44: Fedora 44 ca-certificates (1ccf8e36dcf991db7ae4a4220c1612d1f43d96ec) - fedora-45: Fedora 45 ca-certificates (branch) (3e42eeb6c71e1dd7c126a90a4811ccbe5f79fed7) - rhel-lifecycle: Red Hat product life-cycle API response (RHEL) (unknown) - fedora-mirror-index: mirrors.kernel.org Fedora directory indexes (unknown) Claims (full source mapping in the JSON block below): - trust-scope (REASONED; openssl, mkcert): Self-signed TLS encrypts transit but needs explicit client trust and triggers browser warnings; the guide prefers publicly trusted certificates for browser users and external parties. - rsa-certificate (REASONED; openssl): OpenSSL 3.0 or later generates an RSA 4096-bit self-signed certificate with SHA-256, 365-day validity, an unencrypted key and the shown subject and extensions. - ec-certificate (REASONED; openssl): Generate a prime256v1 EC key first, then a SHA-256 self-signed certificate with 365-day validity and the shown subject and extensions; the guide describes ECDSA as smaller and faster. - san (REASONED; openssl, rfc9525): SAN must cover every client DNS name and IP (this guide's requirement, based on RFC 9525's matching rules); replace the documentation IP or omit IP entries for name-only access. RFC 9525 requires each DNS label to match except under its wildcard rule; an IP-ID reference identifier MUST exactly match an iPAddress subjectAltName entry. It forbids the subject Common Name as a service identifier. - san-mismatch (REASONED; rfc9525): When no presented identifier matches, RFC 9525 says an automated client SHOULD terminate; a configured exception MUST NOT disable this control by default. A human-facing client SHOULD warn and terminate but MAY offer advanced users an override. RFC 9525's ad hoc pinning provision says such pinning SHOULD NOT restrict future connections to only the pinned certificate. - leaf-constraint (REASONED; openssl): Set basicConstraints=critical,CA:FALSE because the stated stock req -x509 configuration otherwise marks a trusted certificate CA:TRUE, allowing its unencrypted server key to sign for any name. - extension-history (REASONED; openssl): The command-line extension override relies on OpenSSL 3.0 or later; the guide says end-of-life 1.1.1 instead emits a duplicate invalid extension. - unencrypted-key (REASONED; openssl): -noenc leaves the key unencrypted for unattended startup; -nodes is the pre-3.0 spelling, still accepted but deprecated. Protect the key with permissions. - expiry (REASONED; openssl): Track the -days expiry with a calendar or monitoring; self-signed certificates do not renew themselves. - key-permissions (REASONED; openssl, coreutils): This guide advises chmod 600 and service-user ownership for server.key; GNU coreutils v9.7 documents chmod's mode changes and chown's owner/group changes, not that advice. Mode 600's owner read/write and no group/other permissions are this guide's reading of GNU coreutils v9.7's Numeric Modes definition in doc/perm.texi. - key-compromise (REASONED; openssl, git): Never commit private keys; ignore *.key and *.pem before generation in a repository and regenerate keys committed or pasted into chat. - mkcert (REASONED; mkcert): mkcert -install creates and trusts a local CA; mkcert issues certificates for the listed development DNS names and IPs. - mkcert-custody (REASONED; mkcert): Use mkcert only for development; its CA key can sign for any name, must not leave the developer's machine and must not serve real users. - debian-trust (REASONED; mkcert, debian-ca, ubuntu-ca, debian-bookworm, ubuntu-jammy, debian-bullseye, ubuntu-focal, ubuntu-resolute): Install app-internal.crt under /usr/local/share/ca-certificates and run update-ca-certificates on Debian trixie/bookworm/bullseye and Ubuntu resolute/noble/jammy/focal; other Debian/Ubuntu releases remain this guide's assessment without release-specific citations. - rhel-trust (REASONED; mkcert, rhel-ca, centos-stream-8, centos-stream-10, fedora-rawhide, fedora-41, fedora-42, fedora-43, rhel-8, centos-stream-9, fedora-44, fedora-45): Install app-internal.crt under /etc/pki/ca-trust/source/anchors and run update-ca-trust extract: RHEL 8/9 are cited. CentOS Stream 8/9/10 (not RHEL), Fedora 41/42/43/44, Fedora 45 branch (release unestablished) and rawhide (development, not a release) manuals give the steps for simple PEM/DER certificates; other RHEL (including 10), Fedora and CentOS Stream releases remain this guide's assessment. - rhel-lifecycle (REASONED; rhel-lifecycle): Red Hat's product life-cycle API response, read 2026-11-04 and cited as the API response, not the rendered errata page, gives each RHEL 10, 9 and 8 phase with its dates, including RHEL 10 Full support from 2025-05-20 to 2030-05-31; the response carries no date or version of its own. Using the trust-store command on RHEL 10 remains this guide's assessment. - fedora-45-index (REASONED; fedora-mirror-index): On 2026-11-04 the mirrors.kernel.org Fedora releases/ index lists 45/, dated 03-Nov-2026 11:31, and the development/ index also lists 45/, dated 30-Oct-2026 10:02; a directory index shows which trees exist, not which releases are supported, so this guide does not establish whether Fedora 45 has been released. - curl-trust (REASONED; openssl, curl-source): curl --cacert server.crt supplies explicit trust for app.internal. - requests-trust (REASONED; openssl, requests): REQUESTS_CA_BUNDLE supplies the certificate path to Python requests. - node-trust (REASONED; mkcert, node): NODE_EXTRA_CA_CERTS supplies additional client trust to Node.js. - verification-bypasses (REASONED; openssl, requests, curl-source, node-v24): Do not commit curl -k, verify=False, rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0. Node.js v24.22.0 scopes the bypasses to process-environment TLS validation, client-connection server verification or server rejection with requestCert=true; accepting attacker certificates and persistence into production are this guide's assessment. - verify-extensions (DEMONSTRATED; openssl): Inspect x509 output for SAN coverage and CA:FALSE, not its exit status: it can exit 0 with no SAN. - verify-tls (REASONED; openssl): The app.internal:443 s_client check supplies SNI, hostname checking, verification errors and CAfile; a pass prints Verification: OK, Verified peername: app.internal and return code 0, and exits 0. - verify-failures (REASONED; openssl): Without -verify_hostname the name is ignored; without -verify_return_error a verification failure can still complete the handshake and exit 0. Both flags make name or trust failures exit nonzero; wrong name reports code 62. - verify-identity (REASONED; openssl): Use -verify_ip for IP identities; -verify_hostname can fall back to CN without a DNS SAN, even with an IP SAN, so CN-only or IP-only certificates can pass hostname checks. - verify-store (REASONED; openssl, mkcert, curl-source): Explicit -CAfile proves certificate and name, not the system-store installation; test each normally configured client separately, expecting plain curl without --cacert to fail before trust installation and succeed after. - trust-limits (REASONED; openssl, mkcert): Self-signed certificates have no revocation or third-party accountability and need manual trust distribution; move beyond them as the audience grows and retain authentication. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "4ae79b82954e778c01185c8ddd7c0a0a4a4cf17268441a590d5e6a961a71a26d", "components": { "openssl": { "name": "OpenSSL documentation (rolling)", "basis": "unknown", "sources": { "s211b1e6e4aa4": "https://docs.openssl.org/", "s581d7ad32359": "https://docs.openssl.org/master/man1/openssl-s_client/", "se5d70578564e": "https://docs.openssl.org/master/man1/openssl-verification-options/" } }, "mkcert": { "name": "mkcert", "basis": "unknown", "sources": { "s41ae17dba4ce": "https://github.com/FiloSottile/mkcert" } }, "requests": { "name": "Requests TLS documentation", "basis": "v2.32.3", "sources": { "s21c6be6f6df4": "https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L238-L239", "scff02f87cf30": "https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L246-L250" } }, "git": { "name": "Git ignore documentation", "basis": "v2.43.0", "sources": { "sf961a2e5b8e1": "https://github.com/git/git/blob/v2.43.0/Documentation/gitignore.txt#L15-L18" } }, "curl-source": { "name": "curl command documentation", "basis": "curl-8_12_1", "sources": { "s19ed21fc98f8": "https://github.com/curl/curl/blob/curl-8_12_1/docs/cmdline-opts/cacert.md#L21-L24", "s60820cd231e1": "https://github.com/curl/curl/blob/curl-8_12_1/docs/cmdline-opts/insecure.md#L21-L28" } }, "node": { "name": "Node.js extra CA certificates", "basis": "v22.19.0", "sources": { "s402034f5dcea": "https://github.com/nodejs/node/blob/v22.19.0/doc/api/cli.md#L3234-L3238" } }, "rfc9525": { "name": "Service identity", "basis": "RFC 9525", "sources": { "s8dcb5fcc7802": "https://www.rfc-editor.org/rfc/rfc9525.html#section-2", "s4afb46847ce6": "https://www.rfc-editor.org/rfc/rfc9525.html#section-6.2", "s31c00ddbdfa5": "https://www.rfc-editor.org/rfc/rfc9525.html#section-6.3", "s416ff7327342": "https://www.rfc-editor.org/rfc/rfc9525.html#section-6.4", "s8bb6a7aa9400": "https://www.rfc-editor.org/rfc/rfc9525.html#section-6.6" } }, "debian-ca": { "name": "Debian ca-certificates", "basis": "trixie", "sources": { "s99e100121493": "https://manpages.debian.org/trixie/ca-certificates/update-ca-certificates.8.en.html#DESCRIPTION" } }, "ubuntu-ca": { "name": "Ubuntu ca-certificates", "basis": "noble", "sources": { "s4c3728cc6c12": "https://manpages.ubuntu.com/manpages/noble/man8/update-ca-certificates.8.html#description" } }, "rhel-ca": { "name": "RHEL shared system certificates", "basis": "RHEL 9", "sources": { "s876e668e7116": "https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/9/html/securing_networks/using-shared-system-certificates_securing-networks#adding-new-certificates_using-shared-system-certificates" } }, "coreutils": { "name": "GNU coreutils file permissions", "basis": "v9.7", "sources": { "s876896563d01": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L11937-L11942", "s881033f651b3": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L11967-L11968", "s9ab719f6189b": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L11614-L11622", "s0ef955010eb3": "https://github.com/coreutils/coreutils/blob/v9.7/doc/perm.texi#L492-L535" } }, "node-v24": { "name": "Node.js API documentation", "basis": "v24.22.0", "sources": { "sbfee17929443": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/cli.md#L3940-L3944", "s684a71cf8b6e": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/tls.md#L1786-L1789", "s025a5157269c": "https://github.com/nodejs/node/blob/v24.22.0/doc/api/tls.md#L2250-L2255" } }, "debian-bookworm": { "name": "Debian ca-certificates", "basis": "bookworm", "sources": { "sa5e706846110": "https://manpages.debian.org/bookworm/ca-certificates/update-ca-certificates.8.en.html" } }, "ubuntu-jammy": { "name": "Ubuntu ca-certificates", "basis": "jammy", "sources": { "s73a246e6ccfa": "https://manpages.ubuntu.com/manpages/jammy/man8/update-ca-certificates.8.html" } }, "centos-stream-8": { "name": "CentOS Stream 8 ca-certificates", "basis": "ad398898923bddb3953d2aa4c8d9d17c901ef3a4", "sources": { "s448b09073b79": "https://gitlab.com/redhat/centos-stream/rpms/ca-certificates/-/blob/ad398898923bddb3953d2aa4c8d9d17c901ef3a4/update-ca-trust.8.txt#L97-L99" } }, "centos-stream-10": { "name": "CentOS Stream 10 ca-certificates", "basis": "1ab579df1508321342e9ab244fd4ed397ea89b80", "sources": { "s2862e6522c85": "https://gitlab.com/redhat/centos-stream/rpms/ca-certificates/-/blob/1ab579df1508321342e9ab244fd4ed397ea89b80/update-ca-trust.8.txt#L97-L99" } }, "fedora-rawhide": { "name": "Fedora rawhide ca-certificates (development branch)", "basis": "725690846ee1ae1cd46e62c3a336c84820390d27", "sources": { "s70fd69620a8b": "https://src.fedoraproject.org/rpms/ca-certificates/blob/725690846ee1ae1cd46e62c3a336c84820390d27/f/update-ca-trust.8.txt#_97" } }, "debian-bullseye": { "name": "Debian ca-certificates", "basis": "bullseye", "sources": { "s2eedd95a8996": "https://manpages.debian.org/bullseye/ca-certificates/update-ca-certificates.8.en.html" } }, "ubuntu-focal": { "name": "Ubuntu ca-certificates", "basis": "focal", "sources": { "s4ddde36c49dc": "https://manpages.ubuntu.com/manpages/focal/man8/update-ca-certificates.8.html" } }, "fedora-41": { "name": "Fedora 41 ca-certificates", "basis": "ea529bea8d6f00da8edfa62c87d115bc023b4b67", "sources": { "s15529d5b4f48": "https://src.fedoraproject.org/rpms/ca-certificates/blob/ea529bea8d6f00da8edfa62c87d115bc023b4b67/f/update-ca-trust.8.txt#_97" } }, "fedora-42": { "name": "Fedora 42 ca-certificates", "basis": "f00d2386f44d6dbcabc18becf7843c789584cfbc", "sources": { "sedab1dd6dee7": "https://src.fedoraproject.org/rpms/ca-certificates/blob/f00d2386f44d6dbcabc18becf7843c789584cfbc/f/update-ca-trust.8.txt#_97" } }, "fedora-43": { "name": "Fedora 43 ca-certificates", "basis": "e73db0bb0458f65d530ae5901de2be73f7d7fb80", "sources": { "sf4a64ff0a939": "https://src.fedoraproject.org/rpms/ca-certificates/blob/e73db0bb0458f65d530ae5901de2be73f7d7fb80/f/update-ca-trust.8.txt#_97" } }, "ubuntu-resolute": { "name": "Ubuntu ca-certificates", "basis": "resolute", "sources": { "sec84181fd9fa": "https://manpages.ubuntu.com/manpages/resolute/man8/update-ca-certificates.8.html" } }, "rhel-8": { "name": "RHEL shared system certificates", "basis": "RHEL 8", "sources": { "s9f44bb7b2935": "https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html/securing_networks/using-shared-system-certificates_securing-networks#adding-new-certificates_using-shared-system-certificates" } }, "centos-stream-9": { "name": "CentOS Stream 9 ca-certificates", "basis": "848a7831862d78a087c8f5d2284ce603f20ed42f", "sources": { "s6289706376f0": "https://gitlab.com/redhat/centos-stream/rpms/ca-certificates/-/blob/848a7831862d78a087c8f5d2284ce603f20ed42f/update-ca-trust.8.txt#L97-L99" } }, "fedora-44": { "name": "Fedora 44 ca-certificates", "basis": "1ccf8e36dcf991db7ae4a4220c1612d1f43d96ec", "sources": { "sd111db8cd76e": "https://src.fedoraproject.org/rpms/ca-certificates/blob/1ccf8e36dcf991db7ae4a4220c1612d1f43d96ec/f/update-ca-trust.8.txt#_97" } }, "fedora-45": { "name": "Fedora 45 ca-certificates (branch)", "basis": "3e42eeb6c71e1dd7c126a90a4811ccbe5f79fed7", "sources": { "s28e014a1e5b3": "https://src.fedoraproject.org/rpms/ca-certificates/blob/3e42eeb6c71e1dd7c126a90a4811ccbe5f79fed7/f/update-ca-trust.8.txt#_97" } }, "rhel-lifecycle": { "name": "Red Hat product life-cycle API response (RHEL)", "basis": "unknown", "sources": { "sadf9901f3c2e": "https://access.redhat.com/product-life-cycles/api/v1/products?name=Red%20Hat%20Enterprise%20Linux" } }, "fedora-mirror-index": { "name": "mirrors.kernel.org Fedora directory indexes", "basis": "unknown", "sources": { "s3898fc8d477b": "https://mirrors.kernel.org/fedora/releases/", "s2c0d4f8934f4": "https://mirrors.kernel.org/fedora/development/" } } }, "claims": { "trust-scope": {"text": "Self-signed TLS encrypts transit but needs explicit client trust and triggers browser warnings; the guide prefers publicly trusted certificates for browser users and external parties.", "components": ["openssl", "mkcert"], "sources": ["openssl:s211b1e6e4aa4", "mkcert:s41ae17dba4ce"], "status": "REASONED"}, "rsa-certificate": {"text": "OpenSSL 3.0 or later generates an RSA 4096-bit self-signed certificate with SHA-256, 365-day validity, an unencrypted key and the shown subject and extensions.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "REASONED"}, "ec-certificate": {"text": "Generate a prime256v1 EC key first, then a SHA-256 self-signed certificate with 365-day validity and the shown subject and extensions; the guide describes ECDSA as smaller and faster.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "REASONED"}, "san": {"text": "SAN must cover every client DNS name and IP (this guide's requirement, based on RFC 9525's matching rules); replace the documentation IP or omit IP entries for name-only access. RFC 9525 requires each DNS label to match except under its wildcard rule; an IP-ID reference identifier MUST exactly match an iPAddress subjectAltName entry. It forbids the subject Common Name as a service identifier.", "components": ["openssl", "rfc9525"], "sources": ["openssl:se5d70578564e", "rfc9525:s8dcb5fcc7802", "rfc9525:s4afb46847ce6", "rfc9525:s31c00ddbdfa5", "rfc9525:s416ff7327342"], "status": "REASONED"}, "san-mismatch": {"text": "When no presented identifier matches, RFC 9525 says an automated client SHOULD terminate; a configured exception MUST NOT disable this control by default. A human-facing client SHOULD warn and terminate but MAY offer advanced users an override. RFC 9525's ad hoc pinning provision says such pinning SHOULD NOT restrict future connections to only the pinned certificate.", "components": ["rfc9525"], "sources": ["rfc9525:s8bb6a7aa9400"], "status": "REASONED"}, "leaf-constraint": {"text": "Set basicConstraints=critical,CA:FALSE because the stated stock req -x509 configuration otherwise marks a trusted certificate CA:TRUE, allowing its unencrypted server key to sign for any name.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "REASONED"}, "extension-history": {"text": "The command-line extension override relies on OpenSSL 3.0 or later; the guide says end-of-life 1.1.1 instead emits a duplicate invalid extension.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "REASONED"}, "unencrypted-key": {"text": "-noenc leaves the key unencrypted for unattended startup; -nodes is the pre-3.0 spelling, still accepted but deprecated. Protect the key with permissions.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "REASONED"}, "expiry": {"text": "Track the -days expiry with a calendar or monitoring; self-signed certificates do not renew themselves.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "REASONED"}, "key-permissions": {"text": "This guide advises chmod 600 and service-user ownership for server.key; GNU coreutils v9.7 documents chmod's mode changes and chown's owner/group changes, not that advice. Mode 600's owner read/write and no group/other permissions are this guide's reading of GNU coreutils v9.7's Numeric Modes definition in doc/perm.texi.", "components": ["openssl", "coreutils"], "sources": ["openssl:s211b1e6e4aa4", "coreutils:s876896563d01", "coreutils:s881033f651b3", "coreutils:s9ab719f6189b", "coreutils:s0ef955010eb3"], "status": "REASONED"}, "key-compromise": {"text": "Never commit private keys; ignore *.key and *.pem before generation in a repository and regenerate keys committed or pasted into chat.", "components": ["openssl", "git"], "sources": ["openssl:s211b1e6e4aa4", "git:sf961a2e5b8e1"], "status": "REASONED"}, "mkcert": {"text": "mkcert -install creates and trusts a local CA; mkcert issues certificates for the listed development DNS names and IPs.", "components": ["mkcert"], "sources": ["mkcert:s41ae17dba4ce"], "status": "REASONED"}, "mkcert-custody": {"text": "Use mkcert only for development; its CA key can sign for any name, must not leave the developer's machine and must not serve real users.", "components": ["mkcert"], "sources": ["mkcert:s41ae17dba4ce"], "status": "REASONED"}, "debian-trust": {"text": "Install app-internal.crt under /usr/local/share/ca-certificates and run update-ca-certificates on Debian trixie/bookworm/bullseye and Ubuntu resolute/noble/jammy/focal; other Debian/Ubuntu releases remain this guide's assessment without release-specific citations.", "components": ["mkcert", "debian-ca", "ubuntu-ca", "debian-bookworm", "ubuntu-jammy", "debian-bullseye", "ubuntu-focal", "ubuntu-resolute"], "sources": ["mkcert:s41ae17dba4ce", "debian-ca:s99e100121493", "ubuntu-ca:s4c3728cc6c12", "debian-bookworm:sa5e706846110", "ubuntu-jammy:s73a246e6ccfa", "debian-bullseye:s2eedd95a8996", "ubuntu-focal:s4ddde36c49dc", "ubuntu-resolute:sec84181fd9fa"], "status": "REASONED"}, "rhel-trust": {"text": "Install app-internal.crt under /etc/pki/ca-trust/source/anchors and run update-ca-trust extract: RHEL 8/9 are cited. CentOS Stream 8/9/10 (not RHEL), Fedora 41/42/43/44, Fedora 45 branch (release unestablished) and rawhide (development, not a release) manuals give the steps for simple PEM/DER certificates; other RHEL (including 10), Fedora and CentOS Stream releases remain this guide's assessment.", "components": ["mkcert", "rhel-ca", "centos-stream-8", "centos-stream-10", "fedora-rawhide", "fedora-41", "fedora-42", "fedora-43", "rhel-8", "centos-stream-9", "fedora-44", "fedora-45"], "sources": ["mkcert:s41ae17dba4ce", "rhel-ca:s876e668e7116", "centos-stream-8:s448b09073b79", "centos-stream-10:s2862e6522c85", "fedora-rawhide:s70fd69620a8b", "fedora-41:s15529d5b4f48", "fedora-42:sedab1dd6dee7", "fedora-43:sf4a64ff0a939", "rhel-8:s9f44bb7b2935", "centos-stream-9:s6289706376f0", "fedora-44:sd111db8cd76e", "fedora-45:s28e014a1e5b3"], "status": "REASONED"}, "rhel-lifecycle": {"text": "Red Hat's product life-cycle API response, read 2026-11-04 and cited as the API response, not the rendered errata page, gives each RHEL 10, 9 and 8 phase with its dates, including RHEL 10 Full support from 2025-05-20 to 2030-05-31; the response carries no date or version of its own. Using the trust-store command on RHEL 10 remains this guide's assessment.", "components": ["rhel-lifecycle"], "sources": ["rhel-lifecycle:sadf9901f3c2e"], "status": "REASONED"}, "fedora-45-index": {"text": "On 2026-11-04 the mirrors.kernel.org Fedora releases/ index lists 45/, dated 03-Nov-2026 11:31, and the development/ index also lists 45/, dated 30-Oct-2026 10:02; a directory index shows which trees exist, not which releases are supported, so this guide does not establish whether Fedora 45 has been released.", "components": ["fedora-mirror-index"], "sources": ["fedora-mirror-index:s3898fc8d477b", "fedora-mirror-index:s2c0d4f8934f4"], "status": "REASONED"}, "curl-trust": {"text": "curl --cacert server.crt supplies explicit trust for app.internal.", "components": ["openssl", "curl-source"], "sources": ["openssl:se5d70578564e", "curl-source:s19ed21fc98f8"], "status": "REASONED"}, "requests-trust": {"text": "REQUESTS_CA_BUNDLE supplies the certificate path to Python requests.", "components": ["openssl", "requests"], "sources": ["openssl:se5d70578564e", "requests:s21c6be6f6df4"], "status": "REASONED"}, "node-trust": {"text": "NODE_EXTRA_CA_CERTS supplies additional client trust to Node.js.", "components": ["mkcert", "node"], "sources": ["mkcert:s41ae17dba4ce", "node:s402034f5dcea"], "status": "REASONED"}, "verification-bypasses": {"text": "Do not commit curl -k, verify=False, rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0. Node.js v24.22.0 scopes the bypasses to process-environment TLS validation, client-connection server verification or server rejection with requestCert=true; accepting attacker certificates and persistence into production are this guide's assessment.", "components": ["openssl", "requests", "curl-source", "node-v24"], "sources": ["openssl:se5d70578564e", "requests:scff02f87cf30", "curl-source:s60820cd231e1", "node-v24:sbfee17929443", "node-v24:s684a71cf8b6e", "node-v24:s025a5157269c"], "status": "REASONED"}, "verify-extensions": {"text": "Inspect x509 output for SAN coverage and CA:FALSE, not its exit status: it can exit 0 with no SAN.", "components": ["openssl"], "sources": ["openssl:s211b1e6e4aa4"], "status": "DEMONSTRATED", "evidence": "On 2026-10-05 in private /dev/shm scratch, OpenSSL 3.5.5 ran the printed x509 command on three disposable one-day RSA certificates. Missing-SAN and CA:TRUE fixtures each exited 0 but lacked the required SAN or leaf constraint; the fixed fixture also exited 0 and printed DNS:app.internal, IP Address:127.0.0.1 and critical CA:FALSE. Subject and validity dates printed for all three. The recorded x509 runs used an earlier form of the block, the bare x509 command at top level, whose status was x509's own; none was re-run on the current form, which exits 1 when the output lacks a subjectAltName or CA:FALSE, 3 when x509 fails, and 0 otherwise.", "verify": [1]}, "verify-tls": {"text": "The app.internal:443 s_client check supplies SNI, hostname checking, verification errors and CAfile; a pass prints Verification: OK, Verified peername: app.internal and return code 0, and exits 0.", "components": ["openssl"], "sources": ["openssl:s581d7ad32359", "openssl:se5d70578564e"], "status": "REASONED", "verify": [2]}, "verify-failures": {"text": "Without -verify_hostname the name is ignored; without -verify_return_error a verification failure can still complete the handshake and exit 0. Both flags make name or trust failures exit nonzero; wrong name reports code 62.", "components": ["openssl"], "sources": ["openssl:s581d7ad32359", "openssl:se5d70578564e"], "status": "REASONED", "verify": [2]}, "verify-identity": {"text": "Use -verify_ip for IP identities; -verify_hostname can fall back to CN without a DNS SAN, even with an IP SAN, so CN-only or IP-only certificates can pass hostname checks.", "components": ["openssl"], "sources": ["openssl:se5d70578564e"], "status": "REASONED", "verify": [2]}, "verify-store": {"text": "Explicit -CAfile proves certificate and name, not the system-store installation; test each normally configured client separately, expecting plain curl without --cacert to fail before trust installation and succeed after.", "components": ["openssl", "mkcert", "curl-source"], "sources": ["openssl:s581d7ad32359", "mkcert:s41ae17dba4ce", "curl-source:s19ed21fc98f8", "curl-source:s60820cd231e1"], "status": "REASONED", "verify": [2]}, "trust-limits": {"text": "Self-signed certificates have no revocation or third-party accountability and need manual trust distribution; move beyond them as the audience grows and retain authentication.", "components": ["openssl", "mkcert"], "sources": ["openssl:s211b1e6e4aa4", "mkcert:s41ae17dba4ce"], "status": "REASONED"} } } ```` ====================================================================== ==> cloudflare.md ====================================================================== # Cloudflare Tunnel and Zero Trust Access Cloudflare Tunnel publishes a self-hosted app through Cloudflare, and Cloudflare Access decides who may sign in. This guide helps your AI assistant set both up securely, including Access policies, MFA for sensitive apps, and the app bound to loopback on the `cloudflared` host. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/cloudflare.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against cloudflared token-file minimum 2025.4.0, cloudflared service installer 2026.9.3, cloudflared README 2026.9.3, and curl command-line options curl-8_23_0; 1 further source is pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 30 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. This is the recommended path when the host cannot or should not accept inbound connections: home labs, NATed machines, cloud VMs you want to keep closed, and any project without its own TLS setup. `cloudflared` opens an outbound-only tunnel to Cloudflare's edge, the edge serves your hostname over HTTPS with a Cloudflare-managed certificate, and Cloudflare Access places authentication (SSO or emailed one-time PIN) in front of the app without any application changes. No inbound firewall ports are opened at all. ## 1. Prerequisites - A domain added to Cloudflare (the free plan is sufficient), with Cloudflare as its DNS. - A Zero Trust organization on the account. Check current plan limits, including the free plan's, with Cloudflare; the fetched [plans page](https://www.cloudflare.com/plans/zero-trust-services/) did not state a user limit, and this guide does not establish one. - `cloudflared` installed on the host that can reach the service (packages for Linux, macOS, and Windows: https://github.com/cloudflare/cloudflared). ## 2. Create the tunnel (dashboard-managed, recommended) Per the Cloudflare docs as of June 2026 (menu locations change; the sources below are authoritative): 1. In the Cloudflare dashboard go to **Networking > Tunnels** and create a tunnel (connector type `cloudflared`). 2. Copy only the tunnel token from the dashboard into a file readable only by the account running `cloudflared` (mode `0600` on Linux/macOS; a restricted ACL on Windows). With `cloudflared` **2025.4.0 or later**, configure your OS service to run `cloudflared tunnel run --token-file /absolute/path/to/token`, substituting your token-file path ([run parameters](https://developers.cloudflare.com/tunnel/reference/run-parameters/#token-file)). Do not run the dashboard's `cloudflared service install ` command: it exposes the token in the installer's command line. 3. Last, and only after step 4 below: add a route: **Routes > Add route > Published application**, choose the subdomain (for example `app.example.com`), and set the service URL to the local service, for example `http://localhost:3000`. The moment that route exists the app is reachable at `https://app.example.com`, by anyone, with no login in front of it. Access is what adds the login and it is step 4, so doing these steps in the order they are numbered leaves a window in which the application is published and unauthenticated, however short you make it. Close it rather than racing it. Either complete step 4 first and add the route afterwards, which works if Cloudflare accepts an Access application for a hostname you have not routed yet; or leave the local service stopped until Access is in place, which always works. With the route created and the service stopped the hostname still resolves, because the route is a proxied DNS record, and a visitor gets an error page from Cloudflare's edge rather than your application: the point is that nothing of yours is being served, not that the name is invisible. [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md) makes the same point about previews and first deployments. Once routed, the app is served over TLS terminated at Cloudflare's edge. Traffic between `cloudflared` and Cloudflare travels inside the encrypted tunnel; the `http://localhost:3000` hop stays on the host itself. ## 3. CLI alternative (locally-managed tunnel) ```bash cloudflared tunnel login cloudflared tunnel create myapp # `route dns` is what publishes the hostname, so it has the same problem as the dashboard's # routing step: run it after the Access application and policy of step 4 exist, not before. cloudflared tunnel route dns myapp app.example.com ``` `~/.cloudflared/config.yml`: ```yaml tunnel: credentials-file: /home/user/.cloudflared/.json ingress: - hostname: app.example.com service: http://localhost:3000 - service: http_status:404 ``` Run with `cloudflared tunnel run myapp`, or install it as a service with `sudo cloudflared --config /home/user/.cloudflared/config.yml service install` (substitute your own home path) (pass `--config` explicitly: under `sudo`, `$HOME` is `/root`, so a bare `cloudflared service install` misses the config you wrote under your own home). The credentials JSON and the tunnel token are secrets: they let anyone publish services on your hostname, so keep them out of repositories. ## 4. Add authentication with Access A tunnel publishes the app; Access is what makes it authenticated. In the Zero Trust dashboard: 1. Go to the **Access > Applications** section and add a **self-hosted** application for `app.example.com`. 2. Create an **Allow** policy. Sensible starting rules: `Emails` listing specific addresses, or `Emails ending in` your domain. 3. Choose login methods. The built-in **One-time PIN** (a code emailed to the allowed address) works with zero identity-provider setup; connect Google, GitHub, Microsoft Entra ID, or another IdP for SSO and MFA. Every request to the hostname now hits a Cloudflare login page first; only identities matching the policy reach the app. MFA: the emailed one-time PIN proves control of a mailbox only. For anything sensitive, connect an identity provider and enforce MFA there; Access then inherits it. If both One-time PIN and the IdP are enabled on the same application a user may pick either, so OTP becomes a bypass of the IdP's MFA: remove One-time PIN from a sensitive application's login methods, leaving only the MFA-enforcing IdP, or enforce Access's own MFA requirement across the allowed methods. Broader options: [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). For APIs and machine clients, create a **service token** in the Zero Trust dashboard (Access service authentication section), add a **Service Auth** policy to the application, and send the token with each request. Paste the client secret alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( # Use Bash in a fresh, trusted shell. Replace the URL and client ID inside # the quotes; paste only the secret at the hidden prompt, never into the command. # The prompted secret reaches curl on stdin, outside argv and shell history; # stdin does not hide it from set -x tracing (the block turns tracing off), # and it remains accessible to the account owner or root. trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n HDR && unset -v HDR; } 2>/dev/null || { echo 'cannot clear HDR in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- PASTE_WHOLE_BLOCK 'https://app.example.com/api' 'REPLACE_WITH_CLIENT_ID' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs a URL and client ID; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*example.com*|*example.net*|*example.org*|*[[:cntrl:]]*) echo 'substitute your own application URL inside the quotes; not probing'; exit 2 ;; https://*) ;; *) echo 'use an https:// URL; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'supply the client ID without control characters; not probing'; exit 2 ;; esac IFS= read -r -s -p 'Client secret (input hidden): ' HDR || { echo 'secret input failed; not probing'; exit 2; } printf '\n' case "$HDR" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'supply the secret without control characters; not probing'; exit 2 ;; esac HDR="CF-Access-Client-Secret: $HDR" printf '%s\n' "CF-Access-Client-Id: $2" "$HDR" | curl -q -g -sS --connect-timeout 5 --max-time 20 --header @- "$1" || exit 2 unset -v HDR ) ``` ## 5. Close the side doors - Bind the application to `127.0.0.1` so the tunnel is the only path to it. If the app also listens publicly, Access is decorative. - Defense in depth: where the app can read a header, validate the `Cf-Access-Jwt-Assertion` that Access sends against your team's certificates (checking the `aud` tag and the issuer), per [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md) section 5. It is the control that still holds when an origin is left reachable, an SSRF reaches `localhost`, or a second route is added to the same tunnel, which is unprotected unless an Access application and policy already cover its hostname and path (a per-hostname application, or a wildcard one). - Do not use quick tunnels (`cloudflared tunnel --url http://localhost:3000`, the random `trycloudflare.com` URLs) for anything real: without `--allowed-mail`, anyone with the URL can access the service. Cloudflare describes these URLs as temporary and for testing and development, not production; the URL stops working when `cloudflared` stops. - If the origin must sit on a different machine from `cloudflared`, TLS on that hop (`service: https://...`; see [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) only encrypts it, it does not restrict who may connect, so the origin machine must also accept connections *only* from the `cloudflared` host (a host or cloud firewall source-restriction), or the app is reachable directly with no Access, just as in the next bullet. Better, run `cloudflared` on the origin machine itself and bind the app to loopback there. For a self-signed origin certificate, configure its issuing CA with `originRequest.caPool`, keeping certificate and hostname verification, rather than `originRequest.noTLSVerify: true`, which discards the server authentication the TLS hop was meant to add. - Related but distinct: for a directly-exposed origin behind Cloudflare's proxy (no tunnel), Full (strict) with a Cloudflare **origin certificate** encrypts and authenticates the origin *to* Cloudflare. Full (strict) with that origin certificate still does not stop a client that discovers the IP of that directly-exposed origin (through DNS history, certificate-transparency scans, or a grey-clouded or MX record on the same host) from connecting straight to the origin and bypassing Access. Lock the origin down with **Authenticated Origin Pulls** and a firewall: enabling AOP in the dashboard alone enforces nothing at the origin, so the origin must require the client certificate (for nginx, `ssl_verify_client on` with `ssl_client_certificate` set to the CA the presented client certificate chains to). With **global** AOP that CA is Cloudflare's shared origin-pull CA, which only proves the request came from Cloudflare's network (any account); prefer a **zone-level** certificate you upload, trust that certificate's issuing CA at the origin and remove the shared-CA trust, so only your zone is accepted. Because AOP is mutual TLS it cannot authenticate plaintext HTTP, so close origin port 80 (or make it redirect only to the canonical HTTPS host, never serve the app) and restrict inbound 443 to Cloudflare's published IP ranges (https://www.cloudflare.com/ips/), per [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md). Origin certificates are trusted only by Cloudflare's edge, never by browsers directly. ## 6. Verify - REASONED: private-browsing Access check; local marking review (October 2026, Bash 5.3.9) did not run a browser comparison. A live Cloudflare origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a Cloudflare account and Access application on a proxied hostname were not available to this marking review. For the visit to `https://REPLACE_WITH_APP_HOSTNAME/`, an exposed route is expected to serve app content anonymously; a fixed route is expected to show Access login, then serve the app after an allowed sign-in, per the guide's cited Cloudflare Zero Trust documentation and Cloudflare Access One-time PIN (login methods coexist with an IdP). With your Access-protected application's host name in place of `REPLACE_WITH_APP_HOSTNAME`, a private-browsing visit to `https://REPLACE_WITH_APP_HOSTNAME/` shows the Access login page, not the app. - REASONED: anonymous HEAD check; local marking review (October 2026, Bash 5.3.9) ran the stated curl command, in an earlier form that named app.example.com, against a curl stub, which printed 'stub curl args=-q -sI https://app.example.com/' (exit 0), not an HTTP response. The command now carries a `REPLACE_WITH_APP_HOSTNAME` placeholder in place of that host, and the changed command has not been run. A live Cloudflare origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a Cloudflare account and Access application on a proxied hostname were not available to this marking review. An exposed route is expected to return the application's own status and headers, potentially HTTP 200; a fixed anonymous route is expected to return 302 with a team cloudflareaccess.com login location, while an allowed session reaches the app, per the guide's cited Cloudflare Zero Trust documentation and Cloudflare Access One-time PIN (login methods coexist with an IdP). HEAD prints status and headers, not content. With your Access-protected application's host name in place of `REPLACE_WITH_APP_HOSTNAME`, `curl -q -sI https://REPLACE_WITH_APP_HOSTNAME/` returns a 302 whose `location` is your team's `*.cloudflareaccess.com` login, never a 200 or your application's own content. - REASONED: service-token GET and anonymous comparison; local marking review (October 2026, Bash 5.3.9) passed the following block through bash -n and ShellCheck 0.11.0 with no output. Five of the fourteen Bash fixtures with a curl stub printed: placeholder and empty URL, 'substitute your own application URL inside the quotes; not probing' (each exit 2); wrong sentinel, 'paste the whole block, including its set -- line; not probing' (exit 2); and one and three values, 'the set -- line needs a URL and client ID; not probing' (each exit 2). Five more of the fourteen fixtures printed: HTTP URL, 'use an https:// URL; not probing' (exit 2); placeholder ID, 'supply the client ID without control characters; not probing' (exit 2); EOF secret input, 'secret input failed; not probing' (exit 2); and empty and placeholder secrets, 'supply the secret without control characters; not probing' (each exit 2). The remaining four fixtures printed: readonly HDR, 'cannot clear HDR in this shell; not probing' (exit 2); readonly IFS, 'a readonly IFS is set in this shell; not probing' (exit 2); valid input, 'stub curl' (exit 0); and curl stub exit 7, 'stub curl' (block exit 2). Those checks and fixtures ran an earlier form of the block, which ended on `unset -v HDR` and so exited 0 after any completed request. In the current form a failed secret read or a failed curl exits 3, and the block exits 0 only when curl prints `http=200 location=`, so the valid-input fixture's 'stub curl' output would now exit 3. On 2026-10-31 the current form passed bash -n and ran against stubs with no network; ShellCheck 0.11.0 runs on it in the repository's gate suite, not in that review. No HTTP status was observed. A live Cloudflare origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a provisioned Cloudflare account, Access application and service token were not available to this marking review. On a route known to return application HTTP 200, exposed anonymous and token requests are expected to reach the app, per the guide's cited Cloudflare Access service tokens (request headers and Service Auth policy) and curl manual (stdin headers, output, redirect reporting and write-out variables). Fixed anonymous requests on that route are expected to show Access login or denial, while the valid token is expected to yield http=200 location= on that route, per the guide's cited Cloudflare Access service tokens (request headers and Service Auth policy) and curl manual (stdin headers, output, redirect reporting and write-out variables). The block discards the body, so the printed status cannot identify content. With a service token, a GET returns your application content, not the Access login. Use a route known to serve your application's content with HTTP 200. Expect `http=200 location=`; an Access login redirect, denial or transport error is not a pass. Compare with the anonymous check above: an exposed app serves the route anonymously too, so token success alone does not prove Access is enforced. REASONED: the usage and secret-handling notes for the service-token block below, per the guide's cited Cloudflare Access service tokens and curl manual entries and the block's own text, with the paste-alone warning per CONTRIBUTING rule 7's `read` exception. No Cloudflare account, Access application or service token is available, so no status or redirect destination was observed. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 without diagnostics. In a scratch-local review, the block ran from a file with a stub curl, no network and a pseudo-terminal: unchanged placeholders printed 'substitute your own application URL inside the quotes; not probing' (exit 2); substituted dummy inputs printed 'stub curl' (exit 0); and an empty secret printed 'supply the secret without control characters; not probing' (exit 2). The same scratch-local review also ran the block from a file with the same stub curl, no network and a pseudo-terminal: readonly HDR printed 'cannot clear HDR in this shell; not probing' (exit 2), and readonly IFS printed 'a readonly IFS is set in this shell; not probing' (exit 2). Those runs used the earlier form described in the list item above; the current form exits 0 only on `http=200 location=`. The stub output is not a Cloudflare response; the expected live comparison remains anonymous denial or Access login versus application content with a valid service token. Use Bash in a fresh, trusted shell and replace the URL and client ID inside the quotes. Paste the secret alone at the hidden prompt, never into the command: a multi-line paste leaves every line after the first for your shell to run. The block discards the body and prints only the status and redirect destination; a 200 alone cannot identify the content. The secret stays in an unexported subshell variable and reaches curl on stdin, which keeps it out of argv and shell history but does not hide it from `set -x` tracing (the block turns tracing off) or the account owner or root. ```bash # No live Cloudflare account is available; not demonstrated against exposed and protected apps. The cited Cloudflare service-token documentation specifies the two headers and Service Auth policy. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n HDR && unset -v HDR; } 2>/dev/null || { echo 'cannot clear HDR in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_APP_URL' 'REPLACE_WITH_CLIENT_ID' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs a URL and client ID; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*example.com*|*example.net*|*example.org*|*[[:cntrl:]]*) echo 'substitute your own application URL inside the quotes; not probing'; exit 2 ;; https://*) ;; *) echo 'use an https:// URL; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'supply the client ID without control characters; not probing'; exit 2 ;; esac IFS= read -r -s -p 'Client secret (input hidden): ' HDR || { echo 'secret input failed; not probing'; exit 3; } printf '\n' case "$HDR" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'supply the secret without control characters; not probing'; exit 2 ;; esac HDR="CF-Access-Client-Secret: $HDR" set -- "$1" "$2" "$(if printf '%s\n' "CF-Access-Client-Id: $2" "$HDR" | \ curl -q -g -sS --connect-timeout 5 --max-time 20 --output /dev/null \ --write-out 'http=%{http_code} location=%{redirect_url}\n' --header @- "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" unset -v HDR set -- "${3%%$'\n'*}" "${3##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$1" case "$2:$1" in '0:http=200 location=') set -- 0 ;; 0:*) echo "not http=200 location=: an Access login redirect or denial is not a pass; inconclusive"; set -- 3 ;; *) echo "curl exit $2: a transport error is not a pass; inconclusive"; set -- 3 ;; esac echo "block exit $1; whether the route serves your application's content, and the anonymous comparison above, are not classified, so judge them yourself" exit "$1" ) ``` - REASONED: binding and effective policy for each topology; local marking review (October 2026, Bash 5.3.9) ran ss -tlnp with an ss stub, which printed 'stub ss args=-tlnp' (exit 0), not host socket state. A live origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no deployed origin with a firewall and container-publication inventory, remote cloudflared host, AOP client certificate or external probe host was available to this marking review. Exposed same-host binding is expected to show a wildcard (0.0.0.0, [::] or *) or non-loopback address and answer directly, while fixed binding is expected to show 127.0.0.1 and answer only locally. Exposed remote-origin policy is expected to admit callers besides the cloudflared host; fixed policy is expected to admit that host and deny or filter others. Exposed no-tunnel 443 is expected to admit non-Cloudflare callers or lack enforced client certificates. Fixed no-tunnel 443 policy is expected to admit Cloudflare IP ranges with AOP and deny or filter others. Both no-tunnel 443 expectations follow the guide's cited Create a remotely-managed tunnel, Authenticated Origin Pulls (the origin must validate the client certificate; shared vs zone-level cert), and Cloudflare IP ranges (origin firewall allowlist). `ss -tlnp` shows the app on `127.0.0.1` only for a same-host tunnel. Do not settle for "no inbound rule added": confirm the port's effective inbound policy, since a default-accept policy, a pre-existing broad allow, or a container publishing through its own NAT can leave it open. The policy differs by topology: a same-host tunnel app is on loopback, a remote tunnel origin accepts only the `cloudflared` host, and a no-tunnel HTTPS origin accepts only Cloudflare's IP ranges (plus AOP). - REASONED: external direct-origin probe on port 3000; local marking review (October 2026, Bash 5.3.9) passed the following block through bash -n and ShellCheck 0.11.0 without output. With a curl stub, placeholder and empty values each printed "substitute your origin's public IP on the set -- line above; not probing" (exit 2), and zero and two values each printed "the set -- line needs exactly 1 value; not probing" (exit 2). The same curl stub fixtures included a wrong marker, which printed "paste the whole block, including its set -- line; not probing" (exit 2), and a valid fixture, which printed 'stub curl args=-q -g -s -o /dev/null --noproxy * --connect-timeout 5 --max-time 20 -w http=%{http_code} exit=%{exitcode} err=%{errormsg}\n http://203.0.113.10:3000/' (exit 0). A live origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no external probe host, deployed origin or allowed-vantage tunnel host control was available to this marking review. On 2026-10-17 the HTTP block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`; a curl stub simulated transfer exits 0, 7 and 28, while its placeholder guard exited 2. The three remote HTTPS allowed-vantage controls ran with a curl stub; with distinct certificate and HTTP Host names, the differing-name form sent `-H` and `--connect-to` separately. As this guide's assessment, an exposed origin is expected to give an HTTP reply directly; the fixed outcome requires an explicit refusal from the independently confirmed origin address, a successful allowed-vantage control and the effective inbound-policy check; no reply is inconclusive, per the guide's cited Create a remotely-managed tunnel. From another host, confirm the origin is not reachable directly on the app's own port (3000 over HTTP in this same-host example; substitute yours). For a remote origin with `service: https://...`, use the HTTPS block below on that service URL's port. As this guide's assessment, a refusal supports isolation only after independently confirming that the public address being probed belongs to this origin; neither allowed-vantage control establishes that address mapping. On the tunnel host, the allowed-vantage control `curl -q -g -s -o /dev/null --noproxy '*' -w '%{http_code}\n' http://127.0.0.1:3000/` must return the app's status (not 000). A remote origin needs the matching HTTPS control: run that control from the `cloudflared` host and require the app's status (not 000), using `curl -q -g -s -o /dev/null --noproxy '*' -H 'Host: REPLACE_WITH_EFFECTIVE_HTTP_HOST' -w '%{http_code}\n' 'REPLACE_WITH_SERVICE_URL'` with system trust. The remote-origin control from the `cloudflared` host must instead be `curl -q -g -s -o /dev/null --noproxy '*' -H 'Host: REPLACE_WITH_EFFECTIVE_HTTP_HOST' --cacert 'REPLACE_WITH_COMBINED_CA_BUNDLE' -w '%{http_code}\n' 'REPLACE_WITH_SERVICE_URL'` when `caPool` is set, with the same required app status (not 000). The combined bundle must contain the default trusted roots used by `cloudflared` and the configured `caPool` certificates. When the effective certificate name is not the service URL host, use `curl -q -g -s -o /dev/null --noproxy '*' -H 'Host: REPLACE_WITH_EFFECTIVE_HTTP_HOST' --cacert 'REPLACE_WITH_COMBINED_CA_BUNDLE' --connect-to 'REPLACE_WITH_CERT_HOSTNAME:REPLACE_WITH_HTTPS_PORT:REPLACE_WITH_SERVICE_HOST:REPLACE_WITH_HTTPS_PORT' -w '%{http_code}\n' 'https://REPLACE_WITH_CERT_HOSTNAME:REPLACE_WITH_HTTPS_PORT/'`. The differing-name form may omit `--cacert` only when system trust reproduces tunnel trust. Choose the effective HTTP Host separately: use the configured HTTP Host Header when set, otherwise the request Host sent to the origin. Substitute it in the `-H` argument, including when it differs from the certificate name. Apply the `matchSNItoHost` override described below when choosing the certificate name. The pinned HTTP Host Header setting and curl `--header` option support this request header. As this guide's assessment, this control shows origin availability, not that the public address in the external probe belongs to it. REASONED: following block; direct-origin isolation follows the cited Create a remotely-managed tunnel. The authoring environment cannot provision a Cloudflare origin and external probe host; no live outcome is recorded. Expected refusal, exposure and inconclusive outcomes are stated below. The stub runs recorded in the list item above used an earlier form of the following block, whose exit status was curl's own. The current form exits 1 for any HTTP reply, 0 when curl cannot connect (exit 7), leaving err for you to read, and 3 for any other curl failure; on 2026-10-31 it passed bash -n and ran against stubs with no network; ShellCheck 0.11.0 runs on it in the repository's gate suite, not in that review. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_ORIGIN_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_ORIGIN_IP*|"") echo "substitute your origin's public IP on the set -- line above; not probing"; exit 2 ;; *) set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:3000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; # the app's own port esac set -- "${2%%$'\n'*}" "${2##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$1" case "$1" in 'http=000 '*) ;; # no HTTP code, so curl's exit status below decides 'http='[0123456789][0123456789][0123456789]' '*) set -- "$1" 0 ;; # an HTTP code is a reply even if the transfer then failed esac case "$2" in 0) echo "FINDING: an HTTP reply directly on port 3000; from your confirmed origin address that means Access is bypassed, and from an unconfirmed one, confirm the mapping first"; set -- 1 ;; 7) echo "curl could not connect: read err above; a refusal from your confirmed origin address supports the fixed state only with the effective inbound-policy check and a passing allowed-vantage control"; set -- 0 ;; *) echo "curl exit $2: a timeout, no reply, resolver failure or error whose destination is uncertain is inconclusive"; set -- 3 ;; esac echo "block exit $1; a failure to connect is not classified, so judge it yourself" exit "$1" ) ``` REASONED: the remote-origin HTTPS direct probe in the following block, whose declaration is the paragraph after that block; on 2026-10-17 the changed block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`. A curl stub on 2026-10-17 exercised the placeholders and invalid HTTP Host and port guards (exit 2), including ports 0 and above 65535 (exit 2), ports 443 and 65535 (exit 0) and bracketed IPv6 (exit 0), and simulated transfer exits 0, 7, 28 and 60 under both SYSTEM_TRUST and combined-CA branches. With distinct certificate and HTTP Host names, the stub received `--connect-to` for the certificate name and `-H` for the HTTP Host in both branches; no live response was observed. No live remote origin or external probe host was available. Use the `https://` service URL port and its certificate hostname. Set the third value to the effective HTTP Host sent to the origin: the configured HTTP Host Header when set, otherwise the request Host. Guard and send it separately from the certificate name, including when the values differ. Set the fourth value to `SYSTEM_TRUST` when `originRequest.caPool` is unset and the issuing CA is trusted by the probe host; otherwise supply a PEM bundle containing both the default trusted roots used by `cloudflared` and the certificates in `originRequest.caPool`. Build that bundle from the relevant default root store and the configured `caPool` PEM; a `caPool` file alone does not reproduce the combined trust pool. If the effective trust roots cannot be reproduced, treat a TLS trust failure as inconclusive. Use the service URL host as the certificate hostname unless `originServerName` sets a different one. When `matchSNItoHost` is true, it overrides `originServerName`: use the request `Host` value sent to the origin, or the configured HTTP Host Header value, as the effective SNI/certificate name for that request. As this guide's assessment, if this value varies or cannot be established, the hostname probe is inconclusive. As this guide's assessment, for an IPv6 origin address, put brackets around the address inside the first quoted value for `--connect-to`. The pinned Cloudflare origin parameters define that name, the SNI override, the HTTP Host Header and the additive CA pool; the pinned curl `--cacert`, `--connect-to` and `--header` entries define the trust file, destination override and HTTP header. As this guide's assessment, a TLS trust or hostname error is inconclusive, especially when the combined trust pool cannot be reproduced; after confirming the address mapping, an HTTP reply directly on the origin port shows exposure; a refusal supports isolation only with the effective inbound policy check and a successful allowed-vantage control. As this guide's assessment, the allowed-vantage control above shows that the origin answers but does not confirm the probed public address. Those 2026-10-17 runs used an earlier form whose exit status was curl's own. The current form also refuses, with status 2, a CA bundle path that is not a readable file. It exits 1 for any HTTP reply, 0 when curl cannot connect (exit 7), leaving err for you to read, and 3 for any other curl failure, including a TLS trust or hostname error; on 2026-10-31 it passed bash -n and ran against stubs with no network; ShellCheck 0.11.0 runs on it in the repository's gate suite, not in that review. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_ORIGIN_IP' 'REPLACE_WITH_CERT_HOSTNAME' 'REPLACE_WITH_EFFECTIVE_HTTP_HOST' 'REPLACE_WITH_COMBINED_CA_BUNDLE_OR_SYSTEM_TRUST' 'REPLACE_WITH_ORIGIN_HTTPS_PORT' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 5 ] || { echo 'the set -- line needs an origin IP, certificate hostname, HTTP Host, CA choice and port; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|-*|*[[:space:]]*) echo 'substitute your origin public IP; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|-*|*[[:space:]]*) echo 'substitute the certificate hostname; not probing'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*|-*|*[[:space:][:cntrl:]]*) echo 'substitute the effective HTTP Host; not probing'; exit 2 ;; esac case "$4" in ''|*REPLACE_WITH_*|-*|*[[:space:]]*) echo 'supply SYSTEM_TRUST or the combined CA bundle path; not probing'; exit 2 ;; esac case "$5" in ''|*[!0123456789]*) echo 'supply an HTTPS port from 1 to 65535; not probing'; exit 2 ;; esac if [ "${#5}" -gt 5 ] || [ "$5" -lt 1 ] || [ "$5" -gt 65535 ]; then echo 'supply an HTTPS port from 1 to 65535; not probing'; exit 2 fi if [ "$4" != SYSTEM_TRUST ] && { [ ! -f "$4" ] || [ ! -r "$4" ]; }; then echo 'the combined CA bundle path is not a readable file here; not probing'; exit 2 fi if [ "$4" = SYSTEM_TRUST ]; then set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' -H "Host: $3" --connect-to "$2:$5:$1:$5" "https://$2:$5/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" else set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$4" -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ -H "Host: $3" --connect-to "$2:$5:$1:$5" "https://$2:$5/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" fi set -- "${6%%$'\n'*}" "${6##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$1" case "$1" in 'http=000 '*) ;; # no HTTP code, so curl's exit status below decides 'http='[0123456789][0123456789][0123456789]' '*) set -- "$1" 0 ;; # an HTTP code is a reply even if the transfer then failed esac case "$2" in 0) echo "FINDING: an HTTP reply directly on the origin port; once the address mapping is confirmed, that shows exposure"; set -- 1 ;; 7) echo "curl could not connect: read err above; a refusal supports isolation only with the effective inbound-policy check and a passing allowed-vantage control"; set -- 0 ;; *) echo "curl exit $2: a TLS trust or hostname error, a timeout or another failure is inconclusive"; set -- 3 ;; esac echo "block exit $1; a failure to connect is not classified, so judge it yourself" exit "$1" ) ``` REASONED: external direct-origin outcome; local marking review (October 2026, Bash 5.3.9) used the curl stub above, not a live origin. A live origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; an external probe host, deployed origin and tunnel-host control were unavailable. As this guide's assessment, read err, not the number: `err` text reporting a refused connection to your confirmed origin address supports the fixed state only with the effective inbound-policy check and a successful allowed-vantage control. The successful allowed-vantage control, as this guide's assessment, is the tunnel-host loopback control `curl -q -g -s -o /dev/null --noproxy '*' -w '%{http_code}\n' http://127.0.0.1:3000/` returning the app's status (not 000). A remote origin's successful allowed-vantage control, as this guide's assessment, is instead one of the matching HTTPS controls above from the `cloudflared` host returning the app's status (not 000), using the combined CA bundle when `originRequest.caPool` is set. This guide's assessment is that any HTTP status (http= not 000) from that confirmed origin address means something answered directly on the app's port and Access is bypassed. As this guide's assessment, an HTTP reply from an unconfirmed address means something answers there; confirm the mapping before calling it this origin's exposure, and never treat that reply as fixed. As this guide's assessment, a timeout, no reply, resolver failure, TLS trust or hostname failure, or error whose destination is uncertain is inconclusive. As this guide's assessment, neither allowed-vantage control proves that the probed public address belongs to this origin; confirm it independently before calling a refusal fixed. Use curl 7.75.0 or newer for `%{exitcode}` and `%{errormsg}` write-out. These expectations follow the cited Create a remotely-managed tunnel; the cited curl manual (stdin headers, output, redirect reporting and write-out variables) explains the printed transfer status and error. - REASONED: no-tunnel AOP origin and its port-80 sentence; local marking review (October 2026, Bash 5.3.9) reviewed the cited Authenticated Origin Pulls (the origin must validate the client certificate; shared vs zone-level cert) and Cloudflare IP ranges (origin firewall allowlist) guidance. The same local marking review (October 2026, Bash 5.3.9) ran the port-80 block below through bash -n, ShellCheck 0.11.0 and curl-stub guard fixtures, not against a live origin. Those runs used an earlier form of the port-80 block, whose exit status was curl's own; the paragraph before that block gives the current statuses. A live origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; an external probe host, deployed no-tunnel origin, Origin CA and allowed-vantage service-token control were unavailable. The no-tunnel AOP origin needs a different check, because the direct request goes to the origin's HTTPS port and its Origin CA certificate is not trusted by an ordinary client. Independently confirm that the public address supplied to `--resolve` belongs to this origin; the service-token GET shows that the app answers through Cloudflare but does not establish that address mapping. Put your app's host name in place of both `REPLACE_WITH_APP_HOSTNAME` placeholders in the following command. Trust the origin's server CA so a server-certificate error does not mask the result, and read the response: `curl -q -g -s --noproxy '*' --cacert REPLACE_WITH_ORIGIN_SERVER_CA --resolve REPLACE_WITH_APP_HOSTNAME:443:REPLACE_WITH_YOUR_ORIGIN_IP -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' https://REPLACE_WITH_APP_HOSTNAME/`. As this guide's assessment, AOP is working when the independently confirmed origin address refuses the missing client certificate (a TLS alert, or nginx's `400 No required SSL certificate was sent`); a failure is your application's content coming back from that confirmed address. As this guide's assessment, a refusal or reply from an unconfirmed address shows only that something answers there; confirm the mapping before calling this origin fixed or exposed. Distinguish the layers, since all three withhold content for different reasons: a firewall drop is inconclusive in this probe even when the service-token GET answers; confirm isolation with the effective inbound-policy check above. As this guide's assessment, a client-certificate refusal from the confirmed origin address indicates AOP, and a `401`/`403` from that address indicates the app's own authorization. These outcome interpretations are this guide's assessment, per the cited Authenticated Origin Pulls (the origin must validate the client certificate; shared vs zone-level cert), Cloudflare Authenticated Origin Pulls and tunnel incompatibility, and Cloudflare Origin CA certificates and direct browser trust entries. This guide's assessment of these outcomes also rests on the cited curl manual (stdin headers, output, redirect reporting and write-out variables), curl noproxy and curl globoff entries. Confirm separately that port 80 does not serve the app. REASONED: no-tunnel port-80 policy; AOP authenticates HTTPS client certificates, so an HTTP origin route must not serve app content, per the cited Authenticated Origin Pulls (the origin must validate the client certificate; shared vs zone-level cert). Check a known app route from an external host with the following block, replacing the origin IP, app hostname and route inside the quotes. On the same route, the service-token GET in the service-token block above is the allowed-vantage control and must return its success status. As this guide's assessment, a fixed origin gives an explicit refusal or only a redirect to the canonical HTTPS host from the independently confirmed origin address (the listed Sources do not prescribe a code), without app content; either fixed outcome requires that control to answer. As this guide's assessment, a timeout or no reply alone is inconclusive; app route content served over HTTP from that confirmed origin address is the exposed outcome. As this guide's assessment, app content from an unconfirmed address shows only that something answers there; confirm the mapping before calling this origin exposed. As this guide's assessment, the service-token GET proves that the application route answers through Cloudflare, but does not confirm that the public address probed on port 80 belongs to this origin; check that mapping independently before treating a refusal as fixed. Use curl 7.75.0 or newer for `%{exitcode}` and `%{errormsg}` write-out; the cited curl manual (stdin headers, output, redirect reporting and write-out variables) supports the printed status and error. REASONED: following block; port-80 HTTP virtual-host route probe; local marking review (October 2026, Bash 5.3.9) passed bash -n and ShellCheck 0.11.0 without output. With a curl stub, a wrong marker printed "paste the whole block, including its set -- line; not probing" (exit 2); zero, two and four values each printed "the set -- line needs an origin IP, app hostname and app route; not probing" (exit 2); address placeholder, empty, option-like and spaced values each printed "substitute your origin's public IP on the set -- line above; not probing" (exit 2). The same curl stub fixtures showed the default placeholders and hostname placeholder, empty, option-like and spaced values each printing "substitute your app hostname on the set -- line above; not probing" (exit 2), and route placeholder, empty, option-like and spaced values each printing "substitute your app route on the set -- line above; not probing" (exit 2). The same curl stub fixtures also included a route without a leading slash, which printed "use a route starting with /; not probing" (exit 2). A valid curl stub fixture printed 'stub curl args=-q -g -s -D - -o - --noproxy * --connect-timeout 5 --max-time 20 -w http=%{http_code} exit=%{exitcode} err=%{errormsg} location=%{redirect_url}\n --resolve app.example.com:80:203.0.113.10 http://app.example.com/health' (exit 0). A live origin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; an external probe host, deployed no-tunnel origin, Origin CA and allowed-vantage service-token control were unavailable. On 2026-10-17 this unchanged port-80 block passed `bash -n` and ShellCheck with `-s bash`; a curl stub produced HTTP 200 (exit 0), canonical HTTPS redirect (exit 0), explicit refusal (exit 7) and timeout (exit 28) outputs, while its placeholder guard exited 2. No live refusal, redirect or app response was observed; expected outcomes are stated above, per the cited Authenticated Origin Pulls (the origin must validate the client certificate; shared vs zone-level cert) and curl manual (stdin headers, output, redirect reporting and write-out variables). Those runs used an earlier form whose exit status was curl's own. The current form exits 3 for a timeout, no reply or any other curl failure except a failure to connect (exit 7), and otherwise 0, leaving the response or the refusal for you to judge; on 2026-10-31 it passed bash -n and ran against stubs with no network; ShellCheck 0.11.0 runs on it in the repository's gate suite, not in that review. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_ORIGIN_IP' 'REPLACE_WITH_APP_HOSTNAME' 'REPLACE_WITH_APP_ROUTE' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs an origin IP, app hostname and app route; not probing"; exit 2; } case "$2" in *REPLACE_WITH_*|""|-*|*[[:space:]]*) echo "substitute your app hostname on the set -- line above; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|""|-*|*[[:space:]]*) echo "substitute your app route on the set -- line above; not probing"; exit 2 ;; /*) ;; *) echo "use a route starting with /; not probing"; exit 2 ;; esac case "$1" in *REPLACE_WITH_*|*YOUR_ORIGIN_IP*|""|-*|*[[:space:]]*) echo "substitute your origin's public IP on the set -- line above; not probing"; exit 2 ;; *) set -- "$1" "$2" "$3" "$(if curl -q -g -s -D - -o - --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg} location=%{redirect_url}\n' --resolve "$2:80:$1" "http://$2$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; esac set -- "${4%$'\n'*}" "${4##*$'\n'}" # the headers, body and write-out line, then curl's exit status printf '%s' "$1" case "$2" in 0) echo "the route answered: read the status, location and body above; a redirect to the canonical HTTPS host without app content supports the fixed state, and app content from your confirmed origin address is the exposed outcome"; set -- 0 ;; 7) echo "curl could not connect: read err above; an explicit refusal supports the fixed state only while the service-token GET on this route answers"; set -- 0 ;; *) echo "curl exit $2: a timeout or no reply alone is inconclusive"; set -- 3 ;; esac echo "block exit $1; the response and a failure to connect are not classified, so judge them yourself" exit "$1" ) ``` ## Sources (checked September 2026) - Cloudflare Zero Trust documentation: https://developers.cloudflare.com/cloudflare-one/ - Create a remotely-managed tunnel: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/ - cloudflared releases: https://github.com/cloudflare/cloudflared - Cloudflare Zero Trust plans (rolling; fetched page did not state a user limit; check current plan limits, including the free plan's, with Cloudflare, checked October 2026): https://www.cloudflare.com/plans/zero-trust-services/ - Cloudflare Quick Tunnels (temporary URL, access by URL without allowed-mail, email PIN option and testing/development use; pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, lines 13-42, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/tunnel/get-started/quick-tunnels/index.mdx#L13-L42 - cloudflared TryCloudflare testing note (pinned tag 2026.9.3, lines 50-52, checked October 2026): https://github.com/cloudflare/cloudflared/blob/2026.9.3/README.md#L50-L52 - Authenticated Origin Pulls (the origin must validate the client certificate; shared vs zone-level cert): https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/ - Cloudflare IP ranges (origin firewall allowlist): https://www.cloudflare.com/ips/ - cloudflared as a Linux service (pass --config under sudo): https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/as-a-service/linux/ - Cloudflare Access One-time PIN (login methods coexist with an IdP): https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/one-time-pin/ - Cloudflare Access service tokens (request headers and Service Auth policy): https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/ - curl manual (stdin headers, output, redirect reporting and write-out variables): https://curl.se/docs/manpage.html - cloudflared run parameters (`--token-file`, requires 2025.4.0 or later): https://developers.cloudflare.com/tunnel/reference/run-parameters/#token-file - Tunnel tokens (retrieving the token from the dashboard installation command without running it): https://developers.cloudflare.com/tunnel/reference/tunnel-tokens/ - cloudflared 2026.9.3 service installer (runs `tunnel run --token-file`): https://github.com/cloudflare/cloudflared/blob/2026.9.3/cmd/cloudflared/common_service.go - Cloudflare Access JWT signature, issuer and application audience validation (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L154-L157 - Cloudflare Tunnel origin caPool trust and noTLSVerify behavior (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L51-L67 - Cloudflare Full (strict) origin certificate requirements (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/ssl/origin-configuration/ssl-modes/full-strict.mdx#L29-L33 - Cloudflare Origin CA certificates and direct browser trust (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/ssl/origin-ca-pause-error.mdx#L8 - Cloudflare Tunnel origin server name and matchSNItoHost override (pinned, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L31-L49 - curl cacert (pinned, checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/cacert.md#L19-L24 - curl connect-to (pinned, checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/connect-to.md#L17-L22 - curl header (pinned, checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/header.md#L23-L40 - Cloudflare Tunnel HTTP Host Header (pinned, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L89-L95 - curl noproxy (pinned, checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/noproxy.md#L18-L23 - curl globoff (pinned, checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/globoff.md#L17-L22 - Cloudflare Authenticated Origin Pulls and tunnel incompatibility (pinned, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/ssl/origin-configuration/authenticated-origin-pull/index.mdx#L16-L26 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - token-file: cloudflared token-file minimum (2025.4.0) - installer: cloudflared service installer (2026.9.3) - cloudflared-readme: cloudflared README (2026.9.3) - cf: Cloudflare Zero Trust (unknown) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - curl: curl manual (unknown) - curl-opts: curl command-line options (curl-8_23_0) Claims (full source mapping in the JSON block below): - outbound (REASONED; cf): cloudflared opens an outbound-only encrypted tunnel; edge HTTPS needs no inbound firewall ports. - prerequisites (REASONED; cf): Cloudflare DNS domain and Zero Trust organization required; check current plan limits, including the free plan's, with Cloudflare; the fetched plans page did not state a user limit, and this guide does not establish one. - remote (REASONED; cf, token-file): Copy the dashboard tunnel token to a protected file and configure the OS service to use it; publish the hostname route to http://localhost:3000 only after Access is in place. - publish-order (REASONED; cf): Create Access before publishing the route, or keep the app stopped; routing alone exposes it without login and a stopped origin still has public DNS. - tls-hops (REASONED; cf): Edge terminates TLS, the tunnel is encrypted, and HTTP to localhost:3000 stays on the host. - local (REASONED; cf): Locally managed login/create/route dns, tunnel UUID and credentials-file configure app ingress followed by http_status:404. - service (REASONED; cf): Run by tunnel name or install with an explicit --config under sudo, whose HOME is /root. - secrets (REASONED; cf): Tunnel credentials JSON and tokens allow service publication on the hostname; keep them out of repositories. - access (REASONED; cf): Self-hosted Access application plus Allow policy limits login to listed emails or domains. - otp (REASONED; cf): One-time PIN proves mailbox control; SSO can inherit IdP MFA. - mfa-bypass (REASONED; cf): When PIN and an IdP coexist, either can be chosen; remove PIN or enforce Access MFA across methods for sensitive apps. - service-token (REASONED; cf, curl): Machine access needs a service token and Service Auth policy. Read the secret at a hidden prompt in a fresh trusted Bash shell, keep it unexported in a subshell, and pipe Client-Id and Client-Secret headers to curl --header @-; this avoids argv/history exposure but not access by the account owner or root. - loopback (REASONED; cf): Bind the same-host app to 127.0.0.1 so direct public requests cannot bypass Access. - jwt (REASONED; cf, cf-docs): Validate Cf-Access-Jwt-Assertion signature, issuer and application audience; origin reachability, SSRF and another route can bypass the front door. - route-coverage (REASONED; cf): Every added tunnel hostname/path needs an Access application and policy, including coverage by a wildcard application. - quick (REASONED; cf, cf-docs, cloudflared-readme): Quick trycloudflare.com tunnels without --allowed-mail let anyone with the URL access the service; Cloudflare describes them as temporary and for testing and development, not production. - remote-origin (REASONED; cf): A remote origin needs HTTPS and a firewall allowing only the cloudflared host; TLS alone does not restrict callers. - origin-ca (REASONED; cf, cf-docs): Use originRequest.caPool for a self-signed origin; noTLSVerify discards server authentication. - full-strict (REASONED; cf, cf-docs): Full (strict) with an Origin CA certificate authenticates the origin to Cloudflare, not callers; browsers do not trust that certificate directly. - aop (REASONED; cf): AOP requires origin-side client-certificate enforcement; nginx uses ssl_verify_client on and ssl_client_certificate. - aop-zone (REASONED; cf): Global AOP proves only Cloudflare network membership; prefer a zone certificate and its issuing CA, removing shared-CA trust. - origin-ports (REASONED; cf): For a no-tunnel origin, restrict 443 to Cloudflare IP ranges and close 80 or redirect only to the canonical HTTPS host without app content; the listed Sources do not prescribe a redirect code, and mTLS cannot authenticate plaintext HTTP. - verify-login (REASONED; cf): Private browsing must show Access login; anonymous HEAD must redirect to the team's cloudflareaccess.com login, never app content. - verify-token (REASONED; cf, curl): On a route known to serve application content with HTTP 200, send the hidden-prompt service token through curl --header @- and expect http=200 location=. Login redirects, denials and transport errors fail; compare anonymous denial because token success alone does not prove Access enforcement. The probe discards the body, so 200 alone cannot identify content. - verify-policy (REASONED; cf): Inspect loopback binding and effective inbound policy, including existing allows and container NAT; permitted sources depend on topology. - verify-origin (REASONED; cf, curl, cf-docs, curl-opts): Externally, the confirmed origin address explicitly refusing the app port, plus a passing allowed-vantage control and effective inbound-policy check, supports isolation; any HTTP reply from that address directly on the app's port exposes bypass. No reply is inconclusive; an unconfirmed address's HTTP reply is never fixed, nor exposure until mapped (this guide's assessment of probe outcomes). - verify-aop (REASONED; cf, cf-docs, curl, curl-opts): On direct 443 with the origin server CA trusted, a missing-client-certificate refusal indicates AOP only from an independently confirmed origin address; an unconfirmed address's refusal or reply needs mapping confirmation before a fixed or exposed verdict. A firewall drop is inconclusive in this probe; effective inbound policy establishes isolation (this guide's assessment of probe outcomes). - verify-http (REASONED; cf, curl): Externally, on no-tunnel port 80 for a known app route, an explicit refusal or a canonical HTTPS redirect without app content from the confirmed origin address supports the fixed state if the route's service-token GET succeeds; app content from that address is exposure. No reply is inconclusive; unconfirmed-address content is not exposure until mapped (this guide's assessment of probe outcomes). - token-file (REASONED; token-file, cf): cloudflared 2025.4.0 or later supports tunnel run --token-file /absolute/path/to/token; restrict the file to the service account with mode 0600 on Linux/macOS or a restricted Windows ACL. - token-install-argv (REASONED; installer, token-file): Avoid cloudflared service install <TOKEN>: the token is exposed on the installer argv, even though the cited 2026.9.3 installer runs the service with --token-file. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "7751a8850a8fc89c50b1b80ab14439610f542813d7ba0eb55d3d6dab51526d87", "components": { "token-file": { "name": "cloudflared token-file minimum", "basis": "2025.4.0", "sources": { "sde222f01d112": "https://developers.cloudflare.com/tunnel/reference/run-parameters/#token-file" } }, "installer": { "name": "cloudflared service installer", "basis": "2026.9.3", "sources": { "sbe50314c5b92": "https://github.com/cloudflare/cloudflared/blob/2026.9.3/cmd/cloudflared/common_service.go" } }, "cloudflared-readme": { "name": "cloudflared README", "basis": "2026.9.3", "sources": { "s64bea39fdaff": "https://github.com/cloudflare/cloudflared/blob/2026.9.3/README.md#L50-L52" } }, "cf": { "name": "Cloudflare Zero Trust", "basis": "unknown", "sources": { "s536e61cfd1d0": "https://developers.cloudflare.com/cloudflare-one/", "s160a7accb558": "https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/", "s58da5534d71a": "https://github.com/cloudflare/cloudflared", "sbe2ecd154061": "https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/", "s9f78b2ad819b": "https://www.cloudflare.com/ips/", "sdb4569bc3e0d": "https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/local-management/as-a-service/linux/", "s5bd93ae868e6": "https://developers.cloudflare.com/cloudflare-one/integrations/identity-providers/one-time-pin/", "s15ab6de85740": "https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/", "sd043afad3d8a": "https://developers.cloudflare.com/tunnel/reference/tunnel-tokens/", "s8d2f573a9d81": "https://www.cloudflare.com/plans/zero-trust-services/" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sc4628a1320d3": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L154-L157", "sa102d58fd878": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L51-L67", "sf8ae935ddb55": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/ssl/origin-configuration/ssl-modes/full-strict.mdx#L29-L33", "s4a635dc9bd32": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/ssl/origin-ca-pause-error.mdx#L8", "s253af0f0386c": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L31-L49", "s64e79c014e9a": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L89-L95", "s78f4d7c15ff5": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/ssl/origin-configuration/authenticated-origin-pull/index.mdx#L16-L26", "sd114b2a04351": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/tunnel/get-started/quick-tunnels/index.mdx#L13-L42" } }, "curl": { "name": "curl manual", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "curl-opts": { "name": "curl command-line options", "basis": "curl-8_23_0", "sources": { "s528fcc47043d": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/cacert.md#L19-L24", "sce3838e7adf1": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/connect-to.md#L17-L22", "s9665b1ea8cc7": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/header.md#L23-L40", "s508454c45cf1": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/noproxy.md#L18-L23", "s511177850f9e": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/globoff.md#L17-L22" } } }, "claims": { "outbound": {"text": "cloudflared opens an outbound-only encrypted tunnel; edge HTTPS needs no inbound firewall ports.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:s160a7accb558"], "status": "REASONED"}, "prerequisites": {"text": "Cloudflare DNS domain and Zero Trust organization required; check current plan limits, including the free plan's, with Cloudflare; the fetched plans page did not state a user limit, and this guide does not establish one.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:s160a7accb558", "cf:s8d2f573a9d81"], "status": "REASONED"}, "remote": {"text": "Copy the dashboard tunnel token to a protected file and configure the OS service to use it; publish the hostname route to http://localhost:3000 only after Access is in place.", "components": ["cf", "token-file"], "sources": ["cf:s160a7accb558", "cf:sd043afad3d8a", "token-file:sde222f01d112"], "status": "REASONED"}, "publish-order": {"text": "Create Access before publishing the route, or keep the app stopped; routing alone exposes it without login and a stopped origin still has public DNS.", "components": ["cf"], "sources": ["cf:s160a7accb558", "cf:s536e61cfd1d0"], "status": "REASONED"}, "tls-hops": {"text": "Edge terminates TLS, the tunnel is encrypted, and HTTP to localhost:3000 stays on the host.", "components": ["cf"], "sources": ["cf:s160a7accb558"], "status": "REASONED"}, "local": {"text": "Locally managed login/create/route dns, tunnel UUID and credentials-file configure app ingress followed by http_status:404.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:sdb4569bc3e0d"], "status": "REASONED"}, "service": {"text": "Run by tunnel name or install with an explicit --config under sudo, whose HOME is /root.", "components": ["cf"], "sources": ["cf:sdb4569bc3e0d"], "status": "REASONED"}, "secrets": {"text": "Tunnel credentials JSON and tokens allow service publication on the hostname; keep them out of repositories.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:s160a7accb558", "cf:sdb4569bc3e0d"], "status": "REASONED"}, "access": {"text": "Self-hosted Access application plus Allow policy limits login to listed emails or domains.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:s5bd93ae868e6"], "status": "REASONED"}, "otp": {"text": "One-time PIN proves mailbox control; SSO can inherit IdP MFA.", "components": ["cf"], "sources": ["cf:s5bd93ae868e6"], "status": "REASONED"}, "mfa-bypass": {"text": "When PIN and an IdP coexist, either can be chosen; remove PIN or enforce Access MFA across methods for sensitive apps.", "components": ["cf"], "sources": ["cf:s5bd93ae868e6", "cf:s536e61cfd1d0"], "status": "REASONED"}, "service-token": {"text": "Machine access needs a service token and Service Auth policy. Read the secret at a hidden prompt in a fresh trusted Bash shell, keep it unexported in a subshell, and pipe Client-Id and Client-Secret headers to curl --header @-; this avoids argv/history exposure but not access by the account owner or root.", "components": ["cf", "curl"], "sources": ["cf:s15ab6de85740", "curl:s2b2686afaf41"], "status": "REASONED"}, "loopback": {"text": "Bind the same-host app to 127.0.0.1 so direct public requests cannot bypass Access.", "components": ["cf"], "sources": ["cf:s160a7accb558"], "status": "REASONED"}, "jwt": {"text": "Validate Cf-Access-Jwt-Assertion signature, issuer and application audience; origin reachability, SSRF and another route can bypass the front door.", "components": ["cf", "cf-docs"], "sources": ["cf:s536e61cfd1d0", "cf-docs:sc4628a1320d3"], "status": "REASONED"}, "route-coverage": {"text": "Every added tunnel hostname/path needs an Access application and policy, including coverage by a wildcard application.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0"], "status": "REASONED"}, "quick": {"text": "Quick trycloudflare.com tunnels without --allowed-mail let anyone with the URL access the service; Cloudflare describes them as temporary and for testing and development, not production.", "components": ["cf", "cf-docs", "cloudflared-readme"], "sources": ["cf:s536e61cfd1d0", "cf:s58da5534d71a", "cf-docs:sd114b2a04351", "cloudflared-readme:s64bea39fdaff"], "status": "REASONED"}, "remote-origin": {"text": "A remote origin needs HTTPS and a firewall allowing only the cloudflared host; TLS alone does not restrict callers.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:s160a7accb558"], "status": "REASONED"}, "origin-ca": {"text": "Use originRequest.caPool for a self-signed origin; noTLSVerify discards server authentication.", "components": ["cf", "cf-docs"], "sources": ["cf:s536e61cfd1d0", "cf-docs:sa102d58fd878"], "status": "REASONED"}, "full-strict": {"text": "Full (strict) with an Origin CA certificate authenticates the origin to Cloudflare, not callers; browsers do not trust that certificate directly.", "components": ["cf", "cf-docs"], "sources": ["cf:sbe2ecd154061", "cf-docs:sf8ae935ddb55", "cf-docs:s4a635dc9bd32"], "status": "REASONED"}, "aop": {"text": "AOP requires origin-side client-certificate enforcement; nginx uses ssl_verify_client on and ssl_client_certificate.", "components": ["cf"], "sources": ["cf:sbe2ecd154061"], "status": "REASONED"}, "aop-zone": {"text": "Global AOP proves only Cloudflare network membership; prefer a zone certificate and its issuing CA, removing shared-CA trust.", "components": ["cf"], "sources": ["cf:sbe2ecd154061"], "status": "REASONED"}, "origin-ports": {"text": "For a no-tunnel origin, restrict 443 to Cloudflare IP ranges and close 80 or redirect only to the canonical HTTPS host without app content; the listed Sources do not prescribe a redirect code, and mTLS cannot authenticate plaintext HTTP.", "components": ["cf"], "sources": ["cf:sbe2ecd154061", "cf:s9f78b2ad819b"], "status": "REASONED"}, "verify-login": {"text": "Private browsing must show Access login; anonymous HEAD must redirect to the team's cloudflareaccess.com login, never app content.", "components": ["cf"], "sources": ["cf:s536e61cfd1d0", "cf:s5bd93ae868e6"], "status": "REASONED"}, "verify-token": {"text": "On a route known to serve application content with HTTP 200, send the hidden-prompt service token through curl --header @- and expect http=200 location=. Login redirects, denials and transport errors fail; compare anonymous denial because token success alone does not prove Access enforcement. The probe discards the body, so 200 alone cannot identify content.", "components": ["cf", "curl"], "sources": ["cf:s15ab6de85740", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-policy": {"text": "Inspect loopback binding and effective inbound policy, including existing allows and container NAT; permitted sources depend on topology.", "components": ["cf"], "sources": ["cf:s160a7accb558", "cf:sbe2ecd154061", "cf:s9f78b2ad819b"], "status": "REASONED"}, "verify-origin": {"text": "Externally, the confirmed origin address explicitly refusing the app port, plus a passing allowed-vantage control and effective inbound-policy check, supports isolation; any HTTP reply from that address directly on the app's port exposes bypass. No reply is inconclusive; an unconfirmed address's HTTP reply is never fixed, nor exposure until mapped (this guide's assessment of probe outcomes).", "components": ["cf", "curl", "cf-docs", "curl-opts"], "sources": ["cf:s160a7accb558", "curl:s2b2686afaf41", "cf-docs:s253af0f0386c", "curl-opts:s528fcc47043d", "curl-opts:sce3838e7adf1", "cf-docs:sa102d58fd878", "curl-opts:s9665b1ea8cc7", "cf-docs:s64e79c014e9a"], "status": "REASONED", "verify": [2, 3]}, "verify-aop": {"text": "On direct 443 with the origin server CA trusted, a missing-client-certificate refusal indicates AOP only from an independently confirmed origin address; an unconfirmed address's refusal or reply needs mapping confirmation before a fixed or exposed verdict. A firewall drop is inconclusive in this probe; effective inbound policy establishes isolation (this guide's assessment of probe outcomes).", "components": ["cf", "cf-docs", "curl", "curl-opts"], "sources": ["cf:sbe2ecd154061", "curl:s2b2686afaf41", "cf-docs:s4a635dc9bd32", "curl-opts:s508454c45cf1", "curl-opts:s511177850f9e", "cf-docs:s78f4d7c15ff5"], "status": "REASONED"}, "verify-http": {"text": "Externally, on no-tunnel port 80 for a known app route, an explicit refusal or a canonical HTTPS redirect without app content from the confirmed origin address supports the fixed state if the route's service-token GET succeeds; app content from that address is exposure. No reply is inconclusive; unconfirmed-address content is not exposure until mapped (this guide's assessment of probe outcomes).", "components": ["cf", "curl"], "sources": ["cf:sbe2ecd154061", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [4]}, "token-file": {"text": "cloudflared 2025.4.0 or later supports tunnel run --token-file /absolute/path/to/token; restrict the file to the service account with mode 0600 on Linux/macOS or a restricted Windows ACL.", "components": ["token-file", "cf"], "sources": ["token-file:sde222f01d112", "cf:sd043afad3d8a"], "status": "REASONED"}, "token-install-argv": {"text": "Avoid cloudflared service install : the token is exposed on the installer argv, even though the cited 2026.9.3 installer runs the service with --token-file.", "components": ["installer", "token-file"], "sources": ["installer:sbe50314c5b92", "token-file:sde222f01d112"], "status": "REASONED"} } } ```` ====================================================================== ==> tailscale.md ====================================================================== # Tailscale: serve and funnel Tailscale is a VPN service that joins your devices into a private WireGuard network called a tailnet. This guide helps your AI assistant use it securely, including `tailscale serve` for tailnet-only access under a narrowed policy, the app still authorizing users, and `tailscale funnel` only with the app's own login. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/tailscale.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Tailscale CLI change v1.52 and curl option documentation 7.75.0; 2 further sources carry no recorded version. Metadata reviewed 2026-10-05; documentation checked September 2026. The guide records 18 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Tailscale gives the same no-open-inbound-ports posture as [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), built on WireGuard with device identity as the access control. Two commands matter, and they differ in exactly one thing: who can reach the service. ## 1. tailscale serve: tailnet-only (authenticated by membership) ```bash tailscale serve --bg localhost:3000 ``` - Traffic **through this proxy** is reachable only by devices in your tailnet, so that path is authenticated by device identity and your tailnet ACLs. `serve` does not change how the application binds, though: an application still listening on `0.0.0.0:3000` keeps answering its LAN or VPC address directly, past the tailnet. - That reach is only as tight as your tailnet policy. A new tailnet's default policy lets every device in the tailnet (`action` accept, `src` `*`, `dst` `*:*`), plus anyone you have shared the node with, connect to this service. Restrict it by removing the default allow-all rule and granting only the intended users, groups, or tags to this host and the port `serve` listens on (a grant is preferred; ACLs remain supported); adding a narrower rule alongside allow-all does not revoke it, because Tailscale rules are additive accepts. - Tailnet reach is network access, not application authorization: an admin panel behind `serve` must still authenticate and authorize its own users, through its own login or by deliberately consuming the identity headers `serve` forwards. Trusting those headers is safe only where you trust every process on the Serve host: binding the app to loopback (next bullet) stops other machines, but any local process can still reach `127.0.0.1:3000` and forge them (Tailscale notes that binding to localhost limits tampering to other services on the Serve device, it does not eliminate it). Requests from tagged devices carry no user identity, so give those another method or reject them. - Bind the fronted application to `127.0.0.1:3000` so no other machine can reach it directly; local processes on the host still can, per the identity-header caveat above. - HTTPS uses an automatically provisioned TLS certificate for the machine's tailnet name. - `--bg` keeps it running in the background; without it, the share stops with the session. This is the right default for admin panels, dashboards, Jupyter, and internal tools: no certificate work, no public exposure at all. ## 2. tailscale funnel: public internet (bring your own auth) ```bash tailscale funnel 3000 ``` - Publishes the service to the entire internet at your `*.ts.net` hostname, TLS included. - Funnel itself adds **no per-request authentication**; the relay does not even decrypt your traffic. Anything funneled needs application-level login per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) and, for human logins, [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), exactly as if it sat behind any public proxy. - Prerequisites per the docs: HTTPS certificates enabled for the tailnet, a `funnel` node attribute in the tailnet policy file, and MagicDNS. ## 3. Choosing between them Serve for anything private (most things). Funnel or [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) for genuinely public services; Cloudflare Access adds managed login in front, which funnel does not, so prefer Access when the public service is for a defined set of people. Command syntax changed in Tailscale v1.52; on older clients consult `tailscale serve --help`. ## 4. Verify ```bash ( # a subshell, so the exits below end only the subshell, not your shell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- "$(if ss -tlnp 'sport = :3000'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # ss's own filter, not a grep: the address column set -- "${1%$'\n'*}" "${1##*$'\n'}" # must read 127.0.0.1:3000, never 0.0.0.0:3000 or [::]:3000 printf '%s' "$1" [ "$2" = 0 ] || { echo "ss exit $2: the listener list could not be read; inconclusive"; exit 3; } if printf '%s' "$1" | awk 'NR > 1 { n++; if ($4 == "0.0.0.0:3000" || $4 == "[::]:3000" || $4 == "*:3000" || $4 == ":::3000") w++; else if ($4 != "127.0.0.1:3000") o++ } END { exit (w ? 1 : (!n ? 3 : (o ? 4 : 0))) }'; then echo "pass: every listener on port 3000 is 127.0.0.1:3000"; exit 0 else case "$?" in 1) echo "FINDING: a wildcard listener on port 3000 answers on every address, not only loopback"; exit 1 ;; 3) echo "no TCP listener on port 3000, so no 127.0.0.1:3000 line to confirm; inconclusive"; exit 3 ;; 4) echo "a listener on port 3000 is neither 127.0.0.1:3000 nor a wildcard; read its address above; inconclusive"; exit 3 ;; *) echo "awk could not read the ss output; inconclusive"; exit 3 ;; esac fi ) ``` REASONED: preceding block; loopback listener inventory and direct LAN/VPC isolation, per the guide's cited ss(8) and Tailscale serve identity headers. No Serve host or separate LAN/VPC probe machine is available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; original, missing-assignment, short, empty and embedded-placeholder cases printed refusal messages without reaching curl, while a substituted address reached one curl stub. For the direct-address probe block, the bash -n, ShellCheck and stub runs recorded here used an earlier form, whose guard refusals left the subshell with status 0 and whose probe left curl's own status. Its current form, whose guard refusals leave the subshell with status 2 and whose probe exits 1 on an HTTP reply and 3 otherwise, passes the repository's shell-block gate (bash -n and ShellCheck 0.11.0) but was not stub-run. For the listener block, the bash -n and ShellCheck runs recorded here used an earlier form, a top-level ss command whose status was its own. Its current form, which exits 0 when every port 3000 listener is 127.0.0.1:3000, 1 for a wildcard listener, 2 when `ss` is not installed, and 3 otherwise, is covered by the repository's shell-block gate (bash -n and ShellCheck 0.11.0) and by stub runs with `ss` present and passing, present and failing, and missing. No deployed bind or HTTP response was observed: exposed wildcard binding permits direct bypass; fixed binding is loopback and the direct address does not answer, with failed requests alone inconclusive. Direct-backend isolation follows the cited Serve identity-header guidance. The authoring environment cannot provision a Serve host and separate LAN/VPC probe machine; HTTP replies expose bypass and failed requests remain inconclusive as described below. From another machine on the same LAN or VPC (run this from OUTSIDE the host), confirm the app does not answer its direct address: ```bash ( # a subshell, so your own shell keeps its positional parameters and the guard's exit does not close it set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_HOST_LAN_OR_VPC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host's LAN or VPC address on the set -- line above; not probing"; exit 2 ;; esac if curl -q -g -sI -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w 'http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n' "http://$1:3000/"; then echo "FINDING: the app answered at $1:3000, so it is reachable past the tailnet"; exit 1 else echo "curl exit $?: no HTTP reply, which is inconclusive on its own; read err above and rely on the ss check for the bind address"; exit 3 fi ) # Any HTTP reply (including 4xx/5xx) proves the app is reachable past the tailnet at that address. A failed request does not by itself prove a loopback bind (a firewall, a wrong address, or a stalled response fails the same way), so read `err`/`exitcode` and rely on the `ss` line above for the actual bind address. ``` REASONED: preceding block; direct-backend isolation and the allowed tailnet path, per the guide's cited Tailscale serve identity headers and Tailscale serve. No Serve host or allowed/excluded tailnet devices are available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; rejected guards reached no curl stub and a substituted direct address reached one. The bash -n, ShellCheck and stub runs recorded here covered the direct-address probe block in an earlier form, whose guard refusals left the subshell with status 0 and whose probe left curl's own status. Its current form, whose guard refusals leave the subshell with status 2 and whose probe exits 1 on an HTTP reply and 3 otherwise, passes the repository's shell-block gate (bash -n and ShellCheck 0.11.0) but was not stub-run. No live access was observed: exposed direct access bypasses the tailnet; fixed direct access is isolated while the allowed tailnet request works. Allowed access alone does not distinguish allow-all from scoped policy. Serve status and allowed-device access follow the cited Serve CLI and policy-test documentation. The authoring environment cannot configure a tailnet with allowed and excluded devices; success alone does not prove policy restriction. Then confirm the tailnet path works and that the section-1 restriction actually holds: ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a command that fails is recorded in the status below instead of ending the block set -- 0 # the block's status so far: 3 (inconclusive) outranks 0 tailscale serve status || { echo "tailscale serve status exit $?: the Serve configuration could not be read; inconclusive"; set -- 3; } curl -q -sI https://host.tailnet.ts.net/ || # from an ALLOWED tailnet device: works { echo "curl exit $?: the request from this allowed device failed; inconclusive"; set -- 3; } echo "block exit $1; the Serve status and the response headers are not classified, and a request that works here does not show that the section-1 restriction holds, so judge them and assert it with the policy tests below" exit "$1" ) ``` REASONED: preceding block; Serve status, allowed access, policy assertions and public Serve/Funnel reachability, per the guide's cited Tailscale serve, Tailscale policy-file tests and Tailscale funnel. No tailnet, policy submission access or allowed/excluded devices are available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics on an earlier form of the block, two top-level commands whose status was curl's. Its current form, which exits 3 when either command fails and 0 otherwise, passes the repository's shell-block gate (bash -n and ShellCheck 0.11.0) but was not stub-run. No live status, policy evaluation or request was observed. Exposed allow-all admits excluded identities; fixed policy accepts the intended identity and denies the excluded one, with failing assertions rejecting the policy file. Public Serve should remain unreachable; public Funnel remains reachable and therefore needs application login. An allowed device connects under both the default allow-all policy and a correctly scoped one, so a request that works here does not prove the section-1 restriction took hold. Assert it in the tailnet policy file's `tests` (an `accept` from the intended identity to this host and port, and a `deny` from an excluded tailnet identity); Tailscale rejects a policy file whose `tests` fail. From a non-tailnet network the serve URL is unreachable while a funnel URL is reachable, so a funneled app must gate every request with its own login. ## Sources (checked September 2026) - Tailscale serve (CLI syntax changed in v1.52): https://tailscale.com/docs/reference/tailscale-cli/serve - Tailscale funnel: https://tailscale.com/docs/features/tailscale-funnel - Tailscale ACL policy examples, for the default allow-all policy (`action` accept, `src` `*`, `dst` `*:*`) and scoping access from a source to a destination: https://tailscale.com/docs/reference/examples/acls - Tailscale serve identity headers, that binding the backend to localhost limits tampering to other services on the Serve device (it does not authenticate the calling process): https://tailscale.com/docs/features/tailscale-serve - Tailscale policy-file `tests`, that a policy file whose assertions fail is rejected: https://tailscale.com/docs/reference/syntax/policy-file#tests - ss(8), the `sport` filter expression used above: https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html - curl 7.75.0 write-out errormsg and exitcode (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/write-out.d#L32-L36 - curl 7.75.0 write-out http_code (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/write-out.d#L48-L51 - curl 7.75.0 write-out time_connect (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/write-out.d#L147-L149 - curl 7.75.0 HEAD requests (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/head.d#L7-L9 - curl 7.75.0 first-argument configuration disabling (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/disable.d#L6-L8 - curl 7.75.0 connection timeout (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/connect-timeout.d#L7-L10 - curl 7.75.0 total timeout (pinned tag curl-7_75_0, checked October 2026): https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/max-time.d#L8-L12 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - cli: Tailscale CLI change (v1.52) - ts: Tailscale documentation (unknown) - ss: ss (unknown) - curl: curl option documentation (7.75.0) Claims (full source mapping in the JSON block below): - serve (REASONED; ts): tailscale serve --bg localhost:3000 proxies to the app for tailnet devices, subject to identity and tailnet policy. - policy-default (REASONED; ts): New tailnets allow all devices, plus node-share recipients; remove allow-all before granting intended users, groups or tags access to the Serve port. - policy-additive (REASONED; ts): Rules are additive accepts; a narrower rule cannot revoke allow-all. Grants are preferred and ACLs remain supported. - app-auth (REASONED; ts): Tailnet reach is not app authorization; the app needs login or deliberate use of Serve identity headers. - header-trust (REASONED; ts): Loopback limits identity-header spoofing to local processes; trusting Serve headers requires trusting every process on that host. - tagged (REASONED; ts): Tagged devices carry no user identity; reject them or supply another authentication method. - bind (REASONED; ts): Serve does not change the app bind; use 127.0.0.1:3000 because a wildcard app still answers directly on LAN/VPC addresses. - serve-tls (REASONED; ts): Serve automatically provisions HTTPS certificates for the machine's tailnet name. - background (REASONED; ts): --bg persists Serve in the background; without it the share ends with the session. - funnel (REASONED; ts): tailscale funnel 3000 publishes the service to the internet at its ts.net hostname with TLS. - funnel-auth (REASONED; ts): Funnel adds no per-request authentication and its relay does not decrypt traffic; the app needs login and human MFA. - funnel-prerequisites (REASONED; ts): Funnel requires tailnet HTTPS certificates, a funnel node attribute and MagicDNS. - syntax (REASONED; cli): Serve/Funnel command syntax changed in v1.52; older clients need their own --help. - verify-bind (REASONED; ss, ts): ss sport=:3000 must show 127.0.0.1:3000, not IPv4 or IPv6 wildcard. - verify-direct (REASONED; ts, curl): Probe from outside the host on the LAN/VPC: any HTTP reply proves bypass; failure alone does not prove loopback binding. - verify-allowed (REASONED; ts, curl): Serve status and an allowed-device request check the intended path; success occurs under both allow-all and scoped policy. - verify-policy (REASONED; ts): Policy tests must accept the intended identity and deny an excluded one; failed assertions reject the policy file. - verify-public (REASONED; ts): From a non-tailnet network Serve is unreachable and Funnel reachable; the funneled app must authenticate every request. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-09", "body_sha256": "d732d5b8d86a5b322517082d8b97dc0d66bd13ba623f0841e82da4153625cf59", "components": { "cli": { "name": "Tailscale CLI change", "basis": "v1.52", "sources": { "s5b9a9b15ae80": "https://tailscale.com/docs/reference/tailscale-cli/serve" } }, "ts": { "name": "Tailscale documentation", "basis": "unknown", "sources": { "s5b9a9b15ae80": "https://tailscale.com/docs/reference/tailscale-cli/serve", "s43c0c1d7112d": "https://tailscale.com/docs/features/tailscale-funnel", "s42e5680f0b20": "https://tailscale.com/docs/reference/examples/acls", "s920d8d7db85b": "https://tailscale.com/docs/features/tailscale-serve", "se137a82d9873": "https://tailscale.com/docs/reference/syntax/policy-file#tests" } }, "ss": { "name": "ss", "basis": "unknown", "sources": { "s1b4e67e38127": "https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html" } }, "curl": { "name": "curl option documentation", "basis": "7.75.0", "sources": { "sc2888bc2b75b": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/write-out.d#L32-L36", "sc6f836c27ed8": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/write-out.d#L48-L51", "sc4ff20756ddd": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/write-out.d#L147-L149", "see8bc6e3acb6": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/head.d#L7-L9", "sb0b300176c5d": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/disable.d#L6-L8", "s72e21eeff9a4": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/connect-timeout.d#L7-L10", "s3b94eb9a9c12": "https://github.com/curl/curl/blob/curl-7_75_0/docs/cmdline-opts/max-time.d#L8-L12" } } }, "claims": { "serve": {"text": "tailscale serve --bg localhost:3000 proxies to the app for tailnet devices, subject to identity and tailnet policy.", "components": ["ts"], "sources": ["ts:s5b9a9b15ae80"], "status": "REASONED"}, "policy-default": {"text": "New tailnets allow all devices, plus node-share recipients; remove allow-all before granting intended users, groups or tags access to the Serve port.", "components": ["ts"], "sources": ["ts:s42e5680f0b20"], "status": "REASONED"}, "policy-additive": {"text": "Rules are additive accepts; a narrower rule cannot revoke allow-all. Grants are preferred and ACLs remain supported.", "components": ["ts"], "sources": ["ts:s42e5680f0b20"], "status": "REASONED"}, "app-auth": {"text": "Tailnet reach is not app authorization; the app needs login or deliberate use of Serve identity headers.", "components": ["ts"], "sources": ["ts:s920d8d7db85b"], "status": "REASONED"}, "header-trust": {"text": "Loopback limits identity-header spoofing to local processes; trusting Serve headers requires trusting every process on that host.", "components": ["ts"], "sources": ["ts:s920d8d7db85b"], "status": "REASONED"}, "tagged": {"text": "Tagged devices carry no user identity; reject them or supply another authentication method.", "components": ["ts"], "sources": ["ts:s920d8d7db85b"], "status": "REASONED"}, "bind": {"text": "Serve does not change the app bind; use 127.0.0.1:3000 because a wildcard app still answers directly on LAN/VPC addresses.", "components": ["ts"], "sources": ["ts:s5b9a9b15ae80", "ts:s920d8d7db85b"], "status": "REASONED"}, "serve-tls": {"text": "Serve automatically provisions HTTPS certificates for the machine's tailnet name.", "components": ["ts"], "sources": ["ts:s5b9a9b15ae80"], "status": "REASONED"}, "background": {"text": "--bg persists Serve in the background; without it the share ends with the session.", "components": ["ts"], "sources": ["ts:s5b9a9b15ae80"], "status": "REASONED"}, "funnel": {"text": "tailscale funnel 3000 publishes the service to the internet at its ts.net hostname with TLS.", "components": ["ts"], "sources": ["ts:s43c0c1d7112d"], "status": "REASONED"}, "funnel-auth": {"text": "Funnel adds no per-request authentication and its relay does not decrypt traffic; the app needs login and human MFA.", "components": ["ts"], "sources": ["ts:s43c0c1d7112d"], "status": "REASONED"}, "funnel-prerequisites": {"text": "Funnel requires tailnet HTTPS certificates, a funnel node attribute and MagicDNS.", "components": ["ts"], "sources": ["ts:s43c0c1d7112d"], "status": "REASONED"}, "syntax": {"text": "Serve/Funnel command syntax changed in v1.52; older clients need their own --help.", "components": ["cli"], "sources": ["cli:s5b9a9b15ae80"], "status": "REASONED"}, "verify-bind": {"text": "ss sport=:3000 must show 127.0.0.1:3000, not IPv4 or IPv6 wildcard.", "components": ["ss", "ts"], "sources": ["ss:s1b4e67e38127", "ts:s920d8d7db85b"], "status": "REASONED", "verify": [1]}, "verify-direct": {"text": "Probe from outside the host on the LAN/VPC: any HTTP reply proves bypass; failure alone does not prove loopback binding.", "components": ["ts", "curl"], "sources": ["ts:s5b9a9b15ae80", "ts:s920d8d7db85b", "curl:sc2888bc2b75b", "curl:sc6f836c27ed8", "curl:sc4ff20756ddd", "curl:see8bc6e3acb6", "curl:sb0b300176c5d", "curl:s72e21eeff9a4", "curl:s3b94eb9a9c12"], "status": "REASONED", "verify": [2]}, "verify-allowed": {"text": "Serve status and an allowed-device request check the intended path; success occurs under both allow-all and scoped policy.", "components": ["ts", "curl"], "sources": ["ts:s5b9a9b15ae80", "ts:s42e5680f0b20", "curl:see8bc6e3acb6", "curl:sb0b300176c5d"], "status": "REASONED", "verify": [3]}, "verify-policy": {"text": "Policy tests must accept the intended identity and deny an excluded one; failed assertions reject the policy file.", "components": ["ts"], "sources": ["ts:se137a82d9873"], "status": "REASONED"}, "verify-public": {"text": "From a non-tailnet network Serve is unreachable and Funnel reachable; the funneled app must authenticate every request.", "components": ["ts"], "sources": ["ts:s5b9a9b15ae80", "ts:s43c0c1d7112d"], "status": "REASONED"} } } ```` ====================================================================== ==> tunnels.md ====================================================================== # Self-hosted tunnels: frp, WireGuard, and ssh -R frp (a reverse proxy), WireGuard (a VPN) and `ssh -R` (SSH remote forwarding) carry traffic to machines that cannot accept direct connections. This guide helps your AI assistant configure each securely, including frp's token, WireGuard keys plus forwarding firewall rules, and `ssh -R` forwards bound to the server's loopback only. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/tunnels.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against frps source v0.72.0, frpc source v0.72.0, frp compare diff v0.71.0...v0.72.0, frp TLS default minimum v0.50.0, go-oidc source v3.21.0, Linux source v6.8, and iproute2 manuals v6.12.0; 2 further sources are pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-11-05; documentation checked September 2026. The guide records 37 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. All three expose a private host to the internet without a public IP, the same job [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) and [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) do, but with no vendor edge: you run and secure both ends yourself, on a host still hardened per [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md). The frp setup with a weak or absent token lets anyone bind proxies through your server; WireGuard has no login at all, only key pairs and the traffic scoping you configure; and `ssh -R` forwards a local port through your own SSH login, kept on the server's loopback by default but reachable by anyone if `GatewayPorts` is widened. ## frp `frps` (the server) listens for client connections on `bindPort`, default `7000` (as of v0.72.0). Authentication is token-based by default: set the identical `auth.token` in `frps.toml` and every `frpc.toml`, since "client needs to set the same value to pass authentication": ```toml # frps.toml bindPort = 7000 auth.token = "REPLACE_WITH_LONG_RANDOM_VALUE" ``` ```toml # frpc.toml serverAddr = "203.0.113.10" serverPort = 7000 auth.token = "REPLACE_WITH_LONG_RANDOM_VALUE" ``` frp also supports `auth.method = "oidc"`: frpc obtains a token from an OIDC provider through the Client Credentials Grant and frps validates it by issuer and audience, authenticating frpc to frps instead of using a shared token, useful for centralizing frp auth behind an identity provider. In frp v0.72.0, the OIDC auth setter for the client configuration builds a `golang.org/x/oauth2/clientcredentials` configuration from the client ID, client secret, scope and token endpoint URL. Audience validation is conditional, though: frps skips it when `auth.oidc.audience` is left empty (it sets the token verifier's `SkipClientIDCheck` in that case), so set a nonempty `auth.oidc.audience` and the intended `auth.oidc.issuer` explicitly rather than assuming that selecting `auth.method = "oidc"` binds the token to your server. In go-oidc v3.21.0, the ID token verifier performs no audience check when `SkipClientIDCheck` is set, and otherwise requires a nonempty client ID that the token's audience contains. GitHub's compare diff from frp v0.71.0 to v0.72.0 shows frp's `go.mod` moving `github.com/coreos/go-oidc/v3` from v3.18.0 to v3.21.0. Both the shared token and OIDC authenticate `frpc` to `frps`; neither authenticates the callers who reach a service you publish through the tunnel. A registered tcp, udp, http, https or tcpmux proxy listens on `proxyBindAddr`, which defaults to the server's `bindAddr` of `0.0.0.0` (as of v0.72.0), so once the proxy is up anyone who can reach that listener reaches the service behind it, exactly as if the app faced a public port directly. An stcp, sudp or xtcp proxy opens no listener of its own on frps. Give the published service its own authentication, or keep the proxy on loopback and front it with an authenticated TLS proxy ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)); a strong `auth.token` protects the tunnel, not the service. From frp v0.50.0, `transport.tls.enable` defaults to `true`, so the connection between `frpc` and `frps` is encrypted out of the box; the gap is verification, not encryption. `frpc` still does not verify `frps`'s certificate by default, so it will encrypt to whatever server answers on `serverAddr`, genuine or not. frp v0.72.0 adds an frpc setting, `transport.tls.useSystemCa`, that turns on verification against the system CAs, and its source comment says frpc skips verification when neither it nor `trustedCaFile` is set. Set `transport.tls.certFile`/`transport.tls.keyFile` on the server and `transport.tls.trustedCaFile` on every client so `frpc` verifies the server's certificate against a trusted CA, and set `transport.tls.force = true` on the server so it refuses any client that did not negotiate TLS at all. The `auth` block is schema-optional, and omitting it does not fail closed: frp defaults to the token method with an empty token, and `frps` derives its auth key from that empty string, so a client presenting an empty token authenticates. An unconfigured token is an open door, not a safe default: always set a long random `auth.token` (or OIDC) before exposing `bindPort` to the internet, and never rely on frp for anything without one. The gofrp.org pages cited here, as served on 2026-11-05, do not say which frp release they describe and each shows "Last modified October 21, 2026", so whether they describe frp v0.72.0 is not established. The statements resting on them alone are the `transport.tls.enable` default, `transport.tls.force` and the server's certificate setup, `transport.tls.certFile` and `transport.tls.keyFile`. The client's `transport.tls.trustedCaFile` verification rests on frp's source instead: GitHub's compare diff from v0.71.0 to v0.72.0 shows frpc passing that file to `NewClientTLSConfigWithSystemCA` (`client/connector.go` L111-L117 and L198-L203 at v0.72.0). With the file set and `useSystemCa` unset, the client TLS configuration that function builds takes `RootCAs` from that file and sets `InsecureSkipVerify` to false (`pkg/transport/tls.go` L182-L185 and L188-L189); with neither trust source it sets `InsecureSkipVerify` to true (L194-L195). ## WireGuard WireGuard has no username or password; identity is a base64-encoded key pair, generated per peer: ```bash umask 077 wg genkey | tee privatekey | wg pubkey > publickey ``` The private key never leaves the peer that generated it; only the public key goes into the other side's configuration. A peer is added to an interface with its public key, an endpoint, and `AllowedIPs`: ```bash wg set wg0 listen-port 51820 private-key /path/to/private-key peer "REPLACE_WITH_PEER_PUBLIC_KEY" allowed-ips 192.168.88.0/24 endpoint 203.0.113.10:51820 ``` `AllowedIPs` is dual-purpose "Cryptokey Routing," but the two purposes are not symmetric. On the sending side it "behaves as a sort of routing table," picking which peer a destination IP goes to. On the receiving side it "behaves as a sort of access control list" for the packet's source address only, dropping a decrypted packet whose source IP does not match the sending peer's configured `AllowedIPs`; it is a spoofing check, not a destination filter, and it is the server's actual restriction on which source address a peer may use. Once a peer is authenticated, its `AllowedIPs` entry places no limit on which destinations that peer is allowed to reach if the server is willing to forward the traffic there. Scope `AllowedIPs` to exactly the address or subnet a peer should be reached at, never `0.0.0.0/0` unless that peer is genuinely meant to be a full-tunnel gateway. The firewall step is to restrict which destinations a peer can reach through the server with a firewall rule on the server itself, matching the same source prefix as that peer's `AllowedIPs` so the peer cannot rotate its source address within that prefix to evade a narrower rule. An example of such a rule is an nftables rule dropping forwarded packets from anywhere in this peer's permitted subnet to a destination outside its intended subnet, with a counter so the rule's effect can be confirmed later: ```bash nft add rule inet filter forward iifname "wg0" ip saddr 192.168.88.0/24 ip daddr != 192.168.88.0/24 counter drop ``` That rule assumes a base chain hooked to `forward` already exists in table `inet filter` (create one with `nft add chain inet filter forward '{ type filter hook forward priority 0; }'` if you manage nftables directly; an unreferenced regular chain of that name is attached to no hook and receives no traffic). It then governs only traffic the server routes between interfaces: it takes effect only with `net.ipv4.ip_forward` enabled, and because `nft add rule` appends, an earlier `accept` in the same chain short-circuits it, so place it ahead of any such accept. It does not restrict what a peer reaches on the server host itself (a service bound to the `wg0` address or to `0.0.0.0` is reached through the `input` hook and needs its own rule), and as written it matches IPv4 only; add an `ip6 saddr`/`ip6 daddr` counterpart if the peer carries an IPv6 `AllowedIPs`. By default WireGuard "tries to be as silent as possible when not being used," so the only inbound port a firewall needs to open is the single WireGuard UDP `ListenPort`; everything else on the host stays closed per [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md). ## ssh -R (remote forwarding) `ssh -R 8080:localhost:3000 user@server` is the quickest ad-hoc self-hosted tunnel: it asks the server to forward connections to its own port 8080 back through your SSH session to your `localhost:3000`. Whether that published port is a private convenience or an open door is decided on the server by `GatewayPorts` in `sshd_config`, not by the client. With the default `GatewayPorts no`, the server binds the forwarded port to loopback, so only the server itself reaches it and nothing is exposed to the network; a client that asks for a public bind, as in `ssh -R '*:8080:localhost:3000' ...`, is quietly overridden back to loopback. `GatewayPorts yes` forces a wildcard bind on every interface regardless of what the client requests. `clientspecified` instead honours the bind address in the request, so under it that same `ssh -R '*:8080:localhost:3000' ...` opens all interfaces while `ssh -R '127.0.0.1:8080:localhost:3000' ...` stays on loopback. SSH authenticates the tunnel session itself, but adds nothing for callers reaching the forwarded port: once that port is on a public interface, if `localhost:3000` has no login of its own, anyone who reaches `server:8080` has it. To publish such an app, do not widen `GatewayPorts`. Leave it `no` so the forward stays on the server's loopback, and run a reverse proxy on the server that reads `localhost:8080` and exposes only an authenticated TLS listener ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)); the forwarded port itself never faces the network. Setting `GatewayPorts yes` would put port 8080 straight onto the public interface, in front of that proxy rather than behind it. For a persistent tunnel, run the `ssh -R` under `autossh` so it reconnects, and give the login its own key on a restricted account. ## Verify REASONED: following block; the cited frp authentication, WireGuard quickstart/Cryptokey Routing, Ubuntu Noble ufw source, nftables and OpenSSH documentation defines the checks below. The authoring environment cannot provision paired tunnel hosts or privileged network/firewall control; the guide records no live run. The checks span hosts and frpc runs in the foreground, so each step is its own block with its own exit status: paste each whole on the host it names. Each block exits 0 when it leaves its output for you to judge against its comments, and 3 when a command whose failure it can recognize fails; the paragraph before each block says which. The frp block also refuses with status 2 when frpc is not installed, and the two listener inventories refuse with status 2 when `ss` is not installed, which in the WireGuard and firewall block holds only while no other read failed. The frp block records 3 when sed cannot generate a changed config from `frpc.toml`, and otherwise exits 0, leaving the frpc logs for you to judge. No earlier run of these blocks is recorded; their current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs of each block's passing and failing cases, with `ss` passing, failing and missing, under an inherited `readonly`, `declare -i` and `set -e`. Run this first block on the frp client, from the directory holding `frpc.toml`. ```bash ( # a subshell, so the exits below end only this step set +e # errexit off here, so an expected login failure does not end the step trap ':' INT # Ctrl-C then stops only the frpc in the foreground, and the step goes on to the next command command -v frpc >/dev/null || { echo "frpc is not installed here; not testing"; exit 2; } set -- 0 # this step's status: 3 if a sed below fails, otherwise 0, because you judge the frpc logs # frp positive control: with the correct auth.token, frpc logs 'login to server success' (proving # reachability and a valid token). frpc then stays in the FOREGROUND; stop it with Ctrl-C to go on to the # next command. This exercises token auth only; auth.token has no effect under auth.method = "oidc". frpc -c frpc.toml # expect a 'login to server success, get run id ...' log line # negative control: the same config with ONLY the token changed must fail LOGIN for an authentication # reason. The exact text is version-dependent, so key on the token/auth reason and the ABSENCE of a # 'login to server success', not a literal string; a config-load, DNS, TLS, or connection error does NOT count if sed 's/^auth\.token[[:space:]]*=.*/auth.token = "WRONG_VALUE"/' frpc.toml > frpc-wrongtoken.toml; then frpc -c frpc-wrongtoken.toml # expect a token-auth failure (e.g. 'token in login doesn't match token from configuration'), no run id else echo "sed exit $?: frpc-wrongtoken.toml could not be generated from frpc.toml; inconclusive"; set -- 3 fi # open-door check: a server whose auth.token is absent or empty accepts an empty token, so confirm your # frps also REJECTS an empty-token client; a successful login here means your server has no token set if sed 's/^auth\.token[[:space:]]*=.*/auth.token = ""/' frpc.toml > frpc-emptytoken.toml; then frpc -c frpc-emptytoken.toml # expect the same login failure else echo "sed exit $?: frpc-emptytoken.toml could not be generated from frpc.toml; inconclusive"; set -- 3 fi echo "block exit $1; the frpc logs above are not classified, so judge them against the comments yourself" exit "$1" ) ``` REASONED: following block; the WireGuard, UDP listener and firewall reads follow the cited WireGuard quickstart, iproute2 manuals, Linux source, Ubuntu Noble ufw manual and source, and nftables manual; no WireGuard host or privileged firewall control is available here. Run it on the host you are checking. It exits 3 when a read fails, 2 when `ss` is not installed and no read failed, and otherwise 0. A host that does not run ufw records 3 for the ufw reads, because the block does not tell a missing ufw from a failed read; read the nft ruleset there instead. ```bash ( # a subshell, so the exit below ends only this step set +e # errexit off here, so a failed read is reported below instead of ending the step set -- 0 # this step's status: 3 (a read failed) outranks 2 (ss is not installed), which outranks 0, because you judge what it prints sudo wg show wg0 # needs privilege; shows the listen-port (51820) + per-peer state, and a recent 'latest handshake' is the liveness signal. wg show reports CONFIGURATION, so also confirm the interface is running: case "$?" in 0) ;; *) echo "sudo wg show wg0 failed; inconclusive"; set -- 3 ;; esac ip link show wg0 # expect the UP flag in the <...> flags; a WireGuard interface usually shows operational 'state UNKNOWN', which is fine. wg show reporting a port while the interface is administratively down is why this check exists case "$?" in 0) ;; *) echo "ip link show wg0 failed; inconclusive"; set -- 3 ;; esac if command -v ss >/dev/null; then # a missing ss is a refusal, ranked as above ss -ulnp # read every UDP listener to spot any OTHER, unexpected service; do not rely on it to confirm WireGuard (the in-kernel socket has no owning process to show under -p, and a real host also lists resolved/mDNS) case "$?" in 0) ;; *) echo "ss -ulnp failed; inconclusive"; set -- 3 ;; esac else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi # ufw must be active with a default-deny (or default-reject) incoming policy, or this proves nothing # ('inactive' or a default-allow policy would pass it while the port is open). Its status shows # ufw-managed rules and defaults, not rules from ufw's rules files: sudo ufw status verbose case "$?" in 0) ;; *) echo "sudo ufw status verbose failed; inconclusive"; set -- 3 ;; esac # Check IPV6 in /etc/default/ufw: yes applies IPv6 rules; no accepts IPv6 only on loopback. # Confirm nothing admits UNAUTHORIZED external traffic to the tunneled service beyond the intended # tunnel/proxy path, for BOTH IPv4 and IPv6. The pinned manual describes `ufw show raw` # as displaying filter, nat, mangle and raw tables using iptables and ip6tables. # The pinned backend raw branch lists IPv4 filter, nat, mangle and raw tables, # and IPv6 filter, mangle and raw tables, beyond the status view: sudo ufw show raw case "$?" in 0) ;; *) echo "sudo ufw show raw failed; inconclusive"; set -- 3 ;; esac # This guide's reasoning: the iptables/ip6tables report does not replace native nftables inspection. # Read the native ruleset, including the forward rule above, separately: sudo nft list ruleset case "$?" in 0) ;; *) echo "sudo nft list ruleset failed; inconclusive"; set -- 3 ;; esac echo "block exit $1; the WireGuard, UDP listener and firewall output above is not classified, so judge it against the comments yourself" exit "$1" ) ``` REASONED: following block; the routing checks follow the cited WireGuard Cryptokey Routing and nftables documentation; no peer or tunnel is available here. Run it on the peer. It exits 3 when the positive control gets no reply and otherwise 0. The forbidden ping's loss is the expected result, and a ping that fails for another reason also exits nonzero, so the block leaves that result for you to read. ```bash ( # a subshell, so the exit below ends only this step set +e # errexit off here, so the expected loss below does not end the step set -- 0 # this step's status: 3 if the positive control fails, otherwise 0, because you judge the forbidden ping # positive control: from the peer, ping a host reachable ONLY through the tunnel (another peer, or a # server-side address in 192.168.88.0/24 that the peer cannot reach on its own LAN), so a success proves # WireGuard traversal and server routing rather than a local route ping -c1 192.168.88.10 case "$?" in 0) ;; *) echo "ping -c1 192.168.88.10 failed: the positive control failed; inconclusive"; set -- 3 ;; esac # forbidden destination: from the peer, a host the server would otherwise forward to (its own LAN, # reachable through the tunnel if not for this rule) but outside 192.168.88.0/24, so a failure here # is attributable to the firewall rule rather than to an address that was never routed or never live ping -c1 192.168.1.50 # expect 100 percent packet loss (attribution comes from the counter increase below, not this loss on its own) echo "block exit $1; the forbidden ping above is not classified, so read its loss yourself" exit "$1" ) ``` REASONED: following block; the counter checks follow the cited nftables manual and WireGuard Cryptokey Routing documentation; no WireGuard server or peer is available here. Confirm the drop is THIS rule acting, not a routing gap or a stale count, with the next three blocks, run on the server, the peer and the server in turn. The counter lives on the server and the traffic comes from the peer. It is a confirmation aid, not a rigorous proof: the counter increments on EVERY matching packet, so run it on an otherwise-idle peer. This first block exits 3 when nft fails, even after printing, or when its output lacks the chain header or the drop rule, which a missing chain, hook or rule and output in another form all cause, so it cannot tell these apart; otherwise it exits 0 and leaves the counter for you to note. ```bash ( # a subshell, so the exit below ends only this step set +e # errexit off here, so an unmatched read is reported below instead of ending the step set -o pipefail # a failed nft then fails the read below even after the filter printed lines set -- 0 # this step's status: 3 if nft failed or the chain header or drop rule did not match, otherwise 0, because you note the counter # 1) On the SERVER, read the rule's packet counter. The chain header must say 'type filter hook forward' # (an unreferenced regular chain gets no traffic) and the filter pins the full match AND the drop verdict # so a same-match accept rule is not read instead; it prints both lines and fails unless both matched: sudo nft list chain inet filter forward | awk '/type filter hook forward/ { print; h = 1 } /iifname "wg0" ip saddr 192.168.88.0\/24 ip daddr != 192.168.88.0\/24 counter .* drop/ { print; r = 1 } END { exit !(h && r) }' case "$?" in 0) ;; *) echo "nft failed, or the chain header or drop rule did not match; read any error above; inconclusive"; set -- 3 ;; esac echo "block exit $1; the chain header and counter above are not classified, so note the packet count yourself" exit "$1" ) ``` REASONED: following block; the forbidden ping for the counter check follows the cited WireGuard Cryptokey Routing documentation; no peer is available here. Run it on the peer. It exits 0 whatever the ping's result, which the counter read in the next block attributes. ```bash ( # a subshell, so the exit below ends only this step set +e # errexit off here, so the expected loss below does not end the step # 2) FROM THE PEER, send the forbidden ping (its own AllowedIPs must include 192.168.1.50, or the packet # never leaves for the server to drop): ping -c1 192.168.1.50 echo "block exit 0; the ping above is not classified: the counter read on the server attributes it" exit 0 ) ``` REASONED: following block; the second counter read follows the cited nftables manual; no WireGuard server is available here. Run it on the server. It exits 3 when nft fails, even after printing, or when no drop-rule line matches, which it cannot tell apart, and otherwise 0, leaving you to compare the packet count with the first read. ```bash ( # a subshell, so the exit below ends only this step set +e # errexit off here, so an unmatched grep is reported below instead of ending the step set -o pipefail # a failed nft then fails the read below even after grep printed the rule line set -- 0 # this step's status: 3 if nft failed or no line matched, otherwise 0, because you compare the counter # 3) On the SERVER, read the counter again: the packet count must have INCREASED by that ping: sudo nft list chain inet filter forward | grep -E 'iifname "wg0" ip saddr 192.168.88.0/24 ip daddr != 192.168.88.0/24 counter .* drop' case "$?" in 0) ;; *) echo "no line matched, or nft failed; read any error above; inconclusive"; set -- 3 ;; esac echo "block exit $1; compare this packet count with the first read yourself" exit "$1" ) ``` REASONED: following block; the ssh -R check follows the cited OpenSSH sshd_config and ssh manuals and the iproute2 ss manual; no SSH server is available here. Run it on the SSH server. It exits 2 when `ss` is not installed, 3 when `ss` fails and otherwise 0, leaving the listeners for you to judge. ```bash # ssh -R: on the SERVER, a forwarded port binds 127.0.0.1 (or [::1]) with GatewayPorts no, never 0.0.0.0 ( # a subshell, so the exit below ends only the subshell; its status is this check's alone: 2 if ss is not installed, 3 if ss fails, otherwise 0 command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp || # read every listener; 8080: only a loopback address unless you deliberately published it { echo "ss -tlnp failed; inconclusive"; exit 3; } echo "unclassified observation: read every listener above; 8080 belongs on a loopback address unless you deliberately published it" exit 0 ) ``` ## Sources (checked September 2026) - frp documentation (setup, server reference) (rolling documentation, checked November 2026): https://gofrp.org/en/docs/ - OpenSSH `sshd_config` (`GatewayPorts` default `no`, `yes` binds all interfaces, `clientspecified`): https://man.openbsd.org/sshd_config#GatewayPorts - OpenSSH `ssh` (`-R [bind_address:]port:host:hostport` remote forwarding): https://man.openbsd.org/ssh#R - frp server configuration reference (`bindPort`, `auth.token`, `transport.tls.force`) (rolling documentation, checked November 2026): https://gofrp.org/en/docs/reference/server-configures/ - frp authentication (`auth.token`, `auth.method = "oidc"`) (rolling documentation, checked November 2026): https://gofrp.org/en/docs/features/common/authentication/ - WireGuard quickstart (`wg genkey`, `wg pubkey`, `wg set`, silent-protocol behavior): https://www.wireguard.com/quickstart/ - WireGuard Cryptokey Routing (`AllowedIPs` on send and receive): https://www.wireguard.com/ - frp TLS (`transport.tls.enable` default from v0.50.0, `transport.tls.force`, cert/key/CA roles) (rolling documentation, checked November 2026): https://gofrp.org/en/docs/features/common/network/network-tls/ - nftables manual (forward/input hooks, verdicts, and rule counters): https://netfilter.org/projects/nftables/manpage.html - frps `bindAddr` default `0.0.0.0` and `bindPort` default `7000`, and an empty `proxyBindAddr` takes `bindAddr` (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/pkg/config/v1/server.go#L112-L116 - frps TCP proxies listen on `proxyBindAddr` (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/tcp.go#L76 - frps UDP proxies listen on `proxyBindAddr` (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/udp.go#L92-L97 - frps HTTP and HTTPS vhost listeners bind `proxyBindAddr` (L289 and L322), sharing the main listener only when `bindAddr` equals `proxyBindAddr` (L215-L221) (pinned tag v0.72.0, checked November 2026). Sources for this item (pinned tag v0.72.0): https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L289-L307, https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L317-L328 and https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L215-L221 - frps tcpmux HTTP CONNECT listener binds `proxyBindAddr` at `tcpmuxHTTPConnectPort` (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L182-L183 - frps stcp and sudp proxies register an in-process visitor listener (`server/visitor/visitor.go` L49-L57, `NewInternalListener`), and xtcp registers with the NAT-hole controller (`server/proxy/xtcp.go` L63); none opens a socket (pinned tag v0.72.0, checked November 2026). Sources for this item (pinned tag v0.72.0, 1 of 2): https://github.com/fatedier/frp/blob/v0.72.0/server/visitor/visitor.go#L49-L57, https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/stcp.go#L43-L46, https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/sudp.go#L43-L46 and https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/proxy.go#L202-L215. Sources for this item (pinned tag v0.72.0, 2 of 2): https://github.com/fatedier/frp/blob/v0.72.0/pkg/util/net/listener.go#L25-L37, https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/xtcp.go#L63 and https://github.com/fatedier/frp/blob/v0.72.0/pkg/nathole/controller.go#L125-L139 - autossh SSH monitoring and restart (pinned commit 90a8c2f0129f6fe19ec26c7d0fdbab4bb468f476, checked October 2026): https://github.com/Autossh/autossh/blob/90a8c2f0129f6fe19ec26c7d0fdbab4bb468f476/README#L25-L26 - Ubuntu Noble ufw manual, status verbose, show raw and IPv4/IPv6 rule visibility (rolling documentation, checked October 2026): https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html - Ubuntu Noble ufw manual, status and status verbose (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 70-93, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/doc/ufw.8?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n70 - Ubuntu Noble ufw manual, status limitations and show raw tables (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 532-541, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/doc/ufw.8?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n532 - Ubuntu Noble ufw manual, IPv6 configuration and rule display (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 546-554, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/doc/ufw.8?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n546 - Ubuntu Noble ufw default IPv6 setting (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 4-7, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/conf/ufw.defaults?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n4 - Ubuntu Noble ufw defaults path and IPv6 setting (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 1-7, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/conf/ufw.defaults?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n1 - Ubuntu Noble ufw iptables backend, raw report IPv4/IPv6 table enumeration (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 169-260, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/src/backend_iptables.py?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n169 - Ubuntu Noble ufw iptables backend, verbose status default policies (pinned commit 4992be7722389108d5f2ebb5ca221b228f0c68b1, lines 475-487, checked October 2026): https://git.launchpad.net/ubuntu/+source/ufw/tree/src/backend_iptables.py?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n475 - iproute2 ip-link manual, "ip link show - display device attributes"; the device name "specifies the network device to show." (pinned tag v6.12.0, checked October 2026): https://raw.githubusercontent.com/iproute2/iproute2/v6.12.0/man/man8/ip-link.8.in - Linux administrative IFF_UP flag (pinned tag v6.8, checked October 2026): https://github.com/torvalds/linux/blob/v6.8/Documentation/networking/operstates.rst#L39-L40 - Linux operational UNKNOWN state (pinned tag v6.8, checked October 2026): https://github.com/torvalds/linux/blob/v6.8/Documentation/networking/operstates.rst#L58-L61 - iproute2 ss manual, UDP/TCP listeners (`-l`: "Display only listening sockets"), numeric output (`-n`: "Do not try to resolve service names.") and process display (`-p`: "Show process using socket.") (pinned tag v6.12.0, checked October 2026): https://raw.githubusercontent.com/iproute2/iproute2/v6.12.0/man/man8/ss.8 - Linux WireGuard UDP socket creation (pinned tag v6.8, checked October 2026): https://github.com/torvalds/linux/blob/v6.8/drivers/net/wireguard/socket.c#L387-L393 - Linux UDP tunnel kernel socket creation (pinned tag v6.8, checked October 2026): https://github.com/torvalds/linux/blob/v6.8/net/ipv4/udp_tunnel_core.c#L17-L19 - frps OIDC verifier sets `SkipClientIDCheck` when `Audience` is empty (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/pkg/auth/oidc.go#L281-L287 - frps optional token field and default token method (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/pkg/config/v1/server.go#L130-L141 - frps login auth-key comparison (pinned tag v0.72.0, checked November 2026; empty-token acceptance is REASONED with the configuration and key derivation): https://github.com/fatedier/frp/blob/v0.72.0/pkg/auth/token.go#L64-L69 - frp auth-key derivation from token and timestamp (pinned tag v0.72.0, checked November 2026; predictable empty-token keys are REASONED from this implementation): https://github.com/fatedier/frp/blob/v0.72.0/pkg/util/util/util.go#L50-L56 - go-oidc ID token verifier, no audience check when `SkipClientIDCheck` is set, otherwise a nonempty `ClientID` that the token's audience must contain (pinned tag v3.21.0, checked November 2026): https://github.com/coreos/go-oidc/blob/v3.21.0/oidc/verify.go#L248-L257 - frp compare diff from v0.71.0 to v0.72.0: its `go.mod` hunk moves `github.com/coreos/go-oidc/v3` from v3.18.0 to v3.21.0, and its `client/connector.go` (L111-L117, L198-L203) and `pkg/transport/tls.go` (L182-L185, L188-L189, L194-L195) hunks give frpc's `trustedCaFile` verification (tags v0.71.0 and v0.72.0, checked November 2026): https://github.com/fatedier/frp/compare/v0.71.0...v0.72.0 - frpc `transport.tls.useSystemCa` field and its comment: verification using system CAs, supported only in frpc's `transport.tls`, and no verification with neither trust source (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/pkg/config/v1/common.go#L102-L105 - frp OIDC auth setter (`NewOidcAuthSetter`, taking `AuthOIDCClientConfig`) builds a `golang.org/x/oauth2/clientcredentials` configuration from the client ID, client secret, scope and token endpoint URL (pinned tag v0.72.0, checked November 2026): https://github.com/fatedier/frp/blob/v0.72.0/pkg/auth/oidc.go#L132-L152 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - frps: frps source (v0.72.0) - frpc: frpc source (v0.72.0) - frp-compare: frp compare diff (v0.71.0...v0.72.0) - tls-min: frp TLS default minimum (v0.50.0) - frp: frp documentation (unknown) - go-oidc: go-oidc source (v3.21.0) - wg: WireGuard (unknown) - ssh: OpenSSH (unknown) - autossh-source: autossh source (90a8c2f0129f6fe19ec26c7d0fdbab4bb468f476) - nft: nftables (unknown) - ufw-docs: Ubuntu Noble ufw manual (unknown) - ufw-source: Ubuntu Noble ufw source (4992be7722389108d5f2ebb5ca221b228f0c68b1) - linux-source: Linux source (v6.8) - iproute2-docs: iproute2 manuals (v6.12.0) Claims (full source mapping in the JSON block below): - frps-port (REASONED; frps): frps bindPort defaults to 7000. - frp-token (REASONED; frp, frps): Token authentication is the default; set the same long random auth.token on frps and every frpc. - frp-empty (REASONED; frp, frps): Omitted auth defaults to an empty token and accepts empty-token clients; configure token or OIDC before exposure. - frp-oidc (REASONED; frp, frps, frp-compare, go-oidc): auth.method=oidc uses Client Credentials Grant for frpc-to-frps authentication; set issuer and nonempty audience because an empty audience sets SkipClientIDCheck, under which go-oidc v3.21.0 performs no audience check. - proxy-bind (REASONED; frps): proxyBindAddr defaults to bindAddr, whose default is 0.0.0.0. - proxy-tcp (REASONED; frps): Registered TCP proxies listen on proxyBindAddr. - proxy-udp (REASONED; frps): Registered UDP proxies listen on proxyBindAddr. - proxy-http (REASONED; frps): HTTP and HTTPS proxy listeners bind proxyBindAddr. - proxy-tcpmux (REASONED; frps): tcpmux proxies listen on proxyBindAddr. - proxy-visitors (REASONED; frps): stcp, sudp and xtcp open no listener of their own on frps. - service-auth (REASONED; frp, frps): frp token/OIDC authenticates tunnel clients, not service callers; add app auth or a loopback authenticated TLS proxy. - tls-default (REASONED; tls-min): transport.tls.enable defaults true from v0.50.0, encrypting frpc-to-frps traffic. - tls-verify (REASONED; frp, frp-compare): frpc does not verify frps certificates by default; configure server certFile/keyFile and client trustedCaFile. - tls-force (REASONED; frp): Set server transport.tls.force=true to reject clients that do not negotiate TLS. - tls-system-ca (REASONED; frpc, frp-compare): frp v0.72.0 adds the frpc setting transport.tls.useSystemCa for verification against system CAs; its source comment says frpc skips verification when neither it nor trustedCaFile is set. - frp-docs-release (REASONED; frp): The cited gofrp.org pages, as served on 2026-11-05, do not say which frp release they describe and show Last modified October 21, 2026; whether they describe v0.72.0 is not established. - wg-keys (REASONED; wg): WireGuard uses per-peer key pairs, not passwords; generate under umask 077 and share only public keys. - wg-endpoint (REASONED; wg): wg set configures wg0 listen-port 51820, private key, peer public key, AllowedIPs and UDP endpoint 203.0.113.10:51820. - wg-send (REASONED; wg): AllowedIPs selects the sending peer by destination; scope to the peer subnet, using 0.0.0.0/0 only for a full-tunnel gateway. - wg-receive (REASONED; wg, nft): Receiving AllowedIPs checks the decrypted source address, not allowed destinations; enforce destination limits with a server firewall. - wg-forward (REASONED; wg, nft): The nftables drop matches the peer's whole allowed source prefix and destinations outside its permitted subnet. - nft-hook (REASONED; nft): Use a forward base chain with IP forwarding enabled; append order matters because earlier accepts short-circuit the drop. - nft-input (REASONED; nft): Forward rules do not restrict services on the server itself; those need input rules. - nft-ipv6 (REASONED; nft): The example matches IPv4 only; IPv6 AllowedIPs needs corresponding ip6 source/destination rules. - wg-port (REASONED; wg): WireGuard is normally silent when idle; open only its configured UDP ListenPort and keep other inbound services closed. - ssh-map (REASONED; ssh): ssh -R 8080:localhost:3000 forwards server port 8080 to the client's localhost:3000. - ssh-no (REASONED; ssh): GatewayPorts defaults no: loopback-only remote forwarding overrides a requested wildcard bind. - ssh-yes (REASONED; ssh): GatewayPorts yes forces wildcard listening regardless of the requested bind. - ssh-client (REASONED; ssh): GatewayPorts clientspecified honours requested wildcard or loopback binding. - ssh-auth (REASONED; ssh): SSH authenticates the tunnel, not callers; retain GatewayPorts no and front localhost:8080 with authenticated TLS. - ssh-persist (REASONED; ssh, autossh-source): Use autossh for reconnection and a dedicated key on a restricted account for persistent forwarding. - verify-token (REASONED; frp, frps): Correct token must log login success; changed and empty tokens must fail authentication, not configuration, DNS, TLS or transport. Stop each foreground client. - verify-wg (REASONED; wg, iproute2-docs, linux-source): wg show needs privilege; inspect handshake and listen-port, then ip link UP because configuration alone does not prove a running interface. - verify-firewall (REASONED; wg, nft, ufw-docs, iproute2-docs, linux-source, ufw-source): Inspect all UDP listeners and active default-deny/reject IPv4/IPv6 firewall policy; ufw status verbose shows ufw-managed rules and defaults but omits rules from ufw's rules files. Check IPV6 in /etc/default/ufw. The pinned manual describes ufw show raw as displaying filter, nat, mangle and raw tables using iptables and ip6tables; the pinned backend raw branch lists IPv4 filter, nat, mangle and raw tables and IPv6 filter, mangle and raw tables. Confirm nothing admits unauthorized external traffic to the tunneled service beyond the intended tunnel or proxy path, for both IPv4 and IPv6. This guide's reasoning is that ufw's view does not replace native nftables inspection. - verify-route (REASONED; wg, nft): An allowed ping must traverse WireGuard, not a local route; forbidden-destination loss alone does not identify the firewall cause. - verify-counter (REASONED; wg, nft): Read the server's full-match drop counter before/after the peer's routed forbidden ping; increases aid attribution only on an otherwise-idle peer. - verify-ssh (REASONED; ssh, iproute2-docs): On the SSH server, ss must show forwarded 8080 only on loopback with GatewayPorts no. ````json { "schema": 1, "checked": "2026-11-05", "documentation_checked": "2026-09", "body_sha256": "db108054b842d14f8b129304d9d9dd62ce456194515019c018d20685957d112a", "components": { "frps": { "name": "frps source", "basis": "v0.72.0", "sources": { "s09030000422a": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/config/v1/server.go#L112-L116", "s85dce7c56c14": "https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/tcp.go#L76", "s1d991aeee006": "https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L182-L183", "s959d15a56615": "https://github.com/fatedier/frp/blob/v0.72.0/server/visitor/visitor.go#L49-L57", "sbe6b9105aa7a": "https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/udp.go#L92-L97", "s9f51a3249b0e": "https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L289-L307", "sf06509e4716f": "https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L317-L328", "s011587ee9ab7": "https://github.com/fatedier/frp/blob/v0.72.0/server/service.go#L215-L221", "s0cbd427864b6": "https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/stcp.go#L43-L46", "s611fe7dd2c05": "https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/sudp.go#L43-L46", "s83240f2fb530": "https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/proxy.go#L202-L215", "sb9b4fc07642f": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/util/net/listener.go#L25-L37", "s4bc4bedb9a2c": "https://github.com/fatedier/frp/blob/v0.72.0/server/proxy/xtcp.go#L63", "s939e55614f6a": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/nathole/controller.go#L125-L139", "sa66bdab13188": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/auth/oidc.go#L281-L287", "sabb455b48565": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/config/v1/server.go#L130-L141", "sb112ed616c95": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/auth/token.go#L64-L69", "sf34ab9020639": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/util/util/util.go#L50-L56", "sebaf70b5f00f": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/auth/oidc.go#L132-L152" } }, "frpc": { "name": "frpc source", "basis": "v0.72.0", "sources": { "sf4fc148ec2ca": "https://github.com/fatedier/frp/blob/v0.72.0/pkg/config/v1/common.go#L102-L105" } }, "frp-compare": { "name": "frp compare diff", "basis": "v0.71.0...v0.72.0", "sources": { "sd31f2e5a7705": "https://github.com/fatedier/frp/compare/v0.71.0...v0.72.0" } }, "tls-min": { "name": "frp TLS default minimum", "basis": "v0.50.0", "sources": { "sdd126f42c07d": "https://gofrp.org/en/docs/features/common/network/network-tls/" } }, "frp": { "name": "frp documentation", "basis": "unknown", "sources": { "s7800b0cbd057": "https://gofrp.org/en/docs/", "seaee50f5c2ba": "https://gofrp.org/en/docs/reference/server-configures/", "s7479bbde986d": "https://gofrp.org/en/docs/features/common/authentication/", "sdd126f42c07d": "https://gofrp.org/en/docs/features/common/network/network-tls/" } }, "go-oidc": { "name": "go-oidc source", "basis": "v3.21.0", "sources": { "s074c31f14cf8": "https://github.com/coreos/go-oidc/blob/v3.21.0/oidc/verify.go#L248-L257" } }, "wg": { "name": "WireGuard", "basis": "unknown", "sources": { "sd41c4b487466": "https://www.wireguard.com/quickstart/", "sf75d6938d30e": "https://www.wireguard.com/" } }, "ssh": { "name": "OpenSSH", "basis": "unknown", "sources": { "s0fdb398555df": "https://man.openbsd.org/sshd_config#GatewayPorts", "s1f80cf6ae7f5": "https://man.openbsd.org/ssh#R" } }, "autossh-source": { "name": "autossh source", "basis": "90a8c2f0129f6fe19ec26c7d0fdbab4bb468f476", "sources": { "s6398847a2896": "https://github.com/Autossh/autossh/blob/90a8c2f0129f6fe19ec26c7d0fdbab4bb468f476/README#L25-L26" } }, "nft": { "name": "nftables", "basis": "unknown", "sources": { "s0defde7f202b": "https://netfilter.org/projects/nftables/manpage.html" } }, "ufw-docs": { "name": "Ubuntu Noble ufw manual", "basis": "unknown", "sources": { "scff3a017f30b": "https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html" } }, "ufw-source": { "name": "Ubuntu Noble ufw source", "basis": "4992be7722389108d5f2ebb5ca221b228f0c68b1", "sources": { "sa61919e3296d": "https://git.launchpad.net/ubuntu/+source/ufw/tree/doc/ufw.8?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n70", "sf5e201b3987a": "https://git.launchpad.net/ubuntu/+source/ufw/tree/doc/ufw.8?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n532", "s0b0f73700e57": "https://git.launchpad.net/ubuntu/+source/ufw/tree/doc/ufw.8?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n546", "s6161b0ad44f0": "https://git.launchpad.net/ubuntu/+source/ufw/tree/conf/ufw.defaults?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n4", "s4470c8632bc7": "https://git.launchpad.net/ubuntu/+source/ufw/tree/conf/ufw.defaults?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n1", "s1fe29ea7aa5f": "https://git.launchpad.net/ubuntu/+source/ufw/tree/src/backend_iptables.py?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n169", "s0dacc2148041": "https://git.launchpad.net/ubuntu/+source/ufw/tree/src/backend_iptables.py?id=4992be7722389108d5f2ebb5ca221b228f0c68b1#n475" } }, "linux-source": { "name": "Linux source", "basis": "v6.8", "sources": { "sc7cb2448145d": "https://github.com/torvalds/linux/blob/v6.8/Documentation/networking/operstates.rst#L39-L40", "sdfc1a2d0c3cc": "https://github.com/torvalds/linux/blob/v6.8/Documentation/networking/operstates.rst#L58-L61", "scd9d50cf72b7": "https://github.com/torvalds/linux/blob/v6.8/drivers/net/wireguard/socket.c#L387-L393", "sb02ea8702b2a": "https://github.com/torvalds/linux/blob/v6.8/net/ipv4/udp_tunnel_core.c#L17-L19" } }, "iproute2-docs": { "name": "iproute2 manuals", "basis": "v6.12.0", "sources": { "s8aa0d96d25e7": "https://raw.githubusercontent.com/iproute2/iproute2/v6.12.0/man/man8/ip-link.8.in", "s5d50117b1a36": "https://raw.githubusercontent.com/iproute2/iproute2/v6.12.0/man/man8/ss.8" } } }, "claims": { "frps-port": {"text": "frps bindPort defaults to 7000.", "components": ["frps"], "sources": ["frps:s09030000422a"], "status": "REASONED"}, "frp-token": {"text": "Token authentication is the default; set the same long random auth.token on frps and every frpc.", "components": ["frp", "frps"], "sources": ["frp:seaee50f5c2ba", "frp:s7479bbde986d", "frps:sabb455b48565", "frps:sb112ed616c95"], "status": "REASONED"}, "frp-empty": {"text": "Omitted auth defaults to an empty token and accepts empty-token clients; configure token or OIDC before exposure.", "components": ["frp", "frps"], "sources": ["frp:seaee50f5c2ba", "frp:s7479bbde986d", "frps:sabb455b48565", "frps:sb112ed616c95", "frps:sf34ab9020639"], "status": "REASONED"}, "frp-oidc": {"text": "auth.method=oidc uses Client Credentials Grant for frpc-to-frps authentication; set issuer and nonempty audience because an empty audience sets SkipClientIDCheck, under which go-oidc v3.21.0 performs no audience check.", "components": ["frp", "frps", "frp-compare", "go-oidc"], "sources": ["frp:s7479bbde986d", "frps:sa66bdab13188", "frp-compare:sd31f2e5a7705", "frps:sebaf70b5f00f", "go-oidc:s074c31f14cf8"], "status": "REASONED"}, "proxy-bind": {"text": "proxyBindAddr defaults to bindAddr, whose default is 0.0.0.0.", "components": ["frps"], "sources": ["frps:s09030000422a"], "status": "REASONED"}, "proxy-tcp": {"text": "Registered TCP proxies listen on proxyBindAddr.", "components": ["frps"], "sources": ["frps:s85dce7c56c14"], "status": "REASONED"}, "proxy-udp": {"text": "Registered UDP proxies listen on proxyBindAddr.", "components": ["frps"], "sources": ["frps:sbe6b9105aa7a"], "status": "REASONED"}, "proxy-http": {"text": "HTTP and HTTPS proxy listeners bind proxyBindAddr.", "components": ["frps"], "sources": ["frps:s9f51a3249b0e", "frps:sf06509e4716f", "frps:s011587ee9ab7"], "status": "REASONED"}, "proxy-tcpmux": {"text": "tcpmux proxies listen on proxyBindAddr.", "components": ["frps"], "sources": ["frps:s1d991aeee006"], "status": "REASONED"}, "proxy-visitors": {"text": "stcp, sudp and xtcp open no listener of their own on frps.", "components": ["frps"], "sources": ["frps:s959d15a56615", "frps:s0cbd427864b6", "frps:s611fe7dd2c05", "frps:s83240f2fb530", "frps:sb9b4fc07642f", "frps:s4bc4bedb9a2c", "frps:s939e55614f6a"], "status": "REASONED"}, "service-auth": {"text": "frp token/OIDC authenticates tunnel clients, not service callers; add app auth or a loopback authenticated TLS proxy.", "components": ["frp", "frps"], "sources": ["frp:s7479bbde986d", "frps:s85dce7c56c14", "frps:sbe6b9105aa7a", "frps:s9f51a3249b0e", "frps:s1d991aeee006"], "status": "REASONED"}, "tls-default": {"text": "transport.tls.enable defaults true from v0.50.0, encrypting frpc-to-frps traffic.", "components": ["tls-min"], "sources": ["tls-min:sdd126f42c07d"], "status": "REASONED"}, "tls-verify": {"text": "frpc does not verify frps certificates by default; configure server certFile/keyFile and client trustedCaFile.", "components": ["frp", "frp-compare"], "sources": ["frp:sdd126f42c07d", "frp-compare:sd31f2e5a7705"], "status": "REASONED"}, "tls-force": {"text": "Set server transport.tls.force=true to reject clients that do not negotiate TLS.", "components": ["frp"], "sources": ["frp:seaee50f5c2ba", "frp:sdd126f42c07d"], "status": "REASONED"}, "tls-system-ca": {"text": "frp v0.72.0 adds the frpc setting transport.tls.useSystemCa for verification against system CAs; its source comment says frpc skips verification when neither it nor trustedCaFile is set.", "components": ["frpc", "frp-compare"], "sources": ["frpc:sf4fc148ec2ca", "frp-compare:sd31f2e5a7705"], "status": "REASONED"}, "frp-docs-release": {"text": "The cited gofrp.org pages, as served on 2026-11-05, do not say which frp release they describe and show Last modified October 21, 2026; whether they describe v0.72.0 is not established.", "components": ["frp"], "sources": ["frp:s7800b0cbd057", "frp:seaee50f5c2ba", "frp:s7479bbde986d", "frp:sdd126f42c07d"], "status": "REASONED"}, "wg-keys": {"text": "WireGuard uses per-peer key pairs, not passwords; generate under umask 077 and share only public keys.", "components": ["wg"], "sources": ["wg:sd41c4b487466"], "status": "REASONED"}, "wg-endpoint": {"text": "wg set configures wg0 listen-port 51820, private key, peer public key, AllowedIPs and UDP endpoint 203.0.113.10:51820.", "components": ["wg"], "sources": ["wg:sd41c4b487466"], "status": "REASONED"}, "wg-send": {"text": "AllowedIPs selects the sending peer by destination; scope to the peer subnet, using 0.0.0.0/0 only for a full-tunnel gateway.", "components": ["wg"], "sources": ["wg:sf75d6938d30e"], "status": "REASONED"}, "wg-receive": {"text": "Receiving AllowedIPs checks the decrypted source address, not allowed destinations; enforce destination limits with a server firewall.", "components": ["wg", "nft"], "sources": ["wg:sf75d6938d30e", "nft:s0defde7f202b"], "status": "REASONED"}, "wg-forward": {"text": "The nftables drop matches the peer's whole allowed source prefix and destinations outside its permitted subnet.", "components": ["wg", "nft"], "sources": ["wg:sf75d6938d30e", "nft:s0defde7f202b"], "status": "REASONED"}, "nft-hook": {"text": "Use a forward base chain with IP forwarding enabled; append order matters because earlier accepts short-circuit the drop.", "components": ["nft"], "sources": ["nft:s0defde7f202b"], "status": "REASONED"}, "nft-input": {"text": "Forward rules do not restrict services on the server itself; those need input rules.", "components": ["nft"], "sources": ["nft:s0defde7f202b"], "status": "REASONED"}, "nft-ipv6": {"text": "The example matches IPv4 only; IPv6 AllowedIPs needs corresponding ip6 source/destination rules.", "components": ["nft"], "sources": ["nft:s0defde7f202b"], "status": "REASONED"}, "wg-port": {"text": "WireGuard is normally silent when idle; open only its configured UDP ListenPort and keep other inbound services closed.", "components": ["wg"], "sources": ["wg:sd41c4b487466"], "status": "REASONED"}, "ssh-map": {"text": "ssh -R 8080:localhost:3000 forwards server port 8080 to the client's localhost:3000.", "components": ["ssh"], "sources": ["ssh:s1f80cf6ae7f5"], "status": "REASONED"}, "ssh-no": {"text": "GatewayPorts defaults no: loopback-only remote forwarding overrides a requested wildcard bind.", "components": ["ssh"], "sources": ["ssh:s0fdb398555df", "ssh:s1f80cf6ae7f5"], "status": "REASONED"}, "ssh-yes": {"text": "GatewayPorts yes forces wildcard listening regardless of the requested bind.", "components": ["ssh"], "sources": ["ssh:s0fdb398555df"], "status": "REASONED"}, "ssh-client": {"text": "GatewayPorts clientspecified honours requested wildcard or loopback binding.", "components": ["ssh"], "sources": ["ssh:s0fdb398555df", "ssh:s1f80cf6ae7f5"], "status": "REASONED"}, "ssh-auth": {"text": "SSH authenticates the tunnel, not callers; retain GatewayPorts no and front localhost:8080 with authenticated TLS.", "components": ["ssh"], "sources": ["ssh:s0fdb398555df", "ssh:s1f80cf6ae7f5"], "status": "REASONED"}, "ssh-persist": {"text": "Use autossh for reconnection and a dedicated key on a restricted account for persistent forwarding.", "components": ["ssh", "autossh-source"], "sources": ["ssh:s1f80cf6ae7f5", "autossh-source:s6398847a2896"], "status": "REASONED"}, "verify-token": {"text": "Correct token must log login success; changed and empty tokens must fail authentication, not configuration, DNS, TLS or transport. Stop each foreground client.", "components": ["frp", "frps"], "sources": ["frp:s7479bbde986d", "frps:sabb455b48565", "frps:sb112ed616c95", "frps:sf34ab9020639"], "status": "REASONED", "verify": [1]}, "verify-wg": {"text": "wg show needs privilege; inspect handshake and listen-port, then ip link UP because configuration alone does not prove a running interface.", "components": ["wg", "iproute2-docs", "linux-source"], "sources": ["wg:sd41c4b487466", "iproute2-docs:s8aa0d96d25e7", "linux-source:sc7cb2448145d", "linux-source:sdfc1a2d0c3cc"], "status": "REASONED", "verify": [2]}, "verify-firewall": {"text": "Inspect all UDP listeners and active default-deny/reject IPv4/IPv6 firewall policy; ufw status verbose shows ufw-managed rules and defaults but omits rules from ufw's rules files. Check IPV6 in /etc/default/ufw. The pinned manual describes ufw show raw as displaying filter, nat, mangle and raw tables using iptables and ip6tables; the pinned backend raw branch lists IPv4 filter, nat, mangle and raw tables and IPv6 filter, mangle and raw tables. Confirm nothing admits unauthorized external traffic to the tunneled service beyond the intended tunnel or proxy path, for both IPv4 and IPv6. This guide's reasoning is that ufw's view does not replace native nftables inspection.", "components": ["wg", "nft", "ufw-docs", "iproute2-docs", "linux-source", "ufw-source"], "sources": ["wg:sd41c4b487466", "nft:s0defde7f202b", "ufw-docs:scff3a017f30b", "iproute2-docs:s5d50117b1a36", "linux-source:scd9d50cf72b7", "linux-source:sb02ea8702b2a", "ufw-source:sa61919e3296d", "ufw-source:sf5e201b3987a", "ufw-source:s0b0f73700e57", "ufw-source:s6161b0ad44f0", "ufw-source:s4470c8632bc7", "ufw-source:s1fe29ea7aa5f", "ufw-source:s0dacc2148041"], "status": "REASONED", "verify": [2]}, "verify-route": {"text": "An allowed ping must traverse WireGuard, not a local route; forbidden-destination loss alone does not identify the firewall cause.", "components": ["wg", "nft"], "sources": ["wg:sf75d6938d30e", "nft:s0defde7f202b"], "status": "REASONED", "verify": [3]}, "verify-counter": {"text": "Read the server's full-match drop counter before/after the peer's routed forbidden ping; increases aid attribution only on an otherwise-idle peer.", "components": ["wg", "nft"], "sources": ["wg:sf75d6938d30e", "nft:s0defde7f202b"], "status": "REASONED", "verify": [4, 5, 6]}, "verify-ssh": {"text": "On the SSH server, ss must show forwarded 8080 only on loopback with GatewayPorts no.", "components": ["ssh", "iproute2-docs"], "sources": ["ssh:s0fdb398555df", "ssh:s1f80cf6ae7f5", "iproute2-docs:s5d50117b1a36"], "status": "REASONED", "verify": [7]} } } ```` ====================================================================== ==> authentication.md ====================================================================== # Strong authentication baseline Authentication is how a service confirms who is asking before allowing access. This guide helps your AI assistant apply the baseline rules on every service, including deny by default, no default or shared credentials, credentials only over TLS or an equivalent such as SSH, modern password hashing, and rate-limited logins. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/authentication.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against OWASP authentication and password storage (no recorded version), OAuth security BCP RFC 9700, OpenID Connect Core 1.0, NIST password guidance SP 800-63B-4, Python bcrypt rejection threshold 5.0.0, OpenSSL rand 3.5, Python secrets 3.13, and Git ignore rules v2.49.0, plus 15 more recorded bases; 16 further sources are pinned to a commit and 7 carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 58 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. TLS without authentication leaves a service open to the whole internet over an encrypted channel. These rules apply to every service in this repository's guides. Each rule is a requirement unless it is marked `should` or phrased as a preference. ## Rules 1. **Deny by default.** Every endpoint that is not deliberately public must require authentication, applying OWASP's [deny-by-default principle](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Authorization_Cheat_Sheet.md#L30-L38). Require authentication for APIs, health dashboards, admin panels, metrics, and message queues, and publish an explicit list of public paths (this guide's recommendation; the cited passage does not cover this); everything else authenticates. 2. **No default or shared credentials.** OWASP notes that [shared secrets hinder attribution](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L7). Change or disable every vendor default account before exposure. Each human gets an individual account; each service gets its own credential. Never ship credentials in code, containers, or documentation (these are this guide's recommendations; the cited passage does not cover them). 3. **TLS first.** Credentials must only cross the network inside TLS, or an equivalent authenticated encrypted transport such as SSH. Serve the login page and the whole authenticated session over HTTPS, not only the credential POST: a network attacker can rewrite an HTTP login page to steal what it submits. HTTP basic authentication and bearer tokens are acceptable only over HTTPS, because both send the secret with every request. 4. **Hash passwords with a modern algorithm.** Prefer argon2id; use scrypt where argon2id is unavailable, bcrypt only for legacy systems, and correctly parameterized PBKDF2 where FIPS compliance requires it (per current OWASP guidance). Never store plaintext, and never use unsalted or fast hashes such as MD5 or SHA-256 for passwords. bcrypt has a 72-byte input limit; an implementation may reject a longer input (Python `bcrypt` 5.0.0+ raises `ValueError`) or silently truncate it (earlier versions do), so prefer argon2id or scrypt for long passphrases. If legacy bcrypt is unavoidable, pre-hash only with a keyed construction (HMAC with a stored pepper, then base64), never a bare digest, which enables password shucking. - Node.js: `argon2` or `bcrypt` packages. - Python: `argon2-cffi` or `bcrypt`. - Shell (for htpasswd files): `htpasswd -B -C 12` (bcrypt; the bare `-B` default cost is 5, below the OWASP minimum of 10). 5. **Generate secrets randomly and keep them out of the repository.** ```bash openssl rand -base64 32 python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` Prefer a secret manager (this guide's recommendation; OWASP lists [secret managers](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L313) as one option). For containers, OWASP advises [environment variables only when mounted files or a secret-store fetch are not possible](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L462-L464). Add `.env` to `.gitignore` before the first commit, and scan the repository for leaked secrets (for example with gitleaks) before pushing. A secret that has reached a public repository, a chat, or a log is compromised: rotate it, since deleting the file does not unpublish it. 6. **Prefer SSO/OIDC over local accounts** where the product supports it, and enable MFA wherever available. [Cloudflare Access](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) can place an authentication layer in front of a self-hosted web application published through Access; the cited pages show that pattern, not that any application works behind it unchanged (this guide's assessment; no application-compatibility list is cited). For the login step, Cloudflare documents [integrating your organization's identity provider](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/index.mdx#L14-L18), with multiple identity providers usable simultaneously and a [generic OIDC connector](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/generic-oidc.mdx#L15) for identity providers not already set in Access, and sending a [one-time PIN to approved email addresses](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/one-time-pin.mdx#L15) as an alternative. OTP login and the identity provider of your choice can be configured simultaneously in Cloudflare Access so users select their own authentication method. Create the Access application before the tunnel route; without it, the published application is available to anyone on the Internet. Validate the Access JWT at the origin. For a Tunnel route, configure origin TLS verification and Access JWT checking; for a directly proxied HTTPS origin, use Full (strict) where applicable. Where no native MFA exists, add it with an identity layer, an app-level TOTP library, or a hosted service, per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). 7. **Scope machine access.** API clients get their own tokens with the least privilege the task needs, not an admin password. Support and exercise rotation; set expiry where the platform allows it. 8. **Harden sessions.** Set cookies `Secure`, `HttpOnly`, and `SameSite` (`Lax` or `Strict`) (per [OWASP session-cookie guidance](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L131-L149)), sign them with a strong random secret (this guide's recommendation; the cited passage does not cover signing), and enforce an idle and an absolute timeout server-side (a cookie expiry alone does not stop a replayed cookie). Use an unpredictable session identifier, regenerate it (invalidating the old one) on login and on any privilege change, and reject a session ID the server never issued (session fixation). Invalidate sessions server-side on logout and on password change. `SameSite` is not a complete CSRF defense (`Lax` still allows a top-level state-changing GET, and a sibling subdomain can be same-site), so a cookie-authenticated state-changing request must also carry an anti-CSRF token or a strict `Origin`/`Sec-Fetch-Site` check, and must never change state through a safe method such as GET. 9. **Rate-limit authentication endpoints** and lock or delay after repeated failures. Per-client rate limits do not stop distributed credential stuffing (valid breached passwords spread thin across many addresses), so add account-aware and aggregate abuse controls alongside source-based ones, and avoid a lockout an attacker can use to deny a user access. Keep login, registration, and password-reset responses uniform in message and timing so they do not disclose whether an account exists. Log authentication successes and failures with source address and account, and keep the logs long enough to investigate an incident. For SSH on Linux hosts, enable the relevant fail2ban jail (guide requirement): the shipped `sshd` jail uses an `sshd` failure filter, and jails are [disabled by default](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L152-L167). Beyond SSH, fail2ban 1.1.1 ships the [`nginx-http-auth` jail](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L370-L376) using the nginx error log and the [`webmin-auth` jail](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L491-L495) using `syslog_authpriv` with `syslog_backend`. The `nginx-http-auth` [normal-mode patterns](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/nginx-http-auth.conf#L9-L37) match password-mismatch, user-not-found and authentication-failure messages with a client address; its comments say an extensive search of all nginx authentication failures has not been done. The `webmin-auth` [patterns](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/webmin-auth.conf#L4-L20) match the shown Webmin `Non-existent login` and `Invalid login` messages with a source host. Enable only the relevant jails in local configuration and, for file backends, set `logpath` to the matching logs; the shipped entries are starting points, so confirm the selected filter matches your deployment's failures (this guide's advice based on those settings). These files establish the listed jails and patterns, not coverage of a particular panel's log format or ease of setup (this guide's assessment); other login panels still require suitable filters and logs. Bound expensive endpoints too: inference, uploads, and job submission need request-size, concurrency, and timeout limits in addition to per-client rate limits. The request-size, concurrency and timeout limits are needed because an exposed AI endpoint left without them can burn GPU time and money even while correctly rejecting bad credentials, a failure mode known as denial of wallet. [realtime-webhooks.md](https://github.com/jposluns/secureconfig/blob/main/realtime-webhooks.md) covers the streaming and webhook transports these limits also apply to. 10. **Least privilege everywhere.** Separate admin from daily-use accounts, and give database and OS service accounts only the rights the application uses. 11. **Federated login is authentication, not authorization.** After Google, Microsoft, GitHub, or any provider returns an identity, check it against an application allowlist (tenant, hosted domain, organization or group membership, or explicit users) before granting access (this guide's recommendation); a Google sign-in alone does not make someone staff. For Google Workspace or Cloud organizations, check the `hd` claim in the validated ID token against an accepted domain; neither the email domain nor the request `hd` parameter controls access. Microsoft's multi-tenant `common` endpoint serves organizational and personal accounts. Validate the token issuer, signing-key issuer, and `tid`, then apply the application tenant allowlist. [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) has the checks; [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) has the providers. 12. **OIDC and OAuth hygiene.** Authorization code flow with PKCE (`S256`, never `plain`); exact-match redirect URIs; fresh per-transaction `state` and `nonce`, verified and bound to the initiating browser session. ID tokens validated for signature (keys from the provider's JWKS, algorithm pinned, never `none`), issuer, audience, and expiry; short-lived access tokens with refresh-token rotation; tokens never in URLs. Prefer a server-side session in an `HttpOnly` cookie to tokens in browser storage. Link accounts by issuer plus subject, never by email alone. 13. **Enforce MFA where access is granted, not only where it is enrolled.** A user who enrolled a second factor but can still act with a password-only session is not protected. Require the factor in provider policy or in the app, and test it. 14. **Protect the control plane.** MFA on the Git host, the cloud account, the DNS registrar, the deployment platform, the secret manager, and the identity provider's administrator account. A takeover there bypasses every control inside the app. 15. **Authenticate every transport.** WebSockets, server-sent events, GraphQL, gRPC, webhooks (verify the sender's signature and reject replays), inference endpoints, and management APIs each need their own authentication check (this guide's requirement). Do not rely on the HTML login to protect them (this guide's requirement); the cited protocol sources do not establish that an HTML login protects these endpoints (this guide's assessment). RFC 6455 says the protocol [does not prescribe how servers authenticate clients during the WebSocket handshake](https://www.rfc-editor.org/rfc/rfc6455.html#section-10.5) and that the WebSocket server can use any client authentication mechanism available to a generic HTTP server, such as cookies, HTTP authentication or TLS authentication. RFC 6455's guidance is that a server meant to process input only from certain sites [SHOULD verify the `Origin` field](https://www.rfc-editor.org/rfc/rfc6455.html#section-10.2) and answer an unacceptable origin with `403 Forbidden`. OWASP says [WebSockets have no built-in authentication and browsers include cookies in WebSocket handshake requests](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L74), exposing them to cross-site WebSocket hijacking, and to [validate the `Origin` header on every handshake against an explicit allowlist of trusted origins](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L85). WHATWG's EventSource constructor [sets the credentials mode for connection requests to `include` when `withCredentials` is true](https://html.spec.whatwg.org/multipage/server-sent-events.html) (living standard, last updated 3 November 2026 when checked). These sources show that browser credentials such as a login cookie can reach a WebSocket or SSE endpoint, not that the endpoint checks them (this guide's assessment). Stripe's webhook helper checks the [signature](https://github.com/stripe/stripe-node/blob/v18.5.0/src/Webhooks.ts#L182-L216) and a [timestamp tolerance](https://github.com/stripe/stripe-node/blob/v18.5.0/src/Webhooks.ts#L357-L410); comprehensive replay rejection remains this guide's application requirement, not a guarantee from those checks (this guide's assessment). OWASP's [GraphQL access-control list](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/GraphQL_Cheat_Sheet.md#L190-L198) recommends checking authorization for requested data, on both edges and nodes, disabling introspection queries system-wide in any production or publicly accessible environments, and disabling GraphiQL and similar schema exploration tools in those environments. For publicly consumed APIs, its separate [introspection/GraphiQL advice](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/GraphQL_Cheat_Sheet.md#L287-L290) is to consider disabling introspection for unauthenticated or unauthorized users, not a general GraphQL authentication boundary (this guide's assessment). GraphQL's [authorization page](https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L5) says GraphQL execution should begin after authentication middleware confirms the user's identity and passes that information to the GraphQL layer. That page's [example](https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L22) has that middleware check the user's credentials and add their identity to the request's `context` object for every field resolver. That page's [recap](https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L89-L92) says authorization logic should be delegated to the business logic layer and that, after execution begins, a GraphQL server should decide whether the client is authorized to access data for the included fields. These lines place authentication before GraphQL execution and pass the authenticated user through `context`; they do not say how to authenticate a GraphQL request, which credentials to accept or what the middleware must check (this guide's assessment). For GraphQL served over HTTP, graphql.org's serving page says [GraphQL should be placed after all authentication middleware](https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/serving-over-http.mdx#L21) so that it has the same session and user information as the other HTTP endpoint handlers, and its [recap](https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/serving-over-http.mdx#L132) says the server should handle user authentication before a GraphQL request is validated. The GraphQL over HTTP working draft says a server [MAY forbid a request, for example to require authentication, and SHOULD then use the relevant `4xx` or `5xx` status code; that decision SHOULD NOT be based on the contents of a well-formed GraphQL-over-HTTP request](https://github.com/graphql/graphql-over-http/blob/d438b209d60fa3d34b0530b605e60d5640c7ef9b/spec/GraphQLOverHTTP.md#L115-L118); its [security section](https://github.com/graphql/graphql-over-http/blob/d438b209d60fa3d34b0530b605e60d5640c7ef9b/spec/GraphQLOverHTTP.md#L705-L720) puts general security concerns out of scope and does not repeat authorization and authentication security. Over HTTP, then, graphql.org recommends authenticating a GraphQL request with the application's own HTTP authentication middleware, as for its other endpoints (this guide's reading of those lines). The choice of credentials to accept and what that middleware must check remain this guide's requirements under rules 7, 8 and 12, and these pages do not cover GraphQL over a non-HTTP transport (this guide's assessment). The GraphQL specification's September 2025 Edition says [GraphQL requests do not require any specific serialization format or transport mechanism](https://spec.graphql.org/September2025/#sec-Execution), which should be chosen by the implementing service; that passage names no credential or authentication mechanism (this guide's reading). As vendor examples only, Apollo Server's [authentication page](https://github.com/apollographql/apollo-server/blob/0c22029c2af47d3829e730e9820c01cef0557627/docs/source/security/authentication.mdx#L25-L62) has a `context` function extract a user token from the HTTP `Authorization` header and add the user to `contextValue`, with an [API-wide variant](https://github.com/apollographql/apollo-server/blob/0c22029c2af47d3829e730e9820c01cef0557627/docs/source/security/authentication.mdx#L79-L135) that throws an `UNAUTHENTICATED` error so the operation is not executed. Hasura, as a second vendor example only, says to [configure an admin secret key](https://github.com/hasura/graphql-engine/blob/216ee9a960a54181dabc2257a2e7925a1fb87bbd/docs/docs/deployment/securing-graphql-endpoint.mdx#L14-L15) so that its GraphQL endpoint and Console are not publicly accessible, and describes a [JWT mode](https://github.com/hasura/graphql-engine/blob/216ee9a960a54181dabc2257a2e7925a1fb87bbd/docs/docs/auth/overview.mdx#L63-L66) that integrates with an existing authentication service. The Apollo Server and Hasura citations above are documentation source files at pinned commits; the published pages carried the same statements when checked in October 2026. As a further vendor example only, Hasura's v2.x [JWT page](https://hasura.io/docs/2.0/auth/authentication/jwt/) says that by default Hasura Engine reads the JWT from an `Authorization: Bearer` header, documents a `Cookie` mode and a custom header mode as alternatives, and says a JWT whose `iss` or `aud` claim does not equal a configured `issuer` or `audience` value is rejected. The same page says that by default a JWT omitting the `aud` or `iss` claim is still accepted when `audience` or `issuer` is configured, unless `extra_required_claims` lists `"audience"` or `"issuer"` respectively, and warns that where providers share JWKs between multiple tenants, leaving `audience` unset allows JWTs issued for other tenants to be valid as well. As a vendor example only, Apollo Server's v4-v5 [subscriptions page](https://www.apollographql.com/docs/apollo-server/data/subscriptions), whose example serves subscriptions from a WebSocket server through `graphql-ws`, says the client's `connectionParams` are passed to the server, enabling it to validate the user's credentials. That page also says an `onConnect` function can reject an incoming connection by returning `false` or by throwing an exception, for example to check authentication when a client first connects. These vendor pages document each product's own credential placement and checks, not a GraphQL credential scheme or a non-HTTP authentication mechanism for other servers (this guide's assessment). OWASP requires [access control at each non-public REST endpoint](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/REST_Security_Cheat_Sheet.md#L35-L38), describing authentication, authorization and session management and recommending centralized user authentication with local endpoint access decisions; it also calls for [strong authentication when management endpoints must be Internet-accessible](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/REST_Security_Cheat_Sheet.md#L167-L170). Apply those REST controls to REST-style inference and management APIs (this guide's requirement and reasoning; the passage does not name inference APIs). The gRPC authentication guide distinguishes [channel credentials, such as SSL credentials, from call credentials attached to a call](https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L62-L74), and says the two can be combined so that the call credentials' authentication data is sent with every call made on the channel. It says [SSL/TLS authenticates the server and encrypts the data exchanged](https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L23-L26), with optional client certificates for mutual authentication, and its description of [token-based authentication with Google](https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L38-L46) says that mechanism must in general be used as well as SSL/TLS on the channel, adding that most gRPC language implementations will not let you send credentials on an unencrypted channel. For OAuth, it says [the token is always part of per-call credentials](https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L122-L131), normally sent in the HTTP Authorization header, and that the server verifies it, in most implementations with a server-side interceptor. Applying these credentials to inference or management APIs served over gRPC is this guide's inference; the file does not mention those APIs and does not establish that a given server verifies call credentials (this guide's assessment). In grpc-go terms, the authentication example's README says that [on the server side users usually get the token and verify it inside an interceptor, returning `codes.Unauthenticated` for an invalid token](https://github.com/grpc/grpc-go/blob/v1.82.2/examples/features/authentication/README.md#L49-L62), and its [server](https://github.com/grpc/grpc-go/blob/v1.82.2/examples/features/authentication/server/main.go#L57-L65) installs that interceptor with `grpc.UnaryInterceptor`. The go-grpc-middleware v2.3.3 [unary](https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L26-L50) and [stream](https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L52-L70) auth interceptors run an application-supplied `AuthFunc`, or a service's `AuthFuncOverride`, before the handler. An example and a middleware library show verification by code the server installs; they do not establish that grpc-go, or any other gRPC implementation, lacks a built-in check (this guide's assessment). From source reading of pinned code, verification differs by server: vLLM v0.30.0's gRPC launcher [creates its server without an interceptor and binds it with `add_insecure_port`](https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/grpc_server.py#L87-L118) (see [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md)). Triton, from the same source reading, [runs a call only when the client metadata carries the configured restricted header key and value, otherwise returning `UNAVAILABLE`](https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.cc#L194-L225), for the API groups `--grpc-restricted-protocol` names (see [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md)). Text Embeddings Inference, from the same source reading, [adds an `authorization` check returning `unauthenticated` to its info, tokenize, embed, predict and rerank services only when an API key is set, and never to health or reflection](https://github.com/huggingface/text-embeddings-inference/blob/1bb59202500e5f69dd8be63dd1604f7625124fbe/router/src/grpc/server.rs#L1472-L1508) (a reading new to this guide; [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) cites that file only for the gRPC build's metrics exporter). The argocd-server [gRPC interceptor chains](https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L961-L985), from the same source reading, call a default [authentication function](https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1537-L1576) that returns without checking credentials when auth is disabled and otherwise attempts token authentication, rejecting the call after a claims error unless settings load and anonymous access is enabled, in which case it permits the call anonymously. Services that override that function are the exception: the server [imports its auth interceptors from `go-grpc-middleware/v2`](https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L37), which Argo CD's `go.mod` pins at v2.3.3 according to [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md), and in v2.3.3 those interceptors run a service's `AuthFuncOverride` in place of the default function when the service implements it. The gitops-controllers.md guide records the overriding services; that guide did not compare the `go.sum` module hash or fetch grpc-go, which supplies the service value. These are inference and management data points, not a general boundary, and whether a deployed server verifies call credentials needs a live test with a real gRPC client (this guide's assessment). For browser WebSocket clients using tokens, OWASP says to [withhold protected data until token validation succeeds and close on authentication failure or timeout](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L118-L120), and to [check authorization for each action](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L128). Standard Webhooks specifies [authenticating the payload, timestamp and stable event identifier](https://github.com/standard-webhooks/standard-webhooks/blob/7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b/spec/standard-webhooks.md#L133-L143), with the identifier usable to process an event only once even after malicious repeats, and recommends both [timestamp tolerance and webhook-id deduplication](https://github.com/standard-webhooks/standard-webhooks/blob/7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b/spec/standard-webhooks.md#L233-L234). As vendor guidance for its own events, Stripe's [webhook documentation](https://docs.stripe.com/webhooks#handle-duplicate-events) says webhook endpoints might occasionally receive the same event more than once, and that logging the processed event IDs and then not processing already-logged events guards against duplicated event receipts. The same section says that in some cases two separate Event objects are generated and sent, and to identify these duplicates by the ID of the object in `data.object` along with `event.type`. Reject repeats within the accepted timestamp window too (this guide's requirement and reasoning from the separate tolerance and idempotency checks); the specification's five-minute ID-storage example does not establish a universal retention period or comprehensive deployed replay rejection (this guide's assessment). These are standards, guidance and source readings, not established deployment behaviour: which credentials a GraphQL request is to carry, what its authentication middleware must check and how GraphQL over a non-HTTP transport is authenticated remain unestablished (this guide's assessment). A general HTML-login boundary for non-HTTP transports, authentication boundaries for non-REST inference and management APIs in general and on servers not read here, and a deployed application's authentication and comprehensive replay rejection also remain unestablished (this guide's assessment). GraphQL, inference and management APIs still require their own authentication checks. Machine credentials follow [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md). 16. **Offboard promptly.** When a person leaves, revoke their provider membership, proxy sessions, application sessions, and personal tokens (this guide's recommendation). Microsoft Entra documents blocking new sign-ins and revoking refresh tokens, but says the application must revoke its own session; its access-token lifetime varies, and CAE critical-event enforcement can take up to 15 minutes for supported services. Cloudflare Access documents per-user session revocation, but an active IdP profile can start a new session; after user logout, previously issued tokens stop being accepted in 20-30 seconds. OAuth2 Proxy and Authelia document session-cookie expiry, but the cited passages do not establish immediate per-user revocation for those proxies. RFC 7009 specifies immediate token invalidation with possible propagation delay and conditional access-token revocation support. The fetched sources do not establish a provider-membership removal mechanism or this deployment's measured maximum time-to-revoke. Measure each system's maximum time-to-revoke where immediate revocation is unavailable, and work to shrink it (this guide's recommendation; no delay was measured here). [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md) has the lifecycle checks; [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) covers revoking the second factor along with the account. 17. **Set a password policy and reject breached passwords.** Require a minimum length of at least 8 characters when a second factor is enforced, otherwise at least 15 (NIST SP 800-63B-4); allow long passphrases and every character, and drop periodic-rotation and composition rules. Screen new and changed passwords against a known-breach corpus (for example the Pwned Passwords range API), because a correct breached password defeats both hashing and per-IP rate limits. 18. **Secure account recovery.** Password and MFA reset is a sensitive authentication surface: issue cryptographically random, single-use, short-lived, account-bound reset tokens; deliver them only to a pre-verified channel; keep the reset response non-enumerating; reauthenticate for a recovery-address or MFA-factor change; and on completion invalidate outstanding sessions and unused reset tokens. Recovery must never silently drop the MFA requirement. ## Quick checks - REASONED: offboarding timing is this guide's test expectation, informed by the cited [Microsoft Entra session model](https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L62), [Cloudflare Access session revocation](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L159-L186), and [RFC 7009 token revocation](https://www.rfc-editor.org/rfc/rfc7009.html#section-2.1). No deployed IdP, proxy, application, token issuer or account is available in the authoring environment, so no revocation request or protected-resource replay was run. With a disposable account, first confirm that an authenticated request to a protected resource succeeds with each provider, proxy and application session and personal token. Remove provider membership and revoke the sessions and tokens, then replay the same protected-resource request with each old credential at recorded intervals until it is refused and returns no protected data; record the elapsed time for every layer and compare each result with its configured maximum time-to-revoke. Before revocation the matched request must succeed; after revocation each old credential must be refused and return no protected data. The check fails if an old credential is still accepted or returns protected data beyond its configured maximum time-to-revoke, or if measured elapsed time to refusal exceeds that maximum. These are deployment test expectations, not observed results. - REASONED: unauthenticated-rejection comparisons per the cited OWASP Authentication Cheat Sheet, with the expected rejections stated in this item; no deployed service exposing these paths and transports is available in the authoring environment, so no request was sent. A protocol-appropriate unauthenticated request to every non-public path from rule 1 (metrics, health, admin, message-queue UIs) and every transport from rule 15 (WebSocket, SSE, GraphQL, gRPC, webhook, inference, management API), not just the HTML app, is rejected and returns no protected data. For that unauthenticated request, an HTTP endpoint gives `401`, `403`, or a login redirect; a gRPC call gives `UNAUTHENTICATED` for missing or invalid credentials (`PERMISSION_DENIED` for an identified caller lacking permission; status codes per the [gRPC status-code documentation](https://github.com/grpc/grpc/blob/v1.84.0/doc/statuscodes.md#L13-L24)); an SSE stream is refused. For the same unauthenticated request, a WebSocket either rejects the upgrade (handshake-time auth) or, with first-message auth, permits the upgrade but rejects protected operations, returns no protected data, and closes the connection on auth failure. The expected rejections for every path and transport in this item are test expectations, not observed service responses. A bare `curl` GET does not exercise the non-HTTP transports, so test each with a real client. - DEMONSTRATED: scanner discrimination on disposable fixture repositories at marking time, with gitleaks 8.30.1: a planted randomized key was reported by the history, working-tree and staged scans (exit 9, leaks found: 1) and a clean control repository reported no leaks (exit 0), while the supplementary git log pattern matched a password-named diff and missed a renamed key, as this item warns. Gitleaks exits 9 only when invoked with --exit-code 9, and its default exit status on leaks is 1, re-observed at marking time across the history, working-tree and staged scan modes with gitleaks 8.30.1, with exit 0 on clean scans. The fixture results demonstrate the checks, not that any given repository is clean. A secret scanner (gitleaks or similar) over the working tree, staged changes, and full history comes back clean; Gitleaks documents directory and Git scans plus pre-commit use. While the fetched Gitleaks README does not specify a separate staged-scan mode, `cmd/git.go` at v8.30.1 defines `--staged` ("scan staged commits (good for pre-commit)") and `--pre-commit` flags that build a Git diff command instead of the Git log command, so verify staged, all-ref and merge-commit coverage with the selected scanner and configuration. The README says the `git` command scans `git log -p` patches and that `--log-opts` configures that command, with `--log-opts="--all commitA..commitB"` as its range example. Git documents `--all` as listing all the refs in `refs/`, along with `HEAD`, and documents that merge commits show no diff, even with `--patch`, unless a `--diff-merges` variant (including short `-m`) is given or `--first-parent` is in use, so passing `--all` and such a variant through `--log-opts` is this guide's route to all-ref and merge-commit history. Those README and Git lines show the flags and `git log` options, not that the scanner reports leaks from every resulting diff or ref (this guide's assessment), which is why the coverage check above stays required. `git log -p | grep -iE 'password|secret|api[_-]?key'` is only a supplementary quick check, since it misses secrets without those words and does not cover uncommitted files, every ref, or changes that appear only in merge commits. `git log -p -m --all` would, per the cited Git lines, extend the patch stream to all refs and merge diffs while keeping the word-list and uncommitted-file misses, and no such extended run is recorded here (the historical fixture run used the command as shown). - REASONED: default-account inspection recommended by this guide; no deployed user store is available in the authoring environment, so no account listing was read. The user store contains no account named `admin`, `test`, or `demo` with a known or empty password. - REASONED: negative-test expectations per the cited OAuth 2.0 Security Best Current Practice (RFC 9700) and OpenID Connect Core 1.0; no deployed application, token issuer or fronting layer is available in the authoring environment, so none of these rejections was observed. The wrong-tenant expectation follows Microsoft's issuer and `tid` validation rules plus this guide's tenant-allowlist recommendation. The cross-user expectation follows the [pinned OWASP Authorization Cheat Sheet](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Authorization_Cheat_Sheet.md#L93-L99). Cloudflare describes Access policy enforcement, origin JWT validation and Tunnel Access JWT checking, and says a publicly routable origin needs its IP protected by other methods. OAuth2 Proxy documents upstream identity headers and warns that when `--reverse-proxy` is enabled and `--trusted-proxy-ip` is unset, a client that can reach OAuth2 Proxy directly may be able to spoof forwarded headers; Authelia uses session cookies to authorize protected websites. At v7.15.6 that OAuth2 Proxy warning adds "Include the direct peer and every trusted proxy hop that can appear in `X-Forwarded-For`." Google IAP says bypassing it permits forged unsigned identity headers; its signed-header how-to says to verify the JWT header, payload and signature to secure an app with the IAP JWT, and calls signed headers secondary security; AWS ALB requires verification of its signed claims header and signer ARN. Origin isolation remains a deployment test expectation; none of these documents establishes the reachability of a particular origin. Negative tests should pass: a missing, expired, wrong-issuer, wrong-audience, or wrong-tenant token is rejected; user A cannot read user B's resources; the origin is unreachable except through its fronting layer. ## Sources (checked September 2026) - fail2ban default disabled setting (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L152-L167 - fail2ban sshd jail configuration (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L271-L279 - fail2ban sshd filter (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/sshd.conf#L104-L111 - fail2ban 1.1.1 nginx HTTP-auth jail (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L370-L376 - fail2ban 1.1.1 nginx HTTP-auth patterns and coverage caveat (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/nginx-http-auth.conf#L9-L37 - fail2ban 1.1.1 Webmin authentication jail (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L491-L495 - fail2ban 1.1.1 Webmin authentication patterns and examples (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/webmin-auth.conf#L4-L20 - gRPC authentication and permission status codes (checked October 2026): https://github.com/grpc/grpc/blob/v1.84.0/doc/statuscodes.md#L13-L24 - WebSocket opening handshake (RFC 6455, checked October 2026): https://www.rfc-editor.org/rfc/rfc6455.html#section-1.3 - WebSocket closing handshake (RFC 6455, checked October 2026): https://www.rfc-editor.org/rfc/rfc6455.html#section-1.4 - WebSocket client authentication left to the server (RFC 6455, checked October 2026): https://www.rfc-editor.org/rfc/rfc6455.html#section-10.5 - WebSocket Origin verification (RFC 6455, checked October 2026): https://www.rfc-editor.org/rfc/rfc6455.html#section-10.2 - WHATWG EventSource failure handling and withCredentials credentials mode (living standard last updated 3 November 2026, withCredentials text checked November 2026, failure handling checked October 2026): https://html.spec.whatwg.org/multipage/server-sent-events.html - Stripe webhook signature computation (checked October 2026): https://github.com/stripe/stripe-node/blob/v18.5.0/src/Webhooks.ts#L182-L216 - Stripe webhook signature and timestamp tolerance (checked October 2026): https://github.com/stripe/stripe-node/blob/v18.5.0/src/Webhooks.ts#L357-L410 - Stripe webhook duplicate events and processed event IDs (checked November 2026): https://docs.stripe.com/webhooks#handle-duplicate-events - OWASP GraphQL data, edge and node authorization and production/public introspection and GraphiQL disabling (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/GraphQL_Cheat_Sheet.md#L190-L198 - OWASP GraphQL introspection and authentication scope (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/GraphQL_Cheat_Sheet.md#L287-L290 - OWASP non-public REST endpoint access control (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/REST_Security_Cheat_Sheet.md#L35-L38 - OWASP management endpoint authentication (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/REST_Security_Cheat_Sheet.md#L167-L170 - OWASP WebSocket token authentication (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L118-L120 - OWASP WebSocket per-action authorization (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L128 - OWASP WebSocket lack of built-in authentication and handshake cookies (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L74 - OWASP WebSocket Origin allowlist validation (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L85 - Standard Webhooks signed metadata and stable event identifiers (pinned commit 7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b, checked October 2026): https://github.com/standard-webhooks/standard-webhooks/blob/7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b/spec/standard-webhooks.md#L133-L143 - Standard Webhooks timestamp tolerance and ID deduplication (pinned commit 7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b, checked October 2026): https://github.com/standard-webhooks/standard-webhooks/blob/7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b/spec/standard-webhooks.md#L233-L234 - GraphQL execution after authentication (pinned commit be061651a39b9b85115bf3e430d516eab9610cee, checked October 2026): https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L5 - GraphQL authentication middleware and request context (pinned commit be061651a39b9b85115bf3e430d516eab9610cee, checked October 2026): https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L22 - GraphQL authorization in the business logic layer (pinned commit be061651a39b9b85115bf3e430d516eab9610cee, checked October 2026): https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L89-L92 - GraphQL placement after all authentication middleware (pinned commit be061651a39b9b85115bf3e430d516eab9610cee, checked October 2026): https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/serving-over-http.mdx#L21 - GraphQL user authentication before request validation (pinned commit be061651a39b9b85115bf3e430d516eab9610cee, checked October 2026): https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/serving-over-http.mdx#L132 - GraphQL over HTTP working draft, forbidding requests to require authentication (pinned commit d438b209d60fa3d34b0530b605e60d5640c7ef9b, checked October 2026): https://github.com/graphql/graphql-over-http/blob/d438b209d60fa3d34b0530b605e60d5640c7ef9b/spec/GraphQLOverHTTP.md#L115-L118 - GraphQL over HTTP working draft, security scope (pinned commit d438b209d60fa3d34b0530b605e60d5640c7ef9b, checked October 2026): https://github.com/graphql/graphql-over-http/blob/d438b209d60fa3d34b0530b605e60d5640c7ef9b/spec/GraphQLOverHTTP.md#L705-L720 - Apollo Server authentication and context example (pinned commit 0c22029c2af47d3829e730e9820c01cef0557627, checked October 2026): https://github.com/apollographql/apollo-server/blob/0c22029c2af47d3829e730e9820c01cef0557627/docs/source/security/authentication.mdx#L25-L62 - Apollo Server API-wide `UNAUTHENTICATED` error variant (pinned commit 0c22029c2af47d3829e730e9820c01cef0557627, checked October 2026): https://github.com/apollographql/apollo-server/blob/0c22029c2af47d3829e730e9820c01cef0557627/docs/source/security/authentication.mdx#L79-L135 - Hasura GraphQL endpoint admin secret (pinned commit 216ee9a960a54181dabc2257a2e7925a1fb87bbd, checked October 2026): https://github.com/hasura/graphql-engine/blob/216ee9a960a54181dabc2257a2e7925a1fb87bbd/docs/docs/deployment/securing-graphql-endpoint.mdx#L14-L15 - Hasura authentication overview and JWT mode (pinned commit 216ee9a960a54181dabc2257a2e7925a1fb87bbd, checked October 2026): https://github.com/hasura/graphql-engine/blob/216ee9a960a54181dabc2257a2e7925a1fb87bbd/docs/docs/auth/overview.mdx#L63-L66 - GraphQL specification, requests need no specific serialization format or transport mechanism (September 2025 Edition, checked November 2026): https://spec.graphql.org/September2025/#sec-Execution - Hasura JWT mode, `Authorization: Bearer` header, issuer and audience checks and shared-JWK audience warning (v2.x, checked November 2026): https://hasura.io/docs/2.0/auth/authentication/jwt/ - Apollo Server subscriptions, `connectionParams` credential validation and `onConnect` connection rejection (v4-v5, checked November 2026): https://www.apollographql.com/docs/apollo-server/data/subscriptions - gRPC SSL/TLS server and mutual authentication (pinned commit d90b5f947a481a063a6eae7043a8891c910762a8, checked October 2026): https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L23-L26 - gRPC token-based authentication with SSL/TLS (pinned commit d90b5f947a481a063a6eae7043a8891c910762a8, checked October 2026): https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L38-L46 - gRPC channel, call and composite credentials (pinned commit d90b5f947a481a063a6eae7043a8891c910762a8, checked October 2026): https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L62-L74 - gRPC OAuth per-call credentials and server-side verification (pinned commit d90b5f947a481a063a6eae7043a8891c910762a8, checked October 2026): https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L122-L131 - grpc-go authentication example, server-side token verification in an interceptor (v1.82.2, checked October 2026): https://github.com/grpc/grpc-go/blob/v1.82.2/examples/features/authentication/README.md#L49-L62 - grpc-go authentication example server installing a unary interceptor (v1.82.2, checked October 2026): https://github.com/grpc/grpc-go/blob/v1.82.2/examples/features/authentication/server/main.go#L57-L65 - go-grpc-middleware unary auth interceptor and AuthFuncOverride (v2.3.3, checked October 2026): https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L26-L50 - go-grpc-middleware stream auth interceptor and AuthFuncOverride (v2.3.3, checked October 2026): https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L52-L70 - vLLM gRPC server without an interceptor on an insecure port (v0.30.0, checked October 2026): https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/grpc_server.py#L87-L118 - Triton gRPC restricted-protocol header check (pinned commit 546a78766fb112128aa0b10a70c55f4f39c3b1df, checked October 2026): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.cc#L194-L225 - Argo CD gRPC interceptor chains (pinned commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579, checked October 2026): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L961-L985 - Argo CD authentication function, disabled auth and anonymous access (pinned commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579, checked October 2026): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1537-L1576 - Argo CD import of the `go-grpc-middleware/v2` auth interceptors (pinned commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579, checked October 2026): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L37 - Text Embeddings Inference gRPC API-key interceptor (pinned commit 1bb59202500e5f69dd8be63dd1604f7625124fbe, checked October 2026): https://github.com/huggingface/text-embeddings-inference/blob/1bb59202500e5f69dd8be63dd1604f7625124fbe/router/src/grpc/server.rs#L1472-L1508 - Git log patch and merge-diff behaviour (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L127-L141 - Git log default revision range (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L102-L109 - Gitleaks Git, directory and stdin modes (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#L193-L215 - Gitleaks pre-commit use (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#L110-L134 - Gitleaks git command staged, pre-commit and log-opts flags (v8.30.1, checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/git.go#L14-L20 - Gitleaks staged diff versus log command selection (v8.30.1, checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/git.go#L64-L77 - Git rev-list --all refs listing (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/rev-list-options.adoc#L167-L169 - OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html - OWASP deny-by-default authorization (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Authorization_Cheat_Sheet.md#L30-L38 - OWASP object-level authorization (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Authorization_Cheat_Sheet.md#L93-L99 - OWASP shared-secret attribution (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L7 - OWASP secret-manager storage (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L313 - OWASP environment-variable caveat (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L462-L464 - OWASP session-cookie attributes (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L131-L149 - OWASP Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - OAuth 2.0 Security Best Current Practice (RFC 9700): https://www.rfc-editor.org/rfc/rfc9700.html - OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html - NIST SP 800-63B-4 (password length, section 3.1.1.2): https://pages.nist.gov/800-63-4/sp800-63b.html - Apache htpasswd (bcrypt `-B`; cost `-C`, default 5): https://httpd.apache.org/docs/2.4/programs/htpasswd.html - pyca/bcrypt changelog (5.0.0 rejects input over 72 bytes): https://github.com/pyca/bcrypt/blob/2b4ba9ac84df972e8e81311d09559af8dc82ef33/CHANGELOG.rst - OpenSSL 3.5 rand and base64 output (checked October 2026): https://docs.openssl.org/3.5/man1/openssl-rand/ - Python 3.13 secrets.token_urlsafe (checked October 2026): https://docs.python.org/3.13/library/secrets.html#secrets.token_urlsafe - Gitleaks git, directory and stdin scanning (pinned commit b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b, checked October 2026): https://github.com/gitleaks/gitleaks/blob/b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b/README.md#L172-L189 - Git v2.49.0 ignore rules exclude untracked files (checked October 2026): https://github.com/git/git/blob/v2.49.0/Documentation/gitignore.adoc#L15-L18 - OWASP compromised credentials and secret revocation (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L214-L218 - OWASP Secure, HttpOnly and SameSite attributes (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L163 - OWASP random session IDs and strict verification (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L265-L267 - OWASP session-ID renewal and invalidation after privilege changes (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L275 - OWASP server-side session invalidation on logout (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L317 - OWASP server-side idle and absolute session timeouts (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L323-L331 - OWASP browser storage and session identifiers (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/HTML5_Security_Cheat_Sheet.md#L53 - OWASP SameSite limitations (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L149 - OWASP CSRF tokens, origin checks and safe methods (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md#L17-L26 - OWASP Fetch Metadata cross-site policy (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md#L197 - OWASP Fetch Metadata sibling-domain trust (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md#L226 - OWASP API4:2023 resource and spending limits (checked October 2026): https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption/ - OWASP request-size limits (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Denial_of_Service_Cheat_Sheet.md#L80 - OWASP connection timeouts and concurrent resource limits (pinned commit 668ba7db3d0da5868b8a0305c259f7f6914a6ecd, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Denial_of_Service_Cheat_Sheet.md#L110-L113 - Microsoft Entra application-session revocation (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L62 - Microsoft Entra account disablement and refresh-token revocation (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L96-L106 - Microsoft Entra access-token lifetime (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity-platform/access-tokens.md#L54-L60 - Microsoft Entra continuous access evaluation delay (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/concept-continuous-access-evaluation.md#L21-L25 - Cloudflare Access session revocation (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L159-L186 - Cloudflare Access user logout delay (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L190-L195 - OAuth2 Proxy cookie expiry and refresh (v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L126-L130 - OAuth2 Proxy trusted proxy IPs (v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L197 - OAuth2 Proxy upstream identity headers (v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L143-L151 - Authelia session authorization (v4.39.28, checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L24-L25 - Authelia session expiration (v4.39.28, checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L220-L221 - OAuth 2.0 token revocation (RFC 7009) (checked October 2026): https://www.rfc-editor.org/rfc/rfc7009.html#section-2.1 - Google IAP signed-header verification (checked October 2026): https://docs.cloud.google.com/iap/docs/signed-headers-howto#securing_iap_headers - AWS ALB signed claims verification (checked October 2026): https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html#user-claims-encoding - Google OpenID Connect hosted-domain claim (checked October 2026): https://developers.google.com/identity/openid-connect/openid-connect - Microsoft Entra multitenant common endpoint and issuer handling (pinned commit 49b84e07023770128d83039c07eafb84ff1de1df, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/49b84e07023770128d83039c07eafb84ff1de1df/docs/identity-platform/howto-convert-app-to-be-multi-tenant.md#L43-L63 - Microsoft Entra multitenant token validation (pinned commit 49b84e07023770128d83039c07eafb84ff1de1df, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/49b84e07023770128d83039c07eafb84ff1de1df/docs/identity-platform/access-tokens.md#L179-L187 - Cloudflare Access self-hosted application (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16 - Cloudflare Access origin connection and token validation (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54 - Cloudflare Access JWT validation (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L15-L17 - Cloudflare Tunnel origin parameters (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L198-L206 - Cloudflare Full (strict) SSL mode (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/ssl/origin-configuration/ssl-modes/full-strict.mdx#L16-L37 - Cloudflare Tunnel origin TLS verification (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L59-L67 - Cloudflare identity provider options (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/index.mdx#L14-L18 - Cloudflare generic OIDC connector (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/generic-oidc.mdx#L15 - Cloudflare Access one-time PIN login (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/one-time-pin.mdx#L15 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - owasp: OWASP authentication and password storage (unknown) - owasp-p1: OWASP authorization, secrets and session guidance (668ba7db3d0da5868b8a0305c259f7f6914a6ecd) - oauth: OAuth security BCP (RFC 9700) - oidc: OpenID Connect Core (1.0) - nist: NIST password guidance (SP 800-63B-4) - htpasswd: Apache htpasswd (unknown) - bcrypt: Python bcrypt rejection threshold (5.0.0) - bcrypt-source: Python bcrypt changelog (2b4ba9ac84df972e8e81311d09559af8dc82ef33) - openssl: OpenSSL rand (3.5) - python: Python secrets (3.13) - gitleaks-source: Gitleaks scanning modes (b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b) - git: Git ignore rules (v2.49.0) - owasp-docs: OWASP security guidance (668ba7db3d0da5868b8a0305c259f7f6914a6ecd) - api-security: OWASP API Security (2023) - google-oidc: Google OpenID Connect (unknown) - entra-multitenant: Microsoft Entra multitenant token validation (49b84e07023770128d83039c07eafb84ff1de1df) - cloudflare-access: Cloudflare Access and origin configuration (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - fail2ban-jail: fail2ban (1.1.1) - grpc-status: gRPC (v1.84.0) - websocket-rfc: WebSocket RFC (RFC 6455) - sse-spec: WHATWG server-sent events (unknown) - stripe-webhooks: stripe-node (v18.5.0) - git-log: Git (v2.56.0) - gitleaks-p3: Gitleaks (v8.30.1) - entra-docs: Microsoft Entra revocation model (231747abc59ae3d50c74e215c1cdd592eb980723) - oauth2-proxy: OAuth2 Proxy (v7.15.6) - authelia: Authelia (v4.39.28) - oauth-revocation: OAuth 2.0 Token Revocation RFC (unknown) - google-iap: Google Identity-Aware Proxy (unknown) - aws-alb: AWS Application Load Balancer authentication (unknown) - standard-webhooks: Standard Webhooks (7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b) - graphql-docs: GraphQL authorization and serving documentation (be061651a39b9b85115bf3e430d516eab9610cee) - grpc-auth: gRPC authentication guide (d90b5f947a481a063a6eae7043a8891c910762a8) - graphql-over-http: GraphQL over HTTP working draft (d438b209d60fa3d34b0530b605e60d5640c7ef9b) - apollo-server: Apollo Server documentation (0c22029c2af47d3829e730e9820c01cef0557627) - hasura: Hasura GraphQL Engine documentation (216ee9a960a54181dabc2257a2e7925a1fb87bbd) - grpc-go-example: grpc-go authentication example (v1.82.2) - grpc-middleware: go-grpc-middleware (v2.3.3) - vllm: vLLM (v0.30.0) - triton: Triton Inference Server (546a78766fb112128aa0b10a70c55f4f39c3b1df) - argo-cd: Argo CD (c9c369efcc5b2a0bd720803f8d14a1c3eaddf579) - tei-grpc: Text Embeddings Inference (1bb59202500e5f69dd8be63dd1604f7625124fbe) - graphql-spec: GraphQL specification (September 2025) - hasura-jwt: Hasura JWT authentication documentation (v2.x) - apollo-subscriptions: Apollo Server subscriptions documentation (v4-v5) - stripe-docs: Stripe webhooks documentation (unknown) Claims (full source mapping in the JSON block below): - tls (REASONED; owasp, oauth): Protect the login page and whole session with TLS or equivalent strong transport; Basic and bearer credentials require HTTPS. - password-hashing (REASONED; owasp): Prefer argon2id, then scrypt; bcrypt is legacy and parameterized PBKDF2 serves FIPS requirements. Never use plaintext, unsalted or fast password hashes. - bcrypt-length (REASONED; bcrypt, bcrypt-source, owasp): bcrypt limits input to 72 bytes; Python bcrypt 5.0.0+ raises ValueError while earlier versions truncate. Prefer argon2id or scrypt for long passphrases. - bcrypt-prehash (REASONED; owasp): If legacy bcrypt is unavoidable, pre-hash with peppered HMAC then base64, never a bare digest susceptible to password shucking. - htpasswd-cost (REASONED; htpasswd, owasp): htpasswd -B -C 12 selects bcrypt cost 12; bare -B defaults to 5, below the OWASP minimum of 10. - federation (REASONED; owasp, oidc): Prefer supported SSO/OIDC and enable MFA wherever available. - machine-tokens (REASONED; oauth): Use separate least-privilege API tokens, exercise rotation and set expiry where supported. - session-identity (REASONED; owasp, owasp-docs): Use unpredictable session identifiers and regenerate on login or privilege change, invalidating the old identifier. - session-revocation (REASONED; owasp, owasp-docs): Invalidate sessions server-side on logout and password change. - login-throttling (REASONED; owasp): Rate-limit authentication with account-aware and aggregate controls alongside source limits; lock or delay failures without enabling denial of access. - enumeration (REASONED; owasp): Keep login, registration and reset responses uniform in message and timing. - auth-logging (REASONED; owasp): Log authentication successes and failures with account and source address; retain logs for investigation. - privilege (REASONED; owasp): Separate administrative and daily-use accounts; restrict database and OS service accounts to the rights the application uses. - authorization (REASONED; oidc, oauth): A federated identity still needs an application allowlist before access is granted. The allowlist is this guide's recommendation. - code-pkce (REASONED; oauth): Use authorization code flow with PKCE S256, never plain. - redirects (REASONED; oauth): Require exact-match redirect URIs. - state-nonce (REASONED; oauth, oidc): Generate fresh state and nonce for each transaction, verify both and bind them to the initiating browser session. - id-token-signature (REASONED; oidc): Validate ID-token signatures with provider JWKS and a pinned algorithm, never none. - id-token-claims (REASONED; oidc): Validate ID-token issuer, audience and expiry before accepting the identity. - token-lifecycle (REASONED; oauth): Use short-lived access tokens and refresh-token rotation; never put tokens in URLs. - account-linking (REASONED; oidc): Link accounts by issuer plus subject, never by email alone. - mfa-enforcement (REASONED; owasp, nist): Require MFA at the point of access in provider policy or the app; enrollment alone must not permit password-only access. - control-plane (REASONED; owasp, nist): Require MFA on the Git host, cloud, DNS, deployment platform, secret manager and IdP administrator account. - password-length (REASONED; nist): Require at least 8 password characters with enforced MFA, otherwise at least 15; allow long passphrases and all characters without composition or periodic-rotation rules. - breached-passwords (REASONED; nist, owasp): Screen new and changed passwords against known breaches; a valid breached password defeats hashing and per-IP limits. - recovery (REASONED; nist, owasp): Use random, single-use, short-lived, account-bound reset tokens and a pre-verified delivery channel; recovery must preserve MFA. - recovery-reauth (REASONED; nist, owasp): Reauthenticate for recovery-address or MFA-factor changes and invalidate outstanding sessions and unused reset tokens on completion. - verify-token-rejection (REASONED; oidc, oauth): Negative tests reject missing, expired, wrong-issuer and wrong-audience tokens. - default-deny (REASONED; owasp, owasp-p1): Every endpoint that is not deliberately public must require authentication, applying OWASP's deny-by-default principle. Require authentication for APIs, health dashboards, admin panels, metrics, and message queues, and publish an explicit list of public paths (this guide's recommendation; the cited passage does not cover this). - individual-credentials (REASONED; owasp, owasp-p1): Change or disable every vendor default account before exposure. Each human gets an individual account; each service gets its own credential. Never ship credentials in code, containers or documentation. OWASP notes that services sharing one secret make the source of a compromise or leak hard to identify; vendor default accounts, individual human and service credentials, and keeping credentials out of code, containers and documentation are this guide's recommendations. - secret-generation (REASONED; owasp, openssl, python): Generate random secrets with openssl rand -base64 32 or Python secrets.token_urlsafe(32). - secret-storage (REASONED; owasp, gitleaks-source, git, owasp-docs, owasp-p1): Prefer a secret manager (this guide's recommendation; OWASP lists secret managers as one option). For containers, OWASP advises environment variables only when mounted files or a secret-store fetch are not possible. Ignore .env before the first commit and scan for leaks before pushing. Rotate secrets exposed in a public repository, chat or log; deletion does not unpublish them. - session-cookies (REASONED; owasp, owasp-docs, owasp-p1): Mark session cookies Secure, HttpOnly and SameSite Lax or Strict; signing them with a strong random secret is this guide's recommendation, not covered by the cited passage. - session-timeouts (REASONED; owasp, owasp-docs): Enforce both idle and absolute session timeouts server-side; cookie expiry alone does not prevent replay. - session-unissued (REASONED; owasp, owasp-docs): Reject session identifiers the server never issued to prevent session fixation. - csrf (REASONED; owasp, owasp-docs): SameSite alone is not a complete CSRF defense: Lax permits a top-level state-changing GET and sibling subdomains can be same-site. Cookie-authenticated state changes need an anti-CSRF token or strict Origin/Sec-Fetch-Site check, and must not use safe methods such as GET. - expensive-endpoint-limits (REASONED; owasp, api-security, owasp-docs): Bound inference, uploads and job submission with request-size, concurrency and timeout limits in addition to per-client rate limits; apply these limits to streaming and webhook transports too. - fail2ban (REASONED; owasp, fail2ban-jail): For SSH on Linux hosts, enable the relevant fail2ban jail (guide requirement). fail2ban 1.1.1 ships sshd, nginx-http-auth and webmin-auth filters and jails, with jails disabled by default; enabling relevant jails with matching file-backend logpaths is guide advice, while panel coverage and setup ease remain unestablished (guide assessment). - federated-allowlist (REASONED; oidc, google-oidc, entra-multitenant): After federated login, check the identity against an application allowlist of tenants, hosted domains, organization or group membership, or explicit users (this guide's recommendation); a Google sign-in alone does not make someone staff. For Google Workspace or Cloud organizations, check the hd claim in the validated ID token; an email domain or request hd parameter is insufficient. Microsoft common serves organizational and personal accounts; validate the token issuer, signing-key issuer and tid, then apply the application tenant allowlist. Vendor sources support the Google hd check and Microsoft token validation; the full allowlist and staff determination are this guide's recommendations. - cloudflare-access (REASONED; cloudflare-access): Cloudflare Access can front a self-hosted web application published through Access with one-time PIN or SSO login; create the Access application before the tunnel route, or it is available to anyone on the Internet. Validate the Access JWT at the origin, verify Tunnel origin TLS, and use Full (strict) for a directly proxied HTTPS origin where applicable; unchanged any-application use is not established. - browser-session (REASONED; owasp, owasp-docs): Prefer a server-side session in an HttpOnly cookie to tokens in browser storage. - transport-authentication (REASONED; owasp, websocket-rfc, sse-spec, grpc-status, stripe-webhooks, owasp-docs, standard-webhooks, graphql-docs, grpc-auth): Guide requirements: WebSockets, SSE, GraphQL, gRPC, webhooks, inference/management APIs need their own authentication check, not HTML login; verify webhook signatures; reject replays, even in-window. Sources are guidance. Unestablished (guide assessment): GraphQL credentials/checks/non-HTTP auth, HTML-login and non-REST inference/management boundaries, full replay rejection, deployed protection. - transport-authentication-sources (REASONED; graphql-docs, grpc-auth): Cited guidance: GraphQL docs say execution should begin after authentication middleware confirms user identity, which their example puts in request context, and that business logic should handle authorization; gRPC's guide separates channel credentials, such as SSL credentials, from call credentials, which can be combined. Applying gRPC credentials to inference/management APIs is guide inference. - transport-authentication-browser (REASONED; websocket-rfc, owasp-docs, sse-spec): Documented: RFC 6455 leaves WebSocket handshake authentication to the server, which may use cookies, HTTP or TLS authentication, and site-restricted servers SHOULD verify Origin; OWASP says browsers send cookies in the handshake and to validate Origin against an allowlist; WHATWG EventSource sets credentials mode include if withCredentials is true. Whether endpoints check them is guide assessment. - transport-authentication-graphql (REASONED; graphql-docs, graphql-over-http, apollo-server, hasura): Documented: graphql.org says to place GraphQL after all authentication middleware, sharing other HTTP handlers' session and user information; the GraphQL over HTTP draft lets a server forbid requests to require authentication and leaves authentication security out of scope. Apollo and Hasura are vendor examples only. Accepted credentials and middleware checks remain guide requirements. - transport-authentication-graphql-transport (REASONED; graphql-spec, apollo-subscriptions): Documented: the GraphQL specification says requests need no specific serialization format or transport mechanism. Vendor example only: Apollo Server v4-v5 passes WebSocket connectionParams to the server, enabling the application to validate user credentials, and onConnect can reject a connection. No transport-independent GraphQL authentication mechanism is established (guide assessment). - transport-authentication-hasura-jwt (REASONED; hasura-jwt): Vendor example only: Hasura v2.x reads the JWT from an Authorization: Bearer header by default, or a Cookie or custom header; a configured issuer or audience rejects a mismatched iss or aud claim, but by default an absent claim is accepted unless extra_required_claims lists audience or issuer. It warns that with JWKs shared across tenants, leaving audience unset lets other tenants' JWTs be valid. - transport-authentication-webhook-duplicates (REASONED; stripe-docs, standard-webhooks): Vendor guidance: Stripe says endpoints might receive the same event more than once; logging processed event IDs and skipping logged ones guards against duplicated event receipts, but two separate Event objects can be sent, identified by data.object's ID with event.type. Neither this nor the Standard Webhooks five-minute example sets a universal webhook-ID retention period (guide assessment). - transport-authentication-grpc (REASONED; grpc-go-example, grpc-middleware, vllm, triton, tei-grpc): Cited example: the grpc-go authentication example's server verifies call credentials in an interceptor it installs. Source reading: go-grpc-middleware runs an application AuthFunc or service override per call; vLLM v0.30.0 gRPC has no interceptor; TEI checks authorization only with an API key; Triton checks configured restricted headers. - transport-authentication-grpc-argo-cd (REASONED; argo-cd, grpc-middleware): Source reading: argocd-server's default gRPC auth function skips checks if auth is off, else tries the token; on a claims error it allows the call anonymously only if settings load with anonymous access on. Service overrides are excluded: go-grpc-middleware v2.3.3 (Argo CD's go.mod pin per gitops-controllers.md) runs them instead; that guide records overrides accepting some unauthenticated calls. - offboarding (REASONED; owasp, oauth, owasp-docs, entra-docs, cloudflare-access, oauth2-proxy, authelia, oauth-revocation): When a person leaves, promptly revoke their provider membership, proxy sessions, application sessions, and personal tokens (this guide's recommendation). Entra documents blocking new sign-ins and revoking refresh tokens, while application-issued sessions require revocation by the application; its access-token lifetime varies, and CAE critical-event enforcement can take up to 15 minutes for supported services. Cloudflare Access documents per-user session revocation, but an active IdP profile can start a new session; after user logout, previously issued tokens stop being accepted in 20-30 seconds. OAuth2 Proxy and Authelia document session-cookie expiry, but these passages do not establish immediate per-user revocation for either proxy. RFC 7009 specifies immediate token invalidation with possible propagation delay and conditional access-token revocation support. The fetched sources do not establish a provider-membership removal mechanism or this deployment's measured maximum time-to-revoke. Measure each system's maximum time-to-revoke where immediate revocation is unavailable, and work to shrink it (this guide's recommendation; no delay was measured here). In a deployment test, first confirm that a matched request with each provider, proxy and application session and personal token succeeds against a protected resource. After revocation, replay each old credential at recorded intervals until it is refused and returns no protected data, and the elapsed time for every layer must not exceed its configured maximum time-to-revoke; these are test expectations, not observed results. - verify-transports (REASONED; owasp, grpc-status, websocket-rfc, sse-spec): Use real protocol clients against every non-public path and transport: expect HTTP 401, 403 or a login redirect; gRPC UNAUTHENTICATED for missing or invalid credentials; SSE refusal; and WebSocket upgrade rejection or protected-operation rejection, and closure on first-message authentication failure. These are test expectations, not observed responses. - verify-secret-scan (REASONED; owasp, gitleaks-source, gitleaks-p3, git-log): A secret scanner must find no leaks in the working tree, staged changes and full history; Gitleaks v8.30.1 provides staged git-diff flags and `--log-opts`, so verify staged, all-ref and merge-commit coverage with the chosen scanner and configuration. Supplementary git log grep misses secrets without the search words, uncommitted files, some refs and merge-only changes; the historical fixture result is not a scan of this repository. - verify-default-accounts (REASONED; owasp): Check that the user store has no admin, test or demo account with a known or empty password. This is the guide's recommendation; the OWASP Authentication Cheat Sheet does not list these account names (checked at commit 72865b1e). - verify-wrong-tenant (REASONED; oidc, entra-multitenant): Expect a wrong-tenant token to be rejected in an application negative test after issuer and tid validation and the application tenant allowlist. Microsoft documents the validation rules; this guide recommends the allowlist and test, and no deployed rejection was observed. - verify-cross-user (REASONED; owasp, owasp-p1): Verify that user A cannot read user B's resources. OWASP describes horizontal privilege escalation and object-specific access checks. - oauth2-proxy-client-ip (REASONED; oauth2-proxy): At v7.15.6, OAuth2 Proxy's trusted proxy warning adds an instruction to include the direct peer and every trusted proxy hop that can appear in X-Forwarded-For. - verify-origin-isolation (REASONED; owasp, cloudflare-access, oauth2-proxy, authelia, google-iap, aws-alb): Expect the origin to be unreachable except through its fronting layer in a deployment negative test. Cloudflare describes Access policy enforcement, JWT validation and Tunnel access checks, and says a publicly routable origin needs its IP protected by other methods. OAuth2 Proxy documents upstream identity headers and warns that when `--reverse-proxy` is enabled and `--trusted-proxy-ip` is unset, a client that can reach OAuth2 Proxy directly may be able to spoof forwarded headers. Authelia uses session cookies to authorize protected websites. Google IAP says a bypassing attacker can forge unsigned identity headers; its signed-header how-to says to verify the JWT header, payload and signature to secure an app with the IAP JWT, and calls signed headers secondary security; AWS ALB requires verification of its signed claims header and signer ARN. These sources support the guide's origin-isolation test expectation but do not establish that a particular origin is unreachable. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "a2513ee36418f8ce4396ccfc1dddc56469cfc4e405483835b4ef80372a1cadbe", "components": { "owasp": { "name": "OWASP authentication and password storage", "basis": "unknown", "sources": { "sc5f1b11d42f4": "https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html", "s49e8d76431d9": "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html" } }, "owasp-p1": { "name": "OWASP authorization, secrets and session guidance", "basis": "668ba7db3d0da5868b8a0305c259f7f6914a6ecd", "sources": { "sa68227c175a9": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Authorization_Cheat_Sheet.md#L30-L38", "s5f5e10a9e4c2": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Authorization_Cheat_Sheet.md#L93-L99", "s5fd4accb6988": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L7", "s6ea90d0c28e9": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L313", "se0eb20386574": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L462-L464", "se49462b39c11": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L131-L149" } }, "oauth": { "name": "OAuth security BCP", "basis": "RFC 9700", "sources": { "s4e6d674bf887": "https://www.rfc-editor.org/rfc/rfc9700.html" } }, "oidc": { "name": "OpenID Connect Core", "basis": "1.0", "sources": { "scfd2790a544c": "https://openid.net/specs/openid-connect-core-1_0.html" } }, "nist": { "name": "NIST password guidance", "basis": "SP 800-63B-4", "sources": { "sada92c9ac010": "https://pages.nist.gov/800-63-4/sp800-63b.html" } }, "htpasswd": { "name": "Apache htpasswd", "basis": "unknown", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "bcrypt": { "name": "Python bcrypt rejection threshold", "basis": "5.0.0", "sources": { "saef7bba61b3a": "https://github.com/pyca/bcrypt/blob/2b4ba9ac84df972e8e81311d09559af8dc82ef33/CHANGELOG.rst" } }, "bcrypt-source": { "name": "Python bcrypt changelog", "basis": "2b4ba9ac84df972e8e81311d09559af8dc82ef33", "sources": { "saef7bba61b3a": "https://github.com/pyca/bcrypt/blob/2b4ba9ac84df972e8e81311d09559af8dc82ef33/CHANGELOG.rst" } }, "openssl": { "name": "OpenSSL rand", "basis": "3.5", "sources": { "s86919b215ba1": "https://docs.openssl.org/3.5/man1/openssl-rand/" } }, "python": { "name": "Python secrets", "basis": "3.13", "sources": { "sd2e8196eaa96": "https://docs.python.org/3.13/library/secrets.html#secrets.token_urlsafe" } }, "gitleaks-source": { "name": "Gitleaks scanning modes", "basis": "b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b", "sources": { "s44a3edaa543d": "https://github.com/gitleaks/gitleaks/blob/b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b/README.md#L172-L189" } }, "git": { "name": "Git ignore rules", "basis": "v2.49.0", "sources": { "s5f377f0fdbd8": "https://github.com/git/git/blob/v2.49.0/Documentation/gitignore.adoc#L15-L18" } }, "owasp-docs": { "name": "OWASP security guidance", "basis": "668ba7db3d0da5868b8a0305c259f7f6914a6ecd", "sources": { "seb866a1f94e9": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L214-L218", "s0562e4dc23f2": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L163", "s0b82bc050cf3": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L265-L267", "sf069993cc3d7": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L275", "sa5f6e96a876c": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L317", "s4779e856ef9d": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L323-L331", "s4f0aa80b1ef6": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/HTML5_Security_Cheat_Sheet.md#L53", "s232e483ac72b": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L149", "sc088939103f0": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md#L17-L26", "sa7544ec9e1d7": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md#L197", "seb3129173dbb": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.md#L226", "s3369d9106dce": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Denial_of_Service_Cheat_Sheet.md#L80", "sd6d35bd1b67d": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Denial_of_Service_Cheat_Sheet.md#L110-L113", "sb49221f05748": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/GraphQL_Cheat_Sheet.md#L190-L198", "s0eccd394429a": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/GraphQL_Cheat_Sheet.md#L287-L290", "se53dcf51421a": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/REST_Security_Cheat_Sheet.md#L35-L38", "s2cddbc943d5f": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/REST_Security_Cheat_Sheet.md#L167-L170", "s6d4b22f77307": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L118-L120", "sf288957942f5": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L128", "s5f89a38f97e4": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L74", "sf12da7bc5eb6": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/WebSocket_Security_Cheat_Sheet.md#L85" } }, "api-security": { "name": "OWASP API Security", "basis": "2023", "sources": { "s213afc24aadb": "https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption/" } }, "google-oidc": { "name": "Google OpenID Connect", "basis": "unknown", "sources": { "s4f815acc2976": "https://developers.google.com/identity/openid-connect/openid-connect" } }, "entra-multitenant": { "name": "Microsoft Entra multitenant token validation", "basis": "49b84e07023770128d83039c07eafb84ff1de1df", "sources": { "s5e0242f87cb7": "https://github.com/MicrosoftDocs/entra-docs/blob/49b84e07023770128d83039c07eafb84ff1de1df/docs/identity-platform/howto-convert-app-to-be-multi-tenant.md#L43-L63", "s95719206c226": "https://github.com/MicrosoftDocs/entra-docs/blob/49b84e07023770128d83039c07eafb84ff1de1df/docs/identity-platform/access-tokens.md#L179-L187" } }, "cloudflare-access": { "name": "Cloudflare Access and origin configuration", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16", "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54", "s81e5dd28d3e5": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L15-L17", "s65c224a2e655": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L59-L67", "sa012410f91eb": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L198-L206", "sbf4ecbd5ffc1": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/ssl/origin-configuration/ssl-modes/full-strict.mdx#L16-L37", "sf2dbab0fc574": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L159-L186", "sa0e3eb928f4a": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L190-L195", "scb7d48f64a99": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/index.mdx#L14-L18", "sd606ae12de33": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/generic-oidc.mdx#L15", "s9f8bead847d2": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/one-time-pin.mdx#L15" } }, "fail2ban-jail": { "name": "fail2ban", "basis": "1.1.1", "sources": { "se7c4e0af046f": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L152-L167", "sc1b6e6bc1f84": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L271-L279", "s9a6aaa273c73": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/sshd.conf#L104-L111", "sa43cf7ba22fb": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L370-L376", "s568ea00c2482": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/nginx-http-auth.conf#L9-L37", "s3c53dd330a7b": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L491-L495", "s099abd84ea19": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/webmin-auth.conf#L4-L20" } }, "grpc-status": { "name": "gRPC", "basis": "v1.84.0", "sources": { "s595c3c297157": "https://github.com/grpc/grpc/blob/v1.84.0/doc/statuscodes.md#L13-L24" } }, "websocket-rfc": { "name": "WebSocket RFC", "basis": "RFC 6455", "sources": { "s07f03be17365": "https://www.rfc-editor.org/rfc/rfc6455.html#section-1.3", "s559dc201b043": "https://www.rfc-editor.org/rfc/rfc6455.html#section-1.4", "s1cf26aa107f9": "https://www.rfc-editor.org/rfc/rfc6455.html#section-10.5", "s6b9238cd4790": "https://www.rfc-editor.org/rfc/rfc6455.html#section-10.2" } }, "sse-spec": { "name": "WHATWG server-sent events", "basis": "unknown", "sources": { "sf47a21cc6a1c": "https://html.spec.whatwg.org/multipage/server-sent-events.html" } }, "stripe-webhooks": { "name": "stripe-node", "basis": "v18.5.0", "sources": { "s42eea6a7e527": "https://github.com/stripe/stripe-node/blob/v18.5.0/src/Webhooks.ts#L182-L216", "s90f21ad845ca": "https://github.com/stripe/stripe-node/blob/v18.5.0/src/Webhooks.ts#L357-L410" } }, "git-log": { "name": "Git", "basis": "v2.56.0", "sources": { "s1cb7f677894d": "https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L127-L141", "sede4bb3fcdf3": "https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L102-L109", "s903a3c765e70": "https://github.com/git/git/blob/v2.56.0/Documentation/rev-list-options.adoc#L167-L169" } }, "gitleaks-p3": { "name": "Gitleaks", "basis": "v8.30.1", "sources": { "se5afdb7c63d2": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#L193-L215", "s685ae6ce9b3d": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#L110-L134", "sfda707f0eb4f": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/git.go#L14-L20", "s8511b8f313b7": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/git.go#L64-L77" } }, "entra-docs": { "name": "Microsoft Entra revocation model", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "s983f45259faa": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L62", "scb38e683ae3f": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L96-L106", "s9ff4d6ed4cfe": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity-platform/access-tokens.md#L54-L60", "sca6feea4b601": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/concept-continuous-access-evaluation.md#L21-L25" } }, "oauth2-proxy": { "name": "OAuth2 Proxy", "basis": "v7.15.6", "sources": { "sa4c6097e2686": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L126-L130", "sa0e9b0ae70a5": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L197", "s18ec95d8d890": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L143-L151" } }, "authelia": { "name": "Authelia", "basis": "v4.39.28", "sources": { "sc03548e2ed00": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L24-L25", "sc844fe82794f": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L220-L221" } }, "oauth-revocation": { "name": "OAuth 2.0 Token Revocation RFC", "basis": "unknown", "sources": { "s3383c9f41cc2": "https://www.rfc-editor.org/rfc/rfc7009.html#section-2.1" } }, "google-iap": { "name": "Google Identity-Aware Proxy", "basis": "unknown", "sources": { "s4513609280bb": "https://docs.cloud.google.com/iap/docs/signed-headers-howto#securing_iap_headers" } }, "aws-alb": { "name": "AWS Application Load Balancer authentication", "basis": "unknown", "sources": { "sa198710d31f3": "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html#user-claims-encoding" } }, "standard-webhooks": { "name": "Standard Webhooks", "basis": "7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b", "sources": { "seaefbbec338e": "https://github.com/standard-webhooks/standard-webhooks/blob/7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b/spec/standard-webhooks.md#L133-L143", "s63e0d131d760": "https://github.com/standard-webhooks/standard-webhooks/blob/7537d2a2d3d52d8f2e0ecd12527af4a9307fd81b/spec/standard-webhooks.md#L233-L234" } }, "graphql-docs": { "name": "GraphQL authorization and serving documentation", "basis": "be061651a39b9b85115bf3e430d516eab9610cee", "sources": { "s3df4f55b0bea": "https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L5", "s199bd5e58cf1": "https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L22", "sc5afee295dec": "https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/authorization.mdx#L89-L92", "s18851e342534": "https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/serving-over-http.mdx#L21", "s578f7bbdabd6": "https://github.com/graphql/graphql.github.io/blob/be061651a39b9b85115bf3e430d516eab9610cee/src/pages/learn/serving-over-http.mdx#L132" } }, "grpc-auth": { "name": "gRPC authentication guide", "basis": "d90b5f947a481a063a6eae7043a8891c910762a8", "sources": { "s362a756f8895": "https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L23-L26", "sbc5e08ab0a2f": "https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L38-L46", "s95d08dc70fee": "https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L62-L74", "s82eaa71eceda": "https://github.com/grpc/grpc.io/blob/d90b5f947a481a063a6eae7043a8891c910762a8/content/en/docs/guides/auth.md#L122-L131" } }, "graphql-over-http": { "name": "GraphQL over HTTP working draft", "basis": "d438b209d60fa3d34b0530b605e60d5640c7ef9b", "sources": { "s868371971ab7": "https://github.com/graphql/graphql-over-http/blob/d438b209d60fa3d34b0530b605e60d5640c7ef9b/spec/GraphQLOverHTTP.md#L115-L118", "sc0111d7924ae": "https://github.com/graphql/graphql-over-http/blob/d438b209d60fa3d34b0530b605e60d5640c7ef9b/spec/GraphQLOverHTTP.md#L705-L720" } }, "apollo-server": { "name": "Apollo Server documentation", "basis": "0c22029c2af47d3829e730e9820c01cef0557627", "sources": { "s42ab6a721b3b": "https://github.com/apollographql/apollo-server/blob/0c22029c2af47d3829e730e9820c01cef0557627/docs/source/security/authentication.mdx#L25-L62", "sca37b76cac23": "https://github.com/apollographql/apollo-server/blob/0c22029c2af47d3829e730e9820c01cef0557627/docs/source/security/authentication.mdx#L79-L135" } }, "hasura": { "name": "Hasura GraphQL Engine documentation", "basis": "216ee9a960a54181dabc2257a2e7925a1fb87bbd", "sources": { "sbdbe33edb8ca": "https://github.com/hasura/graphql-engine/blob/216ee9a960a54181dabc2257a2e7925a1fb87bbd/docs/docs/deployment/securing-graphql-endpoint.mdx#L14-L15", "s9a644965f6c1": "https://github.com/hasura/graphql-engine/blob/216ee9a960a54181dabc2257a2e7925a1fb87bbd/docs/docs/auth/overview.mdx#L63-L66" } }, "grpc-go-example": { "name": "grpc-go authentication example", "basis": "v1.82.2", "sources": { "sf3a4641f924b": "https://github.com/grpc/grpc-go/blob/v1.82.2/examples/features/authentication/README.md#L49-L62", "s7e5cc4ea3d23": "https://github.com/grpc/grpc-go/blob/v1.82.2/examples/features/authentication/server/main.go#L57-L65" } }, "grpc-middleware": { "name": "go-grpc-middleware", "basis": "v2.3.3", "sources": { "s9ec5783f5bab": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L26-L50", "s4413e7e06e93": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L52-L70" } }, "vllm": { "name": "vLLM", "basis": "v0.30.0", "sources": { "sb6766a13ce4a": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/grpc_server.py#L87-L118" } }, "triton": { "name": "Triton Inference Server", "basis": "546a78766fb112128aa0b10a70c55f4f39c3b1df", "sources": { "s2f7f38a83db9": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.cc#L194-L225" } }, "argo-cd": { "name": "Argo CD", "basis": "c9c369efcc5b2a0bd720803f8d14a1c3eaddf579", "sources": { "s751b24797922": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L961-L985", "s2bf01b29602c": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1537-L1576", "s19e9d1a1d4d2": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L37" } }, "tei-grpc": { "name": "Text Embeddings Inference", "basis": "1bb59202500e5f69dd8be63dd1604f7625124fbe", "sources": { "scaa009c6db41": "https://github.com/huggingface/text-embeddings-inference/blob/1bb59202500e5f69dd8be63dd1604f7625124fbe/router/src/grpc/server.rs#L1472-L1508" } }, "graphql-spec": { "name": "GraphQL specification", "basis": "September 2025", "sources": { "s7c188dfe7f19": "https://spec.graphql.org/September2025/#sec-Execution" } }, "hasura-jwt": { "name": "Hasura JWT authentication documentation", "basis": "v2.x", "sources": { "s12bdcc98f47a": "https://hasura.io/docs/2.0/auth/authentication/jwt/" } }, "apollo-subscriptions": { "name": "Apollo Server subscriptions documentation", "basis": "v4-v5", "sources": { "s9e2148905c07": "https://www.apollographql.com/docs/apollo-server/data/subscriptions" } }, "stripe-docs": { "name": "Stripe webhooks documentation", "basis": "unknown", "sources": { "s7e8f3e0d4d70": "https://docs.stripe.com/webhooks#handle-duplicate-events" } } }, "claims": { "tls": {"text": "Protect the login page and whole session with TLS or equivalent strong transport; Basic and bearer credentials require HTTPS.", "components": ["owasp", "oauth"], "sources": ["owasp:sc5f1b11d42f4", "oauth:s4e6d674bf887"], "status": "REASONED"}, "password-hashing": {"text": "Prefer argon2id, then scrypt; bcrypt is legacy and parameterized PBKDF2 serves FIPS requirements. Never use plaintext, unsalted or fast password hashes.", "components": ["owasp"], "sources": ["owasp:s49e8d76431d9"], "status": "REASONED"}, "bcrypt-length": {"text": "bcrypt limits input to 72 bytes; Python bcrypt 5.0.0+ raises ValueError while earlier versions truncate. Prefer argon2id or scrypt for long passphrases.", "components": ["bcrypt", "bcrypt-source", "owasp"], "sources": ["bcrypt:saef7bba61b3a", "bcrypt-source:saef7bba61b3a", "owasp:s49e8d76431d9"], "status": "REASONED"}, "bcrypt-prehash": {"text": "If legacy bcrypt is unavoidable, pre-hash with peppered HMAC then base64, never a bare digest susceptible to password shucking.", "components": ["owasp"], "sources": ["owasp:s49e8d76431d9"], "status": "REASONED"}, "htpasswd-cost": {"text": "htpasswd -B -C 12 selects bcrypt cost 12; bare -B defaults to 5, below the OWASP minimum of 10.", "components": ["htpasswd", "owasp"], "sources": ["htpasswd:scde9004bb967", "owasp:s49e8d76431d9"], "status": "REASONED"}, "federation": {"text": "Prefer supported SSO/OIDC and enable MFA wherever available.", "components": ["owasp", "oidc"], "sources": ["owasp:sc5f1b11d42f4", "oidc:scfd2790a544c"], "status": "REASONED"}, "machine-tokens": {"text": "Use separate least-privilege API tokens, exercise rotation and set expiry where supported.", "components": ["oauth"], "sources": ["oauth:s4e6d674bf887"], "status": "REASONED"}, "session-identity": {"text": "Use unpredictable session identifiers and regenerate on login or privilege change, invalidating the old identifier.", "components": ["owasp", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:s0b82bc050cf3", "owasp-docs:sf069993cc3d7"], "status": "REASONED"}, "session-revocation": {"text": "Invalidate sessions server-side on logout and password change.", "components": ["owasp", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:sf069993cc3d7", "owasp-docs:sa5f6e96a876c"], "status": "REASONED"}, "login-throttling": {"text": "Rate-limit authentication with account-aware and aggregate controls alongside source limits; lock or delay failures without enabling denial of access.", "components": ["owasp"], "sources": ["owasp:sc5f1b11d42f4"], "status": "REASONED"}, "enumeration": {"text": "Keep login, registration and reset responses uniform in message and timing.", "components": ["owasp"], "sources": ["owasp:sc5f1b11d42f4"], "status": "REASONED"}, "auth-logging": {"text": "Log authentication successes and failures with account and source address; retain logs for investigation.", "components": ["owasp"], "sources": ["owasp:sc5f1b11d42f4"], "status": "REASONED"}, "privilege": {"text": "Separate administrative and daily-use accounts; restrict database and OS service accounts to the rights the application uses.", "components": ["owasp"], "sources": ["owasp:sc5f1b11d42f4"], "status": "REASONED"}, "authorization": {"text": "A federated identity still needs an application allowlist before access is granted. The allowlist is this guide's recommendation.", "components": ["oidc", "oauth"], "sources": ["oidc:scfd2790a544c", "oauth:s4e6d674bf887"], "status": "REASONED"}, "code-pkce": {"text": "Use authorization code flow with PKCE S256, never plain.", "components": ["oauth"], "sources": ["oauth:s4e6d674bf887"], "status": "REASONED"}, "redirects": {"text": "Require exact-match redirect URIs.", "components": ["oauth"], "sources": ["oauth:s4e6d674bf887"], "status": "REASONED"}, "state-nonce": {"text": "Generate fresh state and nonce for each transaction, verify both and bind them to the initiating browser session.", "components": ["oauth", "oidc"], "sources": ["oauth:s4e6d674bf887", "oidc:scfd2790a544c"], "status": "REASONED"}, "id-token-signature": {"text": "Validate ID-token signatures with provider JWKS and a pinned algorithm, never none.", "components": ["oidc"], "sources": ["oidc:scfd2790a544c"], "status": "REASONED"}, "id-token-claims": {"text": "Validate ID-token issuer, audience and expiry before accepting the identity.", "components": ["oidc"], "sources": ["oidc:scfd2790a544c"], "status": "REASONED"}, "token-lifecycle": {"text": "Use short-lived access tokens and refresh-token rotation; never put tokens in URLs.", "components": ["oauth"], "sources": ["oauth:s4e6d674bf887"], "status": "REASONED"}, "account-linking": {"text": "Link accounts by issuer plus subject, never by email alone.", "components": ["oidc"], "sources": ["oidc:scfd2790a544c"], "status": "REASONED"}, "mfa-enforcement": {"text": "Require MFA at the point of access in provider policy or the app; enrollment alone must not permit password-only access.", "components": ["owasp", "nist"], "sources": ["owasp:sc5f1b11d42f4", "nist:sada92c9ac010"], "status": "REASONED"}, "control-plane": {"text": "Require MFA on the Git host, cloud, DNS, deployment platform, secret manager and IdP administrator account.", "components": ["owasp", "nist"], "sources": ["owasp:sc5f1b11d42f4", "nist:sada92c9ac010"], "status": "REASONED"}, "password-length": {"text": "Require at least 8 password characters with enforced MFA, otherwise at least 15; allow long passphrases and all characters without composition or periodic-rotation rules.", "components": ["nist"], "sources": ["nist:sada92c9ac010"], "status": "REASONED"}, "breached-passwords": {"text": "Screen new and changed passwords against known breaches; a valid breached password defeats hashing and per-IP limits.", "components": ["nist", "owasp"], "sources": ["nist:sada92c9ac010", "owasp:sc5f1b11d42f4"], "status": "REASONED"}, "recovery": {"text": "Use random, single-use, short-lived, account-bound reset tokens and a pre-verified delivery channel; recovery must preserve MFA.", "components": ["nist", "owasp"], "sources": ["nist:sada92c9ac010", "owasp:sc5f1b11d42f4"], "status": "REASONED"}, "recovery-reauth": {"text": "Reauthenticate for recovery-address or MFA-factor changes and invalidate outstanding sessions and unused reset tokens on completion.", "components": ["nist", "owasp"], "sources": ["nist:sada92c9ac010", "owasp:sc5f1b11d42f4"], "status": "REASONED"}, "verify-token-rejection": {"text": "Negative tests reject missing, expired, wrong-issuer and wrong-audience tokens.", "components": ["oidc", "oauth"], "sources": ["oidc:scfd2790a544c", "oauth:s4e6d674bf887"], "status": "REASONED"}, "default-deny": {"text": "Every endpoint that is not deliberately public must require authentication, applying OWASP's deny-by-default principle. Require authentication for APIs, health dashboards, admin panels, metrics, and message queues, and publish an explicit list of public paths (this guide's recommendation; the cited passage does not cover this).", "components": ["owasp", "owasp-p1"], "sources": ["owasp:sc5f1b11d42f4", "owasp-p1:sa68227c175a9"], "status": "REASONED"}, "individual-credentials": {"text": "Change or disable every vendor default account before exposure. Each human gets an individual account; each service gets its own credential. Never ship credentials in code, containers or documentation. OWASP notes that services sharing one secret make the source of a compromise or leak hard to identify; vendor default accounts, individual human and service credentials, and keeping credentials out of code, containers and documentation are this guide's recommendations.", "components": ["owasp", "owasp-p1"], "sources": ["owasp:sc5f1b11d42f4", "owasp-p1:s5fd4accb6988"], "status": "REASONED"}, "secret-generation": {"text": "Generate random secrets with openssl rand -base64 32 or Python secrets.token_urlsafe(32).", "components": ["owasp", "openssl", "python"], "sources": ["owasp:sc5f1b11d42f4", "openssl:s86919b215ba1", "python:sd2e8196eaa96"], "status": "REASONED"}, "secret-storage": {"text": "Prefer a secret manager (this guide's recommendation; OWASP lists secret managers as one option). For containers, OWASP advises environment variables only when mounted files or a secret-store fetch are not possible. Ignore .env before the first commit and scan for leaks before pushing. Rotate secrets exposed in a public repository, chat or log; deletion does not unpublish them.", "components": ["owasp", "gitleaks-source", "git", "owasp-docs", "owasp-p1"], "sources": ["owasp:sc5f1b11d42f4", "gitleaks-source:s44a3edaa543d", "git:s5f377f0fdbd8", "owasp-docs:seb866a1f94e9", "owasp-p1:s6ea90d0c28e9", "owasp-p1:se0eb20386574"], "status": "REASONED"}, "session-cookies": {"text": "Mark session cookies Secure, HttpOnly and SameSite Lax or Strict; signing them with a strong random secret is this guide's recommendation, not covered by the cited passage.", "components": ["owasp", "owasp-docs", "owasp-p1"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:s0562e4dc23f2", "owasp-p1:se49462b39c11"], "status": "REASONED"}, "session-timeouts": {"text": "Enforce both idle and absolute session timeouts server-side; cookie expiry alone does not prevent replay.", "components": ["owasp", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:s4779e856ef9d"], "status": "REASONED"}, "session-unissued": {"text": "Reject session identifiers the server never issued to prevent session fixation.", "components": ["owasp", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:s0b82bc050cf3"], "status": "REASONED"}, "csrf": {"text": "SameSite alone is not a complete CSRF defense: Lax permits a top-level state-changing GET and sibling subdomains can be same-site. Cookie-authenticated state changes need an anti-CSRF token or strict Origin/Sec-Fetch-Site check, and must not use safe methods such as GET.", "components": ["owasp", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:s232e483ac72b", "owasp-docs:sc088939103f0", "owasp-docs:sa7544ec9e1d7", "owasp-docs:seb3129173dbb"], "status": "REASONED"}, "expensive-endpoint-limits": {"text": "Bound inference, uploads and job submission with request-size, concurrency and timeout limits in addition to per-client rate limits; apply these limits to streaming and webhook transports too.", "components": ["owasp", "api-security", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "api-security:s213afc24aadb", "owasp-docs:s3369d9106dce", "owasp-docs:sd6d35bd1b67d"], "status": "REASONED"}, "fail2ban": {"text": "For SSH on Linux hosts, enable the relevant fail2ban jail (guide requirement). fail2ban 1.1.1 ships sshd, nginx-http-auth and webmin-auth filters and jails, with jails disabled by default; enabling relevant jails with matching file-backend logpaths is guide advice, while panel coverage and setup ease remain unestablished (guide assessment).", "components": ["owasp", "fail2ban-jail"], "sources": ["owasp:sc5f1b11d42f4", "fail2ban-jail:se7c4e0af046f", "fail2ban-jail:sc1b6e6bc1f84", "fail2ban-jail:s9a6aaa273c73", "fail2ban-jail:sa43cf7ba22fb", "fail2ban-jail:s568ea00c2482", "fail2ban-jail:s3c53dd330a7b", "fail2ban-jail:s099abd84ea19"], "status": "REASONED"}, "federated-allowlist": {"text": "After federated login, check the identity against an application allowlist of tenants, hosted domains, organization or group membership, or explicit users (this guide's recommendation); a Google sign-in alone does not make someone staff. For Google Workspace or Cloud organizations, check the hd claim in the validated ID token; an email domain or request hd parameter is insufficient. Microsoft common serves organizational and personal accounts; validate the token issuer, signing-key issuer and tid, then apply the application tenant allowlist. Vendor sources support the Google hd check and Microsoft token validation; the full allowlist and staff determination are this guide's recommendations.", "components": ["oidc", "google-oidc", "entra-multitenant"], "sources": ["oidc:scfd2790a544c", "google-oidc:s4f815acc2976", "entra-multitenant:s5e0242f87cb7", "entra-multitenant:s95719206c226"], "status": "REASONED"}, "cloudflare-access": {"text": "Cloudflare Access can front a self-hosted web application published through Access with one-time PIN or SSO login; create the Access application before the tunnel route, or it is available to anyone on the Internet. Validate the Access JWT at the origin, verify Tunnel origin TLS, and use Full (strict) for a directly proxied HTTPS origin where applicable; unchanged any-application use is not established.", "components": ["cloudflare-access"], "sources": ["cloudflare-access:sfddb92e438ca", "cloudflare-access:s7cafe1b17afd", "cloudflare-access:s81e5dd28d3e5", "cloudflare-access:s65c224a2e655", "cloudflare-access:sa012410f91eb", "cloudflare-access:sbf4ecbd5ffc1", "cloudflare-access:scb7d48f64a99", "cloudflare-access:sd606ae12de33", "cloudflare-access:s9f8bead847d2"], "status": "REASONED"}, "browser-session": {"text": "Prefer a server-side session in an HttpOnly cookie to tokens in browser storage.", "components": ["owasp", "owasp-docs"], "sources": ["owasp:sc5f1b11d42f4", "owasp-docs:s4f0aa80b1ef6"], "status": "REASONED"}, "transport-authentication": {"text": "Guide requirements: WebSockets, SSE, GraphQL, gRPC, webhooks, inference/management APIs need their own authentication check, not HTML login; verify webhook signatures; reject replays, even in-window. Sources are guidance. Unestablished (guide assessment): GraphQL credentials/checks/non-HTTP auth, HTML-login and non-REST inference/management boundaries, full replay rejection, deployed protection.", "components": ["owasp", "websocket-rfc", "sse-spec", "grpc-status", "stripe-webhooks", "owasp-docs", "standard-webhooks", "graphql-docs", "grpc-auth"], "sources": ["owasp:sc5f1b11d42f4", "websocket-rfc:s07f03be17365", "websocket-rfc:s559dc201b043", "sse-spec:sf47a21cc6a1c", "grpc-status:s595c3c297157", "stripe-webhooks:s42eea6a7e527", "stripe-webhooks:s90f21ad845ca", "owasp-docs:sb49221f05748", "owasp-docs:s0eccd394429a", "owasp-docs:se53dcf51421a", "owasp-docs:s2cddbc943d5f", "owasp-docs:s6d4b22f77307", "owasp-docs:sf288957942f5", "standard-webhooks:seaefbbec338e", "standard-webhooks:s63e0d131d760", "graphql-docs:s3df4f55b0bea", "graphql-docs:s199bd5e58cf1", "graphql-docs:sc5afee295dec", "grpc-auth:s362a756f8895", "grpc-auth:sbc5e08ab0a2f", "grpc-auth:s95d08dc70fee", "grpc-auth:s82eaa71eceda"], "status": "REASONED"}, "transport-authentication-sources": {"text": "Cited guidance: GraphQL docs say execution should begin after authentication middleware confirms user identity, which their example puts in request context, and that business logic should handle authorization; gRPC's guide separates channel credentials, such as SSL credentials, from call credentials, which can be combined. Applying gRPC credentials to inference/management APIs is guide inference.", "components": ["graphql-docs", "grpc-auth"], "sources": ["graphql-docs:s3df4f55b0bea", "graphql-docs:s199bd5e58cf1", "graphql-docs:sc5afee295dec", "grpc-auth:s362a756f8895", "grpc-auth:s95d08dc70fee"], "status": "REASONED"}, "transport-authentication-browser": {"text": "Documented: RFC 6455 leaves WebSocket handshake authentication to the server, which may use cookies, HTTP or TLS authentication, and site-restricted servers SHOULD verify Origin; OWASP says browsers send cookies in the handshake and to validate Origin against an allowlist; WHATWG EventSource sets credentials mode include if withCredentials is true. Whether endpoints check them is guide assessment.", "components": ["websocket-rfc", "owasp-docs", "sse-spec"], "sources": ["websocket-rfc:s1cf26aa107f9", "websocket-rfc:s6b9238cd4790", "owasp-docs:s5f89a38f97e4", "owasp-docs:sf12da7bc5eb6", "sse-spec:sf47a21cc6a1c"], "status": "REASONED"}, "transport-authentication-graphql": {"text": "Documented: graphql.org says to place GraphQL after all authentication middleware, sharing other HTTP handlers' session and user information; the GraphQL over HTTP draft lets a server forbid requests to require authentication and leaves authentication security out of scope. Apollo and Hasura are vendor examples only. Accepted credentials and middleware checks remain guide requirements.", "components": ["graphql-docs", "graphql-over-http", "apollo-server", "hasura"], "sources": ["graphql-docs:s18851e342534", "graphql-docs:s578f7bbdabd6", "graphql-over-http:s868371971ab7", "graphql-over-http:sc0111d7924ae", "apollo-server:s42ab6a721b3b", "apollo-server:sca37b76cac23", "hasura:sbdbe33edb8ca", "hasura:s9a644965f6c1"], "status": "REASONED"}, "transport-authentication-graphql-transport": {"text": "Documented: the GraphQL specification says requests need no specific serialization format or transport mechanism. Vendor example only: Apollo Server v4-v5 passes WebSocket connectionParams to the server, enabling the application to validate user credentials, and onConnect can reject a connection. No transport-independent GraphQL authentication mechanism is established (guide assessment).", "components": ["graphql-spec", "apollo-subscriptions"], "sources": ["graphql-spec:s7c188dfe7f19", "apollo-subscriptions:s9e2148905c07"], "status": "REASONED"}, "transport-authentication-hasura-jwt": {"text": "Vendor example only: Hasura v2.x reads the JWT from an Authorization: Bearer header by default, or a Cookie or custom header; a configured issuer or audience rejects a mismatched iss or aud claim, but by default an absent claim is accepted unless extra_required_claims lists audience or issuer. It warns that with JWKs shared across tenants, leaving audience unset lets other tenants' JWTs be valid.", "components": ["hasura-jwt"], "sources": ["hasura-jwt:s12bdcc98f47a"], "status": "REASONED"}, "transport-authentication-webhook-duplicates": {"text": "Vendor guidance: Stripe says endpoints might receive the same event more than once; logging processed event IDs and skipping logged ones guards against duplicated event receipts, but two separate Event objects can be sent, identified by data.object's ID with event.type. Neither this nor the Standard Webhooks five-minute example sets a universal webhook-ID retention period (guide assessment).", "components": ["stripe-docs", "standard-webhooks"], "sources": ["stripe-docs:s7e8f3e0d4d70", "standard-webhooks:s63e0d131d760"], "status": "REASONED"}, "transport-authentication-grpc": {"text": "Cited example: the grpc-go authentication example's server verifies call credentials in an interceptor it installs. Source reading: go-grpc-middleware runs an application AuthFunc or service override per call; vLLM v0.30.0 gRPC has no interceptor; TEI checks authorization only with an API key; Triton checks configured restricted headers.", "components": ["grpc-go-example", "grpc-middleware", "vllm", "triton", "tei-grpc"], "sources": ["grpc-go-example:sf3a4641f924b", "grpc-go-example:s7e5cc4ea3d23", "grpc-middleware:s9ec5783f5bab", "grpc-middleware:s4413e7e06e93", "vllm:sb6766a13ce4a", "triton:s2f7f38a83db9", "tei-grpc:scaa009c6db41"], "status": "REASONED"}, "transport-authentication-grpc-argo-cd": {"text": "Source reading: argocd-server's default gRPC auth function skips checks if auth is off, else tries the token; on a claims error it allows the call anonymously only if settings load with anonymous access on. Service overrides are excluded: go-grpc-middleware v2.3.3 (Argo CD's go.mod pin per gitops-controllers.md) runs them instead; that guide records overrides accepting some unauthenticated calls.", "components": ["argo-cd", "grpc-middleware"], "sources": ["argo-cd:s751b24797922", "argo-cd:s2bf01b29602c", "argo-cd:s19e9d1a1d4d2", "grpc-middleware:s9ec5783f5bab", "grpc-middleware:s4413e7e06e93"], "status": "REASONED"}, "offboarding": {"text": "When a person leaves, promptly revoke their provider membership, proxy sessions, application sessions, and personal tokens (this guide's recommendation). Entra documents blocking new sign-ins and revoking refresh tokens, while application-issued sessions require revocation by the application; its access-token lifetime varies, and CAE critical-event enforcement can take up to 15 minutes for supported services. Cloudflare Access documents per-user session revocation, but an active IdP profile can start a new session; after user logout, previously issued tokens stop being accepted in 20-30 seconds. OAuth2 Proxy and Authelia document session-cookie expiry, but these passages do not establish immediate per-user revocation for either proxy. RFC 7009 specifies immediate token invalidation with possible propagation delay and conditional access-token revocation support. The fetched sources do not establish a provider-membership removal mechanism or this deployment's measured maximum time-to-revoke. Measure each system's maximum time-to-revoke where immediate revocation is unavailable, and work to shrink it (this guide's recommendation; no delay was measured here). In a deployment test, first confirm that a matched request with each provider, proxy and application session and personal token succeeds against a protected resource. After revocation, replay each old credential at recorded intervals until it is refused and returns no protected data, and the elapsed time for every layer must not exceed its configured maximum time-to-revoke; these are test expectations, not observed results.", "components": ["owasp", "oauth", "owasp-docs", "entra-docs", "cloudflare-access", "oauth2-proxy", "authelia", "oauth-revocation"], "sources": ["owasp:sc5f1b11d42f4", "oauth:s4e6d674bf887", "owasp-docs:seb866a1f94e9", "entra-docs:s983f45259faa", "entra-docs:scb38e683ae3f", "entra-docs:s9ff4d6ed4cfe", "entra-docs:sca6feea4b601", "cloudflare-access:sf2dbab0fc574", "cloudflare-access:sa0e3eb928f4a", "oauth2-proxy:sa4c6097e2686", "authelia:sc844fe82794f", "oauth-revocation:s3383c9f41cc2"], "status": "REASONED"}, "verify-transports": {"text": "Use real protocol clients against every non-public path and transport: expect HTTP 401, 403 or a login redirect; gRPC UNAUTHENTICATED for missing or invalid credentials; SSE refusal; and WebSocket upgrade rejection or protected-operation rejection, and closure on first-message authentication failure. These are test expectations, not observed responses.", "components": ["owasp", "grpc-status", "websocket-rfc", "sse-spec"], "sources": ["owasp:sc5f1b11d42f4", "grpc-status:s595c3c297157", "websocket-rfc:s07f03be17365", "websocket-rfc:s559dc201b043", "sse-spec:sf47a21cc6a1c"], "status": "REASONED"}, "verify-secret-scan": {"text": "A secret scanner must find no leaks in the working tree, staged changes and full history; Gitleaks v8.30.1 provides staged git-diff flags and `--log-opts`, so verify staged, all-ref and merge-commit coverage with the chosen scanner and configuration. Supplementary git log grep misses secrets without the search words, uncommitted files, some refs and merge-only changes; the historical fixture result is not a scan of this repository.", "components": ["owasp", "gitleaks-source", "gitleaks-p3", "git-log"], "sources": ["owasp:sc5f1b11d42f4", "gitleaks-source:s44a3edaa543d", "gitleaks-p3:se5afdb7c63d2", "gitleaks-p3:s685ae6ce9b3d", "gitleaks-p3:sfda707f0eb4f", "gitleaks-p3:s8511b8f313b7", "git-log:s1cb7f677894d", "git-log:sede4bb3fcdf3", "git-log:s903a3c765e70"], "status": "REASONED"}, "verify-default-accounts": {"text": "Check that the user store has no admin, test or demo account with a known or empty password. This is the guide's recommendation; the OWASP Authentication Cheat Sheet does not list these account names (checked at commit 72865b1e).", "components": ["owasp"], "sources": ["owasp:sc5f1b11d42f4"], "status": "REASONED"}, "verify-wrong-tenant": {"text": "Expect a wrong-tenant token to be rejected in an application negative test after issuer and tid validation and the application tenant allowlist. Microsoft documents the validation rules; this guide recommends the allowlist and test, and no deployed rejection was observed.", "components": ["oidc", "entra-multitenant"], "sources": ["oidc:scfd2790a544c", "entra-multitenant:s95719206c226"], "status": "REASONED"}, "verify-cross-user": {"text": "Verify that user A cannot read user B's resources. OWASP describes horizontal privilege escalation and object-specific access checks.", "components": ["owasp", "owasp-p1"], "sources": ["owasp:sc5f1b11d42f4", "owasp-p1:s5f5e10a9e4c2"], "status": "REASONED"}, "oauth2-proxy-client-ip": {"text": "At v7.15.6, OAuth2 Proxy's trusted proxy warning adds an instruction to include the direct peer and every trusted proxy hop that can appear in X-Forwarded-For.", "components": ["oauth2-proxy"], "sources": ["oauth2-proxy:sa0e9b0ae70a5"], "status": "REASONED"}, "verify-origin-isolation": {"text": "Expect the origin to be unreachable except through its fronting layer in a deployment negative test. Cloudflare describes Access policy enforcement, JWT validation and Tunnel access checks, and says a publicly routable origin needs its IP protected by other methods. OAuth2 Proxy documents upstream identity headers and warns that when `--reverse-proxy` is enabled and `--trusted-proxy-ip` is unset, a client that can reach OAuth2 Proxy directly may be able to spoof forwarded headers. Authelia uses session cookies to authorize protected websites. Google IAP says a bypassing attacker can forge unsigned identity headers; its signed-header how-to says to verify the JWT header, payload and signature to secure an app with the IAP JWT, and calls signed headers secondary security; AWS ALB requires verification of its signed claims header and signer ARN. These sources support the guide's origin-isolation test expectation but do not establish that a particular origin is unreachable.", "components": ["owasp", "cloudflare-access", "oauth2-proxy", "authelia", "google-iap", "aws-alb"], "sources": ["owasp:sc5f1b11d42f4", "cloudflare-access:s7cafe1b17afd", "cloudflare-access:s81e5dd28d3e5", "cloudflare-access:sa012410f91eb", "oauth2-proxy:s18ec95d8d890", "oauth2-proxy:sa0e9b0ae70a5", "authelia:sc03548e2ed00", "google-iap:s4513609280bb", "aws-alb:sa198710d31f3"], "status": "REASONED"} } } ```` ====================================================================== ==> mfa.md ====================================================================== # Multi-factor authentication (MFA) Multi-factor authentication (MFA) asks for proof beyond a password, such as an authenticator-app code, a passkey or a hardware key. This guide helps your AI assistant choose and enforce it for each human login on an exposed service where viable, preferring platform or identity-provider MFA, with machine access handled separately. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mfa.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against TOTP standard (no recorded version), authentik source version/2026.8.3, Keycloak documentation 26.8.2, oauth2-proxy documentation v7.15.6, Caddy forward_auth minimum 2.5.1, google-authenticator-libpam source 1.11, and MySQL multifactor authentication 8.0 and 8.4, plus 9 more recorded bases; 14 further sources are pinned to a commit and 13 carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 35 claims: 2 demonstrated by a check that ran and 33 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Passwords fail through phishing, reuse, and credential stuffing; a second factor keeps a stolen password from becoming access. This guide names the options and the references; it deliberately stops short of per-product walkthroughs, because an AI assistant that knows which option fits can implement it from the linked project documentation. The per-tool guides in this repository state what is viable for each stack. ## What to do (AI assistants) 1. Give every human-facing login on an exposed service a second factor where viable. 2. Prefer, in this order: 1. Platform-native MFA, or OIDC/SSO login with MFA enforced at the identity provider. 2. An identity-aware layer in front of the app (Cloudflare Access, or a self-hosted portal below), which adds MFA without changing the app, but only when the layer's own policy, or the identity provider it delegates to, requires a second factor. 3. App-level TOTP through a library (below). 4. A hosted MFA service such as Duo. 3. Prefer phishing-resistant factors (WebAuthn/passkeys) over TOTP and push, and any of those over emailed or SMS codes. Neither TOTP nor push is phishing-resistant, and a bare approve/deny push also invites prompt-bombing (MFA fatigue), so a push factor must use number matching or verified push, never a single tap. This guide requires checking the effective push flow or choosing another factor: for Authenticator MFA push in the MFA scenario, Entra requires number matching but permits Yes/No on the same device in Microsoft apps, and Duo documents verified code entry for supported flows but gives conflicting statements about Duo Unix support (see Sources). The cross-method preference is this guide's assessment. 4. When implementing TOTP yourself, there are seven required pieces. Four of the required pieces are: a random per-user secret; an `otpauth://` provisioning URI rendered as a QR code for the user's authenticator app; verification of 1 valid code before the factor activates; and single-use recovery codes (stored hashed). The other three required pieces are: rate limiting on code attempts; rejection of a code that has already been accepted for its time step (RFC 6238 forbids accepting a code twice); and TOTP secrets encrypted at rest and excluded from the repository (they cannot be hashed, since the server must read them to verify codes). Require the existing factor to be re-verified before a user disables it, replaces it, or mints new recovery codes, so a password-only or stolen session cannot remove MFA. 5. Give administrators a phishing-resistant factor (a passkey or a FIDO2 hardware key) wherever the platform offers one. Guide advice: TOTP is the floor. This guide requires that administrator accounts not use SMS. OWASP advises MFA for privileged users and against SMS for high-value applications; Microsoft Entra recommends phishing-resistant MFA for its administrator roles, while NIST SP 800-63B-4 section 2.2.2 requires federal staff, contractors and partners to use phishing-resistant authentication for federal systems and AAL2 verifiers to offer a phishing-resistant option. 6. Enrolment is not enforcement. After users enrol, require the factor at the point of access (provider policy such as Conditional Access, Supabase `aal2` in policies, or the app's own check) and test that a password-only session is refused. ## Identity layers and auth proxies (open source) - **Authelia**: authentication portal that sits in front of a reverse proxy; per its support matrix it integrates with nginx (`auth_request`), Traefik (`forwardAuth`), Caddy (`forward_auth`, 2.5.1 and later), HAProxy (through a Lua module), and Envoy, while Apache and IIS are documented as unsupported. Second factors: TOTP, WebAuthn security keys, and Duo mobile push. Authelia v4.39.28 also documents passkey login; it counts as a single factor, and by default prompts for a password when the request requires MFA. https://www.authelia.com/ - **authentik**: its authenticator-validation stage rejects a response unless the login's device challenges include a compatible class: code responses check TOTP, static, SMS and email as one group, while WebAuthn and Duo each check their own class. Guide advice: require the validation stage in each protected login flow and test that a first-factor-only attempt is denied. https://goauthentik.io/ - **Keycloak**: its OTP policy configures time-based (TOTP) and counter-based generators and QR-code setup for authenticator apps. Guide advice: require OTP in each protected authentication flow and test enforcement after enrolment. https://www.keycloak.org/ - **oauth2-proxy**: configures an OAuth provider for login and routes requests to configured upstream endpoints. Guide advice: require MFA in the provider policy for this login and test that access without the second factor is denied. https://github.com/oauth2-proxy/oauth2-proxy The authentik source, Keycloak documentation and oauth2-proxy documentation cited in Sources do not demonstrate automatic MFA inheritance or a deployed policy. ## App-level TOTP libraries Each generates and verifies RFC 6238 codes and pairs with a QR library so users can enrol a compatible TOTP authenticator app (examples in this guide's assessment: Google Authenticator, Microsoft Authenticator, Aegis, FreeOTP, and password managers with TOTP support). - Python: [pyotp](https://github.com/pyauth/pyotp) with [qrcode](https://pypi.org/project/qrcode/); [django-otp](https://pypi.org/project/django-otp/) integrates this into Django. - Node.js: [otplib](https://github.com/yeojz/otplib) with [qrcode](https://www.npmjs.com/package/qrcode). - Go: [pquerna/otp](https://github.com/pquerna/otp), which includes QR image generation. ## SSH and host logins - [google-authenticator-libpam](https://github.com/google/google-authenticator-libpam): PAM module supporting per-account TOTP. Guide assessment: it protects only the logins whose PAM services include it. Its `google-authenticator` setup command displays a QR code when libqrencode is available. Debian trixie and Ubuntu noble list the `libpam-google-authenticator` package as 20191231-based builds; the behaviour above is cited at tag `1.11`. - Duo Unix (`pam_duo`) adds push-approval MFA to SSH: https://duo.com/docs/duounix. The pinned `pam_duo.8` documents `verified_push` (default `no`), which cannot be combined with `autopush` (see Sources). - Make the PAM factor mandatory, or it is not enforced: with `UsePAM yes` and `KbdInteractiveAuthentication yes`, set `AuthenticationMethods publickey,keyboard-interactive:pam` in sshd (the `:pam` device pins the second step to the PAM stack, since a bare `keyboard-interactive` can route to another backend, and requiring the public key first means a key-based login still runs it). Guide advice: drop `nullok` once rollout is done so this module rejects accounts without a secret file. Set Duo's `failmode=secure` (its default `safe` grants access when Duo is unreachable) with a separately controlled emergency path. ## Hosted MFA - **Hosted identity providers** (Microsoft Entra ID, Google Workspace (administrators can require 2-Step Verification for selected users or groups), Auth0, Amazon Cognito, Clerk, Supabase Auth, and others) offer MFA controls for sign-ins. Guide assessment: an app using one is protected only when its effective provider or app policy requires the second factor for that sign-in. Enforcement lives in a layer that varies by provider: a tenant or Conditional-Access policy, a per-user requirement, or authorization in your own app and database (Supabase, for example, still admits an `aal1` session unless your policies require `aal2`). Enrolment, or merely enabling a factor, does not enforce it; inspect and test the effective policy. Which tiers include MFA, and which do not, is in [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md); wiring is in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md). - **Duo**: the Duo Free edition covers up to 10 users with MFA and the Duo Mobile authenticator app (per https://duo.com/editions-and-pricing as of September 2026; verify current terms). Its Authentication Proxy receives authentication requests from devices and applications via RADIUS or LDAP and contacts Duo for secondary authentication; guide advice: verify that each intended service sends its sign-ins through the proxy and requires the second factor. - **Cloudflare Access** ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)): the emailed one-time PIN proves control of a mailbox only. Either enforce MFA at a connected identity provider (Access inherits it only when that provider's policy requires it for the login), or use Access's own independent MFA to require a second factor (a TOTP authenticator or a WebAuthn key) without relying on the IdP. Enforcing MFA once at a central identity provider is easier to operate and audit than separate factors per app; prefer it when more than 1 service is involved. ## Passkeys and hardware keys WebAuthn passkeys and FIDO2 hardware keys (YubiKey and similar) resist phishing because the credential is bound to the site's origin; a look-alike domain gets nothing. Guide assessment: some hosted providers in [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) offer passkeys or security keys; check the provider and tier. When implementing them yourself, this guide advises using a maintained WebAuthn library rather than parsing attestation by hand (for example, py_webauthn v3.0.1, SimpleWebAuthn v14.0.3, or go-webauthn v0.18.2, whose pinned READMEs describe WebAuthn implementations). This guide's self-implementation advice is also to store the credential public key and sign count, and to require and verify the user-verification (UV) flag server-side where a passkey stands alone, or the ceremony proves possession only, not a second factor. Guide advice: treat the sign count as a clone-risk signal, not an automatic clone block; WebAuthn permits a zero value when an authenticator does not implement a signature counter. Keep a recovery path (a second key or single-use recovery codes) so a lost key is not a lockout, but note that for an admin on a phishing-resistant key the printed codes become the weakest, phishable link. The steps for an admin on a phishing-resistant key are to prefer a second hardware key (this guide's recommendation), rate-limit recovery-code attempts (NIST SP 800-63B-4 section 4.2.1.1 subjects saved recovery codes to the throttling requirements in section 3.2.2), and notify the account holder whenever a recovery code is used. NIST SP 800-63B-4 section 4.2.3 requires every account recovery to send a notification to the subscriber or their designee. This guide also recommends alerting your own operators on recovery-code use; that is not a NIST requirement. ## Where direct MFA is not viable In this guide's assessment, unattended machine connections (service-to-service database and model-server APIs) have no interactive second-factor dialogue, though some databases do offer native MFA for an interactive human login. The database example in this guide's assessment is MySQL: MySQL 8.0 from 8.0.27, and MySQL 8.4, support up to three authentication factors; in MySQL 8.4 its server-side WebAuthn plugin is included only in Enterprise Edition, while the client-side plugin is included in all distributions. For the unattended connections this guide advises the pattern: mutual TLS client certificates as the possession factor for the service itself, and MFA on every human path that reaches the host (SSH, bastions, admin panels). OWASP documents client-certificate authentication and says to validate the server's identity and trust configuration independently. The [PostgreSQL](https://github.com/jposluns/secureconfig/blob/main/postgresql.md) and [MySQL](https://github.com/jposluns/secureconfig/blob/main/mysql.md) guides apply client-certificate controls; the PostgreSQL guide also directs human SSH and administrative UI access to MFA. ## Verify - REASONED: operation-level enforcement per the guide's cited Supabase documentation entries on server-side MFA enforcement and on restrictive RLS requiring aal2, and the cited Microsoft Entra Conditional Access MFA authentication-strength requirement, with the expected refusal without and success with the second factor stated in this item. No deployed application, identity provider or enrolled test account is available in the authoring environment, and this item names no command, so no part of it ran locally. With only the password, attempt an MFA-protected operation, not just a login: once the factor is enrolled AND enforcement is on (provider policy, Supabase `aal2`, or the app's own check, per rule 6) the operation is refused, and completing the second factor then allows it. Some layers still open a password-only (`aal1`) session and enforce at the operation, so a session opening is not itself a failure; a protected operation that succeeds without a second factor is, and the user is not protected. - REASONED: recovery-code handling per the guide's cited NIST SP 800-63B-4 look-up secrets entry, whose section 3.1.2.2 requires single successful use and hashed storage (reread in the local reference copy at marking time). No deployed application or credential store is available in the authoring environment, so neither a refused reuse nor the stored form was observed, and this item names no command. A reused code that works or a stored plaintext code is the exposed state. Recovery codes are single-use, and their hashes rather than their values are stored. - REASONED: attempt throttling per the guide's cited HOTP (RFC 4226) and TOTP (RFC 6238) entries; no deployed application with an enrolled test account is available in the authoring environment, so no wrong codes were sent, and this item names no command. Unlimited wrong attempts are the exposed state and a rate limit or lockout the fixed one. Repeated wrong codes hit a rate limit or lockout. - REASONED: replay refusal per the guide's cited TOTP (RFC 6238) entry; no deployed application with an enrolled test account is available in the authoring environment, so no code was submitted, and this item names no command. A second acceptance of the same code is the exposed state and its refusal the fixed one. Submitting the same valid TOTP code a second time within its time step is refused. - DEMONSTRATED: repository and history search on disposable fixture repositories at marking time: a known-value search (git log --all -S over history, grep -rF outside .git over the working tree) found a planted synthetic base32 TOTP secret and three recovery codes in exposed repositories, in history after deletion and in the working tree, and found none of them in a clean control. Gitleaks 8.30.1 with default rules flagged the TOTP secret only when it was assigned to a SECRET-named variable and missed a bare base32 value and all three recovery codes, so a clean gitleaks run does not satisfy this item. For a new repository, search each known plaintext value with `git log --all -S'REPLACE_WITH_KNOWN_VALUE'` and `grep -rF --exclude-dir=.git -- 'REPLACE_WITH_KNOWN_VALUE' .`; Git selects commits whose occurrence count changes across refs and HEAD, and grep recursively searches fixed strings outside `.git`. Replace `REPLACE_WITH_KNOWN_VALUE` with each known plaintext value before running either command; empty output with the unsubstituted placeholder proves nothing. The search needs the plaintext values; by inference from the documented `--all` scope, reflog-only and unreachable commits are outside this scan, and `-S` misses unchanged occurrence counts. Without `--diff-merges` (except with `--first-parent`), merge commits do not match `-S`, and `-S` does not search commit or tag messages. This demonstrates the search, not that any given repository is clean. No TOTP secret or recovery code appears in the repository or its history. - A departed user's second factor, active sessions, and app-level access are revoked, not only their password changed. Enforcing MFA is not the end; offboarding belongs in the access lifecycle too, and [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md) has the checklist. ## Standards and sources (checked September 2026) - TOTP: https://www.rfc-editor.org/info/rfc6238/ ; HOTP: https://www.rfc-editor.org/info/rfc4226/ - `otpauth://` key URI format: https://github.com/google/google-authenticator/wiki/Key-Uri-Format - WebAuthn: https://www.w3.org/TR/webauthn-2/ - MySQL 8.0 multifactor authentication (support from 8.0.27; checked October 2026): https://dev.mysql.com/doc/refman/8.0/en/multifactor-authentication.html - MySQL 8.4 multifactor authentication (up to three factors; checked October 2026): https://dev.mysql.com/doc/refman/8.4/en/multifactor-authentication.html - MySQL 8.4 WebAuthn authentication (server-side plugin only in Enterprise Edition; client-side plugin in all distributions; checked October 2026): https://dev.mysql.com/doc/refman/8.4/en/webauthn-pluggable-authentication.html - Authelia proxy support matrix (Caddy 2.5.1 and later): https://www.authelia.com/integration/proxies/support/ - Duo editions and pricing: https://duo.com/editions-and-pricing - Duo Verified Push policy (rolling documentation, checked October 2026): https://duo.com/docs/policy#verified-push - google-authenticator-libpam `1.11`, per-account OTP and conditional QR enrolment (checked October 2026): https://github.com/google/google-authenticator-libpam/blob/1.11/README.md#L45-L70 - google-authenticator-libpam `1.11`, per-account secret and `nullok` behaviour (checked October 2026): https://github.com/google/google-authenticator-libpam/blob/1.11/man/pam_google_authenticator.8.md#L16-L29 - Debian trixie `libpam-google-authenticator` package, `20191231-2.1` (rolling package record, checked October 2026): https://packages.debian.org/trixie/libpam-google-authenticator - Ubuntu noble `libpam-google-authenticator` package, `20191231-2build1` (rolling package record, checked October 2026): https://packages.ubuntu.com/noble/libpam-google-authenticator - Duo Authentication Proxy RADIUS and LDAP modes (rolling documentation, checked October 2026): https://duo.com/docs/authproxy-reference - Google Workspace 2-Step Verification policy (rolling documentation, checked October 2026): https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification - authentik source `version/2026.8.3`, authenticator-validation stage and allowed device classes (checked October 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.3/authentik/stages/authenticator_validate/stage.py#L80-L108 - Keycloak documentation `26.8.2`, OTP policy and QR-code setup (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc#L2-L21 - oauth2-proxy documentation `v7.15.6`, configured OAuth provider (checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L94-L108 - oauth2-proxy documentation `v7.15.6`, configured upstream endpoints (checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L305 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, application session revocation (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L58-L62 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, application deprovisioning and session limits (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L137-L147 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, removal of app user and group assignments (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/enterprise-apps/manage-application-permissions.md#L91-L113 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, revocation of user-consented app permissions through Graph or PowerShell (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/enterprise-apps/manage-application-permissions.md#L40-L50 - OWASP cheat sheets `668ba7db3d0da5868b8a0305c259f7f6914a6ecd`, mTLS client-certificate authentication and server validation (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Transport_Layer_Security_Cheat_Sheet.md#L217-L221 - OWASP cheat sheets `668ba7db3d0da5868b8a0305c259f7f6914a6ecd`, revocation of unneeded or compromised secrets (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L218 - pquerna/otp source `617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870`, TOTP enrollment validation (checked October 2026): https://github.com/pquerna/otp/blob/617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870/README.md#L27-L30 - OWASP cheat sheets `668ba7db3d0da5868b8a0305c259f7f6914a6ecd`, reauthentication before factor changes (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L154-L155 - NIST digital identity guidelines `SP 800-63B-4`, single-use look-up secrets and hashed storage (checked October 2026): https://pages.nist.gov/800-63-4/sp800-63b/authenticators/#look-up-secrets - NIST digital identity guidelines `SP 800-63B-4`, section 2.2.2 (AAL2 phishing resistance), sections 3.1.3, 3.2.5, 3.1.3.1 and 3.1.3.3 (push fatigue, phishing resistance, email and PSTN), 3.1.2, 4.1.2.1, 4.2.1.1 and 4.2.3 (recovery), and 4.5 (authenticator invalidation); PDF (checked October 2026): https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b-4.pdf - OWASP cheat sheets `668ba7db3d0da5868b8a0305c259f7f6914a6ecd`, MFA for privileged users (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L40 - OWASP cheat sheets `668ba7db3d0da5868b8a0305c259f7f6914a6ecd`, SMS caution for high-value applications (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L304-L309 - OWASP cheat sheets `668ba7db3d0da5868b8a0305c259f7f6914a6ecd`, recovery codes and alternate factors (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L142-L143 - Authelia source `v4.39.28`, passkey login factor behaviour (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/webauthn.md#L69-L75 - Authelia source `v4.39.28`, TOTP, WebAuthn security keys and Duo push factors (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/introduction.md#L21-L31 - py_webauthn source `v3.0.1`, server-side registration and authentication verification (checked October 2026): https://github.com/duo-labs/py_webauthn/blob/v3.0.1/README.md#L4-L25 - SimpleWebAuthn source `v14.0.3`, server and browser packages (checked October 2026): https://github.com/MasterKale/SimpleWebAuthn/blob/v14.0.3/README.md#L15-L21 - go-webauthn source `v0.18.2`, Go WebAuthn library (checked October 2026): https://github.com/go-webauthn/webauthn/blob/v0.18.2/README.md#L10-L13 - PyOTP source `cc156832fa9f3d5af7fb9e4e6b86b29dfc5b69f1`, OTP generation, verification and RFC 6238 support (checked October 2026): https://github.com/pyauth/pyotp/blob/cc156832fa9f3d5af7fb9e4e6b86b29dfc5b69f1/README.rst#L4-L9 - Python qrcode source `f92c007498d796e4825638b7d50c14ea51e5457a`, QR code generation (checked October 2026): https://github.com/lincolnloop/python-qrcode/blob/f92c007498d796e4825638b7d50c14ea51e5457a/README.rst#L5-L8 - django-otp source `88cf4970423b11f29d6840311f53378d007a3d63`, Django integration and HOTP/TOTP implementations (checked October 2026): https://github.com/django-otp/django-otp/blob/88cf4970423b11f29d6840311f53378d007a3d63/README.rst#L16-L28 - otplib source `c5c11be2a50fd864b587854bff6d97c5f8aa9c84`, HOTP/TOTP and Node support (checked October 2026): https://github.com/yeojz/otplib/blob/c5c11be2a50fd864b587854bff6d97c5f8aa9c84/README.md#L13 - Node qrcode source `3848ed2c17de5bcdead487417dbf14c5dd017f8d`, QR code generation (checked October 2026): https://github.com/soldair/node-qrcode/blob/3848ed2c17de5bcdead487417dbf14c5dd017f8d/README.md#L2 - pquerna/otp source `617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870`, TOTP/HOTP generation, validation and QR images (checked October 2026): https://github.com/pquerna/otp/blob/617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870/README.md#L15-L18 - OpenSSH portable source `V_9_9_P2`, ordered authentication methods and the PAM device (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L197-L211 - OpenSSH portable source `V_9_9_P2`, UsePAM and keyboard-interactive authentication (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L2017-L2026 - Duo Unix source `91e3c987f256afdb166b26be777cef6e1ab2af11`, pam_duo failmode and push approval (checked October 2026): https://github.com/duosecurity/duo_unix/blob/91e3c987f256afdb166b26be777cef6e1ab2af11/pam_duo/pam_duo.8#L44-L53 - Duo Unix source `91e3c987f256afdb166b26be777cef6e1ab2af11`, pam_duo `verified_push` and `autopush` options (checked October 2026): https://github.com/duosecurity/duo_unix/blob/91e3c987f256afdb166b26be777cef6e1ab2af11/pam_duo/pam_duo.8#L60-L63 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, administrator phishing-resistant MFA policy (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-admin-phish-resistant-mfa.md#L12-L21 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, Conditional Access MFA authentication-strength requirement (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-all-users-mfa-strength.md#L56-L57 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, Authenticator push number matching (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-number-match.md#L12 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, Authenticator MFA number entry (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-number-match.md#L26-L28 - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, same-device Yes/No exception (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-number-match.md#L96-L100 - Auth0 archived documentation `5258d17ede3eea0ed0e330d15f8921a542564f68`, factor enablement and separate MFA enforcement choice (checked October 2026): https://github.com/auth0/docs/blob/5258d17ede3eea0ed0e330d15f8921a542564f68/articles/mfa/guides/enable-mfa.md#L12 - Auth0 MFA documentation, current factor and enforcement policy documentation (rolling documentation, checked October 2026): https://auth0.com/docs/secure/multi-factor-authentication/enable-mfa - Amazon Cognito API source `2ba0e39015ddf9c91c6c378f8a4fb79dcb4353da`, required versus optional MFA (checked October 2026): https://github.com/aws/aws-sdk-go-v2/blob/2ba0e39015ddf9c91c6c378f8a4fb79dcb4353da/service/cognitoidentityprovider/api_op_SetUserPoolMfaConfig.go#L67-L76 - Clerk documentation `26ef3ace7508d46d6242c377e830a7791b0a2fdb`, application-wide versus user-selected MFA (checked October 2026): https://github.com/clerk/clerk-docs/blob/26ef3ace7508d46d6242c377e830a7791b0a2fdb/docs/guides/configure/auth-strategies/sign-up-sign-in-options.mdx#L168-L171 - Supabase documentation `10c7379c970ae4d2f30f3158194061cf5ffb8fc5`, server-side MFA enforcement beyond enrollment UI (checked October 2026): https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L94-L96 - Supabase documentation `10c7379c970ae4d2f30f3158194061cf5ffb8fc5`, restrictive RLS requiring aal2 (checked October 2026): https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L192-L200 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, email one-time PIN login (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/one-time-pin.mdx#L15 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, independent MFA with TOTP and WebAuthn security keys (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24 - Git documentation `v2.56.0`, --all includes refs and HEAD (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/rev-list-options.adoc#L167-L169 - Git documentation `v2.56.0`, -S occurrence-count search (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/diff-options.adoc#L650-L653 - Git documentation `v2.56.0`, -S move and unchanged-count limits (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/gitdiffcore.adoc#L251-L259 - Git documentation `v2.56.0`, merge-commit -S limit and first-parent exception (checked October 2026): https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L130-L135 - GNU grep documentation `v3.12`, recursive search (lines 757-769) (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n757 - GNU grep documentation `v3.12`, fixed-string search (lines 1150-1156) (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1150 - GNU grep documentation `v3.12`, directory exclusion (lines 734-740) (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n734 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - totp: TOTP standard (unknown) - hotp: HOTP standard (unknown) - uri: Authenticator key URI format (unknown) - webauthn: WebAuthn standard (unknown) - authelia: Authelia proxy support (unknown) - authentik-source: authentik source (version/2026.8.3) - keycloak-source: Keycloak documentation (26.8.2) - oauth2-proxy-source: oauth2-proxy documentation (v7.15.6) - caddy-min: Caddy forward_auth minimum (2.5.1) - duo: Duo editions (unknown) - duo-push-policy: Duo Verified Push policy (unknown) - ssh-pam: SSH and PAM MFA (citation gap) (unknown) - google-authenticator-libpam-source: google-authenticator-libpam source (1.11) - google-authenticator-libpam-packages: google-authenticator-libpam package records (unknown) - duo-proxy-docs: Duo Authentication Proxy documentation (unknown) - google-workspace-docs: Google Workspace 2SV documentation (unknown) - supabase: Supabase Auth MFA (citation gap) (unknown) - access: Cloudflare Access MFA (citation gap) (unknown) - mysql: MySQL multifactor authentication (8.0) - mysql-8-4: MySQL multifactor authentication (8.4) - mysql-webauthn: MySQL WebAuthn authentication (8.4) - entra-docs: Microsoft Entra documentation (231747abc59ae3d50c74e215c1cdd592eb980723) - owasp-docs: OWASP cheat sheets (668ba7db3d0da5868b8a0305c259f7f6914a6ecd) - otp-source: pquerna/otp source (617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870) - nist: NIST digital identity guidelines (SP 800-63B-4) - authelia-source: Authelia source (v4.39.28) - pyotp-source: PyOTP source (cc156832fa9f3d5af7fb9e4e6b86b29dfc5b69f1) - python-qrcode-source: Python qrcode source (f92c007498d796e4825638b7d50c14ea51e5457a) - django-otp-source: django-otp source (88cf4970423b11f29d6840311f53378d007a3d63) - otplib-source: otplib source (c5c11be2a50fd864b587854bff6d97c5f8aa9c84) - node-qrcode-source: Node qrcode source (3848ed2c17de5bcdead487417dbf14c5dd017f8d) - openssh-source: OpenSSH portable source (V_9_9_P2) - duo-unix-source: Duo Unix source (91e3c987f256afdb166b26be777cef6e1ab2af11) - auth0-docs: Auth0 archived documentation (5258d17ede3eea0ed0e330d15f8921a542564f68) - auth0-rolling: Auth0 MFA documentation (unknown) - cognito-api-source: Amazon Cognito API source (2ba0e39015ddf9c91c6c378f8a4fb79dcb4353da) - clerk-docs: Clerk documentation (26ef3ace7508d46d6242c377e830a7791b0a2fdb) - supabase-docs: Supabase documentation (10c7379c970ae4d2f30f3158194061cf5ffb8fc5) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - webauthn-python: py_webauthn source (v3.0.1) - webauthn-node: SimpleWebAuthn source (v14.0.3) - webauthn-go: go-webauthn source (v0.18.2) - git-docs: Git documentation (v2.56.0) - grep-docs: GNU grep documentation (v3.12) Claims (full source mapping in the JSON block below): - totp-secret (REASONED; totp): Generate a random per-user TOTP secret; verification requires the recoverable secret rather than its hash. - totp-provisioning (REASONED; uri): Provision the authenticator with an otpauth:// URI rendered as a QR code. - totp-storage (REASONED; totp): Encrypt TOTP secrets at rest and exclude them from the repository. - totp-throttling (REASONED; hotp, totp): Rate-limit code attempts; repeated wrong codes must hit a rate limit or lockout. - totp-replay (REASONED; totp): Reject an already accepted code within its time step; Verify submits the same valid code twice and expects refusal on reuse. - authelia-proxies (REASONED; authelia, caddy-min): Authelia integrates with nginx auth_request, Traefik forwardAuth, Caddy forward_auth from 2.5.1, HAProxy via Lua and Envoy; Apache and IIS are unsupported. - duo-free (REASONED; duo): Duo Free covers up to 10 users with MFA and Duo Mobile as recorded in September 2026; verify current terms. - webauthn-origin (REASONED; webauthn): Prefer WebAuthn/passkeys and FIDO2 keys for phishing resistance: credentials are bound to the site origin. - webauthn-verification (REASONED; webauthn): Store the credential public key and sign count; require and verify UV server-side when a passkey stands alone. - hosted-passkeys (REASONED; webauthn): Guide assessment: some hosted identity providers offer passkeys or security keys; check the provider and tier before relying on availability. The linked identity-providers guide gives examples; the WebAuthn standard does not establish universal provider coverage. - webauthn-counter (REASONED; webauthn): Guide advice: treat sign count as a clone-risk signal, not an automatic clone block; WebAuthn permits zero when an authenticator does not implement a signature counter. - verify-secret-storage (DEMONSTRATED; totp): Check that no TOTP secret appears in the repository or its history. - mfa-enforcement (REASONED; totp, entra-docs, auth0-docs, auth0-rolling, cognito-api-source, clerk-docs, supabase-docs): Give every exposed human-facing login a second factor where viable; prefer native or IdP-enforced MFA, then an identity layer whose own or upstream policy requires MFA, app-level TOTP, then a hosted MFA service. Enrollment alone is not enforcement. - push-matching (REASONED; webauthn, duo, duo-unix-source, entra-docs, nist, duo-push-policy): Prefer WebAuthn/passkeys over TOTP or push, and those over email or SMS codes. TOTP and push are not phishing-resistant; push must use number matching or verified push, never bare approve/deny, to resist prompt-bombing. NIST SP 800-63B-4 sections 3.1.3, 3.2.5, 3.1.3.1 and 3.1.3.3 support the fatigue, phishing and email/PSTN rationale. For Authenticator MFA push in the MFA scenario, Entra requires number matching; its same-device Microsoft app flow can show Yes/No. Duo's Verified Duo Push uses code entry in supported flows, but its policy both lists Duo Unix v2.2.0 and later as supported and says verification has no effect in Duo Unix. The pinned pam_duo.8 documents verified_push (default no), which cannot be combined with autopush. This guide requires checking the effective push flow or choosing another factor; the cross-method preference is this guide's assessment. The cited Duo editions source covers editions and pricing only. - totp-activation (REASONED; totp, otp-source): Verify one valid TOTP code before activating the factor. - recovery-codes (REASONED; totp, nist, owasp-docs): Issue single-use recovery codes and store hashes rather than the code values; Verify checks both storage and refusal on reuse. - factor-changes (REASONED; totp, owasp-docs): Require re-verification of the existing factor before disabling or replacing it or minting new recovery codes, so a password-only or stolen session cannot remove MFA. - admin-factors (REASONED; webauthn, owasp-docs, entra-docs, nist): Give administrators a phishing-resistant passkey or FIDO2 key wherever offered; TOTP is the floor and SMS is unacceptable for administrator accounts. OWASP advises MFA for privileged users; Microsoft Entra recommends phishing-resistant MFA for its administrator roles, and NIST SP 800-63B-4 section 2.2.2 requires it for federal staff, contractors and partners accessing federal systems and requires an AAL2 phishing-resistant option. The TOTP floor is this guide's advice; this guide requires that administrator accounts not use SMS, and OWASP advises against SMS for high-value applications. - authelia-factors (REASONED; authelia, authelia-source): The body lists TOTP, WebAuthn security keys and Duo mobile push as Authelia second factors. Authelia v4.39.28 also documents passkey login, which counts as a single factor and by default prompts for a password when the request requires MFA. - identity-layer-factors (REASONED; authentik-source, keycloak-source, oauth2-proxy-source): The authentik validation stage rejects a response unless the login's device challenges include a compatible class; Keycloak documents OTP policy for time-based (TOTP) and counter-based generators; oauth2-proxy configures an OAuth provider and routes to upstream endpoints. Requiring MFA in each protected login flow or provider policy and testing second-factor enforcement is this guide's advice. - totp-libraries (REASONED; totp, pyotp-source, python-qrcode-source, django-otp-source, otplib-source, node-qrcode-source, otp-source): The body lists pyotp with qrcode and django-otp, Node otplib with qrcode, and pquerna/otp with QR generation as RFC 6238 implementation options. Each option generates and verifies RFC 6238 codes and pairs with a QR library so users can enrol a compatible TOTP authenticator app. - ssh-pam-enforcement (REASONED; ssh-pam, openssh-source): Make SSH PAM MFA mandatory with UsePAM yes, KbdInteractiveAuthentication yes and AuthenticationMethods publickey,keyboard-interactive:pam; :pam pins the second step to PAM and the public-key-first sequence still runs it for key-based login. - ssh-nullok (REASONED; google-authenticator-libpam-source, google-authenticator-libpam-packages): Guide advice: remove nullok after rollout so accounts without a secret file are denied by this PAM module. google-authenticator-libpam is a PAM module supporting per-account TOTP. Guide assessment: it protects only the logins whose PAM services include it. Its setup command displays a QR code when libqrencode is available. Debian trixie and Ubuntu noble list libpam-google-authenticator as 20191231-based builds; the behaviour above is cited at tag 1.11. - duo-unix-failmode (REASONED; duo, duo-unix-source): Duo Unix pam_duo adds SSH push MFA; set failmode=secure for mandatory MFA because default safe grants access when Duo is unreachable, and keep a separately controlled emergency path. The pinned pam_duo.8 also documents verified_push (default no), which cannot be combined with autopush. - supabase-assurance (REASONED; supabase, supabase-docs): Supabase admits an aal1 session unless app and database policies require aal2; enrollment or enabling a factor is not enforcement, so inspect and test the effective policy. - google-workspace-enforcement (REASONED; google-workspace-docs): Google Workspace administrators can require 2-Step Verification for selected users or groups. Guide assessment: an app using a hosted provider is protected only when its effective provider or app policy requires the second factor for that sign-in. Guide advice: inspect and test the effective policy for each protected sign-in. - duo-proxy (REASONED; duo-proxy-docs): Duo Authentication Proxy receives requests from devices and applications via RADIUS or LDAP and contacts Duo for secondary authentication. Guide advice: verify that each intended service sends its sign-ins through the proxy and requires the second factor. - access-mfa (REASONED; access, cf-docs): An Access emailed one-time PIN proves mailbox control only; require MFA at the connected IdP for this login or Access independent MFA with TOTP or a WebAuthn key. - webauthn-library (REASONED; webauthn, webauthn-python, webauthn-node, webauthn-go): When implementing WebAuthn yourself, this guide advises using a maintained WebAuthn library rather than hand-parsing attestation. The pinned py_webauthn, SimpleWebAuthn and go-webauthn READMEs document server-side WebAuthn implementation options; the recommendation to use a maintained library is this guide's assessment, not a README requirement. - webauthn-recovery (REASONED; webauthn, nist, owasp-docs): Keep a second key or single-use recovery codes to avoid lost-key lockout; for administrators on phishing-resistant keys, prefer a second hardware key because printed codes are phishable, rate-limit recovery-code attempts, notify the account holder of recovery-code use, and alert operators on it. NIST SP 800-63B-4 section 4.1.2.1 supports two means of authentication in general; section 3.1.2 says look-up secrets are not phishing-resistant; sections 4.2.1.1 and 4.2.3 support saved recovery-code throttling and account-recovery notification to the subscriber or their designee. The administrator second-hardware-key preference and operator alert are this guide's recommendations; NIST does not require an operator alert; no pinned reference establishes the administrator-specific preference or operator alert. - mysql-factors (REASONED; mysql, mysql-8-4, mysql-webauthn): Guide assessment: some databases offer native MFA for an interactive human login. MySQL 8.0 from 8.0.27, and MySQL 8.4, support up to three authentication factors; in MySQL 8.4 the server-side WebAuthn authentication plugin is included only in Enterprise Edition, while the client-side plugin is included in all distributions. - unattended-mtls (REASONED; totp, webauthn, owasp-docs): Guide assessment: unattended database and model-server API connections have no interactive second-factor dialogue. Guide advice: use mutual TLS client certificates as the service's possession factor and MFA on human paths through SSH, bastions and admin panels. OWASP documents that mTLS authenticates the client with a certificate and proof of its private key and requires independent server-identity validation. The possession-factor interpretation and human-path requirement are this guide's advice; the PostgreSQL and MySQL guides document client-certificate controls. - verify-mfa-operation (REASONED; totp, supabase-docs): After enrollment and enforcement, a password-only attempt at an MFA-protected operation must fail and completing the second factor must allow it. Opening an aal1 session alone is not a failure when enforcement occurs at the operation. - verify-recovery-history (DEMONSTRATED; totp, git-docs, grep-docs): Verify requires that no TOTP secret or recovery code appears in the repository or its history. Git --all includes refs and HEAD, while -S selects occurrence-count changes; GNU grep -rF --exclude-dir=.git recursively searches fixed strings outside .git. This known-value scan needs plaintext values; empty output with an unsubstituted placeholder proves nothing. By inference from the documented --all scope, it does not cover reflog-only or unreachable commits; -S also misses unchanged occurrence counts. Without --diff-merges (except with --first-parent), merge commits do not match -S, and -S does not search commit or tag messages. A clean result is not proof that an arbitrary repository is clean. - verify-offboarding (REASONED; totp, entra-docs, owasp-docs, nist): Verify that a departed user's second factor, active sessions and app-level access are revoked; changing only the password is insufficient. NIST SP 800-63B-4 section 4.5 requires CSPs to promptly invalidate authenticators when the CSP determines that the subscriber no longer meets its eligibility requirements. Entra documents removal of application user and group assignments, revocation of user-consented app permissions through Graph or PowerShell, application deprovisioning and application-controlled session revocation; its identity-session revocation alone cannot revoke an application-issued session token. The deployed offboarding outcome remains a test expectation, and the cited documents do not establish it for a particular application. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "bac1bf62e7639167fa222996b6f207cddd47b7e52a35a30260af1b0d9b23c5a0", "components": { "totp": { "name": "TOTP standard", "basis": "unknown", "sources": { "s3190460e5d2d": "https://www.rfc-editor.org/info/rfc6238/" } }, "hotp": { "name": "HOTP standard", "basis": "unknown", "sources": { "s05c8bf9b6329": "https://www.rfc-editor.org/info/rfc4226/" } }, "uri": { "name": "Authenticator key URI format", "basis": "unknown", "sources": { "s4c73b28585c7": "https://github.com/google/google-authenticator/wiki/Key-Uri-Format" } }, "webauthn": { "name": "WebAuthn standard", "basis": "unknown", "sources": { "sef2c27817118": "https://www.w3.org/TR/webauthn-2/" } }, "authelia": { "name": "Authelia proxy support", "basis": "unknown", "sources": { "s1409ac8fe3d9": "https://www.authelia.com/integration/proxies/support/" } }, "authentik-source": { "name": "authentik source", "basis": "version/2026.8.3", "sources": { "s18275a7072e9": "https://github.com/goauthentik/authentik/blob/version/2026.8.3/authentik/stages/authenticator_validate/stage.py#L80-L108" } }, "keycloak-source": { "name": "Keycloak documentation", "basis": "26.8.2", "sources": { "sab71553daacd": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc#L2-L21" } }, "oauth2-proxy-source": { "name": "oauth2-proxy documentation", "basis": "v7.15.6", "sources": { "s523e9ec2bd77": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L94-L108", "sa2319a734772": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L305" } }, "caddy-min": { "name": "Caddy forward_auth minimum", "basis": "2.5.1", "sources": { "s1409ac8fe3d9": "https://www.authelia.com/integration/proxies/support/" } }, "duo": { "name": "Duo editions", "basis": "unknown", "sources": { "sb2b87daf72f0": "https://duo.com/editions-and-pricing" } }, "duo-push-policy": { "name": "Duo Verified Push policy", "basis": "unknown", "sources": { "s4358799e0562": "https://duo.com/docs/policy#verified-push" } }, "ssh-pam": { "name": "SSH and PAM MFA (citation gap)", "basis": "unknown", "sources": { "s3190460e5d2d": "https://www.rfc-editor.org/info/rfc6238/" } }, "google-authenticator-libpam-source": { "name": "google-authenticator-libpam source", "basis": "1.11", "sources": { "s7df1bfd61fef": "https://github.com/google/google-authenticator-libpam/blob/1.11/README.md#L45-L70", "s9ad5ca394e81": "https://github.com/google/google-authenticator-libpam/blob/1.11/man/pam_google_authenticator.8.md#L16-L29" } }, "google-authenticator-libpam-packages": { "name": "google-authenticator-libpam package records", "basis": "unknown", "sources": { "sf33e01264f1d": "https://packages.debian.org/trixie/libpam-google-authenticator", "s598691ba0908": "https://packages.ubuntu.com/noble/libpam-google-authenticator" } }, "duo-proxy-docs": { "name": "Duo Authentication Proxy documentation", "basis": "unknown", "sources": { "s4fa84e377f72": "https://duo.com/docs/authproxy-reference" } }, "google-workspace-docs": { "name": "Google Workspace 2SV documentation", "basis": "unknown", "sources": { "s4e673839253c": "https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification" } }, "supabase": { "name": "Supabase Auth MFA (citation gap)", "basis": "unknown", "sources": { "s3190460e5d2d": "https://www.rfc-editor.org/info/rfc6238/" } }, "access": { "name": "Cloudflare Access MFA (citation gap)", "basis": "unknown", "sources": { "s3190460e5d2d": "https://www.rfc-editor.org/info/rfc6238/", "sef2c27817118": "https://www.w3.org/TR/webauthn-2/" } }, "mysql": { "name": "MySQL multifactor authentication", "basis": "8.0", "sources": { "sa937d1361fe1": "https://dev.mysql.com/doc/refman/8.0/en/multifactor-authentication.html" } }, "mysql-8-4": { "name": "MySQL multifactor authentication", "basis": "8.4", "sources": { "s56ada201b676": "https://dev.mysql.com/doc/refman/8.4/en/multifactor-authentication.html" } }, "mysql-webauthn": { "name": "MySQL WebAuthn authentication", "basis": "8.4", "sources": { "s807a77fa8872": "https://dev.mysql.com/doc/refman/8.4/en/webauthn-pluggable-authentication.html" } }, "entra-docs": { "name": "Microsoft Entra documentation", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "s1341b9dbdad1": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L58-L62", "s2c2f323edbd0": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-all-users-mfa-strength.md#L56-L57", "sda22c238526b": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-number-match.md#L12", "s6889fae58a3a": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-number-match.md#L26-L28", "s3f868ef35f58": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-number-match.md#L96-L100", "s9f91c67f6011": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/enterprise-apps/manage-application-permissions.md#L91-L113", "s46eea4e600ea": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/enterprise-apps/manage-application-permissions.md#L40-L50", "s4e0a25001a1c": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L137-L147", "s2ee634f73c4b": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-admin-phish-resistant-mfa.md#L12-L21" } }, "owasp-docs": { "name": "OWASP cheat sheets", "basis": "668ba7db3d0da5868b8a0305c259f7f6914a6ecd", "sources": { "se60f5c9f1c55": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L218", "s2940f630fb71": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L154-L155", "sa72b5658a19b": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L142-L143", "s95dac24ad579": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L40", "sc030e5ded4b9": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Multifactor_Authentication_Cheat_Sheet.md#L304-L309", "s078b5519d6e8": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Transport_Layer_Security_Cheat_Sheet.md#L217-L221" } }, "otp-source": { "name": "pquerna/otp source", "basis": "617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870", "sources": { "s7c4c6d57f705": "https://github.com/pquerna/otp/blob/617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870/README.md#L27-L30", "s9dc96c06d6a0": "https://github.com/pquerna/otp/blob/617f8d5f3f518bae0f5c3ec14a17ca19f2a6f870/README.md#L15-L18" } }, "nist": { "name": "NIST digital identity guidelines", "basis": "SP 800-63B-4", "sources": { "s4e1146860142": "https://pages.nist.gov/800-63-4/sp800-63b/authenticators/#look-up-secrets", "s2a689905c161": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b-4.pdf" } }, "authelia-source": { "name": "Authelia source", "basis": "v4.39.28", "sources": { "sfeb54a05c30d": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/introduction.md#L21-L31", "s38726e46de86": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/webauthn.md#L69-L75" } }, "pyotp-source": { "name": "PyOTP source", "basis": "cc156832fa9f3d5af7fb9e4e6b86b29dfc5b69f1", "sources": { "sff126dae123b": "https://github.com/pyauth/pyotp/blob/cc156832fa9f3d5af7fb9e4e6b86b29dfc5b69f1/README.rst#L4-L9" } }, "python-qrcode-source": { "name": "Python qrcode source", "basis": "f92c007498d796e4825638b7d50c14ea51e5457a", "sources": { "s9481cd1c5c1c": "https://github.com/lincolnloop/python-qrcode/blob/f92c007498d796e4825638b7d50c14ea51e5457a/README.rst#L5-L8" } }, "django-otp-source": { "name": "django-otp source", "basis": "88cf4970423b11f29d6840311f53378d007a3d63", "sources": { "s3b66277026d8": "https://github.com/django-otp/django-otp/blob/88cf4970423b11f29d6840311f53378d007a3d63/README.rst#L16-L28" } }, "otplib-source": { "name": "otplib source", "basis": "c5c11be2a50fd864b587854bff6d97c5f8aa9c84", "sources": { "s1cfb4a0cb98d": "https://github.com/yeojz/otplib/blob/c5c11be2a50fd864b587854bff6d97c5f8aa9c84/README.md#L13" } }, "node-qrcode-source": { "name": "Node qrcode source", "basis": "3848ed2c17de5bcdead487417dbf14c5dd017f8d", "sources": { "s427c06d742cf": "https://github.com/soldair/node-qrcode/blob/3848ed2c17de5bcdead487417dbf14c5dd017f8d/README.md#L2" } }, "openssh-source": { "name": "OpenSSH portable source", "basis": "V_9_9_P2", "sources": { "sf299a4e3603a": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L197-L211", "s862f0df9ce84": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L2017-L2026" } }, "duo-unix-source": { "name": "Duo Unix source", "basis": "91e3c987f256afdb166b26be777cef6e1ab2af11", "sources": { "s1ea7669082c6": "https://github.com/duosecurity/duo_unix/blob/91e3c987f256afdb166b26be777cef6e1ab2af11/pam_duo/pam_duo.8#L44-L53", "sc183734b371e": "https://github.com/duosecurity/duo_unix/blob/91e3c987f256afdb166b26be777cef6e1ab2af11/pam_duo/pam_duo.8#L60-L63" } }, "auth0-docs": { "name": "Auth0 archived documentation", "basis": "5258d17ede3eea0ed0e330d15f8921a542564f68", "sources": { "sda6cf0f32c03": "https://github.com/auth0/docs/blob/5258d17ede3eea0ed0e330d15f8921a542564f68/articles/mfa/guides/enable-mfa.md#L12" } }, "auth0-rolling": { "name": "Auth0 MFA documentation", "basis": "unknown", "sources": { "s22eb9266f40c": "https://auth0.com/docs/secure/multi-factor-authentication/enable-mfa" } }, "cognito-api-source": { "name": "Amazon Cognito API source", "basis": "2ba0e39015ddf9c91c6c378f8a4fb79dcb4353da", "sources": { "s20a38c138333": "https://github.com/aws/aws-sdk-go-v2/blob/2ba0e39015ddf9c91c6c378f8a4fb79dcb4353da/service/cognitoidentityprovider/api_op_SetUserPoolMfaConfig.go#L67-L76" } }, "clerk-docs": { "name": "Clerk documentation", "basis": "26ef3ace7508d46d6242c377e830a7791b0a2fdb", "sources": { "s965680da0841": "https://github.com/clerk/clerk-docs/blob/26ef3ace7508d46d6242c377e830a7791b0a2fdb/docs/guides/configure/auth-strategies/sign-up-sign-in-options.mdx#L168-L171" } }, "supabase-docs": { "name": "Supabase documentation", "basis": "10c7379c970ae4d2f30f3158194061cf5ffb8fc5", "sources": { "sfae0cdf9b1b6": "https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L94-L96", "s4fe1e36a064b": "https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L192-L200" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s9f8bead847d2": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/integrations/identity-providers/one-time-pin.mdx#L15", "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24" } }, "webauthn-python": { "name": "py_webauthn source", "basis": "v3.0.1", "sources": { "s74d684e16405": "https://github.com/duo-labs/py_webauthn/blob/v3.0.1/README.md#L4-L25" } }, "webauthn-node": { "name": "SimpleWebAuthn source", "basis": "v14.0.3", "sources": { "sc50f8c0c3a95": "https://github.com/MasterKale/SimpleWebAuthn/blob/v14.0.3/README.md#L15-L21" } }, "webauthn-go": { "name": "go-webauthn source", "basis": "v0.18.2", "sources": { "s290e0174a612": "https://github.com/go-webauthn/webauthn/blob/v0.18.2/README.md#L10-L13" } }, "git-docs": { "name": "Git documentation", "basis": "v2.56.0", "sources": { "s903a3c765e70": "https://github.com/git/git/blob/v2.56.0/Documentation/rev-list-options.adoc#L167-L169", "sc68f7234f5e1": "https://github.com/git/git/blob/v2.56.0/Documentation/diff-options.adoc#L650-L653", "s7d93c14a69a6": "https://github.com/git/git/blob/v2.56.0/Documentation/gitdiffcore.adoc#L251-L259", "sa87ce1942f01": "https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L130-L135" } }, "grep-docs": { "name": "GNU grep documentation", "basis": "v3.12", "sources": { "sfdd7a73eab39": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n757", "scd528c854b46": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1150", "s64678780d667": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n734" } } }, "claims": { "totp-secret": {"text": "Generate a random per-user TOTP secret; verification requires the recoverable secret rather than its hash.", "components": ["totp"], "sources": ["totp:s3190460e5d2d"], "status": "REASONED"}, "totp-provisioning": {"text": "Provision the authenticator with an otpauth:// URI rendered as a QR code.", "components": ["uri"], "sources": ["uri:s4c73b28585c7"], "status": "REASONED"}, "totp-storage": {"text": "Encrypt TOTP secrets at rest and exclude them from the repository.", "components": ["totp"], "sources": ["totp:s3190460e5d2d"], "status": "REASONED"}, "totp-throttling": {"text": "Rate-limit code attempts; repeated wrong codes must hit a rate limit or lockout.", "components": ["hotp", "totp"], "sources": ["hotp:s05c8bf9b6329", "totp:s3190460e5d2d"], "status": "REASONED"}, "totp-replay": {"text": "Reject an already accepted code within its time step; Verify submits the same valid code twice and expects refusal on reuse.", "components": ["totp"], "sources": ["totp:s3190460e5d2d"], "status": "REASONED"}, "authelia-proxies": {"text": "Authelia integrates with nginx auth_request, Traefik forwardAuth, Caddy forward_auth from 2.5.1, HAProxy via Lua and Envoy; Apache and IIS are unsupported.", "components": ["authelia", "caddy-min"], "sources": ["authelia:s1409ac8fe3d9", "caddy-min:s1409ac8fe3d9"], "status": "REASONED"}, "duo-free": {"text": "Duo Free covers up to 10 users with MFA and Duo Mobile as recorded in September 2026; verify current terms.", "components": ["duo"], "sources": ["duo:sb2b87daf72f0"], "status": "REASONED"}, "webauthn-origin": {"text": "Prefer WebAuthn/passkeys and FIDO2 keys for phishing resistance: credentials are bound to the site origin.", "components": ["webauthn"], "sources": ["webauthn:sef2c27817118"], "status": "REASONED"}, "webauthn-verification": {"text": "Store the credential public key and sign count; require and verify UV server-side when a passkey stands alone.", "components": ["webauthn"], "sources": ["webauthn:sef2c27817118"], "status": "REASONED"}, "hosted-passkeys": {"text": "Guide assessment: some hosted identity providers offer passkeys or security keys; check the provider and tier before relying on availability. The linked identity-providers guide gives examples; the WebAuthn standard does not establish universal provider coverage.", "components": ["webauthn"], "sources": ["webauthn:sef2c27817118"], "status": "REASONED"}, "webauthn-counter": {"text": "Guide advice: treat sign count as a clone-risk signal, not an automatic clone block; WebAuthn permits zero when an authenticator does not implement a signature counter.", "components": ["webauthn"], "sources": ["webauthn:sef2c27817118"], "status": "REASONED"}, "verify-secret-storage": {"text": "Check that no TOTP secret appears in the repository or its history.", "components": ["totp"], "sources": ["totp:s3190460e5d2d"], "status": "DEMONSTRATED", "evidence": "a known-value search (git log --all -S over history, grep -rF outside .git over the working tree) found a planted synthetic base32 TOTP secret and three recovery codes in exposed repositories, in history after deletion and in the working tree, and found none of them in a clean control."}, "mfa-enforcement": {"text": "Give every exposed human-facing login a second factor where viable; prefer native or IdP-enforced MFA, then an identity layer whose own or upstream policy requires MFA, app-level TOTP, then a hosted MFA service. Enrollment alone is not enforcement.", "components": ["totp", "entra-docs", "auth0-docs", "auth0-rolling", "cognito-api-source", "clerk-docs", "supabase-docs"], "sources": ["totp:s3190460e5d2d", "entra-docs:s2c2f323edbd0", "auth0-docs:sda6cf0f32c03", "auth0-rolling:s22eb9266f40c", "cognito-api-source:s20a38c138333", "clerk-docs:s965680da0841", "supabase-docs:sfae0cdf9b1b6", "supabase-docs:s4fe1e36a064b"], "status": "REASONED"}, "push-matching": {"text": "Prefer WebAuthn/passkeys over TOTP or push, and those over email or SMS codes. TOTP and push are not phishing-resistant; push must use number matching or verified push, never bare approve/deny, to resist prompt-bombing. NIST SP 800-63B-4 sections 3.1.3, 3.2.5, 3.1.3.1 and 3.1.3.3 support the fatigue, phishing and email/PSTN rationale. For Authenticator MFA push in the MFA scenario, Entra requires number matching; its same-device Microsoft app flow can show Yes/No. Duo's Verified Duo Push uses code entry in supported flows, but its policy both lists Duo Unix v2.2.0 and later as supported and says verification has no effect in Duo Unix. The pinned pam_duo.8 documents verified_push (default no), which cannot be combined with autopush. This guide requires checking the effective push flow or choosing another factor; the cross-method preference is this guide's assessment. The cited Duo editions source covers editions and pricing only.", "components": ["webauthn", "duo", "duo-unix-source", "entra-docs", "nist", "duo-push-policy"], "sources": ["webauthn:sef2c27817118", "duo:sb2b87daf72f0", "duo-unix-source:sc183734b371e", "entra-docs:sda22c238526b", "entra-docs:s6889fae58a3a", "entra-docs:s3f868ef35f58", "nist:s2a689905c161", "duo-push-policy:s4358799e0562"], "status": "REASONED"}, "totp-activation": {"text": "Verify one valid TOTP code before activating the factor.", "components": ["totp", "otp-source"], "sources": ["totp:s3190460e5d2d", "otp-source:s7c4c6d57f705"], "status": "REASONED"}, "recovery-codes": {"text": "Issue single-use recovery codes and store hashes rather than the code values; Verify checks both storage and refusal on reuse.", "components": ["totp", "nist", "owasp-docs"], "sources": ["totp:s3190460e5d2d", "nist:s4e1146860142", "owasp-docs:sa72b5658a19b"], "status": "REASONED"}, "factor-changes": {"text": "Require re-verification of the existing factor before disabling or replacing it or minting new recovery codes, so a password-only or stolen session cannot remove MFA.", "components": ["totp", "owasp-docs"], "sources": ["totp:s3190460e5d2d", "owasp-docs:s2940f630fb71"], "status": "REASONED"}, "admin-factors": {"text": "Give administrators a phishing-resistant passkey or FIDO2 key wherever offered; TOTP is the floor and SMS is unacceptable for administrator accounts. OWASP advises MFA for privileged users; Microsoft Entra recommends phishing-resistant MFA for its administrator roles, and NIST SP 800-63B-4 section 2.2.2 requires it for federal staff, contractors and partners accessing federal systems and requires an AAL2 phishing-resistant option. The TOTP floor is this guide's advice; this guide requires that administrator accounts not use SMS, and OWASP advises against SMS for high-value applications.", "components": ["webauthn", "owasp-docs", "entra-docs", "nist"], "sources": ["webauthn:sef2c27817118", "owasp-docs:s95dac24ad579", "owasp-docs:sc030e5ded4b9", "entra-docs:s2ee634f73c4b", "nist:s2a689905c161"], "status": "REASONED"}, "authelia-factors": {"text": "The body lists TOTP, WebAuthn security keys and Duo mobile push as Authelia second factors. Authelia v4.39.28 also documents passkey login, which counts as a single factor and by default prompts for a password when the request requires MFA.", "components": ["authelia", "authelia-source"], "sources": ["authelia:s1409ac8fe3d9", "authelia-source:sfeb54a05c30d", "authelia-source:s38726e46de86"], "status": "REASONED"}, "identity-layer-factors": {"text": "The authentik validation stage rejects a response unless the login's device challenges include a compatible class; Keycloak documents OTP policy for time-based (TOTP) and counter-based generators; oauth2-proxy configures an OAuth provider and routes to upstream endpoints. Requiring MFA in each protected login flow or provider policy and testing second-factor enforcement is this guide's advice.", "components": ["authentik-source", "keycloak-source", "oauth2-proxy-source"], "sources": ["authentik-source:s18275a7072e9", "keycloak-source:sab71553daacd", "oauth2-proxy-source:s523e9ec2bd77", "oauth2-proxy-source:sa2319a734772"], "status": "REASONED"}, "totp-libraries": {"text": "The body lists pyotp with qrcode and django-otp, Node otplib with qrcode, and pquerna/otp with QR generation as RFC 6238 implementation options. Each option generates and verifies RFC 6238 codes and pairs with a QR library so users can enrol a compatible TOTP authenticator app.", "components": ["totp", "pyotp-source", "python-qrcode-source", "django-otp-source", "otplib-source", "node-qrcode-source", "otp-source"], "sources": ["totp:s3190460e5d2d", "pyotp-source:sff126dae123b", "python-qrcode-source:s9481cd1c5c1c", "django-otp-source:s3b66277026d8", "otplib-source:s1cfb4a0cb98d", "node-qrcode-source:s427c06d742cf", "otp-source:s9dc96c06d6a0"], "status": "REASONED"}, "ssh-pam-enforcement": {"text": "Make SSH PAM MFA mandatory with UsePAM yes, KbdInteractiveAuthentication yes and AuthenticationMethods publickey,keyboard-interactive:pam; :pam pins the second step to PAM and the public-key-first sequence still runs it for key-based login.", "components": ["ssh-pam", "openssh-source"], "sources": ["ssh-pam:s3190460e5d2d", "openssh-source:sf299a4e3603a", "openssh-source:s862f0df9ce84"], "status": "REASONED"}, "ssh-nullok": {"text": "Guide advice: remove nullok after rollout so accounts without a secret file are denied by this PAM module. google-authenticator-libpam is a PAM module supporting per-account TOTP. Guide assessment: it protects only the logins whose PAM services include it. Its setup command displays a QR code when libqrencode is available. Debian trixie and Ubuntu noble list libpam-google-authenticator as 20191231-based builds; the behaviour above is cited at tag 1.11.", "components": ["google-authenticator-libpam-source", "google-authenticator-libpam-packages"], "sources": ["google-authenticator-libpam-source:s7df1bfd61fef", "google-authenticator-libpam-source:s9ad5ca394e81", "google-authenticator-libpam-packages:sf33e01264f1d", "google-authenticator-libpam-packages:s598691ba0908"], "status": "REASONED"}, "duo-unix-failmode": {"text": "Duo Unix pam_duo adds SSH push MFA; set failmode=secure for mandatory MFA because default safe grants access when Duo is unreachable, and keep a separately controlled emergency path. The pinned pam_duo.8 also documents verified_push (default no), which cannot be combined with autopush.", "components": ["duo", "duo-unix-source"], "sources": ["duo:sb2b87daf72f0", "duo-unix-source:s1ea7669082c6", "duo-unix-source:sc183734b371e"], "status": "REASONED"}, "supabase-assurance": {"text": "Supabase admits an aal1 session unless app and database policies require aal2; enrollment or enabling a factor is not enforcement, so inspect and test the effective policy.", "components": ["supabase", "supabase-docs"], "sources": ["supabase:s3190460e5d2d", "supabase-docs:sfae0cdf9b1b6", "supabase-docs:s4fe1e36a064b"], "status": "REASONED"}, "google-workspace-enforcement": {"text": "Google Workspace administrators can require 2-Step Verification for selected users or groups. Guide assessment: an app using a hosted provider is protected only when its effective provider or app policy requires the second factor for that sign-in. Guide advice: inspect and test the effective policy for each protected sign-in.", "components": ["google-workspace-docs"], "sources": ["google-workspace-docs:s4e673839253c"], "status": "REASONED"}, "duo-proxy": {"text": "Duo Authentication Proxy receives requests from devices and applications via RADIUS or LDAP and contacts Duo for secondary authentication. Guide advice: verify that each intended service sends its sign-ins through the proxy and requires the second factor.", "components": ["duo-proxy-docs"], "sources": ["duo-proxy-docs:s4fa84e377f72"], "status": "REASONED"}, "access-mfa": {"text": "An Access emailed one-time PIN proves mailbox control only; require MFA at the connected IdP for this login or Access independent MFA with TOTP or a WebAuthn key.", "components": ["access", "cf-docs"], "sources": ["access:s3190460e5d2d", "access:sef2c27817118", "cf-docs:s9f8bead847d2", "cf-docs:s7e793d13cf31"], "status": "REASONED"}, "webauthn-library": {"text": "When implementing WebAuthn yourself, this guide advises using a maintained WebAuthn library rather than hand-parsing attestation. The pinned py_webauthn, SimpleWebAuthn and go-webauthn READMEs document server-side WebAuthn implementation options; the recommendation to use a maintained library is this guide's assessment, not a README requirement.", "components": ["webauthn", "webauthn-python", "webauthn-node", "webauthn-go"], "sources": ["webauthn:sef2c27817118", "webauthn-python:s74d684e16405", "webauthn-node:sc50f8c0c3a95", "webauthn-go:s290e0174a612"], "status": "REASONED"}, "webauthn-recovery": {"text": "Keep a second key or single-use recovery codes to avoid lost-key lockout; for administrators on phishing-resistant keys, prefer a second hardware key because printed codes are phishable, rate-limit recovery-code attempts, notify the account holder of recovery-code use, and alert operators on it. NIST SP 800-63B-4 section 4.1.2.1 supports two means of authentication in general; section 3.1.2 says look-up secrets are not phishing-resistant; sections 4.2.1.1 and 4.2.3 support saved recovery-code throttling and account-recovery notification to the subscriber or their designee. The administrator second-hardware-key preference and operator alert are this guide's recommendations; NIST does not require an operator alert; no pinned reference establishes the administrator-specific preference or operator alert.", "components": ["webauthn", "nist", "owasp-docs"], "sources": ["webauthn:sef2c27817118", "nist:s4e1146860142", "nist:s2a689905c161", "owasp-docs:sa72b5658a19b"], "status": "REASONED"}, "mysql-factors": {"text": "Guide assessment: some databases offer native MFA for an interactive human login. MySQL 8.0 from 8.0.27, and MySQL 8.4, support up to three authentication factors; in MySQL 8.4 the server-side WebAuthn authentication plugin is included only in Enterprise Edition, while the client-side plugin is included in all distributions.", "components": ["mysql", "mysql-8-4", "mysql-webauthn"], "sources": ["mysql:sa937d1361fe1", "mysql-8-4:s56ada201b676", "mysql-webauthn:s807a77fa8872"], "status": "REASONED"}, "unattended-mtls": {"text": "Guide assessment: unattended database and model-server API connections have no interactive second-factor dialogue. Guide advice: use mutual TLS client certificates as the service's possession factor and MFA on human paths through SSH, bastions and admin panels. OWASP documents that mTLS authenticates the client with a certificate and proof of its private key and requires independent server-identity validation. The possession-factor interpretation and human-path requirement are this guide's advice; the PostgreSQL and MySQL guides document client-certificate controls.", "components": ["totp", "webauthn", "owasp-docs"], "sources": ["totp:s3190460e5d2d", "webauthn:sef2c27817118", "owasp-docs:s078b5519d6e8"], "status": "REASONED"}, "verify-mfa-operation": {"text": "After enrollment and enforcement, a password-only attempt at an MFA-protected operation must fail and completing the second factor must allow it. Opening an aal1 session alone is not a failure when enforcement occurs at the operation.", "components": ["totp", "supabase-docs"], "sources": ["totp:s3190460e5d2d", "supabase-docs:sfae0cdf9b1b6", "supabase-docs:s4fe1e36a064b"], "status": "REASONED"}, "verify-recovery-history": {"text": "Verify requires that no TOTP secret or recovery code appears in the repository or its history. Git --all includes refs and HEAD, while -S selects occurrence-count changes; GNU grep -rF --exclude-dir=.git recursively searches fixed strings outside .git. This known-value scan needs plaintext values; empty output with an unsubstituted placeholder proves nothing. By inference from the documented --all scope, it does not cover reflog-only or unreachable commits; -S also misses unchanged occurrence counts. Without --diff-merges (except with --first-parent), merge commits do not match -S, and -S does not search commit or tag messages. A clean result is not proof that an arbitrary repository is clean.", "components": ["totp", "git-docs", "grep-docs"], "sources": ["totp:s3190460e5d2d", "git-docs:s903a3c765e70", "git-docs:sc68f7234f5e1", "git-docs:s7d93c14a69a6", "git-docs:sa87ce1942f01", "grep-docs:sfdd7a73eab39", "grep-docs:scd528c854b46", "grep-docs:s64678780d667"], "status": "DEMONSTRATED", "evidence": "a known-value search (git log --all -S over history, grep -rF outside .git over the working tree) found a planted synthetic base32 TOTP secret and three recovery codes in exposed repositories, in history after deletion and in the working tree, and found none of them in a clean control."}, "verify-offboarding": {"text": "Verify that a departed user's second factor, active sessions and app-level access are revoked; changing only the password is insufficient. NIST SP 800-63B-4 section 4.5 requires CSPs to promptly invalidate authenticators when the CSP determines that the subscriber no longer meets its eligibility requirements. Entra documents removal of application user and group assignments, revocation of user-consented app permissions through Graph or PowerShell, application deprovisioning and application-controlled session revocation; its identity-session revocation alone cannot revoke an application-issued session token. The deployed offboarding outcome remains a test expectation, and the cited documents do not establish it for a particular application.", "components": ["totp", "entra-docs", "owasp-docs", "nist"], "sources": ["totp:s3190460e5d2d", "entra-docs:s1341b9dbdad1", "entra-docs:s9f91c67f6011", "entra-docs:s46eea4e600ea", "entra-docs:s4e0a25001a1c", "owasp-docs:se60f5c9f1c55", "nist:s2a689905c161"], "status": "REASONED"} } } ```` ====================================================================== ==> secrets.md ====================================================================== # Secrets: keeping keys out of repositories Secrets are passwords, API keys, tokens and private keys services use to prove their identity. This guide helps your AI assistant handle them securely, including no plaintext secrets in git, images or logs (encrypt any a team must version), random generation, scans before every push, and rotation first after leaks. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/secrets.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Git ignore patterns 2.42.0, gitleaks git/dir path argument 8.19.1 to 8.30.1, gitleaks 8.19.0 git/dir source flag v8.19.0, gitleaks detect before 8.19.0 v8.18.4, OpenSSL random bytes 3.0, Python secrets 3.13, Vite environment variables 6, and GNU Bash reference manual bash-5.3; 1 further source is pinned to a commit and 18 carry no recorded version. Metadata reviewed 2026-10-05; documentation checked September 2026. The guide records 25 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Leaked API keys and credentials in public repositories are the most common security incident in AI-assisted projects, and scanners harvest fresh commits within minutes. [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) states the baseline; this guide covers the handling. ## Rules 1. **Secrets never enter version control.** Add `.env`, `.env.*` (frameworks such as Next.js load `.env.local` and `.env.production`, so ignoring `.env` alone leaves those committable), `*.key`, and `*.pem` to `.gitignore` before the first commit, allowlisting only a no-secret template such as `!.env.example`; `.gitignore` does not untrack a file that is already committed. Load secrets from environment variables or a secret manager (AWS Secrets Manager, Google Secret Manager, Azure Key Vault, or your platform's store per [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). 2. **Secrets never enter images or build logs.** Never put secrets in Dockerfile `ENV` or `ARG`: Docker documents that both are exposed in the final image; `ENV` also persists in containers from the image, while build arguments may persist in image metadata, provenance attestations and image history. `docker image history` shows an image's history; the reference's example history output shows an `ENV` instruction with its value. As this guide's assessment, `docker image history` does not show every secret value. Pass runtime secrets at runtime ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)); for a secret needed during a build, use a temporary secret mount for that build instruction and do not copy or print it into the image. Do not print secrets in application or CI logs. 3. **Generate secrets randomly** (`openssl rand -base64 32`; `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`), 1 per service and environment, never shared between staging and production. 4. **Scan before every push.** [gitleaks](https://github.com/gitleaks/gitleaks) or [trufflehog](https://github.com/trufflesecurity/trufflehog) as a pre-commit hook and in CI: ```bash gitleaks git . # scans the repository history; use 8.19.1 or later gitleaks dir . # scans the working tree ``` By source reading without a run, `git` and `dir` use a positional path at v8.19.1, defaulting to `.` if omitted or empty; the earlier review records this handling through v8.30.1. At v8.19.0, both commands instead read a `source` flag and do not use `args`. Across all seven top-level `cmd/` files, only `detect` and `protect` register `source`, each locally with default `.`; none registers it on the root, `git` or `dir`. Root `initConfig` also reads `source` when both `--config` and `GITLEAKS_CONFIG` are empty or unset; all three lookups call `log.Fatal` on error. Neither command has an observed result here. At v8.17.0, v8.18.0 and v8.18.4, `detect` reads `source` and selects files with `--no-git`, stdin otherwise with `--pipe`, and Git history otherwise. At all three tags the root registers persistent `--source` with default `.`. 5. **Secrets never enter a command line.** A process's arguments are readable through `/proc//cmdline`, which is what `ps` prints; on a default Linux that means every other user on the host, for as long as the process runs, and the command is then written to your shell history. A `hidepid` proc mount or a separate PID namespace narrows who can see it, neither is the default, and neither covers the history. Prefer a flag that reads from stdin (`htpasswd -i`, `docker login --password-stdin`, `gh auth login --with-token`), a file the tool reads itself (`~/.pgpass`, `curl --netrc`; create it with `umask 077` and keep it mode `0600`, since it stores the secret in plaintext and PostgreSQL ignores a `~/.pgpass` that group or others can read), or an environment variable where the tool offers nothing better. As this guide's advice, where a value has to be typed, use `read -rs` to keep terminal input hidden (`-s`) and preserve backslashes (`-r`); as this guide's assessment, because `read` consumes input rather than a command, this keeps the typed value out of shell history. Bash documents that `set -x` prints commands and arguments after expansion; therefore, as this guide's advice, a script that expands a secret into a pipeline (including the `printf`-into-`--password-stdin` pattern below) must turn tracing off around it, because reading from stdin protects the argument list but not the trace. This block assumes a clean Bash shell and prompts for the registry token instead of using an ambient `TOKEN`. As this guide's advice, paste the token alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. The block resets `DEBUG`, `RETURN` and `ERR` traps with `trap - DEBUG RETURN ERR`, turns tracing, allexport and errexit off with `set +x +a +e`, then enables pipefail with `set -o pipefail`. It clears inherited `TOKEN` attributes via `unset -n` and `unset -v`, unsets `IFS` with `unset -n IFS` (which also removes an `IFS` nameref) and refuses a readonly `IFS`, then unsets the unexported variable with an `EXIT` trap. It exits without continuing if `TOKEN` cannot be cleared, `IFS` is readonly or `read` fails, and refuses an empty value, a placeholder or a control character. As this guide's assessment under these clean-shell assumptions, the entered token stays out of history, argv and the environment; process memory is still readable by the same account and root, and this does not erase an earlier export. Docker stores the login in its configured credential store or configuration file, as described in rule 9. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -o pipefail { unset -n TOKEN && unset -v TOKEN; } 2>/dev/null || { echo 'cannot clear TOKEN in this shell'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell'; exit 2; } trap 'unset -v TOKEN' EXIT printf 'Registry token (input hidden): ' IFS= read -r -s TOKEN || { printf '\n'; echo 'no secret read'; exit 2; } printf '\n' case "$TOKEN" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty secret, placeholder or control character'; exit 2 ;; esac printf '%s' "$TOKEN" | docker login ghcr.io -u "$USER" --password-stdin ) ``` There is no probe for this rule, and the obvious one is worse than none. `ps -eo args | grep -i 'password\|token\|secret'` finds only commands that spell the word out, such as `docker login --password hunter2`. It does not match `htpasswd -cbs .htpasswd admin Xk29fQ7LmVt3w9Zr`, or `mysql -pHunter2`, or `curl -u admin:hunter2`, because a real secret is a random string and `.htpasswd` does not contain the word "password". A snapshot also misses every short-lived process, which is most of them. A check that reads clean while the exposure is running is worse than no check, so this rule is enforced by review and by reaching for the stdin flag, not by grep. 6. **CI/CD secrets live in the platform's secret store** (for example GitHub Actions secrets), scoped to the jobs that need them, never echoed into logs or artefacts. 7. **Keys and customer data stay out of AI-model prompts.** A prompt to a public LLM, or to a hosted API outside your data agreement, becomes that provider's chat transcript and log, which the leak section below counts the same as any other chat or log. Keep API keys and credentials out of prompts entirely, user and system alike, and keep customer data out of any prompt to a service outside your data agreement. A system prompt is not a secret store and is not a security control: OWASP records credentials or a role and permission map placed there as System Prompt Leakage, disclosable by a plain extraction request, so keep them out and enforce authorization outside the model. A tool that needs a credential loads a scoped, revocable one through its own configuration, outside the model's context, so no prompt can echo it ([mcp-servers.md](https://github.com/jposluns/secureconfig/blob/main/mcp-servers.md)). 8. **Confidential business rules are secrets too.** A confidential pricing formula, allocation rule, or customer-specific process is protected because it is confidential, not because it is code. Keep it out of public repositories, out of anything that ships to the browser (a `NEXT_PUBLIC_` or `VITE_` value, or logic implemented in client-shipped code or a source map, per [web-exposure.md](https://github.com/jposluns/secureconfig/blob/main/web-exposure.md)), and out of a public LLM, on the same reasoning as a key. 9. **Credential-bearing config files stay out of the repository too.** Some working files hold live credentials in plaintext, so keep them, and any copy of them, out of version control alongside `.env` and `*.pem`. With local state, the default, a Terraform state file is written as plaintext in the working directory and can contain secret resource values, including any marked `sensitive`; gitignore `*.tfstate` and `*.tfstate.*` (the state, its `.backup`, and per-workspace copies). A kubeconfig can embed a base64 client private key in `client-key-data`, or a bearer token. `~/.docker/config.json` holds registry logins base64-encoded, which is encoding rather than encryption, whenever Docker keeps them in the file rather than an external credential store. The rule-4 scanners may flag a committed one, but a base64-wrapped key or an unpatterned password can pass them clean, so never rely on them: the fix is to not commit the file. ## When a secret leaks Order matters: 1. **Rotate first.** Revoke the exposed credential at its provider and issue a new one. A secret that reached a public repository, a chat, a log, or a paste is compromised even if deleted seconds later; scrapers and forks already have it. 2. Only then clean the history if required (for example with [git-filter-repo](https://github.com/newren/git-filter-repo)), understanding that cleaning is hygiene, never containment: it does not unpublish anything. 3. Check provider logs for use of the leaked credential during the exposure window. ## Encrypting secrets that must be versioned When a team needs configuration secrets in git (for example GitOps deployments), encrypt them: [sops](https://github.com/getsops/sops) with [age](https://github.com/FiloSottile/age) keys encrypts the values inside YAML/JSON/ENV files while leaving the structure diffable. The decryption key itself stays out of the repository. ## Verify (REASONED: scanner outcomes follow the cited gitleaks source, by source reading, and README, and secret-handling guidance; no exposed/fixed scan run is recorded) ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a check that ends nonzero is recorded in the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 gitleaks git . && echo clean set -- "$1" "$?" # gitleaks's exit status: 0 with no leaks, 1 for leaks or an error [ "$2" = 0 ] || { echo "gitleaks exit $2: leaks or an error, so not a pass; read its output above"; set -- 3; } set -- "$1" "$(if grep -rlIE "sk-|AKIA|ASIA|ghp_|-----BEGIN" --exclude-dir=node_modules --exclude-dir=.git .; then printf '\n0'; else printf '\n%s' "$?"; fi)" # -r recurses, -l prints only the filename (never the matching secret), -I assumes unprocessed input does not match after null binary data is discovered, --exclude-dir skips a whole tree rather than filtering matched lines; -E uses extended regular expressions; as this guide's assessment, this scan is crude but fast set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the block's status so far, the file list, then grep's exit status printf '%s' "$2" case "$3" in 0) echo "FINDING: inspect each file listed above privately"; set -- 1 "$2" "$3" ;; 1) echo "no match for these patterns only; this is not proof that no secret is present" ;; *) if [ -n "$2" ]; then echo "FINDING: inspect each file listed above privately; grep exit $3 also means some files were not read"; set -- 1 "$2" "$3" else echo "grep exit $3: a read error, so the pattern search failed; inconclusive"; set -- 3 "$2" "$3" fi ;; esac echo "block exit $1" exit "$1" ) ``` On every push, gitleaks (8.19.1 or later; see rule 4 for older releases) must exit 0 with no findings (the `&& echo clean` then prints `clean`). As this guide's assessment, the grep is a supplementary pattern search, not proof that no secret is present: as this guide's advice, treat any filename it prints as a finding to inspect privately, empty output as no match for these patterns only, and a non-zero exit caused by a read error as a failed check. gitleaks, which scans by rule rather than by a few prefixes, is the primary gate. ## Sources (checked September 2026) - Docker build variables (`ENV` persists in containers; build arguments may persist in metadata, provenance and history), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/variables.md#L17-L52 - Docker build secrets (temporary mounts for build instructions), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L22 - Docker image history command (image history and creation-command output), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_image_history.yaml#L1-L118 - OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html - AWS Secrets Manager overview (rolling, checked October 2026): https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html - Google Cloud Secret Manager overview (rolling, checked October 2026): https://docs.cloud.google.com/secret-manager/docs/overview - Azure Key Vault overview (rolling, checked October 2026): https://learn.microsoft.com/en-us/azure/key-vault/general/overview - gitleaks 8.19.1 to 8.30.1 path handling, v8.19.1 `git` source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/9463ffaa5e9efc94ee235972d1519eac2844fad3/cmd/git.go#L33-L42 - gitleaks 8.19.1 to 8.30.1 path handling, v8.19.1 `dir` source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/9463ffaa5e9efc94ee235972d1519eac2844fad3/cmd/directory.go#L26-L34 - gitleaks 8.19.1 to 8.30.1, earlier review: v8.30.1 `git` path handling (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/git.go#L33-L43 - gitleaks 8.19.1 to 8.30.1, earlier review: v8.30.1 `dir` path handling (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/directory.go#L25-L34 - gitleaks 8.19.1 to 8.30.1, earlier review: v8.30.1 default exit codes (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#L645-L653 - gitleaks v8.19.0 root flags and conditional `source` lookup, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/root.go - gitleaks v8.19.0 `git` flags and unused positional path, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/git.go - gitleaks v8.19.0 `dir` flags and unused positional path, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/directory.go - gitleaks v8.19.0 local `detect` flags and scan selection, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/detect.go - gitleaks v8.19.0 local `protect` flags, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/protect.go - gitleaks v8.19.0 `stdin` registration, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/stdin.go - gitleaks v8.19.0 `version` registration, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/version.go - gitleaks v8.19.0 top-level command inventory, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd - gitleaks v8.19.0 `generate` directory listing only, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/generate - gitleaks v8.18.4 README `--source` default (checked October 2026): https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/README.md#L162 - gitleaks v8.18.4 README `detect` and `--no-git` (checked October 2026): https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/README.md#L172-L183 - gitleaks v8.18.4 root persistent `--source`, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/cmd/root.go#L41-L65 - gitleaks root persistent `--source` comparison with v8.18.4: v8.17.0 source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/7804d652c0460f0b61ea9bb2c1f202bfcdbbf144/cmd/root.go#L38-L56 - gitleaks root persistent `--source` comparison with v8.18.4: v8.18.0 source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/45266551ba14dcc339106a08acf6cd0a5e81c76f/cmd/root.go#L38-L56 - gitleaks v8.18.4 `detect` scan selection, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/cmd/detect.go - gitleaks `detect` comparison with v8.18.4: v8.17.0 source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/7804d652c0460f0b61ea9bb2c1f202bfcdbbf144/cmd/detect.go - gitleaks `detect` comparison with v8.18.4: v8.18.0 source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/45266551ba14dcc339106a08acf6cd0a5e81c76f/cmd/detect.go - trufflehog: https://github.com/trufflesecurity/trufflehog - sops: https://github.com/getsops/sops and age: https://github.com/FiloSottile/age - git-filter-repo: https://github.com/newren/git-filter-repo - proc_pid_cmdline(5), for why a command line is readable by other users: https://man7.org/linux/man-pages/man5/proc_pid_cmdline.5.html - docker login, for `--password-stdin`, and that `~/.docker/config.json` stores credentials base64-encoded when no credential store is set: https://docs.docker.com/reference/cli/docker/login/ - Terraform, that local state is written as a plaintext file that can hold secret resource values: https://developer.hashicorp.com/terraform/language/manage-sensitive-data - Kubernetes kubeconfig API, for `client-key-data` (a client key, serialized base64) and the bearer `token`: https://kubernetes.io/docs/reference/config-api/kubeconfig.v1/ - gh auth login, for `--with-token`: https://cli.github.com/manual/gh_auth_login - htpasswd, for `-i` and what Apache says about `-b`: https://httpd.apache.org/docs/2.4/programs/htpasswd.html - OWASP GenAI, LLM07:2025 System Prompt Leakage (a system prompt is not a secret and should not hold credentials or a role and permission map): https://genai.owasp.org/llmrisk/llm072025-system-prompt-leakage/ - PostgreSQL password file, that `~/.pgpass` must disallow all group and world access (`0600` is the documented example) or PostgreSQL ignores it: https://www.postgresql.org/docs/current/libpq-pgpass.html - curl `.netrc`, that the file should not be readable by anyone besides the user: https://everything.curl.dev/usingcurl/netrc.html - Next.js environment variables, that `.env.local`, `.env.production`, and other `.env.*` files are loaded (so they hold secrets and belong in `.gitignore`): https://nextjs.org/docs/app/guides/environment-variables - Git 2.42.0 ignore patterns, negation and already-tracked files (checked October 2026): https://git-scm.com/docs/gitignore/2.42.0 - OpenSSL 3.0 `rand` byte count and `-base64` encoding (checked October 2026): https://docs.openssl.org/3.0/man1/openssl-rand/ - Python 3.13 `secrets.token_urlsafe` (checked October 2026): https://docs.python.org/3.13/library/secrets.html#secrets.token_urlsafe - Vite 6 environment variables (`VITE_` values exposed to client code) (checked October 2026): https://v6.vite.dev/guide/env-and-mode - GNU Bash reference manual, bash-5.3 (checked October 2026). GNU Bash reference manual bash-5.3 sources (checked October 2026): read `-r`/`-s` 5629-5637 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5629; `set -a` 5917-5921 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5917; `allexport` 6000-6001 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6000. GNU Bash reference manual bash-5.3 sources (checked October 2026): `set -x` 6122-6128 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6122; turning set options off 6194-6196 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6194; `declare` attributes 5161-5195 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5161. GNU Bash reference manual bash-5.3 sources (checked October 2026): `unset -n`/`-v` 4852-4876 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4852; `trap` and sigspecs 4740-4808 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4740. GNU Bash reference manual bash-5.3 sources (checked October 2026): errexit 5929-5944 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5929; pipefail 6062-6065 https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6062 - GNU grep file and directory selection (`-r`, `-I`, `--exclude-dir`; rolling) (checked October 2026): https://www.gnu.org/software/grep/manual/html_node/File-and-Directory-Selection.html - GNU grep general output control (`-l`; rolling) (checked October 2026): https://www.gnu.org/software/grep/manual/html_node/General-Output-Control.html - GNU grep Programs (`-E` selects extended regular expressions; rolling) (checked October 2026): https://www.gnu.org/software/grep/manual/html_node/grep-Programs.html - GNU grep exit status (0 match, 1 no match, 2 error; rolling) (checked October 2026): https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - git-ignore: Git ignore patterns (2.42.0) - gitleaks: gitleaks git/dir path argument (8.19.1 to 8.30.1) - gitleaks-8190: gitleaks 8.19.0 git/dir source flag (v8.19.0) - gitleaks-detect: gitleaks detect before 8.19.0 (v8.18.4) - trufflehog: TruffleHog (unknown) - sops: SOPS (unknown) - age: age (unknown) - git-filter-repo: git-filter-repo (unknown) - openssl-rand: OpenSSL random bytes (3.0) - python-secrets: Python secrets (3.13) - docker-build: Docker build and image history documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - proc: Linux procfs (unknown) - docker: Docker login (unknown) - terraform: Terraform (unknown) - kubernetes: Kubernetes kubeconfig (unknown) - gh: GitHub CLI (unknown) - apache: htpasswd (unknown) - postgres: PostgreSQL password file (unknown) - curl: curl netrc (unknown) - next: Next.js (unknown) - vite-env: Vite environment variables (6) - aws-store: AWS Secrets Manager overview (unknown) - google-store: Google Cloud Secret Manager overview (unknown) - azure-store: Azure Key Vault overview (unknown) - bash: GNU Bash reference manual (bash-5.3) - grep: GNU grep (unknown) - owasp: OWASP secret handling (unknown) Claims (full source mapping in the JSON block below): - gitignore (REASONED; owasp, next, git-ignore): Ignore .env, .env.*, *.key and *.pem before committing; allow only a secret-free template. Already tracked files remain tracked. - runtime-storage (REASONED; owasp, docker-build, aws-store, google-store, azure-store): Load secrets from environment or a secret manager, including AWS Secrets Manager, Google Secret Manager, Azure Key Vault or the platform store; never put secrets in Dockerfile ENV/ARG, images or build logs; use temporary secret mounts for build-time secrets. - image-history (REASONED; docker-build): The Docker image history reference's example history output shows an ENV instruction with its value; Docker image history shows image history; as this guide's assessment, Docker image history does not show every secret value. - random (REASONED; owasp, openssl-rand, python-secrets): Generate a random secret per service and environment with the shown OpenSSL or Python command; never share staging and production credentials. - scanners (REASONED; gitleaks, trufflehog): Scan before every push and in CI with gitleaks or TruffleHog. By source reading at every gitleaks tag from v8.19.1 to v8.30.1, cited at both ends, without a run, git and dir take the scan path from a positional argument that defaults to the current directory, as the shown gitleaks git . and gitleaks dir . commands need. - scanners-8190 (REASONED; gitleaks-8190): By source reading without a run at v8.19.0, only detect and protect register source across all seven top-level cmd files, each locally; neither git nor dir uses args. Both commands, and root initConfig when both --config and GITLEAKS_CONFIG are empty or unset, read source and call log.Fatal on lookup error; no run outcome is established. - scanners-detect (REASONED; gitleaks-detect): By source reading without a run at v8.17.0, v8.18.0 and v8.18.4, detect reads source and selects files with --no-git, otherwise stdin with --pipe, otherwise Git history. At all three tags the root registers persistent --source with default '.'. - argv (REASONED; proc, apache): Arguments expose secrets through procfs, ps and shell history; hidepid or PID namespaces narrow process visibility without covering history. - stdin (REASONED; apache, docker, gh): Use htpasswd -i, docker login --password-stdin or gh auth login --with-token to avoid secret arguments. - credential-files (REASONED; postgres, curl): Protect plaintext credential files with umask 077 and mode 0600; PostgreSQL ignores group/world-readable .pgpass. - shell-input (REASONED; bash, docker, proc): As this guide's assessment, hidden read input avoids history; as this guide's advice, tracing must be off around expansion. The clean-Bash login block clears inherited TOKEN attributes, turns tracing, allexport and errexit off, enables pipefail, and removes TOKEN on exit. - shell-limits (REASONED; bash, proc, docker): As this guide's assessment under the stated clean-Bash assumptions, the prompted token stays out of argv and environment; same-account/root memory access and earlier exports remain outside this protection. - argv-review (REASONED; proc): A ps/grep snapshot misses unlabelled secrets and short-lived processes; review secret-input paths instead of treating a clean snapshot as proof. - ci-store (REASONED; owasp): Scope CI/CD platform secrets to the jobs needing them; never echo them into logs or artefacts. - prompt-secrets (REASONED; owasp): Keep credentials out of all model prompts and customer data outside unapproved services; system prompts are disclosable and authorization belongs outside the model. - business-rules (REASONED; owasp, next, vite-env): Keep confidential business logic out of public repositories, browser-shipped code and public LLM prompts; public environment prefixes and source maps can expose it. - terraform-state (REASONED; terraform): Default local Terraform state is plaintext and may contain sensitive values; ignore *.tfstate and `*.tfstate.*` including backups and workspace copies. - kubeconfig (REASONED; kubernetes): Kubeconfig can embed a base64 client-key-data private key or bearer token; exclude credential-bearing files and copies from version control. - docker-store (REASONED; docker, owasp): Docker stores logins in its credential store or base64-encoded config.json; encoding is not encryption and scanners can miss encoded or unpatterned secrets. - leak-rotation (REASONED; owasp): Revoke and replace a leaked credential before cleanup; deletion cannot undo public repository, chat, log or paste exposure. - history-cleanup (REASONED; git-filter-repo, owasp): History rewriting with git-filter-repo is hygiene after rotation, not containment or unpublishing. - leak-audit (REASONED; owasp): Review provider logs for use of the leaked credential during its exposure window. - encrypted-git (REASONED; sops, age): SOPS with age encrypts YAML/JSON/ENV values while preserving structure for secrets that must be versioned; keep decryption keys outside the repository. - verify-scan (REASONED; gitleaks): With gitleaks 8.19.1 or later, gitleaks must exit 0 with no findings before clean is printed; the v8.30.1 README lists exit 0 for no leaks and 1 for leaks or an error. This rule-based scan is the primary gate. - verify-patterns (REASONED; grep, owasp): Supplementary grep prints filenames only, when null binary data is discovered assumes unprocessed input does not match, skips excluded trees; as this guide's advice, a non-zero exit caused by a read error is a failed check; as this guide's assessment, empty output is not proof of no secret. GNU grep documents -E as extended regular expression syntax. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-09", "body_sha256": "f0f57bfca4dfee70d7a2e00fdd1347991de7050b161f80142f3da893370d3060", "components": { "git-ignore": { "name": "Git ignore patterns", "basis": "2.42.0", "sources": { "s90ce7c8605d9": "https://git-scm.com/docs/gitignore/2.42.0" } }, "gitleaks": { "name": "gitleaks git/dir path argument", "basis": "8.19.1 to 8.30.1", "sources": { "s4e82b98bf700": "https://github.com/gitleaks/gitleaks/blob/9463ffaa5e9efc94ee235972d1519eac2844fad3/cmd/git.go#L33-L42", "sc7929445f947": "https://github.com/gitleaks/gitleaks/blob/9463ffaa5e9efc94ee235972d1519eac2844fad3/cmd/directory.go#L26-L34", "s2a515c553a16": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/git.go#L33-L43", "s3cedc5927daf": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/cmd/directory.go#L25-L34", "s8b08240c42e9": "https://github.com/gitleaks/gitleaks/blob/v8.30.1/README.md#L645-L653" } }, "gitleaks-8190": { "name": "gitleaks 8.19.0 git/dir source flag", "basis": "v8.19.0", "sources": { "s38ee01a95c8c": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/root.go", "s5abcb8b428db": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/git.go", "s8db158cb815b": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/directory.go", "s68724a6b5a24": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/detect.go", "s913053b5ad1e": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/protect.go", "s189c08d2f974": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/stdin.go", "s4b5d9b2ab7a1": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/version.go", "s8a2e9dc86ccd": "https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd", "s43b67b6788dd": "https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/generate" } }, "gitleaks-detect": { "name": "gitleaks detect before 8.19.0", "basis": "v8.18.4", "sources": { "s6686fd9533da": "https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/README.md#L162", "s71b141fe4768": "https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/README.md#L172-L183", "s342bdd9096ec": "https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/cmd/root.go#L41-L65", "sc8ca694b4c7a": "https://github.com/gitleaks/gitleaks/blob/02808f45d038526bfcceebcbf6421c1047997ff9/cmd/detect.go", "sdf22d8b973ff": "https://github.com/gitleaks/gitleaks/blob/7804d652c0460f0b61ea9bb2c1f202bfcdbbf144/cmd/detect.go", "sc459999393a5": "https://github.com/gitleaks/gitleaks/blob/45266551ba14dcc339106a08acf6cd0a5e81c76f/cmd/detect.go", "s0c9e8f865e54": "https://github.com/gitleaks/gitleaks/blob/7804d652c0460f0b61ea9bb2c1f202bfcdbbf144/cmd/root.go#L38-L56", "seb8727e916d2": "https://github.com/gitleaks/gitleaks/blob/45266551ba14dcc339106a08acf6cd0a5e81c76f/cmd/root.go#L38-L56" } }, "trufflehog": { "name": "TruffleHog", "basis": "unknown", "sources": { "s5450689a9bb8": "https://github.com/trufflesecurity/trufflehog" } }, "sops": { "name": "SOPS", "basis": "unknown", "sources": { "s78c5bb528dc4": "https://github.com/getsops/sops" } }, "age": { "name": "age", "basis": "unknown", "sources": { "sca8b0d211739": "https://github.com/FiloSottile/age" } }, "git-filter-repo": { "name": "git-filter-repo", "basis": "unknown", "sources": { "s95bf8a949d5e": "https://github.com/newren/git-filter-repo" } }, "openssl-rand": { "name": "OpenSSL random bytes", "basis": "3.0", "sources": { "s11d11381b080": "https://docs.openssl.org/3.0/man1/openssl-rand/" } }, "python-secrets": { "name": "Python secrets", "basis": "3.13", "sources": { "sd2e8196eaa96": "https://docs.python.org/3.13/library/secrets.html#secrets.token_urlsafe" } }, "docker-build": { "name": "Docker build and image history documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sd8afa286c173": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/variables.md#L17-L52", "s07e00829f121": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L22", "s6283b172a5c6": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_image_history.yaml#L1-L118" } }, "proc": { "name": "Linux procfs", "basis": "unknown", "sources": { "s801d5f1ee88d": "https://man7.org/linux/man-pages/man5/proc_pid_cmdline.5.html" } }, "docker": { "name": "Docker login", "basis": "unknown", "sources": { "sf8243a183d67": "https://docs.docker.com/reference/cli/docker/login/" } }, "terraform": { "name": "Terraform", "basis": "unknown", "sources": { "s8efa1a4bb953": "https://developer.hashicorp.com/terraform/language/manage-sensitive-data" } }, "kubernetes": { "name": "Kubernetes kubeconfig", "basis": "unknown", "sources": { "se41a53ea6778": "https://kubernetes.io/docs/reference/config-api/kubeconfig.v1/" } }, "gh": { "name": "GitHub CLI", "basis": "unknown", "sources": { "sf0b01ee9b3e1": "https://cli.github.com/manual/gh_auth_login" } }, "apache": { "name": "htpasswd", "basis": "unknown", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "postgres": { "name": "PostgreSQL password file", "basis": "unknown", "sources": { "s198d4780d9a4": "https://www.postgresql.org/docs/current/libpq-pgpass.html" } }, "curl": { "name": "curl netrc", "basis": "unknown", "sources": { "s3a0a54039912": "https://everything.curl.dev/usingcurl/netrc.html" } }, "next": { "name": "Next.js", "basis": "unknown", "sources": { "s964651d4eed4": "https://nextjs.org/docs/app/guides/environment-variables" } }, "vite-env": { "name": "Vite environment variables", "basis": "6", "sources": { "sba07c9b57bd9": "https://v6.vite.dev/guide/env-and-mode" } }, "aws-store": { "name": "AWS Secrets Manager overview", "basis": "unknown", "sources": { "s81b41c17ad57": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html" } }, "google-store": { "name": "Google Cloud Secret Manager overview", "basis": "unknown", "sources": { "scd27be211781": "https://docs.cloud.google.com/secret-manager/docs/overview" } }, "azure-store": { "name": "Azure Key Vault overview", "basis": "unknown", "sources": { "seb86b29f4951": "https://learn.microsoft.com/en-us/azure/key-vault/general/overview" } }, "bash": { "name": "GNU Bash reference manual", "basis": "bash-5.3", "sources": { "sb38bf74a89b0": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5629", "s8c59508bd4c8": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5917", "s04ec4ba49971": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6000", "se62fd3ad4fc3": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6122", "s2df39d2c9c1c": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6194", "s8447036b2114": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5161", "s3255eca7e496": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4852", "s93dcf5ba0a87": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4740", "s8016d1aecfca": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5929", "sf6c6f542c26c": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6062" } }, "grep": { "name": "GNU grep", "basis": "unknown", "sources": { "s33689bc366bf": "https://www.gnu.org/software/grep/manual/html_node/File-and-Directory-Selection.html", "s2c1251092e66": "https://www.gnu.org/software/grep/manual/html_node/General-Output-Control.html", "sea43e82b0753": "https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html", "sbd30b628a5b8": "https://www.gnu.org/software/grep/manual/html_node/grep-Programs.html" } }, "owasp": { "name": "OWASP secret handling", "basis": "unknown", "sources": { "s26a987a1053b": "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html", "s4564d1912a87": "https://genai.owasp.org/llmrisk/llm072025-system-prompt-leakage/" } } }, "claims": { "gitignore": {"text": "Ignore .env, .env.*, *.key and *.pem before committing; allow only a secret-free template. Already tracked files remain tracked.", "components": ["owasp", "next", "git-ignore"], "sources": ["owasp:s26a987a1053b", "next:s964651d4eed4", "git-ignore:s90ce7c8605d9"], "status": "REASONED"}, "runtime-storage": {"text": "Load secrets from environment or a secret manager, including AWS Secrets Manager, Google Secret Manager, Azure Key Vault or the platform store; never put secrets in Dockerfile ENV/ARG, images or build logs; use temporary secret mounts for build-time secrets.", "components": ["owasp", "docker-build", "aws-store", "google-store", "azure-store"], "sources": ["owasp:s26a987a1053b", "docker-build:sd8afa286c173", "docker-build:s07e00829f121", "aws-store:s81b41c17ad57", "google-store:scd27be211781", "azure-store:seb86b29f4951"], "status": "REASONED"}, "image-history": {"text": "The Docker image history reference's example history output shows an ENV instruction with its value; Docker image history shows image history; as this guide's assessment, Docker image history does not show every secret value.", "components": ["docker-build"], "sources": ["docker-build:s6283b172a5c6"], "status": "REASONED"}, "random": {"text": "Generate a random secret per service and environment with the shown OpenSSL or Python command; never share staging and production credentials.", "components": ["owasp", "openssl-rand", "python-secrets"], "sources": ["owasp:s26a987a1053b", "openssl-rand:s11d11381b080", "python-secrets:sd2e8196eaa96"], "status": "REASONED"}, "scanners": {"text": "Scan before every push and in CI with gitleaks or TruffleHog. By source reading at every gitleaks tag from v8.19.1 to v8.30.1, cited at both ends, without a run, git and dir take the scan path from a positional argument that defaults to the current directory, as the shown gitleaks git . and gitleaks dir . commands need.", "components": ["gitleaks", "trufflehog"], "sources": ["gitleaks:s4e82b98bf700", "gitleaks:sc7929445f947", "gitleaks:s2a515c553a16", "gitleaks:s3cedc5927daf", "trufflehog:s5450689a9bb8"], "status": "REASONED"}, "scanners-8190": {"text": "By source reading without a run at v8.19.0, only detect and protect register source across all seven top-level cmd files, each locally; neither git nor dir uses args. Both commands, and root initConfig when both --config and GITLEAKS_CONFIG are empty or unset, read source and call log.Fatal on lookup error; no run outcome is established.", "components": ["gitleaks-8190"], "sources": ["gitleaks-8190:s38ee01a95c8c", "gitleaks-8190:s5abcb8b428db", "gitleaks-8190:s8db158cb815b", "gitleaks-8190:s68724a6b5a24", "gitleaks-8190:s913053b5ad1e", "gitleaks-8190:s189c08d2f974", "gitleaks-8190:s4b5d9b2ab7a1", "gitleaks-8190:s8a2e9dc86ccd", "gitleaks-8190:s43b67b6788dd"], "status": "REASONED"}, "scanners-detect": {"text": "By source reading without a run at v8.17.0, v8.18.0 and v8.18.4, detect reads source and selects files with --no-git, otherwise stdin with --pipe, otherwise Git history. At all three tags the root registers persistent --source with default '.'.", "components": ["gitleaks-detect"], "sources": ["gitleaks-detect:s6686fd9533da", "gitleaks-detect:s71b141fe4768", "gitleaks-detect:s342bdd9096ec", "gitleaks-detect:sc8ca694b4c7a", "gitleaks-detect:sdf22d8b973ff", "gitleaks-detect:sc459999393a5", "gitleaks-detect:s0c9e8f865e54", "gitleaks-detect:seb8727e916d2"], "status": "REASONED"}, "argv": {"text": "Arguments expose secrets through procfs, ps and shell history; hidepid or PID namespaces narrow process visibility without covering history.", "components": ["proc", "apache"], "sources": ["proc:s801d5f1ee88d", "apache:scde9004bb967"], "status": "REASONED"}, "stdin": {"text": "Use htpasswd -i, docker login --password-stdin or gh auth login --with-token to avoid secret arguments.", "components": ["apache", "docker", "gh"], "sources": ["apache:scde9004bb967", "docker:sf8243a183d67", "gh:sf0b01ee9b3e1"], "status": "REASONED"}, "credential-files": {"text": "Protect plaintext credential files with umask 077 and mode 0600; PostgreSQL ignores group/world-readable .pgpass.", "components": ["postgres", "curl"], "sources": ["postgres:s198d4780d9a4", "curl:s3a0a54039912"], "status": "REASONED"}, "shell-input": {"text": "As this guide's assessment, hidden read input avoids history; as this guide's advice, tracing must be off around expansion. The clean-Bash login block clears inherited TOKEN attributes, turns tracing, allexport and errexit off, enables pipefail, and removes TOKEN on exit.", "components": ["bash", "docker", "proc"], "sources": ["bash:sb38bf74a89b0", "bash:s8c59508bd4c8", "bash:s04ec4ba49971", "bash:se62fd3ad4fc3", "bash:s2df39d2c9c1c", "bash:s8447036b2114", "bash:s3255eca7e496", "bash:s93dcf5ba0a87", "bash:s8016d1aecfca", "bash:sf6c6f542c26c", "docker:sf8243a183d67", "proc:s801d5f1ee88d"], "status": "REASONED"}, "shell-limits": {"text": "As this guide's assessment under the stated clean-Bash assumptions, the prompted token stays out of argv and environment; same-account/root memory access and earlier exports remain outside this protection.", "components": ["bash", "proc", "docker"], "sources": ["bash:sb38bf74a89b0", "bash:s8c59508bd4c8", "bash:s04ec4ba49971", "bash:se62fd3ad4fc3", "bash:s2df39d2c9c1c", "bash:s8447036b2114", "bash:s3255eca7e496", "bash:s93dcf5ba0a87", "bash:s8016d1aecfca", "bash:sf6c6f542c26c", "proc:s801d5f1ee88d", "docker:sf8243a183d67"], "status": "REASONED"}, "argv-review": {"text": "A ps/grep snapshot misses unlabelled secrets and short-lived processes; review secret-input paths instead of treating a clean snapshot as proof.", "components": ["proc"], "sources": ["proc:s801d5f1ee88d"], "status": "REASONED"}, "ci-store": {"text": "Scope CI/CD platform secrets to the jobs needing them; never echo them into logs or artefacts.", "components": ["owasp"], "sources": ["owasp:s26a987a1053b"], "status": "REASONED"}, "prompt-secrets": {"text": "Keep credentials out of all model prompts and customer data outside unapproved services; system prompts are disclosable and authorization belongs outside the model.", "components": ["owasp"], "sources": ["owasp:s26a987a1053b", "owasp:s4564d1912a87"], "status": "REASONED"}, "business-rules": {"text": "Keep confidential business logic out of public repositories, browser-shipped code and public LLM prompts; public environment prefixes and source maps can expose it.", "components": ["owasp", "next", "vite-env"], "sources": ["owasp:s26a987a1053b", "owasp:s4564d1912a87", "next:s964651d4eed4", "vite-env:sba07c9b57bd9"], "status": "REASONED"}, "terraform-state": {"text": "Default local Terraform state is plaintext and may contain sensitive values; ignore *.tfstate and `*.tfstate.*` including backups and workspace copies.", "components": ["terraform"], "sources": ["terraform:s8efa1a4bb953"], "status": "REASONED"}, "kubeconfig": {"text": "Kubeconfig can embed a base64 client-key-data private key or bearer token; exclude credential-bearing files and copies from version control.", "components": ["kubernetes"], "sources": ["kubernetes:se41a53ea6778"], "status": "REASONED"}, "docker-store": {"text": "Docker stores logins in its credential store or base64-encoded config.json; encoding is not encryption and scanners can miss encoded or unpatterned secrets.", "components": ["docker", "owasp"], "sources": ["docker:sf8243a183d67", "owasp:s26a987a1053b"], "status": "REASONED"}, "leak-rotation": {"text": "Revoke and replace a leaked credential before cleanup; deletion cannot undo public repository, chat, log or paste exposure.", "components": ["owasp"], "sources": ["owasp:s26a987a1053b"], "status": "REASONED"}, "history-cleanup": {"text": "History rewriting with git-filter-repo is hygiene after rotation, not containment or unpublishing.", "components": ["git-filter-repo", "owasp"], "sources": ["git-filter-repo:s95bf8a949d5e", "owasp:s26a987a1053b"], "status": "REASONED"}, "leak-audit": {"text": "Review provider logs for use of the leaked credential during its exposure window.", "components": ["owasp"], "sources": ["owasp:s26a987a1053b"], "status": "REASONED"}, "encrypted-git": {"text": "SOPS with age encrypts YAML/JSON/ENV values while preserving structure for secrets that must be versioned; keep decryption keys outside the repository.", "components": ["sops", "age"], "sources": ["sops:s78c5bb528dc4", "age:sca8b0d211739"], "status": "REASONED"}, "verify-scan": {"text": "With gitleaks 8.19.1 or later, gitleaks must exit 0 with no findings before clean is printed; the v8.30.1 README lists exit 0 for no leaks and 1 for leaks or an error. This rule-based scan is the primary gate.", "components": ["gitleaks"], "sources": ["gitleaks:s4e82b98bf700", "gitleaks:s2a515c553a16", "gitleaks:s8b08240c42e9"], "status": "REASONED", "verify": [1]}, "verify-patterns": {"text": "Supplementary grep prints filenames only, when null binary data is discovered assumes unprocessed input does not match, skips excluded trees; as this guide's advice, a non-zero exit caused by a read error is a failed check; as this guide's assessment, empty output is not proof of no secret. GNU grep documents -E as extended regular expression syntax.", "components": ["grep", "owasp"], "sources": ["grep:s33689bc366bf", "grep:s2c1251092e66", "grep:sea43e82b0753", "grep:sbd30b628a5b8", "owasp:s26a987a1053b"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> vault.md ====================================================================== # HashiCorp Vault HashiCorp Vault is a secrets manager: applications fetch passwords, API keys and other credentials from it instead of keeping them in files. This guide helps your AI assistant make sure it is configured securely, including no dev mode in production, an enabled audit device, and narrowly scoped policies and tokens. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/vault.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Vault loopback run 2.1.1, Vault handler source v1.21.0, Vault forwarding source v1.20.0, Vault JWT audience minimum 1.17, Vault historical wrapping qualification 0.8, and curl minimum write-out version 7.75.0; 1 further source carries no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 36 claims: 16 demonstrated by a check that ran and 20 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Vault's own defaults are careful: the listener binds loopback, TLS is assumed (`tls_disable` is `false`), and the web UI is off. The exposures are the deployment decisions you make on top of that, and the corpus points you here from [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md) without saying how to stand one up safely. Five decisions decide it: not running dev mode, keeping TLS on the listener you expose, choosing and guarding the seal, enabling an audit device, and retiring the initial root token. OpenBao is the Linux Foundation open-source fork after Vault's move to the BUSL licence; the controls below are written for Vault, and OpenBao shares the model but differs in some commands, so check its own docs if you run it. At the time of writing, the defaults and configuration below follow the current [Vault configuration reference](https://developer.hashicorp.com/vault/docs/configuration) and [TCP listener reference](https://developer.hashicorp.com/vault/docs/configuration/listener/tcp). The application ACL, AppRole, response-wrapping, and authenticated-monitoring controls below are available in Vault Community. Edition and version qualifications appear where they matter. ## 1. Never run dev mode in production `vault server -dev` runs entirely in memory, initializes and unseals itself with a single key, serves plain HTTP, and prints a root token to the console. The `-dev-tls` variant adds a throwaway certificate but is still a development server: by default it binds loopback rather than the world, stores nothing durably, and writes its printed root token into your token helper unless `-dev-no-store-token` is set. It does not provide the production protections below. Run a real server from a config file under your service manager (`vault server -config=/etc/vault.d/vault.hcl`), and audit the systemd unit and its environment so no `-dev` flag or dev-token override slips into production. See [dev mode](https://developer.hashicorp.com/vault/docs/concepts/dev-server) and [server flags](https://developer.hashicorp.com/vault/docs/commands/server). ## 2. Bind and front the listeners deliberately The API listener answers on `8200` (and serves the UI at `/ui` on that same listener when `ui = true`), and cluster peers forward requests and run Raft over `8201`. Bind the API to the private address that clients actually use rather than a wildcard, keep `8201` reachable only by the other Vault nodes, and put a default-deny firewall around both. `api_addr` and `cluster_addr` only advertise where peers and redirects should point; they do not restrict what the listener binds, so set the `address` explicitly. Expose the API to anything wider than a trusted network only behind the corpus's fronting controls ([fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)), and enforce Vault's Login MFA on the human auth methods, not only at a UI proxy, since the token method cannot use it ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)); the cluster port must keep Vault's own end-to-end TLS regardless of any proxy. Other listeners exist only if you configure them (a KMIP listener on Enterprise, a Vault Agent or Proxy listener), and an external storage backend is an outbound connection to a separate service with its own listeners, so add each to your own network-surface inventory. See [HA communication](https://developer.hashicorp.com/vault/docs/concepts/ha) and [Login MFA](https://developer.hashicorp.com/vault/docs/auth/login-mfa). Monitoring endpoints expose operational information. Keep anonymous metrics and profiling access disabled on every API listener. The listener in section 3 explicitly sets `unauthenticated_metrics_access = false` and `unauthenticated_pprof_access = false`; both default to false in the current reference at the time of writing. Enabling monitoring is not a reason to change them. See [listener telemetry and profiling parameters](https://developer.hashicorp.com/vault/docs/configuration/listener/tcp#telemetry-parameters). Give the metrics collector a separate identity with this policy: ```hcl path "sys/metrics" { capabilities = ["read"] } ``` The collector requests `GET /v1/sys/metrics`; its token should have no application-secret access. Keep monitoring traffic on the permitted network. The metrics and profiling APIs must be called from the root namespace, including on Enterprise deployments that use namespaces. The metrics policy does not grant profiling access. See the [metrics API](https://developer.hashicorp.com/vault/api-docs/system/metrics), [profiling API](https://developer.hashicorp.com/vault/api-docs/system/pprof), and [policy capabilities](https://developer.hashicorp.com/vault/docs/concepts/policies#capabilities). ## 3. Keep TLS on the listener Vault assumes TLS, so `tls_disable = true` is an explicit choice to put every token and secret on the wire in plaintext; never set it on a listener anything reaches. Give the listener a real certificate and key with `tls_cert_file` and `tls_key_file`, keep the key `0400` and owned by the vault user, and set `tls_min_version = "tls12"` or higher. Certificates come from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or, for an internal CA, [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). A minimal Raft node configuration: ```hcl ui = false disable_mlock = true # Raft memory-maps its data; disable swap at the OS instead (section 8) api_addr = "https://vault-1.internal:8200" cluster_addr = "https://vault-1.internal:8201" # Raft requires this, distinct from the listener storage "raft" { path = "/var/lib/vault/raft" node_id = "vault-1" } listener "tcp" { address = "10.20.0.11:8200" cluster_address = "10.20.0.11:8201" tls_cert_file = "/etc/vault.d/tls/server-fullchain.pem" tls_key_file = "/etc/vault.d/tls/server.key" tls_min_version = "tls12" telemetry { unauthenticated_metrics_access = false } profiling { unauthenticated_pprof_access = false } } ``` Substitute the node's actual addresses and certificate paths. See the [TCP listener reference](https://developer.hashicorp.com/vault/docs/configuration/listener/tcp) and [Integrated Storage configuration](https://developer.hashicorp.com/vault/docs/configuration/storage/raft). ## 4. Choose the seal, and initialize once A fresh server starts sealed: the storage is encrypted and Vault cannot read it until it is unsealed, and the seal you pick decides who or what does that. Shamir splits the unseal capability into key shares (`vault operator init -key-shares=5 -key-threshold=3`), and a threshold of custodians must each supply a share at the hidden prompt to unseal; never pass a share on the command line. The prompt keeps the share out of the command text, shell history and tracing; the account owner and root can still read it in the `vault` process. Auto-unseal uses the configured external seal service, such as AWS KMS (`seal "awskms" { ... }`) or another Vault's Transit secrets engine, and instead emits recovery keys for privileged operations. Transit auto-unseal is available in Community; seal wrapping requires Enterprise. See [initialization](https://developer.hashicorp.com/vault/docs/commands/operator/init), [the unseal prompt](https://developer.hashicorp.com/vault/docs/commands/operator/unseal), [AWS KMS seals](https://developer.hashicorp.com/vault/docs/configuration/seal/awskms), and [Transit seals](https://developer.hashicorp.com/vault/docs/configuration/seal/transit). Initialize a cluster exactly once and join the other nodes to it. Capture initialization output securely, including the unseal or recovery material and initial root token, and preserve split custody of the shares. Later quorum-authorized rekey operations can replace unseal or recovery shares. The trade cuts both ways: a threshold of unseal shares plus a copy of the storage is an offline path to your data, while losing the required shares, or permanently losing the external seal mechanism and its keys, can make the ciphertext, backups included, permanently unreadable. See [operator rekey](https://developer.hashicorp.com/vault/docs/commands/operator/rekey). Recovery keys authorize recovery operations but cannot decrypt the root key when the auto-unseal mechanism is unavailable. A temporary outage prevents recovery until the mechanism returns; permanent loss can make the cluster and backups unrecoverable. Enterprise seal HA is a separate feature, not a Community fallback. See [seal and recovery behavior](https://developer.hashicorp.com/vault/docs/concepts/seal). ## 5. Enable an audit device A new cluster has no audit device, so requests are served with no durable record; server logs are not a substitute. Enable one (`vault audit enable file file_path=/var/log/vault/audit.log`), and add a second, independent one (`vault audit enable syslog` shipping to a SEPARATE host or failure domain: a local syslog agent that writes the same disk is not independent). The reason is that auditing is fail-closed: when Vault cannot write to at least one enabled device it "refuses to service the corresponding API request", so a full disk on your only device is an outage. Collect both devices: either device alone can have gaps when it was unavailable. The syslog device sends to the local syslog agent; configure that agent's remote delivery and monitor it. See [audit-device availability](https://developer.hashicorp.com/vault/docs/audit#availability-of-audit-devices) and the [syslog device](https://developer.hashicorp.com/vault/docs/audit/syslog). Monitor the disk and rotate the file: rename the audit file and send the Vault process `SIGHUP` to reopen it, rather than truncating it. See [file audit rotation](https://developer.hashicorp.com/vault/docs/audit/file#log-file-rotation). Vault writes a keyed HMAC-SHA256 of most string values by default, so a leaked string value is hashed rather than exposed; leave that on and never enable `log_raw`, but non-string values are not hashed, so still restrict who can read the log and treat it as sensitive. A handful of endpoints, including `sys/health` and `sys/seal-status`, are exempt from auditing, which matters when you verify delivery below. See [audit hashing and exemptions](https://developer.hashicorp.com/vault/docs/audit) and [audit-device options](https://developer.hashicorp.com/vault/api-docs/system/audit). ## 6. Revoke the initial root token The initial root token has unlimited access and no expiry, so it is a bootstrap credential, not an operating one. Retire it in order: enable an audit device, configure an auth method and write scoped admin policies, then actually log in as a non-root admin and confirm that identity works, and only then revoke the root token from its own session with `vault token revoke -self`. Log in through the auth method, not with a non-orphan token made by `vault token create` while the root token is selected: that token is the root token's child and is revoked with it (observed on the loopback run in Verify). Confirm the revocation (the Verify section does), and delete any token-helper copy left in `~/.vault-token`. Keep no live root token afterward; if you genuinely need root again, `vault operator generate-root` issues a new one through the unseal quorum, or the recovery-key quorum under auto-unseal, which is the point of not leaving one lying around. See [token behavior](https://developer.hashicorp.com/vault/docs/concepts/tokens), [self-revocation](https://developer.hashicorp.com/vault/docs/commands/token/revoke), and [root generation](https://developer.hashicorp.com/vault/docs/commands/operator/generate-root). For CLI authentication, select tokens through a securely injected `VAULT_TOKEN` environment variable or a token helper. `VAULT_TOKEN` moves a token out of argv, not out of reach: while exported it is in the environment of every program the shell starts, readable through `/proc//environ` by the same account and root, and where the shell received it at startup, in the shell's own. Enter interactive login credentials and unseal shares at the prompt. The prompt keeps them out of the command text, shell history and tracing; the account owner and root can still read them in the `vault` process. Never put credentials in command arguments, URLs, shell history, or build logs. See [CLI authentication](https://developer.hashicorp.com/vault/docs/commands) and [login prompts](https://developer.hashicorp.com/vault/docs/commands/login). ## 7. Scope policies and tokens The root policy bypasses every rule; every other identity gets only the capabilities its policies grant, on a deny-by-default basis. Write narrow policies with `vault policy write`, prefer logins through an auth method over long-lived tokens, and give tokens bounded TTLs so a leaked one expires. An application that can read another application's secrets can turn one compromise into several. For an existing KV v2 mount at `kv/`, grant only the required data path: ```bash vault policy write payments-read - <<'HCL' path "kv/data/payments/config" { capabilities = ["read"] } HCL ``` KV v2 policies use the API's `data/` path. This example grants neither writes nor metadata listing. Give applications no `root` policy or `sudo` capability, and review every policy attached through tokens and identities; one narrow policy does not cancel broader grants elsewhere. This Community example is specifically for KV v2; do not copy its path unchanged for KV v1. See [KV v2 API paths](https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2), [ACL policy semantics](https://developer.hashicorp.com/vault/docs/concepts/policies), and [policy write](https://developer.hashicorp.com/vault/docs/commands/policy/write). A short-lived Vault token does not help if its login credential can mint replacements indefinitely. For a bounded batch job using AppRole, limit both the SecretID and the resulting token. Enable the mount only if it does not already exist; substitute the application's actual source network for the example CIDR. ```bash # Enable only if approle/ does not already exist. vault auth enable approle vault write auth/approle/role/payments \ bind_secret_id=true \ secret_id_ttl=10m \ secret_id_num_uses=1 \ secret_id_bound_cidrs=10.20.30.0/24 \ token_policies=payments-read \ token_no_default_policy=true \ token_type=service \ token_ttl=5m \ token_max_ttl=15m \ token_explicit_max_ttl=15m \ token_period=0 \ token_num_uses=100 \ token_bound_cidrs=10.20.30.0/24 ``` SecretID limits govern login; token limits govern subsequent requests. An expired SecretID can still log in until AppRole's once-a-minute tidy deletes it (observed in Verify). Zero use limits mean unlimited use. `token_explicit_max_ttl` supplies a hard lifetime ceiling. This role excludes the default policy, so clients must not assume its self-service permissions. The values are an example workload budget, not Vault defaults; server and mount limits can shorten issued token lifetimes. These are current Community role parameters, not a universal Vault Agent configuration. See [AppRole configuration](https://developer.hashicorp.com/vault/docs/auth/approle), [exact role parameters](https://developer.hashicorp.com/vault/api-docs/auth/approle), and [token lifetime rules](https://developer.hashicorp.com/vault/docs/concepts/tokens). A one-use SecretID requires fresh delivery for another login. Count the client's actual requests before choosing a token use limit; limited-use tokens cannot create child tokens. HashiCorp generally recommends batch tokens for AppRole, but service tokens are intentional here for use limiting and individual revocation. Batch tokens have different renewal and revocation behavior. See [AppRole constraints](https://developer.hashicorp.com/vault/docs/auth/approle) and [token types](https://developer.hashicorp.com/vault/docs/concepts/tokens). Passing a SecretID through deployment systems exposes it to every intermediary. Deliver a short-lived, single-use wrapping token instead, and require wrapping in the delivery identity's policy: ```bash vault policy write payments-delivery - <<'HCL' path "auth/approle/role/payments/secret-id" { capabilities = ["create", "update"] min_wrapping_ttl = "1s" max_wrapping_ttl = "60s" } HCL ``` Attach this policy to the deployment identity, not the application. A positive minimum wrapping TTL requires wrapping on this path. Review the delivery identity's other policies as well; the effective permissions and wrapping limits must preserve this restriction. See [required response-wrapping TTLs](https://developer.hashicorp.com/vault/docs/concepts/policies#required-response-wrapping-ttls). As that deployment identity: ```bash vault write -wrap-ttl=60s -f auth/approle/role/payments/secret-id ``` Treat the returned wrapping token as a credential. Keep it out of build logs and transfer it through the protected delivery channel. Before unwrapping, the recipient must use wrapping lookup to validate its lifetime and exact `creation_path`, here `auth/approle/role/payments/secret-id`. Unexpected, expired, or already-consumed tokens require investigation. Wrapping-token expiry does not replace the SecretID's own TTL. See [response wrapping and validation](https://developer.hashicorp.com/vault/docs/concepts/response-wrapping). For an explicit lookup request, have the protected delivery mechanism populate an owner-readable JSON file at `/run/secrets/payments-wrapping-lookup.json` with the wrapping token in its `token` field. Pass that request on stdin: ```bash vault write -format=json sys/wrapping/lookup - \ < /run/secrets/payments-wrapping-lookup.json ``` The wrapping token reaches `vault` from the request file on its stdin, so it is not in the command's arguments, and because the command never types it or expands a variable holding it, neither shell history nor `set -x` tracing records it from this command, provided the delivery mechanism, not a shell command containing the token, writes the file. Stdin closes the argv channel only: the account owner and root can still read the file and the `vault` process's memory. Check `data.creation_path`, `data.creation_time`, and `data.creation_ttl`; the latter is the original TTL, not the remaining lifetime. Confirm the token is still within that lifetime before proceeding. Protect and remove the request file when finished. See the [wrapping lookup API](https://developer.hashicorp.com/vault/api-docs/system/wrapping-lookup) and [JSON input to write](https://developer.hashicorp.com/vault/docs/commands/write). With that same wrapping token selected through securely injected `VAULT_TOKEN`, unwrap without a token argument: ```bash vault unwrap ``` No token is typed into this command, so the wrapping token is not in `vault`'s arguments, and neither shell history nor `set -x` tracing records it from this command. `VAULT_TOKEN` moves it out of argv, not out of reach: while the variable is exported, the token is in the environment of every program the shell starts, including `vault`, readable through `/proc//environ` by the same account and root while each runs, and where the shell received `VAULT_TOKEN` in its initial environment, that file also shows it for the shell itself. Whoever reads it there before the unwrap can unwrap it first, which leaves the recipient's unwrap failing on a consumed token; investigate that as above. The opportunity to unwrap ends when the token is consumed or expires; its bytes can remain exposed in process environments afterward. Protect the output, which contains the SecretID. Response wrapping is available in Community; this example targets current behavior. The conceptual reference notes that some wrapping-token features date from Vault 0.8 and may be unavailable before that version. See [unwrap](https://developer.hashicorp.com/vault/docs/commands/unwrap) and [response wrapping](https://developer.hashicorp.com/vault/docs/concepts/response-wrapping). Where workload identity already exists, use Kubernetes or JWT authentication instead of distributing AppRole credentials. Kubernetes roles should bind explicit service-account names and namespaces. JWT roles should constrain the intended identity and audience; since Vault 1.17, a JWT containing `aud` requires an exact match with at least one configured `bound_audiences` value. These are alternative integrations, not additional prerequisites for this example. See [AppRole integration guidance](https://developer.hashicorp.com/vault/docs/auth/approle/approle-pattern), [Kubernetes role bindings](https://developer.hashicorp.com/vault/api-docs/auth/kubernetes), and [JWT authentication](https://developer.hashicorp.com/vault/docs/auth/jwt). Do not try to "block all `sys/`": some system paths answer without authentication by design, both the `sys/seal-status` and `sys/health` monitors need and the `sys/init` and `sys/leader` bootstrap paths, and policy cannot close a bootstrap path, so keep an uninitialized or unsealed Vault off untrusted networks (whoever reaches `sys/init` first can initialize it and take the root token). Other authenticated system operations are policy-gated, while recovery paths such as `sys/unseal` carry their own authorization, so the control is correct policies plus network isolation, not blanket denial. See [initialization](https://developer.hashicorp.com/vault/api-docs/system/init), [leader status](https://developer.hashicorp.com/vault/api-docs/system/leader), and [seal status](https://developer.hashicorp.com/vault/api-docs/system/seal-status). ## 8. Protect storage, memory, and backups Encryption at rest protects confidentiality, not availability: whoever can reach the storage can still delete or corrupt it, and a backup carries exactly the sensitivity of the live data. Run Vault as a dedicated unprivileged user that cannot overwrite its own binary or configuration, keep the Raft directory owned by that user and mode-restricted, take snapshots with `vault operator raft snapshot save /var/backups/vault.snap` and guard them like the unseal material, and give an external backend its own TLS and access controls. Two memory paths leak live key material regardless of the storage: swap and core dumps. Disable swap on the host so the OS cannot page secrets to disk (the Raft configuration above disables `mlock` because Raft memory-maps its data, and disabling swap is the compensating control), and disable core dumps for the service (`LimitCORE=0` in the systemd unit). See [production hardening](https://developer.hashicorp.com/vault/docs/concepts/production-hardening) and [Raft snapshots](https://developer.hashicorp.com/vault/docs/commands/operator/raft#snapshot-save). Deployments using other storage backends should not generalize the Raft recommendation: HashiCorp discourages disabling `mlock` outside Integrated Storage. See [memory-locking configuration](https://developer.hashicorp.com/vault/docs/configuration#disable_mlock). ## Verify DEMONSTRATED: the loopback runs recorded here and at the blocks that record them. Most checks below were **demonstrated on loopback** against Vault 2.1.1, the release binary with its checksum verified against HashiCorp's published SHA256SUMS: a single Raft node running the section 3 configuration unchanged apart from addresses and paths. The Raft node ran with the API on 127.0.0.1:8200, the cluster listener on 127.0.0.1:8201, a private test CA, a file audit device, and the section 2 and section 7 policies and AppRole role with `127.0.0.1/32` as the CIDR. Each block ran as printed, with only its placeholders substituted, except where a step says otherwise. Exposed states ran in further runs of the same binary: one with both anonymous-access settings `true` throughout, one with `tls_disable = true` on the listener (a health check only), and one with the section 3 configuration unchanged apart from addresses and paths. The exposed-state runs also included a follow-up run with the same section 3 configuration, then stopped for a health-output check, then with one added listener stanza on 127.0.0.2:8200 (which also bound 127.0.0.2:8201). The first, third and follow-up runs exercised the policies, roles or delivery policy under test without their limits. What these runs do not show is marked **REASONED** where it occurs, with the reason: HA behavior (no multi-node cluster was set up for these runs), a second audit device in a separate failure domain (the host has no second network), and the recipient's own validation (it belongs to the receiving application). The other items these runs do not show, each marked **REASONED** where it occurs, with the reason, are a wildcard bind (the host forbids binding every interface), `ss` run as root (the host has no `sudo`), and real network paths and firewalls (the host has no second network). These expectations follow the cited Vault documentation. The authoring host has no `sudo`, so the first block's `sudo ss -tlnp` ran through a stand-in that runs `ss` without it; as the same account, `ss` still showed the Vault process. The runs of the first block used an earlier form of it; the gate suite's bash -n and ShellCheck 0.11.0 passes and the stub runs recorded after the two inspection blocks cover its current form, which checks for `ss` before running it. REASONED: curl result interpretation: transport or TLS failure is inconclusive, and only matched authenticated controls support a fixed outcome. Per the guide's cited curl options (write-out fields require 7.75.0; header files, TLS verification, direct connections, and write-out fields). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of the current form of the two inspection blocks are recorded in the paragraph after those blocks; they do not establish live Vault behavior, and no live run of the current form is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The curl `exitcode` and `errormsg` write-out fields require curl 7.75.0 or newer. For authenticated and on-host checks, transport failure, TLS failure, an unexpected redirect, or a missing positive control is inconclusive, never the fixed state. See [curl write-out fields](https://curl.se/docs/manpage.html#-w). REASONED: from the Vault CLI environment and token-helper documentation and the curl manual page cited in Sources, and from the guards in the blocks below, not from the loopback runs, which record no run with altered shell builtins, an apostrophe in a placeholder, or inherited proxy settings or Vault proxy overrides. The `VAULT_TOKEN` environment exposure rests on the stubbed QA runs recorded at the wrapping-lookup blocks below (same-account observers, no real `vault` client, root not tested). Paste whole blocks and substitute inside the single quotes. Use the base HTTPS URL without a trailing slash where a block appends an API path. Never embed credentials in a URL. These guards assume ordinary shell builtins; do not insert a literal apostrophe into a single-quoted placeholder. Use a clean shell without inherited HTTP proxy settings or Vault proxy overrides for direct CLI checks. Select each CLI identity through securely injected `VAULT_TOKEN` or its token helper; do not paste token values into commands. An exported `VAULT_TOKEN` is still readable through process environments (section 6). Keep credential-bearing CLI output out of recordings and build logs. **Listener, health, audit, root revocation, and role inspection (demonstrated on loopback):** run this on the Vault host as a non-root admin authorized to inspect audit devices, look up its own token, and read the example AppRole: ```bash # DEMONSTRATED: loopback listener, health, file audit, root revocation and role runs recorded below; ss used the documented non-root stand-in. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1, which took two values and which ended with the role read's status; the current form, which takes INSPECT or REVOKED_ROOT as a third value and exits once with one status for its classified checks, has not been demonstrated. The current form also checks for ss before running sudo ss, exits 2 when ss is missing and no other check found something or was inconclusive, and is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the stub runs recorded after the second block, which do not establish live Vault behavior. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_INSPECT_OR_REVOKED_ROOT' # Value 3 is INSPECT as the scoped admin, or REVOKED_ROOT for the repeat with the revoked root token selected. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } case "$3" in INSPECT|REVOKED_ROOT) ;; *) echo "set value 3 to INSPECT as the scoped admin, or REVOKED_ROOT with the revoked root token selected; not probing"; exit 2 ;; esac unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL VAULT_ADDR VAULT_CACERT || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a refused part, such as a missing ss), which outranks 0 if command -v ss >/dev/null; then if sudo ss -tlnp; then :; else echo "sudo ss -tlnp exit $?: no socket table to read; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 fi else echo "ss is not installed here; not listing sockets" case "$4" in 0) set -- "$1" "$2" "$3" 2 ;; esac fi if vault status -format=json; then :; else echo "vault status exit $? (2 means sealed, 1 an error): not the initialized, unsealed operating state; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 fi set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/v1/sys/health"; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${5% *}" case "${5##* }:${5% *}" in 0:"http=200 "*) ;; 0:*) echo "unclassified observation: read the health code above against the list below; a healthy response is a connectivity control, not proof of hardening" ;; *) echo "curl exit ${5##* }: the health request failed, as a transport or certificate verification failure does; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if vault audit list -detailed 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${5% *}" case "$3:${5##* }:${5% *}" in INSPECT:*:*"No audit devices are enabled."*) echo "FINDING: no audit device is enabled, so requests are served unlogged"; set -- "$1" "$2" "$3" 1 ;; INSPECT:0:*) echo "unclassified observation: the devices are listed above; check delivery by correlating a request_id, as described below" ;; INSPECT:*) echo "vault audit list exit ${5##* }: a failed or denied inspection; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; REVOKED_ROOT:0:*) echo "unclassified observation: the audit list succeeded with the revoked root token selected; the token lookups below decide whether that token is live" ;; *) echo "with the revoked root token selected the audit list is expected to fail; not classified" ;; esac # In the two lookups, vault's own output goes straight to you and only its error text is held, so no token enters a value. { set -- "$1" "$2" "$3" "$4" "$(if { vault token lookup >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${5% *}" case "$3:${5##* }:${5% *}" in INSPECT:0:*) ;; INSPECT:*) echo "vault token lookup exit ${5##* }: the scoped admin's lookup, the positive control, failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; REVOKED_ROOT:0:*) echo "FINDING: the revoked root token's lookup succeeded, so that token is still live"; set -- "$1" "$2" "$3" 1 ;; REVOKED_ROOT:*:*"permission denied"*|REVOKED_ROOT:*:*"invalid token"*) echo "Vault refused the revoked root token's lookup, the fixed outcome" ;; *) echo "vault token lookup exit ${5##* } without a permission-denied or invalid-token error; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac { set -- "$1" "$2" "$3" "$4" "$(if { vault token lookup -format=json >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${5% *}" case "$3:${5##* }:${5% *}" in INSPECT:0:*) ;; INSPECT:*) echo "vault token lookup exit ${5##* }: the scoped admin's lookup, the positive control, failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; REVOKED_ROOT:0:*) echo "FINDING: the revoked root token's lookup succeeded, so that token is still live"; set -- "$1" "$2" "$3" 1 ;; REVOKED_ROOT:*:*"permission denied"*|REVOKED_ROOT:*:*"invalid token"*) echo "Vault refused the revoked root token's lookup, the fixed outcome" ;; *) echo "vault token lookup exit ${5##* } without a permission-denied or invalid-token error; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac if vault read -format=json auth/approle/role/payments; then :; else set -- "$1" "$2" "$3" "$4" "$?" case "$3" in INSPECT) echo "vault read exit $5: a missing role or denied inspection; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "with the revoked root token selected the role read is expected to fail; not classified" ;; esac fi case "$3" in INSPECT) echo "block exit $4; the socket table, any health code but 200, audit delivery and the role parameters are not classified, so judge them yourself" ;; *) echo "block exit $4; with the revoked root token selected only the token lookups decide whether that token is live, and the socket table and any health code but 200 are not classified, so judge them yourself" ;; esac exit "$4" ) ``` ```bash # Scope of this repeat: wildcard binds, ss as root, HA health and independent audit delivery; the host lacks the capabilities listed below. Repeat these commands for those scenarios using the cited listener, health and audit sources. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_INSPECT_OR_REVOKED_ROOT' # Value 3 is INSPECT as the scoped admin, or REVOKED_ROOT for the repeat with the revoked root token selected. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } case "$3" in INSPECT|REVOKED_ROOT) ;; *) echo "set value 3 to INSPECT as the scoped admin, or REVOKED_ROOT with the revoked root token selected; not probing"; exit 2 ;; esac unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL VAULT_ADDR VAULT_CACERT || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a refused part, such as a missing ss), which outranks 0 if command -v ss >/dev/null; then if sudo ss -tlnp; then :; else echo "sudo ss -tlnp exit $?: no socket table to read; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 fi else echo "ss is not installed here; not listing sockets" case "$4" in 0) set -- "$1" "$2" "$3" 2 ;; esac fi if vault status -format=json; then :; else echo "vault status exit $? (2 means sealed, 1 an error): not the initialized, unsealed operating state; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 fi set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/v1/sys/health"; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${5% *}" case "${5##* }:${5% *}" in 0:"http=200 "*) ;; 0:*) echo "unclassified observation: read the health code above against the list below; a healthy response is a connectivity control, not proof of hardening" ;; *) echo "curl exit ${5##* }: the health request failed, as a transport or certificate verification failure does; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if vault audit list -detailed 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${5% *}" case "$3:${5##* }:${5% *}" in INSPECT:*:*"No audit devices are enabled."*) echo "FINDING: no audit device is enabled, so requests are served unlogged"; set -- "$1" "$2" "$3" 1 ;; INSPECT:0:*) echo "unclassified observation: the devices are listed above; check delivery by correlating a request_id, as described below" ;; INSPECT:*) echo "vault audit list exit ${5##* }: a failed or denied inspection; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; REVOKED_ROOT:0:*) echo "unclassified observation: the audit list succeeded with the revoked root token selected; the token lookups below decide whether that token is live" ;; *) echo "with the revoked root token selected the audit list is expected to fail; not classified" ;; esac # In the two lookups, vault's own output goes straight to you and only its error text is held, so no token enters a value. { set -- "$1" "$2" "$3" "$4" "$(if { vault token lookup >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${5% *}" case "$3:${5##* }:${5% *}" in INSPECT:0:*) ;; INSPECT:*) echo "vault token lookup exit ${5##* }: the scoped admin's lookup, the positive control, failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; REVOKED_ROOT:0:*) echo "FINDING: the revoked root token's lookup succeeded, so that token is still live"; set -- "$1" "$2" "$3" 1 ;; REVOKED_ROOT:*:*"permission denied"*|REVOKED_ROOT:*:*"invalid token"*) echo "Vault refused the revoked root token's lookup, the fixed outcome" ;; *) echo "vault token lookup exit ${5##* } without a permission-denied or invalid-token error; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac { set -- "$1" "$2" "$3" "$4" "$(if { vault token lookup -format=json >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${5% *}" case "$3:${5##* }:${5% *}" in INSPECT:0:*) ;; INSPECT:*) echo "vault token lookup exit ${5##* }: the scoped admin's lookup, the positive control, failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; REVOKED_ROOT:0:*) echo "FINDING: the revoked root token's lookup succeeded, so that token is still live"; set -- "$1" "$2" "$3" 1 ;; REVOKED_ROOT:*:*"permission denied"*|REVOKED_ROOT:*:*"invalid token"*) echo "Vault refused the revoked root token's lookup, the fixed outcome" ;; *) echo "vault token lookup exit ${5##* } without a permission-denied or invalid-token error; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac if vault read -format=json auth/approle/role/payments; then :; else set -- "$1" "$2" "$3" "$4" "$?" case "$3" in INSPECT) echo "vault read exit $5: a missing role or denied inspection; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "with the revoked root token selected the role read is expected to fail; not classified" ;; esac fi case "$3" in INSPECT) echo "block exit $4; the socket table, any health code but 200, audit delivery and the role parameters are not classified, so judge them yourself" ;; *) echo "block exit $4; with the revoked root token selected only the token lookups decide whether that token is live, and the socket table and any health code but 200 are not classified, so judge them yourself" ;; esac exit "$4" ) ``` REASONED: preceding block; all listener, health, audit, root-token and AppRole inspection commands: require intended private sockets, verified TLS, initialized and unsealed status, delivered audit records, revoked-root lookup denial and the section 7 role bounds; unintended listeners, absent logs, successful revoked-root lookup or missing bounds are findings. The inspection checks follow the guide's cited Vault TCP listener (address defaults, tls_disable, cluster_address); Vault sys/health status codes; Vault status command (JSON output and exit codes); Vault audit devices (fail-closed, HMAC default, exempt endpoints). The inspection checks also follow the guide's cited Vault audit entry schema (request IDs and token-use fields); Vault token lookup command (selected-token lookup and JSON output); Vault token revoke command (self-revocation); Vault AppRole API (role parameters, SecretID generation, and login). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. In this follow-up review, both inspection blocks ran with every external command stubbed: `vault status -format=json` returned 2, while all other stub commands returned 0; the subsequent curl health request, audit list, both token lookups and AppRole read still ran, and each block exited 0. These stub results do not establish live Vault behavior. The bash -n, ShellCheck and stub runs recorded above in this paragraph covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of an earlier form of both inspection blocks, each pasted whole with every external command stubbed, exited 0 for the fixed `INSPECT` and `REVOKED_ROOT` states, 3 when `sudo ss` failed, 2 when `ss` was missing and every other check passed, 3 when `ss` was missing and a later check failed, and 1 when `ss` was missing and the audit list was empty. They gave the same statuses under an inherited `readonly` variable, an inherited `declare -i` variable and an inherited `set -e`. Like the earlier stub runs, they do not establish live Vault behavior, and no live run of the current form is recorded. In the form those runs covered, the `REVOKED_ROOT` audit-list and closing lines said the token lookups decide this run. On 2026-11-02, the current form of both blocks, each pasted whole into GNU bash 5.3.9 with `sudo`, `ss`, `vault` and `curl` stubbed, exited 0 for the fixed `INSPECT` and `REVOKED_ROOT` states, 3 when `sudo ss` failed, 2 when `ss` was missing and every other check passed, 3 when `ss` was missing and a later check failed, and 1 when `ss` was missing and the audit list was empty. With `REVOKED_ROOT`, those runs printed that the token lookups decide whether that token is live after a successful audit list and in the closing line, also when the stubbed `vault status` failed and the block exited 3, and they do not establish live Vault behavior either. The current form of both blocks, not yet demonstrated, takes `INSPECT` for the scoped admin or `REVOKED_ROOT` for the revoked-root repeat as its third value and exits once: 1 for an empty audit list or a successful revoked-root lookup, 3 for a failed command or positive control with `INSPECT`, 2 when `ss` is missing and no other check found something or was inconclusive, and 0 otherwise. With `REVOKED_ROOT`, each exits with 3 only for a failed `ss`, `vault status` or health request or a lookup that fails without the expected error. Each block checks for `ss` before running `sudo ss -tlnp`. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it and no check found something, per the guide's cited sudo sudoers manual, secure_path entry. When `ss` is missing, the closing line still names the socket table, though none was printed. The socket table, health codes other than `200`, audit delivery and the role bounds stay for you to judge, as the items below describe: - REASONED: bind and TLS comparison: unintended listeners are findings; intended private binds with verified TLS and matched firewall controls are required. The parenthetical host limitations below describe the historical run. Per the guide's cited Vault TCP listener (address defaults, tls_disable, cluster_address); Vault production hardening (root token, swap, core dumps). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of the current form of the two inspection blocks are recorded in the paragraph after those blocks; they do not establish live Vault behavior, and no live run of the current form is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. **Bind and TLS (demonstrated, TLS in both states; a wildcard bind, `ss` as root and the firewall REASONED: the host forbids binding every interface, has no `sudo`, and has no second network):** read the whole socket table. Confirm `8200` is bound to the intended private address and `8201` to the cluster address, with nothing unexpected. A wildcard or unintended listener is a finding. The TLS health request must reach the intended server with certificate verification enabled. `ss` shows the bind, not the firewall; "only between nodes" is a firewall property. Confirm it with firewall rules and the permitted/forbidden-source probes below. See the [listener reference](https://developer.hashicorp.com/vault/docs/configuration/listener/tcp). - REASONED: status and health interpretation: initialized and unsealed is the intended operating state; sealed, uninitialized and standby responses must be distinguished as specified here, without treating health as proof of hardening. Per the guide's cited Vault status command (JSON output and exit codes); Vault sys/health status codes. Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of the current form of the two inspection blocks are recorded in the paragraph after those blocks; they do not establish live Vault behavior, and no live run of the current form is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The demonstrated label below records the historical loopback run, not this review. **Server state (demonstrated for `501`, `503` and `200`; the standby and removed-node codes REASONED, since no multi-node cluster was set up):** status should report `initialized: true` and `sealed: false`. Status exit code `2` means sealed; an unsealed standby returns `0`; `1` means an error. Health codes `200` active, `429` standby, `501` uninitialized, and `503` sealed are common examples, not an exhaustive list. Current documentation also includes `474` for an unhealthy standby unable to reach the active node and `530` for a removed node. Codes `472` and `473` concern Enterprise disaster-recovery and performance-standby roles. Query parameters can change returned codes. A healthy response is a connectivity control, not proof of hardening. See [status](https://developer.hashicorp.com/vault/docs/commands/status) and the [health API](https://developer.hashicorp.com/vault/api-docs/system/health). - REASONED: audit configuration and delivery: no devices or missing delivery is a finding; require the matching request ID at both independent destinations. The parenthetical host limitation below describes the historical run. Per the guide's cited Vault audit devices (fail-closed, HMAC default, exempt endpoints); Vault audit list command; Vault audit entry schema (request IDs and token-use fields). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of the current form of the two inspection blocks are recorded in the paragraph after those blocks; they do not establish live Vault behavior, and no live run of the current form is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. **Audit configuration and delivery (demonstrated for an empty list and for one and two file devices; a second device in a separate failure domain REASONED, since the host has no second network):** an empty audit list means requests are served unlogged. The fixed configuration lists the intended independent devices. Correlate the successful non-root JSON token lookup's `request_id` with `request.id` in the collected audit entries; the default table output omits the request ID. Check delivery to both destinations while both are healthy and retain both streams, since either can have gaps during an outage. A listed device without delivered records is not a successful delivery check. Do not use health requests for correlation because they are exempt from auditing. See [audit availability and exemptions](https://developer.hashicorp.com/vault/docs/audit), [audit listing](https://developer.hashicorp.com/vault/docs/commands/audit/list), and the [audit schema](https://developer.hashicorp.com/vault/docs/audit/schema). - REASONED: root revocation: a revoked root lookup must fail while an independently authenticated scoped admin succeeds; successful root lookup is the exposed outcome. Per the guide's cited Vault token lookup command (selected-token lookup and JSON output); Vault token revoke command (self-revocation); Vault token concepts (token prefixes, parent and child revocation, root tokens, TTLs, service and batch behavior). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of the current form of the two inspection blocks are recorded in the paragraph after those blocks; they do not establish live Vault behavior, and no live run of the current form is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The demonstrated label below records the historical loopback run, not this review. **Root revocation (demonstrated):** the scoped-admin token lookup is the positive control. Log that admin in through an auth method: a non-orphan token made with `vault token create` while the root token is selected is the root token's child and is revoked with it. Repeat the guarded block with the revoked initial root credential still selected and `REVOKED_ROOT` as its third value, and judge its token-lookup results: Vault must return permission-denied or invalid-token errors. Other authenticated commands in that repeat will also fail. A successful root-token lookup is the exposed state, and the block then exits with status 1. This proves that one token is dead, not that no root token exists; do not retain or generate a live root credential for testing. See [token lookup](https://developer.hashicorp.com/vault/docs/commands/token/lookup) and [self-revocation](https://developer.hashicorp.com/vault/docs/commands/token/revoke). - REASONED: AppRole inspection: require every section 7 bound; absent limits or broader policies are findings, while denied inspection is inconclusive. Per the guide's cited Vault AppRole API (role parameters, SecretID generation, and login). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2. Stub runs of the current form of the two inspection blocks are recorded in the paragraph after those blocks; they do not establish live Vault behavior, and no live run of the current form is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The demonstrated label below records the historical loopback run, not this review. **AppRole configuration (demonstrated):** compare the role read with every parameter in section 7, including both CIDR restrictions, SecretID TTL/use limits, service-token type, token TTL/max TTL/ explicit max TTL, zero period, use limit, and exclusion of the default policy. Durations may be returned in seconds. A missing role or denied inspection is inconclusive; zero use limits, missing bounds, or broader attached policies do not satisfy this example. This read establishes configuration, not runtime enforcement. See the [AppRole role API](https://developer.hashicorp.com/vault/api-docs/auth/approle). DEMONSTRATED: the loopback runs recorded in this section's introduction. On the loopback run, `ss` showed only `127.0.0.1:8200` and `127.0.0.1:8201` for the Vault process. Health returned `501` before initialization, `503` while sealed (status exit code `2`) and `200` once unsealed (exit code `0`); a plaintext request got `400` "Client sent an HTTP request to an HTTPS server", and a request without the CA failed certificate verification. The audit list showed the file device, and the JSON token lookup's `request_id` matched two audit entries, the request and its response. The role read returned every section 7 value, with durations in seconds. After `vault token revoke -self`, the block with the revoked root token selected got `403` `invalid token` for the audit list, both token lookups and the role read. A scoped admin created as a child of the root token lost access with it, while an orphan token and a userpass login kept working. In the exposed states, before any audit device was enabled the audit list printed `No audit devices are enabled.`, and before revocation the root token's lookup succeeded with policy `root` and `ttl` `0s`. With `tls_disable = true` on the listener, a plaintext health request was answered (`501`, uninitialized) and an HTTPS request failed. A `payments` role written without limits or bounds read back with `secret_id_num_uses` `0`, `secret_id_ttl` `0`, no CIDRs, `token_num_uses` `0` and zero TTLs. In a follow-up run, the health request printed `http=200 exit=0 err=` on the healthy node and `http=000 exit=7 err=Failed to connect ...` with the server stopped; with two file audit devices, one request's `request_id` appeared twice in each file. The same follow-up run had a second listener stanza on 127.0.0.2:8200, and the socket table also showed `127.0.0.2:8200` and `127.0.0.2:8201`, an unintended listener with its own cluster port, because `cluster_address` defaults to one port above `address`. The runs of the first block recorded here used an earlier form of it; the gate suite's bash -n and ShellCheck 0.11.0 passes and the stub runs recorded after the two inspection blocks cover its current form, which checks for `ss` before running it. REASONED: header-file preparation for the ACL and monitoring comparisons: use the intended identity's actual header; the paired requests must distinguish authorized success from anonymous or cross-application denial. Stub arguments contained the header-file path, not its dummy token. Per the guide's cited curl options (write-out fields require 7.75.0; header files, TLS verification, direct connections, and write-out fields); Vault KV v2 API (data paths and read operations); Vault metrics API (GET, JSON format, and root namespace); Vault profiling API (index path and root namespace). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The demonstrated label below records the historical loopback run, not this review. **Application ACLs and authenticated monitoring (demonstrated on loopback):** for API authentication, provision an owner-readable header file containing the selected identity's `X-Vault-Token` header through a protected mechanism. The file must contain the actual header, not a shell variable reference. Use only the intended authentication header, and remove the file when finished. REASONED: from curl's header-file input in the manual page cited in Sources, not from the loopback runs, which did not observe it. In the two blocks below, of the credential, only the header file's path enters curl's argv, through `--header "@$3"`; curl reads the `X-Vault-Token` header from the file. The blocks never type the token or expand a variable holding it, so neither shell history nor `set -x` tracing records it from the blocks, provided the file is prepared through protected delivery rather than by a shell command that contains the token. A header file closes the argv channel only: the account owner and root can still read the file and the memory of each curl process while it runs. Run this block for each full endpoint URL and identity described below. It reports an anonymous request followed by the same request with the selected credential; response bodies are discarded to avoid printing secrets or profiles. ```bash # DEMONSTRATED: loopback ACL and active-node monitoring pairs recorded below. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1, which took three values and which ended with the authenticated request's curl status; the current form, which takes EXPECT_200 or EXPECT_403 as a fourth value and classifies both requests, has not been demonstrated. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_FULL_HTTPS_API_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_TOKEN_HEADER_FILE' \ 'REPLACE_WITH_EXPECT_200_OR_EXPECT_403' # Value 4 is EXPECT_200 or EXPECT_403, the authenticated outcome the table row below expects for this identity. # Take it from the row's fixed outcome for the selected identity. These expectations follow that table and # the guide's cited Vault policies and tokens, not the loopback runs, which used an earlier form of this block. # With EXPECT_200, a 2xx is the pass and any other answer gives status 3, a failed control. With EXPECT_403, # 403 is the pass, a 2xx is a finding with status 1 and any other answer gives 3. Where a row pairs a 403 # with another identity's 200, run the block for each. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 3; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } [ -r "$3" ] || { echo "header file is not readable"; exit 2; } case "$4" in EXPECT_200|EXPECT_403) ;; *) echo "set value 4 to EXPECT_200 or EXPECT_403, the authenticated outcome the table row below expects for this identity; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" -w 'anonymous http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${5% *}" # Status: 1 for an anonymous 2xx; 3 for any other anonymous answer but 403, or a failed request; otherwise 0. case "${5##* }:${5% *}" in 0:"anonymous http=403 "*) set -- "$1" "$2" "$3" "$4" 0 ;; 0:"anonymous http=2"??" "*) echo "FINDING: the anonymous request got an HTTP 2xx, so the endpoint answers without a token"; set -- "$1" "$2" "$3" "$4" 1 ;; 0:*) echo "the anonymous request got neither 403 nor a 2xx, and a 404, redirect or standby answer is not an ACL result; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "curl exit ${5##* }: the anonymous request failed; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac # Only the header file's path enters curl's argv; the response body is discarded, so the held write-out line holds no token. set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --header "@$3" \ -w 'authenticated http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${6% *}" # Status: with EXPECT_200, 3 unless the authenticated request got a 2xx (a failed control); with EXPECT_403, # 1 for a 2xx and 3 for any other answer but 403, or a failed request. A finding outranks 3. case "$4:${6##* }:${6% *}" in EXPECT_200:0:"authenticated http=2"??" "*|EXPECT_403:0:"authenticated http=403 "*) ;; EXPECT_403:0:"authenticated http=2"??" "*) echo "FINDING: the authenticated request got an HTTP 2xx where the table row expects 403, so this identity reaches the path"; set -- "$1" "$2" "$3" "$4" 1 ;; EXPECT_200:0:*) echo "the authenticated request did not get the 2xx the table row expects, so this positive control failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; EXPECT_403:0:*) echo "the authenticated request got neither 403 nor a 2xx, and a 404, redirect or standby answer is not an ACL result; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "curl exit ${6##* }: the authenticated request failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "block exit $5; where the table row below pairs a 403 with another identity's 200, repeat this block for that identity with EXPECT_200" exit "$5" ) ``` ```bash # REASONED: HA standby monitoring; no multi-node cluster was set up. Repeat these requests against the node roles described below; the cited handler sources distinguish the outcomes. Stub runs on 2026-11-02 in GNU bash 5.3.9, the block pasted whole with `curl` stubbed, covered this form: with `EXPECT_200` and an anonymous `403`, an authenticated `307` or `400` printed the line to repeat this block on the active node and exited 3, and an authenticated `200` exited 0. They do not establish live Vault behavior. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_FULL_HTTPS_API_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_TOKEN_HEADER_FILE' \ 'REPLACE_WITH_EXPECT_200_OR_EXPECT_403' # Value 4 is EXPECT_200 or EXPECT_403, the authenticated outcome the table row below expects for this identity. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 3; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } [ -r "$3" ] || { echo "header file is not readable"; exit 2; } case "$4" in EXPECT_200|EXPECT_403) ;; *) echo "set value 4 to EXPECT_200 or EXPECT_403, the authenticated outcome the table row below expects for this identity; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" -w 'anonymous http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${5% *}" # Status: 1 for an anonymous 2xx; 3 for any other anonymous answer but 403, or a failed request; otherwise 0. case "${5##* }:${5% *}" in 0:"anonymous http=403 "*) set -- "$1" "$2" "$3" "$4" 0 ;; 0:"anonymous http=2"??" "*) echo "FINDING: the anonymous request got an HTTP 2xx, so the endpoint answers without a token"; set -- "$1" "$2" "$3" "$4" 1 ;; 0:*) echo "the anonymous request got neither 403 nor a 2xx, and a 404, redirect or standby answer is not an ACL result; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "curl exit ${5##* }: the anonymous request failed; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac # Only the header file's path enters curl's argv; the response body is discarded, so the held write-out line holds no token. set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --header "@$3" \ -w 'authenticated http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" printf '%s' "${6% *}" # Status: with EXPECT_200, 3 unless the authenticated request got a 2xx (a failed control, or a standby's answer to repeat on the active node); with EXPECT_403, # 1 for a 2xx and 3 for any other answer but 403, or a failed request. A finding outranks 3. case "$4:${6##* }:${6% *}" in EXPECT_200:0:"authenticated http=2"??" "*|EXPECT_403:0:"authenticated http=403 "*) ;; EXPECT_403:0:"authenticated http=2"??" "*) echo "FINDING: the authenticated request got an HTTP 2xx where the table row expects 403, so this identity reaches the path"; set -- "$1" "$2" "$3" "$4" 1 ;; EXPECT_200:0:*) echo "the authenticated request did not get the 2xx the table row expects; a standby's 307 redirect or local-only 400 is not an authentication result, so for either, repeat this block on the active node; any other answer means this positive control failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; EXPECT_403:0:*) echo "the authenticated request got neither 403 nor a 2xx, and a 404, redirect or standby answer is not an ACL result; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "curl exit ${6##* }: the authenticated request failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "block exit $5; where the table row below pairs a 403 with another identity's 200, repeat this block for that identity with EXPECT_200" exit "$5" ) ``` | Check | Request and identity | Fixed outcome and matched positive control | Exposed outcome | | --- | --- | --- | --- | | REASONED: own-secret positive control: expect authenticated 200 for the existing fixture; failure makes the cross-application comparison inconclusive. Per the guide's cited Vault KV v2 API (data paths and read operations); Vault policies and tokens. Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Application's own secret | `GET /v1/kv/data/payments/config`, payments application token | Authenticated `200` for an existing, readable test fixture | Failure makes the cross-application denial inconclusive | | REASONED: cross-application denial: expect application 403 and fixture-reader 200 for the same path; application 200 is exposed. Per the guide's cited Vault KV v2 API (data paths and read operations); Vault policies and tokens. Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Another application's secret | `GET /v1/kv/data/inventory/config`, payments application token | Application receives `403`; repeat the same URL with a fixture-reader identity authorized for that existing path and require `200` | Application receives `200` | | REASONED: active-node metrics: expect anonymous 403 and collector 200; anonymous 200 is exposed. Per the guide's cited Vault TCP listener (address defaults, tls_disable, cluster_address); Vault metrics API (GET, JSON format, and root namespace). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Metrics | `GET /v1/sys/metrics`, metrics collector token, directly on the active node | Anonymous `403`, collector `200`, on each API listener of the active node | Anonymous `200` | | REASONED: active-node profiling: expect anonymous 403 and authorized operator 200; anonymous 200 is exposed. Per the guide's cited Vault TCP listener (address defaults, tls_disable, cluster_address); Vault profiling API (index path and root namespace). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Profiling | `GET /v1/sys/pprof/`, separate operator with `read` on `sys/pprof/*`, directly on the active node | Anonymous `403`, authorized operator `200`, on each API listener of the active node | Anonymous `200` | | REASONED: collector isolation: expect collector 403 and payments application 200 on the same fixture; collector 200 is exposed. Per the guide's cited Vault KV v2 API (data paths and read operations); Vault policies and tokens. Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Collector isolation | `GET /v1/kv/data/payments/config`, metrics collector token | Collector receives `403`; payments application receives `200` for the same existing fixture | Collector receives `200` | REASONED: fixture and active-node controls: require the authorized 200 control before counting a 403 denial; a 404 or standby selection is inconclusive. Per the guide's cited Vault KV v2 API (data paths and read operations); Vault metrics API (GET, JSON format, and root namespace); Vault profiling API (index path and root namespace). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Use non-sensitive fixtures in an isolated test deployment. A `404` is not proof of an ACL denial. For metrics, use the default JSON representation so the positive control does not depend on enabling Prometheus output. For both monitoring checks, target the active node directly, or the sole server in a single-node deployment. Confirm its role with the guarded status/health check above before running the paired requests. Do not use a load balancer that can select a standby. On the loopback run, every row gave its fixed outcome: anonymous `403` throughout; the payments token `200` on its own secret and `403` on the inventory secret, where the fixture reader got `200`; the collector `200` on `sys/metrics` and `403` on `sys/pprof/` and on the payments secret; the profiling operator `200` on `sys/pprof/`. With the listener's two anonymous-access settings set to `true`, anonymous requests got `200` on `sys/metrics` and `sys/pprof/`, and a token with a broad `kv/data/*` read policy got `200` on the inventory secret: the exposed outcomes. In the run with the section 3 configuration and its anonymous-access settings left `false`, a collector whose policy added `kv/data/*` got `200` on the payments secret, the collector-isolation exposed outcome. REASONED: HA monitoring: expect an ordinary standby's 307 redirect or the documented local-only forwarding rejection, neither an authentication pass; repeat on the active node for anonymous 403 and authorized 200. No multi-node environment was available. Per the guide's cited Vault HA redirect implementation (pinned v1.21.0); Vault local-only forwarding implementation (pinned v1.20.0); Vault metrics API (GET, JSON format, and root namespace); Vault profiling API (index path and root namespace). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture or live run of it is recorded, while the HA standby block's leading comment records its stub runs. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. **REASONED (no multi-node cluster was set up for these runs):** authenticated metrics and profiling use local-only HTTP handlers. An ordinary HA standby returns `307` to the active node's advertised API address when a leader is available; that redirect is not an authentication result. Performance standbys can serve local requests, but the local-only handler rejects requests that require forwarding with `400` in the inspected implementation. Do not follow redirects with the token or count redirects, forwarding errors, or missing positive controls as passes; reconfirm the active node and repeat the pair. See the [HTTP route and standby redirect implementation](https://raw.githubusercontent.com/hashicorp/vault/v1.21.0/http/handler.go) and [local-only forwarding handling](https://raw.githubusercontent.com/hashicorp/vault/v1.20.0/http/logical.go). Keep both anonymous-access settings disabled on every node's API listeners. Inspect each listener's configuration and repeat the paired checks on each node when it is active during an authorized HA test; a pair on the current active node does not verify other nodes' listener settings. REASONED: separate profiling authorization: collector credentials do not establish a profiling control; require the profiling operator's 200 against anonymous 403, with anonymous 200 the exposed state. Per the guide's cited Vault policies and tokens; Vault profiling API (index path and root namespace); Vault metrics API (GET, JSON format, and root namespace); Vault KV v2 API (data paths and read operations); Vault TCP listener (address defaults, tls_disable, cluster_address). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. A collector's metrics-only policy is not a positive control for profiling; use the separately authorized operator. HashiCorp's [debug permissions](https://developer.hashicorp.com/vault/docs/commands/debug#permissions) grant `read` on `sys/pprof/*`, covering the trailing-slash index and profiling subpaths without `sudo`. Leave the collector's policy unchanged. These checks follow the [KV v2 API](https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2), [ACL capabilities](https://developer.hashicorp.com/vault/docs/concepts/policies#capabilities), [listener authentication settings](https://developer.hashicorp.com/vault/docs/configuration/listener/tcp#telemetry-parameters), [metrics API](https://developer.hashicorp.com/vault/api-docs/system/metrics), and [profiling API](https://developer.hashicorp.com/vault/api-docs/system/pprof). REASONED: from the shell behaviour of file input on stdin, not from the loopback runs below, which did not test it. What was run: on 2026-10-13 UTC a QA review ran the following block under `bash -x` with its external commands stubbed and a dummy secret as the credential; no trace line carried the dummy secret, while a positive control that printed the file with a traced builtin `printf` did. A second run the same day pasted the block into Bash 5.3.9 (`bash --noprofile --norc -i`) on a pseudo-terminal with history enabled, with a `vault` stub that read stdin and slept four seconds and the observing and inspected processes under the same account. The second run's history file held no contents of the JSON request file, and neither `/proc//cmdline` (the stub's arguments were `write -format=json auth/approle/login -`) nor `/proc//environ` of the stub and the block's subshell contained the dummy secret. In that run the observer was an ancestor of the inspected processes. A third run the same day, on the QA review's evaluation host, repeated the observation with same-account observers that were siblings of the block's shells rather than ancestors: neither `/proc//cmdline` nor `/proc//environ` of any sampled process, including the `vault` recording stub, the block's subshell and the outer shell, contained the dummy secret. What was not run: the contents of process memory are unknown, because every attempt by those observers to open `/proc//mem` was refused with EACCES (Permission denied) on the evaluation host, where `/proc/sys/kernel/yama/ptrace_scope` read `1`. Root was not tested as the observer, because the evaluation host refused privilege elevation (`sudo -n id` failed because the no-new-privileges flag was set), and no real `vault` client or live Vault service ran, because `vault` was not on the evaluation host's PATH. These three runs covered an earlier form of the following block, whose guard refusals exited with status 1 and which ended with the login's own status; none was re-run on the current form, which takes `EXPECT_SUCCESS` or `EXPECT_REFUSAL` as a fourth value and classifies the login, and which holds only `vault`'s error text in a value. The following login request takes `role_id` and `secret_id` from the request file on `vault`'s stdin: no credential is typed into the command or held in a shell variable, so neither shell history nor `set -x` tracing records it, provided the file is prepared through protected delivery rather than by a shell command that contains the credentials. Stdin closes the argv channel only: the account owner and root can still read the file and the `vault` process's memory. **Bounded credentials and tokens (demonstrated on loopback):** prepare an owner-readable JSON request file containing `role_id` and `secret_id` through protected delivery. The following login request reads the credentials from stdin, not argv. Its successful output contains a token; handle it only in a protected session. ```bash # DEMONSTRATED: loopback AppRole SecretID, CIDR and token-limit runs recorded below. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1, which took three values and which ended with the login's status; the current form, which takes EXPECT_SUCCESS or EXPECT_REFUSAL as a fourth value and classifies the login, has not been demonstrated. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_LOGIN_JSON_FILE' \ 'REPLACE_WITH_EXPECT_SUCCESS_OR_EXPECT_REFUSAL' # Value 4 is EXPECT_SUCCESS for a fresh credential from an allowed source, or EXPECT_REFUSAL for a reused one, # one whose SecretID expired more than two minutes ago or a disallowed-source one, as the cases below describe. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 3; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } case "$4" in EXPECT_SUCCESS|EXPECT_REFUSAL) ;; *) echo "set value 4 to EXPECT_SUCCESS for a fresh credential from an allowed source, or EXPECT_REFUSAL for a reused one, one whose SecretID expired more than two minutes ago or a disallowed-source one; not probing"; exit 2 ;; esac unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL VAULT_ADDR VAULT_CACERT || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 [ -r "$3" ] || { echo "login request file is not readable"; exit 2; } # The login's output, which holds a token, goes straight to you; only vault's error text is held as a value. { set -- "$4" "$(if { vault write -format=json auth/approle/login - < "$3" >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${2% *}" # Status: with EXPECT_SUCCESS, 0 when the login succeeds and 3 when it fails; with EXPECT_REFUSAL, 1 when it # succeeds, 0 for Vault's invalid role or secret ID or CIDR refusal, and 3 for any other failure. case "$1:${2##* }:${2% *}" in EXPECT_SUCCESS:0:*) echo "the login succeeded; read token_policies, lease_duration and the hvs. token prefix above against the cases below"; exit 0 ;; EXPECT_SUCCESS:*) echo "vault write exit ${2##* }: a login expected to succeed failed, so this positive control failed; inconclusive"; exit 3 ;; EXPECT_REFUSAL:0:*) echo "FINDING: a login expected to be refused succeeded"; exit 1 ;; *:*"invalid role or secret ID"*|*:*"unauthorized by CIDR restrictions"*) echo "Vault refused the login with an authentication error, the fixed outcome while a fresh credential from an allowed source still succeeds"; exit 0 ;; *) echo "vault write exit ${2##* } without Vault's invalid role or secret ID or CIDR error, and a network refusal alone demonstrates neither control; inconclusive"; exit 3 ;; esac ) ``` Use separate credentials for independent cases so consuming a SecretID or token does not confound another test: - REASONED: SecretID single use: require first-login success, reuse rejection and fresh-SecretID success; successful reuse is exposed. Python parsed a dummy role_id/secret_id JSON fixture; no live credentials were used. Per the guide's cited Vault AppRole API (role parameters, SecretID generation, and login); Vault AppRole authentication (setup, token choice, and child-token constraints). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The demonstrated label below records the historical loopback run, not this review. **SecretID use limit (demonstrated):** a fresh SecretID succeeds once from an allowed source. An immediate repeat with the same request file must fail with a Vault authentication error; another freshly delivered SecretID must succeed. Successful reuse is the exposed state. On the loopback run, the section 7 role refused the reuse with `400`, and a role with `secret_id_num_uses=0` accepted the same SecretID twice. - **SecretID expiry (demonstrated):** leave a separate SecretID unused for more than two minutes beyond its ten-minute lifetime, then submit its login request. It must fail while a fresh SecretID succeeds from the same source. AppRole deletes expired SecretIDs in a tidy that runs once a minute, and until then an expired SecretID still logs in: on the loopback run, SecretIDs 10 seconds past their TTL logged in, while one 70 seconds past it was refused with `invalid role or secret ID`. Successful login more than two minutes after expiry is the exposed state; the margin allows for a tidy that is only nominally once a minute. With a 20-second test TTL, a SecretID tried about 130 seconds past it was refused with `invalid role or secret ID`, while one from a role with no `secret_id_ttl` logged in. - **Source bounds (demonstrated with a second loopback address):** try a fresh SecretID from outside the configured CIDR but within a test network that can reach Vault. Require authentication rejection, paired with successful login using fresh credentials from the allowed CIDR. Test the resulting token's payments read with the guarded GET block from both sources: allowed source `200`, disallowed source `403`. Network refusal alone does not demonstrate either CIDR control. On the loopback run, with `127.0.0.1/32` as the CIDR, a login sent from 127.0.0.2 (the same JSON request through `curl --interface`, because the block's CLI cannot choose its source address) was refused with `source address "127.0.0.2" unauthorized by CIDR restrictions on the role`. The same loopback run, with `127.0.0.1/32` as the CIDR, showed that a login from 127.0.0.1 succeeded, and its token read the payments secret with `200` from 127.0.0.1 and `403` from 127.0.0.2 (that read also went through `curl --interface`, not the block). With no CIDR bound on the role, a login from 127.0.0.2 succeeded with `200`, and a token from a role without `token_bound_cidrs` read the payments secret from 127.0.0.2 with `200`. - **Token lifetime, use budget and renewal ceiling (demonstrated):** the login response must show the intended `token_policies` and a `lease_duration` of at most 300 seconds, and its token must begin `hvs.`, the service-token prefix (the CLI's JSON output has no token-type field). Using the guarded payments GET, require success before expiry and `403` after the issued lease expires, paired with `200` using a fresh token. Separately exhaust the 100-request budget before expiry, counting all token uses, and require subsequent denial while a fresh token succeeds. Continued access after expiry or exhaustion is the exposed state. The role read checks the fifteen-minute explicit ceiling; an expiry test without renewal does not independently demonstrate that renewal ceiling. Do not use token self-lookup as the application's validity test: this example excludes the default policy and does not grant that permission. On the loopback run, the login returned `token_policies` `payments-read` and `lease_duration` `300` with an `hvs.` token; a read returned `200` before the TTL and `403` ten seconds after it, while a fresh token got `200`; and 100 reads returned `200` before read 101 got `403`; with `token_num_uses=0`, all 101 reads returned `200`. With short test durations (`token_ttl=10s`, `token_explicit_max_ttl=30s`), a token renewed every 8 seconds got `403` on a read at about 43 seconds, after the 30-second ceiling, while the same schedule without an explicit ceiling kept a token working. A role with `token_type=batch` issued an `hvb.` token and a role with no TTL a `lease_duration` of `2764800`, the exposed halves of the type and lease checks. With `token_ttl=10s`, a read at about 15 seconds got `403`, while a token from a role with no token TTL (the default lifetime) got `200`. REASONED: credential and token discriminators: fresh allowed credentials succeed while reused credentials, SecretIDs past the stated expiry margin or disallowed-source credentials fail; token access must stop at the specified use and lifetime bounds. Python parsed the dummy login JSON fixture. Per the guide's cited Vault AppRole API (role parameters, SecretID generation, and login); Vault AppRole authentication (setup, token choice, and child-token constraints); Vault token concepts (token prefixes, parent and child revocation, root tokens, TTLs, service and batch behavior). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The discriminators come from the [AppRole credential and role parameters](https://developer.hashicorp.com/vault/api-docs/auth/approle), [AppRole constraints](https://developer.hashicorp.com/vault/docs/auth/approle), and [token lifetime rules](https://developer.hashicorp.com/vault/docs/concepts/tokens). **Mandatory wrapping (demonstrated on loopback):** select the deployment identity, not an administrator or root token. In an isolated test deployment, the first two requests below must be rejected for their wrapping settings. Vault's error, `403` `permission denied`, does not name the setting, so rely on the controls: the sixty-second positive control and the exposed comparison below. The sixty-second request is the matched positive control and must return wrapping information. Unexpectedly successful negative requests are findings; their credential-bearing output is discarded. ```bash # DEMONSTRATED: loopback mandatory-wrapping negative requests and matched positive control recorded below. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1 and which ended with the positive control's status; the current form, whose guard refusals exit with status 2 and which exits with status 1 after a finding and 3 after a failed positive control, has not been run. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; *) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; *) case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 if vault write -f auth/approle/role/payments/secret-id > /dev/null; then echo "FINDING: unwrapped issuance succeeded" set -- "$@" 1 # records the finding; the block exits once, after the positive control else echo "Refused; this counts only if the sixty-second request below succeeds" fi if vault write -wrap-ttl=61s -f auth/approle/role/payments/secret-id > /dev/null; then echo "FINDING: issuance above the wrapping TTL ceiling succeeded" set -- "$@" 1 else echo "Refused; this counts only if the sixty-second request below succeeds" fi vault write -wrap-ttl=60s -f auth/approle/role/payments/secret-id || set -- "$@" 3 # Status: 1 for any finding; otherwise 3 if the positive control failed; otherwise 0. case "${3-}${4-}${5-}" in *1*) exit 1 ;; *3*) exit 3 ;; esac ;; esac ;; esac ) ``` Without the policy restrictions, otherwise authorized issuance can succeed without wrapping or with a longer wrapping TTL. That is the exposed comparison. A failure of the sixty-second positive control leaves the result inconclusive. On the loopback run, both negative requests were refused with `403` `permission denied`, which does not name the wrapping setting, so the positive control is what separates a wrapping refusal from a missing permission; the sixty-second request returned `wrapping_token_ttl` `1m` and `wrapping_token_creation_path` `auth/approle/role/payments/secret-id`. With a delivery policy that sets neither wrapping TTL, the same kind of identity's unwrapped request returned the SecretID fields and a sixty-one-second request succeeded: the exposed comparison. In the follow-up run, the block printed `Refused; this counts only if the sixty-second request below succeeds` for each refusal in the fixed state, and both `FINDING` lines in the exposed state. See [required wrapping TTLs](https://developer.hashicorp.com/vault/docs/concepts/policies#required-response-wrapping-ttls) and [wrapping requests](https://developer.hashicorp.com/vault/docs/concepts/response-wrapping). REASONED: from the shell behaviour of file input on stdin, not from the loopback lookup runs, which did not test it. What was run: on 2026-10-13 UTC a QA review ran both wrapping-lookup blocks below under `bash -x` with their external commands stubbed and a dummy secret as the token; no trace line carried the dummy secret, while a positive control that printed the file with a traced builtin `printf` did. A second run the same day pasted both blocks into Bash 5.3.9 (`bash --noprofile --norc -i`) on a pseudo-terminal with history enabled, with a `vault` stub that read stdin and slept four seconds, the dummy token placed in the shell's initial environment as `VAULT_TOKEN`, and the observing and inspected processes under the same account. The second run's history file held no token value and `/proc//cmdline` did not contain it, but `/proc//environ` of both the stub and the block's subshell contained `VAULT_TOKEN` set to the dummy token. In that run the observer was an ancestor of the inspected processes. A third run the same day, on the QA review's evaluation host, repeated the observation with same-account observers that were siblings of the block's shells rather than ancestors. The third run found that `/proc//cmdline` of no sampled process contained the dummy token, but `/proc//environ` of the `vault` recording stub, the block's subshell and the outer shell, which held `VAULT_TOKEN` in its initial environment, all contained it. What was not run: the contents of process memory are unknown, because every attempt by those observers to open `/proc//mem` was refused with EACCES (Permission denied) on the evaluation host, where `/proc/sys/kernel/yama/ptrace_scope` read `1`. Root was not tested as the observer, because the evaluation host refused privilege elevation (`sudo -n id` failed because the no-new-privileges flag was set), and no real `vault` client or live Vault service ran, because `vault` was not on the evaluation host's PATH. These three runs covered an earlier form of both wrapping-lookup blocks, whose guard refusals exited with status 1 and which ended with the lookup's own status; none was re-run on the current form, which takes `FRESH` or `SPENT` as a fourth value and classifies the lookup, and which holds only `vault`'s error text in a value. The reasoning from the shell behaviour of file input on stdin applies to both wrapping-lookup blocks below, the demonstrated lookup and the recipient-validation lookup. Each passes the protected lookup request, wrapping token included, to `vault` from a file on its stdin. Neither block types the token or expands a variable holding it, so neither shell history nor `set -x` tracing records it from the block, provided the file is prepared through protected delivery rather than by a shell command that contains the token. Stdin closes the argv channel only: the account owner and root can still read the file and the `vault` process's memory. The token, where `VAULT_TOKEN` holds the same token, as the next paragraph asks, is also in the environment of every program the shell starts while `VAULT_TOKEN` is exported, including `vault`, readable through `/proc//environ` by the same account and root while each runs. Where the shell received `VAULT_TOKEN` in its initial environment, as in the runs above, that file also shows it for the shell itself and the block's subshell; a shell that sets `VAULT_TOKEN` only after it starts was not tested. **Recipient validation (the lookup demonstrated; the recipient's own check REASONED, since it belongs to the receiving application):** prepare the protected JSON lookup request described in section 7, and select the received wrapping token through securely injected `VAULT_TOKEN`. Lookup must succeed before unwrapping: ```bash # DEMONSTRATED: loopback wrapping lookup before expiry and after consumption or expiry, recorded below. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1, which took three values and which ended with the lookup's status; the current form, which takes FRESH or SPENT as a fourth value and classifies the lookup, has not been demonstrated. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_WRAPPING_LOOKUP_JSON_FILE' \ 'REPLACE_WITH_FRESH_OR_SPENT' # Value 4 is FRESH for a token not yet unwrapped and unexpired, or SPENT for a consumed or expired one. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 3; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } case "$4" in FRESH|SPENT) ;; *) echo "set value 4 to FRESH for a token not yet unwrapped and unexpired, or SPENT for a consumed or expired one; not probing"; exit 2 ;; esac unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL VAULT_ADDR VAULT_CACERT || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 [ -r "$3" ] || { echo "lookup request file is not readable"; exit 2; } # The lookup's output goes straight to you; only vault's error text is held as a value. { set -- "$4" "$(if { vault write -format=json sys/wrapping/lookup - < "$3" >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${2% *}" # Status: with FRESH, 0 when the lookup succeeds, its fields left for you to judge, and 3 when it fails; with # SPENT, 1 when the lookup succeeds, 0 for Vault's not-valid wrapping-token error, and 3 for any other failure. case "$1:${2##* }:${2% *}" in FRESH:0:*) echo "the lookup succeeded; read creation_path, creation_ttl and creation_time above against the requirements below"; exit 0 ;; FRESH:*) echo "vault write exit ${2##* }: the lookup of a fresh token, the positive control, failed; inconclusive"; exit 3 ;; SPENT:0:*) echo "FINDING: a consumed or expired wrapping token still passed lookup"; exit 1 ;; *:*"wrapping token is not valid or does not exist"*) echo "Vault refused the consumed or expired token, the fixed outcome while a fresh token from the same delivery path passes"; exit 0 ;; *) echo "vault write exit ${2##* } without Vault's not-valid wrapping-token error; inconclusive"; exit 3 ;; esac ) ``` ```bash # REASONED: recipient validation belongs to the receiving application. Use this lookup with the unexpected-path scenario below; the cited recipient-validation source defines the required rejection. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_WRAPPING_LOOKUP_JSON_FILE' \ 'REPLACE_WITH_FRESH_OR_SPENT' # Value 4 is FRESH for a token not yet unwrapped and unexpired, or SPENT for a consumed or expired one. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 3; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } case "$4" in FRESH|SPENT) ;; *) echo "set value 4 to FRESH for a token not yet unwrapped and unexpired, or SPENT for a consumed or expired one; not probing"; exit 2 ;; esac unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL VAULT_ADDR VAULT_CACERT || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 [ -r "$3" ] || { echo "lookup request file is not readable"; exit 2; } # The lookup's output goes straight to you; only vault's error text is held as a value. { set -- "$4" "$(if { vault write -format=json sys/wrapping/lookup - < "$3" >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${2% *}" # Status: with FRESH, 0 when the lookup succeeds, its fields left for you to judge, and 3 when it fails; with # SPENT, 1 when the lookup succeeds, 0 for Vault's not-valid wrapping-token error, and 3 for any other failure. case "$1:${2##* }:${2% *}" in FRESH:0:*) echo "the lookup succeeded; read creation_path, creation_ttl and creation_time above against the requirements below"; exit 0 ;; FRESH:*) echo "vault write exit ${2##* }: the lookup of a fresh token, the positive control, failed; inconclusive"; exit 3 ;; SPENT:0:*) echo "FINDING: a consumed or expired wrapping token still passed lookup"; exit 1 ;; *:*"wrapping token is not valid or does not exist"*) echo "Vault refused the consumed or expired token, the fixed outcome while a fresh token from the same delivery path passes"; exit 0 ;; *) echo "vault write exit ${2##* } without Vault's not-valid wrapping-token error; inconclusive"; exit 3 ;; esac ) ``` Require the exact creation path, an original TTL from one through sixty seconds, and an unexpired creation time plus TTL. An expired or consumed token must fail lookup; a fresh token from the same delivery path is the positive control. In an isolated recipient test, a valid wrapping token from an unexpected creation path must be rejected by the recipient before unwrapping; accepting it is a recipient-validation finding even though Vault itself recognizes the token. On the loopback run, the lookup returned `creation_path` `auth/approle/role/payments/secret-id`, `creation_ttl` `60` and the creation time, and a lookup of the consumed token failed with `wrapping token is not valid or does not exist`. A wrapping token looked up three seconds after its two-second TTL failed the same way. See [wrapping lookup fields](https://developer.hashicorp.com/vault/api-docs/system/wrapping-lookup) and [recipient validation](https://developer.hashicorp.com/vault/docs/concepts/response-wrapping). REASONED: from the environment behaviour recorded above for the wrapping-lookup blocks, not from the loopback unwrap runs, which did not observe it. The single-use unwrap block below takes the wrapping token from `VAULT_TOKEN`, not from its arguments or a file: the block never types the token or expands a variable holding it, so neither shell history nor `set -x` tracing records it from the block. That closes the argv channel only. While `VAULT_TOKEN` is exported, the token is in the environment of every program the shell starts, including `vault`, readable through `/proc//environ` by the same account and root while each runs; where the shell received `VAULT_TOKEN` in its initial environment, that file also shows it for the shell itself and the block's subshell. A wrapping token read there before the first unwrap can be unwrapped by its reader, so a first unwrap that fails is a finding to investigate, not a pass. **Single-use unwrap (demonstrated on loopback):** only after the preceding validation, run this block with that same wrapping token selected. The first unwrap must succeed; repeat with the consumed token still selected and require a Vault invalid-token or already-unwrapped error. A separate fresh, validated token must still unwrap successfully. Protect the successful output, which contains the SecretID. ```bash # DEMONSTRATED: loopback first unwrap, consumed-token refusal and fresh-token control recorded below. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1, which took two values and which ended with the unwrap's status; the current form, which takes FIRST or REPEAT as a third value and classifies the unwrap, has not been demonstrated. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAULT_HTTPS_URL' \ 'REPLACE_WITH_CA_FILE' \ 'REPLACE_WITH_FIRST_OR_REPEAT' # Value 3 is FIRST for a token's first unwrap, or REPEAT for the repeat with the consumed token still selected. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 2; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an HTTPS URL"; exit 2 ;; esac [ -r "$2" ] || { echo "CA file is not readable"; exit 2; } case "$3" in FIRST|REPEAT) ;; *) echo "set value 3 to FIRST for a token's first unwrap, or REPEAT with the consumed token still selected; not probing"; exit 2 ;; esac unset VAULT_SKIP_VERIFY VAULT_AGENT_ADDR VAULT_NAMESPACE VAULT_WRAP_TTL VAULT_ADDR VAULT_CACERT || exit 2 export VAULT_ADDR="$1" VAULT_CACERT="$2" || exit 2 # The unwrapped SecretID goes straight to you; only vault's error text is held as a value. { set -- "$3" "$(if { vault unwrap >&3; } 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)"; } 3>&1 printf '%s' "${2% *}" # Status: with FIRST, 0 when the unwrap succeeds, 1 when Vault says the token is not valid, because someone may # have unwrapped it first, and 3 for any other failure; with REPEAT, 1 when the unwrap succeeds, 0 for that # not-valid error, and 3 for any other failure. case "$1:${2##* }:${2% *}" in FIRST:0:*) echo "the first unwrap succeeded; protect the SecretID above"; exit 0 ;; REPEAT:0:*) echo "FINDING: the consumed wrapping token unwrapped again"; exit 1 ;; FIRST:*:*"wrapping token is not valid or does not exist"*) echo "FINDING: the first unwrap failed because the token is no longer valid, so it may have been unwrapped before you; investigate"; exit 1 ;; REPEAT:*:*"wrapping token is not valid or does not exist"*) echo "Vault refused the consumed token, the fixed outcome while a fresh, validated token still unwraps"; exit 0 ;; *) echo "vault unwrap exit ${2##* } without Vault's not-valid wrapping-token error; inconclusive"; exit 3 ;; esac ) ``` Successful reuse would violate the expected single-use behavior. Do not mistake selecting a new token between requests for successful reuse of the original. On the loopback run, the first unwrap returned the SecretID fields, the second failed with `wrapping token is not valid or does not exist`, and a fresh wrapping token unwrapped. See [unwrap](https://developer.hashicorp.com/vault/docs/commands/unwrap) and [wrapping-token behavior](https://developer.hashicorp.com/vault/docs/concepts/response-wrapping). **External isolation (both connection outcomes demonstrated on loopback; the real network REASONED, since the host has no second network):** this last probe is the reverse of the on-host checks: run it from a machine outside your trusted network, where the fixed state is that nothing answers. Guard the address so the probe cannot run unsubstituted and time out as if the port were closed: ```bash # DEMONSTRATED: local connection-failure discriminator recorded below; this does not demonstrate an external firewall. The loopback runs used an earlier form of the block, whose guard refusals exited with status 1 and which ended with curl's status; the current form, whose guard refusals exit with status 2 and which classifies the probe by time_connect, has not been demonstrated. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac set -- "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:8200/v1/sys/seal-status"; then printf ' 0'; else printf ' %s' "$?"; fi)" set -- "${1% *}" "${1##* }" # curl's write-out line, then its exit status printf '%s' "$1" # Status: 1 for any non-zero time_connect, because the TCP connection formed; 0 when it stayed 0.000000 after a # refusal, no route or connection timeout (curl exit 7 or 28); otherwise 3, as for a name or local socket error. case "$1" in "http="*" time_connect=0.000000 "*) ;; "http="*" time_connect="*) echo "FINDING: a TCP connection to port 8200 formed from this source"; exit 1 ;; *) echo "curl exit $2 printed no write-out line; inconclusive"; exit 3 ;; esac case "$2" in 7|28) echo "no TCP connection formed (curl exit $2); this shows only that the address you tested is unreachable from here"; exit 0 ;; *) echo "curl exit $2 before any TCP connection, as for a name-resolution or local socket error; inconclusive"; exit 3 ;; esac ) ``` ```bash # REASONED: external isolation and permitted-source control; the host has no second network. Repeat this probe from the sources described below; the cited listener and firewall guidance defines the expected outcomes. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' 'REPLACE_WITH_FORBIDDEN_OR_PERMITTED' # Value 2 is FORBIDDEN when you probe from a source the firewall must refuse, or PERMITTED from a permitted one. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute value 1; not probing"; exit 2 ;; esac case "$2" in FORBIDDEN|PERMITTED) ;; *) echo "set value 2 to FORBIDDEN from a source the firewall must refuse, or PERMITTED from a permitted source; not probing"; exit 2 ;; esac set -- "$2" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:8200/v1/sys/seal-status"; then printf ' 0'; else printf ' %s' "$?"; fi)" set -- "$1" "${2% *}" "${2##* }" # the source, curl's write-out line, then its exit status printf '%s' "$2" # Status: from a FORBIDDEN source, 1 for any non-zero time_connect, 0 when it stayed 0.000000 after curl exit 7 # or 28, otherwise 3; from a PERMITTED source, 0 when the TCP connection formed, the positive control, else 3. case "$1:$2" in FORBIDDEN:"http="*" time_connect=0.000000 "*) ;; FORBIDDEN:"http="*" time_connect="*) echo "FINDING: a TCP connection to port 8200 formed from this forbidden source"; exit 1 ;; PERMITTED:"http="*" time_connect=0.000000 "*) echo "curl exit $3: no TCP connection formed from the permitted source, so the matched control failed; inconclusive"; exit 3 ;; PERMITTED:"http="*" time_connect="*) echo "a TCP connection formed from the permitted source, the positive control, even if certificate verification failed after it"; exit 0 ;; *) echo "curl exit $3 printed no write-out line; inconclusive"; exit 3 ;; esac case "$3" in 7|28) echo "no TCP connection formed (curl exit $3); this shows only that the address you tested is unreachable from here"; exit 0 ;; *) echo "curl exit $3 before any TCP connection, as for a name-resolution or local socket error; inconclusive"; exit 3 ;; esac ) ``` For this probe the discriminator is whether the TCP connection forms, not the HTTP reply: the fixed state is `time_connect` at `0.000000` with `err` naming a refusal, no route, or a filtered-port timeout, and any non-zero `time_connect`, even when the TLS handshake then fails against an internal CA, means the port is reachable and is the finding. Run the same check against `8201` and every externally reachable address. A name-resolution or local-socket error is inconclusive. This proves only that the ADDRESS you tested is unreachable, not that Vault is: a mistyped or misrouted address also yields `time_connect=0`, so confirm `$1` is the real external address (or public NAT) of the listener the on-host `ss` showed bound to `8200`, so a typo does not read as isolation. On loopback, the probe against the listening address printed a non-zero `time_connect` and curl's certificate-verification error (exit `60`), the reachable shape, and against 127.0.0.2, where nothing listens, `time_connect=0.000000` and `Could not connect to server` (exit `7`), the refused shape. REASONED: external isolation control: require a TCP connection from the permitted source and refusal from the forbidden source to the same destination; forbidden-source connectivity is exposed and a failed permitted control is inconclusive. No permitted/forbidden deployment pair was available. Per the guide's cited Vault production hardening (root token, swap, core dumps); Vault HA communication (advertised addresses and cluster TLS); curl options (write-out fields require 7.75.0; header files, TLS verification, direct connections, and write-out fields). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Pair the forbidden-source check with the same destination and port from a permitted host: the positive control must establish a TCP connection, which the preceding block reports with status 0 when its second value is `PERMITTED`. For `8201`, use a permitted Vault node. If the permitted source cannot route to that destination, the matched control is unavailable; record the limitation instead of claiming this probe demonstrates isolation. Confirm the firewall rules as well; a single external probe only establishes reachability from that source. See [production firewall guidance](https://developer.hashicorp.com/vault/docs/concepts/production-hardening), [cluster communication](https://developer.hashicorp.com/vault/docs/concepts/ha), and [seal-status API behavior](https://developer.hashicorp.com/vault/api-docs/system/seal-status). | Check scope | Status | Deployment comparison and prerequisites | | --- | --- | --- | | REASONED: deployment comparisons in this row: require the specified HA responses, delivery to both independent audit destinations, intended binds, unexpected-path rejection by the recipient, and denied external access with a working permitted-source control; missing delivery, unintended binds, accepted unexpected paths and forbidden-source connectivity are findings. No multi-node deployment, recipient implementation or external source pair was available. The guide's cited sources for the deployment comparisons in this row (part 1 of 2): Vault sys/health status codes; Vault HA redirect implementation (pinned v1.21.0); Vault local-only forwarding implementation (pinned v1.20.0); Vault audit devices (fail-closed, HMAC default, exempt endpoints); Vault TCP listener (address defaults, tls_disable, cluster_address). The guide's cited sources for the deployment comparisons in this row (part 2 of 2): Vault response wrapping (single use, delivery, validation, and historical 0.8 version note); Vault wrapping lookup API (creation path, time, and original TTL); Vault production hardening (root token, swap, core dumps); Vault HA communication (advertised addresses and cluster TLS). Related Verify blocks passed `bash -n` and ShellCheck 0.11.0 silently; written placeholders printed `substitute value 1; not probing` (exit 1), and substituted fixtures printed stub calls (exit 0). The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the Verify blocks, whose guard refusals exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. This marking review used stubs: a live Vault stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Checks beyond the recorded loopback runs | REASONED from the cited Vault documentation; not demonstrated | In an authorized isolated Vault deployment, demonstrate what a single loopback node cannot (part 1 of 2): HA standby behavior (the `429`, `474` and `530` health and status codes, and the monitoring checks' redirects and local-only handlers), a second audit device in a separate failure domain with delivery to both destinations, a wildcard bind and `ss` run as root. The demonstration in that authorized isolated Vault deployment, of what a single loopback node cannot demonstrate, is to cover (part 2 of 2): recipient-side rejection of an unexpected creation path, and the external isolation probe from real permitted and forbidden sources against 8200 and 8201. Record the Vault version and edition, effective settings, requests, errors, and matched positive controls without credentials. | ## Sources (checked September 2026) - Vault dev server (in-memory, insecure, not for production): https://developer.hashicorp.com/vault/docs/concepts/dev-server - Vault TCP listener (address defaults, tls_disable, cluster_address): https://developer.hashicorp.com/vault/docs/configuration/listener/tcp - Vault Integrated Storage (Raft) backend (path, node_id, cluster_addr): https://developer.hashicorp.com/vault/docs/configuration/storage/raft - Vault seal concepts (sealed state, Shamir vs auto-unseal, recovery keys): https://developer.hashicorp.com/vault/docs/concepts/seal - Vault operator init: https://developer.hashicorp.com/vault/docs/commands/operator/init - Vault audit devices (fail-closed, HMAC default, exempt endpoints): https://developer.hashicorp.com/vault/docs/audit - Vault production hardening (root token, swap, core dumps): https://developer.hashicorp.com/vault/docs/concepts/production-hardening - Vault sys/health status codes: https://developer.hashicorp.com/vault/api-docs/system/health - Vault policies and tokens: https://developer.hashicorp.com/vault/docs/concepts/policies - Vault Login MFA (enforced on auth-method logins; the token auth method cannot use it): https://developer.hashicorp.com/vault/docs/auth/login-mfa - Vault server flags (config, dev TLS, dev token-helper storage): https://developer.hashicorp.com/vault/docs/commands/server - Vault configuration (UI default, advertised addresses, disable_mlock): https://developer.hashicorp.com/vault/docs/configuration - Vault HA communication (advertised addresses and cluster TLS): https://developer.hashicorp.com/vault/docs/concepts/ha - Vault KMIP secrets engine (Enterprise requirement and separate listener): https://developer.hashicorp.com/vault/docs/secrets/kmip - Vault Agent configuration and listeners: https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent - Vault Proxy configuration and listeners: https://developer.hashicorp.com/vault/docs/agent-and-proxy/proxy - Vault AWS KMS seal (auto-unseal and Enterprise seal wrapping): https://developer.hashicorp.com/vault/docs/configuration/seal/awskms - Vault Transit seal (Community auto-unseal and Enterprise seal wrapping): https://developer.hashicorp.com/vault/docs/configuration/seal/transit - Vault operator unseal (hidden prompt): https://developer.hashicorp.com/vault/docs/commands/operator/unseal - Vault operator rekey (unseal and recovery shares): https://developer.hashicorp.com/vault/docs/commands/operator/rekey - Vault file audit device and SIGHUP rotation: https://developer.hashicorp.com/vault/docs/audit/file - Vault syslog audit device (local agent and remote-delivery responsibility): https://developer.hashicorp.com/vault/docs/audit/syslog - Vault audit API (device options, log_raw, and inspection permissions): https://developer.hashicorp.com/vault/api-docs/system/audit - Vault audit entry schema (request IDs and token-use fields): https://developer.hashicorp.com/vault/docs/audit/schema - Vault audit list command: https://developer.hashicorp.com/vault/docs/commands/audit/list - Vault token concepts (token prefixes, parent and child revocation, root tokens, TTLs, service and batch behavior): https://developer.hashicorp.com/vault/docs/concepts/tokens - Vault token revoke command (self-revocation): https://developer.hashicorp.com/vault/docs/commands/token/revoke - Vault root-token generation: https://developer.hashicorp.com/vault/docs/commands/operator/generate-root - Vault CLI (environment variables, token helper, TLS, and wrapping options): https://developer.hashicorp.com/vault/docs/commands - Vault login command (credential prompts): https://developer.hashicorp.com/vault/docs/commands/login - Vault policy write command (HCL from stdin): https://developer.hashicorp.com/vault/docs/commands/policy/write - Vault KV v2 API (data paths and read operations): https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2 - Vault AppRole authentication (setup, token choice, and child-token constraints): https://developer.hashicorp.com/vault/docs/auth/approle - Vault AppRole API (role parameters, SecretID generation, and login): https://developer.hashicorp.com/vault/api-docs/auth/approle - Vault AppRole integration guidance (prefer existing workload identity): https://developer.hashicorp.com/vault/docs/auth/approle/approle-pattern - Vault auth enable command: https://developer.hashicorp.com/vault/docs/commands/auth/enable - Vault Kubernetes API (service-account and namespace bindings): https://developer.hashicorp.com/vault/api-docs/auth/kubernetes - Vault JWT authentication (identity constraints and Vault 1.17 audience requirement): https://developer.hashicorp.com/vault/docs/auth/jwt - Vault response wrapping (single use, delivery, validation, and historical 0.8 version note): https://developer.hashicorp.com/vault/docs/concepts/response-wrapping - Vault wrapping lookup API (creation path, time, and original TTL): https://developer.hashicorp.com/vault/api-docs/system/wrapping-lookup - Vault write command (JSON stdin, format, and force flags): https://developer.hashicorp.com/vault/docs/commands/write - Vault unwrap command (use the selected token without a token argument): https://developer.hashicorp.com/vault/docs/commands/unwrap - Vault metrics API (GET, JSON format, and root namespace): https://developer.hashicorp.com/vault/api-docs/system/metrics - Vault profiling API (index path and root namespace): https://developer.hashicorp.com/vault/api-docs/system/pprof - Vault initialization API: https://developer.hashicorp.com/vault/api-docs/system/init - Vault leader-status API: https://developer.hashicorp.com/vault/api-docs/system/leader - Vault seal-status API (unauthenticated status): https://developer.hashicorp.com/vault/api-docs/system/seal-status - Vault Raft operator commands (snapshot save): https://developer.hashicorp.com/vault/docs/commands/operator/raft - Vault status command (JSON output and exit codes): https://developer.hashicorp.com/vault/docs/commands/status - Vault 2.1.1 AppRole expired-SecretID tidy (once a minute; a SecretID may live up to a minute past expiry): https://github.com/hashicorp/vault/blob/v2.1.1/builtin/credential/approle/backend.go - Vault token lookup command (selected-token lookup and JSON output): https://developer.hashicorp.com/vault/docs/commands/token/lookup - curl options (write-out fields require 7.75.0; header files, TLS verification, direct connections, and write-out fields): https://curl.se/docs/manpage.html - Vault HA redirect implementation (pinned v1.21.0): https://raw.githubusercontent.com/hashicorp/vault/v1.21.0/http/handler.go - Vault local-only forwarding implementation (pinned v1.20.0): https://raw.githubusercontent.com/hashicorp/vault/v1.20.0/http/logical.go - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - run: Vault loopback run (2.1.1) - ha: Vault handler source (v1.21.0) - forward: Vault forwarding source (v1.20.0) - jwt-min: Vault JWT audience minimum (1.17) - wrap-history: Vault historical wrapping qualification (0.8) - docs: Vault documentation (unknown) - curl: curl minimum write-out version (7.75.0) Claims (full source mapping in the JSON block below): - dev (REASONED; docs): Dev modes, token-helper storage and production config-file startup. - listeners (REASONED; docs): API/UI 8200, cluster 8201, private bind, advertised addresses and cluster TLS; optional KMIP, Agent and Proxy listeners. - mfa (REASONED; docs): Login MFA belongs on human auth methods; token auth cannot use it. - raft-tls (DEMONSTRATED; docs, run): Raft node fields, UI disabled, TLS certificate/key/minimum, anonymous monitoring disabled; address/path substitutions only in the recorded run. - defaults (REASONED; docs): Listener loopback, TLS enabled, UI disabled and anonymous metrics/profiling disabled are documentation defaults. - seal (REASONED; docs): Shamir init/unseal prompts, auto-unseal/recovery, Community Transit and Enterprise seal wrapping/HA; key loss and rekey custody. - audit (REASONED; docs): Enable file and independent syslog auditing, fail-closed availability, HMAC defaults, log_raw, exemptions and SIGHUP rotation. - root (REASONED; docs): Bootstrap root has unlimited access and no expiry; retire after testing another auth method, remove helper copy, regenerate only by quorum. - cli (REASONED; docs): Credential prompts, token environment/helper selection and protected stdin/files avoid credential arguments. - policy (REASONED; docs): Deny-by-default non-root policies, additive grants, KV v2 data path, no writes/listing/sudo, bounded TTL and service-token tradeoffs. - role (DEMONSTRATED; docs, run): Every displayed AppRole role parameter is returned by role inspection; example budgets are not defaults. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior. - delivery (REASONED; docs): Delivery policy requires wrapping TTL 1s to 60s; protect transfer and lookup JSON, check path/time/original TTL before unwrap. - identity (REASONED; docs, jwt-min): Existing workload identity is preferred; Kubernetes names/namespaces and JWT identity/audience binding apply, including the stated 1.17 qualification. - bootstrap (REASONED; docs): Unauthenticated health, seal-status, init and leader paths need network isolation; policies cannot close bootstrap paths. - storage (REASONED; docs): Unprivileged process, protected snapshots/storage, no swap/core dumps; disable_mlock is Raft-specific. - bind-test (DEMONSTRATED; docs, run): Loopback socket inventory and TLS/plaintext discrimination; extra unintended loopback listener detected. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior. - cluster-address (DEMONSTRATED; docs, run): cluster_address defaults to the address port + 1; the follow-up loopback run observed 127.0.0.2:8200 and :8201 for a second listener. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior. - bind-limits (REASONED; docs): Wildcard binds, ss as root and actual firewall isolation remain unobserved. - health-test (DEMONSTRATED; docs, run): Single-node health 501/503/200 and status sealed/unsealed exits; stopped-server failure is inconclusive. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior. - health-ha (REASONED; docs): Standby, removed-node and Enterprise health/status codes and query overrides are documentation reasoning. - audit-test (DEMONSTRATED; docs, run): Empty/one/two file-device inspection and request-ID correlation demonstrate local delivery. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior. - audit-remote (REASONED; docs): Independent failure-domain audit delivery remains unobserved. - root-test (DEMONSTRATED; docs, run): Root self-revocation rejects lookups and revokes non-orphan children; independent logins continue. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior. - acl-test (DEMONSTRATED; docs, run): Own-secret, cross-application denial and collector isolation distinguish narrow from broad policies with fixture controls. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - monitor-test (DEMONSTRATED; docs, run): Active-node anonymous metrics/pprof reject while authorized identities succeed; enabling anonymous access exposes both. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - monitor-ha (REASONED; docs, ha, forward): Root namespace, active-node targeting, separate profiling policy, HA redirects/local-only forwarding and every-node checks. - secretid (DEMONSTRATED; docs, run): SecretID one-use and TTL/tidy discrimination use fresh positive controls; expired IDs can work until tidy. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - cidr (DEMONSTRATED; docs, run): SecretID and token source restrictions discriminate on two loopback addresses; curl --interface replaces CLI source selection. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - token (DEMONSTRATED; docs, run): Service/batch prefixes, TTL, 100-use limit and shortened renewal-ceiling experiment have exposed/fixed controls. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - wrap-test (DEMONSTRATED; docs, run): No-wrap/61s issuance refused, 60s succeeds; unrestricted delivery policy supplies exposed control. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1 and which ended with the positive control's status; the current form has no recorded run. - lookup-test (DEMONSTRATED; docs, run): Lookup returns path/time/original TTL; consumed and expired tokens fail. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - recipient (REASONED; docs): Receiving application must reject an unexpected creation path; its implementation is not demonstrated. - unwrap-test (DEMONSTRATED; docs, run): First unwrap succeeds, reuse fails, fresh control succeeds. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - connection-test (DEMONSTRATED; docs, curl, run): Loopback reachable/refused shapes distinguish TCP connection from TLS/HTTP success, not real external isolation. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run. - external (REASONED; docs, curl): Test actual 8200/8201 destinations from permitted and forbidden sources; routing, local errors and unavailable controls are inconclusive. - wrapping-history (REASONED; wrap-history): Some wrapping-token features date from 0.8; do not generalize current behavior to earlier versions. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "8f0a41b3e9185d03f4f64224707689a0e3f28e0441e70706304a589439d1a27c", "components": { "run": { "name": "Vault loopback run", "basis": "2.1.1", "sources": { "sd936be5e4c41": "https://github.com/hashicorp/vault/blob/v2.1.1/builtin/credential/approle/backend.go" } }, "ha": { "name": "Vault handler source", "basis": "v1.21.0", "sources": { "s8c81f240066a": "https://raw.githubusercontent.com/hashicorp/vault/v1.21.0/http/handler.go" } }, "forward": { "name": "Vault forwarding source", "basis": "v1.20.0", "sources": { "sfdb628929b01": "https://raw.githubusercontent.com/hashicorp/vault/v1.20.0/http/logical.go" } }, "jwt-min": { "name": "Vault JWT audience minimum", "basis": "1.17", "sources": { "sf213104862ac": "https://developer.hashicorp.com/vault/docs/auth/jwt" } }, "wrap-history": { "name": "Vault historical wrapping qualification", "basis": "0.8", "sources": { "sa79f2fd07092": "https://developer.hashicorp.com/vault/docs/concepts/response-wrapping" } }, "docs": { "name": "Vault documentation", "basis": "unknown", "sources": { "sbffd38b4758e": "https://developer.hashicorp.com/vault/docs/concepts/dev-server", "se38e85360763": "https://developer.hashicorp.com/vault/docs/commands/server", "sbfb9c60f21c1": "https://developer.hashicorp.com/vault/docs/configuration/listener/tcp", "s60d6655e9803": "https://developer.hashicorp.com/vault/docs/configuration/storage/raft", "sf7bd8360ca01": "https://developer.hashicorp.com/vault/docs/concepts/seal", "s847725e335a1": "https://developer.hashicorp.com/vault/docs/commands/operator/init", "s6a139716500f": "https://developer.hashicorp.com/vault/docs/audit", "s0f36ae51e56b": "https://developer.hashicorp.com/vault/docs/concepts/production-hardening", "sa1a925943f6a": "https://developer.hashicorp.com/vault/api-docs/system/health", "sfbfbd7179dca": "https://developer.hashicorp.com/vault/docs/concepts/policies", "s85efb0c5061e": "https://developer.hashicorp.com/vault/docs/auth/login-mfa", "sf1aad6c3d00d": "https://developer.hashicorp.com/vault/docs/configuration", "sb271b7950e37": "https://developer.hashicorp.com/vault/docs/concepts/ha", "s91710ca3e5c1": "https://developer.hashicorp.com/vault/docs/secrets/kmip", "sd7d57255b0b5": "https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent", "s8320687a6d11": "https://developer.hashicorp.com/vault/docs/agent-and-proxy/proxy", "sbf26b6d0ef76": "https://developer.hashicorp.com/vault/docs/configuration/seal/awskms", "s3036564c8eb4": "https://developer.hashicorp.com/vault/docs/configuration/seal/transit", "s6a0b17f5713a": "https://developer.hashicorp.com/vault/docs/commands/operator/unseal", "s89677a91be0d": "https://developer.hashicorp.com/vault/docs/commands/operator/rekey", "scfd7c15adedf": "https://developer.hashicorp.com/vault/docs/audit/file", "s32194cc039b1": "https://developer.hashicorp.com/vault/docs/audit/syslog", "s6a89353a6c03": "https://developer.hashicorp.com/vault/api-docs/system/audit", "sd66fc0bcfb15": "https://developer.hashicorp.com/vault/docs/audit/schema", "s4bc92d253ba6": "https://developer.hashicorp.com/vault/docs/commands/audit/list", "s449e83b31426": "https://developer.hashicorp.com/vault/docs/concepts/tokens", "s923f5369652b": "https://developer.hashicorp.com/vault/docs/commands/token/revoke", "s426748068836": "https://developer.hashicorp.com/vault/docs/commands/operator/generate-root", "sab78482b691b": "https://developer.hashicorp.com/vault/docs/commands", "s0f2cf5468884": "https://developer.hashicorp.com/vault/docs/commands/login", "s607f5b7aafbc": "https://developer.hashicorp.com/vault/docs/commands/policy/write", "sf7640abb07c2": "https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2", "s745b51dd1719": "https://developer.hashicorp.com/vault/docs/auth/approle", "s553adec7c375": "https://developer.hashicorp.com/vault/api-docs/auth/approle", "s3cf51daf4c81": "https://developer.hashicorp.com/vault/docs/auth/approle/approle-pattern", "scfa855ec9d87": "https://developer.hashicorp.com/vault/api-docs/auth/kubernetes", "sf213104862ac": "https://developer.hashicorp.com/vault/docs/auth/jwt", "sa79f2fd07092": "https://developer.hashicorp.com/vault/docs/concepts/response-wrapping", "sca6c552728d7": "https://developer.hashicorp.com/vault/api-docs/system/wrapping-lookup", "s17fb33326e35": "https://developer.hashicorp.com/vault/docs/commands/write", "sd1209454cb98": "https://developer.hashicorp.com/vault/docs/commands/unwrap", "s1437757f255b": "https://developer.hashicorp.com/vault/api-docs/system/metrics", "s84bf68313c5d": "https://developer.hashicorp.com/vault/api-docs/system/pprof", "sabe0751ee280": "https://developer.hashicorp.com/vault/api-docs/system/init", "s51416363da81": "https://developer.hashicorp.com/vault/api-docs/system/leader", "s1e239e51209c": "https://developer.hashicorp.com/vault/api-docs/system/seal-status", "sd032f083ae99": "https://developer.hashicorp.com/vault/docs/commands/operator/raft", "s70ec02f4b4d7": "https://developer.hashicorp.com/vault/docs/commands/status", "sdb9905c2f649": "https://developer.hashicorp.com/vault/docs/commands/token/lookup" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "dev": {"text": "Dev modes, token-helper storage and production config-file startup.", "components": ["docs"], "sources": ["docs:sbffd38b4758e", "docs:se38e85360763"], "status": "REASONED"}, "listeners": {"text": "API/UI 8200, cluster 8201, private bind, advertised addresses and cluster TLS; optional KMIP, Agent and Proxy listeners.", "components": ["docs"], "sources": ["docs:sbfb9c60f21c1", "docs:sf1aad6c3d00d", "docs:sb271b7950e37", "docs:s91710ca3e5c1", "docs:sd7d57255b0b5", "docs:s8320687a6d11"], "status": "REASONED"}, "mfa": {"text": "Login MFA belongs on human auth methods; token auth cannot use it.", "components": ["docs"], "sources": ["docs:s85efb0c5061e"], "status": "REASONED"}, "raft-tls": {"text": "Raft node fields, UI disabled, TLS certificate/key/minimum, anonymous monitoring disabled; address/path substitutions only in the recorded run.", "components": ["docs", "run"], "sources": ["docs:sbfb9c60f21c1", "docs:s60d6655e9803", "docs:sf1aad6c3d00d", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "a single Raft node running the section 3 configuration unchanged apart from addresses and paths"}, "defaults": {"text": "Listener loopback, TLS enabled, UI disabled and anonymous metrics/profiling disabled are documentation defaults.", "components": ["docs"], "sources": ["docs:sbfb9c60f21c1", "docs:sf1aad6c3d00d"], "status": "REASONED"}, "seal": {"text": "Shamir init/unseal prompts, auto-unseal/recovery, Community Transit and Enterprise seal wrapping/HA; key loss and rekey custody.", "components": ["docs"], "sources": ["docs:sf7bd8360ca01", "docs:s847725e335a1", "docs:sbf26b6d0ef76", "docs:s3036564c8eb4", "docs:s6a0b17f5713a", "docs:s89677a91be0d"], "status": "REASONED"}, "audit": {"text": "Enable file and independent syslog auditing, fail-closed availability, HMAC defaults, log_raw, exemptions and SIGHUP rotation.", "components": ["docs"], "sources": ["docs:s6a139716500f", "docs:scfd7c15adedf", "docs:s32194cc039b1", "docs:s6a89353a6c03"], "status": "REASONED"}, "root": {"text": "Bootstrap root has unlimited access and no expiry; retire after testing another auth method, remove helper copy, regenerate only by quorum.", "components": ["docs"], "sources": ["docs:s449e83b31426", "docs:s923f5369652b", "docs:s426748068836"], "status": "REASONED"}, "cli": {"text": "Credential prompts, token environment/helper selection and protected stdin/files avoid credential arguments.", "components": ["docs"], "sources": ["docs:s6a0b17f5713a", "docs:sab78482b691b", "docs:s0f2cf5468884", "docs:s17fb33326e35", "docs:sd1209454cb98"], "status": "REASONED"}, "policy": {"text": "Deny-by-default non-root policies, additive grants, KV v2 data path, no writes/listing/sudo, bounded TTL and service-token tradeoffs.", "components": ["docs"], "sources": ["docs:sfbfbd7179dca", "docs:s449e83b31426", "docs:s607f5b7aafbc", "docs:sf7640abb07c2", "docs:s745b51dd1719"], "status": "REASONED"}, "role": {"text": "Every displayed AppRole role parameter is returned by role inspection; example budgets are not defaults. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior.", "components": ["docs", "run"], "sources": ["docs:s553adec7c375", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "The role read returned every section 7 value, with durations in seconds.", "verify": [1]}, "delivery": {"text": "Delivery policy requires wrapping TTL 1s to 60s; protect transfer and lookup JSON, check path/time/original TTL before unwrap.", "components": ["docs"], "sources": ["docs:sfbfbd7179dca", "docs:sa79f2fd07092", "docs:sca6c552728d7", "docs:s17fb33326e35", "docs:sd1209454cb98"], "status": "REASONED"}, "identity": {"text": "Existing workload identity is preferred; Kubernetes names/namespaces and JWT identity/audience binding apply, including the stated 1.17 qualification.", "components": ["docs", "jwt-min"], "sources": ["docs:s3cf51daf4c81", "docs:scfa855ec9d87", "docs:sf213104862ac", "jwt-min:sf213104862ac"], "status": "REASONED"}, "bootstrap": {"text": "Unauthenticated health, seal-status, init and leader paths need network isolation; policies cannot close bootstrap paths.", "components": ["docs"], "sources": ["docs:sa1a925943f6a", "docs:sabe0751ee280", "docs:s51416363da81", "docs:s1e239e51209c"], "status": "REASONED"}, "storage": {"text": "Unprivileged process, protected snapshots/storage, no swap/core dumps; disable_mlock is Raft-specific.", "components": ["docs"], "sources": ["docs:s60d6655e9803", "docs:s0f36ae51e56b", "docs:sf1aad6c3d00d", "docs:sd032f083ae99"], "status": "REASONED"}, "bind-test": {"text": "Loopback socket inventory and TLS/plaintext discrimination; extra unintended loopback listener detected. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior.", "components": ["docs", "run"], "sources": ["docs:sbfb9c60f21c1", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "a plaintext request got `400` \"Client sent an HTTP request to an HTTPS server\"", "verify": [1]}, "cluster-address": {"text": "cluster_address defaults to the address port + 1; the follow-up loopback run observed 127.0.0.2:8200 and :8201 for a second listener. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior.", "components": ["docs", "run"], "sources": ["docs:sbfb9c60f21c1", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "verify": [1], "evidence": "socket table also showed `127.0.0.2:8200` and `127.0.0.2:8201`, an unintended listener with its own cluster port, because `cluster_address` defaults to one port above `address`."}, "bind-limits": {"text": "Wildcard binds, ss as root and actual firewall isolation remain unobserved.", "components": ["docs"], "sources": ["docs:sbfb9c60f21c1", "docs:s0f36ae51e56b"], "status": "REASONED", "verify": [2, 11]}, "health-test": {"text": "Single-node health 501/503/200 and status sealed/unsealed exits; stopped-server failure is inconclusive. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior.", "components": ["docs", "run"], "sources": ["docs:sa1a925943f6a", "docs:s70ec02f4b4d7", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "Health returned `501` before initialization, `503` while sealed (status exit code `2`) and `200` once unsealed (exit code `0`)", "verify": [1]}, "health-ha": {"text": "Standby, removed-node and Enterprise health/status codes and query overrides are documentation reasoning.", "components": ["docs"], "sources": ["docs:sa1a925943f6a", "docs:s70ec02f4b4d7"], "status": "REASONED", "verify": [2]}, "audit-test": {"text": "Empty/one/two file-device inspection and request-ID correlation demonstrate local delivery. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior.", "components": ["docs", "run"], "sources": ["docs:sd66fc0bcfb15", "docs:s4bc92d253ba6", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "with two file audit devices, one request's `request_id` appeared twice in each file", "verify": [1]}, "audit-remote": {"text": "Independent failure-domain audit delivery remains unobserved.", "components": ["docs"], "sources": ["docs:s6a139716500f", "docs:s32194cc039b1"], "status": "REASONED", "verify": [2]}, "root-test": {"text": "Root self-revocation rejects lookups and revokes non-orphan children; independent logins continue. The recorded live loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, which do not establish live Vault behavior.", "components": ["docs", "run"], "sources": ["docs:s449e83b31426", "docs:s923f5369652b", "docs:sdb9905c2f649", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "A scoped admin created as a child of the root token lost access with it, while an orphan token and a userpass login kept working.", "verify": [1]}, "acl-test": {"text": "Own-secret, cross-application denial and collector isolation distinguish narrow from broad policies with fixture controls. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:sfbfbd7179dca", "docs:sf7640abb07c2", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "the payments token `200` on its own secret and `403` on the inventory secret, where the fixture reader got `200`", "verify": [3]}, "monitor-test": {"text": "Active-node anonymous metrics/pprof reject while authorized identities succeed; enabling anonymous access exposes both. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:sbfb9c60f21c1", "docs:s1437757f255b", "docs:s84bf68313c5d", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "anonymous requests got `200` on `sys/metrics` and `sys/pprof/`", "verify": [3]}, "monitor-ha": {"text": "Root namespace, active-node targeting, separate profiling policy, HA redirects/local-only forwarding and every-node checks.", "components": ["docs", "ha", "forward"], "sources": ["docs:s1437757f255b", "docs:s84bf68313c5d", "ha:s8c81f240066a", "forward:sfdb628929b01"], "status": "REASONED", "verify": [4]}, "secretid": {"text": "SecretID one-use and TTL/tidy discrimination use fresh positive controls; expired IDs can work until tidy. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:s553adec7c375", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "SecretIDs 10 seconds past their TTL logged in, while one 70 seconds past it was refused", "verify": [5]}, "cidr": {"text": "SecretID and token source restrictions discriminate on two loopback addresses; curl --interface replaces CLI source selection. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:s553adec7c375", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "a login from 127.0.0.1 succeeded, and its token read the payments secret with `200` from 127.0.0.1 and `403` from 127.0.0.2", "verify": [5]}, "token": {"text": "Service/batch prefixes, TTL, 100-use limit and shortened renewal-ceiling experiment have exposed/fixed controls. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:s449e83b31426", "docs:s553adec7c375", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "100 reads returned `200` before read 101 got `403`; with `token_num_uses=0`, all 101 reads returned `200`.", "verify": [5]}, "wrap-test": {"text": "No-wrap/61s issuance refused, 60s succeeds; unrestricted delivery policy supplies exposed control. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1 and which ended with the positive control's status; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:sfbfbd7179dca", "docs:sa79f2fd07092", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "the same kind of identity's unwrapped request returned the SecretID fields and a sixty-one-second request succeeded", "verify": [6]}, "lookup-test": {"text": "Lookup returns path/time/original TTL; consumed and expired tokens fail. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:sca6c552728d7", "docs:s17fb33326e35", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "A wrapping token looked up three seconds after its two-second TTL failed the same way.", "verify": [7]}, "recipient": {"text": "Receiving application must reject an unexpected creation path; its implementation is not demonstrated.", "components": ["docs"], "sources": ["docs:sa79f2fd07092", "docs:sca6c552728d7"], "status": "REASONED", "verify": [8]}, "unwrap-test": {"text": "First unwrap succeeds, reuse fails, fresh control succeeds. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "run"], "sources": ["docs:sa79f2fd07092", "docs:sd1209454cb98", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "the first unwrap returned the SecretID fields, the second failed with `wrapping token is not valid or does not exist`, and a fresh wrapping token unwrapped.", "verify": [9]}, "connection-test": {"text": "Loopback reachable/refused shapes distinguish TCP connection from TLS/HTTP success, not real external isolation. The recorded loopback runs used an earlier form of the block, whose guard refusals exited with status 1; the current form has no recorded run.", "components": ["docs", "curl", "run"], "sources": ["docs:s1e239e51209c", "curl:s2b2686afaf41", "run:sd936be5e4c41"], "status": "DEMONSTRATED", "evidence": "against 127.0.0.2, where nothing listens, `time_connect=0.000000` and `Could not connect to server` (exit `7`)", "verify": [10]}, "external": {"text": "Test actual 8200/8201 destinations from permitted and forbidden sources; routing, local errors and unavailable controls are inconclusive.", "components": ["docs", "curl"], "sources": ["docs:s0f36ae51e56b", "docs:sb271b7950e37", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [11]}, "wrapping-history": {"text": "Some wrapping-token features date from 0.8; do not generalize current behavior to earlier versions.", "components": ["wrap-history"], "sources": ["wrap-history:sa79f2fd07092"], "status": "REASONED"} } } ```` ====================================================================== ==> identity-providers.md ====================================================================== # Identity providers: hosted login and MFA for your app and your team An identity provider runs sign-in for your team or your app's users, as a hosted service or software you run yourself. This guide helps your AI assistant choose one securely, including workforce, customer, developer and self-hosted options, which free tiers include MFA, and an allowlist check after login. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/identity-providers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Microsoft Entra documentation (no recorded version), Authelia v4.39.28, oauth2-proxy v7.15.6, authentik source version/2026.8.3, Keycloak documentation 26.8.2, Ory Kratos v26.2.0, and Ory Hydra v26.2.0; 11 further sources are pinned to a commit and 14 carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 48 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) says to prefer SSO or OIDC over local accounts and to enforce MFA at the identity provider. This guide names the providers, states what their free tiers include, and says who each fits. Wiring instructions live in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md); login placed in front of an app without code changes lives in [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md) and [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Every stated tier and price below was read from the vendor's pricing page in September 2026 and will change; verify against the linked source before relying on it. Prices are USD list prices. ## 1. Decide which kind of identity you need - **Workforce identity**: your team signs in with the organization's existing accounts (Google Workspace, Microsoft Entra ID, Okta). Use it for admin panels, dashboards, internal tools, and anything only staff should reach. If your organization already runs one of these, use it; do not buy a second directory. - **Customer identity (CIAM)**: your application's own users sign up and sign in. Use a hosted provider rather than writing password storage, MFA, recovery, and rate limiting yourself. - **Developer identity**: GitHub (or GitLab) login gates a tool for developers, optionally restricted to an organization. Often the simplest correct choice for a solo or small project. A login proves who the person is. **It does not decide whether they may use your app.** This guide's advice is to enforce application authorization after login. After any of the providers below, your application (or the fronting layer) still checks that the identity is on an allowlist: a tenant, a hosted domain, a group, or an explicit list of users. Accepting every Google or Microsoft account as "staff" is the recurring mistake; [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) covers the check. ## 2. Workforce providers | Provider | Free tier and MFA facts (September 2026) | Fit | |---|---|---| | Microsoft Entra ID | Free tier, bundled with Azure and Microsoft 365 subscriptions, includes MFA and unlimited SSO. Security defaults require every user to register MFA, enforce it for administrators, and prompt other users when Microsoft judges it necessary. P1 at $7 per user per month (annual commitment) adds Conditional Access: require MFA for included users across all resources with an all-users policy that initially runs in Report-only mode and excludes emergency-access accounts; to require repeated reauthentication, also set the separate sign-in-frequency control to Every time, subject to a five-minute prompt tolerance. These controls do not establish a fresh MFA challenge on every resource access. P2 at $10 adds risk-based policies. | Any team already on Microsoft 365. | | Google Workspace / Cloud Identity | This guide found no Google page stating Sign in with Google (OIDC) pricing (checked October 2026). Google Workspace super administrators can enforce 2-Step Verification in the Admin console for all users, or for a selected organizational unit or configuration group; this guide's assessment is that Cloud Identity uses the same Admin console setting; the cited Workspace page does not show it. Cloud Identity Free exists; its default licence count was not read from a Google page for this guide, so check the source. | Any team already on Workspace. | | Okta Workforce Identity | Okta Verify supports push, TOTP, and FastPass. A $1,500 annual contract minimum applies and there is no free production tier. | Only when your organization already runs Okta. Not a purchase for a small project. | | JumpCloud, OneLogin, Ping Identity | Workforce directories in the same class. Tiers not verified for this guide. | Existing enterprise estates only. | ## 3. Customer identity providers | Provider | Free tier and MFA facts (September 2026) | Notes | |---|---|---| | Microsoft Entra External ID | The Basic tier is free for the first 50,000 monthly active users (MAU). SMS MFA and machine-to-machine (client credentials) authentication are billed per transaction as add-ons. | This guide's assessment is that the external tenant should be kept separate from the workforce tenant; the cited External ID billing page describes workforce and external tenants as distinct tenant configurations but does not say to keep them separate. | | Google Identity Platform / Firebase Authentication | Email, password, and social sign-in are free in base Firebase Authentication. Upgrading to Authentication with Identity Platform unlocks TOTP MFA and, on the pay-as-you-go Blaze plan, a free tier of 50,000 MAU (50 SAML/OIDC MAU); the no-billing Spark plan is capped much lower, at 3,000 daily active users (2 for SAML/OIDC), so confirm which plan applies before relying on the MAU figure. SMS is billed per message. | Cloud Firestore Security Rules evaluate mobile/web client library requests after authentication; server client libraries bypass them and use IAM. Supabase table grants determine permitted operations and RLS policies determine accessible rows; keep the Supabase `service_role` server-side because it bypasses RLS: [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md). | | Auth0 (Okta) | Free plan: 25,000 MAU, passkeys, and unlimited social connections. **No MFA factors on Free**; MFA factors start with the paid Essentials plan, and Adaptive MFA requires an Enterprise plan plus its add-on. B2C Essentials is $35 per month for 500 MAU. | At the time of writing, Free includes passkeys, which with user verification are multifactor; what Free lacks is Auth0's separately licensed MFA policy (enforced factors and step-up). Enabling passkeys leaves password sign-in available while the database connection permits password authentication. The cited rolling passkey and archived MFA pages do not independently establish Free-tier step-up entitlement. | | Amazon Cognito | Lite and Essentials tiers: 10,000 MAU free for direct sign-in and 50 MAU free for SAML/OIDC federation (Lite pools created on or before 10:00 a.m. Pacific Time on 22 November 2024, and qualifying new Lite pools in those existing accounts, keep a grandfathered 50,000 MAU free tier). Essentials is $0.015 per MAU beyond that and includes passkeys; Lite does not. SMS goes through SNS and is billed separately. | Pairs with Application Load Balancer authentication ([cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md)). | | Clerk | Hobby plan: 50,000 monthly retained users (a user who returns at least 24 hours after signing up; not the same unit as MAU). **MFA is Pro and above** at $25 per month, or $20 billed annually. | "Free plan" does not mean MFA. | | WorkOS AuthKit | User management free for the first 1,000,000 MAU. Enterprise SSO and Directory Sync are separate products at $125 per connection per month for the first 15 connections each. | Good app login; the per-connection fees are for selling to enterprises. | | Supabase Auth | Free plan: 50,000 MAU with TOTP MFA included. Phone MFA is a paid add-on at $75 per month for the first project. | This guide's assessment is that TOTP enrolment alone does not enforce MFA. Require the `aal2` level in your authorization policies ([firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md)). | | Stytch, Descope, Kinde, Frontegg, Logto, Hanko, Zitadel, Ory, SuperTokens, FusionAuth | Same class. Logto, Hanko, Zitadel, Ory, and SuperTokens are open source with hosted tiers; FusionAuth is proprietary with a free community edition. Tiers not verified for this guide. | Check the current pricing page before choosing. | ## 4. Developer identity - **GitHub OAuth**: an OAuth app gives login for any GitHub user; restrict to members of your organization or team in the app (or with oauth2-proxy, which has a GitHub provider with org and team restrictions). GitHub Actions OIDC gives workflows short-lived cloud-provider tokens; this guide's assessment is that it is a different mechanism from human OAuth login, covered in [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md). - **AWS IAM Identity Center**: the free workforce directory for your team's own AWS console and CLI access. It is not a general OIDC provider for Application Load Balancer authentication; for app login on AWS pair the ALB with Cognito or an OIDC provider from section 3 ([cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md)). ## 5. Self-hosted identity providers Keycloak, authentik, Zitadel, and Authelia ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)) have documented OTP, authenticator validation, or OIDC capabilities. Keycloak documents OTP policy, QR-code setup, and time-based and counter-based generators; authentik's validation stage rejects a response unless the login's device challenges include a compatible class, checking code classes as one group and WebAuthn and Duo each by class. Ory Kratos provides identity and self-service login with TOTP, WebAuthn, and lookup-secret strategies; Ory Hydra is an OAuth 2.0 and OpenID Connect server that connects to an existing identity provider through a login and consent app; Ory Network is hosted. Self-hosted, these options give you OIDC and MFA without a vendor (this guide's assessment where the cited ranges do not show it). Guide advice: require MFA in each protected login or authentication flow and test that first-factor-only access is denied. Depending on severity, Keycloak may fix vulnerabilities in the current major.minor release, or address lower severity vulnerabilities or hardening in the following major.minor release. It encourages those unable to upgrade regularly to consider Red Hat build of Keycloak, which offers long-term support for specific versions. Keycloak documents an upgrade order and limits to realm export as backup; authentik publishes security advisories and documents an upgrade sequence and PostgreSQL backup; Ory says Apache 2.0 security patches are only for its latest release, and Hydra's migration guide calls for a database backup before a historical SQL migration. This guide cites no Kratos upgrade or backup procedure and no Zitadel or Authelia maintenance source. Guide advice: for a self-hosted deployment, patch, back up, and keep the identity service highly available; this guide's assessment is that a compromised or down identity provider takes every dependent app with it. For a small project a hosted tier above is usually the smaller correct change. ## 6. MFA products - **Duo**: the Duo Free edition covers up to 10 users with MFA and the Duo Mobile app. Its Authentication Proxy speaks RADIUS and LDAP, which retrofits MFA onto VPNs and services with RADIUS support; for mandatory MFA set `failmode=secure`, since the default `safe` admits a primary-authenticated user when Duo is unreachable, and keep a separately controlled emergency path. - **Provider-native authenticators**: Microsoft Authenticator (Entra), Okta Verify (Okta), Google prompts (Google Account sign-in on a signed-in phone); a prompt is a second factor when used in addition to the password for 2-Step Verification, and can also replace a password. Guide assessment: an RFC 6238 authenticator app can be used where the provider offers TOTP, if its algorithm, digits and period match the provider's settings. The cited cases cover Entra OATH TOTP, Okta Google Authenticator/Custom TOTP, and Google Authenticator or another app that creates one-time verification codes for Google Account sign-in; Google names no other app. Compatibility with arbitrary third-party authenticator apps and with other providers, beyond these cases, is this guide's assessment, not a cited statement. There are so many providers that listing them all would be more effort than this project wants to put in; anyone who wants a particular provider listed can request it by opening a GitHub issue or sending a pull request, as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. - Entra permits third-party OATH TOTP apps using an Entra-generated seed and programmable hardware tokens using a software setup seed; its hardware preview supports SHA-1 and SHA-256 with 30- or 60-second periods, while HOTP is unsupported. The cited page does not specify software-app algorithms, digits or periods. Entra's Authenticator page says Microsoft Authenticator can be used as a software token that generates an OATH verification code. Entra's token-management page says third-party software OATH tokens are enabled for sign-in by default and an Authentication Policy Administrator can disable them to prevent users from signing in with a one-time password from a third-party Identity Provider. Neither page specifies software-app algorithms, digits or periods either. The token-management page's `hmacsha1` and 30-second values appear only in hardware-token examples. The OATH-token page's current 6-digit code for activation appears only in its hardware preview refresh's role table; this guide reads it as a hardware-token value, not a software-app digit count. - In Okta's Classic Engine documentation, once an admin adds it as a factor, Okta accepts Google Authenticator's six-digit TOTP as a second factor; this guide cites no Identity Engine Google Authenticator page. In the same documentation, if Custom TOTP is enabled for the org, it uses per-user seeds, with HMAC and shared-secret encoding matching the implementation and a configured time step, and supports only standard OTP tokens and one enrolment per user. The same documentation says that, if Custom TOTP is enabled for the org, the Custom TOTP factor and its Factor Profile ID are created in the Admin Console, and each user is enrolled through the Okta Factors API by passing factorProfileId and sharedSecret for their token. Okta's Identity Engine custom OTP page says Okta generates an authenticator ID, entered as factorProfileId when enrolling users through the Okta Factors API, that a user can be enrolled in only one custom OTP authenticator instance at a time, and that the authenticator is added to a new or existing authenticator enrollment policy. Both Okta pages list a length setting, named TOTP length on the Classic Engine page and OTP length (the number of digits) on the Identity Engine page. Both also list HMAC algorithm, time step, clock drift interval and shared-secret encoding settings and say to select the HMAC and encoding algorithms that match the implementation; neither enumerates selectable HMAC, encoding or length values. The Identity Engine page defines the time step in seconds. The Classic Engine page gives the acceptance window only by example: with a 15-second time step and a clock drift interval of 3, Okta accepts passcodes 45 seconds before or after a user enters their passcode. The Identity Engine page states a rule, multiply the time step by the clock drift interval, with its own example: a 60-second time step and a clock drift interval of five seconds give 300 seconds before or after the passcode entry timestamp. That page describes the clock drift interval in seconds, although its rule uses the interval as a multiplier. These windows are the pages' statements; this guide has not observed Okta's behaviour. Okta's Classic Engine Google Authenticator page states a time-based, six-digit code but no period or algorithm, and names no other app. The pages do not show arbitrary app compatibility. - With 2-Step Verification set up, Google Account sign-in can use Google Authenticator codes, including offline generation. Google's Turn on 2-Step Verification page says a user without an internet connection or mobile service can set up Google Authenticator or another app that creates one-time verification codes, and names no other app. Neither page, nor the Workspace deployment page, specifies algorithms, digits or periods for these codes; the 2-Step Verification page's 6-digit and 8-digit figures are for text-message or call codes and backup codes. Google Authenticator's Key-Uri-Format wiki gives the app's `otpauth` URI defaults as SHA1, 6 digits and a 30-second period and says, in notes unchanged since 2018, that the app's implementations ignore the algorithm and period parameters, and the digits parameter on Android and Blackberry. This guide's inference from that app documentation, not a Google statement about Google Account, is that Google Account authenticator codes use SHA1, six digits and 30 seconds. - **Hardware keys and passkeys**: YubiKey and other FIDO2 keys, and platform passkeys, are the phishing-resistant factor. Microsoft Entra, Auth0, Okta, Keycloak and Zitadel document passkey or WebAuthn support; check each provider's tier before relying on it, and require phishing-resistant hardware keys or platform passkeys for administrators ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). The Auth0 pricing page lists passkeys on Free (September 2026), and Okta's passkey guide uses an Integrator Free Plan org; the cited pages do not establish production-tier availability for the five providers. ## 7. Poor fits for a small project Zscaler Private Access, HashiCorp Boundary, Ping Identity, OneLogin, and Okta as a new purchase are enterprise products with enterprise pricing and sales-led onboarding. They are listed so an assistant recognizes them when a customer already has them, not as recommendations. ## Verify - REASONED: paired administrator MFA access, per the guide's cited Microsoft Entra security defaults and Conditional Access policy and Supabase MFA enforcement. No provider tenant, administrator test account or protected admin function is available; this item supplies no local command or configuration to execute. No login was observed. Exposed access permits a password-only or social-login-only session; fixed access denies that same admin function until MFA completes and then permits it. With only the password (or only a social login) an administrator cannot reach an admin function once MFA is required at the provider; test with a fresh session, and confirm the same admin function DOES succeed once MFA is completed, so the denial is MFA enforcement and not an unrelated failure. - REASONED: allowed and excluded account comparison, per the guide's cited oauth2-proxy v7.15.6 GitHub organization/team restrictions and username exception, Google ID-token hosted-domain claim and Microsoft Entra tenant and application assignment documentation; group-based assignment requires Entra P1 or P2 at the time of writing. No connected application or allowed/excluded provider accounts are available; this item supplies no local command or configuration to execute. No login was observed. Exposed authorization admits a valid outside account; fixed authorization rejects it while admitting an allowed account. A valid account from outside your allowlist (a personal Gmail, a different tenant, a non-member GitHub user) is rejected after login, not admitted, while an allowed account does reach the app, so the rejection is the allowlist and not a broken login path. - REASONED: correlation of sign-in assurance and application authorization, per the guide's cited Microsoft Entra sign-in logs and Microsoft Entra security defaults. No provider test events or application decision logs are available; this item supplies no local command or configuration to execute. No logs were inspected. Exposed decisions admit insufficient assurance or an outside account; fixed decisions require the factor and deny the outside account, corroborated with sign-in records rather than inferred from login success alone. Correlate each test against the provider's sign-in log (in Microsoft Entra this is Sign-in logs, not Audit logs, which record configuration changes) and your application's authorization decision: confirm the required factor or assurance level was met and that the outside account was denied. A log entry alone shows only that a sign-in happened, not that MFA or the allowlist was enforced. ## Sources (checked September 2026) - Microsoft Entra ID pricing (Free, P1, P2, and External ID free up to the first 50,000 monthly active usages; checked October 2026): https://www.microsoft.com/en-us/security/microsoft-entra-pricing - Microsoft Entra External ID pricing (Basic tier, first 50,000 monthly active users at no cost; checked October 2026): https://www.microsoft.com/en-us/security/pricing/microsoft-entra-external-id/ - Microsoft Entra External ID billing model: https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing - Microsoft Entra security defaults: https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults - Google Cloud Identity pricing: https://cloud.google.com/identity/pricing - Google Workspace 2-Step Verification deployment (checked October 2026): https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification - Google Account prompts (checked October 2026): https://support.google.com/accounts/answer/7026266 - Firebase pricing (Authentication and Identity Platform allowances): https://firebase.google.com/pricing - Okta pricing: https://www.okta.com/pricing/ - Auth0 pricing: https://auth0.com/pricing - Amazon Cognito pricing: https://aws.amazon.com/cognito/pricing/ - Clerk pricing: https://clerk.com/pricing - WorkOS pricing: https://workos.com/pricing - Supabase pricing: https://supabase.com/pricing - Duo editions and pricing: https://duo.com/editions-and-pricing - GitHub OAuth apps: https://docs.github.com/en/apps/oauth-apps - AWS IAM Identity Center: https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html ; its OIDC service (AWS CLI and native clients): https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html - Application Load Balancer user authentication (OIDC-compliant IdP or Cognito user pool): https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html - Firebase Authentication limits (Spark-plan daily-active-user caps, distinct from the Blaze MAU free tier): https://firebase.google.com/docs/auth - Duo Authentication Proxy reference (`failmode` default `safe`): https://duo.com/docs/authproxy-reference - Auth0 Adaptive MFA (requires an Enterprise plan plus the add-on): https://auth0.com/docs/secure/multi-factor-authentication/adaptive-mfa - Microsoft Entra sign-in logs (sign-ins live in Sign-in logs; Audit logs record configuration changes): https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-sign-ins - Authelia v4.39.28 second factors (TOTP, WebAuthn and Duo push; checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/introduction.md#L23-L31 - Authelia v4.39.28 OpenID Connect provider (open beta; checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/identity-providers/openid-connect/provider.md#L25-L28 - Supabase MFA enforcement (pinned commit 10c7379c970ae4d2f30f3158194061cf5ffb8fc5, checked October 2026): https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L96 - Supabase `aal2` authorization policy (pinned commit 10c7379c970ae4d2f30f3158194061cf5ffb8fc5, checked October 2026): https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L192-L196 - Okta Verify TOTP, push and FastPass (pinned commit a4f65eea34b89af7f2f543e43a74a0dfbc709cd0, checked October 2026): https://github.com/okta/okta-developer-docs/blob/a4f65eea34b89af7f2f543e43a74a0dfbc709cd0/packages/@okta/vuepress-site/docs/guides/authenticators-okta-verify/main/index.md#L29 - oauth2-proxy v7.15.6 GitHub organization/team restrictions and username exception (checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/providers/github.md#L10-L14 - Zitadel OIDC and MFA capabilities (pinned commit ab3b44cd743709649e46f7708da2f6a6a70bc227, checked October 2026): https://github.com/zitadel/zitadel/blob/ab3b44cd743709649e46f7708da2f6a6a70bc227/README.md#L121-L123 - Zitadel MFA methods (pinned commit ab3b44cd743709649e46f7708da2f6a6a70bc227, checked October 2026): https://github.com/zitadel/zitadel/blob/ab3b44cd743709649e46f7708da2f6a6a70bc227/apps/docs/content/guides/integrate/login-ui/mfa.mdx#L15-L19 - Microsoft Authenticator passkey, passwordless and MFA support (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/concept-authentication-authenticator-app.md#L14 - authentik validation stage and device classes (version/2026.8.3, checked October 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.3/authentik/stages/authenticator_validate/stage.py#L80-L108 - authentik security advisory publication (version/2026.8.3, checked October 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.3/SECURITY.md#L19-L23 - authentik upgrade sequence and database backup (version/2026.8.3, checked October 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.3/website/docs/install-config/upgrade.mdx#L27-L31 - authentik PostgreSQL backup (version/2026.8.3, checked October 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.3/website/docs/sys-mgmt/ops/backup-restore.mdx#L10-L18 - Keycloak OTP policy and QR-code setup (26.8.2, checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc#L2-L21 - Keycloak security fix releases by severity and long-term support alternative for those unable to upgrade regularly (26.8.2, checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/SECURITY.md#L44-L48 - Keycloak upgrade order (26.8.2, checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/upgrading/topics/intro.adoc#L5-L12 - Keycloak realm export backup limits (26.8.2, checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/importExport.adoc#L13-L15 - Ory Kratos identity and MFA (v26.2.0, checked October 2026): https://github.com/ory/kratos/blob/v26.2.0/README.md#L37-L45 - Ory Kratos Apache 2.0 security patch support (v26.2.0, checked October 2026): https://github.com/ory/kratos/blob/v26.2.0/SECURITY.md#L44-L51 - Ory Kratos deployment options and Ory Network (v26.2.0, checked October 2026): https://github.com/ory/kratos/blob/v26.2.0/README.md#L78-L90 - Ory Kratos TOTP second-factor strategy (v26.2.0, checked October 2026): https://github.com/ory/kratos/blob/v26.2.0/selfservice/strategy/totp/strategy.go#L109-L122 - Ory Kratos WebAuthn assurance strategy (v26.2.0, checked October 2026): https://github.com/ory/kratos/blob/v26.2.0/selfservice/strategy/webauthn/strategy.go#L124-L130 - Ory Kratos lookup-secret second-factor strategy (v26.2.0, checked October 2026): https://github.com/ory/kratos/blob/v26.2.0/selfservice/strategy/lookup/strategy.go#L82-L122 - Ory Hydra connects to an existing identity provider through a login and consent app; OAuth 2.0 and OpenID Connect (v26.2.0, checked October 2026): https://github.com/ory/hydra/blob/v26.2.0/README.md#L49-L72 - Ory Hydra Apache 2.0 security patch support (v26.2.0, checked October 2026): https://github.com/ory/hydra/blob/v26.2.0/SECURITY.md#L44-L51 - Ory Hydra historical migration backup instruction (v26.2.0, checked October 2026): https://github.com/ory/hydra/blob/v26.2.0/UPGRADE.md#L233-L236 - Ory Hydra deployment options and Ory Network (v26.2.0, checked October 2026): https://github.com/ory/hydra/blob/v26.2.0/README.md#L126-L136 - Auth0 passkey login and password coexistence (rolling documentation, basis unknown; checked October 2026): https://auth0.com/docs/authenticate/database-connections/passkeys#offer-passwords-and-passkeys-at-login - Auth0 WebAuthn user verification and MFA (rolling documentation, basis unknown; checked October 2026): https://auth0.com/docs/secure/multi-factor-authentication/fido-authentication-with-webauthn#webauthn-as-multi-factor-authentication - Auth0 archived factor enablement and Always enforcement (pinned commit 5258d17ede3eea0ed0e330d15f8921a542564f68, checked October 2026): https://github.com/auth0/docs/blob/5258d17ede3eea0ed0e330d15f8921a542564f68/articles/mfa/guides/enable-mfa.md#L12-L26 - Microsoft Entra all-users Conditional Access MFA policy (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-all-users-mfa-strength.md#L35-L60 - Microsoft Entra Every time sign-in frequency (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/howto-conditional-access-session-lifetime.md#L28-L43 - Microsoft Entra sign-in frequency reauthentication (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/howto-conditional-access-session-lifetime.md#L65-L66 - Microsoft Entra Every time five-minute prompt tolerance (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/howto-conditional-access-session-lifetime.md#L99-L101 - Microsoft Entra ID token issuer and tenant restriction (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity-platform/id-token-claims-reference.md#L40 - Microsoft Entra enterprise application user and group assignment (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/enterprise-apps/assign-user-or-group-access-portal.md#L16-L22 - Google OpenID Connect hosted-domain claim (rolling documentation, basis unknown; checked October 2026): https://developers.google.com/identity/openid-connect/openid-connect#validatinganidtoken - Firebase Security Rules for data access (rolling documentation, basis unknown; checked October 2026): https://firebase.google.com/docs/rules/basics#production-ready_rules - Cloud Firestore mobile/web client request scope (rolling documentation, basis unknown; checked October 2026): https://firebase.google.com/docs/firestore/security/get-started#writing_rules - Cloud Firestore server client library bypass and IAM (rolling documentation, basis unknown; checked October 2026): https://firebase.google.com/docs/firestore/security/get-started - Supabase table grants and RLS policies (pinned commit 10c7379c970ae4d2f30f3158194061cf5ffb8fc5, checked October 2026): https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/database/postgres/row-level-security.mdx#L28-L60 - GitHub Actions OIDC for workflow cloud access (pinned commit cb051611377a0d2904411e6157154e9b4953d870, checked October 2026): https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/actions/concepts/security/openid-connect.md#L19-L23 - Okta passkey authenticator policies (pinned commit a4f65eea34b89af7f2f543e43a74a0dfbc709cd0, checked October 2026): https://github.com/okta/okta-developer-docs/blob/a4f65eea34b89af7f2f543e43a74a0dfbc709cd0/packages/@okta/vuepress-site/docs/guides/custom-passkeys/main/index.md#L17-L21 - Keycloak passkey login (26.8.2, checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/passkeys.adoc#L5-L8 - Zitadel passkeys (pinned commit ab3b44cd743709649e46f7708da2f6a6a70bc227, checked October 2026): https://github.com/zitadel/zitadel/blob/ab3b44cd743709649e46f7708da2f6a6a70bc227/apps/docs/content/concepts/features/passkeys.mdx#L7-L15 - Microsoft Entra OATH TOTP software apps, seeds and hardware preview (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/concept-authentication-oath-tokens.md#L15-L27 - Okta Google Authenticator TOTP factor added by an admin (rolling documentation, basis unknown; checked October 2026): https://help.okta.com/en-us/content/topics/security/mfa/google-authenticator.htm - Okta Custom TOTP enablement, shared secrets, factor settings and limits specific to Custom TOTP (rolling documentation, basis unknown; checked October 2026): https://help.okta.com/en-us/content/topics/security/mfa-totp-seed.htm - Okta Identity Engine custom OTP configuration options, authenticator ID, Factors API enrolment and enrollment policy (rolling documentation, basis unknown; checked October 2026): https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/custom-otp.htm - Google Authenticator app codes for Google Account 2-Step Verification (rolling documentation, basis unknown; checked October 2026): https://support.google.com/accounts/answer/1066447 - Google 2-Step Verification second steps, including Google Authenticator or another app that creates one-time verification codes (rolling documentation, basis unknown; checked October 2026): https://support.google.com/accounts/answer/185839 - Microsoft Authenticator as a software token for OATH verification codes (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/concept-authentication-authenticator-app.md#L78-L85 - Microsoft Entra third-party software OATH tokens enabled for sign-in by default (pinned commit 231747abc59ae3d50c74e215c1cdd592eb980723, checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-manage-oath-tokens.md#L50-L57 - Google Authenticator Key-Uri-Format defaults and ignored parameters, app documentation only (pinned wiki revision 8ba6e79359344f57b2b0ebea2bfab4cdab2a6573, page last changed 2018-11-26 in f1e21a2, checked October 2026): https://github.com/google/google-authenticator/wiki/Key-Uri-Format/8ba6e79359344f57b2b0ebea2bfab4cdab2a6573 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - entra: Microsoft Entra documentation (unknown) - google: Google Cloud Identity pricing (unknown) - google-docs: Google 2-Step Verification and prompts documentation (unknown) - okta: Okta pricing (unknown) - firebase: Firebase documentation (unknown) - auth0: Auth0 documentation (unknown) - cognito: Amazon Cognito pricing (unknown) - clerk: Clerk pricing (unknown) - workos: WorkOS pricing (unknown) - supabase: Supabase pricing (unknown) - github: GitHub OAuth apps (unknown) - aws-identity: AWS IAM Identity Center and ALB (unknown) - duo: Duo documentation (unknown) - authelia-source: Authelia (v4.39.28) - supabase-docs: Supabase MFA documentation (10c7379c970ae4d2f30f3158194061cf5ffb8fc5) - okta-docs: Okta Verify documentation (a4f65eea34b89af7f2f543e43a74a0dfbc709cd0) - oauth2-proxy: oauth2-proxy (v7.15.6) - zitadel-source: Zitadel documentation (ab3b44cd743709649e46f7708da2f6a6a70bc227) - authentik-source: authentik source (version/2026.8.3) - keycloak-source: Keycloak documentation (26.8.2) - ory-kratos: Ory Kratos (v26.2.0) - ory-hydra: Ory Hydra (v26.2.0) - google-oidc-docs: Google OpenID Connect documentation (unknown) - okta-passkey-docs: Okta passkey documentation (a4f65eea34b89af7f2f543e43a74a0dfbc709cd0) - supabase-rls-docs: Supabase RLS documentation (10c7379c970ae4d2f30f3158194061cf5ffb8fc5) - auth0-mfa-source: Auth0 archived MFA documentation (5258d17ede3eea0ed0e330d15f8921a542564f68) - entra-conditional: Microsoft Entra Conditional Access documentation (231747abc59ae3d50c74e215c1cdd592eb980723) - entra-allowlist: Microsoft Entra token and application access documentation (231747abc59ae3d50c74e215c1cdd592eb980723) - github-docs: GitHub Actions documentation (cb051611377a0d2904411e6157154e9b4953d870) - entra-docs: Microsoft Authenticator documentation (231747abc59ae3d50c74e215c1cdd592eb980723) - okta-totp-docs: Okta TOTP documentation (unknown) - google-authenticator-wiki: Google Authenticator Key-Uri-Format wiki (8ba6e79359344f57b2b0ebea2bfab4cdab2a6573) Claims (full source mapping in the JSON block below): - entra-free (REASONED; entra): Entra Free includes MFA and unlimited SSO as recorded in September 2026. - entra-defaults (REASONED; entra): Security defaults require MFA registration for everyone, enforce it for admins and prompt others when Microsoft judges necessary. - entra-paid (REASONED; entra): September 2026 USD annual-commitment prices: P1 $7/user/month adds Conditional Access; P2 $10 adds risk-based policies. Verify current pricing. - google-free (REASONED; google): Cloud Identity Free exists; its default license count was not checked for this guide. - okta-commercial (REASONED; okta): Okta Workforce has a $1,500 annual contract minimum and no free production tier, as recorded in September 2026. - external-id-free (REASONED; entra): Entra External ID Basic tier covers the first 50,000 MAU free; workforce and external tenants are distinct tenant configurations. This guide's assessment is that external and workforce tenants should be kept separate; the cited billing page and both cited pricing pages do not say so. - external-id-addons (REASONED; entra): External ID SMS MFA and machine-to-machine client credentials are billed per transaction as add-ons. - firebase-base (REASONED; firebase): Base Firebase Authentication provides free email, password and social sign-in; Identity Platform unlocks TOTP MFA. - firebase-blaze (REASONED; firebase): With Identity Platform, Blaze has 50,000 free MAU and 50 SAML/OIDC MAU; SMS is billed per message. - firebase-spark (REASONED; firebase): Spark instead caps use at 3,000 daily active users and 2 SAML/OIDC daily active users; do not confuse these with Blaze MAU allowances. - auth0-free (REASONED; auth0): Auth0 Free has 25,000 MAU, passkeys and unlimited social connections, but no separately licensed MFA factors or enforced step-up policy. - auth0-paid (REASONED; auth0): MFA factors start on Essentials; B2C Essentials is $35/month for 500 MAU. Adaptive MFA requires Enterprise plus its add-on. - cognito-free (REASONED; cognito): Cognito Lite and Essentials provide 10,000 free direct-sign-in MAU and 50 free SAML/OIDC MAU. - cognito-grandfather (REASONED; cognito): Lite pools created by 10:00 a.m. Pacific on 22 November 2024, and qualifying new Lite pools in those accounts, retain 50,000 free MAU. - cognito-passkeys (REASONED; cognito): Cognito Essentials includes passkeys and costs $0.015/MAU beyond its allowance; Lite lacks passkeys and SNS SMS is separately billed. - clerk-free (REASONED; clerk): Clerk Hobby covers 50,000 monthly retained users, meaning users returning at least 24 hours after signup, not MAU. - clerk-mfa (REASONED; clerk): Clerk MFA requires Pro or above: $25/month, or $20 with annual billing, as recorded in September 2026. - workos-users (REASONED; workos): WorkOS AuthKit user management covers the first 1,000,000 MAU free. - workos-enterprise (REASONED; workos): WorkOS Enterprise SSO and Directory Sync are separately priced at $125/connection/month for each product's first 15 connections. - supabase-free (REASONED; supabase): Supabase Free includes 50,000 MAU and TOTP MFA. - supabase-phone (REASONED; supabase): Supabase phone MFA is a paid add-on at $75/month for the first project. - github-membership (REASONED; github): GitHub OAuth login must be followed by an organization or team allowlist; OAuth login alone admits any GitHub user. - aws-workforce (REASONED; aws-identity): IAM Identity Center is a free workforce directory for AWS console and CLI, not a general ALB OIDC provider; use Cognito or an OIDC provider for ALB app login. - duo-free (REASONED; duo): Duo Free supports up to 10 users with MFA and Duo Mobile. - duo-proxy (REASONED; duo): Duo Authentication Proxy supports RADIUS and LDAP for VPNs and other compatible services. - duo-failmode (REASONED; duo): Set failmode=secure for mandatory MFA; default safe admits a primary-authenticated user when Duo is unreachable. Keep a separately controlled emergency path. - verify-mfa (REASONED; entra, entra-conditional): REASONED; no provider tenant, administrator test account or an MFA-protected admin function is available, and no login was observed. A fresh password-only or social-login-only session must fail at the protected admin function; completing MFA must permit that same function, proving the denial was MFA enforcement. - verify-allowlist (REASONED; github, aws-identity, google-oidc-docs, entra-allowlist): REASONED; no connected application or allowed/excluded provider accounts are available, and no login was observed. An authenticated outside account must be denied after login while an allowed account reaches the application, proving the denial was the allowlist rather than a broken login path. Google documents the hd claim; Microsoft documents tenant issuer restriction and enterprise-application assignment, with group-based assignment requiring P1 or P2 at the time of writing. - verify-logs (REASONED; entra): REASONED; no provider test events or application decision logs are available, and no logs were inspected. Correlate provider sign-in records with application authorization decisions: the required factor or assurance level must be met and the outside account denied. Entra Sign-in logs record logins and Audit logs record configuration changes; a sign-in alone proves no enforcement. - google-org-mfa (REASONED; google, google-docs): Google Workspace super administrators can enforce 2-Step Verification in the Admin console for all users, or for a selected organizational unit or configuration group, per the deployment page; this guide's assessment, not shown there, is that Cloud Identity uses the same Admin console setting. This guide found no Google page stating Sign in with Google (OIDC) pricing (checked October 2026). - okta-verify-factors (REASONED; okta, okta-docs): Okta Verify supports push, TOTP and FastPass. - auth0-passkey-policy (REASONED; auth0, auth0-mfa-source): At the time of writing, Auth0 Free includes passkeys but lacks its separately licensed MFA policy for enforced factors and step-up; with user verification, WebAuthn is multifactor. Enabling passkeys leaves password sign-in available while the database connection permits password authentication. The cited rolling passkey and archived MFA pages do not independently establish Free-tier step-up entitlement. - supabase-aal2 (REASONED; supabase-docs): Require aal2 in Supabase authorization policies. This guide's assessment is that TOTP enrollment alone does not enforce MFA. - entra-every-signin (REASONED; entra, entra-conditional): At the time of writing, Entra P1 adds Conditional Access. An all-users, all-resources policy, initially in Report-only mode and excluding emergency-access accounts, requires MFA for included users when enforced; a separate Every time sign-in-frequency control requires repeated reauthentication, subject to a five-minute prompt tolerance. Security defaults instead prompt non-admin users when necessary. The cited passages do not establish a fresh MFA challenge on every resource access. - application-allowlist (REASONED; github, aws-identity, google-oidc-docs, entra-allowlist): Guide advice: after any provider login, the application or fronting layer must check a tenant, hosted-domain, group or explicit-user allowlist; accepting every Google or Microsoft account as staff is not authorization. Google documents the ID-token hd claim; Microsoft documents tenant issuer restriction and enterprise-application group assignment, which requires P1 or P2 at the time of writing. No fetched Google Workspace application-access page establishes a native admin-console allowlist. - firebase-data-policy (REASONED; firebase, supabase-rls-docs): Cloud Firestore Security Rules evaluate mobile/web client library requests after authentication; server client libraries bypass the Rules and use IAM. Supabase table grants decide permitted operations and RLS policies decide accessible rows. Supabase service_role bypasses RLS and must stay server-side. - github-proxy-workloads (REASONED; github, oauth2-proxy, github-docs): oauth2-proxy's GitHub provider can restrict organizations and teams; GitHub Actions OIDC gives workflows short-lived cloud-provider tokens. This guide's assessment is that workload OIDC is separate from human GitHub OAuth login. - provider-authenticators (REASONED; entra, okta, google-docs, okta-docs, entra-docs, okta-totp-docs): Microsoft Authenticator and Okta Verify are provider-native; Google Account prompts on signed-in phones can replace a password or add a second step to it. Guide assessment beyond cited Entra, Okta and Google cases: an RFC 6238 app can use provider TOTP if algorithm, digits and period match. Listing every provider is more effort than this project wants; request one by GitHub issue or pull request. - provider-authenticators-entra (REASONED; entra-docs): Entra permits third-party OATH TOTP apps using an Entra-generated seed and programmable hardware tokens using a software setup seed; its hardware preview supports SHA-1 and SHA-256 with 30- or 60-second periods, while HOTP is unsupported. The cited page does not specify software-app algorithms, digits or periods. - provider-authenticators-entra-software (REASONED; entra-docs): Entra's Authenticator page says Microsoft Authenticator can be a software token for OATH codes. Entra's token-management page says third-party software OATH tokens are enabled for sign-in by default; an Authentication Policy Administrator can disable them to prevent sign-in with a one-time password from a third-party Identity Provider. Neither page gives app algorithms, digits or periods. - provider-authenticators-okta (REASONED; okta-totp-docs): In Okta's Classic Engine docs, Okta accepts Google Authenticator's six-digit TOTP as a second factor once an admin adds it; Custom TOTP, if enabled for the org, uses per-user seeds, HMAC and shared-secret encoding matching the implementation and a configured time step, and supports only standard OTP tokens and one enrolment per user. No cited page shows arbitrary app compatibility. - provider-authenticators-okta-enrolment (REASONED; okta-totp-docs): In Okta's Classic Engine documentation, if Custom TOTP is enabled for the org, the factor and its Factor Profile ID are created in the Admin Console, and each user is enrolled through the Okta Factors API by passing factorProfileId and sharedSecret for their token. - provider-authenticators-okta-ie (REASONED; okta-totp-docs): Okta's Identity Engine custom OTP page says Okta generates an authenticator ID, entered as factorProfileId when enrolling users through the Okta Factors API, that a user can be enrolled in only one custom OTP authenticator instance at a time, and that the authenticator is added to an authenticator enrollment policy. - provider-authenticators-okta-settings (REASONED; okta-totp-docs): Okta's Classic Engine Custom TOTP and Identity Engine custom OTP pages list TOTP length and OTP length (the number of digits) respectively, HMAC algorithm, time step, clock drift interval and shared-secret encoding, saying to match HMAC and encoding to the implementation, but no HMAC, encoding or length choices. Okta's Classic Engine Google Authenticator page states no period or algorithm. - provider-authenticators-google (REASONED; google-docs): Google Authenticator help: with 2-Step Verification, Google Account sign-in can use the app's codes, even offline. Google's 2-Step Verification page says a user without internet or mobile service can set up Google Authenticator or another app that creates one-time verification codes, naming no other app. Neither page nor Workspace's deployment page gives these codes' algorithms, digits or periods. - provider-authenticators-google-defaults (REASONED; google-authenticator-wiki, google-docs): Google Authenticator's Key-Uri-Format wiki gives otpauth defaults of SHA1, 6 digits and a 30-second period and says, in notes unchanged since 2018, that the app's implementations ignore the algorithm and period parameters, and digits on Android and Blackberry. This guide's inference, not a Google statement about Google Account: its authenticator codes use SHA1, six digits and 30 seconds. - admin-passkeys (REASONED; entra-docs, auth0, okta-passkey-docs, keycloak-source, zitadel-source): Microsoft Entra, Auth0, Okta, Keycloak and Zitadel document passkey or WebAuthn support. Guide advice: check each provider's tier before relying on availability and require phishing-resistant hardware keys or platform passkeys for administrators. The Auth0 pricing page lists passkeys on Free (September 2026), and Okta's passkey guide uses an Integrator Free Plan org; the cited pages do not establish production-tier availability for the five providers. - self-hosted-mfa (REASONED; entra, authelia-source, zitadel-source, authentik-source, keycloak-source, ory-kratos, ory-hydra): Self-hosted, Keycloak, authentik, Zitadel, Authelia and Ory Kratos, or Ory Hydra connected to an existing identity provider through a login and consent app, give you OIDC and MFA without a vendor (this guide's assessment where the cited ranges do not show it). Guide advice: for a self-hosted deployment, patch, back up and keep the identity service highly available. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "fdbefe28b0184721aaf4417e2f3a4e851bec9dfadcae5f5c59820a69e86b5ea4", "components": { "entra": { "name": "Microsoft Entra documentation", "basis": "unknown", "sources": { "s0ee5939cfc03": "https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing", "s36db3cb5ed40": "https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults", "sca079c06e1ac": "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-sign-ins", "sd65e0717c272": "https://www.microsoft.com/en-us/security/microsoft-entra-pricing", "sce0b766339c3": "https://www.microsoft.com/en-us/security/pricing/microsoft-entra-external-id/" } }, "google": { "name": "Google Cloud Identity pricing", "basis": "unknown", "sources": { "s78598c90d854": "https://cloud.google.com/identity/pricing" } }, "google-docs": { "name": "Google 2-Step Verification and prompts documentation", "basis": "unknown", "sources": { "s4e673839253c": "https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification", "see25fce3a7a3": "https://support.google.com/accounts/answer/7026266", "s2c23c2e40308": "https://support.google.com/accounts/answer/1066447", "s9680f27e2deb": "https://support.google.com/accounts/answer/185839" } }, "okta": { "name": "Okta pricing", "basis": "unknown", "sources": { "sc0f6f4461955": "https://www.okta.com/pricing/" } }, "firebase": { "name": "Firebase documentation", "basis": "unknown", "sources": { "s69c4a65e512f": "https://firebase.google.com/docs/auth", "s6bf1b9443202": "https://firebase.google.com/pricing", "s5368f5c06957": "https://firebase.google.com/docs/rules/basics#production-ready_rules", "sd6071548f6fe": "https://firebase.google.com/docs/firestore/security/get-started", "s75d5ea2efd58": "https://firebase.google.com/docs/firestore/security/get-started#writing_rules" } }, "auth0": { "name": "Auth0 documentation", "basis": "unknown", "sources": { "sc6cc7bcbb422": "https://auth0.com/docs/secure/multi-factor-authentication/adaptive-mfa", "s6d62bb8ed8bf": "https://auth0.com/pricing", "s5794c9fb8add": "https://auth0.com/docs/authenticate/database-connections/passkeys#offer-passwords-and-passkeys-at-login", "s7ecf8ee3e9ba": "https://auth0.com/docs/secure/multi-factor-authentication/fido-authentication-with-webauthn#webauthn-as-multi-factor-authentication" } }, "cognito": { "name": "Amazon Cognito pricing", "basis": "unknown", "sources": { "se7b30a68d1e3": "https://aws.amazon.com/cognito/pricing/" } }, "clerk": { "name": "Clerk pricing", "basis": "unknown", "sources": { "s7d9e2e6a67d5": "https://clerk.com/pricing" } }, "workos": { "name": "WorkOS pricing", "basis": "unknown", "sources": { "s339abf65cec2": "https://workos.com/pricing" } }, "supabase": { "name": "Supabase pricing", "basis": "unknown", "sources": { "sf91e99d7738c": "https://supabase.com/pricing" } }, "github": { "name": "GitHub OAuth apps", "basis": "unknown", "sources": { "s6b78ce27fabc": "https://docs.github.com/en/apps/oauth-apps" } }, "aws-identity": { "name": "AWS IAM Identity Center and ALB", "basis": "unknown", "sources": { "sb1894f4f0139": "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html", "s2f9843a0d8ba": "https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html", "s1b0f37773563": "https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html" } }, "duo": { "name": "Duo documentation", "basis": "unknown", "sources": { "s4fa84e377f72": "https://duo.com/docs/authproxy-reference", "sb2b87daf72f0": "https://duo.com/editions-and-pricing" } }, "authelia-source": { "name": "Authelia", "basis": "v4.39.28", "sources": { "se61b694f1b18": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/introduction.md#L23-L31", "sa874f6497a22": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/identity-providers/openid-connect/provider.md#L25-L28" } }, "supabase-docs": { "name": "Supabase MFA documentation", "basis": "10c7379c970ae4d2f30f3158194061cf5ffb8fc5", "sources": { "sf7aaaf9af229": "https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L96", "sa9ece8e2f60a": "https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/auth/auth-mfa.mdx#L192-L196" } }, "okta-docs": { "name": "Okta Verify documentation", "basis": "a4f65eea34b89af7f2f543e43a74a0dfbc709cd0", "sources": { "sbad1704aaf36": "https://github.com/okta/okta-developer-docs/blob/a4f65eea34b89af7f2f543e43a74a0dfbc709cd0/packages/@okta/vuepress-site/docs/guides/authenticators-okta-verify/main/index.md#L29" } }, "oauth2-proxy": { "name": "oauth2-proxy", "basis": "v7.15.6", "sources": { "s5b6b637c8ac1": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/providers/github.md#L10-L14" } }, "zitadel-source": { "name": "Zitadel documentation", "basis": "ab3b44cd743709649e46f7708da2f6a6a70bc227", "sources": { "s729880406dc3": "https://github.com/zitadel/zitadel/blob/ab3b44cd743709649e46f7708da2f6a6a70bc227/README.md#L121-L123", "sd1f3f5ccaffa": "https://github.com/zitadel/zitadel/blob/ab3b44cd743709649e46f7708da2f6a6a70bc227/apps/docs/content/guides/integrate/login-ui/mfa.mdx#L15-L19", "s27d60b6057d3": "https://github.com/zitadel/zitadel/blob/ab3b44cd743709649e46f7708da2f6a6a70bc227/apps/docs/content/concepts/features/passkeys.mdx#L7-L15" } }, "authentik-source": { "name": "authentik source", "basis": "version/2026.8.3", "sources": { "s18275a7072e9": "https://github.com/goauthentik/authentik/blob/version/2026.8.3/authentik/stages/authenticator_validate/stage.py#L80-L108", "sf805a10ca50a": "https://github.com/goauthentik/authentik/blob/version/2026.8.3/SECURITY.md#L19-L23", "s8f5e451e02b4": "https://github.com/goauthentik/authentik/blob/version/2026.8.3/website/docs/install-config/upgrade.mdx#L27-L31", "s7d192a1be39a": "https://github.com/goauthentik/authentik/blob/version/2026.8.3/website/docs/sys-mgmt/ops/backup-restore.mdx#L10-L18" } }, "keycloak-source": { "name": "Keycloak documentation", "basis": "26.8.2", "sources": { "sab71553daacd": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc#L2-L21", "s6c15a8d999b9": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/passkeys.adoc#L5-L8", "s718758a5df7b": "https://github.com/keycloak/keycloak/blob/26.8.2/SECURITY.md#L44-L48", "se8310ea39db5": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/upgrading/topics/intro.adoc#L5-L12", "s73b2f670dd8f": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/importExport.adoc#L13-L15" } }, "ory-kratos": { "name": "Ory Kratos", "basis": "v26.2.0", "sources": { "sa1328330301d": "https://github.com/ory/kratos/blob/v26.2.0/README.md#L37-L45", "s0208175a002a": "https://github.com/ory/kratos/blob/v26.2.0/README.md#L78-L90", "s557dc3b182b7": "https://github.com/ory/kratos/blob/v26.2.0/selfservice/strategy/totp/strategy.go#L109-L122", "sd666b62f8a70": "https://github.com/ory/kratos/blob/v26.2.0/selfservice/strategy/webauthn/strategy.go#L124-L130", "s3d2d98558214": "https://github.com/ory/kratos/blob/v26.2.0/selfservice/strategy/lookup/strategy.go#L82-L122", "s3e746241fd0d": "https://github.com/ory/kratos/blob/v26.2.0/SECURITY.md#L44-L51" } }, "ory-hydra": { "name": "Ory Hydra", "basis": "v26.2.0", "sources": { "s7735ed8cb7ad": "https://github.com/ory/hydra/blob/v26.2.0/README.md#L49-L72", "s9e834fe62236": "https://github.com/ory/hydra/blob/v26.2.0/README.md#L126-L136", "se08c1e7174df": "https://github.com/ory/hydra/blob/v26.2.0/SECURITY.md#L44-L51", "s58dac2f41ea0": "https://github.com/ory/hydra/blob/v26.2.0/UPGRADE.md#L233-L236" } }, "google-oidc-docs": { "name": "Google OpenID Connect documentation", "basis": "unknown", "sources": { "sce9a29f93b69": "https://developers.google.com/identity/openid-connect/openid-connect#validatinganidtoken" } }, "okta-passkey-docs": { "name": "Okta passkey documentation", "basis": "a4f65eea34b89af7f2f543e43a74a0dfbc709cd0", "sources": { "s567594b07020": "https://github.com/okta/okta-developer-docs/blob/a4f65eea34b89af7f2f543e43a74a0dfbc709cd0/packages/@okta/vuepress-site/docs/guides/custom-passkeys/main/index.md#L17-L21" } }, "supabase-rls-docs": { "name": "Supabase RLS documentation", "basis": "10c7379c970ae4d2f30f3158194061cf5ffb8fc5", "sources": { "sdb0688cb20c8": "https://github.com/supabase/supabase/blob/10c7379c970ae4d2f30f3158194061cf5ffb8fc5/apps/docs/content/guides/database/postgres/row-level-security.mdx#L28-L60" } }, "auth0-mfa-source": { "name": "Auth0 archived MFA documentation", "basis": "5258d17ede3eea0ed0e330d15f8921a542564f68", "sources": { "sf926337e245b": "https://github.com/auth0/docs/blob/5258d17ede3eea0ed0e330d15f8921a542564f68/articles/mfa/guides/enable-mfa.md#L12-L26" } }, "entra-conditional": { "name": "Microsoft Entra Conditional Access documentation", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "s9bf89b3e9f66": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-all-users-mfa-strength.md#L35-L60", "sc657c943e436": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/howto-conditional-access-session-lifetime.md#L28-L43", "sb1623e284d92": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/howto-conditional-access-session-lifetime.md#L65-L66", "se292d57dc695": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/howto-conditional-access-session-lifetime.md#L99-L101" } }, "entra-allowlist": { "name": "Microsoft Entra token and application access documentation", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "sb49c822a58e5": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity-platform/id-token-claims-reference.md#L40", "sfdfe9683e4ff": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/enterprise-apps/assign-user-or-group-access-portal.md#L16-L22" } }, "github-docs": { "name": "GitHub Actions documentation", "basis": "cb051611377a0d2904411e6157154e9b4953d870", "sources": { "s4d638483756e": "https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/actions/concepts/security/openid-connect.md#L19-L23" } }, "entra-docs": { "name": "Microsoft Authenticator documentation", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "sa319646e1a02": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/concept-authentication-authenticator-app.md#L14", "s4d3f93f998dd": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/concept-authentication-oath-tokens.md#L15-L27", "s3d301a92e0bd": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/concept-authentication-authenticator-app.md#L78-L85", "s29290b7d7fef": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/authentication/how-to-mfa-manage-oath-tokens.md#L50-L57" } }, "okta-totp-docs": { "name": "Okta TOTP documentation", "basis": "unknown", "sources": { "s90c65e21037a": "https://help.okta.com/en-us/content/topics/security/mfa/google-authenticator.htm", "sd0899fd16083": "https://help.okta.com/en-us/content/topics/security/mfa-totp-seed.htm", "s6cdace14b67d": "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/custom-otp.htm" } }, "google-authenticator-wiki": { "name": "Google Authenticator Key-Uri-Format wiki", "basis": "8ba6e79359344f57b2b0ebea2bfab4cdab2a6573", "sources": { "s7c1fe25bfffa": "https://github.com/google/google-authenticator/wiki/Key-Uri-Format/8ba6e79359344f57b2b0ebea2bfab4cdab2a6573" } } }, "claims": { "entra-free": {"text": "Entra Free includes MFA and unlimited SSO as recorded in September 2026.", "components": ["entra"], "sources": ["entra:sd65e0717c272"], "status": "REASONED"}, "entra-defaults": {"text": "Security defaults require MFA registration for everyone, enforce it for admins and prompt others when Microsoft judges necessary.", "components": ["entra"], "sources": ["entra:s36db3cb5ed40"], "status": "REASONED"}, "entra-paid": {"text": "September 2026 USD annual-commitment prices: P1 $7/user/month adds Conditional Access; P2 $10 adds risk-based policies. Verify current pricing.", "components": ["entra"], "sources": ["entra:sd65e0717c272"], "status": "REASONED"}, "google-free": {"text": "Cloud Identity Free exists; its default license count was not checked for this guide.", "components": ["google"], "sources": ["google:s78598c90d854"], "status": "REASONED"}, "okta-commercial": {"text": "Okta Workforce has a $1,500 annual contract minimum and no free production tier, as recorded in September 2026.", "components": ["okta"], "sources": ["okta:sc0f6f4461955"], "status": "REASONED"}, "external-id-free": {"text": "Entra External ID Basic tier covers the first 50,000 MAU free; workforce and external tenants are distinct tenant configurations. This guide's assessment is that external and workforce tenants should be kept separate; the cited billing page and both cited pricing pages do not say so.", "components": ["entra"], "sources": ["entra:sd65e0717c272", "entra:sce0b766339c3", "entra:s0ee5939cfc03"], "status": "REASONED"}, "external-id-addons": {"text": "External ID SMS MFA and machine-to-machine client credentials are billed per transaction as add-ons.", "components": ["entra"], "sources": ["entra:s0ee5939cfc03"], "status": "REASONED"}, "firebase-base": {"text": "Base Firebase Authentication provides free email, password and social sign-in; Identity Platform unlocks TOTP MFA.", "components": ["firebase"], "sources": ["firebase:s69c4a65e512f", "firebase:s6bf1b9443202"], "status": "REASONED"}, "firebase-blaze": {"text": "With Identity Platform, Blaze has 50,000 free MAU and 50 SAML/OIDC MAU; SMS is billed per message.", "components": ["firebase"], "sources": ["firebase:s6bf1b9443202"], "status": "REASONED"}, "firebase-spark": {"text": "Spark instead caps use at 3,000 daily active users and 2 SAML/OIDC daily active users; do not confuse these with Blaze MAU allowances.", "components": ["firebase"], "sources": ["firebase:s69c4a65e512f"], "status": "REASONED"}, "auth0-free": {"text": "Auth0 Free has 25,000 MAU, passkeys and unlimited social connections, but no separately licensed MFA factors or enforced step-up policy.", "components": ["auth0"], "sources": ["auth0:s6d62bb8ed8bf"], "status": "REASONED"}, "auth0-paid": {"text": "MFA factors start on Essentials; B2C Essentials is $35/month for 500 MAU. Adaptive MFA requires Enterprise plus its add-on.", "components": ["auth0"], "sources": ["auth0:s6d62bb8ed8bf", "auth0:sc6cc7bcbb422"], "status": "REASONED"}, "cognito-free": {"text": "Cognito Lite and Essentials provide 10,000 free direct-sign-in MAU and 50 free SAML/OIDC MAU.", "components": ["cognito"], "sources": ["cognito:se7b30a68d1e3"], "status": "REASONED"}, "cognito-grandfather": {"text": "Lite pools created by 10:00 a.m. Pacific on 22 November 2024, and qualifying new Lite pools in those accounts, retain 50,000 free MAU.", "components": ["cognito"], "sources": ["cognito:se7b30a68d1e3"], "status": "REASONED"}, "cognito-passkeys": {"text": "Cognito Essentials includes passkeys and costs $0.015/MAU beyond its allowance; Lite lacks passkeys and SNS SMS is separately billed.", "components": ["cognito"], "sources": ["cognito:se7b30a68d1e3"], "status": "REASONED"}, "clerk-free": {"text": "Clerk Hobby covers 50,000 monthly retained users, meaning users returning at least 24 hours after signup, not MAU.", "components": ["clerk"], "sources": ["clerk:s7d9e2e6a67d5"], "status": "REASONED"}, "clerk-mfa": {"text": "Clerk MFA requires Pro or above: $25/month, or $20 with annual billing, as recorded in September 2026.", "components": ["clerk"], "sources": ["clerk:s7d9e2e6a67d5"], "status": "REASONED"}, "workos-users": {"text": "WorkOS AuthKit user management covers the first 1,000,000 MAU free.", "components": ["workos"], "sources": ["workos:s339abf65cec2"], "status": "REASONED"}, "workos-enterprise": {"text": "WorkOS Enterprise SSO and Directory Sync are separately priced at $125/connection/month for each product's first 15 connections.", "components": ["workos"], "sources": ["workos:s339abf65cec2"], "status": "REASONED"}, "supabase-free": {"text": "Supabase Free includes 50,000 MAU and TOTP MFA.", "components": ["supabase"], "sources": ["supabase:sf91e99d7738c"], "status": "REASONED"}, "supabase-phone": {"text": "Supabase phone MFA is a paid add-on at $75/month for the first project.", "components": ["supabase"], "sources": ["supabase:sf91e99d7738c"], "status": "REASONED"}, "github-membership": {"text": "GitHub OAuth login must be followed by an organization or team allowlist; OAuth login alone admits any GitHub user.", "components": ["github"], "sources": ["github:s6b78ce27fabc"], "status": "REASONED"}, "aws-workforce": {"text": "IAM Identity Center is a free workforce directory for AWS console and CLI, not a general ALB OIDC provider; use Cognito or an OIDC provider for ALB app login.", "components": ["aws-identity"], "sources": ["aws-identity:sb1894f4f0139", "aws-identity:s2f9843a0d8ba", "aws-identity:s1b0f37773563"], "status": "REASONED"}, "duo-free": {"text": "Duo Free supports up to 10 users with MFA and Duo Mobile.", "components": ["duo"], "sources": ["duo:sb2b87daf72f0"], "status": "REASONED"}, "duo-proxy": {"text": "Duo Authentication Proxy supports RADIUS and LDAP for VPNs and other compatible services.", "components": ["duo"], "sources": ["duo:s4fa84e377f72"], "status": "REASONED"}, "duo-failmode": {"text": "Set failmode=secure for mandatory MFA; default safe admits a primary-authenticated user when Duo is unreachable. Keep a separately controlled emergency path.", "components": ["duo"], "sources": ["duo:s4fa84e377f72"], "status": "REASONED"}, "verify-mfa": {"text": "REASONED; no provider tenant, administrator test account or an MFA-protected admin function is available, and no login was observed. A fresh password-only or social-login-only session must fail at the protected admin function; completing MFA must permit that same function, proving the denial was MFA enforcement.", "components": ["entra", "entra-conditional"], "sources": ["entra:s36db3cb5ed40", "entra-conditional:s9bf89b3e9f66"], "status": "REASONED"}, "verify-allowlist": {"text": "REASONED; no connected application or allowed/excluded provider accounts are available, and no login was observed. An authenticated outside account must be denied after login while an allowed account reaches the application, proving the denial was the allowlist rather than a broken login path. Google documents the hd claim; Microsoft documents tenant issuer restriction and enterprise-application assignment, with group-based assignment requiring P1 or P2 at the time of writing.", "components": ["github", "aws-identity", "google-oidc-docs", "entra-allowlist"], "sources": ["github:s6b78ce27fabc", "aws-identity:sb1894f4f0139", "google-oidc-docs:sce9a29f93b69", "entra-allowlist:sb49c822a58e5", "entra-allowlist:sfdfe9683e4ff"], "status": "REASONED"}, "verify-logs": {"text": "REASONED; no provider test events or application decision logs are available, and no logs were inspected. Correlate provider sign-in records with application authorization decisions: the required factor or assurance level must be met and the outside account denied. Entra Sign-in logs record logins and Audit logs record configuration changes; a sign-in alone proves no enforcement.", "components": ["entra"], "sources": ["entra:sca079c06e1ac"], "status": "REASONED"}, "google-org-mfa": {"text": "Google Workspace super administrators can enforce 2-Step Verification in the Admin console for all users, or for a selected organizational unit or configuration group, per the deployment page; this guide's assessment, not shown there, is that Cloud Identity uses the same Admin console setting. This guide found no Google page stating Sign in with Google (OIDC) pricing (checked October 2026).", "components": ["google", "google-docs"], "sources": ["google:s78598c90d854", "google-docs:s4e673839253c"], "status": "REASONED"}, "okta-verify-factors": {"text": "Okta Verify supports push, TOTP and FastPass.", "components": ["okta", "okta-docs"], "sources": ["okta:sc0f6f4461955", "okta-docs:sbad1704aaf36"], "status": "REASONED"}, "auth0-passkey-policy": {"text": "At the time of writing, Auth0 Free includes passkeys but lacks its separately licensed MFA policy for enforced factors and step-up; with user verification, WebAuthn is multifactor. Enabling passkeys leaves password sign-in available while the database connection permits password authentication. The cited rolling passkey and archived MFA pages do not independently establish Free-tier step-up entitlement.", "components": ["auth0", "auth0-mfa-source"], "sources": ["auth0:s6d62bb8ed8bf", "auth0:s5794c9fb8add", "auth0:s7ecf8ee3e9ba", "auth0-mfa-source:sf926337e245b"], "status": "REASONED"}, "supabase-aal2": {"text": "Require aal2 in Supabase authorization policies. This guide's assessment is that TOTP enrollment alone does not enforce MFA.", "components": ["supabase-docs"], "sources": ["supabase-docs:sf7aaaf9af229", "supabase-docs:sa9ece8e2f60a"], "status": "REASONED"}, "entra-every-signin": {"text": "At the time of writing, Entra P1 adds Conditional Access. An all-users, all-resources policy, initially in Report-only mode and excluding emergency-access accounts, requires MFA for included users when enforced; a separate Every time sign-in-frequency control requires repeated reauthentication, subject to a five-minute prompt tolerance. Security defaults instead prompt non-admin users when necessary. The cited passages do not establish a fresh MFA challenge on every resource access.", "components": ["entra", "entra-conditional"], "sources": ["entra:sd65e0717c272", "entra:s36db3cb5ed40", "entra-conditional:s9bf89b3e9f66", "entra-conditional:sc657c943e436", "entra-conditional:sb1623e284d92", "entra-conditional:se292d57dc695"], "status": "REASONED"}, "application-allowlist": {"text": "Guide advice: after any provider login, the application or fronting layer must check a tenant, hosted-domain, group or explicit-user allowlist; accepting every Google or Microsoft account as staff is not authorization. Google documents the ID-token hd claim; Microsoft documents tenant issuer restriction and enterprise-application group assignment, which requires P1 or P2 at the time of writing. No fetched Google Workspace application-access page establishes a native admin-console allowlist.", "components": ["github", "aws-identity", "google-oidc-docs", "entra-allowlist"], "sources": ["github:s6b78ce27fabc", "aws-identity:sb1894f4f0139", "google-oidc-docs:sce9a29f93b69", "entra-allowlist:sb49c822a58e5", "entra-allowlist:sfdfe9683e4ff"], "status": "REASONED"}, "firebase-data-policy": {"text": "Cloud Firestore Security Rules evaluate mobile/web client library requests after authentication; server client libraries bypass the Rules and use IAM. Supabase table grants decide permitted operations and RLS policies decide accessible rows. Supabase service_role bypasses RLS and must stay server-side.", "components": ["firebase", "supabase-rls-docs"], "sources": ["firebase:s5368f5c06957", "firebase:sd6071548f6fe", "firebase:s75d5ea2efd58", "supabase-rls-docs:sdb0688cb20c8"], "status": "REASONED"}, "github-proxy-workloads": {"text": "oauth2-proxy's GitHub provider can restrict organizations and teams; GitHub Actions OIDC gives workflows short-lived cloud-provider tokens. This guide's assessment is that workload OIDC is separate from human GitHub OAuth login.", "components": ["github", "oauth2-proxy", "github-docs"], "sources": ["github:s6b78ce27fabc", "oauth2-proxy:s5b6b637c8ac1", "github-docs:s4d638483756e"], "status": "REASONED"}, "provider-authenticators": {"text": "Microsoft Authenticator and Okta Verify are provider-native; Google Account prompts on signed-in phones can replace a password or add a second step to it. Guide assessment beyond cited Entra, Okta and Google cases: an RFC 6238 app can use provider TOTP if algorithm, digits and period match. Listing every provider is more effort than this project wants; request one by GitHub issue or pull request.", "components": ["entra", "okta", "google-docs", "okta-docs", "entra-docs", "okta-totp-docs"], "sources": ["entra:s36db3cb5ed40", "okta:sc0f6f4461955", "google-docs:see25fce3a7a3", "okta-docs:sbad1704aaf36", "entra-docs:sa319646e1a02", "entra-docs:s4d3f93f998dd", "okta-totp-docs:s90c65e21037a", "okta-totp-docs:sd0899fd16083", "google-docs:s2c23c2e40308", "google-docs:s9680f27e2deb"], "status": "REASONED"}, "provider-authenticators-entra": {"text": "Entra permits third-party OATH TOTP apps using an Entra-generated seed and programmable hardware tokens using a software setup seed; its hardware preview supports SHA-1 and SHA-256 with 30- or 60-second periods, while HOTP is unsupported. The cited page does not specify software-app algorithms, digits or periods.", "components": ["entra-docs"], "sources": ["entra-docs:s4d3f93f998dd"], "status": "REASONED"}, "provider-authenticators-entra-software": {"text": "Entra's Authenticator page says Microsoft Authenticator can be a software token for OATH codes. Entra's token-management page says third-party software OATH tokens are enabled for sign-in by default; an Authentication Policy Administrator can disable them to prevent sign-in with a one-time password from a third-party Identity Provider. Neither page gives app algorithms, digits or periods.", "components": ["entra-docs"], "sources": ["entra-docs:s3d301a92e0bd", "entra-docs:s29290b7d7fef"], "status": "REASONED"}, "provider-authenticators-okta": {"text": "In Okta's Classic Engine docs, Okta accepts Google Authenticator's six-digit TOTP as a second factor once an admin adds it; Custom TOTP, if enabled for the org, uses per-user seeds, HMAC and shared-secret encoding matching the implementation and a configured time step, and supports only standard OTP tokens and one enrolment per user. No cited page shows arbitrary app compatibility.", "components": ["okta-totp-docs"], "sources": ["okta-totp-docs:s90c65e21037a", "okta-totp-docs:sd0899fd16083"], "status": "REASONED"}, "provider-authenticators-okta-enrolment": {"text": "In Okta's Classic Engine documentation, if Custom TOTP is enabled for the org, the factor and its Factor Profile ID are created in the Admin Console, and each user is enrolled through the Okta Factors API by passing factorProfileId and sharedSecret for their token.", "components": ["okta-totp-docs"], "sources": ["okta-totp-docs:sd0899fd16083"], "status": "REASONED"}, "provider-authenticators-okta-ie": {"text": "Okta's Identity Engine custom OTP page says Okta generates an authenticator ID, entered as factorProfileId when enrolling users through the Okta Factors API, that a user can be enrolled in only one custom OTP authenticator instance at a time, and that the authenticator is added to an authenticator enrollment policy.", "components": ["okta-totp-docs"], "sources": ["okta-totp-docs:s6cdace14b67d"], "status": "REASONED"}, "provider-authenticators-okta-settings": {"text": "Okta's Classic Engine Custom TOTP and Identity Engine custom OTP pages list TOTP length and OTP length (the number of digits) respectively, HMAC algorithm, time step, clock drift interval and shared-secret encoding, saying to match HMAC and encoding to the implementation, but no HMAC, encoding or length choices. Okta's Classic Engine Google Authenticator page states no period or algorithm.", "components": ["okta-totp-docs"], "sources": ["okta-totp-docs:sd0899fd16083", "okta-totp-docs:s6cdace14b67d", "okta-totp-docs:s90c65e21037a"], "status": "REASONED"}, "provider-authenticators-google": {"text": "Google Authenticator help: with 2-Step Verification, Google Account sign-in can use the app's codes, even offline. Google's 2-Step Verification page says a user without internet or mobile service can set up Google Authenticator or another app that creates one-time verification codes, naming no other app. Neither page nor Workspace's deployment page gives these codes' algorithms, digits or periods.", "components": ["google-docs"], "sources": ["google-docs:s2c23c2e40308", "google-docs:s9680f27e2deb", "google-docs:s4e673839253c"], "status": "REASONED"}, "provider-authenticators-google-defaults": {"text": "Google Authenticator's Key-Uri-Format wiki gives otpauth defaults of SHA1, 6 digits and a 30-second period and says, in notes unchanged since 2018, that the app's implementations ignore the algorithm and period parameters, and digits on Android and Blackberry. This guide's inference, not a Google statement about Google Account: its authenticator codes use SHA1, six digits and 30 seconds.", "components": ["google-authenticator-wiki", "google-docs"], "sources": ["google-authenticator-wiki:s7c1fe25bfffa", "google-docs:s2c23c2e40308"], "status": "REASONED"}, "admin-passkeys": {"text": "Microsoft Entra, Auth0, Okta, Keycloak and Zitadel document passkey or WebAuthn support. Guide advice: check each provider's tier before relying on availability and require phishing-resistant hardware keys or platform passkeys for administrators. The Auth0 pricing page lists passkeys on Free (September 2026), and Okta's passkey guide uses an Integrator Free Plan org; the cited pages do not establish production-tier availability for the five providers.", "components": ["entra-docs", "auth0", "okta-passkey-docs", "keycloak-source", "zitadel-source"], "sources": ["entra-docs:sa319646e1a02", "auth0:s6d62bb8ed8bf", "auth0:s5794c9fb8add", "okta-passkey-docs:s567594b07020", "keycloak-source:s6c15a8d999b9", "zitadel-source:s27d60b6057d3"], "status": "REASONED"}, "self-hosted-mfa": {"text": "Self-hosted, Keycloak, authentik, Zitadel, Authelia and Ory Kratos, or Ory Hydra connected to an existing identity provider through a login and consent app, give you OIDC and MFA without a vendor (this guide's assessment where the cited ranges do not show it). Guide advice: for a self-hosted deployment, patch, back up and keep the identity service highly available.", "components": ["entra", "authelia-source", "zitadel-source", "authentik-source", "keycloak-source", "ory-kratos", "ory-hydra"], "sources": ["entra:s36db3cb5ed40", "authelia-source:se61b694f1b18", "authelia-source:sa874f6497a22", "zitadel-source:s729880406dc3", "authentik-source:sf805a10ca50a", "authentik-source:s8f5e451e02b4", "authentik-source:s7d192a1be39a", "zitadel-source:sd1f3f5ccaffa", "authentik-source:s18275a7072e9", "keycloak-source:sab71553daacd", "keycloak-source:s718758a5df7b", "keycloak-source:se8310ea39db5", "keycloak-source:s73b2f670dd8f", "ory-kratos:sa1328330301d", "ory-kratos:s3e746241fd0d", "ory-kratos:s0208175a002a", "ory-kratos:s557dc3b182b7", "ory-kratos:sd666b62f8a70", "ory-kratos:s3d2d98558214", "ory-hydra:s7735ed8cb7ad", "ory-hydra:se08c1e7174df", "ory-hydra:s58dac2f41ea0", "ory-hydra:s9e834fe62236"], "status": "REASONED"} } } ```` ====================================================================== ==> self-hosted-idp.md ====================================================================== # Self-hosted identity providers: Keycloak and authentik Keycloak and authentik are self-hosted identity providers that give your apps single sign-on and MFA. This guide helps your AI assistant deploy them securely, including a private bind behind a reverse proxy, a restricted admin surface, authentik's first-run setup finished privately, Keycloak's temporary bootstrap admin replaced, and enforced administrator MFA. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/self-hosted-idp.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Keycloak Admin CLI 26.8.2, Keycloak source 26.8.2, Keycloak release records 26.8.2, authentik source version/2026.8.5, authentik Redis removal 2025.10, and curl documentation curl-8_23_0; 1 further source is pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-11-05; documentation checked September 2026. The guide records 78 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. This guide assesses that a compromised or unavailable self-hosted identity provider takes its dependent services with it. [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) covers choosing one; this guide requires patching, tested backups and keeping the provider available (this guide's requirement; the cited files do not prescribe a cadence). At the time of writing, authentik's pinned security policy lists 2026.5.x and 2026.8.x as supported. Whether authentik version/2026.8.4 or version/2026.8.5 fixes security issues is not established here. Keycloak 26.8.2's security policy says that, depending on severity, vulnerabilities may be fixed in the current major.minor release, or lower-severity vulnerabilities or hardening in the following major.minor release; it encourages those unable to upgrade regularly to consider the Red Hat build of Keycloak, which offers long-term support for specific versions. Keycloak 26.8.1 fixes vulnerabilities that bear on this guide's MFA advice, so run 26.8.1 or later; section 6 quotes them. Keycloak cautions that realm export alone has backup limitations: consistency is not guaranteed unless all nodes are stopped before the export, and user and admin events, persisted sessions, workflow state and revoked tokens are omitted. The fetched files do not establish a complete backup procedure for either provider, an authentik upgrade procedure, a universal maintenance cadence or a tested restore. This guide covers what the provider exposes once it is running: an administration surface that does not move when you tell it to, a first-boot window in which an unauthenticated visitor can claim the administrator account, and a management port that, once you turn it on, answers without asking who you are. ## 1. Bind privately and front it Neither product should answer the internet on the port it listens on. Put it behind the reverse proxy you already run ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md)) and bind the service itself to loopback or a private address ([host.md](https://github.com/jposluns/secureconfig/blob/main/host.md)). At authentik tag `version/2026.8.5` (commit `4ec0d19b98da3c329781894cd550da95d3033954`), the shipped Compose server publishes `${COMPOSE_PORT_HTTP:-9000}:9000` and `${COMPOSE_PORT_HTTPS:-9443}:9443`. With those variables unset, both mappings omit a host address and publish on all host interfaces under Docker's default publishing configuration. These are the file's only host publications; it does not publish metrics port 9300. Inside the container, `listen.http` defaults to `[::]:9000` and `listen.https` to `[::]:9443`, wildcard listeners rather than loopback. Host publication and the container's listen address are separate controls. See the pinned Compose and listener-default sources below. ```yaml # compose.yaml: REPLACE the existing ports list, do not add to it. Compose merges ports by # address, target, published port and protocol, so a new mapping leaves an old one in place. # With default variables and Docker publishing defaults, BOTH ports reach every interface. services: server: ports: - "127.0.0.1:9000:9000" - "127.0.0.1:9443:9443" ``` Keycloak in production requires a secure channel. Its production guide states that "all communication to and from Keycloak requires a secure communication channel", and "To prevent several attack vectors, you enable HTTP over TLS, or HTTPS, for that channel." Terminating TLS at the proxy is edge termination, not end-to-end encryption, and it is only acceptable where the hop from proxy to Keycloak cannot be observed: a loopback interface, or a private network segment you control ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) for the public certificate, [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md) for the host itself). Both products also keep everything they know in a database, which is inside this boundary and not outside it ([postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md)). ## 2. Tell Keycloak its hostname, in production mode Keycloak runs in one of two profiles and they do not have the same defaults. The hostname guide states that "In production profile (`kc.sh|bat start`), either `--hostname` or `--hostname-strict false` must be explicitly configured", and that "This does not apply for dev profile (`kc.sh|bat start-dev`) where `--hostname-strict false` is the default value." `hostname-strict` is what stops Keycloak building URLs out of whatever `Host` header a request carries. The vendor's own description: "Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless your reverse proxy overwrites the Host header." ```bash kc.sh start \ --hostname https://id.example.com \ --hostname-admin https://id-admin.internal:8443 \ --proxy-headers xforwarded \ --http-enabled=true # 8080 exists only when you ask for it. Only do this where # the hop from the proxy is loopback or a private segment ``` At Keycloak `26.8.2`, `--http-host` defaults to all addresses (`0.0.0.0`) in production. Set it to the intended loopback or private interface, or use network rules that permit only the proxy, and check the effective listener in Verify step 1. `start-dev` is not a production deployment with a convenient default. It is a different profile whose default is the one you would otherwise have to ask for. ## 3. `hostname-admin` does not restrict the admin API This is the control the product does not have, and it is the reason this guide exists. Setting `--hostname-admin` moves the Administration Console to its own hostname. It does not close the old door. Keycloak states it outright: > Using the `hostname-admin` option does not prevent accessing the Administration REST API endpoints via the frontend URL specified by the `hostname` option. If you want to restrict access to the Administration REST API, you need to do it on the reverse proxy level. So the separation has to be enforced by you, at the proxy, on the public hostname. Keycloak's reverse proxy guide says the same thing about routing: "Exposed admin paths lead to an unnecessary attack vector", and lists `/admin/` as a path to expose "Only internally". The same table marks `/realms/master/` as "Only internally" and `/realms/` as exposed, which is why the rule above closes the administrative realm and not the realms your applications use. ```nginx # In the server block for the PUBLIC hostname id.example.com. # Applications need the realm endpoints. Nobody on the internet needs /admin/. location /admin/ { return 404; } # By this guide's reading, which is not established at 26.8.2, the Admin Console # AUTHENTICATES against this hostname even when hostname-admin moves its API calls # elsewhere, because Keycloak builds the console's auth URL from the FRONTEND base URL. So # a blanket 404 here would lock every administrator out. Restrict by SOURCE instead, which is what # Keycloak asks for: "restrict access to the authentication protocol endpoints of the # administrative realm from public IP addresses." location /realms/master/ { allow 203.0.113.0/24; # where your administrators connect from deny all; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Prefix ""; } location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; # overwrite, never append proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Prefix ""; } ``` This is a fragment for the public hostname's existing `server` block, not a replacement for it. Two different things happen on two different hostnames and it is worth being precise about which. The console's API calls go to the admin hostname, because Keycloak states that the "Administration Console implicitly accesses the API using the URL as specified by the `hostname-admin` option". By this guide's reading, its LOGIN does not: Keycloak builds the console's authentication URL from the frontend base URL, so the browser is sent to `id.example.com/realms/master/` to authenticate no matter where the console itself is served. The hostname page as served on 2026-11-05, before 26.8.2 was published, does not say so, so this reading is not established at 26.8.2; step 6 of the Verify section shows where your own console sends the browser. That is why the rule above restricts that path by source address rather than refusing it, and why an earlier version of this guide that refused it outright would have locked out every administrator including the one who applied it. A denied source gets a 403 from nginx rather than a 404, which is what step 5 of the Verify section looks for; if you see a 404 there, the path is being refused outright rather than restricted, and your administrators are locked out along with everyone else. The admin hostname is a separate `server` block on `id-admin.internal`, reached over a tailnet or an SSH forward per [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md), and it must proxy `/admin/`, `/resources/` and `/.well-known/`. Add `/js/` as well if you run a version whose console still loads its adapter from there; whether the 26.8.2 console does is not established. Step 6 of the Verify section is the test, and it checks that administrator login still WORKS rather than that something is closed. Keycloak's 26.8.0 release record says that "The Client Admin API v2, introduced as an experimental feature in Keycloak 26.7, has been promoted to preview", and that "It can now be enabled with `--features=preview` or `--features=client-admin-api:v2`". The path that API is served under at 26.8.2 is not established, so neither is whether the `/admin/` rule above refuses it. If you enable it, find its path and check that the public hostname refuses it. Keycloak's 26.8.0 release record also says that "In this release, the SCIM API is promoted from preview to supported", for an API that "provides a standards-based interface for managing users and groups within a realm". The server administration guide as served on 2026-11-05, whose page links name version 26.8.1, says the API "is exposed under the following base URL": `http://:/realms//scim/v2`. That page also says the feature must be enabled on the server and then for each realm, and that the API "requires a bearer token with the appropriate permissions". This guide infers that a realm other than `master` with SCIM enabled serves this user and group management API on the public hostname, because the rule above refuses `/admin/` while leaving `/realms/` exposed. The rule restricts `/realms/master/` by source address, as Keycloak's reverse proxy guide asks: "Assuming the administrative realm is called `master`, restrict `/realms/master/` to internal access." So, by the same inference and on that base URL, SCIM in the `master` realm would answer only the sources the rule allows. Whether SCIM is enabled by default at 26.8.2, and the path it is served under at 26.8.2, are not established. If you enable it, find its path and decide at the proxy which clients may reach it. Overwriting rather than appending matters. Keycloak's reverse proxy guide: "Take extra precautions to ensure that the client address is properly set by your reverse proxy via the `Forwarded` or `X-Forwarded-For` headers. If these headers are incorrectly configured, rogue clients can inject false values and trick Keycloak into thinking the client is connecting from a different IP address than the actual one." An appended header lets a client choose the first value, which is the one most code reads. Overwrite all five, not the two you were thinking of. `--proxy-headers xforwarded` parses "`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port`, and `X-Forwarded-Prefix`", and nginx passes through any request header you do not set, so the three you leave alone arrive exactly as the client sent them. Keycloak also offers `--proxy-trusted-addresses`, and its default is to trust every address. At 26.8.2 the option's description reads "If set, then proxy headers from other addresses will be ignored. By default all addresses are trusted.", and the reverse proxy guide says that by default Keycloak "accepts proxy headers from any source". So with `--proxy-headers` set and this option unset, Keycloak parses forwarded headers from any client that reaches it, not only from your proxy. Set it to your proxy's IP addresses or CIDR ranges; Keycloak's own loopback example is `--proxy-trusted-addresses=127.0.0.1/32,::1/128`. At 26.8.2 the option also accepts an IPv6 address in square brackets, a hostname, and an address or hostname with a `:port` suffix, but for any hostname other than `localhost` its validator logs that "Using hostnames is deprecated and will be removed in a future version", so list addresses. Keycloak is honest about what the option is worth: "this is only weak protection because IP addresses can be spoofed." It is worth setting and it is not a substitute for overwriting the headers. Without `--proxy-headers`, requests through a proxy fail rather than fall back: "If you are using a reverse proxy for anything other than TLS passthrough and do not set the `proxy-headers` option, then by default you will see 403 Forbidden responses to requests via the proxy that perform origin checking." ## 4. The first-boot window authentik's initial setup sets the password on the default `akadmin` account; it does not ask the visitor to authenticate as an administrator first. At `version/2026.8.5`, visit the instance root (`/`, with the default web path). While setup is incomplete, it redirects to `/setup`, which plans the flow and redirects to `/if/flow/initial-setup/`. Opening the flow directly in a fresh session is rejected: its expression policy requires the context marker `goauthentik.io/core/setup/started-by` to equal `setup`. This marker comes from the public setup view, not from a credential. The same policy permits a new plan only when `akadmin` is absent or `not akadmin.has_usable_password()`. The flow selects `akadmin` as the pending user, prompts for email, base URL and password, writes the existing user and logs them in. After completion, the setup view records setup as complete, disables the OOBE blueprints and changes the flow to require a superuser. An unauthenticated visitor who can reach a fresh instance and complete this flow can therefore claim its administrator account. Merely loading the page is not the claim, and the planning-time policy is not a promise that already-started sessions are revoked when a password is set. Keep both ports unpublished to untrusted clients and keep the public reverse proxy route closed until setup is complete. For manual setup, use the loopback mappings in section 1 and browse locally or through an SSH forward; a proxy on a private container network can instead reach the service with both host mappings removed. Confirm setup is closed in a fresh session before allowing public traffic. This is the first-boot boundary described in [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md). For unattended setup, supply `AUTHENTIK_BOOTSTRAP_PASSWORD` before first startup through the protected `.env` file already loaded by both Compose services. The pinned startup handler also recognizes `AUTHENTIK_BOOTSTRAP_PASSWORD_HASH` and `AUTHENTIK_BOOTSTRAP_TOKEN`: any nonempty one triggers bootstrap, and successful application of the bootstrap blueprint marks the tenant setup complete. The password-hash form is validated; the source tests specify that the password forms set `akadmin`'s password and the token form supplies an API token. A token is not a browser-login password. Already-setup tenants are skipped, so changing these variables is not password rotation. Verify successful bootstrap and the closed setup route before publication, then remove the bootstrap secret from the startup environment. Protect the file and container-inspection access as described in [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). These are source-derived conditions, not a live demonstration of startup timing. Keycloak's equivalent is a credential rather than a window, and it is meant to be thrown away. Its bootstrap admin documentation states that an account created this way "is **temporary**", that "the account should exist only for the duration necessary to perform operations needed to gain permanent and more secure admin access", and that afterwards "the account needs to be removed manually". This guide infers from the manual-removal instruction that the account does not expire on its own. Create a real administrator, enrol MFA on it per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), then delete the bootstrap account and treat its password as a secret that was on a command line ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ## 5. The management port Keycloak serves health and metrics on a second port. Its management interface documentation states that "Management endpoints such as `/metrics` and `/health` are exposed on the default management port `9000` when metrics and health are enabled", and that "Exposing health and metrics endpoints on the default server is not recommended for security reasons, and you should always use the management interface". The page does document a control: `https-management-client-auth` "Configures the management interface to require/request client authentication", taking `none`, `request` or `required`, and inheriting from the HTTP options when it is not given. Inheriting is the problem, because nothing in this guide's topology sets client authentication on the HTTP options either, so the effective answer is usually `none`. Setting it to `required` is not a one-line change in this topology, either: client certificates authenticate a TLS connection, and the startup command above enables plain HTTP with no server credentials, so there is no TLS on the management interface to carry them. Giving that port its own certificate and trust store comes first. Whether or not you do, keep 9000 on a private interface, which is the control that works without any of it. Your orchestrator needs `/health/ready`; the internet does not. At authentik tag `version/2026.8.5`, core modes start the Rust metrics server: the Rust `main` starts it before the per-mode dispatch (healthcheck exits earlier), so proxy mode in a proxy-feature build starts it too. AllInOne and Worker modes start workers and store their handles in that server, and Server and AllInOne modes store the server handle. Its mode-dependent handler renders Prometheus metrics and appends Python metrics for server, worker and all-in-one modes. The router's fallback sends every path, including `/metrics`, to the handler; its wrapper has no authentication layer. The Rust metrics listener binds `listen.metrics` with plain HTTP (default `[::]:9300`) and a Unix socket. A Go web server metrics listener is also present at the tag: it serves `/metrics` with promhttp plus upstream metrics fetched from `-/metrics/` and calls `http.ListenAndServe`. `WebServer.Start` calls `runMetricsServer` once per `listen.metrics` address, and the Go `cmd/server` command calls `WebServer.Start`, which this guide's search of the version/2026.8.3 tree found as the only caller chain. The tree's main Dockerfile builds the Rust `authentik` binary with the `core` feature and copies it into the image, whose `ak` entrypoint runs `authentik server` for the `server` command that the shipped Compose server service uses. This guide's search of the version/2026.8.3 tree found no image or release build of `cmd/server`, and a developer debug launch targets it. Neither search was repeated at version/2026.8.5, so their results are not established there. By this trace, the shipped server image's metrics listener is the Rust one. Whether the published image was built from this Dockerfile is NOT ESTABLISHED. The LDAP outpost builds its metrics router with `ak.MetricsRouter()`, which attaches a bare Prometheus handler at `/metrics`, and passes that router to both its HTTP metrics server on each metrics listen address and its Unix-socket server. Section 7 cites pinned proxy, LDAP and RADIUS listener code, but RAC listeners were not traced. The cited outpost source does not establish its default listen address. The shipped Compose file does not publish 9300, but that does not protect the container-network listener. Restrict metrics reachability to trusted monitoring clients and do not add a public host mapping. ## 6. Enrolment is not enforcement Every application behind this provider inherits its authentication, which means it inherits the administrator's authentication too. Enrol a phishing-resistant factor on the administrator account before you connect the first application, not after (this guide's requirement; the cited sources do not state this ordering). Both products support TOTP and WebAuthn; authentik's pinned TOTP and WebAuthn setup stages show it has enrolment stages for both factors. [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) covers the enrolment and the reason to require a hardware key or passkey for administrators specifically. Enrolling a factor is not the same as requiring one. In Keycloak the requirement lives in the realm's authentication flow: when its default conditional 2FA sub-flow is reached, it asks for OTP if the user has a configured OTP credential; a successful Cookie execution skips the sub-flow, and passwordless WebAuthn authentication can avoid 2FA. Its WebAuthn Authenticator execution is disabled by default. Required executions must succeed, but a Required second factor can be enrolled during authentication without proving possession of a preexisting factor. At authentik version/2026.8.5, an Authenticator Validate stage must be bound to the authentication flow; its unconfigured-device action defaults to SKIP. For a user with no allowed device, DENY refuses access. CONFIGURE errors and refuses when no configuration stage is set, inserts one stage when exactly one is set, or offers a choice when several are set; with a configuration stage set, only DENY refuses that user. The cited authentik files describe stage behaviour and flow binding but do not establish an administrator-specific factor policy. Test it the way [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) says to: open a fresh session with only the administrator password and confirm access is refused. Two realm settings outlive everything above and neither is touched by it. Keycloak states that "Brute force detection is disabled by default. Enable this feature to protect against brute force attacks", and it is a realm setting rather than a deployment one, so a hardened deployment still accepts unlimited password guesses until you turn it on. And redirect URIs are worth a deliberate pass. Keycloak allows a wildcard at the end of a pattern, which is narrower than it sounds and still widens the set of destinations a token can be sent to; a bare `*` widens it to everything, for anyone who can reach the authorization endpoint, which is everyone. Register exact URIs per [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), and treat every wildcard as something you chose rather than something you inherited. Keycloak's release records for 26.7.5 and 26.8.1 list security fixes that bear on this section. The 26.8.2 record, published on 2026-11-05, has an empty body, so whether 26.8.2 fixes security issues is not established. Keycloak 26.8.1 fixes CVE-2026-94218, "2FA setup enforcement bypass via /realms/{realm}/login-actions/restart", and CVE-2026-96445, "Conditional OTP trusts skip headers on untrusted proxy paths". This guide infers from those titles that on earlier releases an enforced second factor can be bypassed under conditions the records do not describe. The records give only the titles, so what each bypass needs, and whether either fix is in a 26.7.x release, are not established. CVE-2026-96445's title also names untrusted proxy paths, which bears on section 3's forwarded-header advice; whether overwriting the headers at the proxy, or setting `--proxy-trusted-addresses`, prevents it is not established. Keycloak 26.7.5 fixes CVE-2026-16103, "Incomplete fix for CVE-2026-9798 (Keycloak CIBA brute-force lockout bypass at token redemption)", and CVE-2026-88770, "Device Authorization Grant issues tokens to brute-force-locked accounts (sibling of CVE-2026-9798)", which bear on the brute force detection above. The 26.8.0, 26.8.1 and 26.8.2 records do not list those two, so whether those releases contain both fixes is not established. Run Keycloak 26.8.1 or later. The 26.7.5, 26.8.0 and 26.8.1 records list 36 other security-fix entries, which this guide assesses bear on no statement it makes; one more 26.8.1 entry under that heading is a flaky-test fix. ## 7. authentik has no equivalent to `hostname-admin` Section 3 is Keycloak-specific because the control it describes is. At `version/2026.8.5`, authentik routes `/if/admin/` alongside `/if/user/` and flows in its core URL configuration. This guide assesses that authentik has no equivalent to `hostname-admin`: the cited configuration, Go startup code and Rust server startup show no separate administration listener, not an exhaustive proof of deployed topology. Say that out loud rather than assuming the section above covers both products. The pinned defaults set HTTP to `[::]:9000`, HTTPS to `[::]:9443` and metrics to `[::]:9300`; the Go `ListenConfig` has no administration field, and the Rust `ListenConfig` holds HTTP, HTTPS and metrics addresses, an optional `debug_tokio` address and trusted-proxy CIDRs. `WebServer.Start` starts configured HTTP, HTTPS and metrics listeners plus a Unix socket; its plain HTTP and Unix listeners serve the same router, whose web-path handler proxies to the application after embedded-outpost handling, and its HTTPS listener wraps a TCP listener in TLS and serves that router too. The tree's main Dockerfile and `ak` entrypoint run the Rust binary instead (section 5). In server mode, Rust startup spawns gunicorn on a Unix socket, then serves one router over plain HTTP on each `listen.http` address, over rustls on each `listen.https` address and on a Unix socket. That router sends requests for embedded-outpost applications to the outpost and the rest to the core router, which answers `/-/health/` itself, serves static assets and forwards everything else, including websocket upgrades, to gunicorn. Worker mode serves a health-check router on `listen.http`; in all-in-one mode the worker starts no HTTP listener of its own. These files show shared application routing, not a separate admin process or port. They do not prove which listeners a deployed build starts; deployed topology beyond the shipped Compose file is NOT ESTABLISHED. Both interface routes use `BrandDefaultRedirectView`: authenticated external, service-account and internal-service-account users are redirected to a default application or denied; other requests fall through to `InterfaceView`. These view classes show no administrator-permission check, so this guide does not treat loading the admin template as proof of authorization. The API v3 router registers applications' `api_urlpatterns`, including core users, groups, tokens and admin authenticators. The Go API client targets `api/v3` below the configured web path. The settings make the root URL module the root URL configuration, and it mounts each application's URLs under the web path; the API application's mountpoint is `api/` and its URL module mounts `v3/`, so the v3 router is served at `api/v3/`, which is `/api/v3/` with the default web path `/`. The REST framework settings set `TokenAuthentication` and then REST framework `SessionAuthentication` as the default authentication classes, `ObjectPermissions` as the default permission class and `ObjectFilter` among the default filter backends. Application settings modules and a `data.user_settings` module can update these settings; this guide's search of the version/2026.8.3 tree found no application settings module that does, a result not established at version/2026.8.5, and deployment overrides are NOT ESTABLISHED. Endpoints can replace the defaults: the users and groups viewsets list `TokenAuthentication`, `SessionAuthentication` and `AgentAuth`, and the flow executor sets `AllowAny`; this guide's search of the version/2026.8.3 tree found 66 non-test lines that set authentication or permission classes, a count not established at version/2026.8.5. `TokenAuthentication` recognizes API-intent tokens, OAuth access tokens with the authentik API scope, the managed-outpost secret and the IPC key, rejects inactive users and raises an authentication failure for unrecognized credentials; absent or unsupported authentication can return no user. `ObjectPermissions.has_permission` passes every object-specific request (its comment leaves view checks to `ObjectFilter` and writes to `has_object_permission`) and permits configured creation without an add permission; `has_object_permission` accepts global permissions or configured ownership before delegating to the parent object check; `HasPermission` checks `user.has_perms`. These are authentication and RBAC implementations, not proof that every administration endpoint applies them. `SessionAuthentication` is implemented in Django REST framework, outside the pinned tree, so its behaviour is NOT ESTABLISHED here; a per-endpoint audit and a complete permission boundary remain NOT ESTABLISHED. Build any network boundary around authentik administration at the proxy using path restrictions. Identify and test the required paths for the deployed version and maintain that list. This guide assesses that the UI is not designed around this separation and that path-based separation is difficult to maintain. The pinned core URLs also show HTTP admin-template fallbacks at `/ws/outpost//` and `/ws/client/`, and a `/ws/client/` websocket route wrapped in authentication middleware; together with the interface routes and API client prefix, these are paths to investigate, not a complete restriction list or proof of websocket authorization. The Rust static router also serves `/if/admin/assets/` under the configured web path (`/` by default) alongside the user, flow and RAC interface assets. The ASGI application routes websockets through a table built from applications' `websocket_urlpatterns` and nested under the web path; this guide's search of the version/2026.8.3 tree found three such modules, giving `/ws/client/`, `/ws/outpost//`, `/ws/rac//` and `/ws/outpost_rac//` with the default web path `/`. That search was not repeated at version/2026.8.5, so whether that release adds a module is not established. The `/ws/client/` stack resolves the session user, or an anonymous user, and its consumer accepts every connection, joining the user's group only when that user is authenticated. Both outpost routes deny a connection without a bearer token that `TokenAuthentication` accepts; the outpost consumer also requires view permission on that outpost, while the RAC outpost consumer accepts with no outpost-specific check on `/ws/outpost_rac/`. The RAC client consumer accepts, then denies unless the connection token belongs to the session. The flow executor is registered under the API router at `flows/executor//` with `AllowAny`; the API schema names that path's GET operation `flows_executor_get`, and the flow interface calls `flowsExecutorGet`, which this guide takes to be the generated client method for that operation, so this guide infers that refusing the whole API prefix would break login. This guide read the Rust server, core and static routers, the root URL module, application mountpoints, the core, flow, API and v3 URL modules, and the websocket table with the three modules found at version/2026.8.3; any path list derived from them is bounded by those tables. This guide's proxy advice is not a vendor-supported path-isolation recipe; complete path coverage remains open. authentik also reaches past this host. Its Compose file, by default, "mounts the Docker socket to the authentik worker container", which the vendor notes "comes with some inherent security risks", and which exists so authentik can deploy and manage outposts. Docker says only trusted users should control its daemon and that the daemon requires root privileges unless rootless mode is used. This guide assesses access to an ordinary rootful daemon socket as host-root-equivalent control. Docker says rootless mode runs the daemon and containers as a non-root user inside a user namespace, without root privileges; this guide assesses the blast radius as the daemon user and what that account can access. The general authentik installation warning and Docker privilege-model sources do not directly demonstrate host-root equivalence or the user-scoped blast radius. Check the daemon type and restrict socket access. The vendor's own options are a Docker socket proxy or removing the mount and deploying outposts by hand. Whichever you choose, check every deployed outpost's exposure. At version/2026.8.5, authentik supports Proxy, LDAP, RADIUS and RAC outposts. Its embedded Proxy outpost runs inside the main server on the same 9000 and 9443 ports, so it does not add a separate listener port. Managed Docker or Kubernetes outposts, and manually deployed outposts, can run elsewhere. The pinned Go proxy code listens on configured HTTP, HTTPS and metrics addresses. The pinned `proxy.Dockerfile`, titled the authentik proxy outpost image, builds the Rust `authentik` binary with only the `proxy` feature, has no `go build` step, copies only that binary and sets `/authentik proxy` as its entrypoint. That subcommand starts the Rust proxy outpost, which serves plain HTTP on each `listen.http` address and rustls on each `listen.https` address unless it is the embedded outpost; Rust startup schedules the metrics server on `listen.metrics` before that dispatch, and the file exposes 9000, 9300 and 9443. This guide infers that an image built from that file runs the Rust proxy outpost rather than the cited Go proxy code; whether published images were built from it is NOT ESTABLISHED. The Docker controller maps the outpost's ports to the host unless `docker_map_ports` is false; it defaults to true. The Kubernetes controller reconciles Deployment and Service objects. The LDAP and RADIUS listener code is also pinned, but RAC listeners were not traced. This guide assesses that the Verify probes below do not demonstrate any outpost boundary; check each deployed outpost separately. ## Verify REASONED: scope of every live check below; the authoring host forbids opening listeners without an isolated network namespace, and has none, and ran neither identity provider. The fenced shell blocks below passed bash -n (Bash 5.3.9) and ShellCheck 0.11.0 at marking time with no diagnostics in an earlier form, which ran steps 1 to 8 as one block; the repository's gate suite runs bash -n and ShellCheck 0.11.0 over their current forms, whose exit statuses were also stub-run under Bash 5.3.9 with curl, ss and docker replaced by local stubs. Those stub runs date from 2026-10-31, and the steps 2 to 5 and step 7 blocks were stub-run again on 2026-11-01 in their current forms, except that step 8's comments, in the step 7 block, were reworded later with no command changed. In those 2026-11-01 runs, under Bash 5.3.9, both blocks refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with status 2 and no curl call, also after an inherited `shopt -s nocasematch`, `readonly`, `declare -i` or `set -e`, and passed other names, such as notexample.com and example.com.evil.io, to curl. Under dash and busybox sh, both blocks refused every name tried with 2 and "this block needs bash; run it in bash; not probing" before any probe. Live checks here are **REASONED**: the authoring host forbids opening listeners without an isolated network namespace, and has none. This source audit ran neither identity provider. The expected outcomes, including those of the existing checks below, follow the cited vendor documentation and pinned sources; the pinned curl and Keycloak sources support the documented exit codes, timeout options, proxy controls and listener defaults; no deployed outcome was observed. This guide assesses that a password-only refusal for an already-enrolled administrator does not distinguish enrolment from enforcement: Keycloak's default configured-OTP condition and authentik's validation-stage behaviour can both refuse that login. Test a user with no registered factor and inspect the realm flow or authentik stage settings. The runnable Verify commands below retain their existing pass conditions. REASONED: authentik setup-flow comparison per the cited pinned root redirect, setup view, OOBE policy and source tests at version/2026.8.5; no disposable authentik instance or isolated test network is available in the authoring environment, so the expected redirects and logins were not observed. For authentik's setup check, use a disposable instance in an isolated test network and a fresh browser session. Open `http://127.0.0.1:9000/` locally or through the SSH forward. In the exposed state, with no bootstrap credentials and no usable `akadmin` password, expect the redirects through `/setup` to `/if/flow/initial-setup/`, then the setup form without a login. Complete it with test credentials and confirm those credentials log in as `akadmin`. After setup, a new anonymous session visiting `/setup` must return through the root to authentication rather than offer setup. Repeat on a fresh instance bootstrapped with `AUTHENTIK_BOOTSTRAP_PASSWORD`: expect login with the supplied password and no setup form. A direct visit to the flow is not a sufficient negative check because it is denied even before setup. The pinned root redirect, setup view, OOBE policy and source tests distinguish these outcomes; the tests were read, not executed here. REASONED: network-boundary comparison per the cited shipped Compose publications at version/2026.8.5; no isolated exposed fixture or untrusted test client is available in the authoring environment, so neither reachability state was observed. For the network boundary, compare section 1's shipped publications with the loopback replacement using step 1 below. In the isolated exposed fixture, an untrusted test client can reach the setup route; with the loopback mappings and public proxy route closed, that client must not reach it, while the local browser still can. Retain this positive control so a stopped service cannot pass as an effective restriction. Confirm that neither configuration publishes 9300; its wildcard listener inside the container remains a separate monitoring-network boundary. REASONED: authentik replacement-route design from the pinned core, OAuth2 provider, OAuth2 app configuration, root URL, app registry, installed-app settings, API mount and flow files; no deployed authentik route was probed. For authentik, substitute the deployed application's OAuth2 discovery URL as the positive control and confirm that its issuer matches the configured global or per-provider value. Check that an untrusted client cannot reach the chosen administration route while an allowed administrator can. Check the administration API surface you choose to restrict separately: an untrusted client must be refused while an authorized administrator on the allowed network can complete an API request. Do not refuse the whole API prefix without tracing login dependencies; confirm that an ordinary user can still complete login. The pinned OAuth2 file defines the per-application discovery suffix and `/if/admin/` is a core route. The pinned OAuth2 app configuration mounts `authentik.providers.oauth2.urls` at `application/o/` and `authentik.providers.oauth2.urls_root` at the root. The pinned settings install that app and name `authentik.root.urls` as the root URL configuration, which mounts each installed authentik app's mountpoints under the web path, so the discovery URL is `application/o//.well-known/openid-configuration`, which is `/application/o//.well-known/openid-configuration` with the default web path `/`. These files place the route in authentik's URL configuration; they do not show that a deployed instance answers it, so the discovery-URL and issuer check above remains the positive control. Section 7's cited files place the v3 router at `api/v3/`, which is `/api/v3/` with the default web path `/`, and register the flow executor under it at `flows/executor//` with `AllowAny`. This guide infers that end-user flow execution uses the API prefix and that refusing the whole prefix would break login, because the flow interface calls `flowsExecutorGet`, which this guide takes to be the generated client method for that operation. No deployed login was observed. The Keycloak-route exclusion in the comments of the steps 2 to 5 fence below is this guide's inference from the fetched authentik route files, not a proof of every mounted route. For authentik, that fence's comments say to request the application's discovery URL in step 2 and to put its expected issuer on the `set --` line, so this discovery-URL and issuer check is the fence's positive control there too. REASONED: host listener inventory (step 1) in the following block, per the Keycloak listener and cache-port documentation, the pinned authentik listener defaults and the authentik Redis removal notes cited below; the authoring host has no isolated network namespace for listeners and ran neither identity provider, so no listener table was observed. The block exits 2 with `ss is not installed here; not listing sockets` when `ss` is missing, 3 when `ss -tlnp` fails, and otherwise 0, leaving every listener for you to judge against the ports in its comment, because whether 6379 matters depends on your authentik version and the host table does not show a container's own namespace. ```bash # 1. On the host itself: nothing listens on a public address. Keycloak defaults to HTTPS 8443; HTTP 8080 is enabled explicitly here, # and management 9000; authentik serves 9000 and 9443, and metrics on 9300. Three more # are easy to forget because they are not the product's front door. 7800 and 57800 are # Keycloak's cache transport and failure detection, which carry the sessions and tokens # of a clustered deployment; TLS is on by default there for TCP stacks, so this is about # reachability rather than plaintext, and a reachable cluster port is still a cluster you # did not mean to offer. 6379 is Redis, which authentik used for caching, tasks, the # embedded outpost's session store and WebSocket connections. It moved tasks to # Postgres in 2025.8 and the rest in 2025.10, which "no longer uses Redis at all", so # whether this port matters depends on your version. Check it rather than assume. ( # a subshell, so the exits below end only the subshell command -v ss > /dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tlnp; then # read every listener; 6379/7800/8080/8443/9000/9443/9300/57800: 127.0.0.1 or an RFC 1918 address only echo "block exit 0; unclassified observation: every listener above is yours to judge against the ports in this block's comment" exit 0 else echo "ss -tlnp exit $?: the listener table could not be read; inconclusive (block exit 3)" exit 3 fi ) ``` REASONED: following block; Compose publication cross-check per the shipped Compose file and the Docker Compose CLI reference cited below; no Compose deployment of either provider is available in the authoring environment, so no publication table was observed. Run it from the identity provider's Compose project directory. It exits 2 when `docker` is not installed, which on a host that runs Keycloak without Docker means this check does not apply, or when `docker compose version` fails, which this guide expects without the Compose plugin, 3 when `docker compose ps` fails, and otherwise 0, leaving the published ports for you to judge, since a proxy in the same project publishing 443 is expected. ```bash # ss on the HOST does not see a container's own namespace, and a published Docker port # bypasses the host firewall besides, so cross-check what Compose actually published: ( # a subshell, so the exits below end only the subshell command -v docker > /dev/null || { echo "docker is not installed here; not listing Compose publications"; exit 2; } docker compose version > /dev/null 2>&1 || { echo "docker compose is not available here; not listing Compose publications"; exit 2; } if docker compose ps --format 'table {{.Service}}\t{{.Ports}}'; then # no 0.0.0.0 or :: on the identity provider's OWN rows. A proxy in the same # project publishing 443 is correct and expected. This also sees one Compose # project, not every container on the host. echo "block exit 0; unclassified observation: the published ports above are yours to judge" exit 0 else echo "docker compose ps exit $?: run it from the identity provider's Compose project directory; inconclusive (block exit 3)" exit 3 fi ) ``` REASONED: following block; steps 2 to 5 per the Keycloak hostname, reverse proxy and production documentation and the nginx `location` and `return` documentation cited below; no deployed provider or outside test host is available in the authoring environment, so none of these answers was observed. Run it from a host outside every network you have allowed, with your public host name, the realm your applications use and the issuer you expect on its `set --` line. It refuses with status 2 if a value is left unsubstituted or malformed, if the host is example.com, example.net, example.org or one of their subdomains, if the realm is master, if `python3` is missing, if a proxy variable cannot be cleared or if it is pasted into a shell other than bash, such as dash or busybox sh, where `shopt` does not exist, because the block needs bash. It also refuses with status 2 if the `PASTE_WHOLE_BLOCK` marker that opens its `set --` line is missing, as when that line is left out of the paste, printing "paste the whole block, including its set -- line; not probing", or if that line does not carry exactly three values. Step 2 is the positive control: unless the discovery document's issuer is a JSON string equal in full to the one on the `set --` line, the block reports 3 and does not run steps 3 to 5. Step 3 passes on 404 and is a finding (1) on any 2xx, any 3xx, 401 or 403; step 4 passes on 404 and is a finding on 401; step 5 passes on 403 and is a finding on 200 or 404. Any other code, or a request that fails, is inconclusive (3), and a finding outranks an inconclusive result. The paths are Keycloak's; on authentik, adapt them and the issuer as the block's comments and the authentik paragraph above say. Steps 3 to 5 sample three routes, so the rest of the administration surface and step 6's login stay for you to check, as the block's last line says. Step 6's reason, that Keycloak builds the console's authentication URL from the frontend hostname, is this guide's reading and is not established at 26.8.2 (section 3), so watch where the browser actually goes. ```bash # Steps 2 to 5 and 7 must run from a host OUTSIDE every network you have allowed, against a # system you are authorized to test. Neutralize the proxy settings first, all of them: # curl reads ALL_PROXY and all_proxy as well as per-scheme variables (http_proxy is # lowercase only), and it reads ~/.curlrc too. This guide assesses that a proxy whose # egress sits inside an allowed range can make a blocked endpoint appear reachable # from a location that should have been refused. This block and step 7's clear those # variables inside their own subshells, so your own shell keeps its settings, and pass # --noproxy '*' to every curl as well. # -q as curl's first option ignores ~/.curlrc, which can itself set a proxy or turn verification off. # Every path below is KEYCLOAK's. authentik does not serve /realms/ or /admin/master/, # so on authentik substitute its own application and administration URLs. In step 2, request # your application's OAuth2 discovery URL and put the issuer it should name on the set -- line: # step 2 passes only when that document's issuer is exactly that value. ( # a subshell, so your own script arguments and proxy settings are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_HOST' 'REPLACE_WITH_YOUR_APP_REALM' 'REPLACE_WITH_YOUR_ISSUER_URL' # the public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot), the realm your APPLICATIONS use (not master), then the issuer URL step 2 must print: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|master|*/*|*[?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the realm your applications use (not master; no slash, query, fragment or percent sign) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|*[[:space:][:cntrl:]]*) echo "substitute the issuer URL your applications expect, exactly as they are configured with it, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac command -v python3 > /dev/null || { echo "python3 is not installed here, and step 2 needs it to read the issuer; not probing"; exit 2; } unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy || { echo "a proxy variable is readonly in this shell and cannot be cleared; not probing"; exit 2; } set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # 2. POSITIVE CONTROL, and the reason every check below means anything: the public # hostname answers from out here. Use the realm your APPLICATIONS use, not master: # section 3 restricts the master realm to your administrators' range, so probing it here # would test the wrong thing and tell you nothing about the realm your applications use. # python3 reads the discovery document straight from curl through a pipe, never from a shell capture, # which would drop a NUL byte, and exits 4 for an issuer that is not a string or holds a NUL byte. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g --noproxy '*' -sS "https://$1/realms/$2/.well-known/openid-configuration" | python3 -c 'import json,sys; d=json.load(sys.stdin.buffer); i=d["issuer"]; isinstance(i, str) or sys.exit(4); chr(0) in i and sys.exit(4); print(i)'; then printf '\n%s %s' "${PIPESTATUS[0]}" "${PIPESTATUS[1]}"; else printf '\n%s %s' "${PIPESTATUS[0]}" "${PIPESTATUS[1]}"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's and python3's exit statuses, then all python3 printed, ending in print's own newline set -- "$1" "$2" "$3" "$4" "${5%% *}" "$6" "${5#* }" # curl's exit status, python3's output, then python3's exit status if [ "$5" != 0 ]; then echo "curl exit $5: the discovery request failed, so the positive control failed; inconclusive"; set -- "$1" "$2" "$3" 3 else set -- "$1" "$2" "$3" "$4" "$7" "${6%$'\n'}" # python3's exit status, then the whole issuer without print's newline, so an issuer with a line break in it cannot match printf '%s\n' "$6" # It must print YOUR issuer URL. A 200 alone proves only that something answered: a wrong # upstream or a generic responder satisfies it while the real provider sits exposed # elsewhere. If this does not print the issuer you expect, stop reading and fix that first, # because every check below will "pass" for the wrong reason. if [ "$5" != 0 ]; then echo "python3 exit $5: the answer is not a discovery document with an issuer, so the positive control failed; inconclusive"; set -- "$1" "$2" "$3" 3 elif [ "$6" != "$3" ]; then echo "the issuer printed above is not the one on the set -- line, so the positive control failed; inconclusive"; set -- "$1" "$2" "$3" 3 fi fi [ "$4" = 0 ] || { echo "block exit 3; steps 3 to 5 did not run: fix step 2 first, because until it prints your issuer their answers mean nothing"; exit 3; } # 3. The administration console is closed on the PUBLIC hostname. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' "https://$1/admin/master/console/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # 404 is the pass. A 302 to a login page is NOT a pass: it means the console answered # and is offering to authenticate you. Any 2xx, any 3xx, 401 or 403 all mean the path # was served by something. case "$5:$6" in 0:404) ;; 0:2??|0:3??|0:401|0:403) echo "FINDING: the console path answered $6 on the public host name, so something served it"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "HTTP $6 from the console path is neither the 404 pass nor a code step 3 reads as served; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the console request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # 4. The administration REST API is closed on that same hostname. hostname-admin does # not do this for you, so this is the step that proves your proxy rule does. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' "https://$1/admin/realms/master/users"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # 404 is the pass. 401 means the API answered and asked for a token. case "$5:$6" in 0:404) ;; 0:401) echo "FINDING: the admin REST API answered 401 on the public host name, so it is still served there"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "HTTP $6 from the admin REST API path is neither the 404 pass nor the 401 finding; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the admin REST API request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # 5. The administrative realm's login paths are RESTRICTED, not refused. Section 3 allows # them from your administrators' range and denies everything else, so from out here: # 403 is the pass. nginx answers a denied source with 403, not 404. # 200 means the allow rule covers the whole internet, or is not there at all. # 404 means you used `return 404` rather than an allow rule, which closes the path for # your administrators too; see section 3 for why the console needs it on this hostname. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' \ "https://$1/realms/master/.well-known/openid-configuration"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" case "$5:$6" in 0:403) ;; 0:200) echo "FINDING: the master realm answered 200 from out here, so the allow rule covers the whole internet or is not there at all"; set -- "$1" "$2" "$3" 1 ;; 0:404) echo "FINDING: the master realm answered 404, so return 404 closes the path for your administrators too"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "HTTP $6 from the master realm is not the 403 pass or a 200 or 404 finding; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the master realm request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # Steps 3 to 5 sample three routes and expect two different answers: 404 from the two # /admin/ paths, which are refused outright, and 403 from the master realm, which is # restricted by source. Either way they prove those three requests were handled as intended, # not that the whole administration surface is. A more specific `location` elsewhere in the # same server block can still serve one, so read the effective configuration too. echo "block exit $4; steps 3 to 5 sampled three routes, so the rest of the administration surface (read the effective configuration) and step 6 below are yours to check" exit "$4" ) # 6. An administrator can still log in. This is the half the rule in section 3 can break and # the half nobody tests. From inside the network you allowed in that rule, complete a REAL # login to the console rather than just loading the page: # open https://id-admin.internal:8443/admin/master/console/ and authenticate. # Watch where the browser goes. It SHOULD be sent to id.example.com/realms/master/ to # authenticate, because Keycloak builds the console's auth URL from the frontend hostname, # and it should come back. If that request 403s, your allow rule does not cover the address # you are coming from. If it 404s instead, `return 404` is still in place on that path, # which is the state step 5 warns about and it locks out every administrator. If you are # testing from outside the allowed range it SHOULD fail, and that is the rule working # rather than a fault. ``` ```bash # 7. The management, metrics and application ports are not routable from out here. All of # them: a reader who remapped 9000 and left 9443 published passes every other step. # --connect-timeout is separate from -m so the two deadlines are distinguishable in the # output, not because it changes the exit code: this guide infers that a port that # accepts the connection and then stalls, and one never reached before the deadline, # can both end in exit 28. The diagnostic may distinguish them, so print it whole. # Run it from the same outside host as steps 2 to 5; like that block, it clears the # proxy variables in its own subshell and starts curl with -q and --noproxy '*'. ( # a subshell, so your own script arguments and proxy settings are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_HOST' # the public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy || { echo "a proxy variable is readonly in this shell and cannot be cleared; not probing"; exit 2; } set -- "$1" 0 8080 8443 9000 9300 9443 # the host, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports still to probe while [ "$#" -gt 2 ]; do set -- "$1" "$2" "$3" "$(if curl -q -g --noproxy '*' -sS --connect-timeout 3 -m 8 -o /dev/null -v "http://$1:$3/" 2>&1 >/dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:4}" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" "${@:5}" # the host, the status so far, this port, curl's exit status, its diagnostic, then the ports still to probe echo "=== $3: curl exit $4" printf '%s\n' "$5" # Exit 0 means curl completed the HTTP operation, so treat it as an answer. Exit 7 reports # a connection failure, which is consistent with isolation but not proof of it, and counts only # once the diagnostic shows the target you meant. This guide infers that exits 52 and 56 follow an accepted # connection, so a listener is reachable; inspect the diagnostic. Other exits require diagnostic review. # Exit 28 is a timeout and does NOT settle it on its own: this guide infers that a port # that accepted you and then went quiet, and one never reached, can both end there. # The whole diagnostic is printed rather than filtered for that reason. # Read it yourself. A line saying the connection was established, before a timeout, means # something accepted you and then went quiet, which is an answer. Do not reduce this to a # grep for a fixed phrase: curl has changed that wording more than once, and an earlier # version of this guide matched a phrase current curl no longer prints and then read its # absence as proof that nothing was listening. Absence of a line you were looking for is not # evidence. If the output does not settle it, the result is inconclusive and has to be # chased rather than accepted. A firewall that drops rather than refuses connections # gives exit 28 on every port, so this block then ends with 3 and the diagnostics decide it. case "$4" in 0|52|56) echo "FINDING: port $3 accepted the connection from out here (curl exit $4), so a listener is reachable"; set -- "$1" 1 "${@:3}" ;; 7) echo "port $3: connection failed (curl exit 7), consistent with isolation from this host but not proof of it; it counts only if the diagnostic above shows the target you meant" ;; *) echo "port $3: curl exit $4 does not settle it on its own; read the diagnostic above; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "${@:3}" ;; esac set -- "$1" "$2" "${@:6}" done echo "block exit $2; the diagnostics above are yours to read (exit 7 counts only for the target you meant, and a connection established before a timeout is an answer), and step 8 below is a lookup you make yourself" exit "$2" ) # 8. Keycloak's bootstrap admin is gone. This guide infers from manual-removal guidance # that it does not expire on its own. Ask for the name # rather than listing users: the users endpoint's default result limit is not established # at 26.8.2, and a bootstrap account past the end of a capped list looks like a deleted one. # kcadm.sh get users -r master -q exact=true -q "username=REPLACE_WITH_BOOTSTRAP_NAME" --fields username,id # Keep exact=true and inspect the returned username: this guide treats `username=` # without `exact=true` as a non-exact match; the pinned passage documents only the # `q=` form. Whether a default cap applies at 26.8.2 is not established, so an # unfiltered list cannot show that an account is absent. Keep the -q flags in this order as this guide's defensive # advice based on a report that exact=true after the username filter did not take # effect; the pinned admin CLI documentation does not establish that behaviour, and # ordering it defensively costs nothing. # Check the master realm's service accounts too: a bootstrap SERVICE account is created # by the client-id form of the same mechanism and is not in the user list at all. ``` REASONED: preceding block; step 7's direct port probes, step 8's bootstrap-account lookup, the repeat coverage across addresses and families and the surfaces these blocks do not test, per the cited Keycloak and authentik documentation, pinned sources and curl exit-code documentation. No deployed provider, isolated network namespace for listeners, or public address is available in the authoring environment, so none of the steps ran against a provider. The marking-time bash -n and ShellCheck 0.11.0 passes covered the earlier single-block form; the current blocks are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the stub runs recorded at the top of this section. The pinned curl and Keycloak sources support the documented exit codes, timeout options, proxy controls and listener defaults; no deployed outcome was observed. The step 7 block takes your public host name on its `set --` line and refuses with status 2 if it is left unsubstituted or malformed, if it is example.com, example.net, example.org or one of their subdomains, if a proxy variable cannot be cleared, or if the block is pasted into a shell other than bash, such as dash or busybox sh, where `shopt` does not exist, because the block needs bash. It also refuses with status 2 if the `PASTE_WHOLE_BLOCK` marker that opens its `set --` line is missing, as when that line is left out of the paste, printing "paste the whole block, including its set -- line; not probing", or if that line does not carry exactly one value. It records a finding (1) for a port whose curl exit is 0, 52 or 56, and an inconclusive result (3) for exit 28 or any other exit except 7. It records exit 7 as 0 and prints that the connection failure is consistent with isolation but not proof, counting only once the diagnostic shows the target you meant; behind a firewall that drops rather than refuses connections, it cannot end with 0. One name is not one address, and one address is not one origin. Repeat steps 2 to 5 and 7 for every public address the name carries and in both families, since a host filtered on IPv4 while it answers on IPv6 passes all of them. A CDN or load balancer answering on 443 also says nothing about whether the backend has its own reachable address, so check the origin directly as well. And nothing here tests `id-admin.internal`, the database, or any authentik outpost's boundary, including the embedded outpost's routes on the core server ports; those surfaces need separate checks. The port list is the one this guide's own topology creates, not an inventory: scan the whole range if you want one. Step 8's comments give no value for the users endpoint's default result limit: the 100-result default that an earlier version of them gave cites no Keycloak source and is not established at 26.8.2, and step 8's exact-name lookup does not rest on any default. ## Sources (checked September 2026) - Keycloak production configuration, including the TLS requirement and separating the admin REST API and console (checked November 2026): https://www.keycloak.org/server/configuration-production - Keycloak hostname options, including the production and dev profile defaults for `hostname-strict`, and the statement that `hostname-admin` does not restrict the Administration REST API (checked November 2026): https://www.keycloak.org/server/hostname - Keycloak reverse proxy configuration, including `proxy-headers`, `proxy-trusted-addresses`, the forwarded-header warning, and the exposed-paths table (checked November 2026): https://www.keycloak.org/server/reverseproxy - Keycloak bootstrap and recovery admin accounts, including that the account is temporary and must be removed manually (checked November 2026): https://www.keycloak.org/server/bootstrap-admin-recovery - Keycloak management interface and the default management port 9000 (checked November 2026): https://www.keycloak.org/server/management-interface - authentik Docker Compose installation and operational setup instructions (version-specific behavior is pinned below) (checked November 2026): https://docs.goauthentik.io/install-config/install/docker-compose/ - authentik monitoring, including the separate metrics port 9300 and that the metrics carry no authentication (checked November 2026): https://docs.goauthentik.io/sys-mgmt/ops/monitoring - authentik `version/2026.8.5` shipped Compose, image tag, both publications and the services' `.env` inputs (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/compose.yml#L1-L67 - authentik `version/2026.8.5` HTTP, HTTPS and metrics wildcard defaults (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/default.yml#L36-L48 - authentik `version/2026.8.5` shared server and worker metrics router and plain HTTP metrics listener (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/metrics/mod.rs#L70-L100 - authentik `version/2026.8.5` core metrics startup and worker registration (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/main.rs#L99-L122 - authentik `version/2026.8.5` mode-dependent Prometheus metrics handler (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/metrics/handlers.rs#L12-L42 - authentik `version/2026.8.5` metrics router wrapper middleware (no authentication layer) (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-axum/src/router.rs#L36-L59 - authentik `version/2026.8.5` outpost Prometheus handler, metrics listen address and Unix socket serving (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/ak/metrics.go#L18-L72 - authentik `version/2026.8.5` LDAP and LDAPS listener startup, LDAP outpost metrics router and its HTTP and Unix-socket servers (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/ldap/ldap.go#L91-L121 - authentik `version/2026.8.5` Go web server metrics listener present at the tag (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/web/metrics.go#L22-L52 - authentik `version/2026.8.5` administration, user and flow URL routes (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/urls.py#L36-L75 - authentik `version/2026.8.5` Go listener configuration fields (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/config/struct.go#L53-L62 - authentik `version/2026.8.5` Go listener startup and API client prefix (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/web/web.go#L132-L183 - authentik `version/2026.8.5` Go plain HTTP and Unix listeners sharing the router (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/web/web.go#L229-L288 - authentik `version/2026.8.5` Go application web-path proxy and embedded-outpost handling (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/web/proxy.go#L47-L127 - authentik `version/2026.8.5` interface rendering and user-type redirect or denial (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/views/interface.py#L54-L89 - authentik `version/2026.8.5` core route entries, API registrations and websocket route (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/urls.py#L36-L112 - authentik `version/2026.8.5` API v3 application registration and URL assembly (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/authentik/api/v3/urls.py#L18-L58 - authentik `version/2026.8.5` Bearer parsing, IPC user and token authentication (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/authentik/api/authentication.py#L33-L166 - authentik `version/2026.8.5` object and named RBAC permission implementations (checked November 2026): https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/authentik/rbac/permissions.py#L15-L60 - authentik `version/2026.8.5` Rust main metrics startup before mode dispatch and per-mode handle storage (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/main.rs#L57-L135 - authentik `version/2026.8.5` Go `cmd/server` command calling `NewWebServer` and `WebServer.Start`, and the developer debug launch configuration targeting it (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/cmd/server/server.go#L25-L73 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/.vscode/launch.json#L37-L42 - authentik `version/2026.8.5` main Dockerfile Rust `authentik` binary built with the `core` feature (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/Dockerfile#L126-L142 - authentik `version/2026.8.5` main image binary copy and `ak` entrypoint (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/Dockerfile#L234-L252 - authentik `version/2026.8.5` `ak` entrypoint running the Rust binary for server, worker and all-in-one, and the function that executes that command (checked November 2026). The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/ak#L16-L33 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/ak#L35-L54 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/ak#L92-L94 - authentik `version/2026.8.5` Rust server gunicorn Unix-socket backend (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/mod.rs#L86-L106 - authentik `version/2026.8.5` Rust server embedded-outpost routing and HTTP, HTTPS and Unix listeners (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/mod.rs#L260-L352 - authentik `version/2026.8.5` Rust core router websocket upgrade forwarding (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/core.rs#L153-L155 - authentik `version/2026.8.5` Rust core router health, static and gunicorn routes under the web path (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/core.rs#L277-L320 - authentik `version/2026.8.5` shared plain HTTP, Unix-socket and rustls server runners (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-axum/src/server.rs#L22-L170 - authentik `version/2026.8.5` Rust worker-mode HTTP listener and health router (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/worker/mod.rs#L347-L361 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/worker/healthcheck.rs#L66-L76 - authentik `version/2026.8.5` Rust listen configuration fields (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-common/src/config/schema.rs#L203-L214 - authentik `version/2026.8.5` Go TLS listener serving the shared router (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/web/web_tls.go#L46-L72 - authentik `version/2026.8.5` REST framework default authentication, permission and filter classes (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L240-L252 - authentik `version/2026.8.5` root URL configuration and ASGI application settings (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L320-L339 - authentik `version/2026.8.5` app and user settings modules updating REST framework settings (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L566-L609 - authentik `version/2026.8.5` API app `api/` mountpoint and `v3/` mount (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/api/apps.py#L6-L13 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/api/urls.py#L7-L9 - authentik `version/2026.8.5` default web path (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/default.yml#L162-L166 - authentik `version/2026.8.5` Rust configuration loader embedding `default.yml` as its first source, and the Python `SEARCH_PATHS` loader reading `default.yml` first (checked November 2026). The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-common/src/config/mod.rs#L24-L24 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-common/src/config/mod.rs#L71-L89 . More authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/config.py#L26-L28 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/config.py#L86-L111 - authentik `version/2026.8.5` websocket routing table and ASGI protocol router (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/websocket.py#L14-L34 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/asgi.py#L75-L83 - authentik `version/2026.8.5` websocket session authentication stack and client message consumer (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/asgi_middleware.py#L42-L84 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/ws/consumer.py#L38-L52 - authentik `version/2026.8.5` outpost websocket route, bearer-token middleware and outpost permission check (checked November 2026). The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/urls.py#L16-L23 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/channels.py#L13-L43 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/consumer.py#L73-L83 - authentik `version/2026.8.5` RAC websocket routes, client connection-token check and outpost consumer acceptance (checked November 2026). The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/rac/urls.py#L30-L43 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/rac/consumer_client.py#L63-L96 . More authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/rac/consumer_outpost.py#L14-L16 - authentik `version/2026.8.5` flow executor API route and `AllowAny` permission (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/urls.py#L36-L50 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/views/executor.py#L101-L104 - authentik `version/2026.8.5` flow interface call to the flow executor API, and the API schema's `flows_executor_get` operation for the executor path (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/web/src/flow/FlowExecutor.ts#L224-L228 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/schema.yml#L9288-L9290 - authentik `version/2026.8.5` Rust static interface asset routes (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/static.rs#L159-L168 - authentik `version/2026.8.5` users and groups viewset authentication classes (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/api/users.py#L650-L654 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/api/groups.py#L406-L410 - authentik `version/2026.8.5` root redirect before authentication and setup/flow URL routes (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/views/interface.py#L44-L51 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/urls.py#L59-L70 - authentik `version/2026.8.5` setup planning, completion flag and post-setup superuser requirement (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/setup/views.py#L35-L92 - authentik `version/2026.8.5` OOBE prompts, pending user, started-by/usable-password policy and stage/policy bindings (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/blueprints/default/flow-oobe.yaml#L8-L194 - authentik `version/2026.8.5` flow authentication default, planning-time policy check and continuation of existing session plans. The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/models.py#L183-L187 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/planner.py#L263-L292 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/views/executor.py#L175-L196 - authentik `version/2026.8.5` bootstrap environment handling and failure/skip conditions (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/setup/signals.py#L18-L48 - authentik `version/2026.8.5` source tests specifying setup redirects, anonymous password setup, direct-flow denial and bootstrap password/token behavior (read, not run) (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/tests/test_setup.py#L26-L130 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/tests/test_setup.py#L168-L245 - authentik `version/2026.8.5` TOTP setup stage (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_totp/models.py#L29-L44 - authentik `version/2026.8.5` WebAuthn setup stage (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_webauthn/models.py#L90-L113 - authentik `version/2026.8.5` OutpostConfig definition and Docker port-mapping default (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/models.py#L53-L85 - authentik `version/2026.8.5` outpost types (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/models.py#L99-L105 - authentik `version/2026.8.5` outpost deployment and provider types (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/website/docs/add-secure-apps/outposts/index.mdx#L6-L13 - authentik `version/2026.8.5` Docker, Kubernetes and manual outpost deployment (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/website/docs/add-secure-apps/outposts/index.mdx#L30-L48 - authentik `version/2026.8.5` embedded proxy outpost and shared server ports (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/website/docs/add-secure-apps/outposts/embedded/embedded.mdx#L5-L15 - authentik `version/2026.8.5` proxy outpost HTTP, HTTPS and metrics listeners (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/proxyv2/proxyv2.go#L172-L202 - authentik `version/2026.8.5` proxy outpost image build of the Rust `authentik` binary with the `proxy` feature, its title, binary copy, exposed ports and `/authentik proxy` entrypoint, and the Rust proxy outpost subcommand and its HTTP and HTTPS listeners (checked November 2026). The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/proxy.Dockerfile#L29-L42 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/proxy.Dockerfile#L51-L78 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/outpost/proxy/mod.rs#L44-L119 - authentik `version/2026.8.5` RADIUS outpost and metrics listeners (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/radius/radius.go#L100-L125 - authentik `version/2026.8.5` Docker outpost controller conditional port mapping (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/controllers/docker.py#L218-L230 - authentik `version/2026.8.5` Kubernetes outpost Deployment and Service reconcilers (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/controllers/kubernetes.py#L65-L88 - authentik `version/2026.8.5` OAuth2 provider discovery suffix (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/urls.py#L53-L63 - authentik `version/2026.8.5` global and per-provider issuer modes (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/models.py#L115-L122 - authentik `version/2026.8.5` issuer URL construction (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/models.py#L365-L378 - authentik `version/2026.8.5` application URL mounting and web path (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/urls.py#L18-L48 - authentik `version/2026.8.5` OAuth2 provider app mountpoints, `urls` at `application/o/` and `urls_root` at the root (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/apps.py#L12-L15 - authentik `version/2026.8.5` app registry iteration over installed `authentik` app configurations (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/utils/reflection.py#L46-L52 - authentik `version/2026.8.5` OAuth2 provider app in the tenant apps, and the installed apps built from the shared and tenant apps (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L83-L108 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L612-L613 - authentik `version/2026.8.5` security-policy supported-version table at the time of writing (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/SECURITY.md#L25-L32 - Keycloak `26.8.2` security-policy upgrade guidance (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/SECURITY.md#L42-L48 - Keycloak `26.8.2` realm export backup limitations and stop-node conditions (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/importExport.adoc#L11-L16 - Docker Engine security, daemon root requirement, trusted daemon users and host filesystem access (checked October 2026): https://docs.docker.com/engine/security/ - Docker Engine rootless mode, daemon and containers without root privileges (checked October 2026): https://docs.docker.com/engine/security/rootless/ - Docker daemon privileges, trusted control and host filesystem access, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/_index.md#L84-L112 - Docker rootless non-root daemon and containers in a user namespace, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/rootless/_index.md#L8-L21 - Docker Compose CLI reference for `docker compose version`, which shows the Docker Compose version information (checked October 2026): https://docs.docker.com/reference/cli/docker/compose/version/ - Docker Compose CLI reference for `docker compose ps` and its `--format` option, which takes `table`, `table TEMPLATE`, `json` or a Go template (checked October 2026): https://docs.docker.com/reference/cli/docker/compose/ps/ - curl `curl-8_23_0` exit-code list, defining exit 7 as a connection failure: https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L23-L24 - Keycloak `26.8.2` Admin CLI user filtering example and its non-exact matching note (no documented flag-order behaviour) (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/admin-cli.adoc#L1034-L1045 - Keycloak server administration guide, brute force detection ("Brute force detection is disabled by default"), unspecific redirect URIs, and the SCIM API's base URL, enablement and bearer-token requirement (checked November 2026): https://www.keycloak.org/docs/latest/server_admin/index.html - nginx `location` selection and the `return` directive: https://nginx.org/en/docs/http/ngx_http_core_module.html#location , https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return - Keycloak configuring distributed caches, including the default cache ports 7800 (`cache-embedded-network-bind-port`, unicast data transmission) and 57800 (`jgroups.fd.port-offset`, failure detection), and that "Encryption using TLS is enabled by default for TCP-based transport stacks" (checked November 2026): https://www.keycloak.org/server/caching - authentik 2025.10 release notes, removing the Redis dependency ("authentik no longer uses Redis at all") (checked November 2026): https://docs.goauthentik.io/releases/2025.10/ - authentik source `version/2026.8.5`, MFA response validation (including empty-response rejection), successful-validation continuation and remembered-MFA cookie handling (checked November 2026). The authentik `version/2026.8.5` sources (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L67-L166 , https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L368-L443 - Keycloak `26.8.2` bootstrap-admin-recovery.adoc L9-L11 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/bootstrap-admin-recovery.adoc#L9-L11 - Keycloak `26.8.2` flows.adoc L35 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/flows.adoc#L35-L35 - Keycloak `26.8.2` flows.adoc L37-L57 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/flows.adoc#L37-L57 - Keycloak `26.8.2` flows.adoc L135-L143 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/flows.adoc#L135-L143 - Keycloak `26.8.2` webauthn.adoc L19-L28 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/webauthn.adoc#L19-L28 - Keycloak `26.8.2` webauthn.adoc L51-L55 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/webauthn.adoc#L51-L55 - Keycloak `26.8.2` con-required-actions.adoc L6-L20 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/users/con-required-actions.adoc#L6-L20 - Keycloak `26.8.2` HttpOptions.java L14-L18 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java#L14-L18 - Keycloak `26.8.2` HttpOptions.java L32-L42 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java#L32-L42 - Keycloak `26.8.2` reverseproxy.adoc L122-L132 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/reverseproxy.adoc#L122-L132 - Keycloak `26.8.2` reverseproxy.adoc L72-L76, restricting network access to the proxy and `--proxy-trusted-addresses` as weak protection (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/reverseproxy.adoc#L72-L76 - Keycloak `26.8.2` reverseproxy.adoc L200-L242, trusted proxies: the accept-from-any-source default, the address formats, bracketed IPv6, the hostname deprecation and the CIDR examples (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/reverseproxy.adoc#L200-L242 - Keycloak `26.8.2` ProxyOptions.java L23-L26, the `proxy-trusted-addresses` description and its all-addresses-trusted default (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/ProxyOptions.java#L23-L26 - Keycloak `26.8.2` ProxyPropertyMappers.java L61-L67 and L71-L109, the `proxy-trusted-addresses` mapping and its address validator with the hostname deprecation warning (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ProxyPropertyMappers.java#L61-L67 , https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ProxyPropertyMappers.java#L71-L109 - authentik `version/2026.8.5` stages/authenticator_validate/models.py L15-L21 (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/models.py#L15-L21 - authentik `version/2026.8.5` stages/authenticator_validate/models.py L45-L50 (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/models.py#L45-L50 - authentik `version/2026.8.5` stage.py L279-L289 (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L279-L289 - authentik `version/2026.8.5` stage.py L292-L317 (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L292-L317 - authentik `version/2026.8.5` flows/models.py L254-L262 (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/models.py#L254-L262 - authentik `version/2026.8.5` planner.py L339-L345 (checked November 2026): https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/planner.py#L339-L345 - curl `curl-8_23_0` _EXITCODES.md L7-L8 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L7-L8 - curl `curl-8_23_0` _EXITCODES.md L72-L74 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L72-L74 - curl `curl-8_23_0` _EXITCODES.md L111-L120 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L111-L120 - curl `curl-8_23_0` connect-timeout.md L19-L28 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/connect-timeout.md#L19-L28 - curl `curl-8_23_0` max-time.md L19-L23 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/max-time.md#L19-L23 - curl `curl-8_23_0` _ENVIRONMENT.md L3-L10 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_ENVIRONMENT.md#L3-L10 - curl `curl-8_23_0` _ENVIRONMENT.md L12-L29 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_ENVIRONMENT.md#L12-L29 - curl `curl-8_23_0` noproxy.md L18-L23 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/noproxy.md#L18-L23 - curl `curl-8_23_0` disable.md L18-L20 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L18-L20 - curl `curl-8_23_0` disable.md L5 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L5 - curl `curl-8_23_0` verbose.md L21-L25 (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/verbose.md#L21-L25 - Keycloak `26.8.2` otp-policies.adoc L17-L21 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc#L17-L21 - Keycloak `26.8.2` HttpOptions.java L20-L23 (checked November 2026): https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java#L20-L23 - Keycloak release records for 26.7.5, 26.8.0, 26.8.1 and 26.8.2, including their security fixes, the 26.8.0 Admin API v2 preview and SCIM API promotion, and the 26.8.2 record's empty body (checked November 2026): https://github.com/keycloak/keycloak/releases/tag/26.7.5 , https://github.com/keycloak/keycloak/releases/tag/26.8.0 , https://github.com/keycloak/keycloak/releases/tag/26.8.1 , https://github.com/keycloak/keycloak/releases/tag/26.8.2 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - keycloak-admin-cli: Keycloak Admin CLI (26.8.2) - keycloak-source: Keycloak source (26.8.2) - keycloak-releases: Keycloak release records (26.8.2) - authentik: authentik source (version/2026.8.5) - redis-removal: authentik Redis removal (2025.10) - keycloak: Keycloak documentation (unknown) - authentik-docs: authentik operational documentation (unknown) - docker-engine: Docker Engine security documentation (unknown) - nginx: nginx documentation (unknown) - curl: curl documentation (curl-8_23_0) - docker-engine-pinned: Docker Engine security documentation (4e9a5751518ed8223a8dcde53693badddd72604f) Claims (full source mapping in the JSON block below): - private-boundary (REASONED; keycloak, authentik-docs): Bind the IdP privately behind a reverse proxy; keep the database inside the same protected boundary. - authentik-publications (REASONED; authentik): Shipped Compose publishes 9000:9000 and 9443:9443 by default without host addresses, exposing all host interfaces under Docker publishing defaults; it does not publish 9300. - authentik-web-binds (REASONED; authentik): listen.http and listen.https default to [::]:9000 and [::]:9443 inside the container, independently of host publication. - authentik-loopback (REASONED; authentik, authentik-docs): Replace the Compose ports list with 127.0.0.1:9000:9000 and 127.0.0.1:9443:9443; adding entries can retain old publications. - production-tls (REASONED; keycloak): Keycloak production requires a secure channel; edge TLS termination needs a protected loopback or controlled private proxy hop. - hostname-defaults (REASONED; keycloak): Production start requires explicit hostname or hostname-strict false; start-dev defaults hostname-strict false. Keep strict hostname resolution in production unless the proxy overwrites Host. - http-listener (REASONED; keycloak, keycloak-source): The example starts Keycloak with HTTPS frontend/admin hostnames, xforwarded parsing and http-enabled=true for backend HTTP on default port 8080 on a protected proxy hop; the HTTPS port defaults to 8443. At 26.8.2, the HTTP listener option defaults false but is enabled by default in development mode. In production, http-host defaults to 0.0.0.0, so constrain the bind or network reachability. - admin-hostname (REASONED; keycloak): hostname-admin selects the console/API hostname but does not restrict the Administration REST API through the frontend URL. - admin-deny (REASONED; keycloak, nginx): Deny /admin/ on the public hostname at the proxy; the sample returns 404. - master-realm (REASONED; keycloak): Restrict /realms/master/ by administrator source network while keeping application /realms/ exposed; by this guide's reading, not established at 26.8.2, a blanket denial locks out administrators. - admin-login-route (REASONED; keycloak): Console API calls use hostname-admin; by this guide's reading, not established at 26.8.2, console authentication uses the frontend master realm. The private admin proxy also needs /admin/, /resources/ and /.well-known/, and /js/ for consoles that still load from it, which is not established for 26.8.2. - forwarded-headers (REASONED; keycloak): Overwrite all five X-Forwarded-For/Proto/Host/Port/Prefix headers and Host; appending or passing client values permits spoofing. - proxy-trust (REASONED; keycloak, keycloak-source): At 26.8.2, proxy-trusted-addresses defaults to trusting all addresses, so Keycloak accepts proxy headers from any source; if set, headers from other addresses are ignored. List the proxy's IP addresses or CIDR ranges, since hostnames are deprecated. It is weak protection because IP addresses can be spoofed, not a substitute for header overwriting. - proxy-origin (REASONED; keycloak): Without proxy-headers, non-passthrough proxied requests that perform origin checks return 403 by default. - setup-routing (REASONED; authentik): Before setup, authentik root redirects via /setup to /if/flow/initial-setup/; a fresh direct flow visit lacks the public setup marker and is denied. - setup-claim (REASONED; authentik): Initial setup plans only when akadmin is absent or lacks a usable password, then prompts email/base URL/password, writes akadmin and logs in the visitor. - setup-completion (REASONED; authentik): Completion records setup done, disables OOBE blueprints and requires a superuser; planning-time checks do not promise revocation of already-started sessions. - setup-isolation (REASONED; authentik, authentik-docs): Keep both web ports and public proxy route closed to untrusted clients until setup is complete; use loopback or a private container network and confirm closure in a fresh session. - bootstrap-inputs (REASONED; authentik): Before first startup, protected .env can provide AUTHENTIK_BOOTSTRAP_PASSWORD, PASSWORD_HASH or TOKEN; any nonempty input triggers bootstrap and successful blueprint application completes setup. - bootstrap-lifecycle (REASONED; authentik): Hash form is validated; password forms set akadmin password and token form supplies an API token, not a browser password. Already-setup tenants are skipped, so these inputs are not rotation. - bootstrap-cleanup (REASONED; authentik): Verify bootstrap and setup closure before publication, then remove the bootstrap secret from the startup environment and protect file/container-inspection access. - keycloak-bootstrap (REASONED; keycloak, keycloak-source): Keycloak bootstrap admin is temporary and needs manual removal after establishing permanent, more secure, MFA-protected admin access (this guide's requirement); this guide infers from the manual-removal instruction that it does not expire on its own. MFA protection requires a factor in the authentication flow, not only enrolment. - keycloak-management (REASONED; keycloak): Enabled Keycloak health and metrics use management port 9000; keep it private and use /health/ready for the orchestrator. - management-mtls (REASONED; keycloak): https-management-client-auth accepts none/request/required and inherits HTTP settings; this plaintext topology needs management TLS credentials and trust before client certificates can authenticate it. - authentik-metrics (REASONED; authentik): At version/2026.8.5, the Rust core metrics listener uses plain HTTP with default [::]:9300, and the main Dockerfile ships the Rust binary run by ak; whether published images were built from it is NOT ESTABLISHED. The shipped Compose file does not publish 9300, but the container-network listener still needs restriction. - authentik-go-metrics (REASONED; authentik): At version/2026.8.5, a Go web server metrics listener is also present: cmd/server calls WebServer.Start, which calls runMetricsServer per listen.metrics address. A version/2026.8.3 search, not repeated since, found no image or release build of cmd/server; a developer debug launch targets it. The main Dockerfile ships the Rust binary; whether published images were built from it is NOT ESTABLISHED. - authentik-metrics-startup (REASONED; authentik): At version/2026.8.5, the Rust main starts the metrics server before the per-mode dispatch (healthcheck exits earlier), including proxy mode in proxy-feature builds; Server and AllInOne store the server handle, Worker and AllInOne the worker handles. Whether the published image was built from the tree's Dockerfile is NOT ESTABLISHED. - worker-outpost-metrics (REASONED; authentik): At version/2026.8.5, worker startup uses the same Rust metrics server as server mode; its handler serves Prometheus metrics on listen.metrics (default [::]:9300) via plain HTTP, with no authentication layer in the router wrapper. The LDAP outpost builds its metrics router with a bare Prometheus handler at /metrics and serves it on each metrics listen address and a Unix socket; section 7 cites pinned proxy, LDAP and RADIUS listener code, but RAC listeners were not traced. Restrict to monitoring clients. - keycloak-mfa (REASONED; keycloak, keycloak-source): Enrol administrator TOTP or WebAuthn and require the factor in the realm authentication flow. When Keycloak's default conditional 2FA sub-flow is reached, it asks for OTP if the user has a configured OTP credential; a successful Cookie execution skips the sub-flow, and passwordless WebAuthn authentication can avoid 2FA. Its WebAuthn Authenticator execution is disabled by default. A Required second factor can instead be enrolled during authentication without proving possession of a preexisting factor, so test an administrator with no registered factor and inspect the flow. - brute-force-default (REASONED; keycloak): Keycloak brute-force detection is disabled by default and must be enabled per realm. - redirect-wildcards (REASONED; keycloak): Keycloak permits trailing redirect-URI wildcards and bare *; deliberately register exact URIs instead. - docker-socket (REASONED; authentik-docs, authentik): authentik Compose mounts the worker Docker socket for outpost management; use a socket proxy or remove the mount and manage outposts manually. - cache-ports (REASONED; keycloak): Keycloak cache transport uses 7800 and failure detection 57800; TCP stacks enable TLS by default but still need private reachability. - redis-history (REASONED; redis-removal): Redis 6379 is relevant to older authentik: tasks moved in 2025.8 and remaining uses in 2025.10, which removes Redis entirely. - verify-setup (REASONED; authentik): On a disposable instance, complete anonymous root-to-setup flow and confirm akadmin login; after setup or password bootstrap, fresh /setup visits must return to authentication. Source tests were read, not run. - verify-publication (REASONED; authentik): Compare exposed and loopback publications: untrusted clients lose setup access while a local browser still reaches it. Neither configuration publishes 9300. - verify-inventory (REASONED; authentik, keycloak): Inspect all listeners plus Compose publications: host ss misses container namespaces, Compose ps sees one project, and proxy 443 publication is expected. - verify-issuer (REASONED; keycloak): From outside allowed networks, application-realm discovery must print the expected issuer before negative route checks are interpreted; an arbitrary 200 is insufficient. - verify-console (REASONED; keycloak, nginx): Public /admin/master/console/ must return 404; 2xx, 3xx, 401 and 403 do not show that the console path is closed. - verify-admin-api (REASONED; keycloak, nginx): Public /admin/realms/master/users must return 404; 401 means the API still answers and requests a token. - verify-master (REASONED; keycloak, nginx): Outside allowed sources, master-realm discovery must return 403; 200 exposes it and 404 indicates blanket denial. Review effective locations beyond these sampled routes. - verify-admin-login (REASONED; keycloak): An allowed administrator must complete a real login through the private console and observe where it authenticates, which by this guide's reading, not established at 26.8.2, is the frontend master realm; the network restriction on that path must admit the administrator, and outside sources must fail. - verify-direct-ports (REASONED; keycloak, authentik, curl, keycloak-source): Probe 8080/8443/9000/9300/9443 directly: curl exit 0 reports a completed operation, 7 reports a connection failure, and 28 is an inconclusive timeout requiring full diagnostics to distinguish accepted-then-stalled from unreachable. Treat a connection failure as the desired isolation outcome only after checking the diagnostic and target; this guide infers that exits 52 and 56 follow an accepted connection, so a listener is reachable. Other exits require diagnostic review. The connection and full-transfer deadlines differ. The checks remain REASONED expectations, not observed outcomes. - verify-bootstrap-removal (REASONED; keycloak, keycloak-admin-cli): Look up the bootstrap username with exact=true rather than a user list, whose 100-result default is not established at 26.8.2, inspect the returned username, and separately check bootstrap service accounts. Keycloak documents non-exact matching for its -q q=username: filter; this guide keeps the -q order (exact=true first) as defensive advice based on a report, not documented ordering behaviour. - verify-address-coverage (REASONED; keycloak, authentik-docs): Repeat external checks for every public IPv4/IPv6 address and the origin; private admin, database and outposts need separate checks, and the listed ports are not a full inventory. - authentik-mfa (REASONED; authentik-docs, authentik): authentik has TOTP and WebAuthn setup stages; enrol a phishing-resistant administrator factor before connecting the first app and require it through an Authenticator Validate stage bound to the authentication flow (this guide's requirement; the cited sources do not state this ordering). A registered device alone does not establish the flow binding. For a user with no allowed device, DENY refuses access. CONFIGURE errors and refuses when no configuration stage is set, inserts one stage when exactly one is set, or offers a choice when several are set; with a configuration stage set, only DENY refuses that user. The pinned validate-stage and flow-binding sources support these behaviours but do not establish an administrator-specific factor policy. - authentik-admin-boundary (REASONED; authentik): At version/2026.8.5, /if/admin/, /if/user/ and flows share core routing. This guide assesses that authentik has no equivalent to hostname-admin: the cited configuration, Go startup and Rust server startup show no separate administration listener, not an exhaustive proof of deployed topology. The permission boundary remains unestablished. - authentik-admin-listeners (REASONED; authentik): At version/2026.8.5, defaults are HTTP [::]:9000, HTTPS [::]:9443 and metrics [::]:9300; Go startup adds these configured listeners and a Unix socket, and its plain HTTP, HTTPS and Unix listeners share the application proxy router after outpost handling. These files show shared routing, not a separate admin port, and do not prove which listeners a deployed build starts. - authentik-admin-interface (REASONED; authentik): At version/2026.8.5, both interface routes redirect authenticated external, service-account and internal-service-account users to a default app or deny them; other requests fall through. These view classes show no administrator-permission check, so this guide does not treat loading the admin template as proof of authorization. - authentik-admin-api-mount (REASONED; authentik): At version/2026.8.5, API v3 registers application API routes, including core users, groups, tokens and admin authenticators. The root URL configuration mounts each app under the web path; the API app mounts api/ and then v3/, so the v3 router is at <web path>api/v3/, /api/v3/ by default, matching the Go client's api/v3 prefix. Endpoint permission wiring is not established by these files. - authentik-admin-api-auth (REASONED; authentik): At version/2026.8.5, TokenAuthentication recognizes API-intent tokens, API-scoped OAuth tokens, the managed-outpost secret and IPC key; it rejects inactive users and unrecognized credentials, but absent or unsupported authentication can return no user. This does not establish session authentication or every endpoint adopting this class. - authentik-admin-rbac (REASONED; authentik): At version/2026.8.5, ObjectPermissions' request check passes object-lookup requests (its comment leaves view checks to ObjectFilter, writes to the object check) and permits configured creation without add permission; the object check accepts global permissions or configured ownership before delegating. HasPermission checks user.has_perms; endpoint wiring and permission boundary stay unestablished. - authentik-admin-proxy (REASONED; authentik): Build any network boundary around authentik administration at the proxy using path restrictions; identify and test required paths for the deployed version and maintain the list. This guide assesses that the UI is not designed around this separation and path-based separation is difficult to maintain; this is not a vendor-supported recipe, and the cited files give no complete path list. - authentik-admin-paths (REASONED; authentik): At version/2026.8.5, core URLs show /if/admin/, /if/user/, flows, HTTP admin-template fallbacks at /ws/outpost/<uuid:pk>/ and /ws/client/, and a /ws/client/ websocket route wrapped in authentication middleware. With the API client prefix, these are paths to investigate, not a complete restriction list or proof of websocket authorization. - authentik-rust-listeners (REASONED; authentik): At version/2026.8.5, the main Dockerfile ships the Rust binary run by ak. Server mode runs gunicorn on a Unix socket and serves one router over HTTP on listen.http, rustls on listen.https and a Unix socket, sending embedded-outpost apps to the outpost and the rest via the core router to gunicorn. Worker mode serves health checks on listen.http. Deployed topology beyond Compose is NOT ESTABLISHED. - authentik-api-defaults (REASONED; authentik): At version/2026.8.5, REST framework defaults are TokenAuthentication then SessionAuthentication, ObjectPermissions and an ObjectFilter backend; settings modules can update them, and a version/2026.8.3 search, not repeated since, found no app settings module doing so. Users and groups add AgentAuth; the flow executor uses AllowAny. SessionAuthentication and deployment overrides are NOT ESTABLISHED. - authentik-admin-websockets (REASONED; authentik): At version/2026.8.5, the websocket modules a version/2026.8.3 search found give /ws/client/, /ws/outpost/<uuid:pk>/, /ws/rac/<str:token>/ and /ws/outpost_rac/<str:channel>/ under the web path; others are not established. The /ws/client/ route resolves a session or anonymous user and accepts every connection; the RAC client consumer on /ws/rac/ accepts, then denies tokens not bound to the session. - authentik-outpost-websockets (REASONED; authentik): At version/2026.8.5, under the configured web path (default / shown), the /ws/outpost/ and /ws/outpost_rac/ websocket routes deny a connection unless TokenAuthentication accepts its bearer token; /ws/outpost/ also needs outpost view permission, while /ws/outpost_rac/'s RAC outpost consumer accepts with no outpost-specific check. - authentik-admin-api-shared (REASONED; authentik): At version/2026.8.5, the API router registers the flow executor at flows/executor/<slug:flow_slug>/ with AllowAny, the API schema names that path's GET flows_executor_get, and the flow interface calls flowsExecutorGet, read here as that GET's generated client method, so this guide infers refusing the whole API prefix would break login. - authentik-admin-static-paths (REASONED; authentik): At version/2026.8.5, the Rust static router also serves /if/admin/assets/ under the configured web path (shown with the default /) alongside the user, flow and RAC interface assets. Path lists from the routing tables this guide read are bounded by those tables; complete path coverage remains open. - verify-mfa-password (REASONED; keycloak, authentik-docs, authentik, keycloak-source): For both providers, open a fresh session with only the administrator password and confirm access is refused after factor enforcement. This guide assesses that an already-enrolled administrator's password-only refusal does not distinguish enrolment from enforcement; test a user with no registered factor and inspect the flow or stage settings. Keycloak's default conditional 2FA, when reached (a successful Cookie execution skips it), prompts for configured OTP unless passwordless WebAuthn already authenticated the user; the default flow's WebAuthn Authenticator execution is disabled by default; and a Required factor the user lacks can be enrolled during authentication. At authentik version/2026.8.5, the unconfigured-device action defaults to SKIP; DENY refuses a user with no allowed device. CONFIGURE errors and refuses when no configuration stage is set, inserts one stage when exactly one is set, or offers a choice when several are set; with a configuration stage set, only DENY refuses that user. Bind the validation stage to the authentication flow. - management-auth-default (REASONED; keycloak): In this plain-HTTP Keycloak topology no HTTP client authentication is configured, so inherited management client authentication is usually none; required needs management TLS credentials and trust first. - docker-socket-scope (REASONED; authentik-docs, authentik, docker-engine, docker-engine-pinned): Docker's daemon requires root unless rootless (daemon and containers run non-root); only trusted users should control it. For authentik's shipped Compose worker socket, check daemon type and restrict access: this guide assesses ordinary rootful access as host-root-equivalent and rootless blast radius as the daemon user and what it can access; sources do not directly demonstrate either assessment. - outpost-boundaries (REASONED; authentik-docs, authentik): At version/2026.8.5, authentik supports Proxy, LDAP, RADIUS and RAC outposts. The embedded Proxy outpost runs in the main server and shares ports 9000 and 9443 rather than opening another port; managed or manually deployed outposts can run elsewhere. This guide requires separate exposure checks for each deployed outpost and assesses that its Verify probes do not demonstrate them. - outpost-proxy-image (REASONED; authentik): At version/2026.8.5, proxy.Dockerfile, titled the authentik proxy outpost image, builds the Rust authentik binary with only the proxy feature, has no go build step, copies only that binary and runs /authentik proxy. This guide infers that an image built from it runs the Rust proxy outpost, not the cited Go proxy code; whether published images were built from it is NOT ESTABLISHED. - outpost-proxy-listeners (REASONED; authentik): At version/2026.8.5, the authentik proxy subcommand starts the Rust proxy outpost, which serves plain HTTP on each listen.http address and rustls on each listen.https address unless it is the embedded outpost; Rust startup schedules the metrics server on listen.metrics before mode dispatch. Pinned defaults are [::]:9000, [::]:9443 and [::]:9300, and proxy.Dockerfile exposes 9000, 9300 and 9443. - outpost-deployment-listeners (REASONED; authentik): At version/2026.8.5, the pinned Go proxy code listens on configured HTTP, HTTPS and metrics addresses. The Docker controller maps the outpost's ports to the host unless docker_map_ports is false; docker_map_ports defaults to true. The Kubernetes controller reconciles Deployment and Service objects. LDAP and RADIUS listener code is also pinned, but RAC listeners were not traced. - verify-proxy-neutralization (REASONED; keycloak, curl): Run external probes outside every allowed network; unset HTTP/HTTPS/ALL/NO_PROXY variables in both cases and use curl -q --noproxy '*' to bypass proxies and curlrc, which this guide assesses could otherwise make blocked endpoints appear reachable through allowed egress. Curl's HTTP proxy environment variable is lowercase only; -q must be curl's first option. - verify-authentik-routes (REASONED; authentik-docs, authentik): The fenced paths are Keycloak-specific; the exclusion of Keycloak paths from authentik is this guide's inference from the fetched route files, not an exhaustive route proof. For authentik, use a deployed application's actual OAuth2 discovery URL as the positive control and confirm its configured issuer. A responding status alone is not the positive-control pass condition. - verify-authentik-admin-routes (REASONED; authentik-docs, authentik): For authentik, an untrusted client must not reach the chosen administration route while an allowed administrator can. Check the administration API surface you choose to restrict separately: an untrusted client must be refused while an authorized administrator on the allowed network can complete an API request. - verify-authentik-login-api (REASONED; authentik): Do not refuse the whole authentik API prefix without tracing login dependencies; confirm an ordinary user can still complete login. At version/2026.8.5 the v3 router is at <web path>api/v3/ (/api/v3/ by default) and registers the flow executor with AllowAny; the flow interface calls flowsExecutorGet, taken here as its generated client method, so this guide infers that login uses the API prefix. - verify-authentik-oauth2-routes (REASONED; authentik): At version/2026.8.5 the installed OAuth2 app mounts its urls module, which defines the per-application discovery suffix, at application/o/ and urls_root at the root; the root URL module mounts installed authentik apps' mountpoints under the web path, so the prefix is <web path>application/o/, /application/o/ by default. The issuer can be global or per-provider; /if/admin/ is a core route. - admin-factor-order (REASONED; keycloak, authentik-docs, keycloak-source, authentik): Enrol a phishing-resistant factor on the administrator account of either provider before connecting the first application and enforce it in the flow (this guide's requirement; the cited sources do not state this ordering). Keycloak documents Required executions; authentik version/2026.8.5 binds ordered stages to flows, but the cited files do not establish an administrator-specific factor policy. - keycloak-http-opt-in (REASONED; keycloak, keycloak-source): In the production example, backend plain HTTP on default port 8080 exists only when explicitly enabled with --http-enabled=true; confine that hop to loopback or a controlled private segment. At 26.8.2, the HTTP listener option defaults false but is enabled by default in development mode, and HTTPS defaults to port 8443. In production, http-host defaults to 0.0.0.0, so constrain the bind or network reachability. - idp-maintenance (REASONED; keycloak, authentik-docs, authentik, keycloak-source): This guide requires patching, tested backups and keeping a self-hosted identity provider available (the cited files prescribe no cadence) and assesses that a compromised or unavailable provider takes its dependent services with it. The cited sources establish no complete Keycloak or authentik backup procedure, authentik upgrade procedure, universal maintenance cadence or tested restore. - keycloak-security-fixes (REASONED; keycloak-releases): The 26.7.5 and 26.8.1 records list section 6's fixes: 26.8.1 fixes CVE-2026-94218 (2FA setup enforcement bypass) and CVE-2026-96445 (Conditional OTP trusts skip headers on untrusted proxy paths); 26.7.5 fixes brute-force lockout bypasses CVE-2026-16103 and CVE-2026-88770, not in 26.8.x records. 26.8.2's record is empty. Run 26.8.1 or later; each bypass's detail and backports are not established. - keycloak-admin-api-v2 (REASONED; keycloak-releases): Keycloak 26.8.0 promotes the Client Admin API v2 to preview, enabled with --features=preview or --features=client-admin-api:v2; the path it is served under at 26.8.2, and so whether the public /admin/ refusal covers it, are not established. - keycloak-scim-api (REASONED; keycloak-releases, keycloak): Keycloak 26.8.0 promotes the SCIM API, for a realm's users and groups, to supported. The server admin guide served on 2026-11-05 (26.8.1 page links) gives its base URL under /realms/, ending /scim/v2, enabled per server and realm, needing a bearer token. This guide infers an enabled realm serves it publicly, master's only to proxy-allowed sources; its 26.8.2 default and path are not established. ````json { "schema": 1, "checked": "2026-11-05", "documentation_checked": "2026-09", "body_sha256": "c37f45fdab628376916d67cd97fb1cc7ba18924d7394966f91d37965e186a492", "components": { "keycloak-admin-cli": { "name": "Keycloak Admin CLI", "basis": "26.8.2", "sources": { "s8b4e40148629": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/admin-cli.adoc#L1034-L1045" } }, "keycloak-source": { "name": "Keycloak source", "basis": "26.8.2", "sources": { "sd5a9fd571726": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/bootstrap-admin-recovery.adoc#L9-L11", "se54a2a6e7d47": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/flows.adoc#L35-L35", "sff5c9a23ec46": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/flows.adoc#L37-L57", "s277571a617ec": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/flows.adoc#L135-L143", "s6e859a060525": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/webauthn.adoc#L19-L28", "sdc09746a23d8": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/webauthn.adoc#L51-L55", "sde43d0e31c0f": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/users/con-required-actions.adoc#L6-L20", "sa452e5c860cd": "https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java#L14-L18", "s32a00c2c8803": "https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java#L32-L42", "sb3476e7a2eb2": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/reverseproxy.adoc#L122-L132", "s3f7c345f457f": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc#L17-L21", "s7fdfa4997898": "https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/HttpOptions.java#L20-L23", "s94317d53483b": "https://github.com/keycloak/keycloak/blob/26.8.2/SECURITY.md#L42-L48", "s14164b9cc6c3": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/importExport.adoc#L11-L16", "scf439f41b910": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/reverseproxy.adoc#L72-L76", "sa0feb9279213": "https://github.com/keycloak/keycloak/blob/26.8.2/docs/guides/server/reverseproxy.adoc#L200-L242", "s5261cce9086f": "https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/config-api/src/main/java/org/keycloak/config/ProxyOptions.java#L23-L26", "s7d1570c7fded": "https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ProxyPropertyMappers.java#L61-L67", "s217fa09da7aa": "https://github.com/keycloak/keycloak/blob/26.8.2/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ProxyPropertyMappers.java#L71-L109" } }, "keycloak-releases": { "name": "Keycloak release records", "basis": "26.8.2", "sources": { "s32d0f0981b58": "https://github.com/keycloak/keycloak/releases/tag/26.7.5", "s46d0047d5606": "https://github.com/keycloak/keycloak/releases/tag/26.8.0", "sd570d95abe70": "https://github.com/keycloak/keycloak/releases/tag/26.8.1", "s614ddc99af91": "https://github.com/keycloak/keycloak/releases/tag/26.8.2" } }, "authentik": { "name": "authentik source", "basis": "version/2026.8.5", "sources": { "s2a37351e0dbc": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/setup/signals.py#L18-L48", "sbfdadaefea47": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/setup/views.py#L35-L92", "s01be08361cc2": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/tests/test_setup.py#L168-L245", "s67916683010a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/tests/test_setup.py#L26-L130", "s746914604076": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/urls.py#L59-L70", "s313f1f9b5bc9": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/urls.py#L36-L75", "s85e90df8c571": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/views/interface.py#L44-L51", "s0f24d2fd939d": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/models.py#L183-L187", "s1153450d2162": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/planner.py#L263-L292", "s189836c36e3a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/views/executor.py#L175-L196", "se669fb431f33": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/default.yml#L36-L48", "seebef88f2110": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/blueprints/default/flow-oobe.yaml#L8-L194", "s4fc8280e760f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/metrics/mod.rs#L70-L100", "sed8d69a749a7": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/main.rs#L99-L122", "sa2dcdc13cd29": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/metrics/handlers.rs#L12-L42", "s5c36281a2a21": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-axum/src/router.rs#L36-L59", "sf577784d5ebc": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/ak/metrics.go#L18-L72", "sb8dece660959": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/ldap/ldap.go#L91-L121", "s0350a6463f9c": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/web/metrics.go#L22-L52", "scc60a0e302d1": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/compose.yml#L1-L67", "s2986fcb4cb32": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L67-L166", "s16ad051165b7": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L368-L443", "se89f1e7b7d18": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/models.py#L15-L21", "sf19de9022fee": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/models.py#L45-L50", "s77bfc5116d72": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L279-L289", "sdf8b8d567281": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_validate/stage.py#L292-L317", "s68ce378d011e": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/models.py#L254-L262", "s6500fae4a5ac": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/planner.py#L339-L345", "s506b3d80d9ce": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_totp/models.py#L29-L44", "sd5b24af3593a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/stages/authenticator_webauthn/models.py#L90-L113", "s6be1839a0a33": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/models.py#L53-L85", "s6ee73dbd270c": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/models.py#L99-L105", "s741c951134bd": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/website/docs/add-secure-apps/outposts/index.mdx#L6-L13", "s0249923fde2f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/website/docs/add-secure-apps/outposts/index.mdx#L30-L48", "sf4642b40524f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/website/docs/add-secure-apps/outposts/embedded/embedded.mdx#L5-L15", "s0c4d3f60ff27": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/proxyv2/proxyv2.go#L172-L202", "s030a7cc6a274": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/outpost/radius/radius.go#L100-L125", "s25612b8e4bc3": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/urls.py#L53-L63", "s1b0b1b6332d8": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/models.py#L115-L122", "s42b24ec40c5a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/models.py#L365-L378", "s073e329f067a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/urls.py#L18-L48", "s304feb3be14f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/oauth2/apps.py#L12-L15", "s37a8db510590": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/utils/reflection.py#L46-L52", "s2d86a3e9d60d": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L83-L108", "s8d3ff0842282": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L612-L613", "sceae009a9d08": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/SECURITY.md#L25-L32", "s63fcd0ce5a18": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/controllers/docker.py#L218-L230", "sb24757cb27e6": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/controllers/kubernetes.py#L65-L88", "s8012ab258605": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/config/struct.go#L53-L62", "s70d2fe895b7b": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/web/web.go#L132-L183", "s63d78b9d40d8": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/web/web.go#L229-L288", "s6c3afec54d75": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/internal/web/proxy.go#L47-L127", "s169ffaaa70fc": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/views/interface.py#L54-L89", "s98f257d3ede1": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/urls.py#L36-L112", "s2cdcdb3b510b": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/authentik/api/v3/urls.py#L18-L58", "s68d603b9d5bc": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/authentik/api/authentication.py#L33-L166", "s9f2178a64542": "https://github.com/goauthentik/authentik/blob/4ec0d19b98da3c329781894cd550da95d3033954/authentik/rbac/permissions.py#L15-L60", "s6e40a30f1d99": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/main.rs#L57-L135", "sdacc5da217ab": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/cmd/server/server.go#L25-L73", "s675653f9e0f8": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/Dockerfile#L126-L142", "sd3e2ba0c6343": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/Dockerfile#L234-L252", "s8329bf6c67f8": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/ak#L16-L33", "s2dc55e93d595": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/ak#L92-L94", "s5af5d9143036": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/mod.rs#L86-L106", "s3e1090638af8": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/mod.rs#L260-L352", "s14e51acafd6f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/core.rs#L153-L155", "s906b273aa104": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/core.rs#L277-L320", "sa01f6c27bc59": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-axum/src/server.rs#L22-L170", "sdb8c87fa0cfc": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/worker/mod.rs#L347-L361", "s949377fe0d94": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/worker/healthcheck.rs#L66-L76", "sb8906abab13c": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-common/src/config/schema.rs#L203-L214", "s1626a6c0776a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/internal/web/web_tls.go#L46-L72", "s7c88d077f919": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L240-L252", "s12fcc19f34ee": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L320-L339", "s19f8c9e313eb": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/settings.py#L566-L609", "sc84a7d5db870": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/api/apps.py#L6-L13", "sa06907a58b4e": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/api/urls.py#L7-L9", "sf668b16f361a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/default.yml#L162-L166", "s62327707b202": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/websocket.py#L14-L34", "s9e012ed398d4": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/asgi.py#L75-L83", "s5b1985e0936f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/asgi_middleware.py#L42-L84", "s6d275572bf62": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/root/ws/consumer.py#L38-L52", "sf02ba388bb2f": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/urls.py#L16-L23", "s02f5150b41d1": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/channels.py#L13-L43", "s369c9cffd8f5": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/outposts/consumer.py#L73-L83", "s4b92525713bb": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/rac/urls.py#L30-L43", "s6b20bb333911": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/rac/consumer_client.py#L63-L96", "s033849961834": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/urls.py#L36-L50", "s3ea4b10861b1": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/flows/views/executor.py#L101-L104", "s1ff44bd506fd": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/web/src/flow/FlowExecutor.ts#L224-L228", "s247ca79b6f6c": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/server/static.rs#L159-L168", "s402352aa9811": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/api/users.py#L650-L654", "sb2bc7f868825": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/core/api/groups.py#L406-L410", "s07817f9dd888": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/ak#L35-L54", "s430f8fb6719e": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/.vscode/launch.json#L37-L42", "s331ad7f37dac": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/providers/rac/consumer_outpost.py#L14-L16", "s9eece0a5a170": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/schema.yml#L9288-L9290", "sb69f89fd29f8": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-common/src/config/mod.rs#L24-L24", "s3d85bc9743c4": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/packages/ak-common/src/config/mod.rs#L71-L89", "s86eb476b495a": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/config.py#L26-L28", "s3d8996ccb579": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/authentik/lib/config.py#L86-L111", "s82a97fe0acf2": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/proxy.Dockerfile#L29-L42", "s9082a2dbb6c5": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/lifecycle/container/proxy.Dockerfile#L51-L78", "s4a31ab9f5ec9": "https://github.com/goauthentik/authentik/blob/version/2026.8.5/src/outpost/proxy/mod.rs#L44-L119" } }, "redis-removal": { "name": "authentik Redis removal", "basis": "2025.10", "sources": { "seaa0a9896153": "https://docs.goauthentik.io/releases/2025.10/" } }, "keycloak": { "name": "Keycloak documentation", "basis": "unknown", "sources": { "sefec90245fa1": "https://www.keycloak.org/docs/latest/server_admin/index.html", "sbbd55dcb81cd": "https://www.keycloak.org/server/bootstrap-admin-recovery", "s44e24ad4e579": "https://www.keycloak.org/server/caching", "s2f5f079c5e00": "https://www.keycloak.org/server/configuration-production", "se5538391cf9f": "https://www.keycloak.org/server/hostname", "s4b5df6f4ef2e": "https://www.keycloak.org/server/management-interface", "sffc6123bea5c": "https://www.keycloak.org/server/reverseproxy" } }, "authentik-docs": { "name": "authentik operational documentation", "basis": "unknown", "sources": { "sb44156cde339": "https://docs.goauthentik.io/install-config/install/docker-compose/" } }, "docker-engine": { "name": "Docker Engine security documentation", "basis": "unknown", "sources": { "s18f59818e451": "https://docs.docker.com/engine/security/", "s7e61efb4dd59": "https://docs.docker.com/engine/security/rootless/" } }, "nginx": { "name": "nginx documentation", "basis": "unknown", "sources": { "sf7458f108aca": "https://nginx.org/en/docs/http/ngx_http_core_module.html#location", "s8d8f3323b468": "https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return" } }, "curl": { "name": "curl documentation", "basis": "curl-8_23_0", "sources": { "s9d0c5b92e9a4": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L23-L24", "s25c3e6e61c14": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L7-L8", "s9c78f229c8ba": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L72-L74", "sc7945d33c78d": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_EXITCODES.md#L111-L120", "s977c1ef288cc": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/connect-timeout.md#L19-L28", "sc39d43ba6e58": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/max-time.md#L19-L23", "sde07f1edf2dc": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_ENVIRONMENT.md#L3-L10", "s75e3807878ff": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/_ENVIRONMENT.md#L12-L29", "s508454c45cf1": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/noproxy.md#L18-L23", "s2278b86c08a6": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L18-L20", "sc8658f8a2e89": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L5", "s9380fc35e98e": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/verbose.md#L21-L25" } }, "docker-engine-pinned": { "name": "Docker Engine security documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "secebea1a69ab": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/_index.md#L84-L112", "s8eab393a61c6": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/rootless/_index.md#L8-L21" } } }, "claims": { "private-boundary": {"text": "Bind the IdP privately behind a reverse proxy; keep the database inside the same protected boundary.", "components": ["keycloak", "authentik-docs"], "sources": ["keycloak:s2f5f079c5e00", "authentik-docs:sb44156cde339"], "status": "REASONED"}, "authentik-publications": {"text": "Shipped Compose publishes 9000:9000 and 9443:9443 by default without host addresses, exposing all host interfaces under Docker publishing defaults; it does not publish 9300.", "components": ["authentik"], "sources": ["authentik:scc60a0e302d1"], "status": "REASONED"}, "authentik-web-binds": {"text": "listen.http and listen.https default to [::]:9000 and [::]:9443 inside the container, independently of host publication.", "components": ["authentik"], "sources": ["authentik:se669fb431f33"], "status": "REASONED"}, "authentik-loopback": {"text": "Replace the Compose ports list with 127.0.0.1:9000:9000 and 127.0.0.1:9443:9443; adding entries can retain old publications.", "components": ["authentik", "authentik-docs"], "sources": ["authentik:scc60a0e302d1", "authentik-docs:sb44156cde339"], "status": "REASONED"}, "production-tls": {"text": "Keycloak production requires a secure channel; edge TLS termination needs a protected loopback or controlled private proxy hop.", "components": ["keycloak"], "sources": ["keycloak:s2f5f079c5e00", "keycloak:sffc6123bea5c"], "status": "REASONED"}, "hostname-defaults": {"text": "Production start requires explicit hostname or hostname-strict false; start-dev defaults hostname-strict false. Keep strict hostname resolution in production unless the proxy overwrites Host.", "components": ["keycloak"], "sources": ["keycloak:se5538391cf9f"], "status": "REASONED"}, "http-listener": {"text": "The example starts Keycloak with HTTPS frontend/admin hostnames, xforwarded parsing and http-enabled=true for backend HTTP on default port 8080 on a protected proxy hop; the HTTPS port defaults to 8443. At 26.8.2, the HTTP listener option defaults false but is enabled by default in development mode. In production, http-host defaults to 0.0.0.0, so constrain the bind or network reachability.", "components": ["keycloak", "keycloak-source"], "sources": ["keycloak:se5538391cf9f", "keycloak:sffc6123bea5c", "keycloak-source:sa452e5c860cd", "keycloak-source:s32a00c2c8803", "keycloak-source:sb3476e7a2eb2", "keycloak-source:s7fdfa4997898"], "status": "REASONED"}, "admin-hostname": {"text": "hostname-admin selects the console/API hostname but does not restrict the Administration REST API through the frontend URL.", "components": ["keycloak"], "sources": ["keycloak:se5538391cf9f"], "status": "REASONED"}, "admin-deny": {"text": "Deny /admin/ on the public hostname at the proxy; the sample returns 404.", "components": ["keycloak", "nginx"], "sources": ["keycloak:sffc6123bea5c", "nginx:s8d8f3323b468"], "status": "REASONED"}, "master-realm": {"text": "Restrict /realms/master/ by administrator source network while keeping application /realms/ exposed; by this guide's reading, not established at 26.8.2, a blanket denial locks out administrators.", "components": ["keycloak"], "sources": ["keycloak:s2f5f079c5e00", "keycloak:sffc6123bea5c"], "status": "REASONED"}, "admin-login-route": {"text": "Console API calls use hostname-admin; by this guide's reading, not established at 26.8.2, console authentication uses the frontend master realm. The private admin proxy also needs /admin/, /resources/ and /.well-known/, and /js/ for consoles that still load from it, which is not established for 26.8.2.", "components": ["keycloak"], "sources": ["keycloak:se5538391cf9f", "keycloak:sffc6123bea5c"], "status": "REASONED"}, "forwarded-headers": {"text": "Overwrite all five X-Forwarded-For/Proto/Host/Port/Prefix headers and Host; appending or passing client values permits spoofing.", "components": ["keycloak"], "sources": ["keycloak:sffc6123bea5c"], "status": "REASONED"}, "proxy-trust": {"text": "At 26.8.2, proxy-trusted-addresses defaults to trusting all addresses, so Keycloak accepts proxy headers from any source; if set, headers from other addresses are ignored. List the proxy's IP addresses or CIDR ranges, since hostnames are deprecated. It is weak protection because IP addresses can be spoofed, not a substitute for header overwriting.", "components": ["keycloak", "keycloak-source"], "sources": ["keycloak:sffc6123bea5c", "keycloak-source:scf439f41b910", "keycloak-source:sa0feb9279213", "keycloak-source:s5261cce9086f", "keycloak-source:s7d1570c7fded", "keycloak-source:s217fa09da7aa"], "status": "REASONED"}, "proxy-origin": {"text": "Without proxy-headers, non-passthrough proxied requests that perform origin checks return 403 by default.", "components": ["keycloak"], "sources": ["keycloak:sffc6123bea5c"], "status": "REASONED"}, "setup-routing": {"text": "Before setup, authentik root redirects via /setup to /if/flow/initial-setup/; a fresh direct flow visit lacks the public setup marker and is denied.", "components": ["authentik"], "sources": ["authentik:s85e90df8c571", "authentik:s746914604076", "authentik:sbfdadaefea47", "authentik:seebef88f2110"], "status": "REASONED"}, "setup-claim": {"text": "Initial setup plans only when akadmin is absent or lacks a usable password, then prompts email/base URL/password, writes akadmin and logs in the visitor.", "components": ["authentik"], "sources": ["authentik:seebef88f2110"], "status": "REASONED"}, "setup-completion": {"text": "Completion records setup done, disables OOBE blueprints and requires a superuser; planning-time checks do not promise revocation of already-started sessions.", "components": ["authentik"], "sources": ["authentik:sbfdadaefea47", "authentik:s0f24d2fd939d", "authentik:s1153450d2162", "authentik:s189836c36e3a"], "status": "REASONED"}, "setup-isolation": {"text": "Keep both web ports and public proxy route closed to untrusted clients until setup is complete; use loopback or a private container network and confirm closure in a fresh session.", "components": ["authentik", "authentik-docs"], "sources": ["authentik:scc60a0e302d1", "authentik:sbfdadaefea47", "authentik-docs:sb44156cde339"], "status": "REASONED"}, "bootstrap-inputs": {"text": "Before first startup, protected .env can provide AUTHENTIK_BOOTSTRAP_PASSWORD, PASSWORD_HASH or TOKEN; any nonempty input triggers bootstrap and successful blueprint application completes setup.", "components": ["authentik"], "sources": ["authentik:s2a37351e0dbc", "authentik:scc60a0e302d1"], "status": "REASONED"}, "bootstrap-lifecycle": {"text": "Hash form is validated; password forms set akadmin password and token form supplies an API token, not a browser password. Already-setup tenants are skipped, so these inputs are not rotation.", "components": ["authentik"], "sources": ["authentik:s2a37351e0dbc", "authentik:s01be08361cc2"], "status": "REASONED"}, "bootstrap-cleanup": {"text": "Verify bootstrap and setup closure before publication, then remove the bootstrap secret from the startup environment and protect file/container-inspection access.", "components": ["authentik"], "sources": ["authentik:s2a37351e0dbc", "authentik:scc60a0e302d1"], "status": "REASONED"}, "keycloak-bootstrap": {"text": "Keycloak bootstrap admin is temporary and needs manual removal after establishing permanent, more secure, MFA-protected admin access (this guide's requirement); this guide infers from the manual-removal instruction that it does not expire on its own. MFA protection requires a factor in the authentication flow, not only enrolment.", "components": ["keycloak", "keycloak-source"], "sources": ["keycloak:sbbd55dcb81cd", "keycloak:sefec90245fa1", "keycloak-source:sd5a9fd571726", "keycloak-source:sff5c9a23ec46", "keycloak-source:sdc09746a23d8"], "status": "REASONED"}, "keycloak-management": {"text": "Enabled Keycloak health and metrics use management port 9000; keep it private and use /health/ready for the orchestrator.", "components": ["keycloak"], "sources": ["keycloak:s4b5df6f4ef2e"], "status": "REASONED"}, "management-mtls": {"text": "https-management-client-auth accepts none/request/required and inherits HTTP settings; this plaintext topology needs management TLS credentials and trust before client certificates can authenticate it.", "components": ["keycloak"], "sources": ["keycloak:s4b5df6f4ef2e"], "status": "REASONED"}, "authentik-metrics": {"text": "At version/2026.8.5, the Rust core metrics listener uses plain HTTP with default [::]:9300, and the main Dockerfile ships the Rust binary run by ak; whether published images were built from it is NOT ESTABLISHED. The shipped Compose file does not publish 9300, but the container-network listener still needs restriction.", "components": ["authentik"], "sources": ["authentik:s4fc8280e760f", "authentik:sed8d69a749a7", "authentik:sa2dcdc13cd29", "authentik:s5c36281a2a21", "authentik:se669fb431f33", "authentik:scc60a0e302d1", "authentik:s675653f9e0f8", "authentik:sd3e2ba0c6343", "authentik:s8329bf6c67f8", "authentik:s07817f9dd888", "authentik:s2dc55e93d595"], "status": "REASONED"}, "authentik-go-metrics": {"text": "At version/2026.8.5, a Go web server metrics listener is also present: cmd/server calls WebServer.Start, which calls runMetricsServer per listen.metrics address. A version/2026.8.3 search, not repeated since, found no image or release build of cmd/server; a developer debug launch targets it. The main Dockerfile ships the Rust binary; whether published images were built from it is NOT ESTABLISHED.", "components": ["authentik"], "sources": ["authentik:s0350a6463f9c", "authentik:s70d2fe895b7b", "authentik:sdacc5da217ab", "authentik:s430f8fb6719e", "authentik:s675653f9e0f8", "authentik:sd3e2ba0c6343"], "status": "REASONED"}, "authentik-metrics-startup": {"text": "At version/2026.8.5, the Rust main starts the metrics server before the per-mode dispatch (healthcheck exits earlier), including proxy mode in proxy-feature builds; Server and AllInOne store the server handle, Worker and AllInOne the worker handles. Whether the published image was built from the tree's Dockerfile is NOT ESTABLISHED.", "components": ["authentik"], "sources": ["authentik:s6e40a30f1d99", "authentik:sed8d69a749a7", "authentik:s675653f9e0f8", "authentik:sd3e2ba0c6343", "authentik:s8329bf6c67f8", "authentik:s07817f9dd888", "authentik:s2dc55e93d595", "authentik:scc60a0e302d1"], "status": "REASONED"}, "worker-outpost-metrics": {"text": "At version/2026.8.5, worker startup uses the same Rust metrics server as server mode; its handler serves Prometheus metrics on listen.metrics (default [::]:9300) via plain HTTP, with no authentication layer in the router wrapper. The LDAP outpost builds its metrics router with a bare Prometheus handler at /metrics and serves it on each metrics listen address and a Unix socket; section 7 cites pinned proxy, LDAP and RADIUS listener code, but RAC listeners were not traced. Restrict to monitoring clients.", "components": ["authentik"], "sources": ["authentik:s4fc8280e760f", "authentik:sed8d69a749a7", "authentik:sa2dcdc13cd29", "authentik:s5c36281a2a21", "authentik:sf577784d5ebc", "authentik:sb8dece660959", "authentik:se669fb431f33", "authentik:s0c4d3f60ff27", "authentik:s030a7cc6a274"], "status": "REASONED"}, "keycloak-mfa": {"text": "Enrol administrator TOTP or WebAuthn and require the factor in the realm authentication flow. When Keycloak's default conditional 2FA sub-flow is reached, it asks for OTP if the user has a configured OTP credential; a successful Cookie execution skips the sub-flow, and passwordless WebAuthn authentication can avoid 2FA. Its WebAuthn Authenticator execution is disabled by default. A Required second factor can instead be enrolled during authentication without proving possession of a preexisting factor, so test an administrator with no registered factor and inspect the flow.", "components": ["keycloak", "keycloak-source"], "sources": ["keycloak:sefec90245fa1", "keycloak-source:sff5c9a23ec46", "keycloak-source:se54a2a6e7d47", "keycloak-source:s277571a617ec", "keycloak-source:sdc09746a23d8", "keycloak-source:sde43d0e31c0f", "keycloak-source:s6e859a060525", "keycloak-source:s3f7c345f457f"], "status": "REASONED"}, "brute-force-default": {"text": "Keycloak brute-force detection is disabled by default and must be enabled per realm.", "components": ["keycloak"], "sources": ["keycloak:sefec90245fa1"], "status": "REASONED"}, "redirect-wildcards": {"text": "Keycloak permits trailing redirect-URI wildcards and bare *; deliberately register exact URIs instead.", "components": ["keycloak"], "sources": ["keycloak:sefec90245fa1"], "status": "REASONED"}, "docker-socket": {"text": "authentik Compose mounts the worker Docker socket for outpost management; use a socket proxy or remove the mount and manage outposts manually.", "components": ["authentik-docs", "authentik"], "sources": ["authentik-docs:sb44156cde339", "authentik:scc60a0e302d1"], "status": "REASONED"}, "cache-ports": {"text": "Keycloak cache transport uses 7800 and failure detection 57800; TCP stacks enable TLS by default but still need private reachability.", "components": ["keycloak"], "sources": ["keycloak:s44e24ad4e579"], "status": "REASONED"}, "redis-history": {"text": "Redis 6379 is relevant to older authentik: tasks moved in 2025.8 and remaining uses in 2025.10, which removes Redis entirely.", "components": ["redis-removal"], "sources": ["redis-removal:seaa0a9896153"], "status": "REASONED"}, "verify-setup": {"text": "On a disposable instance, complete anonymous root-to-setup flow and confirm akadmin login; after setup or password bootstrap, fresh /setup visits must return to authentication. Source tests were read, not run.", "components": ["authentik"], "sources": ["authentik:s67916683010a", "authentik:s01be08361cc2", "authentik:sbfdadaefea47"], "status": "REASONED"}, "verify-publication": {"text": "Compare exposed and loopback publications: untrusted clients lose setup access while a local browser still reaches it. Neither configuration publishes 9300.", "components": ["authentik"], "sources": ["authentik:scc60a0e302d1", "authentik:se669fb431f33"], "status": "REASONED"}, "verify-inventory": {"text": "Inspect all listeners plus Compose publications: host ss misses container namespaces, Compose ps sees one project, and proxy 443 publication is expected.", "components": ["authentik", "keycloak"], "sources": ["authentik:scc60a0e302d1", "authentik:se669fb431f33", "keycloak:s44e24ad4e579", "keycloak:s4b5df6f4ef2e"], "status": "REASONED", "verify": [1, 2]}, "verify-issuer": {"text": "From outside allowed networks, application-realm discovery must print the expected issuer before negative route checks are interpreted; an arbitrary 200 is insufficient.", "components": ["keycloak"], "sources": ["keycloak:se5538391cf9f", "keycloak:sffc6123bea5c"], "status": "REASONED", "verify": [3]}, "verify-console": {"text": "Public /admin/master/console/ must return 404; 2xx, 3xx, 401 and 403 do not show that the console path is closed.", "components": ["keycloak", "nginx"], "sources": ["keycloak:sffc6123bea5c", "nginx:s8d8f3323b468"], "status": "REASONED", "verify": [3]}, "verify-admin-api": {"text": "Public /admin/realms/master/users must return 404; 401 means the API still answers and requests a token.", "components": ["keycloak", "nginx"], "sources": ["keycloak:se5538391cf9f", "nginx:s8d8f3323b468"], "status": "REASONED", "verify": [3]}, "verify-master": {"text": "Outside allowed sources, master-realm discovery must return 403; 200 exposes it and 404 indicates blanket denial. Review effective locations beyond these sampled routes.", "components": ["keycloak", "nginx"], "sources": ["keycloak:s2f5f079c5e00", "nginx:sf7458f108aca"], "status": "REASONED", "verify": [3]}, "verify-admin-login": {"text": "An allowed administrator must complete a real login through the private console and observe where it authenticates, which by this guide's reading, not established at 26.8.2, is the frontend master realm; the network restriction on that path must admit the administrator, and outside sources must fail.", "components": ["keycloak"], "sources": ["keycloak:se5538391cf9f", "keycloak:sffc6123bea5c"], "status": "REASONED", "verify": [3]}, "verify-direct-ports": {"text": "Probe 8080/8443/9000/9300/9443 directly: curl exit 0 reports a completed operation, 7 reports a connection failure, and 28 is an inconclusive timeout requiring full diagnostics to distinguish accepted-then-stalled from unreachable. Treat a connection failure as the desired isolation outcome only after checking the diagnostic and target; this guide infers that exits 52 and 56 follow an accepted connection, so a listener is reachable. Other exits require diagnostic review. The connection and full-transfer deadlines differ. The checks remain REASONED expectations, not observed outcomes.", "components": ["keycloak", "authentik", "curl", "keycloak-source"], "sources": ["curl:s9d0c5b92e9a4", "keycloak:s4b5df6f4ef2e", "keycloak:sffc6123bea5c", "authentik:scc60a0e302d1", "authentik:s4fc8280e760f", "authentik:se669fb431f33", "keycloak-source:s32a00c2c8803", "curl:s25c3e6e61c14", "curl:s9c78f229c8ba", "curl:sc7945d33c78d", "curl:s977c1ef288cc", "curl:sc39d43ba6e58", "curl:s9380fc35e98e"], "status": "REASONED", "verify": [4]}, "verify-bootstrap-removal": {"text": "Look up the bootstrap username with exact=true rather than a user list, whose 100-result default is not established at 26.8.2, inspect the returned username, and separately check bootstrap service accounts. Keycloak documents non-exact matching for its -q q=username: filter; this guide keeps the -q order (exact=true first) as defensive advice based on a report, not documented ordering behaviour.", "components": ["keycloak", "keycloak-admin-cli"], "sources": ["keycloak:sbbd55dcb81cd", "keycloak:sefec90245fa1", "keycloak-admin-cli:s8b4e40148629"], "status": "REASONED", "verify": [4]}, "verify-address-coverage": {"text": "Repeat external checks for every public IPv4/IPv6 address and the origin; private admin, database and outposts need separate checks, and the listed ports are not a full inventory.", "components": ["keycloak", "authentik-docs"], "sources": ["keycloak:s2f5f079c5e00", "keycloak:sffc6123bea5c", "authentik-docs:sb44156cde339"], "status": "REASONED", "verify": [3, 4]}, "authentik-mfa": {"text": "authentik has TOTP and WebAuthn setup stages; enrol a phishing-resistant administrator factor before connecting the first app and require it through an Authenticator Validate stage bound to the authentication flow (this guide's requirement; the cited sources do not state this ordering). A registered device alone does not establish the flow binding. For a user with no allowed device, DENY refuses access. CONFIGURE errors and refuses when no configuration stage is set, inserts one stage when exactly one is set, or offers a choice when several are set; with a configuration stage set, only DENY refuses that user. The pinned validate-stage and flow-binding sources support these behaviours but do not establish an administrator-specific factor policy.", "components": ["authentik-docs", "authentik"], "sources": ["authentik-docs:sb44156cde339", "authentik:sf19de9022fee", "authentik:s77bfc5116d72", "authentik:sdf8b8d567281", "authentik:s68ce378d011e", "authentik:s6500fae4a5ac", "authentik:se89f1e7b7d18", "authentik:s506b3d80d9ce", "authentik:sd5b24af3593a"], "status": "REASONED"}, "authentik-admin-boundary": {"text": "At version/2026.8.5, /if/admin/, /if/user/ and flows share core routing. This guide assesses that authentik has no equivalent to hostname-admin: the cited configuration, Go startup and Rust server startup show no separate administration listener, not an exhaustive proof of deployed topology. The permission boundary remains unestablished.", "components": ["authentik"], "sources": ["authentik:s313f1f9b5bc9", "authentik:s98f257d3ede1", "authentik:s8012ab258605", "authentik:s70d2fe895b7b", "authentik:sb8906abab13c", "authentik:s3e1090638af8"], "status": "REASONED"}, "authentik-admin-listeners": {"text": "At version/2026.8.5, defaults are HTTP [::]:9000, HTTPS [::]:9443 and metrics [::]:9300; Go startup adds these configured listeners and a Unix socket, and its plain HTTP, HTTPS and Unix listeners share the application proxy router after outpost handling. These files show shared routing, not a separate admin port, and do not prove which listeners a deployed build starts.", "components": ["authentik"], "sources": ["authentik:se669fb431f33", "authentik:s8012ab258605", "authentik:s70d2fe895b7b", "authentik:s63d78b9d40d8", "authentik:s6c3afec54d75", "authentik:s1626a6c0776a"], "status": "REASONED"}, "authentik-admin-interface": {"text": "At version/2026.8.5, both interface routes redirect authenticated external, service-account and internal-service-account users to a default app or deny them; other requests fall through. These view classes show no administrator-permission check, so this guide does not treat loading the admin template as proof of authorization.", "components": ["authentik"], "sources": ["authentik:s98f257d3ede1", "authentik:s169ffaaa70fc"], "status": "REASONED"}, "authentik-admin-api-mount": {"text": "At version/2026.8.5, API v3 registers application API routes, including core users, groups, tokens and admin authenticators. The root URL configuration mounts each app under the web path; the API app mounts api/ and then v3/, so the v3 router is at api/v3/, /api/v3/ by default, matching the Go client's api/v3 prefix. Endpoint permission wiring is not established by these files.", "components": ["authentik"], "sources": ["authentik:s98f257d3ede1", "authentik:s2cdcdb3b510b", "authentik:s70d2fe895b7b", "authentik:s073e329f067a", "authentik:s12fcc19f34ee", "authentik:sc84a7d5db870", "authentik:sa06907a58b4e", "authentik:sf668b16f361a", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s86eb476b495a", "authentik:s3d8996ccb579"], "status": "REASONED"}, "authentik-admin-api-auth": {"text": "At version/2026.8.5, TokenAuthentication recognizes API-intent tokens, API-scoped OAuth tokens, the managed-outpost secret and IPC key; it rejects inactive users and unrecognized credentials, but absent or unsupported authentication can return no user. This does not establish session authentication or every endpoint adopting this class.", "components": ["authentik"], "sources": ["authentik:s68d603b9d5bc"], "status": "REASONED"}, "authentik-admin-rbac": {"text": "At version/2026.8.5, ObjectPermissions' request check passes object-lookup requests (its comment leaves view checks to ObjectFilter, writes to the object check) and permits configured creation without add permission; the object check accepts global permissions or configured ownership before delegating. HasPermission checks user.has_perms; endpoint wiring and permission boundary stay unestablished.", "components": ["authentik"], "sources": ["authentik:s9f2178a64542"], "status": "REASONED"}, "authentik-admin-proxy": {"text": "Build any network boundary around authentik administration at the proxy using path restrictions; identify and test required paths for the deployed version and maintain the list. This guide assesses that the UI is not designed around this separation and path-based separation is difficult to maintain; this is not a vendor-supported recipe, and the cited files give no complete path list.", "components": ["authentik"], "sources": ["authentik:s313f1f9b5bc9", "authentik:s98f257d3ede1", "authentik:s2cdcdb3b510b", "authentik:s70d2fe895b7b"], "status": "REASONED"}, "authentik-admin-paths": {"text": "At version/2026.8.5, core URLs show /if/admin/, /if/user/, flows, HTTP admin-template fallbacks at /ws/outpost// and /ws/client/, and a /ws/client/ websocket route wrapped in authentication middleware. With the API client prefix, these are paths to investigate, not a complete restriction list or proof of websocket authorization.", "components": ["authentik"], "sources": ["authentik:s98f257d3ede1", "authentik:s70d2fe895b7b"], "status": "REASONED"}, "authentik-rust-listeners": {"text": "At version/2026.8.5, the main Dockerfile ships the Rust binary run by ak. Server mode runs gunicorn on a Unix socket and serves one router over HTTP on listen.http, rustls on listen.https and a Unix socket, sending embedded-outpost apps to the outpost and the rest via the core router to gunicorn. Worker mode serves health checks on listen.http. Deployed topology beyond Compose is NOT ESTABLISHED.", "components": ["authentik"], "sources": ["authentik:s675653f9e0f8", "authentik:sd3e2ba0c6343", "authentik:s8329bf6c67f8", "authentik:s07817f9dd888", "authentik:s2dc55e93d595", "authentik:scc60a0e302d1", "authentik:s5af5d9143036", "authentik:s3e1090638af8", "authentik:s14e51acafd6f", "authentik:s906b273aa104", "authentik:sa01f6c27bc59", "authentik:sdb8c87fa0cfc", "authentik:s949377fe0d94", "authentik:se669fb431f33"], "status": "REASONED"}, "authentik-api-defaults": {"text": "At version/2026.8.5, REST framework defaults are TokenAuthentication then SessionAuthentication, ObjectPermissions and an ObjectFilter backend; settings modules can update them, and a version/2026.8.3 search, not repeated since, found no app settings module doing so. Users and groups add AgentAuth; the flow executor uses AllowAny. SessionAuthentication and deployment overrides are NOT ESTABLISHED.", "components": ["authentik"], "sources": ["authentik:s7c88d077f919", "authentik:s19f8c9e313eb", "authentik:s402352aa9811", "authentik:sb2bc7f868825", "authentik:s3ea4b10861b1"], "status": "REASONED"}, "authentik-admin-websockets": {"text": "At version/2026.8.5, the websocket modules a version/2026.8.3 search found give /ws/client/, /ws/outpost//, /ws/rac// and /ws/outpost_rac// under the web path; others are not established. The /ws/client/ route resolves a session or anonymous user and accepts every connection; the RAC client consumer on /ws/rac/ accepts, then denies tokens not bound to the session.", "components": ["authentik"], "sources": ["authentik:s62327707b202", "authentik:s9e012ed398d4", "authentik:sf668b16f361a", "authentik:s98f257d3ede1", "authentik:s5b1985e0936f", "authentik:s6d275572bf62", "authentik:s4b92525713bb", "authentik:s6b20bb333911", "authentik:sf02ba388bb2f", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s86eb476b495a", "authentik:s3d8996ccb579"], "status": "REASONED"}, "authentik-outpost-websockets": {"text": "At version/2026.8.5, under the configured web path (default / shown), the /ws/outpost/ and /ws/outpost_rac/ websocket routes deny a connection unless TokenAuthentication accepts its bearer token; /ws/outpost/ also needs outpost view permission, while /ws/outpost_rac/'s RAC outpost consumer accepts with no outpost-specific check.", "components": ["authentik"], "sources": ["authentik:s62327707b202", "authentik:sf02ba388bb2f", "authentik:s02f5150b41d1", "authentik:s369c9cffd8f5", "authentik:s4b92525713bb", "authentik:s331ad7f37dac", "authentik:sf668b16f361a", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s86eb476b495a", "authentik:s3d8996ccb579"], "status": "REASONED"}, "authentik-admin-api-shared": {"text": "At version/2026.8.5, the API router registers the flow executor at flows/executor// with AllowAny, the API schema names that path's GET flows_executor_get, and the flow interface calls flowsExecutorGet, read here as that GET's generated client method, so this guide infers refusing the whole API prefix would break login.", "components": ["authentik"], "sources": ["authentik:s033849961834", "authentik:s3ea4b10861b1", "authentik:s9eece0a5a170", "authentik:s1ff44bd506fd", "authentik:s073e329f067a", "authentik:s2cdcdb3b510b"], "status": "REASONED"}, "authentik-admin-static-paths": {"text": "At version/2026.8.5, the Rust static router also serves /if/admin/assets/ under the configured web path (shown with the default /) alongside the user, flow and RAC interface assets. Path lists from the routing tables this guide read are bounded by those tables; complete path coverage remains open.", "components": ["authentik"], "sources": ["authentik:s247ca79b6f6c", "authentik:s906b273aa104", "authentik:s3e1090638af8", "authentik:s073e329f067a", "authentik:s2cdcdb3b510b", "authentik:s62327707b202", "authentik:s98f257d3ede1", "authentik:s033849961834", "authentik:sf668b16f361a", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s86eb476b495a", "authentik:s3d8996ccb579"], "status": "REASONED"}, "verify-mfa-password": {"text": "For both providers, open a fresh session with only the administrator password and confirm access is refused after factor enforcement. This guide assesses that an already-enrolled administrator's password-only refusal does not distinguish enrolment from enforcement; test a user with no registered factor and inspect the flow or stage settings. Keycloak's default conditional 2FA, when reached (a successful Cookie execution skips it), prompts for configured OTP unless passwordless WebAuthn already authenticated the user; the default flow's WebAuthn Authenticator execution is disabled by default; and a Required factor the user lacks can be enrolled during authentication. At authentik version/2026.8.5, the unconfigured-device action defaults to SKIP; DENY refuses a user with no allowed device. CONFIGURE errors and refuses when no configuration stage is set, inserts one stage when exactly one is set, or offers a choice when several are set; with a configuration stage set, only DENY refuses that user. Bind the validation stage to the authentication flow.", "components": ["keycloak", "authentik-docs", "authentik", "keycloak-source"], "sources": ["keycloak:sefec90245fa1", "authentik-docs:sb44156cde339", "authentik:s2986fcb4cb32", "authentik:s16ad051165b7", "keycloak-source:sff5c9a23ec46", "keycloak-source:s277571a617ec", "authentik:sf19de9022fee", "authentik:s77bfc5116d72", "authentik:sdf8b8d567281", "authentik:s68ce378d011e", "authentik:s6500fae4a5ac", "authentik:se89f1e7b7d18"], "status": "REASONED"}, "management-auth-default": {"text": "In this plain-HTTP Keycloak topology no HTTP client authentication is configured, so inherited management client authentication is usually none; required needs management TLS credentials and trust first.", "components": ["keycloak"], "sources": ["keycloak:s4b5df6f4ef2e"], "status": "REASONED"}, "docker-socket-scope": {"text": "Docker's daemon requires root unless rootless (daemon and containers run non-root); only trusted users should control it. For authentik's shipped Compose worker socket, check daemon type and restrict access: this guide assesses ordinary rootful access as host-root-equivalent and rootless blast radius as the daemon user and what it can access; sources do not directly demonstrate either assessment.", "components": ["authentik-docs", "authentik", "docker-engine", "docker-engine-pinned"], "sources": ["authentik-docs:sb44156cde339", "authentik:scc60a0e302d1", "docker-engine:s18f59818e451", "docker-engine:s7e61efb4dd59", "docker-engine-pinned:secebea1a69ab", "docker-engine-pinned:s8eab393a61c6"], "status": "REASONED"}, "outpost-boundaries": {"text": "At version/2026.8.5, authentik supports Proxy, LDAP, RADIUS and RAC outposts. The embedded Proxy outpost runs in the main server and shares ports 9000 and 9443 rather than opening another port; managed or manually deployed outposts can run elsewhere. This guide requires separate exposure checks for each deployed outpost and assesses that its Verify probes do not demonstrate them.", "components": ["authentik-docs", "authentik"], "sources": ["authentik-docs:sb44156cde339", "authentik:s6ee73dbd270c", "authentik:s741c951134bd", "authentik:s0249923fde2f", "authentik:sf4642b40524f"], "status": "REASONED"}, "outpost-proxy-image": {"text": "At version/2026.8.5, proxy.Dockerfile, titled the authentik proxy outpost image, builds the Rust authentik binary with only the proxy feature, has no go build step, copies only that binary and runs /authentik proxy. This guide infers that an image built from it runs the Rust proxy outpost, not the cited Go proxy code; whether published images were built from it is NOT ESTABLISHED.", "components": ["authentik"], "sources": ["authentik:s82a97fe0acf2", "authentik:s9082a2dbb6c5", "authentik:s4a31ab9f5ec9", "authentik:s6e40a30f1d99", "authentik:s0c4d3f60ff27"], "status": "REASONED"}, "outpost-proxy-listeners": {"text": "At version/2026.8.5, the authentik proxy subcommand starts the Rust proxy outpost, which serves plain HTTP on each listen.http address and rustls on each listen.https address unless it is the embedded outpost; Rust startup schedules the metrics server on listen.metrics before mode dispatch. Pinned defaults are [::]:9000, [::]:9443 and [::]:9300, and proxy.Dockerfile exposes 9000, 9300 and 9443.", "components": ["authentik"], "sources": ["authentik:s4a31ab9f5ec9", "authentik:s6e40a30f1d99", "authentik:s4fc8280e760f", "authentik:sa01f6c27bc59", "authentik:se669fb431f33", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s82a97fe0acf2", "authentik:s9082a2dbb6c5"], "status": "REASONED"}, "outpost-deployment-listeners": {"text": "At version/2026.8.5, the pinned Go proxy code listens on configured HTTP, HTTPS and metrics addresses. The Docker controller maps the outpost's ports to the host unless docker_map_ports is false; docker_map_ports defaults to true. The Kubernetes controller reconciles Deployment and Service objects. LDAP and RADIUS listener code is also pinned, but RAC listeners were not traced.", "components": ["authentik"], "sources": ["authentik:s0c4d3f60ff27", "authentik:s030a7cc6a274", "authentik:se669fb431f33", "authentik:sb8dece660959", "authentik:s63fcd0ce5a18", "authentik:s6be1839a0a33", "authentik:sb24757cb27e6"], "status": "REASONED"}, "verify-proxy-neutralization": {"text": "Run external probes outside every allowed network; unset HTTP/HTTPS/ALL/NO_PROXY variables in both cases and use curl -q --noproxy '*' to bypass proxies and curlrc, which this guide assesses could otherwise make blocked endpoints appear reachable through allowed egress. Curl's HTTP proxy environment variable is lowercase only; -q must be curl's first option.", "components": ["keycloak", "curl"], "sources": ["keycloak:sffc6123bea5c", "curl:sde07f1edf2dc", "curl:s75e3807878ff", "curl:s508454c45cf1", "curl:s2278b86c08a6", "curl:sc8658f8a2e89"], "status": "REASONED", "verify": [3, 4]}, "verify-authentik-routes": {"text": "The fenced paths are Keycloak-specific; the exclusion of Keycloak paths from authentik is this guide's inference from the fetched route files, not an exhaustive route proof. For authentik, use a deployed application's actual OAuth2 discovery URL as the positive control and confirm its configured issuer. A responding status alone is not the positive-control pass condition.", "components": ["authentik-docs", "authentik"], "sources": ["authentik-docs:sb44156cde339", "authentik:s313f1f9b5bc9", "authentik:s25612b8e4bc3", "authentik:s1b0b1b6332d8", "authentik:s42b24ec40c5a", "authentik:s073e329f067a"], "status": "REASONED", "verify": [3]}, "verify-authentik-admin-routes": {"text": "For authentik, an untrusted client must not reach the chosen administration route while an allowed administrator can. Check the administration API surface you choose to restrict separately: an untrusted client must be refused while an authorized administrator on the allowed network can complete an API request.", "components": ["authentik-docs", "authentik"], "sources": ["authentik-docs:sb44156cde339", "authentik:s313f1f9b5bc9", "authentik:s2cdcdb3b510b"], "status": "REASONED", "verify": [3]}, "verify-authentik-login-api": {"text": "Do not refuse the whole authentik API prefix without tracing login dependencies; confirm an ordinary user can still complete login. At version/2026.8.5 the v3 router is at api/v3/ (/api/v3/ by default) and registers the flow executor with AllowAny; the flow interface calls flowsExecutorGet, taken here as its generated client method, so this guide infers that login uses the API prefix.", "components": ["authentik"], "sources": ["authentik:s073e329f067a", "authentik:sc84a7d5db870", "authentik:sa06907a58b4e", "authentik:s2cdcdb3b510b", "authentik:s033849961834", "authentik:s3ea4b10861b1", "authentik:s9eece0a5a170", "authentik:s1ff44bd506fd", "authentik:sf668b16f361a", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s86eb476b495a", "authentik:s3d8996ccb579"], "status": "REASONED", "verify": [3]}, "verify-authentik-oauth2-routes": {"text": "At version/2026.8.5 the installed OAuth2 app mounts its urls module, which defines the per-application discovery suffix, at application/o/ and urls_root at the root; the root URL module mounts installed authentik apps' mountpoints under the web path, so the prefix is application/o/, /application/o/ by default. The issuer can be global or per-provider; /if/admin/ is a core route.", "components": ["authentik"], "sources": ["authentik:s25612b8e4bc3", "authentik:s1b0b1b6332d8", "authentik:s42b24ec40c5a", "authentik:s313f1f9b5bc9", "authentik:s073e329f067a", "authentik:s304feb3be14f", "authentik:s37a8db510590", "authentik:s2d86a3e9d60d", "authentik:s8d3ff0842282", "authentik:s12fcc19f34ee", "authentik:sf668b16f361a", "authentik:sb69f89fd29f8", "authentik:s3d85bc9743c4", "authentik:s86eb476b495a", "authentik:s3d8996ccb579"], "status": "REASONED"}, "admin-factor-order": {"text": "Enrol a phishing-resistant factor on the administrator account of either provider before connecting the first application and enforce it in the flow (this guide's requirement; the cited sources do not state this ordering). Keycloak documents Required executions; authentik version/2026.8.5 binds ordered stages to flows, but the cited files do not establish an administrator-specific factor policy.", "components": ["keycloak", "authentik-docs", "keycloak-source", "authentik"], "status": "REASONED", "sources": ["keycloak:sefec90245fa1", "authentik-docs:sb44156cde339", "keycloak-source:sff5c9a23ec46", "authentik:s68ce378d011e", "authentik:s6500fae4a5ac", "authentik:sf19de9022fee", "authentik:s77bfc5116d72"]}, "keycloak-http-opt-in": {"text": "In the production example, backend plain HTTP on default port 8080 exists only when explicitly enabled with --http-enabled=true; confine that hop to loopback or a controlled private segment. At 26.8.2, the HTTP listener option defaults false but is enabled by default in development mode, and HTTPS defaults to port 8443. In production, http-host defaults to 0.0.0.0, so constrain the bind or network reachability.", "components": ["keycloak", "keycloak-source"], "sources": ["keycloak:s2f5f079c5e00", "keycloak:sffc6123bea5c", "keycloak-source:sa452e5c860cd", "keycloak-source:s32a00c2c8803", "keycloak-source:sb3476e7a2eb2", "keycloak-source:s7fdfa4997898"], "status": "REASONED"}, "idp-maintenance": {"text": "This guide requires patching, tested backups and keeping a self-hosted identity provider available (the cited files prescribe no cadence) and assesses that a compromised or unavailable provider takes its dependent services with it. The cited sources establish no complete Keycloak or authentik backup procedure, authentik upgrade procedure, universal maintenance cadence or tested restore.", "components": ["keycloak", "authentik-docs", "authentik", "keycloak-source"], "sources": ["keycloak:s2f5f079c5e00", "authentik-docs:sb44156cde339", "authentik:sceae009a9d08", "keycloak-source:s94317d53483b", "keycloak-source:s14164b9cc6c3"], "status": "REASONED"}, "keycloak-security-fixes": {"text": "The 26.7.5 and 26.8.1 records list section 6's fixes: 26.8.1 fixes CVE-2026-94218 (2FA setup enforcement bypass) and CVE-2026-96445 (Conditional OTP trusts skip headers on untrusted proxy paths); 26.7.5 fixes brute-force lockout bypasses CVE-2026-16103 and CVE-2026-88770, not in 26.8.x records. 26.8.2's record is empty. Run 26.8.1 or later; each bypass's detail and backports are not established.", "components": ["keycloak-releases"], "sources": ["keycloak-releases:s32d0f0981b58", "keycloak-releases:s46d0047d5606", "keycloak-releases:sd570d95abe70", "keycloak-releases:s614ddc99af91"], "status": "REASONED"}, "keycloak-admin-api-v2": {"text": "Keycloak 26.8.0 promotes the Client Admin API v2 to preview, enabled with --features=preview or --features=client-admin-api:v2; the path it is served under at 26.8.2, and so whether the public /admin/ refusal covers it, are not established.", "components": ["keycloak-releases"], "sources": ["keycloak-releases:s46d0047d5606"], "status": "REASONED"}, "keycloak-scim-api": {"text": "Keycloak 26.8.0 promotes the SCIM API, for a realm's users and groups, to supported. The server admin guide served on 2026-11-05 (26.8.1 page links) gives its base URL under /realms/, ending /scim/v2, enabled per server and realm, needing a bearer token. This guide infers an enabled realm serves it publicly, master's only to proxy-allowed sources; its 26.8.2 default and path are not established.", "components": ["keycloak-releases", "keycloak"], "sources": ["keycloak-releases:s46d0047d5606", "keycloak:sefec90245fa1", "keycloak:sffc6123bea5c"], "status": "REASONED"} } } ```` ====================================================================== ==> oidc-integration.md ====================================================================== # OIDC login: wiring Google, Microsoft Entra, GitHub, and Okta into your app OpenID Connect lets your app sign people in with their Google, Microsoft Entra or Okta account; GitHub sign-in uses plain OAuth 2.0 without ID tokens. This guide helps your AI assistant wire them in securely, including the code flow with PKCE, validated ID tokens, and a per-provider allowlist check. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/oidc-integration.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against OpenID Connect Core 1.0, OpenID Connect Discovery 1.0, and OpenID Connect RP-Initiated Logout 1.0; 4 further sources are pinned to a commit and 10 carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 52 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Adding "Sign in with Google" takes an afternoon; the recurring defects are in what happens after the redirect comes back: an unvalidated ID token, an account matched by email, or an app that admits every Google or Microsoft account in existence because nobody checked whose it was. This guide gives the one flow every recipe shares, the exact claim to check per provider, the registration steps, and library pointers. Choosing a provider is covered in [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md); login placed in front of an app without code changes is covered in [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md). Authorizing an MCP client to a remote server layers additional obligations on this flow and is covered in [mcp-clients.md](https://github.com/jposluns/secureconfig/blob/main/mcp-clients.md). ## 1. The flow every recipe uses Authorization code flow with PKCE from a server-side (confidential) client. RFC 9700 requires PKCE for public clients and recommends it for all others, web applications included; it requires exact string matching of redirect URIs at the authorization server; and it says clients should not use the implicit grant (`response_type=token`). 1. Register the exact callback URL at the provider (scheme, host, path, trailing slash). Prefix or wildcard matching is what makes redirect-based code theft work. 2. Send a random `state` bound to the browser session and a random `nonce` on every authorization request, plus `code_challenge` with `code_challenge_method=S256`. Scopes: `openid email profile`. 3. Exchange the code at the token endpoint from the server, with the client secret and the `code_verifier` that produced the `code_challenge`. Never from the browser. 4. Validate the ID token before trusting any claim (OpenID Connect Core section 3.1.3.7): the signature against the key set at the provider's `jwks_uri`, using an algorithm you pinned (Core says RS256 unless you registered another; the discovery document lists the provider's `id_token_signing_alg_values_supported`) rather than whatever the token header names. The ID-token validation per that section also checks: `iss` exactly equals the issuer you configured; `aud` contains your client ID and lists no audience you do not trust (per OIDC Core, reject a token carrying an untrusted additional audience, and where an `azp` claim is present confirm it equals your client ID); `exp` is in the future; `nonce` equals the one you sent. 5. Start a server-side session and give the browser only a session cookie marked `Secure`, `HttpOnly`, and `SameSite` per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Do not put ID or access tokens in `localStorage` or a script-readable cookie; nothing in the browser needs them. Keep authorization codes and tokens out of server logs, traces, and analytics, set `Referrer-Policy: no-referrer` on the callback, and redirect to a clean URL so the code does not linger in browser history or a `Referer` header. This login recipe does not request offline access, but refresh-token issuance is provider-specific, so treat any refresh token you receive as a server-side secret, send it only to the token endpoint, and revoke it on offboarding. 6. Link the identity to a local account by the pair (issuer, `sub`). OpenID Connect Core section 5.7 says `email`, `phone_number`, and `preferred_username` are not guaranteed unique and may change; Google and Microsoft document the same for their `email` claims. Matching by email alone lets a re-used or unverified address take over an account. 7. Logout: destroy the server-side session and expire the cookie; where the discovery document lists an `end_session_endpoint`, also redirect there with `id_token_hint` and a registered `post_logout_redirect_uri` (Entra: `/oauth2/v2.0/logout`). The discovery document at `/.well-known/openid-configuration` supplies the endpoints and `jwks_uri`; for tenant-specific discovery its `issuer` value must be identical to the prefix you fetched it from (Entra's tenant-independent `common`/`organizations` metadata instead returns a templated issuer, handled below). Every library in section 4 reads it for you. ## 2. Login is not authorization The provider proves who the person is. Whether they may use your app is your check, run after token validation and before the session exists, against an allowlist. The claim differs per provider: - **Google**: the `hd` claim in the ID token must equal your Workspace domain (`example.com`). The `hd` request parameter only optimizes the account picker; Google's documentation says not to rely on it for access control and to validate the returned `hd` claim. Treat a missing `hd` claim as a rejection. - **Microsoft Entra**: the `tid` claim must be your tenant ID and `iss` must be `https://login.microsoftonline.com//v2.0`. Register the app as **Single tenant only** when only your organization signs in. The `organizations` authority accepts any Entra tenant and `common` also accepts personal accounts; the issuer then varies per tenant, so an app on those authorities that does not check `tid` (or the GUID in `iss`) admits every Microsoft account. Microsoft documents `email` and `preferred_username` as mutable and unfit for authorization; key the local account on `oid` or `sub` plus `tid`. - **GitHub**: GitHub OAuth apps speak OAuth 2.0, not OpenID Connect; there is no discovery document and no ID token. After the token exchange call `GET https://api.github.com/user` for the identity, then check `GET /user/memberships/orgs/` (200 with `"state": "active"` means a member; 404 means not affiliated). The membership check can instead target a team with `GET /orgs//teams//memberships/` (200 with `"state": "active"`; the REST reference documents `pending` for an unaccepted invitation, and 404 means no membership). Both need the `read:org` scope. Key the local account on the numeric `id` from `/user`, not the `login`. - **Okta**: add a `groups` claim to the ID token (org authorization server: **Applications > Applications >** your app **> Sign On**, edit the OpenID Connect ID Token section, filter **Matches regex** `.*`; the client must also request the `groups` scope) and require membership of a named group. The claim holds at most 100 groups and the request fails beyond that; use a narrower filter in large orgs. The default for an identity that passes none of these is to reject and log it, never to create a pending account an admin later forgets to review. ## 3. Register the app at each provider Keep the web app client secret confidential, as [RFC 6749 section 10.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-10.1) requires. Prefer a secret manager (this guide's advice; OWASP lists [secret managers](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L313) as an option). In containers, use an orchestrator-mounted secret or secret-store fetch where possible; use a runtime environment variable only if those are unavailable, per [OWASP container guidance](https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L462-L464). Never commit the secret to a repository or bake it into an image (this guide's advice; [Docker documents](https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L15) that build `ARG` and `ENV` persist in the final image). See [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). Substitute the callback path your library expects for `https://app.example.com/auth/callback`. - **Google**: Google Cloud console **Clients** page (`https://console.developers.google.com/auth/clients`); create an OAuth client and add the redirect URI. The match is exact, including scheme, case, and trailing slash. Discovery: `https://accounts.google.com/.well-known/openid-configuration`; `iss` is `https://accounts.google.com` or `accounts.google.com`. - **Microsoft Entra**: Microsoft Entra admin center, **Entra ID > App registrations > New registration**; under **Supported account types** choose **Single tenant only** unless you are building for other organizations. Then **Authentication > Add a platform > Web** and add the redirect URI. Record the Application (client) ID and create a client secret. Discovery: `https://login.microsoftonline.com//v2.0/.well-known/openid-configuration` with `` your directory (tenant) ID; prefer this tenant-specific form for a single-tenant app. `common` and `organizations` return a templated issuer `https://login.microsoftonline.com/{tenantid}/v2.0`, so validate by substituting the token's `tid` into the template, confirming `tid` is a GUID and the result equals `iss` exactly, restricting the signing key by its published `issuer` scope, and enforcing your tenant allowlist; never relax issuer validation to make `common` work. - **GitHub**: profile picture **> Settings > Developer settings > OAuth apps > New OAuth App**; set the Authorization callback URL. Authorize at `https://github.com/login/oauth/authorize` with `client_id`, `redirect_uri`, `scope=read:user read:org`, `state`, and PKCE (`code_challenge` with `code_challenge_method=S256`; GitHub does not accept `plain`); exchange at `https://github.com/login/oauth/access_token` with `client_id`, `client_secret`, `code`, the `code_verifier` that produced the challenge, and the same `redirect_uri`. Always send `redirect_uri`; when it is absent GitHub uses the first registered callback. Apps that had a single callback URL before August 3, 2026 keep wildcard matching for it, which accepts any subdomain or subdirectory of the registered host; disable wildcard matching in the app settings so the callback must match exactly. - **Okta**: Admin Console, **Applications and Resources > Applications > Create App Integration**, sign-in method **OIDC - OpenID Connect**, type **Web Application**; set the sign-in and sign-out redirect URIs and the assignment (Okta's guide allows everyone in the org; narrow it to a group). Client ID and secret are on the **General** tab under Client Credentials. Discovery: `https://.okta.com/.well-known/openid-configuration` for the org authorization server, `https://.okta.com/oauth2//.well-known/openid-configuration` for a custom one; `iss` equals that prefix. ## 4. Libraries Use a maintained library; do not hand-parse JWTs. The snippets are from each library's documentation; the section 2 check is yours to add. - **Node, [openid-client](https://github.com/panva/openid-client)** (`npm install openid-client`): ```javascript let config = await client.discovery(server, clientId, clientSecret) client.enableNonRepudiationChecks(config) // enforce rule 4's ID-token signature check; by default openid-client trusts the token-endpoint TLS channel for the code flow let code_verifier = client.randomPKCECodeVerifier() let code_challenge = await client.calculatePKCECodeChallenge(code_verifier) let state = client.randomState() let nonce = client.randomNonce() let redirectTo = client.buildAuthorizationUrl(config, { redirect_uri, scope, code_challenge, code_challenge_method: 'S256', state, nonce }) // callback (store code_verifier, state, and nonce in the initiating browser's server-side session, not local variables): let tokens = await client.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: code_verifier, expectedState: state, expectedNonce: nonce }) ``` - **Node, [Auth.js](https://authjs.dev/)** (`npm install next-auth@beta` for Next.js; SvelteKit and Express integrations exist): providers `next-auth/providers/google`, `github`, `okta`, and `microsoft-entra-id`, configured through `AUTH__ID`, `AUTH__SECRET`, and for Okta and Entra `AUTH__ISSUER`; callbacks land on `/api/auth/callback/`. Sessions are an encrypted JWT, or a database session ID, in an `HttpOnly` cookie. With the JWT strategy, sign-out destroys the cookie but the token itself stays valid until `exp` unless your server keeps a blocklist (Auth.js documents this limitation); use database sessions, or the blocklist just mentioned, so logout invalidates a copied cookie rather than leaving it valid until `exp` ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) requires logout to invalidate the session server-side). Set `AUTH_MICROSOFT_ENTRA_ID_ISSUER` to your tenant's `/v2.0` issuer: the documented default is `common`. Put the section 2 check in the `signIn` callback. - **Python, [Authlib](https://docs.authlib.org/en/stable/)** (`pip install Authlib`; Flask, Django, Starlette, FastAPI): ```python oauth.register('google', client_id='YOUR_CLIENT_ID', client_secret='YOUR_CLIENT_SECRET', server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={'scope': 'openid profile email', 'code_challenge_method': 'S256'}) # login: return oauth.google.authorize_redirect(redirect_uri) # callback: token = oauth.google.authorize_access_token(); claims = token['userinfo'] ``` - **Go, [go-oidc](https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc)** (`github.com/coreos/go-oidc/v3/oidc`): ```go provider, err := oidc.NewProvider(ctx, "https://accounts.google.com") verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) idToken, err := verifier.Verify(ctx, rawIDToken) // signature, issuer, audience, expiry ``` The package documents that it does not check the nonce value: compare `idToken.Nonce` to the one you stored. Leave `SkipIssuerCheck` off. ## 5. Where MFA comes from Your app does not run the second factor; the provider does, under its policy. The Entra policy is Conditional Access (**Entra ID > Conditional Access > Policies**, grant **Require authentication strength**; a P1 feature per [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). The Google policy is 2-Step Verification in the Google Admin console (**Security > Authentication > 2-step verification**, Enforcement **On** for every user who signs in to the app, as this guide advises), and the Okta policies are authentication policies plus the global session policy. Ordering and options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). An app can additionally refuse a session whose ID token shows no second factor, where the provider documents the claim. Okta's `amr` array carries values such as `pwd`, `mfa`, `otp`, and `hwk`. For Entra, check the ID token claims reference and the optional claims reference for the `amr` claim and its `mfa` value before relying on it. For Google Workspace, super administrators can enforce 2-Step Verification in the Admin console for all users, or for a selected organizational unit or configuration group. Guide advice: set Enforcement **On** for every user who signs in to the app; no ID token MFA claim was verified for this guide. GitHub OAuth has no ID token. The pinned [organization policy](https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization.md#L22-L35) says that when an organization requires 2FA, members without 2FA retain membership but cannot access organization resources until they enable it; outside collaborators without 2FA are removed and lose repository access, including their forks of private repositories, and outside collaborators who later disable 2FA are automatically removed. The [enterprise administration page](https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L15), which is versioned for GitHub Enterprise Server only (page front matter), describes 2FA support with LDAP or built-in authentication. Its [warning](https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L30-L32) says that when 2FA is required, members without it retain membership but cannot access enterprise resources until they enable it, while outside collaborators (including bot accounts) without it are removed from the enterprise and its organization and lose repository access, including their forks of private repositories. It also says that outside collaborators who disable 2FA while it is required are automatically removed from the enterprise and its organizations, while members who disable it lose enterprise and organization resource access until they re-enable it. The same page documents [audit-log searches for people automatically removed for non-compliance when 2FA was required](https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L45). An active organization membership check establishes membership only; inference from the cited organization and GitHub Enterprise Server policies: membership alone does not prove MFA, because both pages say members without 2FA retain membership; neither establishes the membership API response for such a member. Guide assessment: MFA depends on the organization's effective 2FA requirement and whether it is enabled. Guide advice: verify that requirement before relying on it for MFA. This guide has not established that a member without 2FA fails the app's membership check; test with such an account before relying on it. GitHub's pinned REST API description says the [list-members endpoint](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L39346-L39376) (`GET /orgs/{org}/members`) takes a `2fa_disabled` filter, available only to organization owners, that returns only members without two-factor authentication enabled, so the description treats such accounts as members. Its [membership-check endpoint](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L39435-L39473) (`GET /orgs/{org}/members/{username}`) documents 204 when the requester is an organization member and the user is a member, with no 2FA condition stated. Its [authenticated-user membership endpoint](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L94998-L95037) (`GET /user/memberships/orgs/{org}`, the check in section 2) returns the authenticated user's membership if they are an active or pending member of the organization, 404 if they are not affiliated with it, and 403 if the request is made by a GitHub App that the organization has blocked, with no 2FA condition stated. This is an API description, not observed API behaviour. Guide assessment: an application that treats organization membership as proof of MFA would accept such a member; whether a given application's check fails for them is unestablished. Source reading of the same pinned description, which is an API description, not observed API behaviour: the [`org-membership` schema](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L143753-L143830) that the authenticated-user membership endpoint returns, and the [`organization-simple`](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L137588-L137660) and [`nullable-simple-user`](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L130511-L130635) schemas that the `org-membership` schema references, list no two-factor authentication property. The same source reading, again of an API description and not of observed API behaviour, finds that the [`team-membership` schema](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L151708-L151741) that the [team-membership endpoint](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L48557-L48606) (`GET /orgs/{org}/teams/{team_slug}/memberships/{username}`, the team check in section 2) returns lists no two-factor authentication property. The same source reading of the pinned description finds that neither the authenticated-user membership endpoint's description nor the team-membership endpoint's description states a 2FA condition. Guide assessment: as described, neither membership response tells an application whether the member has 2FA. The same pinned description documents an organization-level 2FA field; this is an API description, not observed API behaviour, and this guide has not observed its value. Its [get-an-organization endpoint](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L20918-L20942) (`GET /orgs/{org}`) says that when `two_factor_requirement_enabled` is `true`, the organization requires all members, billing managers, outside collaborators, guest collaborators, repository collaborators, or everyone with access to any repository within the organization to enable two-factor authentication. The get-an-organization endpoint also says that the authenticated user must be an organization owner to see the full details about an organization, and that OAuth app tokens and personal access tokens (classic) need the `admin:org` scope to see them. The [`organization-full` schema](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L138768-L138772) types the field as a nullable boolean; the description does not say whether the field is among the full details or what a `null` value means. Guide advice: an organization owner can read this field as one way to verify the organization's requirement that this section's advice depends on; it reports the organization's setting, never proof of any one member's MFA, and it is an owner's check, not part of the sign-in app's `read:user read:org` request. The description also documents an account-level field. Its [get-the-authenticated-user endpoint](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L91436-L91470) (`GET /user`, the identity call in section 2) says that OAuth app tokens and personal access tokens (classic) need the `read:user` scope, or the broader `user` scope, to return the private user response, which includes `two_factor_authentication`, and that tokens without these scopes receive the public user response. The [`private-user` schema](https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L169648-L169719) types `two_factor_authentication` as a boolean and lists it as required. This is an API description, not observed API behaviour; the description gives the field no meaning beyond its name and type, and this guide has not observed its value for any account. Guide assessment: as described, the `read:user` scope that section 3 requests returns the field, but it is at most an account-level flag: it is not proof that a sign-in used a second factor or of any one member's MFA, and whether a given application's check of it rejects an account without 2FA is unestablished. ## Verify REASONED: discovery and anonymous-admin probes follow the Google OIDC and OpenID Connect sources below; the protected-endpoint check needs an application deployment, which was not supplied for this metadata review. The guide records no live outcome; expected anonymous rejection is stated in the block. Those probes, with the expected outcomes in the block's comment, rest on the guide's cited Google OpenID Connect entry and OpenID Connect Core 1.0 and Discovery 1.0 entry, and so does this paragraph's framing of the negative tests after the block, which carry their own declarations. The authoring environment has no network access and no application deployment, so neither probe was sent. At marking time the block's two lines, in an earlier form whose anonymous-admin probe named app.example.com, passed bash -n and ShellCheck 0.11.0, and the jq filter printed issuer and jwks_uri from a fixture discovery document and printed nothing, with exit 0, for empty input, so empty output means a failed fetch, not a pass. The anonymous-admin probe now takes the application's host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains, and both probes now run in one subshell that classifies them and exits with one status. On the classifying form before this change, which lowercased the host with `tr` before refusing a reserved name, bash -n and ShellCheck 0.11.0 passed in the repository's shell-block gate (`tools/check_shell_blocks.py`), and no run of its guard or classification was recorded. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and its anonymous-admin part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs on 2026-11-01 with no network. In bash, that part refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probe. Under dash, that part refused every host name with 2 and the bash message before its probe, and the whole block exited 3, because dash reads the discovery part's `$'\n'` differently and that part reported an inconclusive result. In the bash runs the discovery part printed issuer and jwks_uri from a fixture discovery document that the curl stub returned. Negative tests, listed after the block, matter more than the happy path. ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 # guard-conventions: allow probe of Google's fixed public discovery URL; no reader-substituted placeholder in this probe's argv set -- "$1" "$(if curl -q -s https://accounts.google.com/.well-known/openid-configuration; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the status so far, curl's exit status, then the discovery document if [ "$2" != 0 ]; then echo "curl exit $2: the discovery fetch failed; inconclusive"; set -- 3 else set -- "$1" "$(if printf '%s\n' "$3" | jq -r '.issuer, .jwks_uri'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the status so far, jq's exit status, then the issuer and jwks_uri lines printf '%s' "$3" if [ "$2" != 0 ]; then case "$2" in 126|127) echo "jq exit $2: the discovery check could not run; inconclusive" ;; *) echo "jq exit $2: the discovery document could not be read; inconclusive" ;; esac set -- 3 elif [ -z "$3" ]; then echo "jq printed nothing, and empty output means a failed fetch, not a pass; inconclusive"; set -- 3 fi fi set -- "$1" ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your application is served on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$(if curl -q -g -sS -D - -o /dev/null -w '\n%{http_code}' "https://$1/admin"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%$'\n'*}" # curl's exit status, then the headers and the HTTP code set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # curl's exit status, the headers, then the HTTP code printf '%s' "$2" # unauthenticated GET: 401, or a 302 whose Location is the provider/login (not an unrelated app route), never 200 or app content case "$1:$3" in 0:401) exit 0 ;; 0:302) echo "unclassified observation: read the Location header above; the pass is a Location at the provider or its login page, not an unrelated app route"; exit 0 ;; 0:2??) echo "FINDING: unauthenticated /admin answered $3"; exit 1 ;; 0:*) echo "HTTP $3 from unauthenticated /admin is neither the 401 or 302 the comment above expects nor a 2xx finding; inconclusive"; exit 3 ;; *) echo "curl exit $1: the /admin request failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the issuer and jwks_uri values and a 302's Location are not classified, so judge them yourself" exit "$1" ) ``` - REASONED: allowlist refusal per the guide's cited Google OpenID Connect, Microsoft identity platform ID token claims reference, GitHub REST organization and team members, and Okta groups claim entries, with the expected outcome stated in this item. No application deployment and no provider accounts outside an allowlist are available in the authoring environment, and this item names no command, so no part of it ran locally. Sign in with a valid account outside the allowlist (a personal Gmail, another Entra tenant, a GitHub user outside the org, an Okta user outside the group): the provider authenticates, your app refuses and logs the identity. - REASONED: exact redirect-URI matching per the guide's cited RFC 9700 and the cited Microsoft identity platform OpenID Connect and GitHub OAuth apps authorizing entries; no client registered at any provider is available in the authoring environment, so no authorization request was sent, and this item names no command. A redirect to the edited URI is the exposed state and the provider error the fixed one. Edit `redirect_uri` in the authorization URL (add a path segment or change the host): the provider shows an error and never redirects. - REASONED: callback state checking per the guide's cited RFC 9700 and openid-client entries; no application deployment is available in the authoring environment and no OIDC client library is installed there, so no callback was replayed, and this item names no command. An accepted callback with a changed state is the exposed state and its rejection the fixed one. Change `state` on the callback URL: your app rejects the callback. - REASONED: ID-token validation per the guide's cited OpenID Connect Core 1.0 entry (ID token validation 3.1.3.7); no application validation path is available in the authoring environment, so the reader's code was not exercised. At marking time RS256 fixtures signed with an OpenSSL 3.5.5 key ran through PyJWT 2.10.1 with the algorithm, issuer and audience pinned and 60 seconds of leeway: it accepted the control and rejected a token expired 600 seconds earlier, a wrong-aud token and a broken-signature token. The same marking-time checks showed that, by contrast, the same library with signature verification off accepted all four, and that openssl dgst verified the control's signature and failed the broken one. Two further validly signed fixtures, each with aud listing the client ID and a second audience, ran through the same configuration: it accepted both the one whose azp equals the client ID and the one whose azp names another client, so the shown configuration alone rejects neither a wrong azp nor the untrusted second audience. An explicit comparison of the decoded azp with the client ID accepted the first and rejected the second; whether the reader's validation path makes that comparison was not exercised. Feed your rule-4 ID-token validation path (not the callback, which receives a code and never an ID token) a valid token as a positive control, then fixtures each differing in one property: expired beyond the allowed clock skew, a validly signed token with a wrong `aud`/`azp`, and an otherwise-valid token with a broken signature. It must accept the control and reject every fixture. - REASONED: logout invalidation per the guide's cited Auth.js session strategies and OWASP Cheat Sheet Series server-side session invalidation entries, with the expected outcomes stated in this item; no application deployment is available in the authoring environment, and this item names no command, so no part of it ran locally. Log out, then reload a protected page: it redirects to login. With database sessions the old session cookie no longer works; with JWT sessions it works until `exp`, so use database sessions or a revocation check; a copied pre-logout cookie must stop authorizing requests after logout, per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). ## Sources (checked September 2026) - RFC 9700, OAuth 2.0 Security Best Current Practice: https://www.rfc-editor.org/info/rfc9700/ - RFC 6749, OAuth 2.0 client credential confidentiality (section 10.1) (checked October 2026): https://www.rfc-editor.org/rfc/rfc6749.html#section-10.1 - OpenID Connect Core 1.0 (ID token validation 3.1.3.7, claim stability 5.7): https://openid.net/specs/openid-connect-core-1_0.html ; Discovery 1.0: https://openid.net/specs/openid-connect-discovery-1_0.html ; RP-Initiated Logout 1.0: https://openid.net/specs/openid-connect-rpinitiated-1_0.html - RFC 7636 (PKCE; the `code_verifier` is required at the token endpoint when a `code_challenge` was sent): https://www.rfc-editor.org/rfc/rfc7636.html - Microsoft Entra token issuer validation (the `common`/`organizations` templated issuer and the signing-key issuer scope): https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#validate-the-issuer - Google OpenID Connect (discovery URL, `hd`, `sub` versus `email`, token validation): https://developers.google.com/identity/openid-connect/openid-connect - Google Workspace: deploy 2-Step Verification (checked October 2026): https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification - Microsoft identity platform: register an application: https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app ; OpenID Connect (discovery, `{tenant}` values, redirect URI, sign-out): https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc - Microsoft identity platform: ID token claims reference (`tid`, `iss`, `oid`, `sub`, `email`): https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference ; optional claims reference (`amr`, `mfa` value): https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference - Microsoft Entra Conditional Access: require MFA for all users: https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-mfa-strength - GitHub OAuth apps: authorizing: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps ; creating: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app ; scopes: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps - GitHub REST: organization members: https://docs.github.com/en/rest/orgs/members ; team members: https://docs.github.com/en/rest/teams/members - GitHub organization required 2FA (pinned commit cb051611377a0d2904411e6157154e9b4953d870, checked October 2026): https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization.md#L22-L35 - GitHub Enterprise Server 2FA context (pinned commit cb051611377a0d2904411e6157154e9b4953d870, checked October 2026): https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L15 - GitHub Enterprise Server required 2FA: members and outside collaborators (pinned commit cb051611377a0d2904411e6157154e9b4953d870, checked October 2026): https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L30-L32 - GitHub Enterprise Server 2FA non-compliance removals (pinned commit cb051611377a0d2904411e6157154e9b4953d870, checked October 2026): https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L45 - GitHub REST API description, list organization members, check organization membership and get the authenticated user's organization membership (pinned commit 755ee33b4a81d51f6f07a902cce26cbec3b822f2, checked October 2026). GitHub REST API description sources (checked October 2026, 1 of 2): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L39346-L39376 ; https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L39435-L39473. GitHub REST API description sources (checked October 2026, 2 of 2): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L94998-L95037 - GitHub REST API description, organization and team membership response schemas, get an organization (`two_factor_requirement_enabled`) and get the authenticated user (`two_factor_authentication`) (pinned commit 755ee33b4a81d51f6f07a902cce26cbec3b822f2, checked October 2026). GitHub REST API description sources (checked October 2026, 1 of 5): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L143753-L143830 ; https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L137588-L137660. GitHub REST API description sources (checked October 2026, 2 of 5): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L130511-L130635 ; https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L48557-L48606. GitHub REST API description sources (checked October 2026, 3 of 5): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L151708-L151741 ; https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L20918-L20942. GitHub REST API description sources (checked October 2026, 4 of 5): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L138768-L138772 ; https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L91436-L91470. GitHub REST API description sources (checked October 2026, 5 of 5): https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L169648-L169719 - Okta: OAuth 2.0 and OpenID Connect overview: https://developer.okta.com/docs/concepts/oauth-openid/ ; OIDC API reference (discovery, issuer, `amr`, `groups`): https://developer.okta.com/docs/api/openapi/okta-oauth/guides/overview - Okta: add a groups claim: https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/ ; sign users in to your web application: https://developer.okta.com/docs/guides/sign-into-web-app-redirect/-/main/ ; policies concept: https://developer.okta.com/docs/concepts/policies/ - openid-client: https://github.com/panva/openid-client - Auth.js (installation, providers): https://authjs.dev/ ; session strategies (a JWT cannot be expired early without a blocklist): https://authjs.dev/concepts/session-strategies - Authlib Flask client: https://docs.authlib.org/en/stable/oauth2/client/web/flask.html - go-oidc: https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc - OWASP Cheat Sheet Series 668ba7db3d0da5868b8a0305c259f7f6914a6ecd: secret-manager option (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L313 - OWASP Cheat Sheet Series 668ba7db3d0da5868b8a0305c259f7f6914a6ecd: container secret delivery and environment-variable caveat (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L462-L464 - Docker build secrets at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L15 - OWASP Cheat Sheet Series 668ba7db3d0da5868b8a0305c259f7f6914a6ecd: session cookie attributes (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L163 - OWASP Cheat Sheet Series 668ba7db3d0da5868b8a0305c259f7f6914a6ecd: server-side session invalidation (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L317 - OWASP Cheat Sheet Series 668ba7db3d0da5868b8a0305c259f7f6914a6ecd: browser token storage (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L201 - OWASP Cheat Sheet Series 668ba7db3d0da5868b8a0305c259f7f6914a6ecd: local storage and HttpOnly cookies (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/HTML5_Security_Cheat_Sheet.md#L53 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - core: OpenID Connect Core (1.0) - discovery: OpenID Connect Discovery (1.0) - logout: OpenID Connect RP-Initiated Logout (1.0) - google: Google identity documentation (unknown) - entra: Microsoft Entra documentation (unknown) - github-2fa: GitHub organization two-factor authentication policy (cb051611377a0d2904411e6157154e9b4953d870) - github: GitHub OAuth and REST documentation (unknown) - github-rest-api: GitHub REST API description (755ee33b4a81d51f6f07a902cce26cbec3b822f2) - okta: Okta documentation (unknown) - oauth: OAuth security BCP and PKCE (unknown) - openid-client: openid-client (unknown) - authjs: Auth.js (unknown) - authlib: Authlib (unknown) - go-oidc: go-oidc (unknown) - secret-rfc: RFC 6749 client authentication (unknown) - docker-build: Docker build documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - owasp-docs: OWASP Cheat Sheet Series (668ba7db3d0da5868b8a0305c259f7f6914a6ecd) Claims (full source mapping in the JSON block below): - code-flow (REASONED; oauth, okta): Use server-side authorization code flow with PKCE; RFC 9700 requires PKCE for public clients and recommends it for others, and discourages implicit grant. - redirect-match (REASONED; oauth): Register an exact callback URI including scheme, host, path and trailing slash; avoid prefix and wildcard matching. - transaction-binding (REASONED; oauth, core): Generate state bound to the browser session, nonce and S256 code_challenge for each authorization; request openid email profile. - code-exchange (REASONED; oauth, core): Exchange the code server-side with client secret and the code_verifier that produced the challenge. - token-signature (REASONED; core, discovery): Validate the ID-token signature against provider JWKS using a pinned algorithm; Core defaults to RS256 unless another was registered. - token-issuer (REASONED; core): Require iss to equal the configured issuer before trusting claims. - token-audience (REASONED; core): aud must include the client ID and no untrusted additional audience; confirm azp equals the client ID where present. - token-expiry-nonce (REASONED; core): Require unexpired exp and nonce equal to the value sent. - token-leaks (REASONED; oauth): Keep codes and tokens out of logs, traces and analytics; set callback Referrer-Policy: no-referrer and redirect to a clean URL. - refresh-tokens (REASONED; oauth): No offline access is requested, but provider-specific refresh tokens must remain server-side secrets, go only to the token endpoint and be revoked on offboarding. - account-linking (REASONED; core, google, entra): Link by issuer and sub, not mutable or non-unique email, phone_number or preferred_username. - logout (REASONED; logout, entra, owasp-docs): Destroy the server session and cookie; where advertised, redirect to end_session_endpoint with id_token_hint and registered post_logout_redirect_uri; Entra uses /oauth2/v2.0/logout. - discovery (REASONED; discovery, entra): Fetch issuer/.well-known/openid-configuration for endpoints and jwks_uri; tenant-specific issuer must match, with Entra common/organizations handled separately. - google-allowlist (REASONED; google): Require verified ID-token hd to equal the Workspace domain; absent hd is rejection and the request parameter is not access control. - entra-allowlist (REASONED; entra): Require allowed tid and tenant-specific iss; prefer Single tenant only. organizations accepts any Entra tenant, common also personal accounts; key identity by oid or sub plus tid. - github-identity (REASONED; github): GitHub OAuth has no discovery or ID token; fetch GET https://api.github.com/user and key identity by numeric id, not login. - github-org (REASONED; github): With read:org, GET /user/memberships/orgs/<org> must return 200 and state active; 404 is unaffiliated. - github-team (REASONED; github): With read:org, GET /orgs/<org>/teams/<team_slug>/memberships/<username> must return 200 and state active; pending is an unaccepted invitation and 404 is no membership. - okta-groups (REASONED; okta): Add an ID-token groups claim on the org authorization server and request groups scope; require a named group. More than 100 groups fails, so narrow the filter. - default-deny (REASONED; google, entra, okta): Reject and log identities outside the allowlist before creating a session; do not create pending accounts by default. - google-registration (REASONED; google): Register a Google OAuth client and exact callback; discovery is https://accounts.google.com/.well-known/openid-configuration and iss is https://accounts.google.com or accounts.google.com. - entra-registration (REASONED; entra): Register a Web application, callback and client secret; prefer tenant-specific /v2.0/.well-known/openid-configuration discovery for single-tenant apps. - entra-template (REASONED; entra): For common/organizations, substitute GUID tid into the issuer template, match iss exactly, restrict signing keys by issuer scope and enforce the tenant allowlist. - github-registration (REASONED; github): Register an OAuth app callback; authorize at /login/oauth/authorize with read:user read:org, state and S256 PKCE; exchange at /login/oauth/access_token with secret, code, verifier and redirect_uri. - github-callback-default (REASONED; github): Always send redirect_uri: omission selects the first callback. Apps with one callback before August 3, 2026 retain wildcard matching; disable it for exact matching. - okta-registration (REASONED; okta): Create an OIDC Web Application with sign-in/out URIs and narrowed assignment; use org discovery or /oauth2/<authorizationServerId> discovery, with iss matching the prefix. - openid-client-signature (REASONED; openid-client): Call enableNonRepudiationChecks: openid-client otherwise trusts token-endpoint TLS in code flow; retain verifier, state and nonce in the initiating browser's server session. - authjs-config (REASONED; authjs): Auth.js uses AUTH_<PROVIDER>_ID/SECRET and Okta/Entra ISSUER, with /api/auth/callback/<provider>; add the allowlist in signIn and override Entra common with the tenant issuer. - authjs-revocation (REASONED; authjs): Auth.js JWT-cookie logout leaves a copied token valid until exp unless blocked; use database sessions or a blocklist for server-side revocation. - authlib-config (REASONED; authlib): Authlib registration uses Google discovery, openid profile email and S256; authorize_redirect and authorize_access_token implement the callback flow. - go-oidc-verifier (REASONED; go-oidc): go-oidc verifies signature, issuer, audience and expiry; compare idToken.Nonce separately and leave SkipIssuerCheck off. - entra-mfa (REASONED; entra): Require authentication strength through Entra Conditional Access; check ID-token and optional-claims documentation before relying on amr=mfa. - google-mfa (REASONED; google): Google Workspace super administrators can enforce 2-Step Verification in the Admin console for all users, or for a selected organizational unit or configuration group. Guide advice: set Enforcement On for every user who signs in to the app; no Google ID-token MFA claim was verified for this guide. - okta-mfa (REASONED; okta): Use Okta authentication and global-session policies; its amr includes pwd, mfa, otp and hwk. - verify-discovery (REASONED; google, discovery): The discovery probe reads Google issuer and jwks_uri; this does not demonstrate application authorization. - verify-anonymous (REASONED; core): Unauthenticated /admin must return 401 or a 302 to provider/login, never 200 or protected content; an unrelated redirect is not success. - verify-allowlist (REASONED; google, entra, github, okta): A valid account outside the Google domain, Entra tenant, GitHub organization or Okta group authenticates at the provider but is rejected and logged by the app. - verify-redirect (REASONED; oauth): Changing redirect_uri host or adding a path segment must cause a provider error without redirection. - verify-state (REASONED; oauth): A changed callback state must be rejected. - verify-token (REASONED; core): Accept a valid ID-token control and reject one-property fixtures for expiry beyond skew, wrong aud/azp and broken signature in the token-validation path, not the code callback. - verify-logout (REASONED; authjs, logout, owasp-docs): After logout a protected page returns to login and a copied old cookie stops authorizing; JWT sessions need database replacement or a revocation check. - server-session (REASONED; authjs, owasp-docs): After token validation and allowlist authorization, create a server-side session and give the browser only a session cookie marked Secure, HttpOnly and SameSite. - browser-token-storage (REASONED; oauth, owasp-docs): Do not store ID or access tokens in localStorage or script-readable cookies; this server-side login recipe has no browser need for them. - client-secret-storage (REASONED; oauth, secret-rfc, owasp-docs, docker-build): Keep the web app client secret confidential. Prefer a secret manager (this guide's advice; OWASP lists it as an option). In containers, use an orchestrator-mounted secret or secret-store fetch where possible, and a runtime environment variable only if those are unavailable. Never commit the secret to a repository or bake it into an image (this guide's advice; Docker documents that build ARG and ENV persist in the final image). - github-org-mfa (REASONED; github, github-2fa): GitHub OAuth has no ID token; active membership checks establish only membership. Inference: that alone does not prove MFA, as cited policies retain members without 2FA. Guide assessment: MFA depends on the effective org 2FA requirement and whether enabled. Membership API/app-check results for such members are unestablished; guide advice: verify the requirement and test such an account before relying on membership for MFA. - github-org-mfa-api (REASONED; github-rest-api): GitHub's pinned REST API description says the list-members 2fa_disabled filter returns members without 2FA. It documents 204 from /orgs/<org>/members/<username> when requester and user are both organization members, and /user/memberships/orgs/<org> returning the authenticated user's membership if active or pending; neither states a 2FA condition. This is an API description, not observed behaviour. - github-org-mfa-api-assessment (REASONED; github-rest-api): Guide assessment: GitHub's pinned REST API description treats accounts without 2FA as organization members, so an app treating org membership as MFA proof would accept such a member; whether a given app's check fails for them is unestablished. - github-membership-2fa-schema (REASONED; github-rest-api): Source reading of GitHub's pinned REST API description, not observed behaviour: the org-membership schema from /user/memberships/orgs/<org>, its referenced organization-simple and nullable-simple-user schemas, and the team-membership schema list no 2FA property. Guide assessment: as described, neither membership response tells an app whether the member has 2FA. - github-org-2fa-requirement (REASONED; github-rest-api): Pinned GitHub REST API description, not observed behaviour: GET /orgs/<org> two_factor_requirement_enabled true means members, billing managers, collaborators and others with repository access must enable 2FA; full details need an org owner and, for OAuth app tokens and classic PATs, admin:org. Guide advice: owners may check it as the org's requirement, never as proof of one member's MFA. - github-user-2fa-field (REASONED; github-rest-api): Pinned GitHub REST API description, not observed behaviour: OAuth app tokens and classic PATs need read:user or user for GET /user to return the private user response, which includes two_factor_authentication (a required boolean there); tokens without them get the public response. Guide assessment: at most an account-level flag, not proof that a sign-in used a second factor or of one member's MFA. - authjs-session-storage (REASONED; authjs): Auth.js stores an encrypted JWT or database session ID in an HttpOnly cookie; use database sessions or a server blocklist when copied-cookie revocation is required. - mfa-session-check (REASONED; entra, okta): An app may additionally refuse an ID-token session showing no second factor only where the provider documents that claim; do not assume a portable MFA claim across providers. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "6333a88ccbfb517faafea9d5e52afb55889b80c72c36be40705e0e4a3cf17427", "components": { "core": { "name": "OpenID Connect Core", "basis": "1.0", "sources": { "scfd2790a544c": "https://openid.net/specs/openid-connect-core-1_0.html" } }, "discovery": { "name": "OpenID Connect Discovery", "basis": "1.0", "sources": { "s85a922f6cf28": "https://openid.net/specs/openid-connect-discovery-1_0.html" } }, "logout": { "name": "OpenID Connect RP-Initiated Logout", "basis": "1.0", "sources": { "s9cbf9365b369": "https://openid.net/specs/openid-connect-rpinitiated-1_0.html" } }, "google": { "name": "Google identity documentation", "basis": "unknown", "sources": { "s4f815acc2976": "https://developers.google.com/identity/openid-connect/openid-connect", "s4e673839253c": "https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification" } }, "entra": { "name": "Microsoft Entra documentation", "basis": "unknown", "sources": { "s024236a981bf": "https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens#validate-the-issuer", "sc592f9f58893": "https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference", "s46eca003125d": "https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference", "s6bed41a98a09": "https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app", "s21141e7818fc": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc", "s78a2bccc8dab": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-mfa-strength" } }, "github-2fa": { "name": "GitHub organization two-factor authentication policy", "basis": "cb051611377a0d2904411e6157154e9b4953d870", "sources": { "sa57ab4061d3c": "https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/organizations/keeping-your-organization-secure/managing-two-factor-authentication-for-your-organization/requiring-two-factor-authentication-in-your-organization.md#L22-L35", "sa28abd053c4f": "https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L15", "sa50e82c6b843": "https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L30-L32", "sce31f83d713d": "https://github.com/github/docs/blob/cb051611377a0d2904411e6157154e9b4953d870/content/admin/managing-accounts-and-repositories/managing-organizations-in-your-enterprise/requiring-two-factor-authentication-for-an-organization.md#L45" } }, "github": { "name": "GitHub OAuth and REST documentation", "basis": "unknown", "sources": { "sce29d52054f5": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps", "s996a4496ed25": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app", "sf0d89c2d1756": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps", "s06cab1763f65": "https://docs.github.com/en/rest/orgs/members", "s36328325db87": "https://docs.github.com/en/rest/teams/members" } }, "github-rest-api": { "name": "GitHub REST API description", "basis": "755ee33b4a81d51f6f07a902cce26cbec3b822f2", "sources": { "s4d2626e102e2": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L39346-L39376", "s54f7eab44fed": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L39435-L39473", "s77daa176291b": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L94998-L95037", "sc8ddd0c01756": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L143753-L143830", "sce0128e0b7ed": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L137588-L137660", "s92d8374289a2": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L130511-L130635", "se2e4db45b2e8": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L48557-L48606", "s2c4912871237": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L151708-L151741", "s2befb8e2e315": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L20918-L20942", "sbf2082f71186": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L138768-L138772", "s277726bce66c": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L91436-L91470", "s362649836114": "https://github.com/github/rest-api-description/blob/755ee33b4a81d51f6f07a902cce26cbec3b822f2/descriptions/api.github.com/api.github.com.json#L169648-L169719" } }, "okta": { "name": "Okta documentation", "basis": "unknown", "sources": { "s380f2ddc93a1": "https://developer.okta.com/docs/api/openapi/okta-oauth/guides/overview", "s06b93a8255e7": "https://developer.okta.com/docs/concepts/oauth-openid/", "s2935e4fa2dea": "https://developer.okta.com/docs/concepts/policies/", "s0abd0d40c095": "https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/", "s012a67c2b0ea": "https://developer.okta.com/docs/guides/sign-into-web-app-redirect/-/main/" } }, "oauth": { "name": "OAuth security BCP and PKCE", "basis": "unknown", "sources": { "sde329701b60c": "https://www.rfc-editor.org/info/rfc9700/", "scb6a61add96b": "https://www.rfc-editor.org/rfc/rfc7636.html" } }, "openid-client": { "name": "openid-client", "basis": "unknown", "sources": { "sa67c9c6498a8": "https://github.com/panva/openid-client" } }, "authjs": { "name": "Auth.js", "basis": "unknown", "sources": { "scb72ef455167": "https://authjs.dev/", "s57969fbce700": "https://authjs.dev/concepts/session-strategies" } }, "authlib": { "name": "Authlib", "basis": "unknown", "sources": { "s8e3650e799b8": "https://docs.authlib.org/en/stable/oauth2/client/web/flask.html" } }, "go-oidc": { "name": "go-oidc", "basis": "unknown", "sources": { "s60cc1d7a8fe6": "https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc" } }, "secret-rfc": { "name": "RFC 6749 client authentication", "basis": "unknown", "sources": { "sc01487b5e4ad": "https://www.rfc-editor.org/rfc/rfc6749.html#section-10.1" } }, "docker-build": { "name": "Docker build documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "s09e6709d8eb4": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L15" } }, "owasp-docs": { "name": "OWASP Cheat Sheet Series", "basis": "668ba7db3d0da5868b8a0305c259f7f6914a6ecd", "sources": { "s0562e4dc23f2": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L163", "sa5f6e96a876c": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L317", "s41cb2204a4e2": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Session_Management_Cheat_Sheet.md#L201", "s6ea90d0c28e9": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L313", "se0eb20386574": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L462-L464", "s4f0aa80b1ef6": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/HTML5_Security_Cheat_Sheet.md#L53" } } }, "claims": { "code-flow": {"text": "Use server-side authorization code flow with PKCE; RFC 9700 requires PKCE for public clients and recommends it for others, and discourages implicit grant.", "components": ["oauth", "okta"], "sources": ["oauth:sde329701b60c", "oauth:scb6a61add96b", "okta:s06b93a8255e7"], "status": "REASONED"}, "redirect-match": {"text": "Register an exact callback URI including scheme, host, path and trailing slash; avoid prefix and wildcard matching.", "components": ["oauth"], "sources": ["oauth:sde329701b60c"], "status": "REASONED"}, "transaction-binding": {"text": "Generate state bound to the browser session, nonce and S256 code_challenge for each authorization; request openid email profile.", "components": ["oauth", "core"], "sources": ["oauth:scb6a61add96b", "core:scfd2790a544c"], "status": "REASONED"}, "code-exchange": {"text": "Exchange the code server-side with client secret and the code_verifier that produced the challenge.", "components": ["oauth", "core"], "sources": ["oauth:scb6a61add96b", "core:scfd2790a544c"], "status": "REASONED"}, "token-signature": {"text": "Validate the ID-token signature against provider JWKS using a pinned algorithm; Core defaults to RS256 unless another was registered.", "components": ["core", "discovery"], "sources": ["core:scfd2790a544c", "discovery:s85a922f6cf28"], "status": "REASONED"}, "token-issuer": {"text": "Require iss to equal the configured issuer before trusting claims.", "components": ["core"], "sources": ["core:scfd2790a544c"], "status": "REASONED"}, "token-audience": {"text": "aud must include the client ID and no untrusted additional audience; confirm azp equals the client ID where present.", "components": ["core"], "sources": ["core:scfd2790a544c"], "status": "REASONED"}, "token-expiry-nonce": {"text": "Require unexpired exp and nonce equal to the value sent.", "components": ["core"], "sources": ["core:scfd2790a544c"], "status": "REASONED"}, "token-leaks": {"text": "Keep codes and tokens out of logs, traces and analytics; set callback Referrer-Policy: no-referrer and redirect to a clean URL.", "components": ["oauth"], "sources": ["oauth:sde329701b60c"], "status": "REASONED"}, "refresh-tokens": {"text": "No offline access is requested, but provider-specific refresh tokens must remain server-side secrets, go only to the token endpoint and be revoked on offboarding.", "components": ["oauth"], "sources": ["oauth:sde329701b60c"], "status": "REASONED"}, "account-linking": {"text": "Link by issuer and sub, not mutable or non-unique email, phone_number or preferred_username.", "components": ["core", "google", "entra"], "sources": ["core:scfd2790a544c", "google:s4f815acc2976", "entra:sc592f9f58893"], "status": "REASONED"}, "logout": {"text": "Destroy the server session and cookie; where advertised, redirect to end_session_endpoint with id_token_hint and registered post_logout_redirect_uri; Entra uses /oauth2/v2.0/logout.", "components": ["logout", "entra", "owasp-docs"], "sources": ["logout:s9cbf9365b369", "entra:s21141e7818fc", "owasp-docs:sa5f6e96a876c"], "status": "REASONED"}, "discovery": {"text": "Fetch issuer/.well-known/openid-configuration for endpoints and jwks_uri; tenant-specific issuer must match, with Entra common/organizations handled separately.", "components": ["discovery", "entra"], "sources": ["discovery:s85a922f6cf28", "entra:s024236a981bf"], "status": "REASONED"}, "google-allowlist": {"text": "Require verified ID-token hd to equal the Workspace domain; absent hd is rejection and the request parameter is not access control.", "components": ["google"], "sources": ["google:s4f815acc2976"], "status": "REASONED"}, "entra-allowlist": {"text": "Require allowed tid and tenant-specific iss; prefer Single tenant only. organizations accepts any Entra tenant, common also personal accounts; key identity by oid or sub plus tid.", "components": ["entra"], "sources": ["entra:sc592f9f58893", "entra:s6bed41a98a09", "entra:s21141e7818fc"], "status": "REASONED"}, "github-identity": {"text": "GitHub OAuth has no discovery or ID token; fetch GET https://api.github.com/user and key identity by numeric id, not login.", "components": ["github"], "sources": ["github:sce29d52054f5"], "status": "REASONED"}, "github-org": {"text": "With read:org, GET /user/memberships/orgs/ must return 200 and state active; 404 is unaffiliated.", "components": ["github"], "sources": ["github:sf0d89c2d1756", "github:s06cab1763f65"], "status": "REASONED"}, "github-team": {"text": "With read:org, GET /orgs//teams//memberships/ must return 200 and state active; pending is an unaccepted invitation and 404 is no membership.", "components": ["github"], "sources": ["github:sf0d89c2d1756", "github:s36328325db87"], "status": "REASONED"}, "okta-groups": {"text": "Add an ID-token groups claim on the org authorization server and request groups scope; require a named group. More than 100 groups fails, so narrow the filter.", "components": ["okta"], "sources": ["okta:s0abd0d40c095"], "status": "REASONED"}, "default-deny": {"text": "Reject and log identities outside the allowlist before creating a session; do not create pending accounts by default.", "components": ["google", "entra", "okta"], "sources": ["google:s4f815acc2976", "entra:sc592f9f58893", "okta:s0abd0d40c095"], "status": "REASONED"}, "google-registration": {"text": "Register a Google OAuth client and exact callback; discovery is https://accounts.google.com/.well-known/openid-configuration and iss is https://accounts.google.com or accounts.google.com.", "components": ["google"], "sources": ["google:s4f815acc2976"], "status": "REASONED"}, "entra-registration": {"text": "Register a Web application, callback and client secret; prefer tenant-specific /v2.0/.well-known/openid-configuration discovery for single-tenant apps.", "components": ["entra"], "sources": ["entra:s6bed41a98a09", "entra:s21141e7818fc"], "status": "REASONED"}, "entra-template": {"text": "For common/organizations, substitute GUID tid into the issuer template, match iss exactly, restrict signing keys by issuer scope and enforce the tenant allowlist.", "components": ["entra"], "sources": ["entra:s024236a981bf"], "status": "REASONED"}, "github-registration": {"text": "Register an OAuth app callback; authorize at /login/oauth/authorize with read:user read:org, state and S256 PKCE; exchange at /login/oauth/access_token with secret, code, verifier and redirect_uri.", "components": ["github"], "sources": ["github:sce29d52054f5", "github:s996a4496ed25", "github:sf0d89c2d1756"], "status": "REASONED"}, "github-callback-default": {"text": "Always send redirect_uri: omission selects the first callback. Apps with one callback before August 3, 2026 retain wildcard matching; disable it for exact matching.", "components": ["github"], "sources": ["github:sce29d52054f5"], "status": "REASONED"}, "okta-registration": {"text": "Create an OIDC Web Application with sign-in/out URIs and narrowed assignment; use org discovery or /oauth2/ discovery, with iss matching the prefix.", "components": ["okta"], "sources": ["okta:s380f2ddc93a1", "okta:s012a67c2b0ea"], "status": "REASONED"}, "openid-client-signature": {"text": "Call enableNonRepudiationChecks: openid-client otherwise trusts token-endpoint TLS in code flow; retain verifier, state and nonce in the initiating browser's server session.", "components": ["openid-client"], "sources": ["openid-client:sa67c9c6498a8"], "status": "REASONED"}, "authjs-config": {"text": "Auth.js uses AUTH__ID/SECRET and Okta/Entra ISSUER, with /api/auth/callback/; add the allowlist in signIn and override Entra common with the tenant issuer.", "components": ["authjs"], "sources": ["authjs:scb72ef455167"], "status": "REASONED"}, "authjs-revocation": {"text": "Auth.js JWT-cookie logout leaves a copied token valid until exp unless blocked; use database sessions or a blocklist for server-side revocation.", "components": ["authjs"], "sources": ["authjs:s57969fbce700"], "status": "REASONED"}, "authlib-config": {"text": "Authlib registration uses Google discovery, openid profile email and S256; authorize_redirect and authorize_access_token implement the callback flow.", "components": ["authlib"], "sources": ["authlib:s8e3650e799b8"], "status": "REASONED"}, "go-oidc-verifier": {"text": "go-oidc verifies signature, issuer, audience and expiry; compare idToken.Nonce separately and leave SkipIssuerCheck off.", "components": ["go-oidc"], "sources": ["go-oidc:s60cc1d7a8fe6"], "status": "REASONED"}, "entra-mfa": {"text": "Require authentication strength through Entra Conditional Access; check ID-token and optional-claims documentation before relying on amr=mfa.", "components": ["entra"], "sources": ["entra:s78a2bccc8dab", "entra:sc592f9f58893", "entra:s46eca003125d"], "status": "REASONED"}, "google-mfa": {"text": "Google Workspace super administrators can enforce 2-Step Verification in the Admin console for all users, or for a selected organizational unit or configuration group. Guide advice: set Enforcement On for every user who signs in to the app; no Google ID-token MFA claim was verified for this guide.", "components": ["google"], "sources": ["google:s4e673839253c"], "status": "REASONED"}, "okta-mfa": {"text": "Use Okta authentication and global-session policies; its amr includes pwd, mfa, otp and hwk.", "components": ["okta"], "sources": ["okta:s2935e4fa2dea", "okta:s380f2ddc93a1"], "status": "REASONED"}, "verify-discovery": {"text": "The discovery probe reads Google issuer and jwks_uri; this does not demonstrate application authorization.", "components": ["google", "discovery"], "sources": ["google:s4f815acc2976", "discovery:s85a922f6cf28"], "status": "REASONED", "verify": [1]}, "verify-anonymous": {"text": "Unauthenticated /admin must return 401 or a 302 to provider/login, never 200 or protected content; an unrelated redirect is not success.", "components": ["core"], "sources": ["core:scfd2790a544c"], "status": "REASONED", "verify": [1]}, "verify-allowlist": {"text": "A valid account outside the Google domain, Entra tenant, GitHub organization or Okta group authenticates at the provider but is rejected and logged by the app.", "components": ["google", "entra", "github", "okta"], "sources": ["google:s4f815acc2976", "entra:sc592f9f58893", "github:s06cab1763f65", "okta:s0abd0d40c095"], "status": "REASONED"}, "verify-redirect": {"text": "Changing redirect_uri host or adding a path segment must cause a provider error without redirection.", "components": ["oauth"], "sources": ["oauth:sde329701b60c"], "status": "REASONED"}, "verify-state": {"text": "A changed callback state must be rejected.", "components": ["oauth"], "sources": ["oauth:sde329701b60c"], "status": "REASONED"}, "verify-token": {"text": "Accept a valid ID-token control and reject one-property fixtures for expiry beyond skew, wrong aud/azp and broken signature in the token-validation path, not the code callback.", "components": ["core"], "sources": ["core:scfd2790a544c"], "status": "REASONED"}, "verify-logout": {"text": "After logout a protected page returns to login and a copied old cookie stops authorizing; JWT sessions need database replacement or a revocation check.", "components": ["authjs", "logout", "owasp-docs"], "sources": ["authjs:s57969fbce700", "logout:s9cbf9365b369", "owasp-docs:sa5f6e96a876c"], "status": "REASONED"}, "server-session": {"text": "After token validation and allowlist authorization, create a server-side session and give the browser only a session cookie marked Secure, HttpOnly and SameSite.", "components": ["authjs", "owasp-docs"], "sources": ["authjs:s57969fbce700", "owasp-docs:s0562e4dc23f2"], "status": "REASONED"}, "browser-token-storage": {"text": "Do not store ID or access tokens in localStorage or script-readable cookies; this server-side login recipe has no browser need for them.", "components": ["oauth", "owasp-docs"], "sources": ["oauth:sde329701b60c", "owasp-docs:s41cb2204a4e2", "owasp-docs:s4f0aa80b1ef6"], "status": "REASONED"}, "client-secret-storage": {"text": "Keep the web app client secret confidential. Prefer a secret manager (this guide's advice; OWASP lists it as an option). In containers, use an orchestrator-mounted secret or secret-store fetch where possible, and a runtime environment variable only if those are unavailable. Never commit the secret to a repository or bake it into an image (this guide's advice; Docker documents that build ARG and ENV persist in the final image).", "components": ["oauth", "secret-rfc", "owasp-docs", "docker-build"], "sources": ["oauth:sde329701b60c", "secret-rfc:sc01487b5e4ad", "owasp-docs:s6ea90d0c28e9", "owasp-docs:se0eb20386574", "docker-build:s09e6709d8eb4"], "status": "REASONED"}, "github-org-mfa": {"text": "GitHub OAuth has no ID token; active membership checks establish only membership. Inference: that alone does not prove MFA, as cited policies retain members without 2FA. Guide assessment: MFA depends on the effective org 2FA requirement and whether enabled. Membership API/app-check results for such members are unestablished; guide advice: verify the requirement and test such an account before relying on membership for MFA.", "components": ["github", "github-2fa"], "sources": ["github:sce29d52054f5", "github:s06cab1763f65", "github-2fa:sa57ab4061d3c", "github-2fa:sa28abd053c4f", "github-2fa:sa50e82c6b843", "github-2fa:sce31f83d713d"], "status": "REASONED"}, "github-org-mfa-api": {"text": "GitHub's pinned REST API description says the list-members 2fa_disabled filter returns members without 2FA. It documents 204 from /orgs//members/ when requester and user are both organization members, and /user/memberships/orgs/ returning the authenticated user's membership if active or pending; neither states a 2FA condition. This is an API description, not observed behaviour.", "components": ["github-rest-api"], "sources": ["github-rest-api:s4d2626e102e2", "github-rest-api:s54f7eab44fed", "github-rest-api:s77daa176291b"], "status": "REASONED"}, "github-org-mfa-api-assessment": {"text": "Guide assessment: GitHub's pinned REST API description treats accounts without 2FA as organization members, so an app treating org membership as MFA proof would accept such a member; whether a given app's check fails for them is unestablished.", "components": ["github-rest-api"], "sources": ["github-rest-api:s4d2626e102e2", "github-rest-api:s54f7eab44fed", "github-rest-api:s77daa176291b"], "status": "REASONED"}, "github-membership-2fa-schema": {"text": "Source reading of GitHub's pinned REST API description, not observed behaviour: the org-membership schema from /user/memberships/orgs/, its referenced organization-simple and nullable-simple-user schemas, and the team-membership schema list no 2FA property. Guide assessment: as described, neither membership response tells an app whether the member has 2FA.", "components": ["github-rest-api"], "sources": ["github-rest-api:s77daa176291b", "github-rest-api:sc8ddd0c01756", "github-rest-api:sce0128e0b7ed", "github-rest-api:s92d8374289a2", "github-rest-api:se2e4db45b2e8", "github-rest-api:s2c4912871237"], "status": "REASONED"}, "github-org-2fa-requirement": {"text": "Pinned GitHub REST API description, not observed behaviour: GET /orgs/ two_factor_requirement_enabled true means members, billing managers, collaborators and others with repository access must enable 2FA; full details need an org owner and, for OAuth app tokens and classic PATs, admin:org. Guide advice: owners may check it as the org's requirement, never as proof of one member's MFA.", "components": ["github-rest-api"], "sources": ["github-rest-api:s2befb8e2e315", "github-rest-api:sbf2082f71186"], "status": "REASONED"}, "github-user-2fa-field": {"text": "Pinned GitHub REST API description, not observed behaviour: OAuth app tokens and classic PATs need read:user or user for GET /user to return the private user response, which includes two_factor_authentication (a required boolean there); tokens without them get the public response. Guide assessment: at most an account-level flag, not proof that a sign-in used a second factor or of one member's MFA.", "components": ["github-rest-api"], "sources": ["github-rest-api:s277726bce66c", "github-rest-api:s362649836114"], "status": "REASONED"}, "authjs-session-storage": {"text": "Auth.js stores an encrypted JWT or database session ID in an HttpOnly cookie; use database sessions or a server blocklist when copied-cookie revocation is required.", "components": ["authjs"], "sources": ["authjs:s57969fbce700"], "status": "REASONED"}, "mfa-session-check": {"text": "An app may additionally refuse an ID-token session showing no second factor only where the provider documents that claim; do not assume a portable MFA claim across providers.", "components": ["entra", "okta"], "sources": ["entra:sc592f9f58893", "entra:s46eca003125d", "okta:s380f2ddc93a1"], "status": "REASONED"} } } ```` ====================================================================== ==> cloud-identity-proxies.md ====================================================================== # Identity-aware proxies: login in front of the app with no code change An identity-aware proxy is a provider-run login gate in front of your app. This guide helps your AI assistant configure the AWS, Google Cloud, Azure, Cloudflare Access, ngrok and Vercel versions securely, including no route around the proxy, signed identity assertions verified where provided, and authorization still checked. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/cloud-identity-proxies.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against AWS ALB (no recorded version), Vercel production-protection announcement 2026-09-09, and curl minimum write-out version 7.75.0; 5 further sources carry no recorded version. Metadata reviewed 2026-10-05; documentation checked September 2026. The guide records 51 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An identity-aware proxy puts a login page in front of an application without changing the application: the cloud's load balancer, platform edge, or tunnel authenticates the user against an identity provider and forwards the request with the user's identity in headers. The [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) guide documents the pattern for Cloudflare Access; this guide covers the equivalents in AWS, Google Cloud, Azure, ngrok, and Vercel. The pattern fails in two ways: the origin stays reachable around the proxy, or the app trusts an identity header anyone can type. Both are addressed below. ## 1. Rules common to every proxy 1. **The origin accepts traffic only from the proxy.** Bind to loopback behind a tunnel, reference the load balancer's security group, or restrict ingress to the load balancer. If the app also answers directly, the login page is decorative ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md)). 2. **The app never trusts a plain identity header.** Where the proxy provides a signed assertion (AWS, Google Cloud, Cloudflare), verify its signature, issuer, audience, and expiry before using any claim. Where the platform documents that clients cannot set the identity headers (Azure), that guarantee holds only for requests that arrived through the platform. 3. **MFA comes from the identity provider behind the proxy, with one exception.** For ALB, IAP, Azure, ngrok, and Vercel, enforce the second factor at the IdP and the proxy inherits it. Cloudflare Access is the exception: it can enforce independent MFA (a TOTP authenticator app, or a WebAuthn security key or biometric) directly in Access without the IdP doing so ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). 4. Authorization is still yours: the proxy proves who the user is, and the app or the proxy policy decides what they may do ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). 5. **Fail closed.** The auth layer must deny when it cannot reach a decision: missing auth configuration, an unreachable authorization service, and an unmatched route must never fall through to the app unauthenticated. Test this with a fresh, unauthenticated request against a protected route while the authorization service is unreachable, and require denial, not pass-through; an already-authenticated session is a separate question, since a proxy is not guaranteed to recheck the identity provider on every request. ## 2. AWS Application Load Balancer A listener rule with an `authenticate-oidc` (any OIDC provider) or `authenticate-cognito` (Cognito user pool, including social and SAML federation) action, followed by a `forward` action. Both action types work only on HTTPS listeners. The `OnUnauthenticatedRequest` field takes `authenticate` (the default: redirect to the IdP), `allow` (forward without claims, for pages with a public view), or `deny` (HTTP 401). `SessionTimeout` defaults to 7 days and can be set as short as 1 second. The IdP must allow `https:///oauth2/idpresponse` as a redirect URL. ```json { "Type": "authenticate-oidc", "AuthenticateOidcConfig": { "Issuer": "https://idp.example.com", "AuthorizationEndpoint": "...", "TokenEndpoint": "...", "UserInfoEndpoint": "...", "ClientId": "...", "ClientSecret": "REPLACE_WITH_LONG_RANDOM_VALUE", "SessionTimeout": 3600, "OnUnauthenticatedRequest": "deny" }, "Order": 1 } ``` The load balancer forwards the user claims to targets in `x-amzn-oidc-data`, a JWT signed with ES256. The app must verify that signature (public key from `https://public-keys.auth.elb..amazonaws.com/`, key ID from the JWT header), confirm that the `signer` field in the JWT header is your load balancer's ARN, and confirm the token's `exp` has not passed, before using any claim. AWS publishes the `aws-jwt-verify` library, which performs these checks for you. ALB's audience binding is that signer ARN together with the `client` field, both in the JWT header, not a payload `aud` claim. `x-amzn-oidc-accesstoken` and `x-amzn-oidc-identity` are unsigned legacy headers and cannot be verified, so never use them for identity. Restrict the targets' security group to accept traffic only from the load balancer's security group, and use an HTTPS target group if the claims must be encrypted on the last hop. ## 3. Google Cloud Identity-Aware Proxy IAP protects App Engine, Cloud Run, Compute Engine, GKE, and on-premises applications served through a supported backend; it does not support backend buckets, so Cloud Storage served directly through a backend bucket is not IAP-gated (Cloud Storage served through an IAP-protected Cloud Run backend is). With Google Accounts or an external IdP through Workforce Identity Federation, a user reaches the app only if they hold the **IAP-secured Web App User** IAM role on the resource. With customer identities through Identity Platform, IAM does not govern access at all: the app must authorize on the claims in the verified IAP assertion, because the IAM role is not enforced in that mode. IAP adds `x-goog-iap-jwt-assertion`, an ES256 JWT. Verify the signature against the keys at `https://www.gstatic.com/iap/verify/public_key-jwk`, check `iss` is `https://cloud.google.com/iap`, and check `aud` matches your resource. The resource's `aud` value is `/projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID` for a backend service, `/projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_NAME` for Cloud Run, `/projects/PROJECT_NUMBER/apps/PROJECT_ID` for App Engine. The unsigned `x-goog-authenticated-user-email` and `x-goog-authenticated-user-id` headers can be forged by anyone who bypasses IAP; Google's docs describe the JWT as the secure alternative. Bypass is the main risk. Google's docs say to verify whether backend resources can be reached directly when IAP is enabled on a load balancer, and that IAP on a load balancer secures only traffic through that load balancer, not traffic that reaches a Cloud Run service through its `run.app` URL. Either enable IAP directly on the Cloud Run service (the documented recommendation, no load balancer needed), or disable the default URL or restrict ingress so only the load balancer reaches it. For Compute Engine and GKE, firewall rules must block traffic that does not come through the load balancer. ## 4. Azure App Service and Azure Functions Built-in authentication ("Easy Auth") is a platform module in front of the app; per the docs no SDK, language, or application code change is required. Providers: Microsoft Entra, Facebook, Google, X, GitHub, Apple (preview at the time of writing), and any OpenID Connect provider. Under **Settings > Authentication**, choose **Require authentication** to reject unauthenticated traffic (as an HTTP 302 redirect to the provider, recommended for websites; or 401, recommended for APIs; or 403 or 404), or **Allow unauthenticated requests** to let the app decide. Enabling the feature redirects all requests to HTTPS regardless of the app's enforce-HTTPS setting (`requireHttps` in the V2 configuration can turn this off; do not). Requiring authentication applies to every path; exceptions need a configuration file with excluded paths. The app receives the identity in `X-MS-CLIENT-PRINCIPAL` (Base64 JSON of the claims), `X-MS-CLIENT-PRINCIPAL-ID`, `X-MS-CLIENT-PRINCIPAL-NAME`, and `X-MS-CLIENT-PRINCIPAL-IDP`, with `/.auth/me` available when the token store is on. The docs state that external requests are not allowed to set these headers, so they are present only if App Service set them. With the Entra provider, any user in the tenant can obtain a token by default; restrict the app registration to assigned users if that is not intended. **Azure Container Apps** uses the same authentication system, run as a sidecar container on each replica, with Microsoft Entra ID, Facebook, GitHub, Google, X, and custom OpenID Connect providers, and the same **Require authentication** / **Allow unauthenticated access** choice and `X-MS-CLIENT-PRINCIPAL-*` headers that external requests cannot set. The docs require HTTPS only: `allowInsecure` must be disabled on the ingress. **Azure Static Web Apps** authenticates with GitHub and Microsoft Entra ID out of the box on all plans (a registered custom provider replaces the preconfigured ones; X is no longer preconfigured). Sign-in is at `/.auth/login/github` or `/.auth/login/aad`, signed-in users hold the `anonymous` and `authenticated` roles, and route rules in `staticwebapp.config.json` restrict paths by role. The preconfigured Entra provider accepts any Microsoft account; to limit sign-in to one tenant, configure a custom Entra provider. ## 5. Cloudflare Access Set up the tunnel and Access policy per [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Then have the app validate the `Cf-Access-Jwt-Assertion` header (preferred over the `CF_Authorization` cookie, which is not guaranteed to be passed) against the public keys at `https://.cloudflareaccess.com/cdn-cgi/access/certs`, checking that `aud` equals the application's AUD tag and `iss` is your team domain. Access rotates the signing key every 6 weeks with the previous key valid for 7 days, so fetch keys from the endpoint rather than hard-coding them. ## 6. ngrok ngrok's traffic policy `oauth` action (providers include `google`, `github`, and `microsoft`) and `openid-connect` action (`issuer_url`, `client_id`, `client_secret`, `scopes`) redirect unauthenticated visitors to the provider, so a local tool becomes public only behind a login. Restrict who gets through with an expression on the identity the action exposes: ```yaml on_http_request: - actions: - type: oauth config: provider: google - expressions: - "!actions.ngrok.oauth.identity.email.endsWith('@example.com')" actions: - type: deny config: status_code: 403 ``` Use `!(actions.ngrok.oauth.identity.email in ['alice@example.com'])` for an explicit list; the negation matters, because the expression must be true for the `deny` to fire, so it has to match everyone you are turning away, not the one you are letting in. Leaving `client_id` and `client_secret` empty uses ngrok's managed OAuth application for the supported providers. Run with `ngrok http 3000 --traffic-policy-file policy.yml`; the older `--oauth`, `--oauth-allow-domain`, and `--oauth-allow-email` agent flags are marked deprecated in favour of traffic policy. Bind the app to `127.0.0.1` so the tunnel is its only path (rule 1); if the agent host is a cloud VM with the app on `0.0.0.0`, the login is decorative. ## 7. Vercel Deployment Protection Vercel's protection guards a deployment from the public; it is not your application's user login. **Vercel Authentication** (all plans) admits logged-in team or project members with at least a viewer role, users granted access on request, holders of a shareable link, and automation with the bypass header. **Standard Protection** covers preview deployments and generated deployment URLs but not production domains; the **All Deployments** scope closes that gap. Vercel's September 9, 2026 change made pairing the All Deployments scope with Vercel Authentication free on every plan, including Hobby, rather than Pro and Enterprise only (per Vercel's Deployment Protection changelog; confirm current availability in your own dashboard). Until All Deployments protection is configured, the production domain stays public on every plan. A **Deployment Protection Exception** on a domain disables protection there for existing and future deployments, so review and remove any unintended exception rather than assuming All Deployments covers every URL. **Password Protection** is included on Enterprise and, at the time of writing, available on Pro at USD 20 per month per protected project (the older USD 150 per month Advanced Deployment Protection package is legacy, kept only for existing Pro teams), and is not offered on Hobby; Trusted IPs and Passport (your own IdP) are Enterprise only. ## Verify REASONED: following block; the cited ALB, IAP and Azure authentication/header documentation defines origin isolation, anonymous denial and forged-header rejection. No cloud origin, IdP or external probe host can be provisioned here, so no live proxy result is recorded; the loopback and closed-port runs for an earlier change are recorded later in this paragraph. Expected responses and inconclusive failures are stated below. Origin isolation, anonymous and forged-header denial, plus signed-assertion discrimination, also follow the guide's cited AWS, Authenticate users using an Application Load Balancer, Google Cloud IAP, securing your app with signed headers, Azure App Service authentication and authorization and Cloudflare Access, validate JWTs. No valid assertion is available either. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics on this block in an earlier form, before its anonymous and forged-header probes took the app's host on a guarded `set --` line. JSON and YAML parsed, and rejected guards reached no curl stub. For an earlier change, curl 8.18.0 ran the probe shapes against a loopback HTTP listener standing in for the example host (302 on the anonymous HEAD, 401 on `/whoami`) and against a closed loopback port. The `-sI` and `-sSI` shapes, in that curl 8.18.0 run, both printed the full status line and headers on the listener and both exited 7 on the closed port, where `-sI` printed nothing and `-sSI` printed `curl: (7) Failed to connect ...` on stderr. The forged-header probe, in that curl 8.18.0 run, printed only the body without `-D -` and the status line, headers and body with it. The altered-assertion shape, in that curl 8.18.0 run, delivered `x-amzn-oidc-data` through `--header @-` from stdin (the listener received the header; the value was a dummy, not a real assertion) and exited 7 with the connect error against the closed port. Unsubstituted, empty and two-value guard pastes again printed their refusals without reaching a curl stub, in plain bash and under set -u, and a substituted address reached the stub once. These loopback runs record curl's output shape, not vendor proxy behaviour. Those curl 8.18.0 runs used the earlier probe form, which named the example host directly; the guarded form, which refuses example.com, example.net, example.org and their subdomains, has not been run against a listener. The bash -n, ShellCheck, guard-paste and stub runs recorded up to this point covered the origin-direct subshell in an earlier form, whose guard refusals left the subshell with status 0; in the current form its guard refusals leave the subshell with status 2. The block's earlier form, which runs that subshell and the anonymous and forged-header probes in one outer subshell that exits once with their combined status, and which lowercased the app's host with `tr` before refusing a reserved name, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite, and no guard-paste or stub run of that form is recorded. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and its app-host part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs on 2026-11-01 with no network. In bash, that part refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probe. Under dash, that part refused every host name with 2 and the bash message before its probe, and the whole block exited 3, because dash reads the origin-direct part's `$'\n'` differently and that part reported an inconclusive result. A synthetic JSON/base64url claim mutation preserved encoding but mismatched its unchanged HMAC signature; this does not test vendor verifiers. Live exposed bypass or forged-identity acceptance and fixed isolation, anonymous challenge, valid-assertion acceptance and altered-assertion rejection remain unobserved. After logging in, confirm that the app's own identity check reads the signed assertion (`x-amzn-oidc-data`, `x-goog-iap-jwt-assertion`, `Cf-Access-Jwt-Assertion`) and rejects a forged one. Test directly, because the proxy overwrites the header, so a through-proxy test proves nothing. The direct test, from the origin host, or a host inside the allowed security group, is to first send a request carrying a valid, unexpired assertion and confirm the app accepts it (the positive control), then send one whose signature is unchanged but a non-authorizing claim is altered while the token stays well formed (valid JSON and base64url), and require rejection. Both assertions are credentials (restoring the altered claim makes the copy valid again), so never place either in curl's argv, where other local accounts could read it through `ps` and `/proc//cmdline`. The handling for each assertion is to hold the assertion in the positional parameters of a subshell that first runs `trap - DEBUG RETURN ERR`, then `set +x +a +e`, and refuses an empty value or one containing a control character, and to feed it to curl on stdin: `printf 'x-amzn-oidc-data: %s\n' "$1" | curl -q -sS -D - --noproxy '*' --header @- http://127.0.0.1:3000/whoami`. That handling follows CONTRIBUTING rule 7 and assumes a clean shell. That handling keeps the assertion out of argv only, not out of `set -x` tracing or your own account's view of the process, and an assertion pasted on the subshell's `set --` line still enters shell history, so clear it afterwards. Keep the token well formed, because an app that never checks the signature would still reject a token mangled into malformed JSON, which would not prove verification. ```bash # origin direct. Run this from a host OUTSIDE your cloud network (your laptop on a public connection, # not the instance or a VPC peer), so the probe actually crosses the cloud boundary you are testing. Read err, # not the number: it must name a refusal or timeout reaching YOUR address. An HTTP code means the origin # answered. A resolver failure, a local socket error, or a timeout that did not come from the remote # address is inconclusive, never a pass. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:3000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # curl's exit status, then its http= line printf '%s\n' "$3" case "$3" in 'http=000 '*) ;; *) echo "FINDING: an HTTP code means the origin answered"; exit 1 ;; esac case "$2" in 7|28) echo "unclassified observation: read err above, which must name a refusal or timeout reaching YOUR address"; exit 0 ;; *) echo "curl exit $2 is neither a refusal nor a timeout; inconclusive, never a pass"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your identity-aware proxy serves the app on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above set -- "$1" "$2" "$(if curl -q -g -sSI -w 'http=%{http_code}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # via proxy, no session: 302 to the IdP, or 401/403 set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n\n'*}" # curl's exit status, then the headers and the http= line set -- "$1" "$2" "$3" "$4" "${4##*$'\n'}" # the same values, then the http= line alone printf '%s\n' "$4" case "$3:$5" in 0:http=401|0:http=403) ;; 0:http=302) echo "unclassified observation: read the Location above, which must be your identity provider's sign-in" ;; 0:http=2??) echo "FINDING: the proxy answered ${5#http=} to a request with no session"; set -- "$1" 1 ;; 0:*) echo "HTTP ${5#http=} with no session is neither a 302 to the IdP, a 401 or 403, nor a 2xx finding; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the request with no session failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac # The identity headers below are deliberately forged values, so admin@example.com stays as written. set -- "$1" "$2" "$(if curl -q -g -sS -D - -w '\nhttp=%{http_code}\n' -H "x-amzn-oidc-identity: admin" \ -H "X-MS-CLIENT-PRINCIPAL-NAME: admin" \ -H "X-Goog-Authenticated-User-Email: admin@example.com" \ "https://$1/whoami"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # status line and body: the login redirect or a 401/403, never a 200 echoing "admin" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n\n'*}" # curl's exit status, then the response and the http= line set -- "$1" "$2" "$3" "$4" "${4##*$'\n'}" # the same values, then the http= line alone printf '%s\n' "$4" case "$3:$5" in 0:http=401|0:http=403) ;; 0:http=302) echo "unclassified observation: read the Location above, which must be the login redirect" ;; 0:http=200) case "${4#*$'\r\n\r\n'}" in # the response body only: the text after the blank line that ends the headers *admin*) echo "FINDING: /whoami answered 200 echoing admin, so the app trusted the forged identity headers"; set -- "$1" 1 ;; *) echo "HTTP 200 from /whoami without admin is neither the login redirect, a 401 or 403, nor the forged identity; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac ;; 0:*) echo "HTTP ${5#http=} for the forged headers is neither the login redirect, a 401 or 403, nor a 200; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the forged-header request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the origin's err text on a refusal or timeout, each 302 Location and the other response headers are not classified, so judge them yourself" exit "$1" ) ``` - REASONED: default platform-route isolation, per the guide's cited Google Cloud IAP, enabling IAP for Cloud Run and Vercel Authentication documentation. No authorized Cloud Run or Vercel deployment and external probe host are available; live exposed and fixed responses remain unobserved. Reach the app's own default platform hostname from outside your cloud network and confirm it does not serve your app. Fetch a real app route (not just `/`) at your Cloud Run `run.app` URL or your Vercel production domain: use the following block, and read the status line, headers, and body together. A pass is the platform's own challenge, never your app's content: for Cloud Run a 302 redirect to a Google or IAP sign-in, or a 401 from IAP (IAP returns 401 rather than a 302 when the request does not advertise HTML, i.e. no `Accept: text/html` header); for Vercel the Vercel authentication page. A 200 serving your app's own content means the platform URL bypasses the proxy; a 404 or a transport error is inconclusive, never a pass, because a missing service also returns 404. REASONED: default platform-route probe, per the same cited Google Cloud IAP and Vercel Authentication documentation; no authorized deployment and external probe host are available for live exposed/fixed checks. In local Bash and Bash with set -u, the unsubstituted, empty, missing-set-line (with a caller URL), zero-value, two-value and embedded-placeholder cases each printed a refusal without reaching the curl stub; a substituted URL reached it once with the expected arguments. Ten values under IFS=0 with set -u were also refused. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. The bash -n, ShellCheck and stub runs recorded here covered the platform-route block in an earlier form, whose guard refusals left the subshell with status 0. The current form, whose guard refusals leave the subshell with status 2 and which exits 3 on a 404 or a failed request, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite; no guard-paste or stub run of it is recorded here. These local checks do not establish platform behaviour. Substitute the full URL of a real app route inside the quotes, keeping them. This anonymous request carries no secret, so no trap clearing is needed. ```bash ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so the request's status is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_YOUR_PLATFORM_URL/an-app-route' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PLATFORM_URL*|"") echo "substitute your own app route URL on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sS --noproxy '*' -D - -w '\nhttp=%{http_code}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n\n'*}" # curl's exit status, then the response and the http= line set -- "$1" "$2" "$3" "${3##*$'\n'}" # the same values, then the http= line alone printf '%s\n' "$3" case "$2:$4" in 0:http=404) echo "HTTP 404: a missing service also returns 404; inconclusive, never a pass"; set -- 3 ;; 0:*) set -- 0 ;; *) echo "curl exit $2: a transport error; inconclusive, never a pass"; set -- 3 ;; esac echo "block exit $1; any answer but a 404 is not classified: read the status line, headers and body above, where the platform's own challenge is the pass and your app's content means the platform URL bypasses the proxy" exit "$1" ) ``` - REASONED: fresh-request denial during an authorization outage, per the guide's cited AWS, Authenticate users using an Application Load Balancer and Azure App Service authentication and authorization. No controlled proxy, authorization service or protected route is available; this prose supplies no local outage command or configuration to execute. No outage was observed. The exposed state passes an unauthenticated request to the app; the fixed state denies it even when authorization is unavailable, without asserting that existing sessions terminate. Stop the authorization service, or break its configuration, and send a fresh, unauthenticated request to a protected route: it must be denied, never passed through to the app. This checks that an undecidable auth state fails closed; it does not test whether an already-established session survives the outage, since a proxy is not required to recheck the identity provider on every request. ## Common mistakes - Enabling IAP or ALB authentication while the Cloud Run `run.app` URL, the instance's public IP, or a second listener still serves the app directly. - Reading `x-amzn-oidc-identity`, `X-Goog-Authenticated-User-Email`, or a similar unsigned header as the user identity. - Treating Vercel Authentication as end-user login, or leaving production on Standard Protection and assuming it is covered. - Using the ngrok `oauth` action without a `deny` rule on email or domain, which lets anyone with a Google account in. ## Sources (checked September 2026) - AWS, Authenticate users using an Application Load Balancer: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html - Google Cloud IAP overview: https://docs.cloud.google.com/iap/docs/concepts-overview - Google Cloud IAP, securing your app with signed headers: https://docs.cloud.google.com/iap/docs/signed-headers-howto - Google Cloud IAP, enabling IAP for Cloud Run: https://docs.cloud.google.com/iap/docs/enabling-cloud-run - Google Cloud IAP, supported load balancer backends (backend buckets not supported): https://docs.cloud.google.com/iap/docs/load-balancer-howto - Google Cloud IAP, external identities and Identity Platform (IAM not used for access control): https://docs.cloud.google.com/iap/docs/external-identities - Azure App Service authentication and authorization: https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization - Azure App Service, work with user identities (headers): https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-user-identities - Azure Container Apps authentication: https://learn.microsoft.com/en-us/azure/container-apps/authentication - Azure Static Web Apps authentication and authorization: https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-authorization - Cloudflare Access, validate JWTs: https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/ - Cloudflare Access, independent MFA: https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/ - ngrok traffic policy OAuth action: https://ngrok.com/docs/gateway/traffic-policy/actions/oauth - ngrok traffic policy OpenID Connect action: https://ngrok.com/docs/gateway/traffic-policy/actions/oidc - ngrok agent CLI (`ngrok http` flags): https://ngrok.com/docs/gateway/agent/cli - Vercel Deployment Protection: https://vercel.com/docs/deployment-protection - Vercel Authentication: https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication - Vercel Password Protection (pricing): https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/password-protection - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0): https://curl.se/docs/manpage.html - Vercel production protection on every plan (announcement dated 2026-09-09, checked October 2026): https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - aws: AWS ALB (unknown) - iap: Google Cloud IAP (unknown) - azure: Azure authentication (unknown) - ngrok: ngrok (unknown) - vercel: Vercel (unknown) - vercel-announcement: Vercel production-protection announcement (2026-09-09) - cf: Cloudflare Access (unknown) - curl: curl minimum write-out version (7.75.0) Claims (full source mapping in the JSON block below): - origin (REASONED; aws, iap): Origins must accept only proxy traffic through loopback, security-group restriction or ingress controls; direct access bypasses login. - assertions (REASONED; aws, iap, cf, azure): Verify signed identity assertions before use; Azure's protected-header guarantee applies only to requests through its platform. - mfa-idp (REASONED; aws, iap, azure, ngrok, vercel): ALB, IAP, Azure, ngrok and Vercel inherit MFA from the identity provider; enforce it there. - mfa-access (REASONED; cf): Cloudflare Access can enforce independent TOTP or WebAuthn MFA without IdP enforcement. - authorization (REASONED; aws, iap, ngrok): Authentication proves identity; application or proxy policy must still authorize access. - fail-closed (REASONED; aws, azure): Missing auth configuration, an unreachable auth service or unmatched route must never pass an unauthenticated request through. - alb-actions (REASONED; aws): authenticate-oidc or authenticate-cognito precedes forward and works only on HTTPS listeners. - alb-anonymous (REASONED; aws): OnUnauthenticatedRequest defaults authenticate; allow forwards without claims, and deny returns HTTP 401. - alb-session (REASONED; aws): SessionTimeout defaults seven days and permits a one-second minimum; the example selects 3600 seconds. - alb-callback (REASONED; aws): The IdP redirect URL is the load-balancer DNS/CNAME over HTTPS at /oauth2/idpresponse. - alb-jwt (REASONED; aws): Verify ES256 x-amzn-oidc-data using the regional public-key endpoint and key ID; bind signer ARN/client in the JWT header and check expiry. - alb-legacy (REASONED; aws): Unsigned x-amzn-oidc-accesstoken and x-amzn-oidc-identity must not supply trusted identity. - alb-target (REASONED; aws): Restrict target security groups to the ALB security group; use HTTPS targets when claims need last-hop encryption. - iap-backends (REASONED; iap): IAP protects supported App Engine, Cloud Run, Compute Engine, GKE and on-premises backends, not backend buckets. - iap-iam (REASONED; iap): Google Accounts and Workforce Identity Federation require IAP-secured Web App User IAM access. - iap-customer (REASONED; iap): Identity Platform customer identities bypass IAM authorization; the app must authorize verified assertion claims. - iap-jwt (REASONED; iap): Verify ES256 x-goog-iap-jwt-assertion with Google's IAP JWKs, issuer https://cloud.google.com/iap and the resource audience. - iap-audience (REASONED; iap): IAP audience paths differ for backendServices, regional Cloud Run services and App Engine apps. - iap-unsigned (REASONED; iap): Unsigned authenticated-user-email/id headers are forgeable when IAP is bypassed; use the JWT. - iap-run (REASONED; iap): Load-balancer IAP does not protect run.app; enable IAP on Cloud Run or disable the default URL/restrict ingress. - iap-firewall (REASONED; iap): Compute Engine and GKE firewalls must block access that bypasses the load balancer. - azure-providers (REASONED; azure): Easy Auth fronts the app without code changes; documented providers include Entra, Facebook, Google, X, GitHub, Apple preview and custom OIDC. - azure-require (REASONED; azure): Require authentication rejects anonymously with 302, 401, 403 or 404; Allow unauthenticated requests delegates the decision to the app. - azure-https (REASONED; azure): Enabling Easy Auth redirects to HTTPS regardless of the app setting; do not disable requireHttps in V2 configuration. - azure-paths (REASONED; azure): Authentication covers every path unless excluded paths are configured. - azure-headers (REASONED; azure): X-MS-CLIENT-PRINCIPAL carries Base64 claims with ID/NAME/IDP headers; external requests cannot set these platform headers. - azure-me (REASONED; azure): /.auth/me is available with the token store enabled. - azure-tenant (REASONED; azure): Entra defaults to allowing any tenant user to obtain a token; restrict the app registration to assigned users when needed. - container-auth (REASONED; azure): Container Apps uses per-replica auth sidecars, supported social/Entra/OIDC providers, Require/Allow choices and protected principal headers. - container-https (REASONED; azure): Container Apps authentication requires HTTPS-only ingress with allowInsecure disabled. - static-login (REASONED; azure): Static Web Apps preconfigures GitHub and Entra on all plans at /.auth/login/github and /.auth/login/aad; custom providers replace defaults. - static-roles (REASONED; azure): Signed-in Static Web Apps users have anonymous and authenticated roles; staticwebapp.config.json route rules restrict by role. - static-tenant (REASONED; azure): The preconfigured Static Web Apps Entra provider accepts any Microsoft account; a custom provider is needed for one tenant. - cf-jwt (REASONED; cf): Prefer Cf-Access-Jwt-Assertion over the non-guaranteed CF_Authorization cookie; validate team certs, application AUD and team-domain issuer. - cf-rotation (REASONED; cf): Access rotates signing keys every six weeks with seven-day previous-key validity; fetch endpoint keys instead of hard-coding. - ngrok-login (REASONED; ngrok): Traffic-policy oauth or openid-connect redirects to the provider; OIDC uses issuer_url, client_id, client_secret and scopes. - ngrok-deny (REASONED; ngrok): OAuth login alone admits unintended accounts; a deny expression must match identities outside the email/domain allowlist. - ngrok-managed (REASONED; ngrok): Empty client_id/client_secret selects ngrok's managed OAuth app for supported providers. - ngrok-cli (REASONED; ngrok): Use ngrok http 3000 --traffic-policy-file policy.yml; legacy OAuth allow-domain/email flags are deprecated. - ngrok-origin (REASONED; ngrok): Bind the app to 127.0.0.1 so a direct VM address cannot bypass ngrok login. - vercel-members (REASONED; vercel): Deployment Protection is not end-user login; Vercel Authentication admits members/viewers, approved requesters, share links and automation bypass. - vercel-scope (REASONED; vercel, vercel-announcement): Standard Protection excludes production domains; configure All Deployments. The guide records free availability on every plan from September 9, 2026. - vercel-exception (REASONED; vercel): A domain Protection Exception disables protection for existing and future deployments; remove unintended exceptions. - vercel-password (REASONED; vercel): At writing, Password Protection is Enterprise-included or USD 20/month/project on Pro; USD 150 legacy is existing-Pro only; Hobby lacks it. - vercel-enterprise (REASONED; vercel): Trusted IPs and Passport are Enterprise-only at the time of writing. - verify-origin (REASONED; aws, iap, curl): An external direct-port-3000 probe must refuse/time out at the intended address; HTTP proves exposure and resolver/local errors are inconclusive. - verify-anonymous (REASONED; aws, azure): Anonymous proxy requests must redirect to the IdP or return 401/403. - verify-headers (REASONED; aws, iap, azure): Forged unsigned identity headers through the proxy must still yield a login challenge or denial, never admin identity. - verify-signature (REASONED; aws, iap, cf): At the origin, accept a valid assertion then reject a well-formed changed claim with the original signature; proxy overwrite or malformed JSON proves nothing. - verify-platform (REASONED; iap, vercel): Probe a real route at run.app or the Vercel production domain: require the platform challenge; app content bypasses auth and 404/transport errors are inconclusive. - verify-outage (REASONED; aws, azure): Break authorization and require denial of a fresh unauthenticated request; established-session survival is a separate test. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-09", "body_sha256": "427f1c43708e214388f52140b2aba8eba34855bf8b278a14515e316f2da11591", "components": { "aws": { "name": "AWS ALB", "basis": "unknown", "sources": { "sb1894f4f0139": "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html" } }, "iap": { "name": "Google Cloud IAP", "basis": "unknown", "sources": { "saafdfc163fce": "https://docs.cloud.google.com/iap/docs/concepts-overview", "s4aebf47a7647": "https://docs.cloud.google.com/iap/docs/signed-headers-howto", "s4033073bd65d": "https://docs.cloud.google.com/iap/docs/enabling-cloud-run", "s241e0f3cc6a3": "https://docs.cloud.google.com/iap/docs/load-balancer-howto", "s35eb8e8a02a9": "https://docs.cloud.google.com/iap/docs/external-identities" } }, "azure": { "name": "Azure authentication", "basis": "unknown", "sources": { "s5c4dc1862d97": "https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization", "s409d91a5181f": "https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-user-identities", "sba51f0a6aa9e": "https://learn.microsoft.com/en-us/azure/container-apps/authentication", "scc78dfba88dd": "https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-authorization" } }, "ngrok": { "name": "ngrok", "basis": "unknown", "sources": { "sa9c57301ff54": "https://ngrok.com/docs/gateway/traffic-policy/actions/oauth", "sc18d7af3967e": "https://ngrok.com/docs/gateway/traffic-policy/actions/oidc", "s93e9bbd8127f": "https://ngrok.com/docs/gateway/agent/cli" } }, "vercel": { "name": "Vercel", "basis": "unknown", "sources": { "se6089d5c4e96": "https://vercel.com/docs/deployment-protection", "se36c5ca2a8db": "https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication", "sf7c5fffb3f97": "https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/password-protection" } }, "vercel-announcement": { "name": "Vercel production-protection announcement", "basis": "2026-09-09", "sources": { "s7836e043434a": "https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan" } }, "cf": { "name": "Cloudflare Access", "basis": "unknown", "sources": { "s882e71a17d0e": "https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/", "s06747cf7388c": "https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "origin": {"text": "Origins must accept only proxy traffic through loopback, security-group restriction or ingress controls; direct access bypasses login.", "components": ["aws", "iap"], "sources": ["aws:sb1894f4f0139", "iap:s4033073bd65d"], "status": "REASONED"}, "assertions": {"text": "Verify signed identity assertions before use; Azure's protected-header guarantee applies only to requests through its platform.", "components": ["aws", "iap", "cf", "azure"], "sources": ["aws:sb1894f4f0139", "iap:s4aebf47a7647", "cf:s882e71a17d0e", "azure:s409d91a5181f"], "status": "REASONED"}, "mfa-idp": {"text": "ALB, IAP, Azure, ngrok and Vercel inherit MFA from the identity provider; enforce it there.", "components": ["aws", "iap", "azure", "ngrok", "vercel"], "sources": ["aws:sb1894f4f0139", "iap:saafdfc163fce", "azure:s5c4dc1862d97", "ngrok:sa9c57301ff54", "ngrok:sc18d7af3967e", "vercel:se36c5ca2a8db"], "status": "REASONED"}, "mfa-access": {"text": "Cloudflare Access can enforce independent TOTP or WebAuthn MFA without IdP enforcement.", "components": ["cf"], "sources": ["cf:s06747cf7388c"], "status": "REASONED"}, "authorization": {"text": "Authentication proves identity; application or proxy policy must still authorize access.", "components": ["aws", "iap", "ngrok"], "sources": ["aws:sb1894f4f0139", "iap:s35eb8e8a02a9", "ngrok:sa9c57301ff54"], "status": "REASONED"}, "fail-closed": {"text": "Missing auth configuration, an unreachable auth service or unmatched route must never pass an unauthenticated request through.", "components": ["aws", "azure"], "sources": ["aws:sb1894f4f0139", "azure:s5c4dc1862d97"], "status": "REASONED"}, "alb-actions": {"text": "authenticate-oidc or authenticate-cognito precedes forward and works only on HTTPS listeners.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "alb-anonymous": {"text": "OnUnauthenticatedRequest defaults authenticate; allow forwards without claims, and deny returns HTTP 401.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "alb-session": {"text": "SessionTimeout defaults seven days and permits a one-second minimum; the example selects 3600 seconds.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "alb-callback": {"text": "The IdP redirect URL is the load-balancer DNS/CNAME over HTTPS at /oauth2/idpresponse.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "alb-jwt": {"text": "Verify ES256 x-amzn-oidc-data using the regional public-key endpoint and key ID; bind signer ARN/client in the JWT header and check expiry.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "alb-legacy": {"text": "Unsigned x-amzn-oidc-accesstoken and x-amzn-oidc-identity must not supply trusted identity.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "alb-target": {"text": "Restrict target security groups to the ALB security group; use HTTPS targets when claims need last-hop encryption.", "components": ["aws"], "sources": ["aws:sb1894f4f0139"], "status": "REASONED"}, "iap-backends": {"text": "IAP protects supported App Engine, Cloud Run, Compute Engine, GKE and on-premises backends, not backend buckets.", "components": ["iap"], "sources": ["iap:saafdfc163fce", "iap:s241e0f3cc6a3"], "status": "REASONED"}, "iap-iam": {"text": "Google Accounts and Workforce Identity Federation require IAP-secured Web App User IAM access.", "components": ["iap"], "sources": ["iap:saafdfc163fce"], "status": "REASONED"}, "iap-customer": {"text": "Identity Platform customer identities bypass IAM authorization; the app must authorize verified assertion claims.", "components": ["iap"], "sources": ["iap:s35eb8e8a02a9"], "status": "REASONED"}, "iap-jwt": {"text": "Verify ES256 x-goog-iap-jwt-assertion with Google's IAP JWKs, issuer https://cloud.google.com/iap and the resource audience.", "components": ["iap"], "sources": ["iap:s4aebf47a7647"], "status": "REASONED"}, "iap-audience": {"text": "IAP audience paths differ for backendServices, regional Cloud Run services and App Engine apps.", "components": ["iap"], "sources": ["iap:s4aebf47a7647", "iap:s4033073bd65d"], "status": "REASONED"}, "iap-unsigned": {"text": "Unsigned authenticated-user-email/id headers are forgeable when IAP is bypassed; use the JWT.", "components": ["iap"], "sources": ["iap:s4aebf47a7647"], "status": "REASONED"}, "iap-run": {"text": "Load-balancer IAP does not protect run.app; enable IAP on Cloud Run or disable the default URL/restrict ingress.", "components": ["iap"], "sources": ["iap:s4033073bd65d"], "status": "REASONED"}, "iap-firewall": {"text": "Compute Engine and GKE firewalls must block access that bypasses the load balancer.", "components": ["iap"], "sources": ["iap:s241e0f3cc6a3"], "status": "REASONED"}, "azure-providers": {"text": "Easy Auth fronts the app without code changes; documented providers include Entra, Facebook, Google, X, GitHub, Apple preview and custom OIDC.", "components": ["azure"], "sources": ["azure:s5c4dc1862d97"], "status": "REASONED"}, "azure-require": {"text": "Require authentication rejects anonymously with 302, 401, 403 or 404; Allow unauthenticated requests delegates the decision to the app.", "components": ["azure"], "sources": ["azure:s5c4dc1862d97"], "status": "REASONED"}, "azure-https": {"text": "Enabling Easy Auth redirects to HTTPS regardless of the app setting; do not disable requireHttps in V2 configuration.", "components": ["azure"], "sources": ["azure:s5c4dc1862d97"], "status": "REASONED"}, "azure-paths": {"text": "Authentication covers every path unless excluded paths are configured.", "components": ["azure"], "sources": ["azure:s5c4dc1862d97"], "status": "REASONED"}, "azure-headers": {"text": "X-MS-CLIENT-PRINCIPAL carries Base64 claims with ID/NAME/IDP headers; external requests cannot set these platform headers.", "components": ["azure"], "sources": ["azure:s409d91a5181f"], "status": "REASONED"}, "azure-me": {"text": "/.auth/me is available with the token store enabled.", "components": ["azure"], "sources": ["azure:s409d91a5181f"], "status": "REASONED"}, "azure-tenant": {"text": "Entra defaults to allowing any tenant user to obtain a token; restrict the app registration to assigned users when needed.", "components": ["azure"], "sources": ["azure:s5c4dc1862d97"], "status": "REASONED"}, "container-auth": {"text": "Container Apps uses per-replica auth sidecars, supported social/Entra/OIDC providers, Require/Allow choices and protected principal headers.", "components": ["azure"], "sources": ["azure:sba51f0a6aa9e"], "status": "REASONED"}, "container-https": {"text": "Container Apps authentication requires HTTPS-only ingress with allowInsecure disabled.", "components": ["azure"], "sources": ["azure:sba51f0a6aa9e"], "status": "REASONED"}, "static-login": {"text": "Static Web Apps preconfigures GitHub and Entra on all plans at /.auth/login/github and /.auth/login/aad; custom providers replace defaults.", "components": ["azure"], "sources": ["azure:scc78dfba88dd"], "status": "REASONED"}, "static-roles": {"text": "Signed-in Static Web Apps users have anonymous and authenticated roles; staticwebapp.config.json route rules restrict by role.", "components": ["azure"], "sources": ["azure:scc78dfba88dd"], "status": "REASONED"}, "static-tenant": {"text": "The preconfigured Static Web Apps Entra provider accepts any Microsoft account; a custom provider is needed for one tenant.", "components": ["azure"], "sources": ["azure:scc78dfba88dd"], "status": "REASONED"}, "cf-jwt": {"text": "Prefer Cf-Access-Jwt-Assertion over the non-guaranteed CF_Authorization cookie; validate team certs, application AUD and team-domain issuer.", "components": ["cf"], "sources": ["cf:s882e71a17d0e"], "status": "REASONED"}, "cf-rotation": {"text": "Access rotates signing keys every six weeks with seven-day previous-key validity; fetch endpoint keys instead of hard-coding.", "components": ["cf"], "sources": ["cf:s882e71a17d0e"], "status": "REASONED"}, "ngrok-login": {"text": "Traffic-policy oauth or openid-connect redirects to the provider; OIDC uses issuer_url, client_id, client_secret and scopes.", "components": ["ngrok"], "sources": ["ngrok:sa9c57301ff54", "ngrok:sc18d7af3967e"], "status": "REASONED"}, "ngrok-deny": {"text": "OAuth login alone admits unintended accounts; a deny expression must match identities outside the email/domain allowlist.", "components": ["ngrok"], "sources": ["ngrok:sa9c57301ff54"], "status": "REASONED"}, "ngrok-managed": {"text": "Empty client_id/client_secret selects ngrok's managed OAuth app for supported providers.", "components": ["ngrok"], "sources": ["ngrok:sa9c57301ff54"], "status": "REASONED"}, "ngrok-cli": {"text": "Use ngrok http 3000 --traffic-policy-file policy.yml; legacy OAuth allow-domain/email flags are deprecated.", "components": ["ngrok"], "sources": ["ngrok:s93e9bbd8127f"], "status": "REASONED"}, "ngrok-origin": {"text": "Bind the app to 127.0.0.1 so a direct VM address cannot bypass ngrok login.", "components": ["ngrok"], "sources": ["ngrok:s93e9bbd8127f"], "status": "REASONED"}, "vercel-members": {"text": "Deployment Protection is not end-user login; Vercel Authentication admits members/viewers, approved requesters, share links and automation bypass.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96", "vercel:se36c5ca2a8db"], "status": "REASONED"}, "vercel-scope": {"text": "Standard Protection excludes production domains; configure All Deployments. The guide records free availability on every plan from September 9, 2026.", "components": ["vercel", "vercel-announcement"], "sources": ["vercel:se6089d5c4e96", "vercel:se36c5ca2a8db", "vercel-announcement:s7836e043434a"], "status": "REASONED"}, "vercel-exception": {"text": "A domain Protection Exception disables protection for existing and future deployments; remove unintended exceptions.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96"], "status": "REASONED"}, "vercel-password": {"text": "At writing, Password Protection is Enterprise-included or USD 20/month/project on Pro; USD 150 legacy is existing-Pro only; Hobby lacks it.", "components": ["vercel"], "sources": ["vercel:sf7c5fffb3f97"], "status": "REASONED"}, "vercel-enterprise": {"text": "Trusted IPs and Passport are Enterprise-only at the time of writing.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96"], "status": "REASONED"}, "verify-origin": {"text": "An external direct-port-3000 probe must refuse/time out at the intended address; HTTP proves exposure and resolver/local errors are inconclusive.", "components": ["aws", "iap", "curl"], "sources": ["aws:sb1894f4f0139", "iap:s4033073bd65d", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-anonymous": {"text": "Anonymous proxy requests must redirect to the IdP or return 401/403.", "components": ["aws", "azure"], "sources": ["aws:sb1894f4f0139", "azure:s5c4dc1862d97"], "status": "REASONED", "verify": [1]}, "verify-headers": {"text": "Forged unsigned identity headers through the proxy must still yield a login challenge or denial, never admin identity.", "components": ["aws", "iap", "azure"], "sources": ["aws:sb1894f4f0139", "iap:s4aebf47a7647", "azure:s409d91a5181f"], "status": "REASONED", "verify": [1]}, "verify-signature": {"text": "At the origin, accept a valid assertion then reject a well-formed changed claim with the original signature; proxy overwrite or malformed JSON proves nothing.", "components": ["aws", "iap", "cf"], "sources": ["aws:sb1894f4f0139", "iap:s4aebf47a7647", "cf:s882e71a17d0e"], "status": "REASONED"}, "verify-platform": {"text": "Probe a real route at run.app or the Vercel production domain: require the platform challenge; app content bypasses auth and 404/transport errors are inconclusive.", "components": ["iap", "vercel"], "sources": ["iap:s4033073bd65d", "vercel:se6089d5c4e96", "vercel:se36c5ca2a8db"], "status": "REASONED", "verify": [2]}, "verify-outage": {"text": "Break authorization and require denial of a fresh unauthenticated request; established-session survival is a separate test.", "components": ["aws", "azure"], "sources": ["aws:sb1894f4f0139", "azure:s5c4dc1862d97"], "status": "REASONED"} } } ```` ====================================================================== ==> machine-auth.md ====================================================================== # Machine identity: API keys, client credentials, mutual TLS, and workload identity Machine identity is how services, CI jobs and other workloads prove who they are without a person signing in. This guide helps your AI assistant choose and configure those credentials securely, including scoped, expiring API keys, OAuth client credentials, mutual TLS, and workload identity federation instead of stored cloud keys. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/machine-auth.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against OAuth client credentials (no recorded version), nginx mTLS release-1.28.0, Apache mTLS 2.4, PostgreSQL client certificates 17, MySQL client certificates 8.4, Redis TLS client authentication 7.4.2, and RabbitMQ TLS client authentication 3.13, plus 8 more recorded bases; 4 further sources are pinned to a commit and 17 carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 44 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Machines cannot do MFA, so their credentials are long-lived by default, and long-lived credentials leak through repositories, container images, and logs. The fix is scoped, short-lived, and where possible credential-free access: a CI job or workload that holds no key cannot leak one. [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) sets the baseline and [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) covers handling and leak response; this guide covers the credential types themselves, from the weakest to the one that removes the secret entirely. ## 1. API keys and bearer tokens The simplest machine credential and the one that leaks most. When your service issues or accepts them: - One key per client and per environment, generated randomly (commands in [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)), with the least privilege that client's task needs. A shared key cannot be revoked for one client without breaking the rest. - Give every key an expiry and rotate on a schedule, with a short overlap during which both keys work, so rotation is routine rather than an outage. - Send keys only in a header (`Authorization: Bearer ...`) over TLS, never in a URL. RFC 6750 makes TLS mandatory for bearer tokens and says they "SHOULD NOT be passed in page URLs", because URLs land in browser history, proxy logs, and server logs. - Compare the presented key in constant time: `hmac.compare_digest()` in Python, `crypto.timingSafeEqual()` in Node.js (both arguments must have the same byte length). A plain `==` stops at the first differing byte and leaks timing. - Where the service only needs to verify the key, as this guide's advice, issue a randomly generated key, store a hash of it and compare against the hash of the presented key. As this guide's advice, show the plaintext once at creation; as this guide's inference, a database dump then does not directly reveal plaintext keys if only hashes are stored and the keys have enough entropy to resist offline guessing. One example implementation, not a standard or requirement, is djangorestframework-api-key v3.1.0: its `Sha512ApiKeyHasher` hashes `password.encode()` with SHA-512, hex-encodes the digest and prefixes it with `sha512$$`, without a salt. The library justifies omitting salt for high-entropy keys and warns that this hasher should never be used in Django's `PASSWORD_HASHERS`. Its `verify` hashes the presented value with the same encoding and calls `constant_time_compare` on the encoded hashes. Its `KeyGenerator` defaults to an 8-character prefix and a 32-character secret, calls Django's `get_random_string` for each, joins them as `prefix.secret` and passes that whole key to its hash method; it returns the key, prefix and hash. The cited Django 5.2.18 helper chooses each character using Python's `secrets.choice` from 62 characters: lowercase ASCII letters, uppercase ASCII letters and digits; `constant_time_compare` calls `secrets.compare_digest` after converting both values with `force_bytes`. With those defaults, this guide calculates `32 * log2(62)`, about 190.5 bits for the secret alone, assuming independent uniform choices. As this guide's advice, fast hashes are suitable only for high-entropy random keys, not passwords; the OWASP password-storage citation remains password guidance, not an API-key implementation. These source lines show the example's code, not a deployed service's storage policy or measured timing. ## 2. OAuth 2.0 client credentials For service-to-service calls to an identity provider or an API that supports it, use the client credentials grant (RFC 6749 section 4.4): the client authenticates to the token endpoint with `grant_type=client_credentials` and receives a short-lived access token. RFC 6749 says a refresh token SHOULD NOT be issued (obtain another by re-authenticating with the client credentials), and the grant is for confidential clients only. Request the narrowest `scope` (or audience, where the provider uses one) the call needs, so a stolen token is bounded in time and reach, and validate the token on the receiving side as an access token, not an ID token. The receiving side must verify the token's signature against the issuer's keys (rejecting `alg: none`), the exact issuer, an `aud` that names your API, and expiry, and for a JWT access token must require the `at+jwt` type so an ID token cannot be substituted, then must enforce the scopes the call needs (RFC 9068). The client secret is still a long-lived credential: store it per section 5, or replace it with a federated credential per section 4 where the provider allows. Hosted providers may bill this flow: Microsoft Entra External ID charges machine-to-machine authentication per transaction as an add-on, so a token refresh every hour is roughly 720 billable transactions a month (as of September 2026; tiers in [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). ## 3. Mutual TLS A client certificate from your own internal CA ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) is a possession factor for a machine: the private key never crosses the wire, and a replaced client key cuts off one client, not all of them. Replacing a key does not by itself reject the old certificate: revoke it (a CRL or OCSP the server checks), remove it from an explicit allowlist, or let it expire, and test that the old credential is refused (RFC 5280 covers revocation). It is not human MFA, and [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) still applies to every human path that reaches the host. The service guides already carry the server-side directives: `ssl_verify_client on` in [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), `SSLVerifyClient require` in [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md), `clientcert=verify-full` in [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), `REQUIRE X509` in [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md), `tls-auth-clients yes` in [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md), `ssl_options.fail_if_no_peer_cert = true` in [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md), and `require_certificate true` in [mosquitto.md](https://github.com/jposluns/secureconfig/blob/main/mosquitto.md). Issue one certificate per client, keep the CA key off the servers it signs for, and set short lifetimes so a lost key expires rather than lingers. ## 4. Workload identity federation: no long-lived cloud keys in CI A GitHub Actions job can request a short-lived OIDC token (`permissions: id-token: write`) issued by `https://token.actions.githubusercontent.com` with a `sub` claim such as `repo:octo-org/octo-repo:ref:refs/heads/main` or `repo:octo-org/octo-repo:environment:prod`. The cloud provider trusts that issuer and exchanges the token for temporary credentials, so the repository stores no cloud key at all. The key point is the trust condition: every GitHub repository uses the same issuer, so a federation trust that admits any repository is a leaked key with extra steps. Condition on the exact repository and on the branch or environment. **AWS.** The `aws-actions/configure-aws-credentials` step takes `role-to-assume` and `aws-region` and sends `sts.amazonaws.com` as the audience by default. The role's trust policy does the restricting: ```json { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main" } } }] } ``` IAM refuses a trust policy whose `sub` condition is absent or only a wildcard, and AWS warns that a condition wider than your organization lets "GitHub Actions from organizations or repositories outside of your control" assume the role. **Google Cloud.** Create a Workload Identity Pool provider with `gcloud iam workload-identity-pools providers create-oidc` using `--issuer-uri="https://token.actions.githubusercontent.com"`, an `--attribute-mapping` such as `google.subject=assertion.sub,attribute.repository=assertion.repository`, and an `--attribute-condition` that pins the branch or environment as well as the owner. One such condition is `assertion.repository_owner == 'example-org' && assertion.ref == 'refs/heads/main'` (the owner alone still admits every branch of every repo it owns, the boundary section 4 warns against). Then grant `roles/iam.workloadIdentityUser` on the service account to `principalSet://iam.googleapis.com//attribute.repository/example-org/example-repo`, which names the exact repository. Google recommends conditions on the numeric `repository_id` and `repository_owner_id` claims over names, since a name can be re-registered by someone else. The `google-github-actions/auth` step takes `workload_identity_provider` and `service_account`. **Azure.** Add a federated credential on the app registration or user-assigned managed identity with issuer `https://token.actions.githubusercontent.com`, subject `repo:example-org/example-repo:environment:production` (or `repo:example-org/example-repo:ref:refs/heads/main`), and audience `api://AzureADTokenExchange`. An example command for adding that credential is `az ad app federated-credential create --id --parameters credential.json`. Wildcards are not supported and the subject must match exactly; a wrong subject is accepted at creation and fails only at exchange time, with no error message. The `azure/login` step then needs only `client-id`, `tenant-id`, and `subscription-id`; there is no client secret to store. Two cautions. GitHub documents an immutable default `sub` format that includes owner and repository IDs for repositories created after July 15, 2026 (as of September 2026), so read the claim your token actually carries before writing the condition. And pin every action to a full-length commit SHA, not a release tag or branch, which a compromised upstream can move to point at new code. Inside a cluster, [SPIFFE/SPIRE](https://spiffe.io/) is the equivalent: attested, short-lived identities issued to workloads without a stored secret. ## 5. Store the machine credentials that must exist API keys, client secrets, and client-certificate keys that cannot be federated away go in a secret manager or the platform's own store ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Secret manager choices start with AWS Secrets Manager (https://aws.amazon.com/secrets-manager/), Google Cloud Secret Manager (https://docs.cloud.google.com/secret-manager/docs/overview), and Azure Key Vault (https://azure.microsoft.com/en-us/products/key-vault). The list continues with HashiCorp Vault ([vault.md](https://github.com/jposluns/secureconfig/blob/main/vault.md); https://developer.hashicorp.com/vault) or OpenBao, its open-source fork under the Linux Foundation (https://openbao.org/), Infisical (https://infisical.com/), and Doppler (https://www.doppler.com/). The list ends with 1Password Secrets Automation (https://www.1password.dev/secrets-automation) and Bitwarden Secrets Manager (https://bitwarden.com/products/secrets-manager/). As this guide's advice, the workload should authenticate to the store with its platform identity (an instance role, a managed identity, or the federation above) where the selected store and workload support it, so access need not depend on one more long-lived bootstrap key; otherwise protect the store credential as a machine secret. ## Verify - REASONED: environment-scoping comparison per the guide's cited RFC 6750 bearer token usage, with the expected 401 or 403 in production and success in staging stated in this item; no staging and production API deployment is available in the authoring environment, so no request reached a service. At marking time the command, in an earlier form that named api.example.com, passed bash -n (Bash 5.3.9) and ShellCheck 0.11.0 with no diagnostics, and run against a stub curl from a mode-0600 header file holding a synthetic key, it passed the header file by name and the key did not appear in its arguments. The command now carries a `REPLACE_WITH_PRODUCTION_API_HOST` placeholder in place of that host, and the changed command has not been run. Put your production API's host name in place of `REPLACE_WITH_PRODUCTION_API_HOST` in the following command. A key issued to another client or environment is rejected: with the key in a mode-`0600` header file kept out of version control (never on the command line, per section 1), `curl -q -sS -o /dev/null -w '%{http_code}\n' -H @staging.header https://REPLACE_WITH_PRODUCTION_API_HOST/v1/status` against production returns `401` or `403`, never `200`. The same key does succeed in its own staging environment (so the denial is environment scoping, not a broken key or endpoint). - REASONED: revocation and expiry rejection per the guide's cited RFC 6750 bearer token usage and RFC 9068 access-token profile, with the expected rejection and the client-identified log record stated in this item, so acceptance or a missing log record is the exposed state. No deployed service, token issuer or service log is available in the authoring environment, and this item names no command, so no part of it ran locally. A revoked or expired key or token is rejected the same way, and the rejection appears in the service log with the client identity. - REASONED: CI credential posture per the guide's cited GitHub pages on security hardening with OpenID Connect and on configuring it in AWS, Google Cloud and Azure, with the fixed state stated in this item, so a long-lived cloud key among the secrets or a missing id-token: write is the exposed state. No GitHub repository or Actions settings are available in the authoring environment, so no secrets list or workflow was read, and this item names no command and the guide gives no workflow file to parse, so no part of it ran locally. The CI job holds no long-lived cloud key: the repository's Actions secrets contain no `AWS_SECRET_ACCESS_KEY`, service-account JSON, or Azure client secret, and the workflow declares `permissions: id-token: write`. - REASONED: trust-condition inspection per the guide's cited AWS IAM page on configuring a role for the GitHub OIDC identity provider, Google Cloud Workload Identity Federation pages and Microsoft Entra workload identity federation pages; no AWS, Google Cloud or Azure account is available in the authoring environment, so no live trust was read. At marking time jq 1.8.1 read section 4's AWS trust policy, whose sub value met this item's criteria, while copies with the sub set to repo:example-org/*, repo:example-org/example-repo:* or a bare * and one with no sub each failed them. Run in jq on section 4's inline Google and Azure values, the same criteria found that both Azure subjects, repo:example-org/example-repo:environment:production and repo:example-org/example-repo:ref:refs/heads/main, met them. The Google principalSet binding names example-org/example-repo with no trailing wildcard but pins no branch or environment, and the attribute condition pins the owner and refs/heads/main but names no repository, so the Google check is met only by the binding and the condition together. The federation trust condition names the exact repository: the AWS `sub` condition and the Azure `subject` each contain `example-org/example-repo` and pin the branch or environment. The Google `attribute.repository` binding and its attribute condition together restrict access to `example-org/example-repo` and the branch or environment. No AWS, Azure or Google trust value ends in a wildcard (`repo:example-org/*`, `repo:example-org/example-repo:*`, or a bare `*`) that would admit another repo, branch, or environment. - REASONED: history, working-tree and image-history checks per the Gitleaks v8.19.0 README and command sources and the Docker image-history reference below, with the expected no-key result stated in this item, so a reported key is the exposed state; no Gitleaks binary ran for this item and it names no project repository or image, so no scan result is recorded here. By source reading without a run at v8.19.0, `git` and `dir` read `source` without using `args`; across all seven top-level `cmd/` files, only `detect` and `protect` register `source`, each locally. As this guide's inference, those local registrations do not supply the flag to sibling commands or the root; neither command has an observed outcome here, as [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) also records. This item uses the hidden but still available `detect` command, which registers local `--source` and `--no-git`; [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) covers later releases. `gitleaks detect --source . --config .gitleaks.toml` (Git history), `gitleaks detect --no-git --source . --config .gitleaks.toml` (working tree), with `.gitleaks.toml` set up as the default-rule coverage item below describes, and `docker history --no-trunc REPLACE_WITH_IMAGE`, with your own image name or ID in place of `REPLACE_WITH_IMAGE`, show no key. Docker history lists an image's history, including creation commands and sizes, so as this guide's assessment, the `docker history` check passes only when its output lists the image's history rows and none of those rows shows a key. As this guide's assessment, an error from `docker history`, such as one caused by an unsubstituted `REPLACE_WITH_IMAGE`, an unknown image or an unreachable Docker daemon, or output with no history rows is inconclusive, not a pass, because no history was read. As this guide's assessment, also scan the image layers, which must show no key, because the history output is not a contents audit and does not show the contents of files copied into the image ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). - REASONED: image-layer scanning for the preceding artifact check, per the Trivy v0.76.0 container-image, secret-scanner and CLI sources below; no scan result is recorded here. The documented `trivy image` command takes an image name; `--scanners secret` selects secret scanning of files inside the image. The secret-scanner page says it scans plaintext files according to built-in rules or configuration and can detect secrets in compiled Python files (`.pyc`); allowed paths, matching allow rules and `--skip-dirs` or `--skip-files` can exclude content, and `trivy-secret.yaml` can change the rules. Trivy tries to detect a base image and skip those layers for secret scanning; as this guide's assessment, a secret placed in a base layer may not be reported. The container-image page says Trivy scans the image configuration for misconfigurations and secrets, which are disabled by default and enabled with `--image-config-scanners`; with `--image-config-scanners secret`, the image config is converted into JSON and scanned for secrets, which the page calls especially useful for environment variables. As this guide's advice, a clean image-file scan does not cover secrets in the image configuration unless `--image-config-scanners secret` is used. The image-layer scan must show no key; as this guide's assessment, a clean result does not prove absence of keys, and the cited v0.76.0 pages do not establish detection coverage for this project's key format or behaviour in other releases. - REASONED: default-rule coverage of this guide's API key formats for the artifact and image-layer scans above, per the Trivy v0.76.0 built-in rules, allow rules and scanner at 531360e5d2a88e732373f1614ba99a38738538b8 and the Gitleaks v8.19.0 default configuration, loaders and command sources below; no Trivy or Gitleaks binary ran for this item, so no scan result is recorded here. The keys this guide describes carry no fixed prefix: section 1 points to the generators in [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), `openssl rand -base64 32` and `secrets.token_urlsafe(32)`, which, as this guide's calculation for standard and URL-safe base64 of 32 bytes, give 44 characters ending in `=` that can include `+` and `/`, and 43 characters drawn from letters, digits, `-` and `_`. The djangorestframework-api-key example in section 1 joins an 8-character prefix and a 32-character secret of letters and digits with a dot. As read at that commit, each of Trivy's 105 built-in rules targets a specific token or file shape, such as a vendor prefix or name, a private-key header, a JWT or a Maven settings file, behind a keyword prefilter that ignores ASCII case; the one keyed on the word `key`, `aws-secret-access-key`, also needs `aws` before the key name and a 40-character value. Gitleaks' `generic-api-key` rule needs a keyword such as `key`, `api`, `token`, `secret` or `auth`, at most 20 further name characters and an operator such as `=` or `:` before a value of 10 to 150 letters, digits, `-`, `_`, `.` or `=`, with an entropy above 3.5 and no stopword in the value. Allow rules can suppress a match: Trivy's built-in allow rules skip test, example, vendor and Markdown paths, among others, and any match containing `example`, and Gitleaks' global allowlist skips paths such as `vendor`, `node_modules` and lock files. As this guide's computation at marking time, Python 3.14.4's `re` module (not Go's RE2 engine, and neither scanner) applied every pinned default rule with its keyword, path, entropy, stopword and allow-rule checks to 3,000 seeded synthetic keys per format, running rules whose `(?i)` follows the start wholly case-insensitive, a superset of Go's reading. That computation emulated Gitleaks' keyword, entropy and stopword steps from the rule and allowlist definitions, without its detection code. By later source reading of the v8.19.0 detection code, without a run, a file on the global path allowlist is skipped first, and a rule with keywords runs only if one of them, lowercased, occurs anywhere in the lowercased scanned fragment. Each regex match is then dropped, in this order, if its line carries `gitleaks:allow` and `IgnoreGitleaksAllow` is not set, if a rule or global allowlist regex matches its target, or if its secret contains a rule or global stopword, ignoring case. Each allowlist regex tests the captured secret by default, or the whole regex match or its line when that rule's or the global allowlist's `RegexTarget` is `match` or `line`. Only then, and only if the rule's `entropy` is nonzero (`if rule.Entropy != 0.0 {`), is the secret's Shannon entropy, in bits per character for ASCII input, compared with the rule's `entropy`, and a finding survives that comparison only if its entropy is greater; a rule whose `entropy` is zero skips this step. Inside the same condition, when the rule ID starts with `generic`, the secret must also contain a digit from 1 to 9, a step not in the rule definitions. As this guide's inference, the Gitleaks result that follows still holds for which rule matched and where; its counts could include keys with an entropy of exactly 3.5 or no digit from 1 to 9, because the guide does not record whether the computation applied those two steps. That computation found that no Trivy rule matched any key bare, after `API_KEY=`, quoted after `service_token:` or in an `Authorization: Bearer` line, and that the only Gitleaks rule that matched was `generic-api-key`, never bare or in that header line. The `generic-api-key` rule, after `API_KEY=` or `service_token:`, reported 781 base64 keys, 2,949 URL-safe keys and 2,954 prefix-and-secret keys, missing every base64 key containing `+` or `/` and dropping the other misses on stopwords in this emulation. As this guide's assessment, Trivy's built-in rules do not cover these formats and Gitleaks' defaults cover them only in part, so a custom rule is needed. Trivy loads `trivy-secret.yaml` from the current directory or the file named by `--secret-config` and adds custom rules to the enabled built-in rules. Gitleaks' `--config` help gives the order `--config` or `-c`, then the `GITLEAKS_CONFIG` variable, then `(target path)/.gitleaks.toml`. As this guide's reading of the v8.19.0 loader, without a run, that third step looks up a `source` flag on the root command, which registers none, so this guide does not rely on it and names the file with `--config` (or `GITLEAKS_CONFIG`). This guide's reading of the same v8.19.0 loader, without a run, is that in that file an `[extend]` table with `useDefault = true` loads the embedded default rules alongside the custom rule, and an `extend.path` file, which cannot be combined with `useDefault`, can also supply rules; with neither `useDefault` nor `path` set in `[extend]`, only that file's own rules load. Before trusting a clean scan, put a synthetic key of the issued format in a neutrally named file, such as `settings.env`, in a scratch directory whose path avoids allowlisted names such as `test`, `example`, `vendor`, `locale`, `node_modules` and lock-file names. The neutral file name and the scratch directory's path matter because, by source reading of the pinned rules, Trivy's built-in path allow rules, which still apply when custom rules are loaded unless disabled by ID, skip test, example, vendor, locale and Markdown paths, among others. The neutral file name and the scratch directory's path also matter because, by the same source reading, the Gitleaks default configuration's global path allowlist lists `vendor`, `node_modules`, lock files such as `package-lock.json` and `yarn.lock`, and paths ending in suffixes such as `exe` and `pdf`, with or without a preceding dot, among others. Then confirm that `trivy fs --secret-config trivy-secret.yaml REPLACE_WITH_A_DIRECTORY` and `gitleaks detect --no-git --source REPLACE_WITH_A_DIRECTORY --config .gitleaks.toml` each report it. This guide's reading of the v8.19.0 source, without a run, is that `gitleaks dir` reads a `source` flag it does not register, so the hidden `detect` command, which registers `--no-git` and `--source`, takes its place. A clean scan does not establish absence of keys, and behaviour in other releases is not established. - REASONED: mTLS refusal per the guide's cited RFC 5280 and the cited nginx, Apache, PostgreSQL, MySQL, Redis, RabbitMQ and Mosquitto client-certificate sources, with the expected refusals and the authorized connection stated in this item; none of the cited services is deployed in the authoring environment, so none of them was tested. At marking time openssl verify (OpenSSL 3.5.5) on local fixtures accepted the authorized client certificate and rejected one from an untrusted CA (error 20), the same client certificate checked at a time after its expiry (error 10) and a revoked certificate checked against the CA's CRL (error 23), which verified OK when the CRL was not checked. On 127.0.0.1 with throwaway fixtures, openssl s_server without client-certificate verification returned HTTP 200 to an s_client that presented no certificate; with -Verify 1 -verify_return_error it refused that client with a certificate required alert over TLS 1.3, refused a client certificate from an untrusted CA with an unknown ca alert, and returned HTTP 200 to the authorized client. The unauthorized-identity case needs a service that authorizes on certificate identity, and no such service is deployed in the authoring environment, so it was not run. An mTLS service refuses a client that presents no certificate, an untrusted or expired certificate, and (where it authorizes on identity) a validly-signed but unauthorized certificate, and refuses a certificate once revoked, while the authorized client still connects. ## Sources (checked September 2026) - Docker image history command (image history and creation-command output), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_image_history.yaml#L1-L118 - OAuth 2.0 client credentials grant (RFC 6749 section 4.4): https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 - OAuth 2.0 bearer token usage, TLS and URL rules (RFC 6750 sections 5.2 and 5.3): https://www.rfc-editor.org/info/rfc6750/ - JWT profile for OAuth 2.0 access tokens (RFC 9068 section 4: validate signature, `iss`, `aud`, `exp`, and the `at+jwt` type): https://www.rfc-editor.org/rfc/rfc9068.html - Python `hmac.compare_digest`: https://docs.python.org/3/library/hmac.html ; Node.js `crypto.timingSafeEqual`: https://nodejs.org/api/crypto.html - Microsoft Entra External ID billing model (M2M add-on): https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing - GitHub: about security hardening with OpenID Connect (claims, subject formats): https://docs.github.com/en/actions/concepts/security/openid-connect - GitHub: configuring OpenID Connect in AWS: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws ; in Google Cloud: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform ; in Azure: https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure - AWS IAM: configuring a role for the GitHub OIDC identity provider (trust policy, `sub` restriction): https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html - aws-actions/configure-aws-credentials: https://github.com/aws-actions/configure-aws-credentials - Google Cloud: Workload Identity Federation with deployment pipelines (GitHub Actions attribute mapping and conditions): https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines ; attribute conditions: https://docs.cloud.google.com/iam/docs/workload-identity-federation - google-github-actions/auth: https://github.com/google-github-actions/auth - Microsoft Entra: workload identity federation: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation ; creating the trust on an app (subject formats, audience, exact match): https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust - Azure: authenticate from GitHub Actions by OpenID Connect (`azure/login`): https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect - SPIFFE and SPIRE: https://spiffe.io/ and https://spiffe.io/docs/latest/spire-about/ - RFC 5280 (certificate revocation): https://www.rfc-editor.org/info/rfc5280/ - Secret manager vendor pages: linked inline in section 5. - OWASP Password Storage Cheat Sheet (hashes can be guessed; password guidance is not an API-key hash implementation), at 1a78ae3e443cef156e947f87e892efbcc62876ee (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/1a78ae3e443cef156e947f87e892efbcc62876ee/cheatsheets/Password_Storage_Cheat_Sheet.md#L21-L47 - djangorestframework-api-key v3.1.0 example (Django helper imports and prefix-dot-secret format; SHA-512, hexadecimal encoding, no-salt rationale, password warning and hash comparison; default key generation and preferred-hasher dispatch), checked October 2026. The djangorestframework-api-key v3.1.0 example sources: https://github.com/florimondmanca/djangorestframework-api-key/blob/v3.1.0/src/rest_framework_api_key/crypto.py#L9-L13 ; https://github.com/florimondmanca/djangorestframework-api-key/blob/v3.1.0/src/rest_framework_api_key/crypto.py#L21-L44 . More djangorestframework-api-key v3.1.0 example sources: https://github.com/florimondmanca/djangorestframework-api-key/blob/v3.1.0/src/rest_framework_api_key/crypto.py#L47-L82 - Django 5.2.18 crypto helpers (Python secrets import, 62-character alphabet, secrets.choice generation and secrets.compare_digest comparison), checked October 2026: https://github.com/django/django/blob/5.2.18/django/utils/crypto.py#L7-L10 ; https://github.com/django/django/blob/5.2.18/django/utils/crypto.py#L48-L67 - OWASP Secrets Management Cheat Sheet (central storage and management), at 1a78ae3e443cef156e947f87e892efbcc62876ee (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/1a78ae3e443cef156e947f87e892efbcc62876ee/cheatsheets/Secrets_Management_Cheat_Sheet.md#L5-L7 - AWS Secrets Manager overview (rolling, checked October 2026): https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html - Google Cloud Secret Manager overview (rolling, checked October 2026): https://docs.cloud.google.com/secret-manager/docs/overview - Azure Key Vault overview (rolling, checked October 2026): https://learn.microsoft.com/en-us/azure/key-vault/general/overview - OpenBao README (secret storage), v2.7.1 (checked October 2026): https://github.com/openbao/openbao/blob/v2.7.1/README.md#L47-L51 - OpenBao website (fork and Linux Foundation OpenSSF attribution; rolling, checked October 2026): https://openbao.org/ - 1Password Secrets Automation (rolling, checked October 2026): https://www.1password.dev/secrets-automation - Bitwarden Secrets Manager overview (rolling, checked October 2026): https://bitwarden.com/help/secrets-manager-overview/ - nginx release-1.28.0 `ssl_verify_client` directive and `on` value (checked October 2026): https://github.com/nginx/nginx/blob/release-1.28.0/src/http/modules/ngx_http_ssl_module.c#L76-L78 ; https://github.com/nginx/nginx/blob/release-1.28.0/src/http/modules/ngx_http_ssl_module.c#L162-L167 - Apache 2.4 `SSLVerifyClient require` (checked October 2026): https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslverifyclient - PostgreSQL 17 certificate authentication and `clientcert=verify-full` (checked October 2026): https://www.postgresql.org/docs/17/auth-cert.html - MySQL 8.4 `CREATE USER` TLS options (`REQUIRE X509`) (checked October 2026): https://dev.mysql.com/doc/refman/8.4/en/create-user.html - Redis 7.4.2 TLS client-certificate requirement and `tls-auth-clients yes` configuration (checked October 2026): https://github.com/redis/redis/blob/7.4.2/redis.conf#L240-L241 ; https://github.com/redis/redis/blob/7.4.2/src/config.c#L99-L103 ; https://github.com/redis/redis/blob/7.4.2/src/config.c#L3239 - RabbitMQ 3.13 TLS (`ssl_options.fail_if_no_peer_cert`, peer verification and CA configuration) (checked October 2026): https://www.rabbitmq.com/docs/3.13/ssl - Mosquitto v2.0.21 `require_certificate true` (checked October 2026): https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.21/man/mosquitto.conf.5.xml#L74-L76 - Gitleaks v8.19.0 README Git-history and directory scanning commands (`git` and `dir`, which this guide's source reading does not establish at v8.19.0; see Verify), and the note that `detect` and `protect` are deprecated, hidden and still available (checked October 2026). Gitleaks v8.19.0 README sources: https://github.com/gitleaks/gitleaks/blob/v8.19.0/README.md#L181 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/README.md#L188 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/README.md#L174-L176 - Trivy v0.76.0 image-file targets, secret-scanner selection and `--skip-dirs`/`--skip-files` flags, at 531360e5d2a88e732373f1614ba99a38738538b8 (checked October 2026). Trivy v0.76.0 documentation sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/target/container_image.md#L9-L20 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/references/configuration/cli/trivy_image.md#L3-L7 . More Trivy v0.76.0 documentation sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/references/configuration/cli/trivy_image.md#L139-L146 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/references/configuration/cli/trivy_image.md#L160-L161 - Trivy v0.76.0 secret scanning (plaintext and compiled Python files, allowed paths, base-layer skipping, exclusions, configuration and allow rules), at 531360e5d2a88e732373f1614ba99a38738538b8 (checked October 2026). Trivy v0.76.0 secret-scanning documentation sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L3-L21 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L63-L66 . More Trivy v0.76.0 secret-scanning documentation sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L86-L98 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L159-L162 - Trivy v0.76.0 image-configuration scanning (misconfigurations and secrets, disabled by default and enabled with `--image-config-scanners`; secrets in the image config converted into JSON with `--image-config-scanners secret`), at 531360e5d2a88e732373f1614ba99a38738538b8 (checked October 2026). Trivy v0.76.0 container-image documentation sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/target/container_image.md#L100-L109 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/target/container_image.md#L177-L185 - HashiCorp Vault v1.20.0 secret storage and access control (checked October 2026): https://github.com/hashicorp/vault/blob/v1.20.0/README.md#L18 - Infisical v0.165.16 secrets and configuration management (checked October 2026): https://github.com/Infisical/infisical/blob/v0.165.16/README.md#L48 - Doppler CLI 3.76.6 secrets and configuration access (checked October 2026): https://github.com/DopplerHQ/cli/blob/3.76.6/README.md#L3 - gitleaks v8.19.0 root flags and conditional `source` lookup, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/root.go - gitleaks v8.19.0 `git` flags and unused positional path, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/git.go - gitleaks v8.19.0 `dir` flags and unused positional path, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/directory.go - gitleaks v8.19.0 local `detect` flags and scan selection, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/detect.go - gitleaks v8.19.0 local `protect` flags, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/protect.go - gitleaks v8.19.0 `stdin` registration, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/stdin.go - gitleaks v8.19.0 `version` registration, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/version.go - gitleaks v8.19.0 top-level command inventory, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd - gitleaks v8.19.0 `generate` directory listing only, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/generate - Gitleaks v8.19.0 keyword prefilter built from the configuration's keywords, source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/detect.go#L104-L114 - Gitleaks v8.19.0 detection order (global path allowlist and keyword gating of each rule, then per-match allow comment, allowlist regexes, stopwords, entropy and the generic digit check), source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/detect.go#L170-L373 - Gitleaks v8.19.0 Shannon entropy and digit test (`containsDigit` checks only 1 to 9), source reading without a run (checked October 2026): https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/utils.go#L43-L60 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/utils.go#L180-L189 - Trivy v0.76.0 built-in secret rules and shared patterns, built-in allow rules and their allowed-path skip, which still applies with custom rules, ASCII case-insensitive keyword prefilter, allow-on-match and scan order, custom-rule enabling, filesystem scan and secret configuration file, at 531360e5d2a88e732373f1614ba99a38738538b8 (checked October 2026). Trivy v0.76.0 `pkg/fanal/secret` sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/builtin-rules.go#L80-L88 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/builtin-rules.go#L105-L1065 . More Trivy v0.76.0 `pkg/fanal/secret` sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/builtin-allow-rules.go#L3-L72 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/keyword.go#L18-L22 . More Trivy v0.76.0 `pkg/fanal/secret` sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L181-L232 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L709-L771 . More Trivy v0.76.0 `pkg/fanal/secret` sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L446-L455 . Trivy v0.76.0 secret-scanning documentation sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L70-L72 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L95-L121 . More Trivy v0.76.0 `pkg/fanal/secret` sources: https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L462-L479 ; https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L505-L514 - Gitleaks v8.19.0 default configuration (global path allowlist; `generic-api-key` keywords, regex, entropy and stopwords), rule and stopword definitions, `--config` precedence, root configuration loading and translation into rules, embedded defaults and configuration extension, and the `dir`, `git` and `detect` command flags and source handling (checked October 2026). Gitleaks v8.19.0 repository sources: https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/gitleaks.toml#L13-L28 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/gitleaks.toml#L507-L1992 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/rule.go#L17-L40 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/allowlist.go#L61-L69 . More Gitleaks v8.19.0 repository sources: https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L29-L34 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L68-L74 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L164-L175 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L237-L245 . More Gitleaks v8.19.0 repository sources: https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/directory.go#L13-L42 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L44-L58 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L97-L145 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L13-L14 . More Gitleaks v8.19.0 repository sources: https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L190-L231 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/detect.go#L32-L46 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/detect.go#L62-L124 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/git.go#L13-L44 . More Gitleaks v8.19.0 repository sources: https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L158-L170 ; https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L76-L178 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - oauth: OAuth client credentials (unknown) - bearer: Bearer token guidance (unknown) - jwt: JWT access-token profile (unknown) - entra: Entra External ID (unknown) - github: GitHub Actions OIDC (unknown) - aws: AWS IAM and credential action (unknown) - google: Google Cloud federation and auth action (unknown) - azure: Azure federation and login (unknown) - spiffe: SPIFFE and SPIRE (unknown) - pki: Certificate revocation (unknown) - nginx-mtls: nginx mTLS (release-1.28.0) - apache-mtls: Apache mTLS (2.4) - postgres-mtls: PostgreSQL client certificates (17) - mysql-mtls: MySQL client certificates (8.4) - redis-mtls: Redis TLS client authentication (7.4.2) - rabbitmq-mtls: RabbitMQ TLS client authentication (3.13) - mosquitto-mtls: Mosquitto TLS client authentication (v2.0.21) - vault: HashiCorp Vault (v1.20.0) - infisical: Infisical (v0.165.16) - doppler-cli: Doppler CLI (3.76.6) - openbao-release: OpenBao README (v2.7.1) - openbao-site: OpenBao website (unknown) - onepassword: 1Password Secrets Automation (unknown) - bitwarden: Bitwarden Secrets Manager (unknown) - aws-store: AWS Secrets Manager overview (unknown) - google-store: Google Cloud Secret Manager overview (unknown) - azure-store: Azure Key Vault overview (unknown) - gitleaks: Gitleaks scan commands and default rules (v8.19.0) - python: Python hmac (unknown) - node: Node.js crypto (unknown) - docker-build: Docker build and image history documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - owasp-password: OWASP Password Storage Cheat Sheet (1a78ae3e443cef156e947f87e892efbcc62876ee) - owasp-secrets: OWASP Secrets Management Cheat Sheet (1a78ae3e443cef156e947f87e892efbcc62876ee) - drf-api-key: djangorestframework-api-key (v3.1.0) - django-crypto: Django crypto helpers (5.2.18) - trivy: Trivy image secret scanning (531360e5d2a88e732373f1614ba99a38738538b8) Claims (full source mapping in the JSON block below): - key-scope (REASONED; bearer): Issue a random least-privilege key per client and environment so one client can be revoked independently. - key-lifetime (REASONED; bearer): Give keys expiries and scheduled rotation with a short overlap for continuity. - bearer-transport (REASONED; bearer): Send bearer keys in Authorization headers over mandatory TLS; URLs leak through history and logs. - constant-time (REASONED; python, node): Use hmac.compare_digest or crypto.timingSafeEqual rather than plain equality; the guide requires equal byte lengths. - key-hash (REASONED; bearer, owasp-password, owasp-secrets, drf-api-key, django-crypto): As this guide's advice, where only verification is needed, store random-key hashes, compare presented-key hashes and show plaintext once at creation; as this guide's inference, hash-only dumps do not directly reveal plaintext keys with enough entropy to resist offline guessing. The cited SHA-512 code is an example, not a standard or requirement, for high-entropy random keys, not passwords. - client-grant (REASONED; oauth): Confidential clients authenticate with grant_type=client_credentials for access tokens; refresh tokens SHOULD NOT be issued, so re-authenticate. - token-scope (REASONED; oauth, bearer): Request the narrowest scope or provider audience and bound access-token lifetime and reach. - jwt-validation (REASONED; jwt): Validate access-token signature, reject alg none, check exact issuer, API audience and expiry, require at+jwt for JWT access tokens, and enforce scopes. - m2m-billing (REASONED; entra): As of September 2026, Entra External ID M2M is billed per transaction as an add-on; hourly renewal is roughly 720 monthly transactions. - client-secret (REASONED; oauth, azure): Client credentials still need long-lived-secret protection unless replaced by federation supported by the provider. - mtls-identity (REASONED; pki): Use a separate short-lived certificate per client and keep CA keys off signed servers; mTLS is a possession factor, not human MFA. - mtls-nginx (REASONED; pki, nginx-mtls): nginx uses ssl_verify_client on in the linked service guide. - mtls-apache (REASONED; pki, apache-mtls): Apache uses SSLVerifyClient require in the linked service guide. - mtls-postgres (REASONED; pki, postgres-mtls): PostgreSQL uses clientcert=verify-full in the linked service guide. - mtls-mysql (REASONED; pki, mysql-mtls): MySQL uses REQUIRE X509 in the linked service guide. - mtls-redis (REASONED; pki, redis-mtls): Redis uses tls-auth-clients yes in the linked service guide. - mtls-rabbitmq (REASONED; pki, rabbitmq-mtls): RabbitMQ uses ssl_options.fail_if_no_peer_cert = true in the linked service guide. - mtls-mosquitto (REASONED; pki, mosquitto-mtls): Mosquitto uses require_certificate true in the linked service guide. - mtls-revocation (REASONED; pki): Replacing a key does not reject the old certificate; revoke with checked CRL/OCSP, remove it from an allowlist or wait for expiry, and test refusal. - oidc-token (REASONED; github): GitHub id-token: write permits short-lived OIDC tokens from token.actions.githubusercontent.com; restrict trust to the exact repository and branch or environment. - aws-action (REASONED; aws): configure-aws-credentials uses role-to-assume and aws-region and defaults its audience to sts.amazonaws.com. - aws-trust (REASONED; aws, github): AWS trust uses AssumeRoleWithWebIdentity and exact aud/sub conditions; IAM rejects absent or wildcard-only sub, and broad subjects admit outside repositories. - google-provider (REASONED; google): Create an OIDC provider with the GitHub issuer, subject/repository mappings and an owner plus branch/environment condition. - google-binding (REASONED; google): Grant workloadIdentityUser to the exact repository principalSet; prefer immutable numeric repository and owner IDs over reusable names. - google-action (REASONED; google, github): google-github-actions/auth takes workload_identity_provider and service_account. - azure-trust (REASONED; azure): Configure the exact GitHub issuer, repository branch/environment subject and api://AzureADTokenExchange audience; wildcards are unsupported and wrong subjects fail at exchange. - azure-action (REASONED; azure, github): azure/login needs client-id, tenant-id and subscription-id for OIDC, without a stored client secret. - subject-format (REASONED; github): The guide records an immutable default subject with owner/repository IDs for repositories created after July 15, 2026; inspect the actual claim before writing trust. - action-pins (REASONED; github): Pin actions to full commit SHAs; tags and branches can move to different code. - workload-attestation (REASONED; spiffe): SPIFFE/SPIRE provides attested short-lived workload identities without a stored secret. - secret-store (REASONED; azure, google, aws, vault, infisical, doppler-cli, owasp-secrets, aws-store, google-store, azure-store, openbao-release, openbao-site, onepassword, bitwarden): Store unavoidable machine secrets in the listed secret managers. As this guide's advice, authenticate with platform identity where the selected store and workload support it, so access need not depend on another long-lived bootstrap key; otherwise protect the required store credential. - verify-scope (REASONED; bearer): A protected header-file staging key must receive 401/403 in production while succeeding in staging; otherwise denial does not establish environment scoping. - verify-expiry (REASONED; bearer, jwt): Revoked or expired credentials must be rejected with a service-log record identifying the client. - verify-ci (REASONED; github): Repository Actions secrets should hold no long-lived cloud credentials and the workflow must declare id-token: write. - verify-trust (REASONED; aws, google, azure): Inspect AWS sub, Azure subject, and the Google repository binding together with its attribute condition, for exact repository plus branch/environment restriction without broad wildcard suffixes. - verify-artifacts (REASONED; bearer, gitleaks, docker-build): History, working-tree and image-layer scans must show no keys; docker history lists an image's history; as this guide's assessment, it is not a contents audit and does not show the contents of files copied into the image, so a separate image-layer scan is required. By source reading without a run, Gitleaks v8.19.0 git and dir are not established, so the Gitleaks scans use hidden detect. - verify-image-scan (REASONED; trivy): Trivy v0.76.0 documents trivy image with --scanners secret for image files: plaintext under rules/configuration and detectable secrets in compiled Python files. Trivy tries to detect a base image and skip those layers for secret scanning; as this guide's assessment, a secret placed in a base layer may not be reported. - verify-image-rules (REASONED; trivy): Trivy v0.76.0 allowed paths, matching allow rules, --skip-dirs and --skip-files can exclude content from secret scanning; trivy-secret.yaml can change the rules. - verify-image-limits (REASONED; trivy): The image-layer scan must show no key; as this guide's assessment, a clean result does not prove absence of keys, and the cited Trivy v0.76.0 pages do not establish detection coverage for this project's key format or behaviour in other releases. - verify-image-config (REASONED; trivy): Trivy v0.76.0 image-config misconfiguration and secret scans are disabled by default and enabled with --image-config-scanners; with --image-config-scanners secret, the config is converted to JSON and scanned for secrets, especially useful for environment variables. As this guide's advice, a clean image-file scan does not cover image-config secrets unless --image-config-scanners secret is used. - verify-key-coverage (REASONED; trivy, gitleaks, drf-api-key): As this guide's Python re computation, not Go RE2 or a scanner: no Trivy v0.76.0 built-in rule matched this guide's synthetic API keys bare, assigned or in a Bearer header; Gitleaks v8.19.0 matched them only via generic-api-key after a keyword assignment, missing base64 keys with + or / and stopword hits. By source reading, that rule also needs a digit 1 to 9; other releases are unestablished. - verify-key-rule (REASONED; trivy, gitleaks): As this guide's assessment, Trivy v0.76.0 built-in rules miss this guide's API key formats and Gitleaks v8.19.0 defaults cover them only in part, so add a custom rule: Trivy loads trivy-secret.yaml or --secret-config; by source reading without a run, gitleaks detect needs --config or GITLEAKS_CONFIG. A clean scan does not establish absence of keys. - verify-key-extend (REASONED; gitleaks): By source reading of the Gitleaks v8.19.0 loader without a run, a config file named with --config or GITLEAKS_CONFIG loads only its own rules unless its extend table sets useDefault = true, which adds the embedded default rules, or path, which adds another file's rules; setting both is a fatal error. - verify-mtls (REASONED; pki): Reject missing, untrusted, expired, revoked and, where identity authorization applies, unauthorized certificates while the authorized client succeeds. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "6a24abc10fe1b9b6d87d73822faa2e92dc51935b99b58b9d4e79b45552abf14e", "components": { "oauth": { "name": "OAuth client credentials", "basis": "unknown", "sources": { "s65b02b1812cc": "https://datatracker.ietf.org/doc/html/rfc6749#section-4.4" } }, "bearer": { "name": "Bearer token guidance", "basis": "unknown", "sources": { "sefd3dd623da3": "https://www.rfc-editor.org/info/rfc6750/" } }, "jwt": { "name": "JWT access-token profile", "basis": "unknown", "sources": { "sc36d79fcad94": "https://www.rfc-editor.org/rfc/rfc9068.html" } }, "entra": { "name": "Entra External ID", "basis": "unknown", "sources": { "s0ee5939cfc03": "https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing" } }, "github": { "name": "GitHub Actions OIDC", "basis": "unknown", "sources": { "s65bfc1e3ee0d": "https://docs.github.com/en/actions/concepts/security/openid-connect", "s653a0d94dd2f": "https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws", "sa8ed4c86a8ea": "https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform", "s80d46a4f675c": "https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure" } }, "aws": { "name": "AWS IAM and credential action", "basis": "unknown", "sources": { "s6fff7087bca4": "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html", "se185ac33f225": "https://github.com/aws-actions/configure-aws-credentials" } }, "google": { "name": "Google Cloud federation and auth action", "basis": "unknown", "sources": { "s03844cd0246a": "https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines", "s0f874cc5b70e": "https://docs.cloud.google.com/iam/docs/workload-identity-federation", "saa567d0c2c78": "https://github.com/google-github-actions/auth" } }, "azure": { "name": "Azure federation and login", "basis": "unknown", "sources": { "sb7a38a36ac47": "https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation", "sc70c3d4b8844": "https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust", "s231245f7e96b": "https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect" } }, "spiffe": { "name": "SPIFFE and SPIRE", "basis": "unknown", "sources": { "s96e9b9e2739d": "https://spiffe.io/", "sfa39616d8260": "https://spiffe.io/docs/latest/spire-about/" } }, "pki": { "name": "Certificate revocation", "basis": "unknown", "sources": { "s93ee2b4751f8": "https://www.rfc-editor.org/info/rfc5280/" } }, "nginx-mtls": { "name": "nginx mTLS", "basis": "release-1.28.0", "sources": { "saba09cebd9dc": "https://github.com/nginx/nginx/blob/release-1.28.0/src/http/modules/ngx_http_ssl_module.c#L76-L78", "se19e48aec677": "https://github.com/nginx/nginx/blob/release-1.28.0/src/http/modules/ngx_http_ssl_module.c#L162-L167" } }, "apache-mtls": { "name": "Apache mTLS", "basis": "2.4", "sources": { "s011c9b11bbf6": "https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslverifyclient" } }, "postgres-mtls": { "name": "PostgreSQL client certificates", "basis": "17", "sources": { "sece82dc43429": "https://www.postgresql.org/docs/17/auth-cert.html" } }, "mysql-mtls": { "name": "MySQL client certificates", "basis": "8.4", "sources": { "s4009a8e12cba": "https://dev.mysql.com/doc/refman/8.4/en/create-user.html" } }, "redis-mtls": { "name": "Redis TLS client authentication", "basis": "7.4.2", "sources": { "s37a9d8a0e34f": "https://github.com/redis/redis/blob/7.4.2/redis.conf#L240-L241", "sc437a14c713a": "https://github.com/redis/redis/blob/7.4.2/src/config.c#L99-L103", "se86d20ae4ca9": "https://github.com/redis/redis/blob/7.4.2/src/config.c#L3239" } }, "rabbitmq-mtls": { "name": "RabbitMQ TLS client authentication", "basis": "3.13", "sources": { "s617e896b6f53": "https://www.rabbitmq.com/docs/3.13/ssl" } }, "mosquitto-mtls": { "name": "Mosquitto TLS client authentication", "basis": "v2.0.21", "sources": { "sf7dad152fc94": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.21/man/mosquitto.conf.5.xml#L74-L76" } }, "vault": { "name": "HashiCorp Vault", "basis": "v1.20.0", "sources": { "sc7513679fe7e": "https://github.com/hashicorp/vault/blob/v1.20.0/README.md#L18" } }, "infisical": { "name": "Infisical", "basis": "v0.165.16", "sources": { "s725a4ac24a82": "https://github.com/Infisical/infisical/blob/v0.165.16/README.md#L48" } }, "doppler-cli": { "name": "Doppler CLI", "basis": "3.76.6", "sources": { "se354d6243027": "https://github.com/DopplerHQ/cli/blob/3.76.6/README.md#L3" } }, "openbao-release": { "name": "OpenBao README", "basis": "v2.7.1", "sources": { "sdb9d182ed195": "https://github.com/openbao/openbao/blob/v2.7.1/README.md#L47-L51" } }, "openbao-site": { "name": "OpenBao website", "basis": "unknown", "sources": { "s76158fafba22": "https://openbao.org/" } }, "onepassword": { "name": "1Password Secrets Automation", "basis": "unknown", "sources": { "s3dba9e568d37": "https://www.1password.dev/secrets-automation" } }, "bitwarden": { "name": "Bitwarden Secrets Manager", "basis": "unknown", "sources": { "se179d6bd5c04": "https://bitwarden.com/help/secrets-manager-overview/" } }, "aws-store": { "name": "AWS Secrets Manager overview", "basis": "unknown", "sources": { "s81b41c17ad57": "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html" } }, "google-store": { "name": "Google Cloud Secret Manager overview", "basis": "unknown", "sources": { "scd27be211781": "https://docs.cloud.google.com/secret-manager/docs/overview" } }, "azure-store": { "name": "Azure Key Vault overview", "basis": "unknown", "sources": { "seb86b29f4951": "https://learn.microsoft.com/en-us/azure/key-vault/general/overview" } }, "gitleaks": { "name": "Gitleaks scan commands and default rules", "basis": "v8.19.0", "sources": { "s6310bf73dd3e": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/README.md#L181", "s3809c36cec88": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/README.md#L188", "scbf3e990ccaa": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/gitleaks.toml#L13-L28", "s283e09458143": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/gitleaks.toml#L507-L1992", "s0e91f385f29d": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/rule.go#L17-L40", "sa0a5c152f00b": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/allowlist.go#L61-L69", "s2013eedad51e": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L29-L34", "s6399241b615e": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L68-L74", "s22558f2b4beb": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L164-L175", "s85ed52e3f61b": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L237-L245", "s6bf210c39b05": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/directory.go#L13-L42", "s6d88a08ac6ba": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/README.md#L174-L176", "s235435e6815e": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L44-L58", "s4b1376cdb8f0": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L97-L145", "s16f2dbd24fac": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L13-L14", "s3fee5ad8586d": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L190-L231", "s8629863aeff0": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/detect.go#L32-L46", "s47abbb4b3ee2": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/detect.go#L62-L124", "sd59d825eae35": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/git.go#L13-L44", "sba35fcdb9687": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/cmd/root.go#L158-L170", "s2f37a953975d": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/config/config.go#L76-L178", "s38ee01a95c8c": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/root.go", "s5abcb8b428db": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/git.go", "s8db158cb815b": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/directory.go", "s68724a6b5a24": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/detect.go", "s913053b5ad1e": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/protect.go", "s189c08d2f974": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/stdin.go", "s4b5d9b2ab7a1": "https://github.com/gitleaks/gitleaks/blob/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/version.go", "s8a2e9dc86ccd": "https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd", "s43b67b6788dd": "https://github.com/gitleaks/gitleaks/tree/44ad62e0b103f7907c4b3dd494aca64e4fefd94f/cmd/generate", "s7e8d49968e7b": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/detect.go#L104-L114", "s93e2b8afe3e2": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/detect.go#L170-L373", "sb475192e7bfd": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/utils.go#L43-L60", "s3db5f83b981f": "https://github.com/gitleaks/gitleaks/blob/v8.19.0/detect/utils.go#L180-L189" } }, "python": { "name": "Python hmac", "basis": "unknown", "sources": { "s7a54556e5dac": "https://docs.python.org/3/library/hmac.html" } }, "node": { "name": "Node.js crypto", "basis": "unknown", "sources": { "s96b80e6816a2": "https://nodejs.org/api/crypto.html" } }, "docker-build": { "name": "Docker build and image history documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "s6283b172a5c6": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_image_history.yaml#L1-L118" } }, "owasp-password": { "name": "OWASP Password Storage Cheat Sheet", "basis": "1a78ae3e443cef156e947f87e892efbcc62876ee", "sources": { "scfb73a9d1029": "https://github.com/OWASP/CheatSheetSeries/blob/1a78ae3e443cef156e947f87e892efbcc62876ee/cheatsheets/Password_Storage_Cheat_Sheet.md#L21-L47" } }, "owasp-secrets": { "name": "OWASP Secrets Management Cheat Sheet", "basis": "1a78ae3e443cef156e947f87e892efbcc62876ee", "sources": { "s3acb98020188": "https://github.com/OWASP/CheatSheetSeries/blob/1a78ae3e443cef156e947f87e892efbcc62876ee/cheatsheets/Secrets_Management_Cheat_Sheet.md#L5-L7" } }, "drf-api-key": { "name": "djangorestframework-api-key", "basis": "v3.1.0", "sources": { "s13cac8efb2c8": "https://github.com/florimondmanca/djangorestframework-api-key/blob/v3.1.0/src/rest_framework_api_key/crypto.py#L9-L13", "s5776d8150ed6": "https://github.com/florimondmanca/djangorestframework-api-key/blob/v3.1.0/src/rest_framework_api_key/crypto.py#L21-L44", "s6b7803ec22e6": "https://github.com/florimondmanca/djangorestframework-api-key/blob/v3.1.0/src/rest_framework_api_key/crypto.py#L47-L82" } }, "django-crypto": { "name": "Django crypto helpers", "basis": "5.2.18", "sources": { "sa74f4eec0332": "https://github.com/django/django/blob/5.2.18/django/utils/crypto.py#L7-L10", "sefce580872b0": "https://github.com/django/django/blob/5.2.18/django/utils/crypto.py#L48-L67" } }, "trivy": { "name": "Trivy image secret scanning", "basis": "531360e5d2a88e732373f1614ba99a38738538b8", "sources": { "sd694528fbbae": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/target/container_image.md#L9-L20", "s5db9e168161d": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/references/configuration/cli/trivy_image.md#L3-L7", "s8468a617a850": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/references/configuration/cli/trivy_image.md#L139-L146", "s4b6915d2b221": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/references/configuration/cli/trivy_image.md#L160-L161", "se5a5b0338769": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L3-L21", "s72ebf18f1e70": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L63-L66", "s9bc900326c4b": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L86-L98", "s99905622759a": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L159-L162", "sce50c12b5f76": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/target/container_image.md#L100-L109", "s6bb65bd52e65": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/target/container_image.md#L177-L185", "se9bb37460ffe": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/builtin-rules.go#L80-L88", "s6f857bd4569d": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/builtin-rules.go#L105-L1065", "sf4ca7afa790f": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/builtin-allow-rules.go#L3-L72", "s71fb518185c8": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/keyword.go#L18-L22", "s0d1c116b56e3": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L181-L232", "s78e04527854f": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L709-L771", "s82e83a428bf7": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L446-L455", "s8ee72b0d3617": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L70-L72", "sc91bfa96859f": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/docs/guide/scanner/secret.md#L95-L121", "se82b81bc0eff": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L462-L479", "s84b474b84678": "https://github.com/aquasecurity/trivy/blob/531360e5d2a88e732373f1614ba99a38738538b8/pkg/fanal/secret/scanner.go#L505-L514" } } }, "claims": { "key-scope": {"text": "Issue a random least-privilege key per client and environment so one client can be revoked independently.", "components": ["bearer"], "sources": ["bearer:sefd3dd623da3"], "status": "REASONED"}, "key-lifetime": {"text": "Give keys expiries and scheduled rotation with a short overlap for continuity.", "components": ["bearer"], "sources": ["bearer:sefd3dd623da3"], "status": "REASONED"}, "bearer-transport": {"text": "Send bearer keys in Authorization headers over mandatory TLS; URLs leak through history and logs.", "components": ["bearer"], "sources": ["bearer:sefd3dd623da3"], "status": "REASONED"}, "constant-time": {"text": "Use hmac.compare_digest or crypto.timingSafeEqual rather than plain equality; the guide requires equal byte lengths.", "components": ["python", "node"], "sources": ["python:s7a54556e5dac", "node:s96b80e6816a2"], "status": "REASONED"}, "key-hash": {"text": "As this guide's advice, where only verification is needed, store random-key hashes, compare presented-key hashes and show plaintext once at creation; as this guide's inference, hash-only dumps do not directly reveal plaintext keys with enough entropy to resist offline guessing. The cited SHA-512 code is an example, not a standard or requirement, for high-entropy random keys, not passwords.", "components": ["bearer", "owasp-password", "owasp-secrets", "drf-api-key", "django-crypto"], "sources": ["bearer:sefd3dd623da3", "owasp-password:scfb73a9d1029", "owasp-secrets:s3acb98020188", "drf-api-key:s13cac8efb2c8", "drf-api-key:s5776d8150ed6", "drf-api-key:s6b7803ec22e6", "django-crypto:sa74f4eec0332", "django-crypto:sefce580872b0"], "status": "REASONED"}, "client-grant": {"text": "Confidential clients authenticate with grant_type=client_credentials for access tokens; refresh tokens SHOULD NOT be issued, so re-authenticate.", "components": ["oauth"], "sources": ["oauth:s65b02b1812cc"], "status": "REASONED"}, "token-scope": {"text": "Request the narrowest scope or provider audience and bound access-token lifetime and reach.", "components": ["oauth", "bearer"], "sources": ["oauth:s65b02b1812cc", "bearer:sefd3dd623da3"], "status": "REASONED"}, "jwt-validation": {"text": "Validate access-token signature, reject alg none, check exact issuer, API audience and expiry, require at+jwt for JWT access tokens, and enforce scopes.", "components": ["jwt"], "sources": ["jwt:sc36d79fcad94"], "status": "REASONED"}, "m2m-billing": {"text": "As of September 2026, Entra External ID M2M is billed per transaction as an add-on; hourly renewal is roughly 720 monthly transactions.", "components": ["entra"], "sources": ["entra:s0ee5939cfc03"], "status": "REASONED"}, "client-secret": {"text": "Client credentials still need long-lived-secret protection unless replaced by federation supported by the provider.", "components": ["oauth", "azure"], "sources": ["oauth:s65b02b1812cc", "azure:sb7a38a36ac47"], "status": "REASONED"}, "mtls-identity": {"text": "Use a separate short-lived certificate per client and keep CA keys off signed servers; mTLS is a possession factor, not human MFA.", "components": ["pki"], "sources": ["pki:s93ee2b4751f8"], "status": "REASONED"}, "mtls-nginx": {"text": "nginx uses ssl_verify_client on in the linked service guide.", "components": ["pki", "nginx-mtls"], "sources": ["pki:s93ee2b4751f8", "nginx-mtls:saba09cebd9dc", "nginx-mtls:se19e48aec677"], "status": "REASONED"}, "mtls-apache": {"text": "Apache uses SSLVerifyClient require in the linked service guide.", "components": ["pki", "apache-mtls"], "sources": ["pki:s93ee2b4751f8", "apache-mtls:s011c9b11bbf6"], "status": "REASONED"}, "mtls-postgres": {"text": "PostgreSQL uses clientcert=verify-full in the linked service guide.", "components": ["pki", "postgres-mtls"], "sources": ["pki:s93ee2b4751f8", "postgres-mtls:sece82dc43429"], "status": "REASONED"}, "mtls-mysql": {"text": "MySQL uses REQUIRE X509 in the linked service guide.", "components": ["pki", "mysql-mtls"], "sources": ["pki:s93ee2b4751f8", "mysql-mtls:s4009a8e12cba"], "status": "REASONED"}, "mtls-redis": {"text": "Redis uses tls-auth-clients yes in the linked service guide.", "components": ["pki", "redis-mtls"], "sources": ["pki:s93ee2b4751f8", "redis-mtls:s37a9d8a0e34f", "redis-mtls:sc437a14c713a", "redis-mtls:se86d20ae4ca9"], "status": "REASONED"}, "mtls-rabbitmq": {"text": "RabbitMQ uses ssl_options.fail_if_no_peer_cert = true in the linked service guide.", "components": ["pki", "rabbitmq-mtls"], "sources": ["pki:s93ee2b4751f8", "rabbitmq-mtls:s617e896b6f53"], "status": "REASONED"}, "mtls-mosquitto": {"text": "Mosquitto uses require_certificate true in the linked service guide.", "components": ["pki", "mosquitto-mtls"], "sources": ["pki:s93ee2b4751f8", "mosquitto-mtls:sf7dad152fc94"], "status": "REASONED"}, "mtls-revocation": {"text": "Replacing a key does not reject the old certificate; revoke with checked CRL/OCSP, remove it from an allowlist or wait for expiry, and test refusal.", "components": ["pki"], "sources": ["pki:s93ee2b4751f8"], "status": "REASONED"}, "oidc-token": {"text": "GitHub id-token: write permits short-lived OIDC tokens from token.actions.githubusercontent.com; restrict trust to the exact repository and branch or environment.", "components": ["github"], "sources": ["github:s65bfc1e3ee0d"], "status": "REASONED"}, "aws-action": {"text": "configure-aws-credentials uses role-to-assume and aws-region and defaults its audience to sts.amazonaws.com.", "components": ["aws"], "sources": ["aws:se185ac33f225"], "status": "REASONED"}, "aws-trust": {"text": "AWS trust uses AssumeRoleWithWebIdentity and exact aud/sub conditions; IAM rejects absent or wildcard-only sub, and broad subjects admit outside repositories.", "components": ["aws", "github"], "sources": ["aws:s6fff7087bca4", "github:s653a0d94dd2f"], "status": "REASONED"}, "google-provider": {"text": "Create an OIDC provider with the GitHub issuer, subject/repository mappings and an owner plus branch/environment condition.", "components": ["google"], "sources": ["google:s03844cd0246a", "google:s0f874cc5b70e"], "status": "REASONED"}, "google-binding": {"text": "Grant workloadIdentityUser to the exact repository principalSet; prefer immutable numeric repository and owner IDs over reusable names.", "components": ["google"], "sources": ["google:s03844cd0246a", "google:s0f874cc5b70e"], "status": "REASONED"}, "google-action": {"text": "google-github-actions/auth takes workload_identity_provider and service_account.", "components": ["google", "github"], "sources": ["google:saa567d0c2c78", "github:sa8ed4c86a8ea"], "status": "REASONED"}, "azure-trust": {"text": "Configure the exact GitHub issuer, repository branch/environment subject and api://AzureADTokenExchange audience; wildcards are unsupported and wrong subjects fail at exchange.", "components": ["azure"], "sources": ["azure:sb7a38a36ac47", "azure:sc70c3d4b8844"], "status": "REASONED"}, "azure-action": {"text": "azure/login needs client-id, tenant-id and subscription-id for OIDC, without a stored client secret.", "components": ["azure", "github"], "sources": ["azure:s231245f7e96b", "github:s80d46a4f675c"], "status": "REASONED"}, "subject-format": {"text": "The guide records an immutable default subject with owner/repository IDs for repositories created after July 15, 2026; inspect the actual claim before writing trust.", "components": ["github"], "sources": ["github:s65bfc1e3ee0d"], "status": "REASONED"}, "action-pins": {"text": "Pin actions to full commit SHAs; tags and branches can move to different code.", "components": ["github"], "sources": ["github:s65bfc1e3ee0d", "github:s653a0d94dd2f", "github:sa8ed4c86a8ea", "github:s80d46a4f675c"], "status": "REASONED"}, "workload-attestation": {"text": "SPIFFE/SPIRE provides attested short-lived workload identities without a stored secret.", "components": ["spiffe"], "sources": ["spiffe:s96e9b9e2739d", "spiffe:sfa39616d8260"], "status": "REASONED"}, "secret-store": {"text": "Store unavoidable machine secrets in the listed secret managers. As this guide's advice, authenticate with platform identity where the selected store and workload support it, so access need not depend on another long-lived bootstrap key; otherwise protect the required store credential.", "components": ["azure", "google", "aws", "vault", "infisical", "doppler-cli", "owasp-secrets", "aws-store", "google-store", "azure-store", "openbao-release", "openbao-site", "onepassword", "bitwarden"], "sources": ["azure:sb7a38a36ac47", "google:s03844cd0246a", "aws:s6fff7087bca4", "vault:sc7513679fe7e", "infisical:s725a4ac24a82", "doppler-cli:se354d6243027", "owasp-secrets:s3acb98020188", "aws-store:s81b41c17ad57", "google-store:scd27be211781", "azure-store:seb86b29f4951", "openbao-release:sdb9d182ed195", "openbao-site:s76158fafba22", "onepassword:s3dba9e568d37", "bitwarden:se179d6bd5c04"], "status": "REASONED"}, "verify-scope": {"text": "A protected header-file staging key must receive 401/403 in production while succeeding in staging; otherwise denial does not establish environment scoping.", "components": ["bearer"], "sources": ["bearer:sefd3dd623da3"], "status": "REASONED"}, "verify-expiry": {"text": "Revoked or expired credentials must be rejected with a service-log record identifying the client.", "components": ["bearer", "jwt"], "sources": ["bearer:sefd3dd623da3", "jwt:sc36d79fcad94"], "status": "REASONED"}, "verify-ci": {"text": "Repository Actions secrets should hold no long-lived cloud credentials and the workflow must declare id-token: write.", "components": ["github"], "sources": ["github:s65bfc1e3ee0d", "github:s653a0d94dd2f", "github:sa8ed4c86a8ea", "github:s80d46a4f675c"], "status": "REASONED"}, "verify-trust": {"text": "Inspect AWS sub, Azure subject, and the Google repository binding together with its attribute condition, for exact repository plus branch/environment restriction without broad wildcard suffixes.", "components": ["aws", "google", "azure"], "sources": ["aws:s6fff7087bca4", "google:s03844cd0246a", "google:s0f874cc5b70e", "azure:sc70c3d4b8844"], "status": "REASONED"}, "verify-artifacts": {"text": "History, working-tree and image-layer scans must show no keys; docker history lists an image's history; as this guide's assessment, it is not a contents audit and does not show the contents of files copied into the image, so a separate image-layer scan is required. By source reading without a run, Gitleaks v8.19.0 git and dir are not established, so the Gitleaks scans use hidden detect.", "components": ["bearer", "gitleaks", "docker-build"], "sources": ["bearer:sefd3dd623da3", "gitleaks:s6310bf73dd3e", "gitleaks:s3809c36cec88", "docker-build:s6283b172a5c6", "gitleaks:s6d88a08ac6ba", "gitleaks:sd59d825eae35", "gitleaks:s6bf210c39b05", "gitleaks:s8629863aeff0", "gitleaks:s47abbb4b3ee2", "gitleaks:s4b1376cdb8f0", "gitleaks:s235435e6815e", "gitleaks:s38ee01a95c8c", "gitleaks:s5abcb8b428db", "gitleaks:s8db158cb815b", "gitleaks:s68724a6b5a24", "gitleaks:s913053b5ad1e", "gitleaks:s189c08d2f974", "gitleaks:s4b5d9b2ab7a1", "gitleaks:s8a2e9dc86ccd", "gitleaks:s43b67b6788dd"], "status": "REASONED"}, "verify-image-scan": {"text": "Trivy v0.76.0 documents trivy image with --scanners secret for image files: plaintext under rules/configuration and detectable secrets in compiled Python files. Trivy tries to detect a base image and skip those layers for secret scanning; as this guide's assessment, a secret placed in a base layer may not be reported.", "components": ["trivy"], "sources": ["trivy:sd694528fbbae", "trivy:s5db9e168161d", "trivy:s8468a617a850", "trivy:se5a5b0338769", "trivy:s72ebf18f1e70"], "status": "REASONED"}, "verify-image-rules": {"text": "Trivy v0.76.0 allowed paths, matching allow rules, --skip-dirs and --skip-files can exclude content from secret scanning; trivy-secret.yaml can change the rules.", "components": ["trivy"], "sources": ["trivy:se5a5b0338769", "trivy:s9bc900326c4b", "trivy:s99905622759a", "trivy:s4b6915d2b221", "trivy:se82b81bc0eff", "trivy:s84b474b84678"], "status": "REASONED"}, "verify-image-limits": {"text": "The image-layer scan must show no key; as this guide's assessment, a clean result does not prove absence of keys, and the cited Trivy v0.76.0 pages do not establish detection coverage for this project's key format or behaviour in other releases.", "components": ["trivy"], "sources": ["trivy:sd694528fbbae", "trivy:s5db9e168161d", "trivy:s8468a617a850", "trivy:se5a5b0338769", "trivy:s72ebf18f1e70", "trivy:s9bc900326c4b", "trivy:s99905622759a"], "status": "REASONED"}, "verify-image-config": {"text": "Trivy v0.76.0 image-config misconfiguration and secret scans are disabled by default and enabled with --image-config-scanners; with --image-config-scanners secret, the config is converted to JSON and scanned for secrets, especially useful for environment variables. As this guide's advice, a clean image-file scan does not cover image-config secrets unless --image-config-scanners secret is used.", "components": ["trivy"], "sources": ["trivy:sce50c12b5f76", "trivy:s6bb65bd52e65"], "status": "REASONED"}, "verify-key-coverage": {"text": "As this guide's Python re computation, not Go RE2 or a scanner: no Trivy v0.76.0 built-in rule matched this guide's synthetic API keys bare, assigned or in a Bearer header; Gitleaks v8.19.0 matched them only via generic-api-key after a keyword assignment, missing base64 keys with + or / and stopword hits. By source reading, that rule also needs a digit 1 to 9; other releases are unestablished.", "components": ["trivy", "gitleaks", "drf-api-key"], "sources": ["trivy:se9bb37460ffe", "trivy:s6f857bd4569d", "trivy:sf4ca7afa790f", "trivy:s71fb518185c8", "trivy:s0d1c116b56e3", "trivy:s78e04527854f", "gitleaks:scbf3e990ccaa", "gitleaks:s283e09458143", "gitleaks:s0e91f385f29d", "gitleaks:sa0a5c152f00b", "drf-api-key:s6b7803ec22e6", "gitleaks:s7e8d49968e7b", "gitleaks:s93e2b8afe3e2", "gitleaks:sb475192e7bfd", "gitleaks:s3db5f83b981f"], "status": "REASONED"}, "verify-key-rule": {"text": "As this guide's assessment, Trivy v0.76.0 built-in rules miss this guide's API key formats and Gitleaks v8.19.0 defaults cover them only in part, so add a custom rule: Trivy loads trivy-secret.yaml or --secret-config; by source reading without a run, gitleaks detect needs --config or GITLEAKS_CONFIG. A clean scan does not establish absence of keys.", "components": ["trivy", "gitleaks"], "sources": ["trivy:sc91bfa96859f", "trivy:s82e83a428bf7", "trivy:s8ee72b0d3617", "gitleaks:s2013eedad51e", "gitleaks:s235435e6815e", "gitleaks:s4b1376cdb8f0", "gitleaks:s8629863aeff0", "gitleaks:s47abbb4b3ee2"], "status": "REASONED"}, "verify-key-extend": {"text": "By source reading of the Gitleaks v8.19.0 loader without a run, a config file named with --config or GITLEAKS_CONFIG loads only its own rules unless its extend table sets useDefault = true, which adds the embedded default rules, or path, which adds another file's rules; setting both is a fatal error.", "components": ["gitleaks"], "sources": ["gitleaks:s2013eedad51e", "gitleaks:s4b1376cdb8f0", "gitleaks:s6399241b615e", "gitleaks:s16f2dbd24fac", "gitleaks:s22558f2b4beb", "gitleaks:s3fee5ad8586d", "gitleaks:s85ed52e3f61b", "gitleaks:sba35fcdb9687", "gitleaks:s2f37a953975d"], "status": "REASONED"}, "verify-mtls": {"text": "Reject missing, untrusted, expired, revoked and, where identity authorization applies, unauthorized certificates while the authorized client succeeds.", "components": ["pki"], "sources": ["pki:s93ee2b4751f8"], "status": "REASONED"} } } ```` ====================================================================== ==> fronting-auth.md ====================================================================== # Fronting auth: putting login and MFA in front of an app that has none (oauth2-proxy, Authelia, Pomerium) oauth2-proxy, Authelia and Pomerium are login gateways for apps that lack sign-in, checking every request first. This guide helps your AI assistant set one up securely, including an app reachable only through the proxy, MFA from the identity provider or Authelia itself, and one trusted identity header. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/fronting-auth.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against oauth2-proxy v7.15.6, Authelia v4.39.28, and curl minimum write-out version 7.75.0; 1 further source is pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 34 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Many self-hosted apps (internal tools, dashboards, webhook receivers) ship with no login at all. The fix is the same shape every time: the app binds to loopback, a proxy in front does authentication and MFA, and it passes the app a verified identity, which the app must not accept from anywhere else. This guide is what [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md), and [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) point at. ## 1. The pattern 1. **Bind the app to loopback**, `127.0.0.1`, never `0.0.0.0` or `::`. Bind the auth service so only the fronting proxy reaches it too: loopback when it shares the host, or a private container network with no published port when it does not, never a public interface. The oauth2-proxy `--http-address` option defaults to `127.0.0.1:4180` as of v7.15.6; Authelia's `server.address` defaults to `tcp://:9091/` as of v4.39.28, whose host-and-port, `:9091` with an empty host, Authelia passes to Go's `net.Listen`, which then listens on every local address, so restrict it. If the app also answers directly, the login page in front of it is decorative. 2. **A proxy authenticates first**: oauth2-proxy, Authelia, or Pomerium checks the session before the request reaches the app; an unauthenticated request never gets there. 3. **The proxy passes identity in a header and the app reads that header** instead of running its own login. The header must be one the proxy sets on every request from its own verified response, so the proxy overwrites any value a client sent: nginx `X-User`/`X-Email` in the example below, oauth2-proxy's `X-Auth-Request-User` carried through Traefik, Authelia's `Remote-User`. The app must never read a header the proxy does not set, because nginx and Traefik forward the client's other headers unchanged. 4. **The app must trust only the exact header the proxy sets, and reject any other.** Network isolation, only the proxy can reach the app, stops a direct client from setting the header; but a client can also send a header *through* the proxy, and the proxy overwrites only the one header it sets while forwarding the rest, so the app must read that one header and no other. The managed-cloud version of this same bypass risk is in [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md). ## 2. oauth2-proxy: auth in front of an OIDC/OAuth2 provider Core flags (env vars use the `OAUTH2_PROXY_` prefix): `--provider` (`oidc`, `google`, `github`, and others), `--client-id`/`--client-secret` from the IdP, `--email-domain` (a domain, or `*` for any authenticated user), `--upstream` (the app address, or `static://202` when a forward-auth proxy handles the actual proxying), and `--cookie-secret`. The `--cookie-secret` value must be exactly 16, 24, or 32 bytes, optionally base64-encoded: `openssl rand -base64 32 | tr -- '+/' '-_'`. nginx uses `auth_request`, which requires oauth2-proxy's `--reverse-proxy` flag; forwarding the identity headers below also requires `--set-xauthrequest` (it makes oauth2-proxy set `X-Auth-Request-User` and `X-Auth-Request-Email` on its own `/oauth2/auth` response, which the `auth_request_set` lines then read): ```nginx location /oauth2/ { proxy_pass http://127.0.0.1:4180; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Auth-Request-Redirect $request_uri; } location = /oauth2/auth { proxy_pass http://127.0.0.1:4180; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header Content-Length ""; proxy_pass_request_body off; } location / { auth_request /oauth2/auth; error_page 401 = @oauth2_signin; auth_request_set $user $upstream_http_x_auth_request_user; auth_request_set $email $upstream_http_x_auth_request_email; proxy_set_header X-User $user; proxy_set_header X-Email $email; proxy_pass http://127.0.0.1:3000; } location @oauth2_signin { return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri; } ``` Traefik uses a `forwardAuth` middleware at oauth2-proxy's `/oauth2/auth`, with `--upstream=static://202`, `--reverse-proxy=true`, and `--set-xauthrequest` (so oauth2-proxy sets `X-Auth-Request-User`/`X-Auth-Request-Email` on its `/oauth2/auth` response) set on oauth2-proxy. `authResponseHeaders` must list every identity header the app trusts, because Traefik copies only those from the auth response onto the request (replacing any the client sent) and forwards the client's other headers untouched: ```yaml http: middlewares: oauth-auth: forwardAuth: address: "http://oauth2-proxy:4180/oauth2/auth" trustForwardHeader: true authResponseHeaders: [X-Auth-Request-User, X-Auth-Request-Email, X-Auth-Request-Access-Token, Authorization] ``` Attach the middleware to every router that serves the protected app: Traefik applies a middleware only where a router references it, so add `middlewares: [oauth-auth]` on the router in the file provider, or the label `traefik.http.routers..middlewares=oauth-auth@file`. A middleware that is defined but never referenced protects nothing. Both examples run oauth2-proxy with `--reverse-proxy`, so also set `--trusted-proxy-ip` to the address the fronting proxy connects from. The [v7.15.6 nginx integration](https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/integrations/nginx.md#L18-L21) says to configure `--trusted-proxy-ip=127.0.0.1/32` when Nginx connects from localhost, which fits the nginx example above, since it proxies to `127.0.0.1:4180` (this guide's inference). For the Traefik example, use the address Traefik connects from on the container network (this guide's inference; the Traefik integration page does not name `--trusted-proxy-ip`). The v7.15.6 configuration overview warns that if `--trusted-proxy-ip` is left unset, oauth2-proxy trusts all source IPs, so a client that can reach it directly may be able to spoof forwarded headers. The [v7.15.5 release notes](https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.5) list 2 critical advisories whose fixes concern settings this section uses. GHSA-wr5q-7wxw-x568, "Authentication bypass via spoofed client-IP headers in OAuth2 Proxy", is fixed so that, when `--reverse-proxy` is enabled, client-IP headers configured via `--real-client-ip-header` are accepted only from direct peers that match `--trusted-proxy-ip`. GHSA-63jm-59jj-478j, "Authentication bypass via inconsistent skip-auth path interpretation", is fixed by stricter `--skip-auth-route` and `--skip-auth-regex` matching, and the notes add that trusted `X-Forwarded-Uri` metadata, which the nginx example sets, must use origin form (`/path?query`). Both fixes are listed under v7.15.5, so releases before it lack them (this guide's inference from the release notes; the advisory pages, with their affected ranges, were not read), and this guide's oauth2-proxy basis is v7.15.6. This guide's configuration sets no `--trusted-ip` and no skip-auth rule; if you add either, review it against those notes. The same notes list a moderate advisory, GHSA-hhqp-vx7f-5c6m, "Credential disclosure through OAuth callback error logging", with no further detail, so whether it affects a given deployment is not established here. The v7.15.5 and [v7.15.6 release notes](https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.6) also list 4 and 18 CVE identifiers under "Vulnerabilities have been addressed", without titles or detail, so whether any bears on this guide's configuration is not established. ## 3. Authelia: a portal that does the second factor Authelia is a login portal with built-in TOTP and WebAuthn, sitting behind the proxy rather than replacing it. Per its support page, nginx, Traefik, Caddy (2.5.1+), HAProxy (via a Lua module), Envoy, Skipper, NGINX Proxy Manager, and SWAG are supported; Apache and IIS are documented as having no compatible module and are not supported. nginx calls a dedicated `auth-request` endpoint (its `auth_request` module cannot forward the method and body the way the others' forward-auth middlewares do). That endpoint must be defined; Authelia's own snippets (`authelia-location.conf` and `authelia-authrequest.conf`) show it as: ```nginx resolver 127.0.0.11 valid=30s; set $upstream_authelia http://authelia:9091/api/authz/auth-request; location /internal/authelia/authz { internal; proxy_pass $upstream_authelia; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$host$request_uri; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Content-Length ""; proxy_set_header Connection ""; proxy_pass_request_body off; } # in the protected location block: auth_request /internal/authelia/authz; auth_request_set $user $upstream_http_remote_user; auth_request_set $groups $upstream_http_remote_groups; auth_request_set $name $upstream_http_remote_name; auth_request_set $email $upstream_http_remote_email; proxy_set_header Remote-User $user; proxy_set_header Remote-Groups $groups; proxy_set_header Remote-Name $name; proxy_set_header Remote-Email $email; auth_request_set $redirection_url $upstream_http_location; error_page 401 =302 $redirection_url; ``` nginx needs a way to resolve the `authelia` hostname at request time since `proxy_pass` here targets a variable rather than a static address, so the `resolver` line above (or a matching `upstream` block) is required; Authelia's nginx integration assumes a Docker DNS resolver is available for this, per Authelia's nginx integration guide. The `auth_request_set`/`proxy_set_header Remote-*` pairs set the app's identity headers from Authelia's response and, because nginx forwards client request headers to the upstream by default, overwrite any `Remote-User` a caller tried to send directly; without them, and without the network isolation from section 1, a client that reached the app could assert `Remote-User: admin` itself. The Traefik and Caddy examples below carry the same identity headers through `authResponseHeaders` and `copy_headers`. Traefik and Caddy call `/api/authz/forward-auth` instead: ```yaml - traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/authz/forward-auth - traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Email,Remote-Name ``` ```caddyfile forward_auth authelia:9091 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Email Remote-Name } ``` Authelia needs its own random session secret and access-control rules (which paths need `one_factor` vs `two_factor`); its second factor is TOTP, WebAuthn/passkeys, or Duo mobile push. ## 4. Pomerium: the proxy is the access layer Pomerium is an identity-aware proxy rather than a sidecar to nginx: it terminates the connection, authenticates the user, and enforces policy in one process. An IdP goes under `idp_provider`, `idp_provider_url`, `idp_client_id`, and `idp_client_secret`; each app is a route carrying its own `policy` (which users, domains, or claims may reach it), rather than a shared middleware bolted onto an existing proxy. Choose Pomerium over the two above when you want routing, TLS, and access control in one process instead of an auth check layered in front of nginx, Traefik, or Caddy. ## 5. MFA and identity source oauth2-proxy's MFA is whatever its OIDC/OAuth provider enforces; Pomerium's is whatever its `idp_provider` enforces; only Authelia enforces a second factor itself. See [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) (enrolment is not enforcement) and [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) for which hosted providers' tiers include MFA. ## Verify REASONED: the cited oauth2-proxy and Authelia listener defaults support this host-side inventory, which is prose rather than part of the block below, because it runs on the proxy's host and the block runs from another host. On the proxy's host, run `ss -tlnp` and read every listener: app 3000, oauth2-proxy 4180 and Authelia 9091 reachable only by the proxy (loopback, or a private container network), never a public interface. The authoring environment cannot provision the proxy, backend, IdP and external probe host, so no listing is recorded. REASONED: following block; the cited oauth2-proxy/Authelia integrations, pinned listener defaults and curl manual define isolation, direct-header refusal and anonymous challenges. The authoring environment cannot provision the proxy, backend, IdP and external probe host; no live outcome is recorded. Expected responses and inconclusive failures are stated below. Listener isolation, anonymous challenges and direct and through-proxy identity-header checks follow the guide's cited oauth2-proxy nginx integration, oauth2-proxy Traefik integration, Traefik forwardAuth middleware and Authelia nginx integration. ShellCheck 0.11.0 and bash -n exited 0 without diagnostics on this block in an earlier form, before its anonymous-challenge curl took the app's host on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains; neither tool was re-run on that guarded form, and its guard was not run. Both YAML blocks parsed. Five rejected cases of the public-address guard printed refusal messages with no curl calls; a substituted address reached four curl stubs. The bash -n and ShellCheck runs recorded up to this point covered this fence and the stub runs its public-address block, all in an earlier form, whose guard refusals left the subshell with status 0; none was re-run on the form after that guarded form, whose guard refusals first left the subshell with status 2. The current form leaves the listener inventory to the section's first paragraph and gives the whole block one exit status. It is 1 if port 3000, 4180 or 9091 or the forged-header probe answers with an HTTP code; 3 if one of those probes ends without an HTTP code, a refusal or a timeout, or if the anonymous request fails or answers anything but a 401 or a 302 with a Location; 2 if a guard refuses and nothing was found or failed; and otherwise 0. The anonymous request passes on a 401, or on a 302 to `/oauth2/sign_in`, with or without a query, given as a relative path or on the probed host over https. A refusal or timeout, and a 302 with any other Location, are left for you to judge: err must name YOUR address, and that Location passes only if it is your sign-in portal. Checks on 2026-10-31, with bash -n and with curl stubs and no network, covered the earlier form of the block, in which the anonymous-challenge part lowercased the host with `tr` before refusing a reserved name; they covered each status, an earlier finding or failure followed by passing later checks, and an inherited `readonly`, `declare -i` or `set -e`. The stub runs covered the refusals of a missing marker, too few or too many values, and an empty or placeholder value in each part, and in the second part of a host name with a scheme, port, trailing dot, `@`, `?`, `%`, `#` or space and of an example.com, example.net or example.org host including `App.Example.COM`; each refused part made no curl call. In those runs a 302 to `/oauth2/sign_in` on another host or over http printed an unclassified line and exited 0, and a 302 to the nginx example's `/oauth2/sign_in?rd=` target exited 0 with no such line. ShellCheck 0.11.0 passed on that form in the repository's gate suite. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and its anonymous-challenge part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs on 2026-11-01 with no network. In bash, that part refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probe. Under dash, that part refused every host name with 2 and the bash message before its probe, and the whole block exited 3, because dash reads the public-address part's `$'\n'` differently and that part reported an inconclusive result. No live outcome is observed: exposed access reaches a backend or trusts admin; fixed access isolates listeners, challenges anonymous users and admits a valid session under its real identity. After a real login through the proxy, confirm a session reaches the app and the app-side log shows the identity header the proxy set, not the forged one in the block. The direct forged header on the block's probe is refused at the network level, because the app is reachable only through the proxy. A header sent *through* the proxy is the separate risk: it is defeated only because the proxy overwrites the exact header the app trusts (nginx `X-User`/`X-Email`, the Traefik `authResponseHeaders`, Authelia's `Remote-*`) with its verified value. Confirm that overwrite: with a valid session, replay a request through the proxy that adds the trusted header (`-H 'X-User: admin'` for the nginx example, `-H 'X-Auth-Request-User: admin'` for the Traefik example, `-H 'Remote-User: admin'` for Authelia) and check the app-side log still shows your real identity, not `admin`. If it shows `admin`, the proxy is not overwriting that header and any authenticated user can impersonate anyone. ```bash # Run this block from another host: app 3000, oauth2-proxy 4180 and Authelia 9091 must each be unreachable from it. Read err, not the number: it must name a refusal or timeout reaching # YOUR address. An HTTP code means the app answered. A resolver failure, a local socket error, or a # timeout that did not come from the remote address is inconclusive, never a pass. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( # a subshell, so your own script arguments are untouched set +e # expected refusals must not skip later probes under an inherited set -e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above # app 3000, oauth2-proxy 4180, Authelia 9091: each must be unreachable from another host set -- "$1" "$2" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 -w 'port=3000 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:3000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its port= http= exit= err= line printf '%s\n' "$4" case "$3:$4" in 7:*"http=000 "*|28:*"http=000 "*) echo "port 3000: unclassified, read err above; a refusal or timeout reaching YOUR address is the pass, and any other cause is inconclusive" ;; *"http=000 "*|*:) echo "port 3000: curl exit $3 with no HTTP code and no refusal or timeout; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *"http="*) echo "FINDING: port 3000 answered with an HTTP code, so the app is reachable from this host"; set -- "$1" 1 ;; *) echo "port 3000: curl exit $3 printed no http= line; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 -w 'port=4180 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:4180/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its port= http= exit= err= line printf '%s\n' "$4" case "$3:$4" in 7:*"http=000 "*|28:*"http=000 "*) echo "port 4180: unclassified, read err above; a refusal or timeout reaching YOUR address is the pass, and any other cause is inconclusive" ;; *"http=000 "*|*:) echo "port 4180: curl exit $3 with no HTTP code and no refusal or timeout; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *"http="*) echo "FINDING: port 4180 answered with an HTTP code, so oauth2-proxy is reachable from this host"; set -- "$1" 1 ;; *) echo "port 4180: curl exit $3 printed no http= line; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 -w 'port=9091 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:9091/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its port= http= exit= err= line printf '%s\n' "$4" case "$3:$4" in 7:*"http=000 "*|28:*"http=000 "*) echo "port 9091: unclassified, read err above; a refusal or timeout reaching YOUR address is the pass, and any other cause is inconclusive" ;; *"http=000 "*|*:) echo "port 9091: curl exit $3 with no HTTP code and no refusal or timeout; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *"http="*) echo "FINDING: port 9091 answered with an HTTP code, so Authelia is reachable from this host"; set -- "$1" 1 ;; *) echo "port 9091: curl exit $3 printed no http= line; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' -H 'X-User: admin' "http://$1:3000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # forged header the nginx app trusts, straight at the app set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its http= exit= err= line printf '%s\n' "$4" case "$3:$4" in 7:*"http=000 "*|28:*"http=000 "*) echo "forged header: unclassified, read err above; a refusal or timeout reaching YOUR address is the pass, and any other cause is inconclusive" ;; *"http=000 "*|*:) echo "forged header: curl exit $3 with no HTTP code and no refusal or timeout; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *"http="*) echo "FINDING: the forged X-User request reached port 3000 and got an HTTP code, so network isolation does not refuse it"; set -- "$1" 1 ;; *) echo "forged header: curl exit $3 printed no http= line; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your auth proxy serves the app on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is this part's status so far: 3 (inconclusive) outranks 0 set -- "$1" "$2" "$(if curl -q -g -sI -w '\n%{http_code} %{redirect_url}' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # no session: a 401, or a 302 whose Location is the sign-in page (/oauth2/sign_in or the portal); any other 302 is not a pass set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the headers with the HTTP code and redirect target on the last line set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the headers, then the HTTP code and redirect target printf '%s' "$4" case "$3:$5" in "0:401 "*|"0:302 https://$1/oauth2/sign_in"|"0:302 https://$1/oauth2/sign_in?"*|"0:302 /oauth2/sign_in"|"0:302 /oauth2/sign_in?"*) ;; "0:302 ") echo "a 302 with no Location is not a pass; inconclusive"; set -- "$1" 3 ;; "0:302 "*) echo "unclassified: the 302 goes to ${5#* }, not /oauth2/sign_in; it passes only if that is your sign-in portal, and any other 302 is not a pass" ;; 0:*) echo "HTTP ${5%% *} without a session is neither a 401 nor a 302 to the sign-in page; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $3: the request without a session failed; inconclusive"; set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; a refusal or timeout on ports 3000, 4180 and 9091 and on the forged-header probe, and a 302 to a location other than /oauth2/sign_in, are not classified, so read err and the Location yourself" exit "$1" ) ``` ## Common mistakes - The app also listens on a public interface next to the proxy, so a direct request skips authentication entirely. - The app trusts `X-Auth-Request-User` or `Remote-User` from any caller, not only from the proxy. - oauth2-proxy's `--cookie-secret` reused across environments or committed to the repository. - Deploying Authelia behind Apache or IIS, which it does not support. ## Sources (checked September 2026) - oauth2-proxy configuration overview, flags, cookie-secret length and the `--trusted-proxy-ip` warning (pinned tag v7.15.6, checked November 2026): https://raw.githubusercontent.com/oauth2-proxy/oauth2-proxy/v7.15.6/docs/docs/configuration/overview.md - oauth2-proxy nginx integration (rolling documentation, checked September 2026): https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/ - oauth2-proxy Traefik integration (pinned tag v7.15.6, checked November 2026): https://raw.githubusercontent.com/oauth2-proxy/oauth2-proxy/v7.15.6/docs/docs/configuration/integrations/traefik.md - oauth2-proxy nginx integration, `--trusted-proxy-ip=127.0.0.1/32` when Nginx connects from localhost (pinned tag v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/integrations/nginx.md#L18-L21 - oauth2-proxy v7.15.5 release notes, security advisories GHSA-wr5q-7wxw-x568, GHSA-63jm-59jj-478j and GHSA-hhqp-vx7f-5c6m with the critical fixes' notes, and 4 CVE identifiers (read for the v7.15.6 basis, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.5 - oauth2-proxy v7.15.6 release notes, 18 CVE identifiers and the changes since v7.15.5 (pinned tag v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.6 - Traefik forwardAuth middleware (authResponseHeaders replaces only the listed headers; others pass through): https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/forwardauth/ - Authelia proxy integration introduction (rolling documentation, checked September 2026): https://www.authelia.com/integration/proxies/introduction/ - Authelia proxy support matrix, Apache and IIS unsupported; Caddy support minimum (rolling documentation, checked September 2026): https://www.authelia.com/integration/proxies/support/ - Authelia nginx integration (rolling documentation, checked September 2026): https://www.authelia.com/integration/proxies/nginx/ - Authelia Traefik integration (rolling documentation, checked September 2026): https://www.authelia.com/integration/proxies/traefik/ - Authelia Caddy integration (rolling documentation, checked September 2026): https://www.authelia.com/integration/proxies/caddy/ - Authelia second-factor introduction (rolling documentation, checked September 2026): https://www.authelia.com/configuration/second-factor/introduction/ - Pomerium identity provider settings: https://www.pomerium.com/docs/reference/identity-provider-settings - Pomerium documentation: https://www.pomerium.com/docs - oauth2-proxy `--http-address` default `127.0.0.1:4180` (pinned tag v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/pkg/apis/options/legacy_options.go#L494 - Authelia `server.address` default `tcp://:9091/` (pinned tag v4.39.28): https://github.com/authelia/authelia/blob/v4.39.28/internal/configuration/schema/server.go#L90-L92 - Authelia listens with `net.Listen(a.Network(), a.NetworkAddress())` (`types_addresses_nix.go` L37), where `NetworkAddress()` returns the URL's host and port, `:9091` for the default (`types_address.go` L480) (pinned tag v4.39.28): https://github.com/authelia/authelia/blob/v4.39.28/internal/configuration/schema/types_addresses_nix.go#L37 - Go `net.Listen` with an empty host listens on all available unicast and anycast addresses of the local system: https://pkg.go.dev/net#Listen - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0): https://curl.se/docs/manpage.html - Authelia v4.39.28 random session secret (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L78-L82 - Authelia v4.39.28 path-matching resources (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L102 - Authelia v4.39.28 one_factor and two_factor policies (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L527-L537 - Pomerium route authorization policy (pinned commit 691714e2f0ae74fab47412275e8b64b68acdc0d3, checked October 2026): https://github.com/pomerium/documentation/blob/691714e2f0ae74fab47412275e8b64b68acdc0d3/content/docs/reference/routes/policy.mdx#L20 - Pomerium domain and user policy examples (pinned commit 691714e2f0ae74fab47412275e8b64b68acdc0d3, checked October 2026): https://github.com/pomerium/documentation/blob/691714e2f0ae74fab47412275e8b64b68acdc0d3/content/docs/internals/ppl.mdx#L50-L52 - Pomerium domain and group-claim policy (pinned commit 691714e2f0ae74fab47412275e8b64b68acdc0d3, checked October 2026): https://github.com/pomerium/documentation/blob/691714e2f0ae74fab47412275e8b64b68acdc0d3/content/docs/internals/ppl.mdx#L90 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - oauth: oauth2-proxy (v7.15.6) - authelia: Authelia (v4.39.28) - oauth-rolling: oauth2-proxy documentation (rolling) (unknown) - authelia-rolling: Authelia documentation (rolling) (unknown) - pomerium: Pomerium (unknown) - pomerium-docs: Pomerium policy documentation (691714e2f0ae74fab47412275e8b64b68acdc0d3) - traefik: Traefik (unknown) - go: Go net.Listen (unknown) - curl: curl minimum write-out version (7.75.0) Claims (full source mapping in the JSON block below): - isolate (REASONED; oauth, oauth-rolling, authelia-rolling): Bind app and auth service to loopback or an unpublished private container network; only the fronting proxy may reach them. - oauth-bind (REASONED; oauth): oauth2-proxy --http-address defaults to 127.0.0.1:4180. - authelia-bind (REASONED; authelia, go): Authelia server.address defaults tcp://:9091/; its empty host reaches Go net.Listen and binds every local address. - authenticate (REASONED; pomerium, oauth-rolling, authelia-rolling): oauth2-proxy, Authelia or Pomerium must check the session before forwarding to the app. - header-trust (REASONED; oauth, traefik, oauth-rolling, authelia-rolling): The app must trust only headers overwritten from verified proxy responses; other client headers pass through, so isolation alone is insufficient. - oauth-provider (REASONED; oauth): OAUTH2_PROXY_ environment variables map core flags; configure provider and IdP client-id/client-secret. - oauth-domain (REASONED; oauth): --email-domain restricts a domain; * admits any authenticated user. - oauth-upstream (REASONED; oauth): --upstream targets the app, or static://202 when another proxy handles forwarding. - oauth-cookie (REASONED; oauth): --cookie-secret must be 16, 24 or 32 bytes, optionally base64-encoded; generate randomly and do not reuse or commit it. - oauth-nginx (REASONED; oauth-rolling): nginx auth_request at /oauth2/auth needs --reverse-proxy; --set-xauthrequest supplies User/Email response headers. - oauth-nginx-identity (REASONED; oauth-rolling): nginx maps verified X-Auth-Request-User/Email to X-User/Email before forwarding to 127.0.0.1:3000. - oauth-signin (REASONED; oauth-rolling): The nginx example forwards /oauth2/ to 127.0.0.1:4180 and redirects auth 401 to /oauth2/sign_in with the return URL. - oauth-traefik (REASONED; oauth): Traefik forwardAuth calls oauth2-proxy:4180/oauth2/auth with static://202, reverse-proxy and set-xauthrequest enabled. - traefik-headers (REASONED; traefik, oauth): authResponseHeaders must include every trusted identity header; only those replace client values, with other headers forwarded unchanged. - traefik-attach (REASONED; oauth, traefik): Attach oauth-auth to every protected router; defining middleware without a router reference protects nothing. - oauth-trusted-proxy (REASONED; oauth): With --reverse-proxy, set --trusted-proxy-ip to the fronting proxy's address, 127.0.0.1/32 for a localhost nginx; left unset, oauth2-proxy trusts all source IPs and a client reaching it directly may spoof forwarded headers. - oauth-advisories (REASONED; oauth): The v7.15.5 notes list critical fixes GHSA-wr5q-7wxw-x568 (client-IP headers under --reverse-proxy) and GHSA-63jm-59jj-478j (skip-auth paths, X-Forwarded-Uri origin form), which earlier releases lack, by inference, and moderate GHSA-hhqp-vx7f-5c6m, whose effect is not established. Basis: v7.15.6. - authelia-support (REASONED; authelia-rolling): Authelia supports the listed nginx, Traefik, Caddy, HAProxy/Lua, Envoy, Skipper, NGINX Proxy Manager and SWAG integrations, not Apache/IIS. - caddy-min (REASONED; authelia-rolling): Authelia's documented Caddy support requires 2.5.1+. - authelia-nginx (REASONED; authelia-rolling): nginx uses the dedicated /api/authz/auth-request endpoint through an internal location with original method/URL and no request body. - authelia-dns (REASONED; authelia-rolling): Variable proxy_pass needs runtime DNS resolution or a matching upstream; the shown 127.0.0.11 resolver assumes Docker DNS. - authelia-identity (REASONED; authelia-rolling): nginx copies verified Remote-User/Groups/Name/Email and uses the auth Location for 401 redirection; trusted client headers must be overwritten. - authelia-traefik (REASONED; authelia-rolling): Traefik calls authelia:9091/api/authz/forward-auth and copies the four Remote-* identity headers. - authelia-caddy (REASONED; authelia-rolling): Caddy forward_auth calls authelia:9091 with /api/authz/forward-auth and copies the four Remote-* identity headers. - authelia-session (REASONED; authelia-rolling, authelia): Authelia needs a random session secret and path access rules selecting one_factor or two_factor. - authelia-mfa (REASONED; authelia-rolling): Authelia enforces TOTP, WebAuthn/passkeys or Duo push itself. - pomerium-idp (REASONED; pomerium): Pomerium combines routing, TLS and access control; configure idp_provider, idp_provider_url, idp_client_id and idp_client_secret. - pomerium-policy (REASONED; pomerium, pomerium-docs): Each Pomerium route carries policy restricting users, domains or claims. - idp-mfa (REASONED; oauth, pomerium): oauth2-proxy and Pomerium rely on MFA enforced by their identity provider. - verify-listeners (REASONED; oauth, authelia): Inspect every listener: app 3000, oauth2-proxy 4180 and Authelia 9091 must be reachable only by the proxy. - verify-direct (REASONED; curl, oauth-rolling, authelia-rolling): From another host, all three ports must refuse/time out at the actual address; HTTP proves exposure and local/resolver failures are inconclusive. - verify-forged-direct (REASONED; curl, oauth-rolling): A forged X-User sent directly to app port 3000 must be refused by network isolation. - verify-login (REASONED; oauth-rolling, authelia-rolling): Without a session, expect 401 or a 302 to the sign-in page; other redirects do not pass. A real session must reach the app. - verify-overwrite (REASONED; oauth, traefik, oauth-rolling, authelia-rolling): With a valid session, replay a forged trusted identity header through each proxy; the app log must show the real identity, not admin. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "f3864ccb3882a8572a5802bd328723e49e7c49a32f3bff4a48184c7be4e090c5", "components": { "oauth": { "name": "oauth2-proxy", "basis": "v7.15.6", "sources": { "s4e4893506224": "https://raw.githubusercontent.com/oauth2-proxy/oauth2-proxy/v7.15.6/docs/docs/configuration/overview.md", "s259af7bc0946": "https://raw.githubusercontent.com/oauth2-proxy/oauth2-proxy/v7.15.6/docs/docs/configuration/integrations/traefik.md", "se3815f92bd1c": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/pkg/apis/options/legacy_options.go#L494", "s54abafae25c5": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/integrations/nginx.md#L18-L21", "s85b199e72a39": "https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.5", "s7acbcc639708": "https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.6" } }, "authelia": { "name": "Authelia", "basis": "v4.39.28", "sources": { "s064d1e72c1e8": "https://github.com/authelia/authelia/blob/v4.39.28/internal/configuration/schema/server.go#L90-L92", "sbec86d37ea1c": "https://github.com/authelia/authelia/blob/v4.39.28/internal/configuration/schema/types_addresses_nix.go#L37", "sec3688bec026": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L78-L82", "s91886e5dca2c": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L102", "sf170ee48ce0d": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L527-L537" } }, "oauth-rolling": { "name": "oauth2-proxy documentation (rolling)", "basis": "unknown", "sources": { "sdcd74a9cd9c6": "https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/" } }, "authelia-rolling": { "name": "Authelia documentation (rolling)", "basis": "unknown", "sources": { "s0cb4f49871b5": "https://www.authelia.com/integration/proxies/introduction/", "s1409ac8fe3d9": "https://www.authelia.com/integration/proxies/support/", "s4af41b2fed9f": "https://www.authelia.com/integration/proxies/nginx/", "sfb8ce161d56a": "https://www.authelia.com/integration/proxies/traefik/", "sd5437cbc4328": "https://www.authelia.com/integration/proxies/caddy/", "scd46ff77c737": "https://www.authelia.com/configuration/second-factor/introduction/" } }, "pomerium": { "name": "Pomerium", "basis": "unknown", "sources": { "s181907d16ed1": "https://www.pomerium.com/docs/reference/identity-provider-settings", "s4ed306cb118e": "https://www.pomerium.com/docs" } }, "pomerium-docs": { "name": "Pomerium policy documentation", "basis": "691714e2f0ae74fab47412275e8b64b68acdc0d3", "sources": { "s18dd6f6efcc6": "https://github.com/pomerium/documentation/blob/691714e2f0ae74fab47412275e8b64b68acdc0d3/content/docs/reference/routes/policy.mdx#L20", "s69a7e15c61ca": "https://github.com/pomerium/documentation/blob/691714e2f0ae74fab47412275e8b64b68acdc0d3/content/docs/internals/ppl.mdx#L50-L52", "s84633400030e": "https://github.com/pomerium/documentation/blob/691714e2f0ae74fab47412275e8b64b68acdc0d3/content/docs/internals/ppl.mdx#L90" } }, "traefik": { "name": "Traefik", "basis": "unknown", "sources": { "s7f6f6200de1f": "https://doc.traefik.io/traefik/reference/routing-configuration/http/middlewares/forwardauth/" } }, "go": { "name": "Go net.Listen", "basis": "unknown", "sources": { "s37204ff1b27c": "https://pkg.go.dev/net#Listen" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "isolate": {"text": "Bind app and auth service to loopback or an unpublished private container network; only the fronting proxy may reach them.", "components": ["oauth", "oauth-rolling", "authelia-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "oauth:s259af7bc0946", "authelia-rolling:s0cb4f49871b5"], "status": "REASONED"}, "oauth-bind": {"text": "oauth2-proxy --http-address defaults to 127.0.0.1:4180.", "components": ["oauth"], "sources": ["oauth:se3815f92bd1c"], "status": "REASONED"}, "authelia-bind": {"text": "Authelia server.address defaults tcp://:9091/; its empty host reaches Go net.Listen and binds every local address.", "components": ["authelia", "go"], "sources": ["authelia:s064d1e72c1e8", "authelia:sbec86d37ea1c", "go:s37204ff1b27c"], "status": "REASONED"}, "authenticate": {"text": "oauth2-proxy, Authelia or Pomerium must check the session before forwarding to the app.", "components": ["pomerium", "oauth-rolling", "authelia-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "authelia-rolling:s0cb4f49871b5", "pomerium:s4ed306cb118e"], "status": "REASONED"}, "header-trust": {"text": "The app must trust only headers overwritten from verified proxy responses; other client headers pass through, so isolation alone is insufficient.", "components": ["oauth", "traefik", "oauth-rolling", "authelia-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "oauth:s259af7bc0946", "authelia-rolling:s4af41b2fed9f", "traefik:s7f6f6200de1f"], "status": "REASONED"}, "oauth-provider": {"text": "OAUTH2_PROXY_ environment variables map core flags; configure provider and IdP client-id/client-secret.", "components": ["oauth"], "sources": ["oauth:s4e4893506224"], "status": "REASONED"}, "oauth-domain": {"text": "--email-domain restricts a domain; * admits any authenticated user.", "components": ["oauth"], "sources": ["oauth:s4e4893506224"], "status": "REASONED"}, "oauth-upstream": {"text": "--upstream targets the app, or static://202 when another proxy handles forwarding.", "components": ["oauth"], "sources": ["oauth:s4e4893506224", "oauth:s259af7bc0946"], "status": "REASONED"}, "oauth-cookie": {"text": "--cookie-secret must be 16, 24 or 32 bytes, optionally base64-encoded; generate randomly and do not reuse or commit it.", "components": ["oauth"], "sources": ["oauth:s4e4893506224"], "status": "REASONED"}, "oauth-nginx": {"text": "nginx auth_request at /oauth2/auth needs --reverse-proxy; --set-xauthrequest supplies User/Email response headers.", "components": ["oauth-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6"], "status": "REASONED"}, "oauth-nginx-identity": {"text": "nginx maps verified X-Auth-Request-User/Email to X-User/Email before forwarding to 127.0.0.1:3000.", "components": ["oauth-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6"], "status": "REASONED"}, "oauth-signin": {"text": "The nginx example forwards /oauth2/ to 127.0.0.1:4180 and redirects auth 401 to /oauth2/sign_in with the return URL.", "components": ["oauth-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6"], "status": "REASONED"}, "oauth-traefik": {"text": "Traefik forwardAuth calls oauth2-proxy:4180/oauth2/auth with static://202, reverse-proxy and set-xauthrequest enabled.", "components": ["oauth"], "sources": ["oauth:s259af7bc0946", "oauth:s4e4893506224"], "status": "REASONED"}, "traefik-headers": {"text": "authResponseHeaders must include every trusted identity header; only those replace client values, with other headers forwarded unchanged.", "components": ["traefik", "oauth"], "sources": ["traefik:s7f6f6200de1f", "oauth:s259af7bc0946"], "status": "REASONED"}, "traefik-attach": {"text": "Attach oauth-auth to every protected router; defining middleware without a router reference protects nothing.", "components": ["oauth", "traefik"], "sources": ["oauth:s259af7bc0946", "traefik:s7f6f6200de1f"], "status": "REASONED"}, "oauth-trusted-proxy": {"text": "With --reverse-proxy, set --trusted-proxy-ip to the fronting proxy's address, 127.0.0.1/32 for a localhost nginx; left unset, oauth2-proxy trusts all source IPs and a client reaching it directly may spoof forwarded headers.", "components": ["oauth"], "sources": ["oauth:s54abafae25c5", "oauth:s4e4893506224"], "status": "REASONED"}, "oauth-advisories": {"text": "The v7.15.5 notes list critical fixes GHSA-wr5q-7wxw-x568 (client-IP headers under --reverse-proxy) and GHSA-63jm-59jj-478j (skip-auth paths, X-Forwarded-Uri origin form), which earlier releases lack, by inference, and moderate GHSA-hhqp-vx7f-5c6m, whose effect is not established. Basis: v7.15.6.", "components": ["oauth"], "sources": ["oauth:s85b199e72a39", "oauth:s7acbcc639708"], "status": "REASONED"}, "authelia-support": {"text": "Authelia supports the listed nginx, Traefik, Caddy, HAProxy/Lua, Envoy, Skipper, NGINX Proxy Manager and SWAG integrations, not Apache/IIS.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:s1409ac8fe3d9"], "status": "REASONED"}, "caddy-min": {"text": "Authelia's documented Caddy support requires 2.5.1+.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:s1409ac8fe3d9"], "status": "REASONED"}, "authelia-nginx": {"text": "nginx uses the dedicated /api/authz/auth-request endpoint through an internal location with original method/URL and no request body.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:s4af41b2fed9f"], "status": "REASONED"}, "authelia-dns": {"text": "Variable proxy_pass needs runtime DNS resolution or a matching upstream; the shown 127.0.0.11 resolver assumes Docker DNS.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:s4af41b2fed9f"], "status": "REASONED"}, "authelia-identity": {"text": "nginx copies verified Remote-User/Groups/Name/Email and uses the auth Location for 401 redirection; trusted client headers must be overwritten.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:s4af41b2fed9f"], "status": "REASONED"}, "authelia-traefik": {"text": "Traefik calls authelia:9091/api/authz/forward-auth and copies the four Remote-* identity headers.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:sfb8ce161d56a"], "status": "REASONED"}, "authelia-caddy": {"text": "Caddy forward_auth calls authelia:9091 with /api/authz/forward-auth and copies the four Remote-* identity headers.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:sd5437cbc4328"], "status": "REASONED"}, "authelia-session": {"text": "Authelia needs a random session secret and path access rules selecting one_factor or two_factor.", "components": ["authelia-rolling", "authelia"], "sources": ["authelia-rolling:s0cb4f49871b5", "authelia-rolling:s4af41b2fed9f", "authelia:sec3688bec026", "authelia:s91886e5dca2c", "authelia:sf170ee48ce0d"], "status": "REASONED"}, "authelia-mfa": {"text": "Authelia enforces TOTP, WebAuthn/passkeys or Duo push itself.", "components": ["authelia-rolling"], "sources": ["authelia-rolling:scd46ff77c737"], "status": "REASONED"}, "pomerium-idp": {"text": "Pomerium combines routing, TLS and access control; configure idp_provider, idp_provider_url, idp_client_id and idp_client_secret.", "components": ["pomerium"], "sources": ["pomerium:s181907d16ed1", "pomerium:s4ed306cb118e"], "status": "REASONED"}, "pomerium-policy": {"text": "Each Pomerium route carries policy restricting users, domains or claims.", "components": ["pomerium", "pomerium-docs"], "sources": ["pomerium:s4ed306cb118e", "pomerium-docs:s18dd6f6efcc6", "pomerium-docs:s69a7e15c61ca", "pomerium-docs:s84633400030e"], "status": "REASONED"}, "idp-mfa": {"text": "oauth2-proxy and Pomerium rely on MFA enforced by their identity provider.", "components": ["oauth", "pomerium"], "sources": ["oauth:s4e4893506224", "pomerium:s181907d16ed1"], "status": "REASONED"}, "verify-listeners": {"text": "Inspect every listener: app 3000, oauth2-proxy 4180 and Authelia 9091 must be reachable only by the proxy.", "components": ["oauth", "authelia"], "sources": ["oauth:se3815f92bd1c", "authelia:s064d1e72c1e8", "authelia:sbec86d37ea1c"], "status": "REASONED"}, "verify-direct": {"text": "From another host, all three ports must refuse/time out at the actual address; HTTP proves exposure and local/resolver failures are inconclusive.", "components": ["curl", "oauth-rolling", "authelia-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "authelia-rolling:s0cb4f49871b5", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-forged-direct": {"text": "A forged X-User sent directly to app port 3000 must be refused by network isolation.", "components": ["curl", "oauth-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-login": {"text": "Without a session, expect 401 or a 302 to the sign-in page; other redirects do not pass. A real session must reach the app.", "components": ["oauth-rolling", "authelia-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "authelia-rolling:s4af41b2fed9f"], "status": "REASONED", "verify": [1]}, "verify-overwrite": {"text": "With a valid session, replay a forged trusted identity header through each proxy; the app log must show the real identity, not admin.", "components": ["oauth", "traefik", "oauth-rolling", "authelia-rolling"], "sources": ["oauth-rolling:sdcd74a9cd9c6", "oauth:s259af7bc0946", "authelia-rolling:s4af41b2fed9f", "authelia-rolling:sfb8ce161d56a", "authelia-rolling:sd5437cbc4328", "traefik:s7f6f6200de1f"], "status": "REASONED"} } } ```` ====================================================================== ==> apache.md ====================================================================== # Apache HTTP Server: TLS and authentication Apache HTTP Server is a web server for serving sites over HTTP and HTTPS. This guide helps your AI assistant make sure it is configured securely: the TLS modules enabled, an HTTPS virtual host configured, HTTP redirected to HTTPS, and authentication required where needed. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/apache.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Apache HTTP Server 2.4, Apache TLSv1.3 minimum 2.4.36+, Apache per-vhost protocol minimum 2.4.42+, Apache chain-file minimum 2.4.8, httpd htpasswd 2.4.69, OpenSSL qualification 1.1.1+, Certbot Apache installation v2.3.0, and Debian apache2 packaging 2.4.69-1, plus 5 more recorded bases; 1 further source carries no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 20 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Applies to Apache 2.4. Get a certificate first: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) for a public host, or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) for internal use. The advice for a public host is to note that `certbot --apache` obtains and installs the certificate and, at the time of writing, enables the HTTP-to-HTTPS redirect by default (`--redirect` requests it explicitly), but to review its generated TLS config against steps 1 to 3 and add HSTS yourself, since certbot's `--hsts` is off by default. ## 1. Enable the modules ```bash # Debian/Ubuntu sudo a2enmod ssl headers sudo a2ensite default-ssl # or your own :443 vhost file # RHEL/Fedora sudo dnf install mod_ssl httpd-tools ``` Debian apache2 2.4.69-1 installs `a2enmod`, links `a2ensite` to it, and ships the `default-ssl` configuration. Fedora f44 httpd packaging at commit 6df70d39 defines the `mod_ssl` and `httpd-tools` packages. These packaging citations do not establish every Debian/Ubuntu or RHEL release. Paragraph sources (1 of 2): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1; https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.install#L1-7; https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.links#L7. Paragraph sources (2 of 2): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/sites-available/default-ssl.conf#L1-4; https://src.fedoraproject.org/rpms/httpd/blob/6df70d392dace11a7c01ceffc5d8a929de0b7a35/f/httpd.spec#_204-212. ## 2. Configure the HTTPS virtual host ```apache ServerName example.com DocumentRoot /var/www/html SSLEngine on SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem # TLS 1.2 minimum. The TLSv1.3 keyword needs Apache 2.4.36+ with OpenSSL 1.1.1+; # on older builds use: SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 # A per-vhost SSLProtocol takes effect independently only on Apache 2.4.42+ (with OpenSSL # 1.1.1+ and client SNI); on older builds the base or first-listed :443 vhost sets the floor, # so set the same SSLProtocol in the global/base config as well. SSLProtocol -all +TLSv1.2 +TLSv1.3 # Send HSTS only once HTTPS is confirmed working. Add `includeSubDomains` ONLY after every # subdomain also serves valid HTTPS: it forces them all onto HTTPS and blocks any HTTP-only one. Header always set Strict-Transport-Security "max-age=31536000" ``` On Apache 2.4.8 and later, `SSLCertificateFile` may contain the certificate plus its chain (certbot's `fullchain.pem`), and `SSLCertificateChainFile` is deprecated. For cipher suites beyond the protocol floor, generate a current list with the [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/) rather than copying one from an old tutorial. ## 3. Redirect HTTP to HTTPS ```apache ServerName example.com Redirect permanent / https://example.com/ ``` Keep port 80 serving only this redirect (and ACME HTTP-01 challenges if certbot uses the webroot method). ## 4. Require authentication Application-level login is preferable ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). To gate a whole site or path at the server, use basic authentication over TLS with bcrypt-hashed entries: ```bash sudo htpasswd -B -C 12 -c /etc/apache2/.htpasswd admin # suggested Debian/Ubuntu path; /etc/httpd/conf/.htpasswd is the suggested RHEL/Fedora choice. -C 12 sets bcrypt cost; -c creates the file only for the first user ``` Debian apache2 2.4.69-1 provides `htpasswd` in `apache2-utils`; Fedora f44 httpd packaging at commit 6df70d39 puts it in `httpd-tools`. In httpd 2.4.69, `-B` selects bcrypt, `-C` sets its cost, and the default bcrypt cost is 5. The OWASP cheat sheet at commit 72865b1e recommends a work factor of at least 10 for legacy bcrypt; 12 is this guide's choice. Both password-file paths above are guide choices. Paragraph sources (1 of 2): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1; https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/control#L92-102; https://src.fedoraproject.org/rpms/httpd/blob/6df70d392dace11a7c01ceffc5d8a929de0b7a35/f/httpd.spec#_819-824. Paragraph sources (2 of 2): https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128; https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L43-L45; https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L290-L295; https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13. ```apache AuthType Basic AuthName "Restricted" # suggested Debian/Ubuntu path; on RHEL/Fedora use /etc/httpd/conf/.htpasswd (match the htpasswd -c path above) AuthUserFile /etc/apache2/.htpasswd Require valid-user ``` For machine-to-machine links, mutual TLS is stronger than passwords. Put it in a SEPARATE `:443` vhost dedicated to machine clients, not the human-facing one: vhost-level `SSLVerifyClient require` makes every client present a trusted certificate, so a browser user with no client certificate is rejected during the TLS handshake before basic auth can apply. `SSLCACertificateFile` belongs at server or vhost level, not inside ``: ```apache SSLCACertificateFile /etc/ssl/certs/internal-ca.crt SSLVerifyClient require SSLVerifyDepth 2 ``` Basic authentication is single-factor. Authelia v4.39.28 says Apache is unsupported for its proxy integration (https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L112-L115). For human-facing sites, use [mod_auth_openidc](https://github.com/OpenIDC/mod_auth_openidc) v2.4.20.3 to make Apache an OIDC relying party (https://github.com/OpenIDC/mod_auth_openidc/blob/v2.4.20.3/README.md#L11-L20), and configure MFA at the identity provider as this guide advises. Cloudflare Access is another suggested option for fronting an Apache origin (this guide's assessment). A Cloudflare Access self-hosted application places an authentication layer between users and an origin server. Sources for the self-hosted application sentence (1 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16. Sources for the self-hosted application sentence (2 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54. An Access policy can require MFA (https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81); see [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). This guide assesses that an Apache origin reachable without passing through Access is not protected by it. Sources for the unprotected-origin assessment (1 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L45-L45. Sources for the unprotected-origin assessment (2 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48. ## 5. Verify **REASONED:** this introduction and the block that follows it; the first block's scenarios follow the cited Apache documentation, and this guide records no exposed/fixed deployment run. This metadata-only review has no authorized Apache deployment fixture. Most of the checks below are read-and-judge checks. `curl` exits 0 for a 401 as readily as for a 200, so the status code is printed and you compare it. The first block takes your host name, an unconfigured name and a protected path on its `set --` line, and refuses with status 2 if any is left unsubstituted or malformed, or if the unconfigured name is your own host name in any letter case. That comparison cannot see your other ServerName and ServerAlias values, so check the name against them yourself. The comparison runs in the C locale, so the letters A to Z match in either case in every locale, Turkish included, and every other letter matches only itself; the block refuses with status 2 if LC_ALL is read-only in your shell. Only that comparison runs in the C locale, in a nested subshell of its own, so the rest of the block, including the commands it runs, keeps your locale settings. The block also needs bash: in a shell without bash's `shopt`, such as dash, it refuses with status 2 before probing. The block then runs the configuration test and reload, classifies its requests to the site root and to the unconfigured name, records each outcome as it runs, and exits once at the end. It exits 1 if either request answers 200, otherwise 3 if either answers anything but 401 or the configuration test, the reload or one of its curl or ss commands fails, otherwise 2 if `ss` is not installed, and 0 when both answer 401. The redirect headers and the listener list stay for you to judge. The `apachectl -S` block exits 3 if the command fails and 0 otherwise, because you judge the vhost list it prints. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_AN_UNCONFIGURED_NAME' 'REPLACE_WITH_A_PROTECTED_PATH' # the deployment's public host name, then a name your certificate covers that no ServerName or ServerAlias matches (each with no scheme, user name, port, path, query, fragment, percent sign or trailing dot), then a protected path without its leading slash: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute a name your certificate covers that no ServerName or ServerAlias matches (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|/*|*[[:space:][:cntrl:]]*) echo "substitute a protected path, without its leading slash, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac if ( # a nested subshell, so only this comparison runs in the C locale and the rest of the block keeps yours { unset -v LC_ALL; } 2>/dev/null || exit 4 LC_ALL=C # nocasematch folds case by locale, and the C locale folds A to Z only, as host names need shopt -s nocasematch || exit 5 # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$2" in # letter case ignored; this catches only your own host name, so check the name against your other ServerName and ServerAlias values yourself "$1") exit 6 ;; esac ) then : else case "$?" in 4) echo "LC_ALL is read-only in this shell, so the host name comparison cannot run; not probing" ;; 5) echo "this block needs bash; run it in bash; not probing" ;; 6) echo "the unconfigured name is your own host name, which selects your own vhost and answers 401 whatever the other vhosts serve: substitute a name no ServerName or ServerAlias matches on the set -- line above; not probing" ;; *) echo "the host name comparison did not finish; not probing" ;; esac exit 2 fi set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused to run), which outranks 0 if sudo apachectl configtest && sudo systemctl reload apache2; then :; else # httpd on RHEL echo "configuration test or reload exit $?: the requests below may test the configuration Apache was already running; inconclusive"; set -- "$1" "$2" "$3" 3 fi curl -q -g -sSI --noproxy '*' "http://$1/" || # expect 301 with a https:// Location { echo "curl exit $?: the redirect request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3; } set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # 200 before section 4, 401 after it. Once # authentication is on, a 200 here is the finding case "$5:$6" in 0:401) ;; 0:200) echo "FINDING: the site root answered 200 with no credentials; with section 4's authentication on, that is the finding"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "HTTP $6 from the site root is neither the 401 pass nor the 200 finding; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the site root request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # Read every listener. Do not filter to the ports you expect: a filter cannot show you a port # you did not think of, which is the whole question, and `grep ':(80|443)'` also matches an # address such as [2001:db8:80::1]:9000. if command -v ss >/dev/null; then ss -tlnp || { echo "ss -tlnp failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3; } else echo "ss is not installed here; not listing sockets" case "$4" in 0) set -- "$1" "$2" "$3" 2 ;; esac fi # A request whose name matches no ServerName or ServerAlias does not fail. Apache falls through # to "the first listed virtual host that matches" the address and port, so a vhost listed # before yours over the same DocumentRoot answers without your authentication. # # Over TLS the name that selects the vhost is the SNI one, not the Host header: Apache says # that when the handshake carries it, "that hostname is used below just like the Host: header # would be used on a non-SSL connection". So setting only the header tests nothing. It leaves # SNI saying your own host name, which selects YOUR vhost and answers 401, or makes mod_ssl reject the # mismatched pair with 421; both look like a pass and neither is the request an attacker sends. # The URL hostname supplies SNI and the Host header; --connect-to changes only the connection destination (your address): set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' \ --connect-to "$2:443:$1:443" \ "https://$2/$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # 401 is the pass. 200 means another vhost served the # protected resource with no authentication at all case "$5:$6" in 0:401) echo "pass: the unconfigured name was refused with 401" ;; 0:200) echo "FINDING: another vhost served the protected path with no authentication"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "HTTP $6 is neither the 401 pass nor the 200 finding; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: a transport, certificate or local error; inconclusive (if your certificate covers no spare name, use the configuration review below)"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; the redirect headers and the listener list above are not classified, so judge them yourself" exit "$4" ) ``` **REASONED:** following block; the `apachectl -S` configuration review rests on the cited Apache name-based virtual host and virtual host matching documentation, and this guide records no exposed/fixed deployment run. This metadata-only review has no authorized Apache deployment fixture. That name has to be one your certificate already covers, typically a spare label under a wildcard SAN, because the guide will not tell you to disable certificate verification to run a test. If the certificate covers no name you can spare, this check is a configuration review instead: list the vhosts for that address and port, and confirm the first one Apache would choose does not serve the protected `DocumentRoot`. ```bash ( # a subshell, so the exit below ends only the subshell sudo apachectl -S || # the vhost list, in Apache's own matching order { echo "apachectl -S failed; inconclusive"; exit 3; } echo "unclassified observation: read the vhost list above; the first vhost Apache would choose for that address and port must not serve the protected DocumentRoot" exit 0 ) ``` ## Common mistakes - Serving the application on port 80 next to the HTTPS vhost instead of only redirecting. - Enabling `mod_ssl` without `Header`/HSTS, leaving downgrade open on repeat visits. - World-readable private keys; keep them `0600` and root-owned. - Protecting `/admin` but leaving `/api` open; `Require` rules apply per path, so enumerate what is public. ## Sources (checked September 2026) - Apache SSL/TLS how-to: https://httpd.apache.org/docs/2.4/ssl/ssl_howto.html - Apache authentication how-to: https://httpd.apache.org/docs/2.4/howto/auth.html - Apache name-based virtual hosts, for which vhost answers an unmatched Host header: https://httpd.apache.org/docs/2.4/vhosts/name-based.html - Apache mod_ssl `SSLVHostSNIPolicy`, for the 421 a mismatched SNI and Host pairing can produce (TLSv1.3: Apache 2.4.36+ with OpenSSL 1.1.1+; per-vhost SSLProtocol: Apache 2.4.42+ with OpenSSL 1.1.1+ and client SNI; chain in SSLCertificateFile: Apache 2.4.8+): https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslvhostsnipolicy - Apache virtual host matching in detail, for SNI selecting the vhost on a TLS connection: https://httpd.apache.org/docs/2.4/vhosts/details.html - Mozilla SSL Configuration Generator: https://ssl-config.mozilla.org/ - Certbot v2.3.0 Apache installation (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L104-L106 - Certbot v2.3.0 redirect default and enhancement application (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/client.py#L646 ; https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/client.py#L674-L677 - Certbot v2.3.0 redirect and opt-in HSTS defaults (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/constants.py#L66-L68 - Apache 2.4 `Header always set` response-header handling (checked October 2026): https://httpd.apache.org/docs/2.4/mod/mod_headers.html - Debian apache2 packaging version 2.4.69-1 (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1 - Debian apache2 2.4.69-1 helper, `a2ensite` link and default SSL configuration. Sources for this item (1 of 2): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.install#L1-7 ; https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.links#L7. Sources for this item (2 of 2): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/sites-available/default-ssl.conf#L1-4 - Debian apache2 2.4.69-1 `apache2-utils` package: https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/control#L92-102 - Fedora f44 httpd packaging at commit 6df70d39 package definitions and `htpasswd` file list: https://src.fedoraproject.org/rpms/httpd/blob/6df70d392dace11a7c01ceffc5d8a929de0b7a35/f/httpd.spec#_204-212 ; https://src.fedoraproject.org/rpms/httpd/blob/6df70d392dace11a7c01ceffc5d8a929de0b7a35/f/httpd.spec#_819-824 - httpd 2.4.69 `htpasswd` options and bcrypt default: https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128 ; https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L43-L45 ; https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L290-L295 - OWASP cheat sheet at commit 72865b1e legacy bcrypt guidance: https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13 - Authelia v4.39.28 Apache proxy support: https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L112-L115 - Cloudflare Access self-hosted application at commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026). Sources for this item (1 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16. Sources for this item (2 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54 - Cloudflare Access origin reachability at commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026). Sources for this item (1 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L45-L45. Sources for this item (2 of 2): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48 - Cloudflare Access MFA policy controls at commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81 - mod_auth_openidc v2.4.20.3 relying-party description: https://github.com/OpenIDC/mod_auth_openidc/blob/v2.4.20.3/README.md#L11-L20 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - apache: Apache HTTP Server (2.4) - tls-min: Apache TLSv1.3 minimum (2.4.36+) - sni-min: Apache per-vhost protocol minimum (2.4.42+) - chain-min: Apache chain-file minimum (2.4.8) - htpasswd: httpd htpasswd (2.4.69) - openssl: OpenSSL qualification (1.1.1+) - certbot: Certbot Apache installation (v2.3.0) - debian-packaging: Debian apache2 packaging (2.4.69-1) - fedora-packaging: Fedora f44 httpd packaging (commit 6df70d39) - authelia: Authelia proxy support (v4.39.28) - cloudflare-access: Cloudflare Access documentation (commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - openidc: mod_auth_openidc (v2.4.20.3) - policy: Mozilla TLS policy (unknown) - owasp: OWASP password storage cheat sheet (commit 72865b1e) Claims (full source mapping in the JSON block below): - certbot (REASONED; apache, certbot): The guide says certbot --apache installs certificates and redirects by default; --redirect is explicit and --hsts defaults off. - modules (REASONED; apache, debian-packaging, fedora-packaging): Enable ssl/headers and a 443 vhost on Debian/Ubuntu, or install mod_ssl/httpd-tools on RHEL/Fedora; pinned packaging shows a2enmod, a2ensite (a link to a2enmod), default-ssl, and Fedora package names. - tls (REASONED; apache): The 443 vhost enables TLS with a certificate chain and private key and TLSv1.2/TLSv1.3 only. - tls-version (REASONED; apache, tls-min, openssl): TLSv1.3 needs Apache 2.4.36+ with OpenSSL 1.1.1+; older builds use SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1. - vhost-floor (REASONED; apache, sni-min, openssl): Independent per-vhost SSLProtocol needs Apache 2.4.42+, OpenSSL 1.1.1+ and client SNI; older builds also need the floor in global/base config. - chain (REASONED; apache, chain-min): Apache 2.4.8+ permits the chain in SSLCertificateFile; SSLCertificateChainFile is deprecated. - hsts (REASONED; apache): Header always sets HSTS after HTTPS works; includeSubDomains requires valid HTTPS everywhere. - cipher-policy (REASONED; policy): Generate explicit cipher policy with Mozilla rather than copying old lists. - redirect (REASONED; apache): The 80 vhost sends a permanent redirect to the canonical HTTPS host; retain only redirects and needed ACME HTTP-01 paths. - password-file (REASONED; apache, debian-packaging, fedora-packaging, htpasswd, owasp): Create a bcrypt htpasswd file with cost 12 and -c only for its first user; httpd 2.4.69 defaults bcrypt cost to 5 and OWASP recommends at least 10 for legacy bcrypt. Both password-file paths are guide choices. - basic (REASONED; apache): AuthType Basic, AuthName, AuthUserFile and Require valid-user protect the chosen site/path over TLS; enumerate every public path. - mtls (REASONED; apache): A separate machine-client 443 vhost requires a trusted certificate with SSLVerifyClient require and depth 2; SSLCACertificateFile is server/vhost scoped. - mfa (REASONED; apache, authelia, openidc, cloudflare-access): Basic is single-factor; Authelia v4.39.28 says Apache is unsupported for its proxy integration, and mod_auth_openidc v2.4.20.3 is an OIDC relying party. Provider MFA is guide advice; Cloudflare Access documents self-hosted application and MFA policy controls; fronting an Apache origin is this guide's suggested option and assessment. This guide assesses that an Apache origin reachable without passing through Access is not protected by it. - key-permissions (REASONED; apache): Keep private keys root-owned and mode 0600. - verify-config (REASONED; apache): Run apachectl configtest before service reload; no outcome is recorded. - verify-redirect (REASONED; apache): HTTP should return 301 with an HTTPS Location. - verify-auth (REASONED; apache): Read printed status: curl can exit 0 for 200 or 401; expect 200 before auth and 401 after it. - verify-listeners (REASONED; apache): Inspect the entire listener table, without filtering away unexpected ports. - verify-unmatched (REASONED; apache): Use an unconfigured certificate-covered URL name for both SNI and Host; 401 passes, 200 exposes a protected resource through another vhost. Host-only probes can misleadingly give 401 or 421. - verify-vhost-review (REASONED; apache): If no spare certificate-covered name exists, use apachectl -S and review that the first matching vhost cannot serve the protected DocumentRoot. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "4fc847dae3fcde93596591c447ffe745048433b4cbf149aa2527c49ae90709a2", "components": { "apache": { "name": "Apache HTTP Server", "basis": "2.4", "sources": { "sde155f1667f3": "https://httpd.apache.org/docs/2.4/ssl/ssl_howto.html", "s99dcb49d86a4": "https://httpd.apache.org/docs/2.4/howto/auth.html", "sc07f97af99e3": "https://httpd.apache.org/docs/2.4/vhosts/name-based.html", "scf804f4940d7": "https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslvhostsnipolicy", "s7c302c734a0d": "https://httpd.apache.org/docs/2.4/vhosts/details.html", "s1dbb2636d050": "https://httpd.apache.org/docs/2.4/mod/mod_headers.html" } }, "tls-min": { "name": "Apache TLSv1.3 minimum", "basis": "2.4.36+", "sources": { "scf804f4940d7": "https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslvhostsnipolicy" } }, "sni-min": { "name": "Apache per-vhost protocol minimum", "basis": "2.4.42+", "sources": { "scf804f4940d7": "https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslvhostsnipolicy" } }, "chain-min": { "name": "Apache chain-file minimum", "basis": "2.4.8", "sources": { "scf804f4940d7": "https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslvhostsnipolicy" } }, "htpasswd": { "name": "httpd htpasswd", "basis": "2.4.69", "sources": { "s309ecdd34379": "https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128", "s7850e1e9dfa1": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L43-L45", "s1c1a101a70e7": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L290-L295" } }, "openssl": { "name": "OpenSSL qualification", "basis": "1.1.1+", "sources": { "scf804f4940d7": "https://httpd.apache.org/docs/2.4/mod/mod_ssl.html#sslvhostsnipolicy" } }, "certbot": { "name": "Certbot Apache installation", "basis": "v2.3.0", "sources": { "sc74fcd0167d8": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L104-L106", "sa453a66e4130": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/client.py#L646", "sacf90ffa5a6d": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/client.py#L674-L677", "s74412ad87016": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/constants.py#L66-L68" } }, "debian-packaging": { "name": "Debian apache2 packaging", "basis": "2.4.69-1", "sources": { "s1c05297f1ef0": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.install#L1-7", "s32bd234fb40a": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/sites-available/default-ssl.conf#L1-4", "sc9b152b1a444": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.links#L7", "sd1c9de6b39aa": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/control#L92-102", "s62f6ed2866ab": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1" } }, "fedora-packaging": { "name": "Fedora f44 httpd packaging", "basis": "commit 6df70d39", "sources": { "s7d73f140a3fe": "https://src.fedoraproject.org/rpms/httpd/blob/6df70d392dace11a7c01ceffc5d8a929de0b7a35/f/httpd.spec#_204-212", "s4479577a2e6a": "https://src.fedoraproject.org/rpms/httpd/blob/6df70d392dace11a7c01ceffc5d8a929de0b7a35/f/httpd.spec#_819-824" } }, "authelia": { "name": "Authelia proxy support", "basis": "v4.39.28", "sources": { "s1de7fe72d011": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L112-L115" } }, "cloudflare-access": { "name": "Cloudflare Access documentation", "basis": "commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16", "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54", "s603bdb6b24ab": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L45-L45", "s863803bd9a1f": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48", "sdcac732541da": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81" } }, "openidc": { "name": "mod_auth_openidc", "basis": "v2.4.20.3", "sources": { "s01e183d16f03": "https://github.com/OpenIDC/mod_auth_openidc/blob/v2.4.20.3/README.md#L11-L20" } }, "policy": { "name": "Mozilla TLS policy", "basis": "unknown", "sources": { "s529b0eabe2ed": "https://ssl-config.mozilla.org/" } }, "owasp": { "name": "OWASP password storage cheat sheet", "basis": "commit 72865b1e", "sources": { "s4531e65b6502": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13" } } }, "claims": { "certbot": {"text": "The guide says certbot --apache installs certificates and redirects by default; --redirect is explicit and --hsts defaults off.", "components": ["apache", "certbot"], "sources": ["apache:sde155f1667f3", "certbot:sc74fcd0167d8", "certbot:sa453a66e4130", "certbot:sacf90ffa5a6d", "certbot:s74412ad87016"], "status": "REASONED"}, "modules": {"text": "Enable ssl/headers and a 443 vhost on Debian/Ubuntu, or install mod_ssl/httpd-tools on RHEL/Fedora; pinned packaging shows a2enmod, a2ensite (a link to a2enmod), default-ssl, and Fedora package names.", "components": ["apache", "debian-packaging", "fedora-packaging"], "sources": ["apache:sde155f1667f3", "debian-packaging:s62f6ed2866ab", "debian-packaging:s1c05297f1ef0", "debian-packaging:sc9b152b1a444", "debian-packaging:s32bd234fb40a", "fedora-packaging:s7d73f140a3fe"], "status": "REASONED"}, "tls": {"text": "The 443 vhost enables TLS with a certificate chain and private key and TLSv1.2/TLSv1.3 only.", "components": ["apache"], "sources": ["apache:sde155f1667f3", "apache:scf804f4940d7"], "status": "REASONED"}, "tls-version": {"text": "TLSv1.3 needs Apache 2.4.36+ with OpenSSL 1.1.1+; older builds use SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1.", "components": ["apache", "tls-min", "openssl"], "sources": ["apache:scf804f4940d7", "tls-min:scf804f4940d7", "openssl:scf804f4940d7"], "status": "REASONED"}, "vhost-floor": {"text": "Independent per-vhost SSLProtocol needs Apache 2.4.42+, OpenSSL 1.1.1+ and client SNI; older builds also need the floor in global/base config.", "components": ["apache", "sni-min", "openssl"], "sources": ["apache:scf804f4940d7", "apache:s7c302c734a0d", "sni-min:scf804f4940d7", "openssl:scf804f4940d7"], "status": "REASONED"}, "chain": {"text": "Apache 2.4.8+ permits the chain in SSLCertificateFile; SSLCertificateChainFile is deprecated.", "components": ["apache", "chain-min"], "sources": ["apache:scf804f4940d7", "chain-min:scf804f4940d7"], "status": "REASONED"}, "hsts": {"text": "Header always sets HSTS after HTTPS works; includeSubDomains requires valid HTTPS everywhere.", "components": ["apache"], "sources": ["apache:sde155f1667f3", "apache:s1dbb2636d050"], "status": "REASONED"}, "cipher-policy": {"text": "Generate explicit cipher policy with Mozilla rather than copying old lists.", "components": ["policy"], "sources": ["policy:s529b0eabe2ed"], "status": "REASONED"}, "redirect": {"text": "The 80 vhost sends a permanent redirect to the canonical HTTPS host; retain only redirects and needed ACME HTTP-01 paths.", "components": ["apache"], "sources": ["apache:sc07f97af99e3", "apache:sde155f1667f3"], "status": "REASONED"}, "password-file": {"text": "Create a bcrypt htpasswd file with cost 12 and -c only for its first user; httpd 2.4.69 defaults bcrypt cost to 5 and OWASP recommends at least 10 for legacy bcrypt. Both password-file paths are guide choices.", "components": ["apache", "debian-packaging", "fedora-packaging", "htpasswd", "owasp"], "sources": ["apache:s99dcb49d86a4", "debian-packaging:s62f6ed2866ab", "debian-packaging:sd1c9de6b39aa", "fedora-packaging:s4479577a2e6a", "htpasswd:s309ecdd34379", "htpasswd:s7850e1e9dfa1", "htpasswd:s1c1a101a70e7", "owasp:s4531e65b6502"], "status": "REASONED"}, "basic": {"text": "AuthType Basic, AuthName, AuthUserFile and Require valid-user protect the chosen site/path over TLS; enumerate every public path.", "components": ["apache"], "sources": ["apache:s99dcb49d86a4"], "status": "REASONED"}, "mtls": {"text": "A separate machine-client 443 vhost requires a trusted certificate with SSLVerifyClient require and depth 2; SSLCACertificateFile is server/vhost scoped.", "components": ["apache"], "sources": ["apache:scf804f4940d7"], "status": "REASONED"}, "mfa": {"text": "Basic is single-factor; Authelia v4.39.28 says Apache is unsupported for its proxy integration, and mod_auth_openidc v2.4.20.3 is an OIDC relying party. Provider MFA is guide advice; Cloudflare Access documents self-hosted application and MFA policy controls; fronting an Apache origin is this guide's suggested option and assessment. This guide assesses that an Apache origin reachable without passing through Access is not protected by it.", "components": ["apache", "authelia", "openidc", "cloudflare-access"], "sources": ["apache:s99dcb49d86a4", "authelia:s1de7fe72d011", "openidc:s01e183d16f03", "cloudflare-access:sfddb92e438ca", "cloudflare-access:s7cafe1b17afd", "cloudflare-access:sdcac732541da", "cloudflare-access:s603bdb6b24ab", "cloudflare-access:s863803bd9a1f"], "status": "REASONED"}, "key-permissions": {"text": "Keep private keys root-owned and mode 0600.", "components": ["apache"], "sources": ["apache:sde155f1667f3"], "status": "REASONED"}, "verify-config": {"text": "Run apachectl configtest before service reload; no outcome is recorded.", "components": ["apache"], "sources": ["apache:sde155f1667f3"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "HTTP should return 301 with an HTTPS Location.", "components": ["apache"], "sources": ["apache:sc07f97af99e3"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "Read printed status: curl can exit 0 for 200 or 401; expect 200 before auth and 401 after it.", "components": ["apache"], "sources": ["apache:s99dcb49d86a4"], "status": "REASONED", "verify": [1]}, "verify-listeners": {"text": "Inspect the entire listener table, without filtering away unexpected ports.", "components": ["apache"], "sources": ["apache:sc07f97af99e3"], "status": "REASONED", "verify": [1]}, "verify-unmatched": {"text": "Use an unconfigured certificate-covered URL name for both SNI and Host; 401 passes, 200 exposes a protected resource through another vhost. Host-only probes can misleadingly give 401 or 421.", "components": ["apache"], "sources": ["apache:sc07f97af99e3", "apache:scf804f4940d7", "apache:s7c302c734a0d"], "status": "REASONED", "verify": [1]}, "verify-vhost-review": {"text": "If no spare certificate-covered name exists, use apachectl -S and review that the first matching vhost cannot serve the protected DocumentRoot.", "components": ["apache"], "sources": ["apache:sc07f97af99e3", "apache:s7c302c734a0d"], "status": "REASONED", "verify": [2]} } } ```` ====================================================================== ==> nginx.md ====================================================================== # nginx: TLS and authentication nginx is a web server, commonly run as a reverse proxy in front of applications. This guide helps your AI assistant make sure it is configured securely: an HTTPS server block with a certificate, HTTP redirected to HTTPS, authentication required, and expensive endpoints bounded. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/nginx.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against nginx source release-1.26.3, Certbot nginx configurator v2.3.0, Authelia nginx integration v4.39.28, oauth2-proxy nginx integration v7.15.6, Apache httpd source 2.4.69, Debian apache2 packaging 2.4.69-1, HSTS specification RFC 6797, and OWASP Password Storage Cheat Sheet commit 72865b1e; 3 further sources carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 29 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Get a certificate first: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) for a public host (note that `certbot --nginx` edits the server block for you), or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) for internal use. ## 1. HTTPS server block ```nginx server { listen 443 ssl; listen [::]:443 ssl; http2 on; # nginx 1.25.1+; on older versions: listen 443 ssl http2; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; server_tokens off; # do not emit the nginx version in headers or error pages # Send HSTS only once HTTPS is confirmed working. `includeSubDomains` commits EVERY subdomain to HTTPS # for the whole max-age; drop it until each subdomain serves HTTPS, and start with a short max-age. add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; location / { proxy_pass http://127.0.0.1:3000; # your app, bound to loopback only proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; # overwrite; nginx forwards a client-set value by default } } ``` At nginx release-1.26.3, the HTTP/2 module provides `http2 on`; nginx 1.25.1 introduced it and deprecated the older `listen ... http2` parameter ([module](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/v2/ngx_http_v2_module.c#L76-L81), [changelog](https://github.com/nginx/nginx/blob/release-1.26.3/docs/xml/nginx/changes.xml#L470-L480), [listen parser](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_core_module.c#L4181-L4188)). For explicit cipher lists, generate them with the [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/) instead of copying from old tutorials; the protocol floor above is the part that must not be omitted. The nginx server defaults `ssl_session_tickets on`, and un-rotated ticket keys weaken forward secrecy: either set `ssl_session_tickets off`, or on nginx 1.23.2+ give it a shared `ssl_session_cache` so it rotates the ticket keys automatically. At nginx release-1.26.3, the default-on setting and shared-cache rotation are supported by the [SSL module](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L857-L862), [1.23.2 changelog](https://github.com/nginx/nginx/blob/release-1.26.3/docs/xml/nginx/changes.xml#L739-L775) and [rotation code](https://github.com/nginx/nginx/blob/release-1.26.3/src/event/ngx_event_openssl.c#L4568-L4576). ## 2. Redirect HTTP to HTTPS ```nginx server { listen 80; listen [::]:80; server_name example.com; return 301 https://$host$request_uri; } ``` `$host` echoes the client's `Host` header, so if this `:80` block is the `default_server` (explicit, or implicit as the first block for that address and port), a request with `Host: attacker.example.net` is redirected off-site. Give the `default_server` a `return 444;` (or a redirect to the literal canonical host) rather than reflecting `$host`. ## 3. Require authentication Application-level login is preferable ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). To gate a site or path at the proxy, use basic authentication over TLS: ```bash sudo apt install apache2-utils # provides htpasswd sudo htpasswd -B -C 12 -c /etc/nginx/.htpasswd admin # -C 12 sets bcrypt cost; bare -B is 5, below the OWASP minimum of 10 ``` At httpd 2.4.69, `htpasswd -B` selects bcrypt, `-C` sets its cost, and omitting `-C` uses cost 5 ([htpasswd usage](https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128), [default constant](https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45), [bcrypt call](https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L290-L295), [cost option](https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L382)). The OWASP cheat sheet at commit 72865b1e recommends a work factor of at least 10 for legacy bcrypt ([OWASP recommendation](https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13)). Debian's apache2 packaging at 2.4.69-1 ([changelog](https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1)) lists `htpasswd` among the programs in `apache2-utils` ([package description](https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/control#L92-101)); other distributions may package it differently. ```nginx # this is section 1's `location /`, now also carrying the auth directives - do not paste a second # `location /`; the proxy_set_header lines must stay, or the app loses Host / the forwarded headers location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; # overwrite; nginx forwards a client-set value by default } ``` Mutual TLS for machine-to-machine access: ```nginx ssl_client_certificate /etc/ssl/certs/internal-ca.crt; ssl_verify_client on; ``` At nginx release-1.26.3, these directives configure client-certificate verification ([directive definitions](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L153-L172)), and `ssl_verify_client on` or `optional` without `ssl_client_certificate` fails the [configuration check](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L788-L802) ("no ssl_client_certificate for ssl_verify_client"); `optional_no_ca` ([enum value 3](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L74-L80)) is exempt. Basic authentication is single-factor. For human-facing sites, add MFA with the `auth_request` mechanism pointed at an [Authelia](https://www.authelia.com/) or [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) portal, or front the site with Cloudflare Access; options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) (this guide's requirement). Whether MFA is enforced in a particular installation depends on the configured portal or Access policy (this guide's assessment). Authelia v4.39.28 requires nginx's `http_auth_request` module ([requirements](https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L39-L45)); its nginx setup includes an auth-request snippet in each protected location ([location guidance](https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L245-L247)) containing `auth_request /internal/authelia/authz;` ([snippet](https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L517-L519)). oauth2-proxy v7.15.6 documents nginx `auth_request` against its authentication endpoint, requires `--reverse-proxy` for this option and says to also configure `--trusted-proxy-ip=127.0.0.1/32` when Nginx connects from localhost ([integration](https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/integrations/nginx.md#L16-L29)). The [v7.15.5 release notes](https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.5) list the critical advisory GHSA-wr5q-7wxw-x568, "Authentication bypass via spoofed client-IP headers in OAuth2 Proxy", whose fix, when `--reverse-proxy` is enabled, accepts client-IP headers configured via `--real-client-ip-header` only from direct peers that match `--trusted-proxy-ip`. The same notes say that if `--trusted-proxy-ip` is left unset, OAuth2 Proxy keeps backwards compatibility by trusting all source addresses, which does not protect against spoofed `X-Forwarded-For` headers. Releases before v7.15.5 lack that fix (this guide's inference from the release notes; the advisory page was not read), and this guide's oauth2-proxy basis is v7.15.6. Cloudflare Access documents protecting self-hosted web applications by checking requests against Access policies before allowing them through ([web applications](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/)). At nginx release-1.26.3, `auth_request` accepts a subrequest status from `NGX_HTTP_OK` (200) up to `NGX_HTTP_SPECIAL_RESPONSE` (300, exclusive), returns 401 or 403 as received, and answers any other status with 500 ([module handler](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_request_module.c#L135-L182), [status codes](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_request.h#L78-L138)). The nginx source cited here does not cover the Authelia, oauth2-proxy or Cloudflare Access integrations. ## 4. Bound the expensive endpoints An authenticated caller can still exhaust an inference, upload, or job-submission endpoint, which is denial of wallet when the endpoint costs GPU time ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). Declare the zones in the `http` block and apply the limits per location. ```nginx # add to the http block limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_conn_zone $binary_remote_addr zone=apiconn:10m; # add to the server block from section 1 client_max_body_size 10m; # 413 above this; the default is 1m # add INSIDE the existing `location /` from sections 1 and 3. Do not paste a new # location block: this one already carries auth_basic and the proxy_set_header # directives, and a location without them is unauthenticated and loses the # forwarded headers. A sibling location does not inherit either. limit_req zone=api burst=20 nodelay; # 503 once the burst is spent limit_conn apiconn 10; # concurrent connections per client address proxy_read_timeout 60s; ``` Put the limits in the location that already carries authentication. nginx selects the single most specific matching location and does not inherit `auth_basic` from a less specific one, so adding a separate `location /api/` for the limits would create a route that is rate limited and unauthenticated. If you do split them out, repeat the `auth_basic` directives in each location, or move them to the `server` level so every location inherits them. `proxy_read_timeout` bounds the gap between successive reads from the upstream, not the total duration of a response. A stream that keeps sending stays alive indefinitely under a 60s value; a stream that stalls for 61s is cut. Choose it from the longest acceptable silence, and note that raising it lengthens that tolerance rather than removing the timeout. Mind the client address these headers carry. At a direct internet edge the block above appends the peer to `X-Forwarded-For` with `$proxy_add_x_forwarded_for`, so the app must read the LAST entry (the one nginx added), never the client-controlled first one. And if nginx sits behind a CDN or another proxy (for example fronting the site with Cloudflare Access, above), `$binary_remote_addr` is the CDN's edge IP, not the client's, so all callers arriving via the same edge IP share one `limit_req`/`limit_conn` bucket. That CDN or proxy arrangement means the limits blur across clients or throttle real users, and a client-supplied `X-Forwarded-For` reaches the app, to be trusted if the app reads its first entry. Recover the real client address with `set_real_ip_from ` and `real_ip_header CF-Connecting-IP` (or `X-Forwarded-For`) from `ngx_http_realip_module`, so the limit key and the logged address are the client rather than the edge. ## 5. Verify (REASONED: all Verify scenarios follow the cited nginx documentation; this guide records no exposed/fixed deployment run. This metadata-only review has no authorized nginx deployment fixture. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of this block. Stub runs with the network and host commands replaced ran its guards but no deployment on an earlier form, which lowercased the host with `tr` and checked `tr`'s exit status before refusing reserved names; earlier QA reports record stub runs of forms before that one, and no exposed/fixed deployment run of any form is recorded. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and each of its two host parts refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. Stub runs on 2026-11-01 of the previous form, before the curl exit 60, TLS 1.1, body-size and rate-limit changes recorded below, with stub curl, sudo, head, rm and ss and no network, also ran no deployment. In bash, each host part refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probes. Under dash, those stub runs refused every host name in each host part with 2 and the bash message before its probes; the block still ran `sudo nginx -t`, the reload and `ss -tlnp` and exited 3, as the earlier form did there for every host, which it reported inconclusive at its `tr` step. Stub runs of the current form on 2026-11-02, in bash 5.3.9 with stub curl, sudo, ss, openssl, wget and nc and no network, also ran no deployment. In them, curl exit 60 on the HTTPS request and a TLS 1.1 request failing locally with exit 35 each recorded 3 with a message that also names the client-side cause, an 11M body answered with the 9M control's own 405 or 302 recorded 3 with a message naming three readings, a 405 pair after an earlier TLS 1.1 finding kept 1, a 200 pair recorded 1 as accepted, and an 11M 413 passed. An 11M 400 after a 405 control recorded 3, a voided 9M control (401, 413, 502 or curl exit 28) never made the 11M answer a finding, a failing `xargs` or curl in the rate-limit run recorded 3 with the message naming `xargs`, and under dash 0.5.12 the block still exited 3. When `ss` is missing, the block prints `ss is not installed here; not listing sockets` and exits 2 only if no other part found something or was inconclusive.) ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input or found ss missing), which outranks 0 if sudo nginx -t && sudo systemctl reload nginx; then :; else echo "nginx -t or reload exit $?: the requests below may test the configuration nginx was already running; inconclusive"; set -- 3 fi ( # a nested subshell, so this part's set -- and exit leave the block's status above untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_auth_OR_open' # your public host name (the server_name), no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then auth if section 3's auth covers location / or open if you left / public on purpose: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in auth|open) ;; *) echo "substitute auth (section 3's auth covers location /) or open (you left / public on purpose) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is this part's status so far, ranked as above curl -q -g -sI "http://$1/" || # expect 301 with a https:// Location { echo "curl exit $?: the redirect request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } if curl -q -g -sI --max-time 10 "https://$1/"; then :; else # TLS must verify with NO -k (a cert error means TLS is misconfigured, or that this client does not trust your CA); status is 200 if / is open, or 401 if you applied section 3's auth to it set -- "$1" "$2" "$3" "$?" case "$4" in 60) echo "curl exit 60: the certificate did not verify, so either TLS is misconfigured or this client does not trust its CA, as with an internal CA (see self-signed.md); inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the HTTPS request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac fi set -- "$1" "$2" "$3" "$(if curl -q -g -s -o /dev/null -w '%{http_code}\n' --max-time 10 "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # if section 3's auth is applied: an uncredentialed request must be 401/403, never 200 case "$2:$4:$5" in auth:0:401|auth:0:403|open:0:*) ;; auth:0:2??) echo "FINDING: / answered $5 with no credentials, though section 3's auth should cover it"; set -- "$1" "$2" 1 ;; auth:0:*) echo "HTTP $5 without credentials is neither the 401/403 pass nor a 2xx finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request without credentials failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac exit "$3" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # The admin password reaches curl through a config file on stdin (curl # --config -), never through argv: -u admin:PASSWORD is world-readable via # ps and /proc//cmdline on a shared host. Paste the whole parenthesised # block, including its set -- line. (The TLS-floor, size-prep and rate-limit # steps sit inside the same block only so the host and password are entered # once.) trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The password you substitute on the set -- line enters shell history. # Clear that history line afterward. # Stdin closes the argv channel only: it does not hide the password from # set -x tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_PASSWORD' # your public host name (the server_name), no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the admin password: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the admin password on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "${2//\\/\\\\}" set -- "$1" "${2//\"/\\\"}" set -- "$1" "$2" 0 # the 3rd value is this part's status so far, ranked as above set -- "$1" "$2" "$3" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --max-time 10 --config - "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # with credentials: your app's response, never 401 case "$4:$5" in 0:401) echo "HTTP 401 with the credentials, so they were refused; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; 0:*) ;; *) echo "curl exit $4: the request with credentials failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -s -o /dev/null -w 'http=%{http_code} err=%{errormsg}\n' --max-time 10 --tlsv1.1 --tls-max 1.1 "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then its http= and err= line printf '%s\n' "$5" # protocol floor: offering only TLS 1.1 MUST be rejected. The pass is specifically a `protocol_version` alert (the server refuses the version); a generic handshake failure (for example no shared cipher) or a local "could not load"/policy error is inconclusive, not proof. This shows the 1.1 boundary; the same TLSv1.2+ floor also refuses 1.0, which you confirm separately with `--tlsv1.0 --tls-max 1.0` case "$4:$5" in 0:*) echo "FINDING: a handshake offering only TLS 1.1 succeeded, so the TLSv1.2+ floor is not in force"; set -- "$1" "$2" 1 ;; *protocol?version*) ;; *) echo "curl exit $4 without a protocol_version alert, so this probe cannot establish the server's TLS floor, and a client unable to offer TLS 1.1 cannot complete this check; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac if head -c 9M /dev/zero > /tmp/under.bin && head -c 11M /dev/zero > /tmp/over.bin; then set -- "$1" "$2" "$3" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --max-time 20 --config - --data-binary @/tmp/under.bin "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # positive control: must be your app's normal response to this POST # (for example 200/204/405), never 413. A 401 means the credentials, not # the size limit, were exercised, and a 000 means transport failed: either voids the control case "$4:$5" in 0:401|0:413) echo "HTTP $5 for the 9M body voids the positive control; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; 0:2??|0:3??|0:4??) set -- "$1" "$2" "$3" "$4" "$5" "$5" ;; # the control passed: keep its code to compare with the 11M body's 0:*) echo "HTTP $5 for the 9M body is not your app's normal response; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the 9M request failed, which voids the positive control; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --config - --data-binary @/tmp/over.bin "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${6-}" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" "$5" # curl's exit status, the HTTP code, then the 9M code if that control passed printf '%s\n' "$5" # 413. The 9M control is the discriminating half: nginx's default # client_max_body_size is 1m, so 9M is refused until `10m` is set, # while 11M returns 413 either way. Neither status says WHICH layer # refused: a backend with its own limit produces the same codes. To # attribute it to nginx, set `client_max_body_size 0` in an isolated # environment, which disables the check entirely; commenting the # directive out only restores the 1m default and still returns 413 case "$4:$5" in 0:413) ;; 0:2??) echo "FINDING: the 11M body was accepted with $5, so no body-size limit refused it"; set -- "$1" "$2" 1 ;; "0:${6:-none}") echo "HTTP $5 for the 11M body is the 9M control's own response, so one of three readings holds: no body-size limit refused it, something answered both requests before nginx's limit applies (a proxy in front, or a server-level return or rewrite), or an error_page remapped the limit's 413; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; 0:*) echo "HTTP $5 for the 11M body is neither the 413 pass, a 2xx nor the 9M control's response; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the 11M request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac else echo "head could not create /tmp/under.bin and /tmp/over.bin, so the size checks did not run; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 fi (set -o pipefail; seq 1 40 | xargs -P 40 -I{} curl -q -g -s -o /dev/null -w '%{http_code}\n' "https://$1/" | sort | uniq -c) || # pipefail only in this subshell { echo "rate-limit run exit $?: seq, xargs, sort, uniq or one of the 40 curl requests failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } # Run them CONCURRENTLY and WITHOUT credentials: nginx evaluates # limit_req in the PREACCESS phase, before auth_basic in the ACCESS # phase, so an unauthenticated flood still exercises the limiter, which # is keyed on $binary_remote_addr (the client address). Expect 503s # (the limiter rejecting; limit_req_status defaults to 503) mixed with # 401s if section 3's auth covers /, or with 200s if it does not. # A 503 is not conclusive on its own: `limit_conn` also returns # 503, and an upstream under load returns it too, so this does not # establish that either nginx limiter fired. Attributing it needs an # isolated environment with both limiters disabled as a baseline, # then each enabled alone, with the arrival rate actually measured rm -f /tmp/under.bin /tmp/over.bin || { echo "rm exit $?: the cleanup failed, so /tmp/under.bin or /tmp/over.bin may remain; delete them yourself"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } exit "$3" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above if command -v ss >/dev/null; then set -- "$1" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far, then the listener table set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the listener table, then ss's exit status printf '%s' "$2" # read every listener; 3000: the app itself: loopback only (bind 127.0.0.1; the check also counts any 127.x.x.x, [::1] or [::ffff:127.x.x.x] address as loopback), never 0.0.0.0. All the checks # above pass while the app also answers directly on port 3000, # which bypasses this proxy's TLS and its authentication. That # bypass is the first common mistake below, and the first item in # common-mistakes.md if [ "$3" != 0 ]; then echo "ss -tlnp failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" elif printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:3000$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 3000 is bound to a non-loopback address"; set -- 1 "$2" "$3" else case "$?" in 1) echo "no port 3000 listener outside loopback; unclassified observation: read every other listener above" ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi echo "block exit $1; the redirect and HTTPS headers, the rate-limit counts, the uncredentialed status with open, and listeners other than port 3000 (every listener, if ss is missing) are not classified, so judge them yourself" exit "$1" ) ``` ## Common mistakes - The app still listens on `0.0.0.0:3000` next to the proxy, so the proxy's TLS and auth are bypassed. Bind the app to `127.0.0.1` and confirm with `ss -tlnp`. - `add_header` in a `location` block silently drops ALL headers inherited from `server`. That is inheritance, which `always` does not change (`always` changes which response codes the header is added to, not inheritance). Keep HSTS at the `server` level, and if a `location` needs its own `add_header`, re-declare HSTS inside it too. At nginx release-1.26.3, the [header merge code](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L666-L687) inherits parent headers only when the child has no header list, and the first `add_header` at a level creates that level's own list ([directive handler](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L788-L796)). - A default `server` block that still serves plain HTTP for unmatched hosts; give the catch-all `default_server` a `return 444;` (or a redirect to the literal canonical host), not the `$host`-reflecting redirect above. - `auth_basic` on `/` but a later `location` (for example `/static`) that re-opens access; `auth_basic off` should be a deliberate exception, not an accident. ## Sources (checked September 2026) - Configuring HTTPS servers: https://nginx.org/en/docs/http/configuring_https_servers.html - Core module (`client_max_body_size`): https://nginx.org/en/docs/http/ngx_http_core_module.html - Request rate limiting (`limit_req_zone`, `limit_req`, `burst`, `nodelay`): https://nginx.org/en/docs/http/ngx_http_limit_req_module.html - Connection limiting (`limit_conn_zone`, `limit_conn`): https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html - Proxy module (`proxy_read_timeout`, and `proxy_send_timeout` and `proxy_connect_timeout` if you add them): https://nginx.org/en/docs/http/ngx_http_proxy_module.html - ngx_http_auth_basic_module: https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html - Real IP module (`set_real_ip_from`, `real_ip_header`) for nginx behind a CDN or proxy: https://nginx.org/en/docs/http/ngx_http_realip_module.html - Mozilla SSL Configuration Generator: https://ssl-config.mozilla.org/ - Certbot v2.3.0 nginx server-block installation (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot-nginx/certbot_nginx/_internal/configurator.py#L711 - RFC 6797 HSTS policy lifetime and subdomains (checked October 2026): https://www.rfc-editor.org/rfc/rfc6797.html#section-6.1.2 - nginx release-1.26.3 header-filter `always` handling (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L216-L218 - nginx release-1.26.3 changes http2 (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/docs/xml/nginx/changes.xml#L470-L480 - nginx release-1.26.3 changes tickets (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/docs/xml/nginx/changes.xml#L739-L775 - nginx release-1.26.3 http2 (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/v2/ngx_http_v2_module.c#L76-L81 - nginx release-1.26.3 listen http2 (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_core_module.c#L4181-L4188 - nginx release-1.26.3 ssl tickets (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L857-L862 - nginx release-1.26.3 ssl mtls (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L153-L172 - nginx release-1.26.3 ssl mtls check (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L788-L802 - nginx release-1.26.3 ssl_verify_client values (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L74-L80 - nginx release-1.26.3 headers merge (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L666-L687 - nginx release-1.26.3 add_header list creation (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L788-L796 - nginx release-1.26.3 auth request (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_request_module.c#L135-L182 - nginx release-1.26.3 HTTP status codes (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_request.h#L78-L138 - Authelia v4.39.28 nginx module requirements (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L39-L45 - Authelia v4.39.28 nginx protected locations (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L245-L247 - Authelia v4.39.28 nginx auth_request snippet (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L517-L519 - oauth2-proxy v7.15.6 nginx auth_request integration and `--trusted-proxy-ip` for a localhost Nginx (checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/integrations/nginx.md#L16-L29 - oauth2-proxy v7.15.5 release notes, critical advisory GHSA-wr5q-7wxw-x568 and its fix notes (read for the v7.15.6 basis, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.5 - Cloudflare Access web applications (unversioned page, checked October 2026): https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/ - nginx release-1.26.3 ticket rotation (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/event/ngx_event_openssl.c#L4568-L4576 - Apache httpd 2.4.69 htpasswd (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128 - Apache httpd 2.4.69 bcrypt default (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45 - Apache httpd 2.4.69 bcrypt use (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L290-L295 - Apache httpd 2.4.69 bcrypt cost (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L382 - Debian apache2 2.4.69-1 `apache2-utils` package description listing htpasswd (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/control#L92-101 - Debian apache2 packaging version 2.4.69-1 (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1 - OWASP Password Storage Cheat Sheet at commit 72865b1e (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - nginx-source: nginx source (release-1.26.3) - ng: nginx documentation (unknown) - certbot: Certbot nginx configurator (v2.3.0) - authelia: Authelia nginx integration (v4.39.28) - oauth2-proxy: oauth2-proxy nginx integration (v7.15.6) - cloudflare-access: Cloudflare Access web applications (unknown) - httpd: Apache httpd source (2.4.69) - debian-apache2: Debian apache2 packaging (2.4.69-1) - policy: Mozilla TLS policy (unknown) - hsts: HSTS specification (RFC 6797) - owasp: OWASP Password Storage Cheat Sheet (commit 72865b1e) Claims (full source mapping in the JSON block below): - certbot (REASONED; ng, certbot): The guide says certbot --nginx edits the server block; certificate acquisition is delegated to the linked certificate guides. - tls (REASONED; ng): 443 IPv4/IPv6 TLS uses certificate chain and private key, TLSv1.2/TLSv1.3 and ssl_prefer_server_ciphers off. - http2 (REASONED; ng, nginx-source): At nginx release-1.26.3, http2 on is the HTTP/2 module directive; nginx 1.25.1 introduced it and deprecated the older listen 443 ssl http2 parameter. - tokens (REASONED; ng): server_tokens off suppresses nginx version in headers and error pages. - hsts (REASONED; ng, hsts, nginx-source): Send HSTS only after HTTPS works; includeSubDomains commits every subdomain for max-age. - hsts-inheritance (REASONED; ng, nginx-source): At nginx release-1.26.3, a location add_header replaces inherited server headers; always changes response-code coverage, not inheritance. - proxy (REASONED; ng): Proxy to loopback 127.0.0.1:3000 and set Host, X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. - forwarded-host (REASONED; ng): Overwrite X-Forwarded-Host: client-set values otherwise pass upstream. - cipher-policy (REASONED; ng, policy): Use Mozilla for explicit cipher policy; retain the stated protocol floor. - tickets (REASONED; ng, nginx-source): At nginx release-1.26.3, ssl_session_tickets defaults on; nginx 1.23.2 introduced automatic ticket-key rotation with a shared ssl_session_cache. - redirect (REASONED; ng): Port 80 on IPv4/IPv6 redirects with 301; a default server reflecting $host can redirect unmatched hosts off-site, so use 444 or a canonical host. - password-file (REASONED; ng, httpd, owasp, debian-apache2): At httpd 2.4.69, htpasswd -B selects bcrypt, bare -B uses cost 5, and -C sets the cost; the OWASP cheat sheet at commit 72865b1e recommends at least 10 for legacy bcrypt. Debian apache2 2.4.69-1 packages htpasswd in apache2-utils. - basic (REASONED; ng): auth_basic and auth_basic_user_file gate the existing proxied location over TLS; preserve its forwarded headers. - mtls (REASONED; ng, nginx-source): At nginx release-1.26.3, ssl_client_certificate and ssl_verify_client on configure trusted client-certificate verification. - mfa (REASONED; ng, nginx-source, authelia, oauth2-proxy, cloudflare-access): At nginx release-1.26.3, auth_request permits 2xx authentication subresponses and returns 401/403; Authelia v4.39.28 and oauth2-proxy v7.15.6 document nginx auth_request integration, and Cloudflare Access documents protecting self-hosted web applications; the nginx source cited here does not cover those integrations. MFA is this guide's requirement; whether a given installation enforces it depends on its configured portal or Access policy (this guide's assessment). - oauth2-proxy-client-ip (REASONED; oauth2-proxy): oauth2-proxy v7.15.6 says to set --trusted-proxy-ip=127.0.0.1/32 when Nginx connects from localhost; the v7.15.5 release notes list critical advisory GHSA-wr5q-7wxw-x568, an authentication bypass via spoofed client-IP headers, as fixed in that release, and say that an unset --trusted-proxy-ip trusts all source addresses. - body-limit (REASONED; ng): client_max_body_size defaults to 1m; 10m rejects larger bodies with 413, and 0 disables the check. - rate-limit (REASONED; ng): Per-address limit_req uses a 10r/s zone, burst 20 and nodelay; rejection status defaults to 503. - connection-limit (REASONED; ng): limit_conn apiconn 10 caps concurrent connections per client address; it can also return 503. - location-auth (REASONED; ng): Keep limits in the authenticated location; sibling locations do not inherit its auth or proxy headers. Repeat auth or inherit it from server; auth_basic off is deliberate. - read-timeout (REASONED; ng): proxy_read_timeout 60s limits gaps between upstream reads, not total response duration; active streams can continue indefinitely. - client-address (REASONED; ng): At a direct edge trust the appended last X-Forwarded-For entry; behind a CDN, recover the client with trusted set_real_ip_from and real_ip_header before per-client limits. - verify-config (REASONED; ng): Run nginx -t before reload; no configuration-test outcome is recorded. - verify-redirect (REASONED; ng): HTTP should return 301 with an HTTPS Location. - verify-tls (REASONED; ng): HTTPS must validate without -k; TLS 1.1-only rejection must be protocol_version, not a generic/local failure; test TLS 1.0 separately. - verify-auth (REASONED; ng): An open path may return 200; the protected path must reject no credentials with 401/403, and valid credentials reach the app rather than 401. - verify-size (REASONED; ng): Authenticated 9M succeeds normally only after raising the 1m default; 11M returns 413. Disable the check with 0 in isolation to distinguish backend refusal. - verify-rate (REASONED; ng): Concurrent unauthenticated probes exercise pre-auth limits; 503 does not identify the limiter. Compare both off and each alone with measured arrival rate. - verify-backend (REASONED; ng): Inspect every listener: direct public access to backend 3000 bypasses proxy TLS/auth even when front-door probes pass. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "8a4ab478aaaa2f58804b00b20a1d83e38acc7de0be9fdb5c2a6d95cdf256af1c", "components": { "nginx-source": { "name": "nginx source", "basis": "release-1.26.3", "sources": { "s0ae4e9bc8d07": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L216-L218", "s002242c2d0cd": "https://github.com/nginx/nginx/blob/release-1.26.3/docs/xml/nginx/changes.xml#L470-L480", "s305b7194043c": "https://github.com/nginx/nginx/blob/release-1.26.3/docs/xml/nginx/changes.xml#L739-L775", "sbea253fd242b": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/v2/ngx_http_v2_module.c#L76-L81", "s936193a42402": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_core_module.c#L4181-L4188", "sac3cbbf7fc48": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L857-L862", "s66c86979411e": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L153-L172", "sabb195c173d5": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L788-L802", "sa930199b563a": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_ssl_module.c#L74-L80", "seb1f8a9d91f9": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L666-L687", "sf6eac8f6749d": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L788-L796", "s3df6e64cd6a3": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_request_module.c#L135-L182", "sced570e33c0f": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_request.h#L78-L138", "sfafe27857e33": "https://github.com/nginx/nginx/blob/release-1.26.3/src/event/ngx_event_openssl.c#L4568-L4576" } }, "ng": { "name": "nginx documentation", "basis": "unknown", "sources": { "s6a0728ca7609": "https://nginx.org/en/docs/http/configuring_https_servers.html", "s40bdf1af1596": "https://nginx.org/en/docs/http/ngx_http_core_module.html", "sb3bee9429629": "https://nginx.org/en/docs/http/ngx_http_limit_req_module.html", "sddc43aac8779": "https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html", "sf3430c5a0b22": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html", "sf32ce2956917": "https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html", "sddacc1191f5f": "https://nginx.org/en/docs/http/ngx_http_realip_module.html" } }, "certbot": { "name": "Certbot nginx configurator", "basis": "v2.3.0", "sources": { "s3a02bfe1d7ad": "https://github.com/certbot/certbot/blob/v2.3.0/certbot-nginx/certbot_nginx/_internal/configurator.py#L711" } }, "authelia": { "name": "Authelia nginx integration", "basis": "v4.39.28", "sources": { "s279cd6449b53": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L39-L45", "sdf01968ab996": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L245-L247", "s237ae14bbb4a": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L517-L519" } }, "oauth2-proxy": { "name": "oauth2-proxy nginx integration", "basis": "v7.15.6", "sources": { "s29777d295c56": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/integrations/nginx.md#L16-L29", "s85b199e72a39": "https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.5" } }, "cloudflare-access": { "name": "Cloudflare Access web applications", "basis": "unknown", "sources": { "sfe56578bedf4": "https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/" } }, "httpd": { "name": "Apache httpd source", "basis": "2.4.69", "sources": { "s309ecdd34379": "https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128", "sf711558bdf3c": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45", "s1c1a101a70e7": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L290-L295", "s4482c55d79a2": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L382" } }, "debian-apache2": { "name": "Debian apache2 packaging", "basis": "2.4.69-1", "sources": { "sc2a26b3a7af5": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/control#L92-101", "s62f6ed2866ab": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1" } }, "policy": { "name": "Mozilla TLS policy", "basis": "unknown", "sources": { "s529b0eabe2ed": "https://ssl-config.mozilla.org/" } }, "hsts": { "name": "HSTS specification", "basis": "RFC 6797", "sources": { "s3f5af5f440f9": "https://www.rfc-editor.org/rfc/rfc6797.html#section-6.1.2" } }, "owasp": { "name": "OWASP Password Storage Cheat Sheet", "basis": "commit 72865b1e", "sources": { "s4531e65b6502": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13" } } }, "claims": { "certbot": {"text": "The guide says certbot --nginx edits the server block; certificate acquisition is delegated to the linked certificate guides.", "components": ["ng", "certbot"], "sources": ["ng:s6a0728ca7609", "certbot:s3a02bfe1d7ad"], "status": "REASONED"}, "tls": {"text": "443 IPv4/IPv6 TLS uses certificate chain and private key, TLSv1.2/TLSv1.3 and ssl_prefer_server_ciphers off.", "components": ["ng"], "sources": ["ng:s6a0728ca7609"], "status": "REASONED"}, "http2": {"text": "At nginx release-1.26.3, http2 on is the HTTP/2 module directive; nginx 1.25.1 introduced it and deprecated the older listen 443 ssl http2 parameter.", "components": ["ng", "nginx-source"], "sources": ["ng:s40bdf1af1596", "nginx-source:s002242c2d0cd", "nginx-source:sbea253fd242b", "nginx-source:s936193a42402"], "status": "REASONED"}, "tokens": {"text": "server_tokens off suppresses nginx version in headers and error pages.", "components": ["ng"], "sources": ["ng:s40bdf1af1596"], "status": "REASONED"}, "hsts": {"text": "Send HSTS only after HTTPS works; includeSubDomains commits every subdomain for max-age.", "components": ["ng", "hsts", "nginx-source"], "sources": ["ng:s6a0728ca7609", "hsts:s3f5af5f440f9", "nginx-source:s0ae4e9bc8d07"], "status": "REASONED"}, "hsts-inheritance": {"text": "At nginx release-1.26.3, a location add_header replaces inherited server headers; always changes response-code coverage, not inheritance.", "components": ["ng", "nginx-source"], "sources": ["ng:s40bdf1af1596", "nginx-source:s0ae4e9bc8d07", "nginx-source:seb1f8a9d91f9", "nginx-source:sf6eac8f6749d"], "status": "REASONED"}, "proxy": {"text": "Proxy to loopback 127.0.0.1:3000 and set Host, X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host.", "components": ["ng"], "sources": ["ng:sf3430c5a0b22"], "status": "REASONED"}, "forwarded-host": {"text": "Overwrite X-Forwarded-Host: client-set values otherwise pass upstream.", "components": ["ng"], "sources": ["ng:sf3430c5a0b22"], "status": "REASONED"}, "cipher-policy": {"text": "Use Mozilla for explicit cipher policy; retain the stated protocol floor.", "components": ["ng", "policy"], "sources": ["ng:s6a0728ca7609", "policy:s529b0eabe2ed"], "status": "REASONED"}, "tickets": {"text": "At nginx release-1.26.3, ssl_session_tickets defaults on; nginx 1.23.2 introduced automatic ticket-key rotation with a shared ssl_session_cache.", "components": ["ng", "nginx-source"], "sources": ["ng:s6a0728ca7609", "nginx-source:sac3cbbf7fc48", "nginx-source:s305b7194043c", "nginx-source:sfafe27857e33"], "status": "REASONED"}, "redirect": {"text": "Port 80 on IPv4/IPv6 redirects with 301; a default server reflecting $host can redirect unmatched hosts off-site, so use 444 or a canonical host.", "components": ["ng"], "sources": ["ng:s40bdf1af1596"], "status": "REASONED"}, "password-file": {"text": "At httpd 2.4.69, htpasswd -B selects bcrypt, bare -B uses cost 5, and -C sets the cost; the OWASP cheat sheet at commit 72865b1e recommends at least 10 for legacy bcrypt. Debian apache2 2.4.69-1 packages htpasswd in apache2-utils.", "components": ["ng", "httpd", "owasp", "debian-apache2"], "sources": ["ng:sf32ce2956917", "debian-apache2:sc2a26b3a7af5", "debian-apache2:s62f6ed2866ab", "httpd:s309ecdd34379", "httpd:sf711558bdf3c", "httpd:s1c1a101a70e7", "httpd:s4482c55d79a2", "owasp:s4531e65b6502"], "status": "REASONED"}, "basic": {"text": "auth_basic and auth_basic_user_file gate the existing proxied location over TLS; preserve its forwarded headers.", "components": ["ng"], "sources": ["ng:sf32ce2956917", "ng:sf3430c5a0b22"], "status": "REASONED"}, "mtls": {"text": "At nginx release-1.26.3, ssl_client_certificate and ssl_verify_client on configure trusted client-certificate verification.", "components": ["ng", "nginx-source"], "sources": ["ng:s6a0728ca7609", "nginx-source:s66c86979411e", "nginx-source:sabb195c173d5", "nginx-source:sa930199b563a"], "status": "REASONED"}, "mfa": {"text": "At nginx release-1.26.3, auth_request permits 2xx authentication subresponses and returns 401/403; Authelia v4.39.28 and oauth2-proxy v7.15.6 document nginx auth_request integration, and Cloudflare Access documents protecting self-hosted web applications; the nginx source cited here does not cover those integrations. MFA is this guide's requirement; whether a given installation enforces it depends on its configured portal or Access policy (this guide's assessment).", "components": ["ng", "nginx-source", "authelia", "oauth2-proxy", "cloudflare-access"], "sources": ["ng:sf32ce2956917", "nginx-source:s3df6e64cd6a3", "nginx-source:sced570e33c0f", "authelia:s279cd6449b53", "authelia:sdf01968ab996", "authelia:s237ae14bbb4a", "oauth2-proxy:s29777d295c56", "cloudflare-access:sfe56578bedf4"], "status": "REASONED"}, "oauth2-proxy-client-ip": {"text": "oauth2-proxy v7.15.6 says to set --trusted-proxy-ip=127.0.0.1/32 when Nginx connects from localhost; the v7.15.5 release notes list critical advisory GHSA-wr5q-7wxw-x568, an authentication bypass via spoofed client-IP headers, as fixed in that release, and say that an unset --trusted-proxy-ip trusts all source addresses.", "components": ["oauth2-proxy"], "sources": ["oauth2-proxy:s29777d295c56", "oauth2-proxy:s85b199e72a39"], "status": "REASONED"}, "body-limit": {"text": "client_max_body_size defaults to 1m; 10m rejects larger bodies with 413, and 0 disables the check.", "components": ["ng"], "sources": ["ng:s40bdf1af1596"], "status": "REASONED"}, "rate-limit": {"text": "Per-address limit_req uses a 10r/s zone, burst 20 and nodelay; rejection status defaults to 503.", "components": ["ng"], "sources": ["ng:sb3bee9429629"], "status": "REASONED"}, "connection-limit": {"text": "limit_conn apiconn 10 caps concurrent connections per client address; it can also return 503.", "components": ["ng"], "sources": ["ng:sddc43aac8779"], "status": "REASONED"}, "location-auth": {"text": "Keep limits in the authenticated location; sibling locations do not inherit its auth or proxy headers. Repeat auth or inherit it from server; auth_basic off is deliberate.", "components": ["ng"], "sources": ["ng:sf32ce2956917", "ng:s40bdf1af1596", "ng:sf3430c5a0b22"], "status": "REASONED"}, "read-timeout": {"text": "proxy_read_timeout 60s limits gaps between upstream reads, not total response duration; active streams can continue indefinitely.", "components": ["ng"], "sources": ["ng:sf3430c5a0b22"], "status": "REASONED"}, "client-address": {"text": "At a direct edge trust the appended last X-Forwarded-For entry; behind a CDN, recover the client with trusted set_real_ip_from and real_ip_header before per-client limits.", "components": ["ng"], "sources": ["ng:sf3430c5a0b22", "ng:sddacc1191f5f", "ng:sb3bee9429629", "ng:sddc43aac8779"], "status": "REASONED"}, "verify-config": {"text": "Run nginx -t before reload; no configuration-test outcome is recorded.", "components": ["ng"], "sources": ["ng:s40bdf1af1596"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "HTTP should return 301 with an HTTPS Location.", "components": ["ng"], "sources": ["ng:s40bdf1af1596"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "HTTPS must validate without -k; TLS 1.1-only rejection must be protocol_version, not a generic/local failure; test TLS 1.0 separately.", "components": ["ng"], "sources": ["ng:s6a0728ca7609"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "An open path may return 200; the protected path must reject no credentials with 401/403, and valid credentials reach the app rather than 401.", "components": ["ng"], "sources": ["ng:sf32ce2956917"], "status": "REASONED", "verify": [1]}, "verify-size": {"text": "Authenticated 9M succeeds normally only after raising the 1m default; 11M returns 413. Disable the check with 0 in isolation to distinguish backend refusal.", "components": ["ng"], "sources": ["ng:s40bdf1af1596"], "status": "REASONED", "verify": [1]}, "verify-rate": {"text": "Concurrent unauthenticated probes exercise pre-auth limits; 503 does not identify the limiter. Compare both off and each alone with measured arrival rate.", "components": ["ng"], "sources": ["ng:sb3bee9429629", "ng:sddc43aac8779", "ng:sf32ce2956917"], "status": "REASONED", "verify": [1]}, "verify-backend": {"text": "Inspect every listener: direct public access to backend 3000 bypasses proxy TLS/auth even when front-door probes pass.", "components": ["ng"], "sources": ["ng:sf3430c5a0b22"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> lighttpd.md ====================================================================== # lighttpd: TLS and authentication lighttpd is a web server used to serve websites and web applications. This guide helps your AI assistant make sure it is configured securely: enabling TLS, redirecting HTTP to HTTPS, and requiring authentication. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/lighttpd.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against lighttpd TLS minimum 1.4.56, lighttpd separate-key minimum 1.4.53, lighttpd redirect default transition 1.4.75, lighttpd module loader source 1.4.75, lighttpd file authentication source 1.4.75, lighttpd verify source 1.4.75, Apache htpasswd manual 2.4, and Debian apache2-utils package trixie, plus 5 more recorded bases; 2 further sources carry no recorded version. Metadata reviewed 2026-10-19; documentation checked September 2026. The guide records 16 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Applies to lighttpd 1.4.56 and later. NEWS records the `mod_openssl` `MinProtocol TLSv1.2` default in its 1.4.56 release section (https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L1660-L1660; https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L1927-L1927). Disabling SSLv2/SSLv3/TLS 1.0/TLS 1.1 is this guide's reading of that floor; NEWS does not establish defaults for every later release. Get a certificate first: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). ## 1. Enable TLS ``` server.modules += ( "mod_openssl" ) $SERVER["socket"] == ":443" { ssl.engine = "enable" ssl.pemfile = "/etc/letsencrypt/live/example.com/fullchain.pem" ssl.privkey = "/etc/letsencrypt/live/example.com/privkey.pem" } $SERVER["socket"] == "[::]:443" { ssl.engine = "enable" ssl.pemfile = "/etc/letsencrypt/live/example.com/fullchain.pem" ssl.privkey = "/etc/letsencrypt/live/example.com/privkey.pem" } ``` Version notes: - NEWS records the optional `mod_openssl` `ssl.privkey` directive in its 1.4.53 release section (https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L2465-L2465; https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L2505-L2505). On older versions, concatenate certificate and key into one file and point `ssl.pemfile` at it (this guide's compatibility advice; the entry does not establish that procedure). - To set the protocol floor explicitly (this guide's advice; NEWS records the `mod_openssl` TLS 1.2 minimum default in 1.4.56, not every later release): ``` ssl.openssl.ssl-conf-cmd = ( "MinProtocol" => "TLSv1.2" ) ``` ## 2. Redirect HTTP to HTTPS Load `mod_redirect` explicitly. In lighttpd 1.4.75, `configfile.c` inserts `mod_h2` unless `server.feature-flags` disables `server.h2proto` (enabled by default). In 1.4.75, when `server.compat-module-load` is enabled (the default), `configfile.c` adds `mod_indexfile` when index files are enabled, `mod_dirlisting` when directory listing is enabled, and `mod_staticfile` when it is absent from `server.modules`; compatibility handling can also add other modules, including authentication and TLS modules under their respective conditions. For other versions, check the module-loading behaviour. Per the lighttpd wiki: ``` server.modules += ( "mod_redirect" ) $HTTP["scheme"] == "http" { url.redirect = ("" => "https://${url.authority}${url.path}${qsa}") url.redirect-code = 308 # explicit on versions before 1.4.75 } ``` NEWS records `url.redirect-code = 308` as the new default in its 1.4.75 release section (https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L6-L7). Setting 308 explicitly before 1.4.75 is this guide's advice; the entry does not specify each earlier default. ## 3. Require authentication Application-level login is preferable ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). Basic authentication at the server, over TLS only: ``` server.modules += ( "mod_auth", "mod_authn_file" ) auth.backend = "htpasswd" auth.backend.htpasswd.userfile = "/etc/lighttpd/lighttpd.user" auth.require = ( "/" => ( "method" => "basic", "realm" => "Restricted", "require" => "valid-user" ) ) ``` Create the user file with Apache's `htpasswd` (Debian `apache2-utils`; for Fedora, use `httpd-tools` as this guide's package assessment). In lighttpd 1.4.75, the htpasswd backend reads `username:stored-hash` entries. The 1.4.75 htpasswd backend checks `{SHA}` SHA-1 and `$apr1$` hashes internally and, when built with `crypt()` support, checks other stored hashes of at least 13 characters through the platform's `crypt()`, except that, when built with MD4 support, the `$1+ntlm$` path derives an NTLM hash and calls `crypt()` with the derived value and a `$1$` salt. The `$1+ntlm$` path is conditional on `USE_LIB_CRYPTO_MD4` in the 1.4.75 source (https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_authn_file.c#L340-L397; https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_authn_file.c#L613-L740). Check the build and platform's accepted hash formats before choosing an `htpasswd` flag; this is the guide's compatibility advice. Check the lighttpd mod_auth documentation and Apache `htpasswd` manual in Sources for the supported hash formats. Basic authentication is single-factor, and this guide finds no lighttpd entry in Authelia v4.39.28's complete supported-proxy list (https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L24-L36). Add MFA by fronting the service with Cloudflare Access ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)) or an MFA-capable proxy; options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Cloudflare documents an Access application as an authentication layer between users and a self-hosted origin. Cloudflare Access application sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16. More Cloudflare Access application sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54. Cloudflare documents an Access policy that can require MFA. Cloudflare Access MFA policy sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81. Applying Access and its MFA policy to a lighttpd origin is this guide's assessment. This guide assesses that a lighttpd origin reachable without passing through Access is not protected by it. Origin-protection assessment sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L45-L45. More origin-protection assessment sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48. ## 4. Verify REASONED: interpretation procedure from the status-output commands and expected outcomes below; no deployment or protected path was supplied. The block classifies its two authentication requests and exits once at the end: 1 if the site root answers a 2xx without credentials or the request carrying another Host answers 200, otherwise 3 if either answers anything but 401 or the configuration test, the reload or a curl or ss command fails, otherwise 2 if `ss` is not installed, and otherwise 0 when both answer 401. The redirect headers, and the listener list when `ss` ran, stay for you to judge, and its last line names them. REASONED: configuration, redirect, authentication, listener inventory and alternate-Host checks follow the cited lighttpd TLS, mod_auth, redirect and configuration documentation and the pinned command, unit, systemd, host-condition and Apache virtual-host sources below. No deployment or protected path was supplied for live checks, and this guide records no run; expected outcomes are stated in the following block. The reload line is this guide's advice for an installed unit that reads `/etc/lighttpd/lighttpd.conf` and defines reload; confirm both before using it. The cited upstream 1.4.75 unit does so, but its signal-based reload is asynchronous. The block takes the site's host name and a protected path on its guarded `set --` line, refuses example.com, example.net, example.org and their subdomains, and only then runs the configuration test and reload. Its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes; no stub or deployment run of it is recorded here. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_A_PROTECTED_PATH' # the site's public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot), then a protected path without its leading slash: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|/*|*[[:space:][:cntrl:]]*) echo "substitute a protected path, without its leading slash, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (ss is not installed), which outranks 0 if sudo lighttpd -tt -f /etc/lighttpd/lighttpd.conf && sudo systemctl reload lighttpd; then :; else echo "configuration test or reload exit $?: the requests below may test the configuration lighttpd was already running; inconclusive"; set -- "$1" "$2" 3 fi curl -q -g -sI "http://$1/" || # expect a redirect to https:// { echo "curl exit $?: the redirect request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } set -- "$1" "$2" "$3" "$(if curl -q -g -s -o /dev/null -w '%{http_code}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # expect 401 without credentials once auth is on case "$4:$5" in 0:401) ;; 0:2??) echo "FINDING: / answered $5 without credentials"; set -- "$1" "$2" 1 ;; 0:*) echo "HTTP $5 from / without credentials is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the HTTPS request without credentials failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" # Read every listener rather than filtering to the ports you expect. The checks above prove # the front door asks for credentials; they do not prove it is the only door, and a filtered # list cannot show you a port you did not think of. if command -v ss >/dev/null; then ss -tlnp || { echo "ss -tlnp exit $?: the listener list failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } set -- "$1" "$2" "$3" listed # the 4th value says whether ss ran, so the last line names the listener list only when it did else echo "ss is not installed here; not listing sockets" case "$3" in 0) set -- "$1" "$2" 2 ;; esac set -- "$1" "$2" "$3" unlisted fi # `auth.require` can sit at the top level or inside a `$HTTP["host"]` conditional, and a # conditional one applies only to the hosts it names. If yours is conditional, a request # carrying a host it does not name is served by the global configuration instead (this # guide's inference from conditional merging and the mod_auth documentation). That # conditional uses the request Host for auth after headers are read. In lighttpd 1.4.75, # TLS SNI, when the client sends it, first selects mod_openssl settings; Apache # 2.4.69 uses the SNI hostname, when the client sends it and the server supports SNI, # for name-based TLS virtual-host # selection. This guide's advice is to vary Host while keeping the URL and SNI fixed for # this auth check: set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -s -o /dev/null -w '%{http_code}\n' -H 'Host: not-configured.example' \ "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # This guide interprets 401 as the pass and 200 as the protected # path served without authentication on this TLS connection to a # client that sends another host case "$5:$6" in 0:401) ;; 0:200) echo "FINDING: /$2 answered 200 to a request carrying another Host, so it was served without authentication"; set -- "$1" "$2" 1 "$4" ;; 0:*) echo "HTTP $6 from /$2 with another Host is neither the 401 pass nor the 200 finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4" ;; *) echo "curl exit $5: the request carrying another Host failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4" ;; esac case "$4" in listed) echo "block exit $3; the redirect headers and the listener list are not classified, so judge them yourself" ;; *) echo "block exit $3; the redirect headers are not classified, so judge them yourself" ;; esac exit "$3" ) ``` ## Common mistakes - Loading `mod_openssl` but leaving the `:80` socket serving content instead of only the redirect. - Forgetting the `[::]:443` socket, leaving IPv6 clients on plain HTTP. - Pointing `ssl.pemfile` at a certificate without its chain; use `fullchain.pem`. ## Sources (checked September 2026) - lighttpd 1.4.75 NEWS, 1.4.56 release heading (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L1660-L1660 - lighttpd 1.4.75 NEWS, 1.4.56 mod_openssl protocol default (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L1927-L1927 - lighttpd 1.4.75 NEWS, 1.4.53 release heading (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L2465-L2465 - lighttpd 1.4.75 NEWS, 1.4.53 optional separate-key directive (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L2505-L2505 - lighttpd 1.4.75 NEWS, 1.4.75 redirect-code default (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L6-L7 - lighttpd 1.4.75 -f option (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/doc/lighttpd.8#L22-L23 - lighttpd 1.4.75 -t and -tt options (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/doc/lighttpd.8#L33-L37 - lighttpd 1.4.75 configuration-read error exit (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1439-L1451 - lighttpd 1.4.75 -p and -t option parsing (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1466-L1467 - lighttpd 1.4.75 preflight test path (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1510-L1523 - lighttpd 1.4.75 module and configuration-finalization error exits (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1942-L1953 - lighttpd 1.4.75 process exit result (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L2320-L2384 - lighttpd 1.4.75 USR1 graceful restart handler (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L229-L235 - lighttpd 1.4.75 upstream systemd unit (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/doc/systemd/lighttpd.service#L5-L11 - lighttpd 1.4.75 Host header parsing (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/request.c#L423-L430 - lighttpd 1.4.75 request authority from Host (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/request.c#L1235-L1247 - lighttpd 1.4.75 host-condition value (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile-glue.c#L504-L508 - lighttpd 1.4.75 conditional configuration merge (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L213-L223 - lighttpd 1.4.75 OpenSSL conditional settings (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_openssl.c#L1025-L1034 - lighttpd 1.4.75 SNI callback and deferred Host-condition reset note (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_openssl.c#L1294-L1330 - lighttpd 1.4.75 post-header condition-cache reset and configuration merge (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/response.c#L227-L228 - systemd v262 systemctl reload semantics (checked October 2026): https://github.com/systemd/systemd/blob/v262/man/systemctl.xml#L462-L474 - systemd v262 ExecReload and asynchronous signal semantics (checked October 2026): https://github.com/systemd/systemd/blob/v262/man/systemd.service.xml#L498-L519 - Apache 2.4.69 TLS virtual-host SNI selection (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/docs/manual/vhosts/details.xml#L217-L231 - lighttpd TLS documentation (1.4.56 and later; ssl.privkey from 1.4.53): https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_SSL - lighttpd mod_auth documentation, including `auth.require` inside a `$HTTP["host"]` conditional: https://redmine.lighttpd.net/projects/lighttpd/wiki/Mod_auth - lighttpd HTTP-to-HTTPS redirect how-to (308 explicit before 1.4.75): https://redmine.lighttpd.net/projects/lighttpd/wiki/HowToRedirectHttpToHttps - lighttpd configuration options (`server.modules` and default modules): https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ConfigurationOptions - lighttpd 1.4.75 compatibility loader and conditional modules source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L395-L544 - lighttpd 1.4.75 TLS module helper source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L270-L290 - lighttpd 1.4.75 authentication module helper source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L382-L393 - lighttpd 1.4.75 directive binding, feature flag and compatibility gate source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L662-L945 - lighttpd 1.4.75 compatibility loader default source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L1684-L1711 - lighttpd 1.4.75 htpasswd parsing source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_authn_file.c#L340-L397 - lighttpd 1.4.75 htpasswd password check source (checked October 2026): https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_authn_file.c#L613-L740 - Apache HTTP Server 2.4 `htpasswd` manual (checked October 2026): https://httpd.apache.org/docs/2.4/programs/htpasswd.html - Debian trixie `apache2-utils` package (checked October 2026): https://packages.debian.org/trixie/apache2-utils - Authelia v4.39.28 supported-proxy list, complete table (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L24-L36 - Cloudflare Access self-hosted application at commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026). Cloudflare Access self-hosted application sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16. More Cloudflare Access self-hosted application sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54 - Cloudflare Access origin reachability at commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026). Cloudflare Access origin reachability sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L45-L45. More Cloudflare Access origin reachability sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48 - Cloudflare Access MFA policy controls at commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81 - Fedora `httpd-tools` package (checked October 2026): https://packages.fedoraproject.org/pkgs/httpd/httpd-tools/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - tls: lighttpd TLS minimum (1.4.56) - key: lighttpd separate-key minimum (1.4.53) - redirect: lighttpd redirect default transition (1.4.75) - modules-source: lighttpd module loader source (1.4.75) - authn-source: lighttpd file authentication source (1.4.75) - lighttpd-verify-source: lighttpd verify source (1.4.75) - docs: lighttpd documentation (unknown) - htpasswd-tool: Apache htpasswd manual (2.4) - debian-package: Debian apache2-utils package (trixie) - authelia: Authelia proxy support (v4.39.28) - cloudflare-access: Cloudflare Access documentation (commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - fedora-package: Fedora httpd-tools package (unknown) - apache-vhosts: Apache virtual-host source (2.4.69) - systemd-reload: systemd reload manual (v262) - release-news: lighttpd release NEWS (1.4.75) Claims (full source mapping in the JSON block below): - tls-defaults (REASONED; tls, release-news): For this guide's 1.4.56-and-later scope, NEWS records the mod_openssl MinProtocol TLSv1.2 default in 1.4.56. Disabling SSLv2, SSLv3, TLS 1.0 and TLS 1.1 is this guide's reading of that floor; NEWS does not establish defaults for every later release. - tls-listeners (REASONED; tls): Load mod_openssl and enable TLS on both :443 and [::]:443 with ssl.pemfile and ssl.privkey; omitting the IPv6 socket leaves IPv6 clients on plain HTTP. - certificate-chain (REASONED; tls): ssl.pemfile uses fullchain.pem rather than a certificate without its chain. - separate-key (REASONED; key, release-news): NEWS records optional mod_openssl ssl.privkey in 1.4.53. On older versions, concatenate certificate and key into ssl.pemfile (this guide's compatibility advice; the entry does not establish that procedure). - protocol-floor (REASONED; tls, release-news): Set MinProtocol to TLSv1.2 with ssl.openssl.ssl-conf-cmd explicitly (this guide's advice); NEWS records that default for mod_openssl in 1.4.56, not every later release. - module-defaults (REASONED; docs, modules-source): In lighttpd 1.4.75, mod_h2 is inserted unless server.feature-flags disables server.h2proto (enabled by default); when server.compat-module-load is enabled (the default), compatibility handling can add other modules, including authentication and TLS modules under their respective conditions. Load mod_redirect explicitly. - http-redirect (REASONED; docs): For the http scheme, redirect to HTTPS preserving authority, path and query; port 80 must redirect rather than serve content. - redirect-code (REASONED; redirect, release-news): NEWS records url.redirect-code = 308 as the new default in 1.4.75. Set 308 explicitly before 1.4.75 (this guide's advice; the entry does not specify each earlier default). - basic-auth (REASONED; docs): Load mod_auth and mod_authn_file, select the htpasswd file backend and require valid-user Basic authentication on / over TLS; application login is preferable. - password-file (REASONED; docs, authn-source, htpasswd-tool, debian-package, fedora-package): Create the user file with Apache htpasswd (Debian apache2-utils; Fedora httpd-tools is this guide's package assessment); lighttpd 1.4.75 reads username:stored-hash entries and checks {SHA} SHA-1 and $apr1$ internally. Check the build and platform's accepted hashes before choosing a flag; this check is this guide's compatibility advice. - mfa (REASONED; docs, authelia, cloudflare-access): Basic authentication is single-factor; add MFA by fronting this service with Cloudflare Access or an MFA-capable proxy (this guide's requirement). Authelia v4.39.28's complete supported-proxy list has no lighttpd entry (this guide's inference from the table); Cloudflare Access documents self-hosted application and MFA policy controls; applying them to lighttpd is this guide's assessment. This guide assesses that a lighttpd origin reachable without passing through Access is not protected by it. - verify-config (REASONED; docs, lighttpd-verify-source, systemd-reload): lighttpd 1.4.75 -tt -f /etc/lighttpd/lighttpd.conf tests the named file for syntax errors and initializes modules; configuration failures prevent the && reload (this guide's inference from the nonzero process result and shell chaining). The cited upstream unit starts from that file and defines a USR1 reload through ExecReload; systemctl reload triggers the service action, whose signal does not establish completion. Confirm the installed unit uses that file and defines reload (this guide's advice). - verify-redirect (REASONED; docs): The HTTP header request should show a redirect to HTTPS; the block leaves that header for the reader to judge. - verify-auth (REASONED; docs): An HTTPS request without credentials should return 401 once authentication is enabled. - verify-listeners (REASONED; tls): Read every listener with ss -tlnp: front-door authentication does not establish that no other listener serves content. The ss version is unrecorded. - verify-host (REASONED; docs, lighttpd-verify-source, apache-vhosts): In lighttpd 1.4.75, request-time $HTTP["host"] matching uses the HTTP Host value, while TLS SNI can select mod_openssl settings before request headers; a Host outside a conditional auth.require falls back to global configuration for that rule (this guide's inference from conditional merging and mod_auth documentation). On a protected path, this guide interprets 401 as the pass and 200 as the protected path served without authentication on this TLS connection to a client that sends another host. Apache 2.4.69 uses the SNI hostname, when the client sends it and the server supports SNI, for name-based TLS virtual-host selection; varying Host while keeping URL and SNI fixed is this guide's auth-check advice. ````json { "schema": 1, "checked": "2026-10-19", "documentation_checked": "2026-09", "body_sha256": "b408b44fee9507e7328eb4a7d86dc8f89df051aa27999d49be595a175d7c2be0", "components": { "tls": { "name": "lighttpd TLS minimum", "basis": "1.4.56", "sources": { "sbff3cb2e01a8": "https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_SSL" } }, "key": { "name": "lighttpd separate-key minimum", "basis": "1.4.53", "sources": { "sbff3cb2e01a8": "https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_SSL" } }, "redirect": { "name": "lighttpd redirect default transition", "basis": "1.4.75", "sources": { "s6de629323c64": "https://redmine.lighttpd.net/projects/lighttpd/wiki/HowToRedirectHttpToHttps" } }, "modules-source": { "name": "lighttpd module loader source", "basis": "1.4.75", "sources": { "s8680e561bed2": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L395-L544", "s8e9688979427": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L270-L290", "s9bd716d27e8d": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L382-L393", "s21288573041a": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L662-L945", "s67bb3733d81c": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L1684-L1711" } }, "authn-source": { "name": "lighttpd file authentication source", "basis": "1.4.75", "sources": { "s2c11c0fbc0ca": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_authn_file.c#L340-L397", "se971effb489f": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_authn_file.c#L613-L740" } }, "lighttpd-verify-source": { "name": "lighttpd verify source", "basis": "1.4.75", "sources": { "s0bbb4c861d5e": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/doc/lighttpd.8#L22-L23", "sca076bcd18dc": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/doc/lighttpd.8#L33-L37", "s9e69235fbbc7": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1439-L1451", "s87104d328493": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1466-L1467", "sba72bab46867": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1510-L1523", "s2faa9b3149ac": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L1942-L1953", "s87b4b266bb0e": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L2320-L2384", "sa989ef425f1d": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/server.c#L229-L235", "sf9d56b052c61": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/doc/systemd/lighttpd.service#L5-L11", "s6a29b290d83a": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/request.c#L423-L430", "s2fc4cf82abd9": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/request.c#L1235-L1247", "s3f692a48bd01": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile-glue.c#L504-L508", "sbb09703388c3": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/configfile.c#L213-L223", "s8bb6bd7774f9": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_openssl.c#L1025-L1034", "sb4c43fa1a807": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/mod_openssl.c#L1294-L1330", "s9e49b928cc81": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/src/response.c#L227-L228" } }, "docs": { "name": "lighttpd documentation", "basis": "unknown", "sources": { "s1dbd1ce27da5": "https://redmine.lighttpd.net/projects/lighttpd/wiki/Mod_auth", "s6d5666b862a0": "https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ConfigurationOptions", "s6de629323c64": "https://redmine.lighttpd.net/projects/lighttpd/wiki/HowToRedirectHttpToHttps" } }, "htpasswd-tool": { "name": "Apache htpasswd manual", "basis": "2.4", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "debian-package": { "name": "Debian apache2-utils package", "basis": "trixie", "sources": { "s436bdf5f1fb9": "https://packages.debian.org/trixie/apache2-utils" } }, "authelia": { "name": "Authelia proxy support", "basis": "v4.39.28", "sources": { "s77083bc8faa2": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L24-L36" } }, "cloudflare-access": { "name": "Cloudflare Access documentation", "basis": "commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16", "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54", "s603bdb6b24ab": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L45-L45", "s863803bd9a1f": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48", "sdcac732541da": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81" } }, "fedora-package": { "name": "Fedora httpd-tools package", "basis": "unknown", "sources": { "s919088d03a5e": "https://packages.fedoraproject.org/pkgs/httpd/httpd-tools/" } }, "apache-vhosts": { "name": "Apache virtual-host source", "basis": "2.4.69", "sources": { "s558db45d4d61": "https://github.com/apache/httpd/blob/2.4.69/docs/manual/vhosts/details.xml#L217-L231" } }, "systemd-reload": { "name": "systemd reload manual", "basis": "v262", "sources": { "sb3f5069cd250": "https://github.com/systemd/systemd/blob/v262/man/systemctl.xml#L462-L474", "s441757b7048f": "https://github.com/systemd/systemd/blob/v262/man/systemd.service.xml#L498-L519" } }, "release-news": { "name": "lighttpd release NEWS", "basis": "1.4.75", "sources": { "s4e990a9e5ec1": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L1660-L1660", "se97c93bf64a8": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L1927-L1927", "s1658d22c71f9": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L2465-L2465", "s95dd525fe4ac": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L2505-L2505", "sc0d0162991d5": "https://git.lighttpd.net/lighttpd/lighttpd1.4/src/tag/lighttpd-1.4.75/NEWS#L6-L7" } } }, "claims": { "tls-defaults": {"text": "For this guide's 1.4.56-and-later scope, NEWS records the mod_openssl MinProtocol TLSv1.2 default in 1.4.56. Disabling SSLv2, SSLv3, TLS 1.0 and TLS 1.1 is this guide's reading of that floor; NEWS does not establish defaults for every later release.", "components": ["tls", "release-news"], "sources": ["tls:sbff3cb2e01a8", "release-news:s4e990a9e5ec1", "release-news:se97c93bf64a8"], "status": "REASONED"}, "tls-listeners": {"text": "Load mod_openssl and enable TLS on both :443 and [::]:443 with ssl.pemfile and ssl.privkey; omitting the IPv6 socket leaves IPv6 clients on plain HTTP.", "components": ["tls"], "sources": ["tls:sbff3cb2e01a8"], "status": "REASONED"}, "certificate-chain": {"text": "ssl.pemfile uses fullchain.pem rather than a certificate without its chain.", "components": ["tls"], "sources": ["tls:sbff3cb2e01a8"], "status": "REASONED"}, "separate-key": {"text": "NEWS records optional mod_openssl ssl.privkey in 1.4.53. On older versions, concatenate certificate and key into ssl.pemfile (this guide's compatibility advice; the entry does not establish that procedure).", "components": ["key", "release-news"], "sources": ["key:sbff3cb2e01a8", "release-news:s1658d22c71f9", "release-news:s95dd525fe4ac"], "status": "REASONED"}, "protocol-floor": {"text": "Set MinProtocol to TLSv1.2 with ssl.openssl.ssl-conf-cmd explicitly (this guide's advice); NEWS records that default for mod_openssl in 1.4.56, not every later release.", "components": ["tls", "release-news"], "sources": ["tls:sbff3cb2e01a8", "release-news:s4e990a9e5ec1", "release-news:se97c93bf64a8"], "status": "REASONED"}, "module-defaults": {"text": "In lighttpd 1.4.75, mod_h2 is inserted unless server.feature-flags disables server.h2proto (enabled by default); when server.compat-module-load is enabled (the default), compatibility handling can add other modules, including authentication and TLS modules under their respective conditions. Load mod_redirect explicitly.", "components": ["docs", "modules-source"], "sources": ["docs:s6d5666b862a0", "modules-source:s8680e561bed2", "modules-source:s8e9688979427", "modules-source:s9bd716d27e8d", "modules-source:s21288573041a", "modules-source:s67bb3733d81c"], "status": "REASONED"}, "http-redirect": {"text": "For the http scheme, redirect to HTTPS preserving authority, path and query; port 80 must redirect rather than serve content.", "components": ["docs"], "sources": ["docs:s6de629323c64"], "status": "REASONED"}, "redirect-code": {"text": "NEWS records url.redirect-code = 308 as the new default in 1.4.75. Set 308 explicitly before 1.4.75 (this guide's advice; the entry does not specify each earlier default).", "components": ["redirect", "release-news"], "sources": ["redirect:s6de629323c64", "release-news:sc0d0162991d5"], "status": "REASONED"}, "basic-auth": {"text": "Load mod_auth and mod_authn_file, select the htpasswd file backend and require valid-user Basic authentication on / over TLS; application login is preferable.", "components": ["docs"], "sources": ["docs:s1dbd1ce27da5"], "status": "REASONED"}, "password-file": {"text": "Create the user file with Apache htpasswd (Debian apache2-utils; Fedora httpd-tools is this guide's package assessment); lighttpd 1.4.75 reads username:stored-hash entries and checks {SHA} SHA-1 and $apr1$ internally. Check the build and platform's accepted hashes before choosing a flag; this check is this guide's compatibility advice.", "components": ["docs", "authn-source", "htpasswd-tool", "debian-package", "fedora-package"], "sources": ["docs:s1dbd1ce27da5", "authn-source:s2c11c0fbc0ca", "authn-source:se971effb489f", "htpasswd-tool:scde9004bb967", "debian-package:s436bdf5f1fb9", "fedora-package:s919088d03a5e"], "status": "REASONED"}, "mfa": {"text": "Basic authentication is single-factor; add MFA by fronting this service with Cloudflare Access or an MFA-capable proxy (this guide's requirement). Authelia v4.39.28's complete supported-proxy list has no lighttpd entry (this guide's inference from the table); Cloudflare Access documents self-hosted application and MFA policy controls; applying them to lighttpd is this guide's assessment. This guide assesses that a lighttpd origin reachable without passing through Access is not protected by it.", "components": ["docs", "authelia", "cloudflare-access"], "sources": ["docs:s1dbd1ce27da5", "authelia:s77083bc8faa2", "cloudflare-access:sfddb92e438ca", "cloudflare-access:s7cafe1b17afd", "cloudflare-access:sdcac732541da", "cloudflare-access:s603bdb6b24ab", "cloudflare-access:s863803bd9a1f"], "status": "REASONED"}, "verify-config": {"text": "lighttpd 1.4.75 -tt -f /etc/lighttpd/lighttpd.conf tests the named file for syntax errors and initializes modules; configuration failures prevent the && reload (this guide's inference from the nonzero process result and shell chaining). The cited upstream unit starts from that file and defines a USR1 reload through ExecReload; systemctl reload triggers the service action, whose signal does not establish completion. Confirm the installed unit uses that file and defines reload (this guide's advice).", "components": ["docs", "lighttpd-verify-source", "systemd-reload"], "sources": ["docs:s6d5666b862a0", "lighttpd-verify-source:s0bbb4c861d5e", "lighttpd-verify-source:sca076bcd18dc", "lighttpd-verify-source:s9e69235fbbc7", "lighttpd-verify-source:s87104d328493", "lighttpd-verify-source:sba72bab46867", "lighttpd-verify-source:s2faa9b3149ac", "lighttpd-verify-source:s87b4b266bb0e", "lighttpd-verify-source:sa989ef425f1d", "lighttpd-verify-source:sf9d56b052c61", "systemd-reload:sb3f5069cd250", "systemd-reload:s441757b7048f"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "The HTTP header request should show a redirect to HTTPS; the block leaves that header for the reader to judge.", "components": ["docs"], "sources": ["docs:s6de629323c64"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "An HTTPS request without credentials should return 401 once authentication is enabled.", "components": ["docs"], "sources": ["docs:s1dbd1ce27da5"], "status": "REASONED", "verify": [1]}, "verify-listeners": {"text": "Read every listener with ss -tlnp: front-door authentication does not establish that no other listener serves content. The ss version is unrecorded.", "components": ["tls"], "sources": ["tls:sbff3cb2e01a8"], "status": "REASONED", "verify": [1]}, "verify-host": {"text": "In lighttpd 1.4.75, request-time $HTTP[\"host\"] matching uses the HTTP Host value, while TLS SNI can select mod_openssl settings before request headers; a Host outside a conditional auth.require falls back to global configuration for that rule (this guide's inference from conditional merging and mod_auth documentation). On a protected path, this guide interprets 401 as the pass and 200 as the protected path served without authentication on this TLS connection to a client that sends another host. Apache 2.4.69 uses the SNI hostname, when the client sends it and the server supports SNI, for name-based TLS virtual-host selection; varying Host while keeping URL and SNI fixed is this guide's auth-check advice.", "components": ["docs", "lighttpd-verify-source", "apache-vhosts"], "sources": ["docs:s1dbd1ce27da5", "lighttpd-verify-source:s6a29b290d83a", "lighttpd-verify-source:s2fc4cf82abd9", "lighttpd-verify-source:s3f692a48bd01", "lighttpd-verify-source:sbb09703388c3", "lighttpd-verify-source:s8bb6bd7774f9", "lighttpd-verify-source:sb4c43fa1a807", "lighttpd-verify-source:s9e49b928cc81", "apache-vhosts:s558db45d4d61"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> caddy.md ====================================================================== # Caddy: TLS and authentication Caddy is a web server that hosts sites and passes requests to applications behind it. This guide helps your AI assistant configure it securely, including automatic HTTPS for public domains, its local CA for internal hosts, authentication, request size limits (stock builds lack rate limiting), and a private admin API. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/caddy.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Caddy admin source v2.11.9, Caddy prior-release admin source v2.11.4, systemd manual v262, and Let's Encrypt expiration-email notice 2025; 7 further sources are pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 34 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Caddy 2 obtains, installs, and renews publicly trusted certificates automatically and redirects HTTP to HTTPS by default. For a new deployment with a public domain, it is the shortest correct path to HTTPS: no ACME client, no renewal timer, no redirect block. ## 1. Public site with automatic HTTPS `/etc/caddy/Caddyfile`: ```caddyfile { email admin@example.com # ACME account contact (Let's Encrypt no longer emails expiry notices; monitor renewal yourself) } app.example.com { reverse_proxy 127.0.0.1:3000 header Strict-Transport-Security "max-age=31536000; includeSubDomains" # Caddy sets no HSTS on its own; add once every subdomain serves HTTPS (includeSubDomains/preload are hard to undo) } ``` Requirements: the DNS record points at this host, and ports 80 and 443 are reachable from the internet. Start or reload: ```bash sudo systemctl reload caddy ``` That is the whole certificate setup: certificates come from Let's Encrypt or ZeroSSL and renew automatically. Two things automatic HTTPS does not do on its own: it does not send HSTS (the `header Strict-Transport-Security` line above adds it, so a browser stays on HTTPS after the first visit rather than being downgradable before the redirect). The second thing is that automatic HTTPS, on its own, does not trust client-supplied `X-Forwarded-*` headers (Caddy ignores them by default to prevent spoofing; if Caddy runs behind a CDN or another proxy, set `trusted_proxies` so it honours them). ## 2. Internal hosts without a public domain `tls internal` makes Caddy issue from its own local CA instead of a public one: ```caddyfile app.internal { tls internal reverse_proxy 127.0.0.1:3000 } ``` Clients must trust Caddy's root CA (on the Caddy host itself, `caddy trust` installs it into the local trust store). Distribution of that trust to other machines follows [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). To use certificate files you generated yourself instead: `tls /path/cert.pem /path/key.pem`. ## 3. Require authentication Application-level login is preferable ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). At the proxy, use `basic_auth` (named `basicauth` before Caddy v2.8.0). Hash the password first: ```bash caddy hash-password # prompts, outputs a bcrypt hash ``` ```caddyfile app.example.com { basic_auth { admin $2a$14$REPLACE_WITH_HASH_FROM_caddy_hash-password } reverse_proxy 127.0.0.1:3000 } ``` To protect only part of a site, wrap the directive in a matcher: ```caddyfile @admin path /admin /admin/* basic_auth @admin { admin $2a$14$REPLACE_WITH_HASH } ``` Path matches are exact, and `/admin/*` alone does not match `/admin` itself, so list both forms; multiple paths in one matcher are OR'ed. `basic_auth` is single-factor. For human-facing sites, add MFA with the `forward_auth` directive (Caddy 2.5 and later) pointed at an [Authelia](https://www.authelia.com/) portal, or front the site with Cloudflare Access. Cloudflare docs at c896795b describe identity-provider MFA and independent Access MFA; the governing setting follows policy over application over organization, so configure it to require MFA ([MFA requirements](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85)). As this guide reasons, per [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), fronting with Cloudflare Access only helps if the origin is locked down. The same reasoning, again per `cloudflare.md`, is that Caddy still answers on 443, so a client that discovers the origin IP connects directly and bypasses Access unless you close direct ingress (a tunnel, or Authenticated Origin Pulls plus a Cloudflare-IP firewall) and validate the Access JWT at the origin ([JWT validation](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L15-L17); [tunnel `access` setting](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L196-L215)). Options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 4. Bound the expensive endpoints Caddy caps request bodies natively. **It has no rate limiting in the standard build**: `rate_limit` is not a Caddyfile directive, and rate limiting requires the community `caddy-ratelimit` module compiled in with xcaddy, or a layer in front of Caddy. Do not assume a stock Caddy is rate limited, and do not follow a `rate_limit` example without checking that your binary has that module. Add `request_body` to the site block you already have, rather than pasting a fresh one: the `basic_auth` directive from section 3 lives in that block, and a site block without it is a public route. ```caddy # add to the existing app.example.com site block from sections 1 and 3. # Do not replace that block: basic_auth lives there, and a site block # without it is a public route. request_body { max_size 10MB } ``` ## 5. The admin API Caddy runs a local admin API, by default on `localhost:2019` (as of v2.11.9), that requires no credentials: anything that can reach it replaces the whole configuration with `POST /load`, edits it path by path under `/config/`, or stops the server with `POST /stop`. Its Host and Origin header checks block a browser on another site, but they are not process isolation, so the endpoint's safety rests on the loopback bind. Never publish it: do not reverse-proxy a route to `:2019`, and never move it to a public address. The official [2.11.4 runtime tag map](https://github.com/docker-library/official-images/blob/d82ca5102fa6735be29d5e1fc6ce03af77eb091e/library/caddy#L7-L65) maps [2.11.4-alpine](https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L59-L63), [2.11.4-windowsservercore-ltsc2022](https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2022/Dockerfile#L42-L47), [2.11.4-windowsservercore-ltsc2025](https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2025/Dockerfile#L42-L47), [2.11.4-nanoserver-ltsc2022](https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2022/Dockerfile#L27-L32), [2.11.4-nanoserver-ltsc2025](https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2025/Dockerfile#L27-L32) to Dockerfiles at fba28535. Each lists 2019 in `EXPOSE` and runs the default Caddyfile, which has no `admin` option; as shipped, with no mounted configuration that sets `admin` and no `CADDY_ADMIN`, the admin API listens on `localhost:2019`. On Docker Engine on Linux, ordinary bridge networking keeps that address on the container's own loopback. Host networking shares the host's network namespace, while `--network container:` shares another container's networking stack, including its loopback. Treat processes in that shared namespace as potential admin clients. Do not set `admin 0.0.0.0:2019` (or `CADDY_ADMIN`) to make that port work, do not publish it, and do not run the container with host networking or share its namespace with untrusted containers; run `caddy` commands inside the container instead, for example with `docker exec`. On a host where untrusted workloads share the machine, loopback is not enough, because any local process can reach `127.0.0.1:2019`. Bind the endpoint to a permissioned Unix socket instead, in the same global options block from section 1 (a Caddyfile has only one), so only processes that can open the socket file reconfigure Caddy: ```caddy { email admin@example.com admin unix//run/caddy/admin.sock # defaults to mode 0200 (owner-only); append |0600 to set the mode explicitly } ``` The caddyserver/dist [service unit at 33ae08ff](https://github.com/caddyserver/dist/blob/33ae08ff08d168572df2956ed14fbc4949880d94/init/caddy.service#L22-L32) runs as `caddy` and has no `RuntimeDirectory=` setting. To give that user a directory for the socket under `/run`, add a drop-in with `sudo systemctl edit caddy`: ```ini [Service] RuntimeDirectory=caddy ``` The [systemd v262 manual](https://github.com/systemd/systemd/blob/v262/man/systemd.exec.xml#L1558-L1660) says `RuntimeDirectory=caddy` creates `/run/caddy` when the unit starts, owns it according to `User=`/`Group=`, and removes it when the unit stops unless preservation is configured. The switch from the default TCP endpoint to the socket needs `sudo systemctl restart caddy`, not a reload: this is the guide's reasoning that a restart creates the runtime directory and binds the socket in one step. After that, `caddy reload`, and the [dist unit's `ExecReload` at 33ae08ff](https://github.com/caddyserver/dist/blob/33ae08ff08d168572df2956ed14fbc4949880d94/init/caddy.service#L22-L32), apply config through this API by reading the admin address from the Caddyfile, so the socket keeps reload working. Disabling the endpoint with `admin off` turns off API reload, so under systemd a config change then needs `systemctl restart caddy`. A manual `caddy run` process started without `--resume` can instead reload the startup config file with `SIGUSR1`. A change made through the admin API, or a `caddy reload` with a different file or adapter, disables signal reloads, so check the logs to confirm the reload took. Caddy can also expose a remote admin endpoint for mutual-TLS management from other hosts, configured through the JSON `admin.remote` block with `access_control`. Caddy marks this endpoint EXPERIMENTAL and subject to change, and it is off unless you enable it, so prefer the local endpoint unless remote management is required. If you do enable it, scope each client key tightly, because the access-control enforcement differs between v2.11.4 and v2.11.9. In v2.11.9 a request is allowed if any single permission in any `access_control` entry that lists the key matches it, and an entry with no `permissions` grants that key everything. In v2.11.4 only the first entry that listed the key was consulted and every permission in it had to match, so a configuration carried forward can grant more access after an upgrade. Version 2.11.9 also rejects a `paths` value that is not canonical, a check v2.11.4 did not perform. Give each key one entry holding one permission that sets both `paths` and `methods`, write every `paths` value in canonical form, and re-check a configuration carried from an earlier version before upgrading. ## 6. Verify (REASONED: all Verify scenarios follow the cited Caddy documentation and pinned admin/image sources; this guide records no exposed/fixed deployment run. This metadata-only review has no authorized Caddy deployment fixture. The redirect, authentication and matcher-scope curls and the body-size password subshell take the site host on guarded `set --` lines that refuse example.com, example.net, example.org and their subdomains. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of this block. Stub runs with the network and host commands replaced ran its guards but no deployment on an earlier form, which lowercased the host with `tr` and checked `tr`'s exit status before refusing reserved names; earlier QA reports record stub runs of forms before that one, and no exposed/fixed deployment run of any form is recorded. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and each of its two host parts refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. Stub runs of the current form on 2026-11-01, with stub curl, caddy, head, rm, ss and ls and no network, also ran no deployment. In bash, each host part refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probes. Under dash, stub runs of the current form refused every host name in each host part with 2 and the bash message before its probes; the block still ran `caddy validate`, `ss -tlnp` and `ls` and exited 3, as the earlier form did there for every host, which it reported inconclusive at its `tr` step. When `ss` is missing, the block prints `ss is not installed here; not listing sockets` and exits 2 only if no other part found something or was inconclusive. With no `/run/caddy/admin.sock` as well, it records 3, because without the listener table it cannot tell an admin API on TCP or turned off from a failed `ls`.) ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input or found ss missing), which outranks 0 caddy validate --config /etc/caddy/Caddyfile || { echo "caddy validate exit $?: the configuration did not validate; inconclusive"; set -- 3; } ( # a nested subshell, so this part's set -- and exit leave the block's status above untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_site_OR_admin' # your public host name (the site address), no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then site if section 3's basic_auth covers the whole site or admin for its @admin matcher variant: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in site|admin) ;; *) echo "substitute site (basic_auth covers the whole site) or admin (the @admin matcher variant) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is this part's status so far, ranked as above curl -q -g -sI "http://$1/" || # expect a redirect to https:// { echo "curl exit $?: the redirect request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } curl -q -g -sI "https://$1/" || # expect 401 without credentials once auth is on (site); the last request below classifies / { echo "curl exit $?: the HTTPS request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } set -- "$1" "$2" "$3" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' "https://$1/admin"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # with either variant: 401 case "$4:$5" in 0:401) ;; 0:2??) echo "FINDING: /admin answered $5 with no credentials"; set -- "$1" "$2" 1 ;; 0:*) echo "HTTP $5 from /admin is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the /admin request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' "https://$1/admin/x"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # 401 as well case "$4:$5" in 0:401) ;; 0:2??) echo "FINDING: /admin/x answered $5 with no credentials"; set -- "$1" "$2" 1 ;; 0:*) echo "HTTP $5 from /admin/x is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the /admin/x request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # with site, 401 is the pass and a 2xx the finding. With admin, to prove the matcher SCOPES auth to /admin (not the whole site), a non-/admin path must NOT return 401: it reaches the app (a 200, or the app's own redirect or 404). A 401 here means auth is applied site-wide, not scoped to /admin case "$2:$4:$5" in site:0:401|admin:0:2??|admin:0:3??|admin:0:404) ;; site:0:2??) echo "FINDING: / answered $5 with no credentials, so basic_auth does not cover the whole site"; set -- "$1" "$2" 1 ;; admin:0:401) echo "FINDING: / answered 401, so auth is applied site-wide, not scoped to /admin"; set -- "$1" "$2" 1 ;; *:0:*) echo "HTTP $5 from / is not a result this check classifies for $2; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request to / failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac exit "$3" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # curl reads the admin password from a config stream on stdin (--config -), # never argv (-u admin:PASSWORD is readable in ps / /proc//cmdline). trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The password you substitute on the set -- line enters shell history. # Clear that history line afterward. # Stdin closes the argv channel only: it does not hide the password from # set -x tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_PASSWORD' # your public host name (the site address), no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the admin password: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the admin password on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "${2//\\/\\\\}" set -- "$1" "${2//\"/\\\"}" set -- "$1" "$2" 0 # the 3rd value is this part's status so far, ranked as above if head -c 1M /dev/zero > /tmp/under.bin && head -c 11M /dev/zero > /tmp/over.bin; then set -- "$1" "$2" "$3" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --config - --data-binary @/tmp/under.bin "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # positive control: under the limit, expect the app's own normal response (a 2xx, or its own 404/redirect), never 413; a 401 means the credentials, not the size limit, were exercised and a 000 means transport failed, either of which voids the control case "$4:$5" in 0:2??|0:3??|0:404) ;; 0:*) echo "HTTP $5 for the 1M body is not the app's own response, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the 1M request failed, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --config - --data-binary @/tmp/over.bin "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # 413. Supply credentials: an unauthenticated probe returns 401 and # tells you nothing about max_size. Caddy's default order puts # request_body ahead of basic_auth, but the limit is enforced when a # later handler reads past it, and authentication stops the proxy # handler reading at all. A backend with its own limit returns the # same code, so attributing the refusal needs an isolated # environment with request_body removed case "$4:$5" in 0:413) ;; 0:*) echo "HTTP $5 for the 11M body is not the expected 413; inconclusive, because the limit applies only when a later handler reads past it"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the 11M request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac else echo "head could not create /tmp/under.bin and /tmp/over.bin, so the size checks did not run; inconclusive"; set -- "$1" "$2" 3 fi rm -f /tmp/under.bin /tmp/over.bin || { echo "rm exit $?: the cleanup failed, so /tmp/under.bin or /tmp/over.bin may remain; delete them yourself"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } exit "$3" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above if command -v ss >/dev/null; then set -- "$1" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far, then the listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the listener table, then ss's exit status printf '%s' "$2" # read every listener; 3000: the app itself: loopback only (bind 127.0.0.1; the check also counts any 127.x.x.x, [::1] or [::ffff:127.x.x.x] address as loopback), never 0.0.0.0. Every check above # passes while the app also answers directly on port 3000, which # bypasses Caddy's TLS and its authentication. The admin API on TCP: only a loopback # address, such as 127.0.0.1:2019 or [::1]:2019, never a # public address. A missing 2019 line is not a pass on its own: it also # means you moved it to a unix socket or set admin off, so confirm which [ "$3" = 0 ] || { echo "ss -tlnp failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:3000$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 3000 is bound to a non-loopback address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:2019$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 2019, the admin API's default, is bound to a non-loopback address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 "" missing ;; *) set -- "$1" "" missing ;; esac # an empty listener table, so the checks below read no listeners, and missing in place of ss's exit status for the admin-socket check fi if ls -l /run/caddy/admin.sock; then # if you bound it to a unix socket: it exists and is owner-restricted # (a stream socket, so it does not show in ss -tlnp; use ss -xlp to list it) echo "unclassified observation: read the socket's mode and owner above; the pass is a socket that is owner-restricted" elif printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:2019$/ { found = 1 } END { exit !found }'; then echo "no /run/caddy/admin.sock, and the admin API is on TCP: the port 2019 check above classified it" elif [ "$3" = missing ]; then echo "ls could not list /run/caddy/admin.sock, and with ss missing this block cannot tell an admin API on TCP or turned off from a failed ls; inconclusive" [ "$1" = 1 ] || set -- 3 "$2" "$3" else echo "ls could not list /run/caddy/admin.sock; inconclusive (if the admin API is on TCP or off, the ss -tlnp lines above are the check)" [ "$1" = 1 ] || set -- 3 "$2" "$3" fi echo "block exit $1; the redirect and HTTPS headers, the admin socket's mode and owner, and listeners on ports other than 3000 and 2019 (every listener, if ss is missing) are not classified, so judge them yourself" exit "$1" ) ``` ## Common mistakes - The app also listens on a public interface, bypassing Caddy; bind it to `127.0.0.1`. - Blocking port 80 at the firewall: Caddy needs it for the HTTP-01 challenge and for the automatic redirect. - Enabling on-demand TLS without an `ask` endpoint or a host allowlist: this guide does not use it, but an unrestricted `on_demand` lets anyone point a hostname at the server to mint certificates and exhaust resources. - Putting the literal password in the Caddyfile; `basic_auth` takes the bcrypt hash, not the password. ## Sources (checked September 2026) - Official Caddy Docker images: the tag map (library file pinned commit d82ca5102fa6735be29d5e1fc6ce03af77eb091e), and at image source pinned commit fba2853501d36e8a72f946ac8cb7ff64d07e48f2 each runtime image's Caddyfile download, `EXPOSE 2019` and `CMD`. The same item also covers the default Caddyfile at caddyserver/dist pinned commit 33ae08ff08d168572df2956ed14fbc4949880d94 and the `CADDY_ADMIN` variable that sets the admin address (pinned tag v2.11.9). Sources for this item (1 of 7): https://github.com/docker-library/official-images/blob/d82ca5102fa6735be29d5e1fc6ce03af77eb091e/library/caddy#L7-L65 and https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L17. Sources for this item (2 of 7): https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L59-L63 and https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2022/Dockerfile#L10. Sources for this item (3 of 7): https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2022/Dockerfile#L42-L47 and https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2025/Dockerfile#L10. Sources for this item (4 of 7): https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2025/Dockerfile#L42-L47 and https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2022/Dockerfile#L8. Sources for this item (5 of 7): https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2022/Dockerfile#L27-L32 and https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2025/Dockerfile#L8. Sources for this item (6 of 7): https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2025/Dockerfile#L27-L32 and https://github.com/caddyserver/dist/blob/33ae08ff08d168572df2956ed14fbc4949880d94/config/Caddyfile. Sources for this item (7 of 7): https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L60-L69 - Automatic HTTPS (rolling documentation, checked September 2026): https://caddyserver.com/docs/automatic-https - Caddy admin API (default `localhost:2019`, `POST /load` and `/config/` replace or edit the whole config, requires no credentials with only Host/Origin header checks, the permissioned-unix-socket warning for untrusted-workload hosts; rolling documentation, checked September 2026): https://caddyserver.com/docs/api - Caddy `admin` global option (`admin off`, an address, or `admin unix//...`; rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/options - `request_body` directive (`max_size`; rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/request_body - Caddyfile directive list, which carries no `rate_limit` entry (rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives - caddy-ratelimit, the community module that adds rate limiting (pinned README): https://raw.githubusercontent.com/mholt/caddy-ratelimit/5625512f24f6f59d6f64fb3aafe5eecff0b286db/README.md - basic_auth directive (renamed from basicauth in Caddy v2.8.0; rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/basic_auth - tls directive (rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/tls - reverse_proxy directive (X-Forwarded-* ignored from untrusted sources by default; `trusted_proxies`; rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/reverse_proxy - Caddy conventions (unix socket default mode 0200, `|` suffix; rolling documentation, checked September 2026): https://caddyserver.com/docs/conventions - header directive (HSTS; rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/header - forward_auth directive (rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/forward_auth - Caddy command-line signals (SIGUSR1 reload conditions; rolling documentation, checked September 2026): https://caddyserver.com/docs/command-line#signals - Let's Encrypt ending expiration-notification emails (2025): https://letsencrypt.org/2025/01/22/ending-expiration-emails - Request matchers (path, wildcards, multiple paths; rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/matchers - Caddy admin API default `DefaultAdminListen = "localhost:2019"` (pinned tag v2.11.9): https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L1477 - Caddy remote admin endpoint access controls: the RemoteAdmin endpoint definition, the enforceAccessControls authorization logic and the access_control permission-path canonical check (pinned tag v2.11.9), compared against the prior enforcement and provisioning (pinned tag v2.11.4). Sources: https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L174-L193 and https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L678-L758 and https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L559-L570 and https://github.com/caddyserver/caddy/blob/v2.11.4/admin.go#L664-L723 and https://github.com/caddyserver/caddy/blob/v2.11.4/admin.go#L545-L557 - caddyserver/dist service unit (pinned commit 33ae08ff08d168572df2956ed14fbc4949880d94, checked October 2026): https://github.com/caddyserver/dist/blob/33ae08ff08d168572df2956ed14fbc4949880d94/init/caddy.service#L22-L32 - systemd execution manual (pinned v262, checked October 2026): https://github.com/systemd/systemd/blob/v262/man/systemd.exec.xml#L1558-L1660 - Cloudflare Access MFA requirements (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85 - Cloudflare Access JWT validation (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L15-L17 - Cloudflare Tunnel `access` setting, `cloudflared` validating the Access JWT (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L196-L215 - Docker networking documentation at 4e9a5751518ed8223a8dcde53693badddd72604f (container loopback, host namespace sharing, and the `--network container:` loopback example; the Linux bridge/host distinction above is inferred from these documented namespace semantics). Sources for this item (1 of 2): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/_index.md#L286 and https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/drivers/host.md#L11-L13. Sources for this item (2 of 2): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/_index.md#L302-L325 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - admin: Caddy admin source (v2.11.9) - admin-prev: Caddy prior-release admin source (v2.11.4) - images: Caddy Docker source (fba2853501d36e8a72f946ac8cb7ff64d07e48f2) - dist: Default Caddyfile source (33ae08ff08d168572df2956ed14fbc4949880d94) - docs-rolling: Caddy documentation (rolling) (unknown) - service: caddyserver/dist service unit (33ae08ff08d168572df2956ed14fbc4949880d94) - ratelimit-source: caddy-ratelimit source (5625512f24f6f59d6f64fb3aafe5eecff0b286db) - tags: Official image tag map (d82ca5102fa6735be29d5e1fc6ce03af77eb091e) - docker-network: Docker networking documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - systemd: systemd manual (v262) - cloudflare: Cloudflare docs (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - letsencrypt-notice: Let's Encrypt expiration-email notice (2025) Claims (full source mapping in the JSON block below): - auto-https (REASONED; docs-rolling): Caddy 2 obtains and renews public certificates and redirects HTTP to HTTPS; DNS must point here and the example requires public 80/443. - issuers (REASONED; docs-rolling): Public certificates come from Let's Encrypt or ZeroSSL and renew automatically. - renewal-monitoring (REASONED; docs-rolling, letsencrypt-notice): Set the ACME email contact but monitor renewal yourself: Let's Encrypt stopped expiration emails. - backend (REASONED; docs-rolling): reverse_proxy sends traffic to 127.0.0.1:3000; keep the app private to prevent TLS/auth bypass. - hsts (REASONED; docs-rolling): Caddy sends no HSTS automatically; add it only after HTTPS works across every included subdomain. - forwarded (REASONED; docs-rolling): Caddy ignores untrusted client X-Forwarded-* by default; configure trusted_proxies behind a trusted CDN/proxy. - internal-tls (REASONED; docs-rolling): tls internal uses a local CA; clients must trust its root, with caddy trust for the local host. tls also accepts explicit cert/key files. - basic (REASONED; docs-rolling): basic_auth uses hashed passwords from caddy hash-password; it was named basicauth before v2.8.0 and remains single-factor. - path-auth (REASONED; docs-rolling): Protect /admin and /admin/* together: path matches are exact and multiple paths are OR'ed. - mfa (REASONED; docs-rolling, cloudflare): forward_auth (Caddy 2.5+) can delegate human authentication to Authelia; Cloudflare Access is a proposed layer that enforces MFA when its governing Access setting (policy over application over organization) requires it. - access-origin (REASONED; docs-rolling, cloudflare): Cloudflare Access can require MFA when configured; the guide reasons that direct origin ingress can bypass Access, so restrict it; validate the Access JWT at the origin, which a tunnel's `access` setting has `cloudflared` do. Caddy still answers directly on 443. - body-limit (REASONED; docs-rolling): Add request_body max_size 10MB inside the existing authenticated site; replacing the site without basic_auth makes it public. - rate-limit (REASONED; docs-rolling, ratelimit-source): The standard build has no rate_limit directive; use a compiled community caddy-ratelimit module or an upstream layer. - admin-bind (REASONED; admin, docs-rolling): The admin API defaults to localhost:2019 as of v2.11.9; never publish it or reverse-proxy to it. - admin-authority (REASONED; docs-rolling): Reachable unauthenticated admin clients can replace config at POST /load, edit /config/ or stop with POST /stop. - admin-isolation (REASONED; docs-rolling): Host/Origin checks are not process isolation; local untrusted workloads require a permissioned Unix socket. - docker-admin (REASONED; tags, images, dist, admin): The official 2.11.4 Alpine, Windows Server Core 2022/2025, and Nano Server 2022/2025 runtime tags map to fba28535 Dockerfiles that EXPOSE 2019 and run the default Caddyfile without admin; absent overrides, the endpoint stays namespace-local. - admin-override (REASONED; admin, dist, docs-rolling): A mounted admin setting or CADDY_ADMIN changes the address; do not set 0.0.0.0:2019 to make publication work. - namespace (REASONED; images, dist, admin, docker-network, docs-rolling): The shipped admin default is localhost:2019. On Docker Engine on Linux, bridge networking keeps loopback container-local; host or container networking shares a namespace, so treat its processes as potential admin clients. - admin-socket (REASONED; docs-rolling): admin unix//run/caddy/admin.sock uses owner-only mode 0200 by default; append |0600 to set it explicitly. - remote-admin-perms (REASONED; admin, admin-prev): On Caddy's remote admin endpoint, v2.11.9 allows a request if any permission in any access_control entry listing the key matches it; an entry with no permissions grants everything. v2.11.4 used only the first entry listing the key and required all its permissions to match, so a carried-forward config can grant more on upgrade. Give each key one entry with one permission setting paths and methods. - remote-admin-canonical (REASONED; admin, admin-prev): On Caddy's remote admin endpoint, v2.11.9 rejects a permission paths value that is not canonical, with a "path ... is not canonical" error; v2.11.4 had no such check, so re-check permission paths before upgrading. - remote-admin-experimental (REASONED; admin): Caddy's remote admin endpoint is off unless enabled, authenticates clients with mutual TLS, and Caddy marks it EXPERIMENTAL and subject to change; prefer the local endpoint unless remote management is required. - runtime-directory (REASONED; docs-rolling, service, systemd): The dist service at 33ae08ff runs as caddy without RuntimeDirectory; systemd v262 creates /run/caddy for RuntimeDirectory=caddy at service start and owns it as caddy. Restart for the socket switch is guide reasoning. - reload (REASONED; docs-rolling, service): The dist service at 33ae08ff uses caddy reload --config /etc/caddy/Caddyfile --force; API reload uses the Caddyfile admin address. With admin off, guide reasoning requires systemd restart. - signal-reload (REASONED; docs-rolling): A manual run without --resume may reload startup config via SIGUSR1; API changes or reload with a different file/adapter disable signal reloads. - on-demand (REASONED; docs-rolling): Do not enable unrestricted on_demand TLS without an ask endpoint or host allowlist; this guide does not enable it. - verify-config (REASONED; docs-rolling): Validate /etc/caddy/Caddyfile; no validation outcome is recorded. - verify-redirect (REASONED; docs-rolling): HTTP should redirect to HTTPS. - verify-auth (REASONED; docs-rolling): With site-wide auth, HTTPS without credentials returns 401. - verify-scope (REASONED; docs-rolling): The @admin variant must return 401 for /admin and /admin/x, but an app response rather than 401 for a non-admin path. - verify-size (REASONED; docs-rolling): Authenticated 1M reaches the app and 11M returns 413; 401/000 void the control. Auth can prevent body reads; remove request_body in isolation to attribute refusal. - verify-backend (REASONED; docs-rolling): Read all listeners: app 3000 must be loopback-only even if proxy checks pass. - verify-admin (REASONED; docs-rolling): TCP admin must bind only loopback; absent 2019 requires confirming admin off or a socket. Check Unix socket existence and owner restrictions separately. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "b7ba9b6a6d3e53409c78c11b26a63fb37a27cf6295c2227248f4a11125f5a839", "components": { "admin": { "name": "Caddy admin source", "basis": "v2.11.9", "sources": { "s0aae83b9b68f": "https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L1477", "s17a528119e04": "https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L174-L193", "s5972de88e037": "https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L559-L570", "sabfb2d5a0b65": "https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L60-L69", "sd22c92915883": "https://github.com/caddyserver/caddy/blob/v2.11.9/admin.go#L678-L758" } }, "admin-prev": { "name": "Caddy prior-release admin source", "basis": "v2.11.4", "sources": { "s70916972291e": "https://github.com/caddyserver/caddy/blob/v2.11.4/admin.go#L545-L557", "sab913697fac4": "https://github.com/caddyserver/caddy/blob/v2.11.4/admin.go#L664-L723" } }, "images": { "name": "Caddy Docker source", "basis": "fba2853501d36e8a72f946ac8cb7ff64d07e48f2", "sources": { "s0f0994ef9fac": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L17", "s2dce0bbf3a70": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L59-L63", "sd2c1b05a8088": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2022/Dockerfile#L27-L32", "sf3e9c2d4e0cd": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2022/Dockerfile#L8", "sc5dd3f9746fe": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2025/Dockerfile#L27-L32", "s6c800a156eac": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows-nanoserver/ltsc2025/Dockerfile#L8", "sc5c82704d33f": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2022/Dockerfile#L10", "sc1cb0e1fb9eb": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2022/Dockerfile#L42-L47", "s11528dfcc516": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2025/Dockerfile#L10", "s70b76475a281": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/windows/ltsc2025/Dockerfile#L42-L47" } }, "dist": { "name": "Default Caddyfile source", "basis": "33ae08ff08d168572df2956ed14fbc4949880d94", "sources": { "s433758a90af0": "https://github.com/caddyserver/dist/blob/33ae08ff08d168572df2956ed14fbc4949880d94/config/Caddyfile" } }, "docs-rolling": { "name": "Caddy documentation (rolling)", "basis": "unknown", "sources": { "s434e7b7d7f63": "https://caddyserver.com/docs/automatic-https", "s3923477355fb": "https://caddyserver.com/docs/api", "sdbcd496895d6": "https://caddyserver.com/docs/caddyfile/options", "s31af5ed45a20": "https://caddyserver.com/docs/caddyfile/directives/request_body", "sec59a61934c5": "https://caddyserver.com/docs/caddyfile/directives", "sbc2bd9599bd1": "https://caddyserver.com/docs/caddyfile/directives/basic_auth", "s2e47c3139bd2": "https://caddyserver.com/docs/caddyfile/directives/tls", "s3865a73ccda3": "https://caddyserver.com/docs/caddyfile/directives/reverse_proxy", "s82efc39a8130": "https://caddyserver.com/docs/conventions", "s5a6da5adf385": "https://caddyserver.com/docs/caddyfile/directives/header", "s8df93f3e3d4c": "https://caddyserver.com/docs/caddyfile/directives/forward_auth", "sf12ad47e81b5": "https://caddyserver.com/docs/command-line#signals", "s38b1b78ce980": "https://caddyserver.com/docs/caddyfile/matchers" } }, "service": { "name": "caddyserver/dist service unit", "basis": "33ae08ff08d168572df2956ed14fbc4949880d94", "sources": { "se70d276b03a1": "https://github.com/caddyserver/dist/blob/33ae08ff08d168572df2956ed14fbc4949880d94/init/caddy.service#L22-L32" } }, "ratelimit-source": { "name": "caddy-ratelimit source", "basis": "5625512f24f6f59d6f64fb3aafe5eecff0b286db", "sources": { "s093420cd3d2f": "https://raw.githubusercontent.com/mholt/caddy-ratelimit/5625512f24f6f59d6f64fb3aafe5eecff0b286db/README.md" } }, "tags": { "name": "Official image tag map", "basis": "d82ca5102fa6735be29d5e1fc6ce03af77eb091e", "sources": { "sdc8aca42f5e2": "https://github.com/docker-library/official-images/blob/d82ca5102fa6735be29d5e1fc6ce03af77eb091e/library/caddy#L7-L65" } }, "docker-network": { "name": "Docker networking documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "se1437c0f19fb": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/_index.md#L286", "sfce645f8b46e": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/drivers/host.md#L11-L13", "sfd29f33e4108": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/_index.md#L302-L325" } }, "systemd": { "name": "systemd manual", "basis": "v262", "sources": { "see108b0e0bba": "https://github.com/systemd/systemd/blob/v262/man/systemd.exec.xml#L1558-L1660" } }, "cloudflare": { "name": "Cloudflare docs", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s6f6104362214": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85", "s81e5dd28d3e5": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json.mdx#L15-L17", "sa7c8d635bfd5": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L196-L215" } }, "letsencrypt-notice": { "name": "Let's Encrypt expiration-email notice", "basis": "2025", "sources": { "sc33d2e1f6703": "https://letsencrypt.org/2025/01/22/ending-expiration-emails" } } }, "claims": { "auto-https": {"text": "Caddy 2 obtains and renews public certificates and redirects HTTP to HTTPS; DNS must point here and the example requires public 80/443.", "components": ["docs-rolling"], "sources": ["docs-rolling:s434e7b7d7f63"], "status": "REASONED"}, "issuers": {"text": "Public certificates come from Let's Encrypt or ZeroSSL and renew automatically.", "components": ["docs-rolling"], "sources": ["docs-rolling:s434e7b7d7f63"], "status": "REASONED"}, "renewal-monitoring": {"text": "Set the ACME email contact but monitor renewal yourself: Let's Encrypt stopped expiration emails.", "components": ["docs-rolling", "letsencrypt-notice"], "sources": ["docs-rolling:sdbcd496895d6", "letsencrypt-notice:sc33d2e1f6703"], "status": "REASONED"}, "backend": {"text": "reverse_proxy sends traffic to 127.0.0.1:3000; keep the app private to prevent TLS/auth bypass.", "components": ["docs-rolling"], "sources": ["docs-rolling:s3865a73ccda3"], "status": "REASONED"}, "hsts": {"text": "Caddy sends no HSTS automatically; add it only after HTTPS works across every included subdomain.", "components": ["docs-rolling"], "sources": ["docs-rolling:s5a6da5adf385", "docs-rolling:s434e7b7d7f63"], "status": "REASONED"}, "forwarded": {"text": "Caddy ignores untrusted client X-Forwarded-* by default; configure trusted_proxies behind a trusted CDN/proxy.", "components": ["docs-rolling"], "sources": ["docs-rolling:s3865a73ccda3"], "status": "REASONED"}, "internal-tls": {"text": "tls internal uses a local CA; clients must trust its root, with caddy trust for the local host. tls also accepts explicit cert/key files.", "components": ["docs-rolling"], "sources": ["docs-rolling:s2e47c3139bd2", "docs-rolling:s434e7b7d7f63"], "status": "REASONED"}, "basic": {"text": "basic_auth uses hashed passwords from caddy hash-password; it was named basicauth before v2.8.0 and remains single-factor.", "components": ["docs-rolling"], "sources": ["docs-rolling:sbc2bd9599bd1"], "status": "REASONED"}, "path-auth": {"text": "Protect /admin and /admin/* together: path matches are exact and multiple paths are OR'ed.", "components": ["docs-rolling"], "sources": ["docs-rolling:s38b1b78ce980", "docs-rolling:sbc2bd9599bd1"], "status": "REASONED"}, "mfa": {"text": "forward_auth (Caddy 2.5+) can delegate human authentication to Authelia; Cloudflare Access is a proposed layer that enforces MFA when its governing Access setting (policy over application over organization) requires it.", "components": ["docs-rolling", "cloudflare"], "sources": ["docs-rolling:s8df93f3e3d4c", "cloudflare:s6f6104362214"], "status": "REASONED"}, "access-origin": {"text": "Cloudflare Access can require MFA when configured; the guide reasons that direct origin ingress can bypass Access, so restrict it; validate the Access JWT at the origin, which a tunnel's `access` setting has `cloudflared` do. Caddy still answers directly on 443.", "components": ["docs-rolling", "cloudflare"], "sources": ["docs-rolling:s3865a73ccda3", "cloudflare:s6f6104362214", "cloudflare:s81e5dd28d3e5", "cloudflare:sa7c8d635bfd5"], "status": "REASONED"}, "body-limit": {"text": "Add request_body max_size 10MB inside the existing authenticated site; replacing the site without basic_auth makes it public.", "components": ["docs-rolling"], "sources": ["docs-rolling:s31af5ed45a20", "docs-rolling:sbc2bd9599bd1"], "status": "REASONED"}, "rate-limit": {"text": "The standard build has no rate_limit directive; use a compiled community caddy-ratelimit module or an upstream layer.", "components": ["docs-rolling", "ratelimit-source"], "sources": ["docs-rolling:sec59a61934c5", "ratelimit-source:s093420cd3d2f"], "status": "REASONED"}, "admin-bind": {"text": "The admin API defaults to localhost:2019 as of v2.11.9; never publish it or reverse-proxy to it.", "components": ["admin", "docs-rolling"], "sources": ["admin:s0aae83b9b68f", "admin:sabfb2d5a0b65", "docs-rolling:s3923477355fb"], "status": "REASONED"}, "admin-authority": {"text": "Reachable unauthenticated admin clients can replace config at POST /load, edit /config/ or stop with POST /stop.", "components": ["docs-rolling"], "sources": ["docs-rolling:s3923477355fb"], "status": "REASONED"}, "admin-isolation": {"text": "Host/Origin checks are not process isolation; local untrusted workloads require a permissioned Unix socket.", "components": ["docs-rolling"], "sources": ["docs-rolling:s3923477355fb", "docs-rolling:sdbcd496895d6"], "status": "REASONED"}, "docker-admin": {"text": "The official 2.11.4 Alpine, Windows Server Core 2022/2025, and Nano Server 2022/2025 runtime tags map to fba28535 Dockerfiles that EXPOSE 2019 and run the default Caddyfile without admin; absent overrides, the endpoint stays namespace-local.", "components": ["tags", "images", "dist", "admin"], "sources": ["tags:sdc8aca42f5e2", "images:s0f0994ef9fac", "images:s2dce0bbf3a70", "images:sd2c1b05a8088", "images:sf3e9c2d4e0cd", "images:s6c800a156eac", "images:sc5c82704d33f", "images:sc1cb0e1fb9eb", "images:s11528dfcc516", "images:s70b76475a281", "dist:s433758a90af0", "admin:s0aae83b9b68f", "admin:sabfb2d5a0b65", "images:sc5dd3f9746fe"], "status": "REASONED"}, "admin-override": {"text": "A mounted admin setting or CADDY_ADMIN changes the address; do not set 0.0.0.0:2019 to make publication work.", "components": ["admin", "dist", "docs-rolling"], "sources": ["docs-rolling:sdbcd496895d6", "admin:s0aae83b9b68f", "admin:sabfb2d5a0b65", "dist:s433758a90af0"], "status": "REASONED"}, "namespace": {"text": "The shipped admin default is localhost:2019. On Docker Engine on Linux, bridge networking keeps loopback container-local; host or container networking shares a namespace, so treat its processes as potential admin clients.", "components": ["images", "dist", "admin", "docker-network", "docs-rolling"], "sources": ["docs-rolling:s3923477355fb", "images:s0f0994ef9fac", "images:s2dce0bbf3a70", "images:sd2c1b05a8088", "images:sf3e9c2d4e0cd", "images:sc5dd3f9746fe", "images:s6c800a156eac", "images:sc5c82704d33f", "images:sc1cb0e1fb9eb", "images:s11528dfcc516", "images:s70b76475a281", "dist:s433758a90af0", "admin:s0aae83b9b68f", "docker-network:se1437c0f19fb", "docker-network:sfce645f8b46e", "docker-network:sfd29f33e4108"], "status": "REASONED"}, "admin-socket": {"text": "admin unix//run/caddy/admin.sock uses owner-only mode 0200 by default; append |0600 to set it explicitly.", "components": ["docs-rolling"], "sources": ["docs-rolling:sdbcd496895d6", "docs-rolling:s82efc39a8130"], "status": "REASONED"}, "remote-admin-perms": {"text": "On Caddy's remote admin endpoint, v2.11.9 allows a request if any permission in any access_control entry listing the key matches it; an entry with no permissions grants everything. v2.11.4 used only the first entry listing the key and required all its permissions to match, so a carried-forward config can grant more on upgrade. Give each key one entry with one permission setting paths and methods.", "components": ["admin", "admin-prev"], "sources": ["admin:sd22c92915883", "admin-prev:sab913697fac4"], "status": "REASONED"}, "remote-admin-canonical": {"text": "On Caddy's remote admin endpoint, v2.11.9 rejects a permission paths value that is not canonical, with a \"path ... is not canonical\" error; v2.11.4 had no such check, so re-check permission paths before upgrading.", "components": ["admin", "admin-prev"], "sources": ["admin:s5972de88e037", "admin-prev:s70916972291e"], "status": "REASONED"}, "remote-admin-experimental": {"text": "Caddy's remote admin endpoint is off unless enabled, authenticates clients with mutual TLS, and Caddy marks it EXPERIMENTAL and subject to change; prefer the local endpoint unless remote management is required.", "components": ["admin"], "sources": ["admin:s17a528119e04"], "status": "REASONED"}, "runtime-directory": {"text": "The dist service at 33ae08ff runs as caddy without RuntimeDirectory; systemd v262 creates /run/caddy for RuntimeDirectory=caddy at service start and owns it as caddy. Restart for the socket switch is guide reasoning.", "components": ["docs-rolling", "service", "systemd"], "sources": ["docs-rolling:sdbcd496895d6", "service:se70d276b03a1", "systemd:see108b0e0bba"], "status": "REASONED"}, "reload": {"text": "The dist service at 33ae08ff uses caddy reload --config /etc/caddy/Caddyfile --force; API reload uses the Caddyfile admin address. With admin off, guide reasoning requires systemd restart.", "components": ["docs-rolling", "service"], "sources": ["docs-rolling:sdbcd496895d6", "docs-rolling:sf12ad47e81b5", "service:se70d276b03a1"], "status": "REASONED"}, "signal-reload": {"text": "A manual run without --resume may reload startup config via SIGUSR1; API changes or reload with a different file/adapter disable signal reloads.", "components": ["docs-rolling"], "sources": ["docs-rolling:sf12ad47e81b5"], "status": "REASONED"}, "on-demand": {"text": "Do not enable unrestricted on_demand TLS without an ask endpoint or host allowlist; this guide does not enable it.", "components": ["docs-rolling"], "sources": ["docs-rolling:s434e7b7d7f63"], "status": "REASONED"}, "verify-config": {"text": "Validate /etc/caddy/Caddyfile; no validation outcome is recorded.", "components": ["docs-rolling"], "sources": ["docs-rolling:sf12ad47e81b5"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "HTTP should redirect to HTTPS.", "components": ["docs-rolling"], "sources": ["docs-rolling:s434e7b7d7f63"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "With site-wide auth, HTTPS without credentials returns 401.", "components": ["docs-rolling"], "sources": ["docs-rolling:sbc2bd9599bd1"], "status": "REASONED", "verify": [1]}, "verify-scope": {"text": "The @admin variant must return 401 for /admin and /admin/x, but an app response rather than 401 for a non-admin path.", "components": ["docs-rolling"], "sources": ["docs-rolling:s38b1b78ce980", "docs-rolling:sbc2bd9599bd1"], "status": "REASONED", "verify": [1]}, "verify-size": {"text": "Authenticated 1M reaches the app and 11M returns 413; 401/000 void the control. Auth can prevent body reads; remove request_body in isolation to attribute refusal.", "components": ["docs-rolling"], "sources": ["docs-rolling:s31af5ed45a20", "docs-rolling:sbc2bd9599bd1", "docs-rolling:sec59a61934c5"], "status": "REASONED", "verify": [1]}, "verify-backend": {"text": "Read all listeners: app 3000 must be loopback-only even if proxy checks pass.", "components": ["docs-rolling"], "sources": ["docs-rolling:s3865a73ccda3"], "status": "REASONED", "verify": [1]}, "verify-admin": {"text": "TCP admin must bind only loopback; absent 2019 requires confirming admin off or a socket. Check Unix socket existence and owner restrictions separately.", "components": ["docs-rolling"], "sources": ["docs-rolling:s3923477355fb", "docs-rolling:sdbcd496895d6", "docs-rolling:s82efc39a8130"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> haproxy.md ====================================================================== # HAProxy: TLS termination and authentication HAProxy is a proxy that accepts client connections on a public frontend and forwards them to backend services. This guide helps your AI assistant make sure it is configured securely: terminating TLS and redirecting HTTP, requiring authentication, bounding expensive endpoints, and protecting the stats interface and runtime API. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/haproxy.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against HAProxy 3.0, HAProxy http-after-response minimum 2.2+, OpenSSL password command 3.0, and Certbot deploy hook documentation v2.3.0; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-10-05; documentation checked September 2026. The guide records 30 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. HAProxy's public frontend, its stats interface, its runtime API, and any directly reachable backend are separate exposure surfaces; protect and verify each one you enable. These examples target a maintained HAProxy 3.0 installation. Get a certificate first ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)). This example combines the certificate chain and its matching private key in one PEM file (HAProxy also supports separately loaded keys): ```bash sudo mkdir -p /etc/haproxy/certs && sudo chmod 700 /etc/haproxy/certs # Write the combined PEM to a root-only temp file (umask 077 keeps it mode 600), then rename it into place, # so the private key is never briefly world-readable and a failure leaves nothing half-written. sudo bash -c 'umask 077; tmp=$(mktemp /etc/haproxy/certs/.pem.XXXXXX) || exit 1 cat /etc/letsencrypt/live/example.com/fullchain.pem \ /etc/letsencrypt/live/example.com/privkey.pem > "$tmp" || { rm -f "$tmp"; exit 1; } mv "$tmp" /etc/haproxy/certs/example.com.pem' ``` With Certbot v2.3.0, run the concatenation and reload HAProxy after a successful renewal using `certbot renew --deploy-hook /path/to/deploy-hook-script` or an executable in `/etc/letsencrypt/renewal-hooks/deploy` (assuming the `/etc/letsencrypt` configuration directory, on `certbot renew`). ## 1. Terminate TLS and redirect HTTP ```haproxy global ssl-default-bind-options ssl-min-ver TLSv1.2 # Preserve the package's service hardening; for a root-started deployment, drop privileges and jail the # workers (provision the account and an empty root-owned /var/lib/haproxy first). user haproxy group haproxy chroot /var/lib/haproxy stats socket /run/haproxy/admin.sock user root group root mode 600 level admin defaults mode http timeout connect 5s timeout client 30s timeout server 30s timeout http-request 10s frontend web bind :80 bind :443 ssl crt /etc/haproxy/certs/example.com.pem http-request redirect scheme https code 301 unless { ssl_fc } http-after-response set-header Strict-Transport-Security "max-age=31536000" if { ssl_fc } default_backend app backend app server app1 127.0.0.1:3000 check ``` `ssl-min-ver` is available on maintained HAProxy 3.0. For explicit cipher lists, `ssl-default-bind-ciphers` configures TLS 1.2 and `ssl-default-bind-ciphersuites` configures TLS 1.3; the [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/) is a policy source, not the authority for HAProxy syntax. `http-after-response` (HAProxy 2.2+) sets the HSTS header on HAProxy's own generated responses, such as the 401 and 413 below, which `http-response` would miss; add `includeSubDomains` only once every affected subdomain is served over HTTPS. The backend here is plaintext on loopback; for a remote backend, require and verify its certificate, for example `server app1 app.internal:443 ssl verify required ca-file /etc/ssl/certs/internal-ca.crt verifyhost app.internal check`. ## 2. Require authentication Application-level login is preferable ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). At the proxy, define a userlist with a crypt(3)-hashed password and demand it: ```bash openssl passwd -6 # prompts, outputs a $6$ SHA-512 crypt hash ``` ```haproxy userlist admins user admin password $6$REPLACE_WITH_HASH backend app http-request auth realm Restricted unless { http_auth(admins) } server app1 127.0.0.1:3000 check ``` `openssl passwd -6` prompts for the password. The prompt keeps the password out of the command text, shell history and shell tracing; the account owner and root can still read it in the `openssl` process while it runs. Hashed `password` entries rely on the system's crypt(3); `$6$` works on glibc-based Linux. Avoid `insecure-password`, which stores the password in cleartext in the configuration file. For machine-to-machine access, client certificates are stronger: add `verify required ca-file /etc/ssl/certs/internal-ca.crt` to the `bind :443` line. Basic authentication here is single-factor. For human-facing sites, add MFA with an [Authelia](https://www.authelia.com/) portal (HAProxy is supported through Authelia's Lua module) or by fronting the site with Cloudflare Access and requiring MFA in its governing Access setting (policy over application over organization); options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 3. Bound the expensive endpoints The timeouts in section 1 are half of this. Add a concurrency cap with `maxconn`, which in `global` is the maximum per-process concurrent connections and in a frontend caps that frontend. HAProxy has **no single request-body-size directive**. The rule below uses the `req.body_size` fetch, which the manual defines as the *advertised* length of the body, so for a request carrying `Content-Length` it reads that value and can reject without waiting for the request body. Two limits are worth knowing. It needs no request buffering; do not add `option http-buffer-request` for it, because the manual says that option waits until either the whole body is received or the request buffer is full, which buffers the upload you are trying to refuse. And for a chunked request, which advertises no length, the manual says the fetch returns the size of the available data instead, which without buffering is only what has arrived so far, so this rule does not meaningfully bound a chunked upload. Enforce the real limit at the application and treat this as a front-door guard against the common case. ```haproxy # add to the existing global section maxconn 4096 # add to `frontend web` from section 1. Do not paste a new frontend: that one # carries bind, the HTTPS redirect, the HSTS header and default_backend. maxconn 2000 http-request deny deny_status 413 if { req.body_size gt 10485760 } ``` ## 4. The management plane: stats and the runtime API The stats page and the runtime API are separate from proxied traffic and easy to leave open. Inventory both in every loaded configuration, and leave each disabled unless needed. The stats page is disabled unless a configuration enables it (in a `frontend`, `listen`, `backend`, or `defaults`), and enabling it authenticates nothing on its own: an unauthenticated page leaks operational detail, and `stats admin` additionally lets a caller enable, disable, or drain backends. Inventory every loaded configuration, omit `stats admin` from a monitoring page, keep any admin page private, and reuse the hashed userlist rather than a cleartext `stats auth`: ```haproxy listen monitoring bind 127.0.0.1:8404 ssl crt /etc/haproxy/certs/example.com.pem mode http stats enable stats uri /stats stats http-request auth unless { http_auth(admins) } ``` The runtime API (the `stats socket` added to `global` above) is not enabled by default, and its default command level is `operator`; `level admin` grants authority, not authentication. Keep it a root-owned local Unix socket at mode `600` and never expose it as an unauthenticated TCP socket, because HTTP stats authentication does not protect it. ## 5. Verify ```bash # REASONED: configuration and exposed/fixed checks follow the cited HAProxy documentation; # no HAProxy runtime is available in the authoring environment. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 3 (inconclusive) outranks 2 (a part refused its input), which outranks 0; no answer here is classified # A failed haproxy -c records 3, not 2: the configuration test is the check the verify-config claim names, not a guard on your setup. sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy case "$?" in 0) ;; *) echo "haproxy -c or reload exit $?: the requests below may test the configuration HAProxy was already running; inconclusive"; set -- 3 ;; esac # REASONED, not demonstrated here: no HAProxy runtime in the authoring environment, so the shell of an earlier # form of this block was checked but the exposed-vs-fixed responses were not. The current form is covered by # the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by stub runs on # 2026-10-31 with Bash 5.3.9, stub curl, sudo, haproxy, systemctl, rm and ss, a pty for the hidden prompt and # no network; no HAProxy answered them. A missing ss records 2 only when nothing else was recorded, and an ss # that runs and fails records 3. A transport, TLS, or # DNS error is inconclusive, never a pass. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e # never trace or export the credential read below { unset -n pw && unset -v pw; } 2>/dev/null || { echo 'a readonly pw is set in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the host; not probing'; exit 2 ;; esac IFS= read -r -s -p 'admin password: ' pw < /dev/tty || { echo; echo 'password input failed; not probing'; exit 3; }; echo case "$pw" in ''|*[[:cntrl:]]*) echo 'supply the admin password; not probing'; exit 2 ;; esac pw=${pw//\\/\\\\}; pw=${pw//\"/\\\"} # escape \ and " so curl --config parsing keeps the exact password set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above # 1) HTTP must redirect to HTTPS. curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 30 -D - -o /dev/null -w 'redirect http=%{http_code}\n' "http://$1/" # expect 301 + https Location case "$?" in 0) ;; *) echo "curl exit $?: the redirect request failed; inconclusive"; set -- "$1" 3 ;; esac # 2) Auth triple against ONE protected resource. No creds and wrong creds must be 401; valid creds the app's # expected success. The password reaches curl through stdin config (curl --config -), never argv. # The hidden prompt keeps it out of shell history and set +x keeps it out of set -x tracing. Stdin # closes the argv channel only: the account owner and root can still read it in the subshell and curl. curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 30 -o /dev/null -w 'no-creds http=%{http_code}\n' "https://$1/" case "$?" in 0) ;; *) echo "curl exit $?: the request without credentials failed; inconclusive"; set -- "$1" 3 ;; esac printf 'user = "admin:%s"\n' 'definitely-wrong' | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 30 --config - -o /dev/null -w 'bad-creds http=%{http_code}\n' "https://$1/" case "$?" in 0) ;; *) echo "curl exit $?: the request with wrong credentials failed; inconclusive"; set -- "$1" 3 ;; esac printf 'user = "admin:%s"\n' "$pw" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 30 --config - -o /dev/null -w 'valid http=%{http_code}\n' "https://$1/" case "$?" in 0) ;; *) echo "curl exit $?: the request with valid credentials failed; inconclusive"; set -- "$1" 3 ;; esac # 3) Body-size guard, BOTH cases with valid creds so a 401 cannot masquerade as the result. Payloads live in # a private temp dir removed at the end of this part. set -- "$1" "$2" "$(if mktemp -d; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%%$'\n'*}" "${3##*$'\n'}" # this part's status, the temp dir, then mktemp's exit status if [ "$4" != 0 ]; then echo 'no tempdir; not probing'; set -- "$1" 3 else if head -c 1M /dev/zero > "$3/under.bin" && head -c 11M /dev/zero > "$3/over.bin"; then printf 'user = "admin:%s"\n' "$pw" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 30 --config - --data-binary @"$3/under.bin" \ -o /dev/null -w 'under http=%{http_code}\n' "https://$1/" case "$?" in 0) ;; *) echo "curl exit $?: the under request failed; inconclusive"; set -- "$1" 3 "$3" "$4" ;; esac printf 'user = "admin:%s"\n' "$pw" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 30 --config - --data-binary @"$3/over.bin" \ -o /dev/null -w 'over http=%{http_code}\n' "https://$1/" case "$?" in 0) ;; *) echo "curl exit $?: the over request failed; inconclusive"; set -- "$1" 3 "$3" "$4" ;; esac else echo 'payload write failed'; set -- "$1" 3 "$3" "$4" fi rm -rf -- "$3" case "$?" in 0) ;; *) echo "rm exit $?: the temp dir $3 could not be removed; inconclusive"; set -- "$1" 3 "$3" "$4" ;; esac fi # under: the app's success, not 413; over: 413. req.body_size reads the advertised Content-Length, which curl # sets here; a chunked upload advertises none and is not covered. A backend can return 413 too, so to # attribute the refusal to HAProxy, in an isolated fixture remove ONLY the http-request deny line and # confirm the over upload then reaches the app. exit "$2" ) case "$?:$1" in 0:*) ;; 2:0) set -- 2 ;; 2:*) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above if command -v ss >/dev/null; then # no other part needs ss, so a missing ss records 2 only when nothing else was recorded ss -tlnp # inventory: the backend (3000) must be 127.0.0.1 only, never 0.0.0.0 - the checks above pass while # the backend also answers directly on 3000, bypassing HAProxy's TLS and auth. Confirm the runtime # socket is a root-owned mode-600 Unix socket (ls -l /run/haproxy/admin.sock), not a TCP listener. case "$?" in 0) ;; *) echo "ss -tlnp exit $?; inconclusive"; set -- 3 ;; esac else echo "ss is not installed here; not listing sockets"; case "$1" in 0) set -- 2 ;; esac fi echo "block exit $1; no answer in this block is classified: judge the redirect headers, the no-creds, bad-creds, valid, under and over codes and the listener table yourself against the comments above" exit "$1" ) ``` REASONED: following block; management-surface comparisons follow the cited HAProxy stats, runtime API and TLS documentation, with no HAProxy runtime or certificate fixtures available. Bash 5.3.9(1)-release passed bash -n on an earlier form, when the stats, client-certificate and runtime-socket checks shared one block, and extracted guards with local printf markers rejected empty, placeholder and missing host setup but admitted both proxy.internal and bad host, so hostname syntax is not validated. In that earlier form the certificate/key guards skipped placeholders, empty and nonexistent paths with exit 0 and reached the marker for readable plain-text files, proving neither certificate validity nor a successful TLS check; they now refuse with status 2. No network command ran. The current forms of the three blocks below are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by stub runs on 2026-10-31 with Bash 5.3.9, stub curl, sudo and socat, a pty for the hidden prompt and no network. On 2026-10-31, run against a local mode-000 Unix socket with no network, a real socat 1.8.1.1 printed its denial as `socat[pid] E GOPEN: ...: Permission denied`, or as `E stat(...): Permission denied` when the socket's directory was not searchable, so the runtime-socket block reads only socat's own error line with a permission denial as the `nobody` denial. Each of them exits 2 when a guard refuses, 1 for a finding, 3 when a command it attempts or its positive control fails or an answer is neither the pass nor the finding, and otherwise 0, and its last line names what it leaves for you to judge. Verify each management surface you enabled, REASONED from the cited HAProxy documentation: the stats listener, the runtime socket and, where `verify required` is set, client certificates. Run only the blocks for the surfaces you enabled. The step for an enabled stats listener, REASONED from the cited HAProxy documentation, is to verify that listener with an unauthenticated request (refused) and a valid-credential request (returns the statistics) against that listener specifically. The step for an enabled runtime socket, REASONED from the cited HAProxy documentation, is to verify that socket by its ownership and mode and an unauthorized local identity (permission denied), since `show cli level` reporting `admin` is the authorized level, not proof of access control. The client certificate step, REASONED from the cited HAProxy documentation, is to verify, where `verify required` is set, client certificates (no certificate and an untrusted one rejected at TLS, a trusted one admitted with `--cert`/`--key`, server verification kept on). In this block and the one above, paste the password alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( # a subshell, so the exits below end only the subshell trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n sp && unset -v sp; } 2>/dev/null || { echo 'a readonly sp is set in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the host; not probing'; exit 2 ;; esac # The stats password reaches curl through stdin config (curl --config -), never argv. The hidden prompt # keeps it out of shell history and set +x keeps it out of set -x tracing. Stdin closes the argv channel # only: the account owner and root can still read it in the subshell and curl. IFS= read -r -s -p 'stats admin password: ' sp < /dev/tty || { echo; echo 'stats password input failed; not probing'; exit 3; }; echo case "$sp" in ''|*[[:cntrl:]]*) echo 'supply the stats password; not probing'; exit 2 ;; esac sp=${sp//\\/\\\\}; sp=${sp//\"/\\\"} set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Stats listener (run on the HAProxy machine; --resolve keeps the certificate hostname while connecting to # the loopback bind): no credentials must be refused; valid credentials return the statistics. set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 --resolve "$1:8404:127.0.0.1" -o /dev/null -w 'stats no-creds=%{http_code}\n' "https://$1:8404/stats"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its stats no-creds= line printf '%s\n' "$4" case "$3:$4" in "0:stats no-creds=401"|"0:stats no-creds=403") ;; "0:stats no-creds=2"??) echo "FINDING: the stats listener answered without credentials"; set -- "$1" 1 ;; 0:*) echo "the request without credentials was neither refused nor answered; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $3: the stats request without credentials failed; inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if printf 'user = "admin:%s"\n' "$sp" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 --resolve "$1:8404:127.0.0.1" --config - -o /dev/null -w 'stats auth=%{http_code}\n' "https://$1:8404/stats"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its stats auth= line printf '%s\n' "$4" case "$3:$4" in "0:stats auth=200") ;; 0:*) echo "valid credentials did not return the statistics, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the stats request with valid credentials failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the statistics page itself is not read here, so open it with valid credentials and judge it yourself" exit "$2" ) ``` ```bash # REASONED: client-certificate checks follow the cited HAProxy bind verify documentation; no HAProxy runtime or certificate fixtures are available. # mTLS, only if `bind ... verify required`: no cert and an untrusted cert must be rejected at TLS; a trusted # cert must be admitted (it may still return 401 if Basic auth is also configured). The path guards keep a # local file error from standing in for a server rejection. ( # a subshell, so the exits below end only the subshell set +e # errexit off here, so a rejected request is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HOST' 'REPLACE_WITH_UNTRUSTED_CRT' 'REPLACE_WITH_UNTRUSTED_KEY' 'REPLACE_WITH_TRUSTED_CRT' 'REPLACE_WITH_TRUSTED_KEY' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 5 ] || { echo 'the set -- line needs exactly 5 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the host; not probing'; exit 2 ;; esac case "$2$3" in *REPLACE_WITH_*) echo "substitute the untrusted cert and key; not probing"; exit 2 ;; esac case "$4$5" in *REPLACE_WITH_*) echo "substitute the trusted cert and key; not probing"; exit 2 ;; esac if [ -r "$2" ] && [ -r "$3" ]; then :; else echo "untrusted cert or key not readable; not probing"; exit 2; fi if [ -r "$4" ] && [ -r "$5" ]; then :; else echo "trusted cert or key not readable; not probing"; exit 2; fi set -- "$1" "$2" "$3" "$4" "$5" 0 # the 6th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 -o /dev/null -w 'mtls no-cert=%{http_code} exit=%{exitcode}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then its mtls no-cert= line printf '%s\n' "$8" case "$7:$8" in 0:"mtls no-cert="[0123456789][0123456789][0123456789]" "*) echo "FINDING: the request without a client certificate was admitted"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; # a code not of three characters from 0123456789 reaches one of the last two arms: inconclusive "35:mtls no-cert=000 "*|"56:mtls no-cert=000 "*) ;; # rejected at TLS with no HTTP status received; reading curl's handshake (35) and receive (56) errors as the server's certificate rejection is reasoned, not sourced *:"mtls no-cert=000 "*) echo "curl exit $7 is not a TLS rejection; inconclusive"; set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *:"mtls no-cert="[0123456789][0123456789][0123456789]" "*) echo "FINDING: the request without a client certificate received an HTTP status before curl exit $7, so TLS admitted it"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 35:*|56:*) echo "curl exit $7 came with a malformed write-out line, so it cannot be read as a TLS rejection; inconclusive"; set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7 is not a TLS rejection; inconclusive"; set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 --cert "$2" --key "$3" \ -o /dev/null -w 'mtls untrusted=%{http_code} exit=%{exitcode}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then its mtls untrusted= line printf '%s\n' "$8" case "$7:$8" in 0:"mtls untrusted="[0123456789][0123456789][0123456789]" "*) echo "FINDING: the request with the untrusted certificate was admitted"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; "35:mtls untrusted=000 "*|"56:mtls untrusted=000 "*) ;; *:"mtls untrusted=000 "*) echo "curl exit $7 is not a TLS rejection; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *:"mtls untrusted="[0123456789][0123456789][0123456789]" "*) echo "FINDING: the request with the untrusted certificate received an HTTP status before curl exit $7, so TLS admitted it"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 35:*|56:*) echo "curl exit $7 came with a malformed write-out line, so it cannot be read as a TLS rejection; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7 is not a TLS rejection; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 --cert "$4" --key "$5" \ -o /dev/null -w 'mtls trusted=%{http_code} exit=%{exitcode}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then its mtls trusted= line printf '%s\n' "$8" case "$7:$8" in 0:"mtls trusted="[0123456789][0123456789][0123456789]" "*) ;; # any code of three characters from 0123456789 is the admission, a 401 from Basic auth included 0:*) echo "curl exit 0 came with a malformed write-out line, so the positive control cannot be read; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the trusted certificate was not admitted, so the positive control failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac echo "block exit $6; the HTTP codes are not classified (a trusted certificate may still draw 401 from Basic auth), and exit 35 or 56 reads as a TLS rejection only beside an admitted trusted certificate, so judge the lines above yourself" exit "$6" ) ``` ```bash # REASONED: runtime-socket checks follow the cited HAProxy management documentation; no HAProxy runtime is available. # Runtime socket: root-owned mode-600 Unix socket, and an unauthorized local identity must be denied. ( # a subshell, so the exit below ends only the subshell set +e # errexit off here, so the expected denial below is classified instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 ls -l /run/haproxy/admin.sock # expect srw------- root root case "$?" in 0) ;; *) echo "ls exit $?: /run/haproxy/admin.sock could not be listed; inconclusive"; set -- 3 ;; esac # guard-conventions: allow fixed local HAProxy admin socket /run/haproxy/admin.sock; no reader-substituted target set -- "$1" "$(if printf 'show info\n' | sudo socat - /run/haproxy/admin.sock; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # socat's exit status, then the first line it printed printf '%s\n' "$3" # authorized (root): prints info case "$2" in 0) ;; *) echo "socat exit $2: the authorized show info request failed; inconclusive"; set -- 3 ;; esac set -- "$1" "$(if sudo -u nobody socat - /run/haproxy/admin.sock < /dev/null 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # unauthorized identity: expect permission denied set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # socat's exit status, then what it printed printf '%s\n' "${3%$'\n'}" # without its own final line feed, so no blank line follows case "$2:$3" in 0:*) echo "FINDING: the nobody account connected to the runtime socket"; set -- 1 ;; *"] E "*"ermission denied"*) ;; # socat's own error line (socat[pid] E ...) with a permission denial, as observed with socat 1.8.1.1; not sudo's or another tool's *) echo "socat exit $2 without a permission denial; inconclusive"; set -- 3 ;; esac echo "block exit $1; the socket's mode and owner in the ls -l line and the show info output are not classified, so judge them yourself" exit "$1" ) ``` ## Common mistakes - Copying only `fullchain.pem` into the combined crt file used here; that file needs the matching private key too (HAProxy can instead load a separate adjacent key file, but this example uses the combined form). - Renewing the certificate without rebuilding the combined PEM or reloading HAProxy. - Backends reachable directly on `0.0.0.0`, bypassing the proxy; bind them to `127.0.0.1` and confirm with `ss -tlnp`. ## Sources (checked September 2026) - HAProxy 3.0 Configuration Manual (`bind ssl crt`/`verify`, `ssl-default-bind-options`/`-ciphers`/`-ciphersuites`, `maxconn`, `req.body_size`, `http-request deny deny_status`, `http-after-response`, `timeout http-request`, `stats enable`/`auth`/`admin`, `user`/`group`/`chroot`) (http-after-response: HAProxy 2.2+): https://docs.haproxy.org/3.0/configuration.html - HAProxy 3.0 Management Guide (runtime API over the `stats socket`, command levels, reload): https://docs.haproxy.org/3.0/management.html - HAProxy supported releases: https://www.haproxy.org/ - HAProxy statistics dashboard (`stats enable`/`auth`/`admin` capabilities): https://www.haproxy.com/documentation/haproxy-configuration-tutorials/alerts-and-monitoring/statistics/ - Authelia HAProxy integration (MFA portal): https://www.authelia.com/integration/proxies/haproxy/ - Mozilla SSL Configuration Generator (TLS policy, not HAProxy syntax): https://ssl-config.mozilla.org/ - OpenSSL 3.0 `passwd -6` SHA-512 crypt and terminal prompting (checked October 2026): https://docs.openssl.org/3.0/man1/openssl-passwd/ - Certbot v2.3.0 deploy hooks after successful renewal (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L659-L675 - Cloudflare Access MFA documentation at c896795b8e8bab68d05ae78d64c9cbcda1fdb104, application or policy MFA (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - haproxy: HAProxy (3.0) - hsts: HAProxy http-after-response minimum (2.2+) - docs: Supporting documentation (unknown) - openssl-cli: OpenSSL password command (3.0) - certbot-source: Certbot deploy hook documentation (v2.3.0) - cloudflare-source: Cloudflare Access MFA documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) Claims (full source mapping in the JSON block below): - scope (REASONED; haproxy, docs): Examples target a maintained HAProxy 3.0 installation and treat frontend, stats, runtime API and direct backend as separate surfaces. - pem (REASONED; haproxy): Use matching chain/key in a combined PEM, built via a root-only temporary file and rename; separate key loading is also supported. - renewal (REASONED; haproxy, certbot-source): Rebuild the combined PEM and reload HAProxy after successful renewal using Certbot v2.3.0 --deploy-hook or an executable in /etc/letsencrypt/renewal-hooks/deploy (if the configuration directory is /etc/letsencrypt; certbot renew only). - tls (REASONED; haproxy): Frontend :443 loads the PEM with ssl and ssl-min-ver TLSv1.2; :80 redirects to HTTPS with 301. - ciphers (REASONED; haproxy, docs): ssl-default-bind-ciphers governs TLS 1.2 and -ciphersuites TLS 1.3; Mozilla is policy guidance, not HAProxy syntax authority. - privileges (REASONED; haproxy): Preserve service hardening; root-started workers drop to haproxy user/group and chroot into a provisioned root-owned directory. - timeouts (REASONED; haproxy): Set connect 5s, client/server 30s and http-request 10s timeouts. - hsts (REASONED; haproxy, hsts): http-after-response (HAProxy 2.2+) covers generated 401/413 responses that http-response misses; includeSubDomains requires HTTPS on every affected subdomain. - backend (REASONED; haproxy): Example backend is plaintext 127.0.0.1:3000 with health checks; keep it private. - remote-backend (REASONED; haproxy): Remote TLS backends require certificate verification, a trusted CA and verifyhost matching the backend name. - basic (REASONED; haproxy, openssl-cli): Use a crypt(3)-hashed userlist and http_auth condition; $6$ works on glibc Linux, while insecure-password stores plaintext. - mtls (REASONED; haproxy): For machine access, bind verify required with ca-file requires trusted client certificates. - mfa (REASONED; docs, cloudflare-source): Basic is single-factor; Authelia uses its HAProxy Lua integration, or front the site with Cloudflare Access and require MFA in its governing Access setting (policy over application over organization). - maxconn (REASONED; haproxy): Global maxconn 4096 caps per-process concurrent connections; frontend maxconn 2000 caps that frontend. - body-guard (REASONED; haproxy): No single request-body-size directive: req.body_size reads advertised Content-Length; deny_status 413 rejects values above 10485760 without buffering. - body-buffering (REASONED; haproxy): Do not add http-buffer-request for this guard: it waits for a complete body or full request buffer. - chunked (REASONED; haproxy): Chunked req.body_size reflects available data, so this unbuffered guard does not meaningfully bound chunked uploads; enforce the real limit in the app. - stats-default (REASONED; haproxy, docs): Stats is disabled until configured and enabling it adds no authentication; inventory every loaded config. - stats-admin (REASONED; haproxy, docs): stats admin permits backend enable/disable/drain; omit it for monitoring and keep any admin page private. - stats-auth (REASONED; haproxy, docs): Bind the example stats listener to TLS 127.0.0.1:8404 at /stats and use hashed-userlist auth instead of cleartext stats auth. - runtime-default (REASONED; haproxy): Runtime API is not enabled by default; default command level is operator, and level admin grants authority rather than authentication. - runtime-isolation (REASONED; haproxy): Keep /run/haproxy/admin.sock a root-owned mode-600 Unix socket; HTTP stats auth does not protect an exposed runtime TCP socket. - verify-config (REASONED; haproxy): haproxy -c precedes reload; the guide records shell checks but no HAProxy runtime or exposed/fixed response demonstration. - verify-redirect (REASONED; haproxy): HTTP must return 301 with an HTTPS Location; transport/TLS/DNS errors are inconclusive. - verify-auth (REASONED; haproxy): On one protected resource, absent and wrong credentials yield 401 while valid credentials reach the expected app response; credentials travel via stdin. - verify-size (REASONED; haproxy): With valid credentials, 1M succeeds and 11M returns 413; remove only the deny rule in isolation to attribute refusal. These Content-Length probes do not test chunked bodies. - verify-backend (REASONED; haproxy): Inventory all listeners: backend 3000 must be loopback-only even if frontend checks pass, and the runtime endpoint must not be TCP. - verify-stats (REASONED; haproxy, docs): Probe the 8404 stats listener itself via loopback with the certificate hostname: no credentials refused, valid credentials return statistics. - verify-mtls (REASONED; haproxy): With verify required, no/untrusted client certificates must fail TLS and trusted ones pass; readable cert/key controls avoid local errors, and Basic may still return 401. - verify-runtime (REASONED; haproxy): Check root/root mode 600 and root show info success versus nobody permission denial; reported admin command level is not proof of access control. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-09", "body_sha256": "d2feed52638b0fb5736dd12ba940bb27a5bb2fc765bec492b0f15a8b22908a8d", "components": { "haproxy": { "name": "HAProxy", "basis": "3.0", "sources": { "s5792e6ff4990": "https://docs.haproxy.org/3.0/configuration.html", "s9c32621d6493": "https://docs.haproxy.org/3.0/management.html" } }, "hsts": { "name": "HAProxy http-after-response minimum", "basis": "2.2+", "sources": { "s5792e6ff4990": "https://docs.haproxy.org/3.0/configuration.html" } }, "docs": { "name": "Supporting documentation", "basis": "unknown", "sources": { "s2be90eb936bd": "https://www.haproxy.org/", "sae992a8ac3a7": "https://www.haproxy.com/documentation/haproxy-configuration-tutorials/alerts-and-monitoring/statistics/", "s192d42dd9feb": "https://www.authelia.com/integration/proxies/haproxy/", "s529b0eabe2ed": "https://ssl-config.mozilla.org/" } }, "openssl-cli": { "name": "OpenSSL password command", "basis": "3.0", "sources": { "s3b2ccc45875a": "https://docs.openssl.org/3.0/man1/openssl-passwd/" } }, "certbot-source": { "name": "Certbot deploy hook documentation", "basis": "v2.3.0", "sources": { "sd39028a759a7": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L659-L675" } }, "cloudflare-source": { "name": "Cloudflare Access MFA documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sdcac732541da": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81" } } }, "claims": { "scope": {"text": "Examples target a maintained HAProxy 3.0 installation and treat frontend, stats, runtime API and direct backend as separate surfaces.", "components": ["haproxy", "docs"], "sources": ["haproxy:s5792e6ff4990", "haproxy:s9c32621d6493", "docs:s2be90eb936bd"], "status": "REASONED"}, "pem": {"text": "Use matching chain/key in a combined PEM, built via a root-only temporary file and rename; separate key loading is also supported.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "renewal": {"text": "Rebuild the combined PEM and reload HAProxy after successful renewal using Certbot v2.3.0 --deploy-hook or an executable in /etc/letsencrypt/renewal-hooks/deploy (if the configuration directory is /etc/letsencrypt; certbot renew only).", "components": ["haproxy", "certbot-source"], "sources": ["haproxy:s5792e6ff4990", "haproxy:s9c32621d6493", "certbot-source:sd39028a759a7"], "status": "REASONED"}, "tls": {"text": "Frontend :443 loads the PEM with ssl and ssl-min-ver TLSv1.2; :80 redirects to HTTPS with 301.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "ciphers": {"text": "ssl-default-bind-ciphers governs TLS 1.2 and -ciphersuites TLS 1.3; Mozilla is policy guidance, not HAProxy syntax authority.", "components": ["haproxy", "docs"], "sources": ["haproxy:s5792e6ff4990", "docs:s529b0eabe2ed"], "status": "REASONED"}, "privileges": {"text": "Preserve service hardening; root-started workers drop to haproxy user/group and chroot into a provisioned root-owned directory.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "timeouts": {"text": "Set connect 5s, client/server 30s and http-request 10s timeouts.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "hsts": {"text": "http-after-response (HAProxy 2.2+) covers generated 401/413 responses that http-response misses; includeSubDomains requires HTTPS on every affected subdomain.", "components": ["haproxy", "hsts"], "sources": ["haproxy:s5792e6ff4990", "hsts:s5792e6ff4990"], "status": "REASONED"}, "backend": {"text": "Example backend is plaintext 127.0.0.1:3000 with health checks; keep it private.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "remote-backend": {"text": "Remote TLS backends require certificate verification, a trusted CA and verifyhost matching the backend name.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "basic": {"text": "Use a crypt(3)-hashed userlist and http_auth condition; $6$ works on glibc Linux, while insecure-password stores plaintext.", "components": ["haproxy", "openssl-cli"], "sources": ["haproxy:s5792e6ff4990", "openssl-cli:s3b2ccc45875a"], "status": "REASONED"}, "mtls": {"text": "For machine access, bind verify required with ca-file requires trusted client certificates.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "mfa": {"text": "Basic is single-factor; Authelia uses its HAProxy Lua integration, or front the site with Cloudflare Access and require MFA in its governing Access setting (policy over application over organization).", "components": ["docs", "cloudflare-source"], "sources": ["docs:s192d42dd9feb", "cloudflare-source:sdcac732541da"], "status": "REASONED"}, "maxconn": {"text": "Global maxconn 4096 caps per-process concurrent connections; frontend maxconn 2000 caps that frontend.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "body-guard": {"text": "No single request-body-size directive: req.body_size reads advertised Content-Length; deny_status 413 rejects values above 10485760 without buffering.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "body-buffering": {"text": "Do not add http-buffer-request for this guard: it waits for a complete body or full request buffer.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "chunked": {"text": "Chunked req.body_size reflects available data, so this unbuffered guard does not meaningfully bound chunked uploads; enforce the real limit in the app.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED"}, "stats-default": {"text": "Stats is disabled until configured and enabling it adds no authentication; inventory every loaded config.", "components": ["haproxy", "docs"], "sources": ["haproxy:s5792e6ff4990", "docs:sae992a8ac3a7"], "status": "REASONED"}, "stats-admin": {"text": "stats admin permits backend enable/disable/drain; omit it for monitoring and keep any admin page private.", "components": ["haproxy", "docs"], "sources": ["haproxy:s5792e6ff4990", "docs:sae992a8ac3a7"], "status": "REASONED"}, "stats-auth": {"text": "Bind the example stats listener to TLS 127.0.0.1:8404 at /stats and use hashed-userlist auth instead of cleartext stats auth.", "components": ["haproxy", "docs"], "sources": ["haproxy:s5792e6ff4990", "docs:sae992a8ac3a7"], "status": "REASONED"}, "runtime-default": {"text": "Runtime API is not enabled by default; default command level is operator, and level admin grants authority rather than authentication.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990", "haproxy:s9c32621d6493"], "status": "REASONED"}, "runtime-isolation": {"text": "Keep /run/haproxy/admin.sock a root-owned mode-600 Unix socket; HTTP stats auth does not protect an exposed runtime TCP socket.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990", "haproxy:s9c32621d6493"], "status": "REASONED"}, "verify-config": {"text": "haproxy -c precedes reload; the guide records shell checks but no HAProxy runtime or exposed/fixed response demonstration.", "components": ["haproxy"], "sources": ["haproxy:s9c32621d6493"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "HTTP must return 301 with an HTTPS Location; transport/TLS/DNS errors are inconclusive.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "On one protected resource, absent and wrong credentials yield 401 while valid credentials reach the expected app response; credentials travel via stdin.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED", "verify": [1]}, "verify-size": {"text": "With valid credentials, 1M succeeds and 11M returns 413; remove only the deny rule in isolation to attribute refusal. These Content-Length probes do not test chunked bodies.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED", "verify": [1]}, "verify-backend": {"text": "Inventory all listeners: backend 3000 must be loopback-only even if frontend checks pass, and the runtime endpoint must not be TCP.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990", "haproxy:s9c32621d6493"], "status": "REASONED", "verify": [1]}, "verify-stats": {"text": "Probe the 8404 stats listener itself via loopback with the certificate hostname: no credentials refused, valid credentials return statistics.", "components": ["haproxy", "docs"], "sources": ["haproxy:s5792e6ff4990", "docs:sae992a8ac3a7"], "status": "REASONED", "verify": [2]}, "verify-mtls": {"text": "With verify required, no/untrusted client certificates must fail TLS and trusted ones pass; readable cert/key controls avoid local errors, and Basic may still return 401.", "components": ["haproxy"], "sources": ["haproxy:s5792e6ff4990"], "status": "REASONED", "verify": [3]}, "verify-runtime": {"text": "Check root/root mode 600 and root show info success versus nobody permission denial; reported admin command level is not proof of access control.", "components": ["haproxy"], "sources": ["haproxy:s9c32621d6493", "haproxy:s5792e6ff4990"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> traefik.md ====================================================================== # Traefik: automatic TLS and authentication middleware Traefik is a reverse proxy that sits in front of your applications, often Docker containers, and routes each request to the right one. This guide helps your AI assistant configure it securely, including HTTPS entry points with HTTP redirected, ACME certificates, authentication middleware on routes, and request limits. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/traefik.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Traefik v3 documentation v3.7, Traefik ACME storage source v3.7.17, Traefik BasicAuth source v3.7.17, Apache httpd htpasswd source 2.4.69, and curl write-out minimum 7.75.0; 2 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-11-08; documentation checked September 2026. The guide records 31 claims: 1 demonstrated by a check that ran and 30 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Applies to Traefik v2 and v3. Traefik obtains and renews certificates itself through ACME resolvers, which suits container deployments. ## 1. Static configuration: entry points, redirect, ACME `traefik.yml`: ```yaml entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" certificatesResolvers: letsencrypt: acme: email: admin@example.com storage: /letsencrypt/acme.json tlsChallenge: {} providers: docker: exposedByDefault: false ``` `acme.json` must persist across restarts (volume-mount it) and be mode `600`. The TLS-ALPN challenge above needs port 443 reachable from the internet; use `httpChallenge` (port 80) or a `dnsChallenge` (wildcards, no inbound ports) where that fits better. The `providers.docker` block turns on container discovery, and `exposedByDefault: false` is the security-relevant half. Its default is `true`, so a `providers.docker` block without that line builds a router for every eligible container it discovers, not only the one you labelled. The provider reads labels over the Docker socket, so mount it into the Traefik service: ```yaml services: traefik: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro ``` Mounting the socket gives Traefik control of the Docker daemon, which is root-equivalent on the host: a compromised Traefik is a compromised host. The `:ro` marks the socket file read-only; it does not make the Docker API read-only. To limit what Traefik can call, put a read-only Docker socket proxy in front of the socket (see the Docker provider reference under Sources). Raise the protocol floor with a TLS options block in the dynamic configuration: ```yaml tls: options: default: minVersion: VersionTLS12 ``` ## 2. Route a service with TLS (Docker labels) ```yaml services: app: image: yourapp labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`app.example.com`) - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 ``` Labelling this container `traefik.enable=true` opts it in; it does not keep any other container private. With `exposedByDefault: false` set in section 1, Traefik ignores every container that lacks that label. Without it, discovery is daemon-wide, not scoped to this Compose project, so any other container that exposes a port Traefik can build a service from also gets a router matched by its `Host` header on the same `:443` listener. A container with no usable port is skipped, and the port Traefik picks need not serve usable HTTP; but a router you never requested is the exposure this setting shuts off. The label opts a container in; it never opts others out, so keep it on each application you mean to publish. Do not also publish the app's port with `ports:`; only Traefik publishes 80 and 443. See [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md). ## 3. Require authentication Application-level login is preferable ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). At the proxy, attach a basicAuth middleware with bcrypt entries from `htpasswd -nB -C 12 admin`. In Apache httpd 2.4.69, bare `-nB` defaults to bcrypt cost 5 and `-C 12` selects cost 12; OWASP at 72865b1e recommends a work factor of 10 or more for legacy bcrypt: ```yaml labels: - traefik.http.middlewares.app-auth.basicauth.users=admin:$$2y$$12$$REPLACE_WITH_HASH - traefik.http.routers.app.middlewares=app-auth ``` In Compose files every `$` in the hash must be doubled to `$$`. The file-provider equivalent, where no escaping is needed: ```yaml http: middlewares: app-auth: basicAuth: users: - "admin:$2y$12$REPLACE_WITH_HASH" ``` Load that dynamic file with `providers.file.directory` (or `providers.file.filename`) in the static config, and when a Docker-labelled router references a file-defined middleware, qualify it as `app-auth@file`. basicAuth is single-factor. For human-facing sites, add MFA with the `forwardAuth` middleware pointed at [Authelia](https://www.authelia.com/) or [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), or front the site with Cloudflare Access and require MFA in its governing Access setting (policy over application over organization); options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Run Traefik v3.7.10 or later, or v3.6.25 or later on the 3.6 line. Advisory GHSA-6765-c87h-8mrf, rated low, reports that in Traefik v3.6.11 to v3.6.24 and v3.7.0 to v3.7.9 the BasicAuth singleflight key that deduplicates password checks could collide across different credential pairs, so a request could be authenticated as a different user than its own credentials; it is fixed in v3.6.25 and v3.7.10. This guide's prior v3.5.0 basis and the current v3.7.17 are outside that affected range. Traefik v3.7.17 keys the check on the user and the password, refuses to create a BasicAuth middleware with no users, and checks an unknown user's password against one of the configured hashes, which its source says resists timing attacks only when every configured hash uses the same algorithm. ## 4. Bound the expensive endpoints Three middlewares, attached to the router alongside the auth middleware from section 3. ```yaml # ADD these to the labels you already have. Do not replace that list: # section 2 carries the router rule, entrypoints, certresolver and service # port, and section 3 carries basicauth.users. A labels block without them # breaks routing and authentication. labels: - traefik.http.middlewares.app-body.buffering.maxRequestBodyBytes=10485760 - traefik.http.middlewares.app-inflight.inflightreq.amount=10 - traefik.http.middlewares.app-rate.ratelimit.average=10 - traefik.http.middlewares.app-rate.ratelimit.burst=20 # replace the section 3 middlewares= line with this one - traefik.http.routers.app.middlewares=app-rate,app-inflight,app-auth,app-body ``` Order matters here. `app-rate` and `app-inflight` come before `app-auth` so they bound failed-login attempts: Traefik v3.7.17 BasicAuth returns on failed credentials without calling its next handler, so a limiter placed after auth cannot count a rejected attempt. `buffering` reads the request into memory or disk before forwarding it, so it must come after the admission controls; placed first, an accepted upload consumes the buffer before `ratelimit` or `inflightreq` has considered it. `maxRequestBodyBytes` sets the largest body accepted; `memRequestBodyBytes`, which defaults to 1048576, is the separate threshold at which buffering moves from memory to disk. Raising the first increases what you will buffer, not where that buffer lives. `ratelimit` is per source by default, while `inflightreq` caps concurrent in-flight requests rather than their rate, and its `sourceCriterion` defaults to the request host rather than the client, so set it explicitly if you want a per-client cap. ## 5. Verify (REASONED: all Verify scenarios follow the cited Traefik and curl documentation; no live Docker/Traefik canary demonstration is recorded. The recorded 429 alone does not demonstrate limiter attribution, and the 429 was recorded before the flood probe took a guarded host and before the first block ran its parts in one subshell with one exit status. This metadata-only review has no authorized deployment fixture. The redirect, unauthenticated, body-size and flood requests take the app's host name on guarded `set --` lines that refuse example.com, example.net, example.org and their subdomains. The repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block cover the current forms of the three blocks. Stub runs on 2026-10-31 with Bash 5.3.9, stub curl, docker, sudo, rm and ss and no network ran the guards and every exit status, on the current forms of the second and third blocks and on the earlier form of the first, which lowercased the host with `tr` before refusing reserved names; no Traefik answered them. The first block now refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and each of its host parts refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. That current form is covered by stub runs on 2026-11-01 with stub curl, docker, sudo, head, rm and ss and no network. In bash, each host part refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probes. Under dash, stub runs of the current form refused every host name in each host part with 2 and the bash message before its probes; the block still ran `docker compose ps` and `sudo ss -tlnp` and exited 2. The first block records 2 for a missing `ss` only when nothing else was recorded, because none of its other parts needs `ss`, and 3 when `sudo ss -tlnp` runs and fails. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH (the guide's cited sudo sudoers manual, secure_path entry), so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it.) ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 3 (inconclusive) outranks 2 (a part refused its input), which outranks 0; no answer here is classified ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name Traefik routes to the app (the Host rule in section 2), no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above curl -q -g -sI "http://$1/" # expect a redirect to https:// case "$?" in 0) ;; *) echo "curl exit $?: the redirect request failed; inconclusive"; set -- "$1" 3 ;; esac curl -q -g -sI "https://$1/" # expect 401 without credentials once auth is on case "$?" in 0) ;; *) echo "curl exit $?: the HTTPS request failed; inconclusive"; set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 2:0) set -- 2 ;; 2:*) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # curl reads the admin password from a config stream on stdin (--config -), # never argv (-u admin:PASSWORD is readable in ps / /proc//cmdline). trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The password you substitute on the set -- line enters shell history. # Clear that history line afterward. # Stdin closes the argv channel only: it does not hide the password from # set -x tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' 'REPLACE_WITH_PASSWORD' # the same app host name as the checks above (no scheme, user name, port, path, query, fragment, percent sign or trailing dot), then the admin password: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the admin password on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "${2//\\/\\\\}" set -- "$1" "${2//\"/\\\"}" set -- "$1" "$2" 0 # the 3rd value is this part's status so far, ranked as above if head -c 1M /dev/zero > /tmp/under.bin && head -c 11M /dev/zero > /tmp/over.bin; then printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --config - --data-binary @/tmp/under.bin "https://$1/" # positive control: under the limit, expect the app's own normal response (a 2xx, or its own 404/redirect), never 413; a 401 means the credentials, not the size limit, were exercised and a 000 means transport failed, either of which voids the control case "$?" in 0) ;; *) echo "curl exit $?: the 1M request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac printf 'user = "admin:%s"\n' "$2" | curl -q -g -s -o /dev/null -w '%{http_code}\n' --config - --data-binary @/tmp/over.bin "https://$1/" # 413. Credentials matter: app-body runs after app-auth, so an # unauthenticated probe is counted by app-rate/app-inflight but stops # at 401 before app-body buffers it. A backend with its own limit # returns the same code, so attributing the refusal needs an isolated # environment with app-body removed case "$?" in 0) ;; *) echo "curl exit $?: the 11M request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac else echo "head could not create /tmp/under.bin and /tmp/over.bin, so the size checks did not run; inconclusive"; set -- "$1" "$2" 3 fi rm -f /tmp/under.bin /tmp/over.bin case "$?" in 0) ;; *) echo "rm exit $?: /tmp/under.bin and /tmp/over.bin could not be removed; inconclusive"; set -- "$1" "$2" 3 ;; esac exit "$3" ) case "$?:$1" in 0:*) ;; 2:0) set -- 2 ;; 2:*) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the same app host name as the checks above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -o pipefail # the pipeline's status is its last failing command's, so a failed request inside xargs is not hidden by uniq seq 1 40 | xargs -P 40 -I{} curl -q -g -s -o /dev/null -w '%{http_code}\n' "https://$1/" | sort | uniq -c # No credentials here: app-rate and app-inflight run BEFORE app-auth in the # middleware chain (section 4); Traefik v3.7.17 BasicAuth does not call its # next handler on failure, so an unauthenticated flood is counted by the # limiter, keyed on the client source. A 429 appeared. That is all this shows. # inflightreq also returns # 429, and an upstream under load can too, so this does not # establish that ratelimit fired. Attributing it needs an isolated # environment with both disabled as a baseline, then each enabled # alone, with the arrival rate actually measured case "$?" in 0) exit 0 ;; *) echo "flood pipeline exit $?: a request or a counting command failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 2:0) set -- 2 ;; 2:*) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above docker compose ps # only Traefik publishes ports; the app's 3000 must NOT be published case "$?" in 0) ;; *) echo "docker compose ps exit $?; inconclusive"; set -- 3 ;; esac if command -v ss >/dev/null; then # no other part needs ss, so a missing ss records 2 only when nothing else was recorded sudo ss -tlnp # read the whole listener table, do not grep it down to the port you # expect (that hides an unexpected listener): only Traefik should # answer on the host. A backend answering on 3000 directly bypasses # Traefik's TLS and its authentication middleware case "$?" in 0) ;; *) echo "sudo ss -tlnp exit $?; inconclusive"; set -- 3 ;; esac else echo "ss is not installed here; not listing sockets"; case "$1" in 0) set -- 2 ;; esac fi echo "block exit $1; no answer in this block is classified: judge the redirect and 401 headers, the 1M and 11M codes, the flood counts and both listings yourself against the comments above" exit "$1" ) ``` Check the Traefik log for ACME errors on first start; issuance failures otherwise surface as a self-signed "TRAEFIK DEFAULT CERT" in the browser. `docker compose ps` and `ss` help find a container that bypasses Traefik with its own `ports:`, though neither is exhaustive: `docker compose ps` lists only this Compose project, and a port published through NAT alone, with Docker's userland proxy disabled, opens no host listening socket for `ss` to show. Neither, in any case, sees a container Traefik routes without your asking: a routed container has no host port publication of its own. To confirm `exposedByDefault: false` actually excludes unlabelled containers, launch an unlabelled canary on Traefik's network and probe the front door. Do this on a disposable copy of the deployment; never make a container deliberately routable on the production host. These canary steps are reasoned from Traefik's documented routing behavior and the exposed-versus-fixed outcomes it predicts; they have not been demonstrated against a live Docker/Traefik deployment here. ```yaml services: canary: image: traefik/whoami # one HTTP port, echoes its own hostname; no traefik.* labels, no ports: ``` `traefik/whoami` listens on a single HTTP port, so Traefik's single-port detection can build a service for it and a routed reply is unmistakable (the body carries a `Hostname:` line). Read the router rule Traefik generated for the canary; its `Host(...)` value is the canary's normalized name, and you pass that value as the `Host:` header below. Probe plain HTTP against the `:443` listener: the auto-generated router never requested TLS, so it answers plain HTTP even there. Run these probe blocks with Bash and curl 7.75.0 or newer, which added the `exitcode` and `errormsg` write-out variables they print. ```bash ( # a subshell, so your own shell arguments are untouched; it exits 0 for 404, 1 for 200, 502 or 504, 3 otherwise set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_TRAEFIK_HOST' 'REPLACE_WITH_HTTPS_PORT' 'REPLACE_WITH_CANARY_HOST_HEADER' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then echo "substitute the values on the set -- line above; not probing"; exit 2; fi case "$1:$2:$3" in *REPLACE_WITH_*) echo "substitute the values on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -H "Host: $3" \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:$2/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then everything it printed printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "${5%$'\n'}" # the same, without the final line feed, so the last line is curl's http= line and the body cannot stand in for it case "$4:${5##*$'\n'}" in "0:http=404 exit="*) echo "pass: Traefik answered 404 for the canary, so it ignored the unlabelled container"; set -- 0 ;; "0:http=200 exit="*|"0:http=502 exit="*|"0:http=504 exit="*) echo "FINDING: Traefik routed the unlabelled canary (expected on the exposed calibration fixture; anywhere else an exposure to remove)"; set -- 1 ;; 0:*) echo "the canary's answer is neither the 404 pass nor a 200, 502 or 504 route; inconclusive"; set -- 3 ;; *) echo "curl exit $4: the canary request failed; inconclusive"; set -- 3 ;; esac echo "block exit $1; a 404 counts only after the exposed-fixture calibration returned 200 with the canary's own Hostname: body, which this block does not check, so judge that and the body yourself" exit "$1" ) ``` With `exposedByDefault: false` the canary returns `http=404` from Traefik's unmatched-route handler: it was ignored, and that is the pass. Calibrate first on the exposed fixture (temporarily `exposedByDefault: true`), where the canary must return `http=200` with a `Hostname:` body naming the canary itself, not some other service; that proves the probe reaches Traefik through the canary's own router. Without that positive calibration a `404` is not trustworthy: it can equally mean Traefik never discovered the canary or built no matching route. A canary Traefik did discover but cannot reach on its network answers `502` or `504`, not `404`. On production, run the probe only after applying the fix; a canary that answers `200` there is a live exposure to remove, not a passing test. A `404` for the canary also appears when the whole Docker provider is off, which breaks all routing. Confirm the provider still works by probing the app itself, published only over HTTPS: ```bash ( # a subshell, so your own shell arguments are untouched; it exits 0 when the app answers, 3 for 404 or a failed request set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_APP_HOST' 'REPLACE_WITH_HTTPS_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "substitute the values on the set -- line above; not probing"; exit 2; fi case "$1:$2" in *REPLACE_WITH_*) echo "substitute the values on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1:$2/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then everything it printed printf '%s' "$4" set -- "$1" "$2" "$3" "${4%$'\n'}" # the same, without the final line feed, so the last line is curl's http= line and the body cannot stand in for it case "$3:${4##*$'\n'}" in "0:http=404 exit="*) echo "the app answered 404, so the provider is not routing it; inconclusive, and a canary 404 means nothing until it does"; set -- 3 ;; 0:*) echo "the app answered, so the provider is routing it"; set -- 0 ;; *) echo "curl exit $3: the request failed, and a connection failure means the provider is not routing the app; inconclusive"; set -- 3 ;; esac echo "block exit $1; the status code is not classified, and a 401 may come from the app's own login rather than Traefik's basicAuth, so judge it and the router's middlewares yourself" exit "$1" ) ``` With the authentication from section 3 in place, this unauthenticated probe returns `http=401`: a response, rather than a connection failure or a `404`, means the provider is routing this app. The `401` may come from Traefik's basicAuth or from the app's own login, so it confirms routing, not specifically that the proxy middleware is attached; confirm that separately from the router's `middlewares` in the dashboard or config. (Feed the admin credential to that curl on stdin, as the section 5 Verify block does, to see the app's own authenticated response instead.) The fix is confirmed when, in the same run, the app answers `401` (or your known authenticated `200`) and the canary answers `404`: routing works and the unlabelled container is excluded. A `404` or a connection failure for the app instead means the provider is not routing it. No `-k` here; the app needs a real certificate. ## Common mistakes - Enabling the Traefik dashboard (`api.insecure=true` or an unprotected `api@internal` router) on a public entry point; keep it off or behind the auth middleware. - Forgetting to persist `acme.json`, which re-issues certificates on every restart and hits CA rate limits. - Single `$` in Compose basicauth labels, which breaks the hash silently. - Enabling the Docker provider but leaving `exposedByDefault` at its default of `true`, which exposes every eligible container it discovers to routing, not only the labelled one; a working HTTP route also needs a reachable HTTP backend on the detected port. - Assuming this page covers Docker Swarm. It covers standalone Docker; Swarm is a separate provider (`providers.swarm` in Traefik v3) with its own `exposedByDefault`, which also defaults to `true`. Consult the Swarm provider reference for your version before relying on this page under Swarm. ## Sources (checked September 2026) - Traefik documentation (rolling documentation, checked September 2026): https://doc.traefik.io/traefik/ (HTTPS/ACME, routers, and basicAuth middleware sections) - Docker provider (`exposedByDefault`, socket endpoint): https://doc.traefik.io/traefik/reference/install-configuration/providers/docker/ - Swarm provider (`providers.swarm.exposedByDefault`): https://doc.traefik.io/traefik/reference/install-configuration/providers/swarm/ - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0): https://curl.se/docs/manpage.html - Buffering middleware (`maxRequestBodyBytes`, `memRequestBodyBytes`): https://doc.traefik.io/traefik/middlewares/http/buffering/ - InFlightReq middleware (`amount`): https://doc.traefik.io/traefik/middlewares/http/inflightreq/ - RateLimit middleware (`average`, `burst`, `period`): https://doc.traefik.io/traefik/middlewares/http/ratelimit/ - Traefik v3.7 entry-point HTTPS redirection (checked November 2026): https://doc.traefik.io/traefik/v3.7/reference/install-configuration/entrypoints/ - Traefik v3.7 ACME renewal, challenges and certificate storage (checked November 2026): https://doc.traefik.io/traefik/v3.7/reference/install-configuration/tls/certificate-resolvers/acme/ - Traefik v3.7.17 ACME storage file creation with mode 600 (checked November 2026): https://github.com/traefik/traefik/blob/v3.7.17/pkg/provider/acme/local_store.go#L172 - Traefik v3.7 minimum TLS version (checked November 2026): https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/tls/tls-options/ - Traefik v3.7 file-provider configuration (checked November 2026): https://doc.traefik.io/traefik/v3.7/reference/install-configuration/providers/others/file/ - Traefik v3.7 BasicAuth and Compose dollar-sign escaping (checked November 2026): https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/middlewares/basicauth/ - Traefik v3.7 ForwardAuth delegation (checked November 2026): https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/middlewares/forwardauth/ - Traefik v3.7.17 BasicAuth failure and next-handler path (checked November 2026): https://github.com/traefik/traefik/blob/v3.7.17/pkg/middlewares/auth/basic_auth.go#L82-L119 - Traefik v3.7.17 BasicAuth refusal of an empty users list and choice of a configured hash for unknown users (checked November 2026): https://github.com/traefik/traefik/blob/v3.7.17/pkg/middlewares/auth/basic_auth.go#L48-L55 - Traefik v3.7.17 BasicAuth unknown-user hash check and user-and-password singleflight key (checked November 2026): https://github.com/traefik/traefik/blob/v3.7.17/pkg/middlewares/auth/basic_auth.go#L122-L143 - Traefik security advisory GHSA-6765-c87h-8mrf (severity low; affects v3.6.11 to v3.6.24 and v3.7.0 to v3.7.9, fixed in v3.6.25 and v3.7.10), cited in the comment above `singleflightKey` in the v3.7.17 BasicAuth source (checked November 2026): https://github.com/traefik/traefik/security/advisories/GHSA-6765-c87h-8mrf - Apache httpd 2.4.69 htpasswd bcrypt default cost (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45 - Apache httpd 2.4.69 htpasswd default cost applied (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L292-L295 - Apache httpd 2.4.69 htpasswd `-C` cost option (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L382 - Apache httpd 2.4.69 htpasswd usage (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128 - OWASP Password Storage Cheat Sheet at 72865b1e3b5754246a101778dc00b37f9c233373, legacy bcrypt work factor (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13 - Cloudflare Access MFA documentation at c896795b8e8bab68d05ae78d64c9cbcda1fdb104, application or policy MFA (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81 - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - traefik-v3: Traefik v3 documentation (v3.7) - traefik-source: Traefik ACME storage source (v3.7.17) - traefik-basicauth-source: Traefik BasicAuth source (v3.7.17) - traefik-docs: Traefik documentation (rolling) (unknown) - docker: Traefik Docker provider (unknown) - swarm: Traefik Swarm provider (unknown) - middleware: Traefik middleware documentation (unknown) - httpd-source: Apache httpd htpasswd source (2.4.69) - cloudflare-source: Cloudflare Access MFA documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - curl: curl write-out minimum (7.75.0) - owasp-source: OWASP Password Storage Cheat Sheet (72865b1e3b5754246a101778dc00b37f9c233373) Claims (full source mapping in the JSON block below): - entrypoints (REASONED; traefik-docs, traefik-v3): For Traefik v2/v3, web :80 redirects to HTTPS websecure :443 and an ACME resolver obtains/renews certificates. - acme-storage (REASONED; traefik-docs, traefik-v3, traefik-source): Persist acme.json across restarts with mode 600 to retain certificates and avoid repeated issuance/rate limits. - acme-challenges (REASONED; traefik-docs, traefik-v3): TLS-ALPN requires inbound 443; HTTP-01 uses 80, while DNS supports wildcards without inbound ports. - docker-default (REASONED; docker): Docker discovery defaults exposedByDefault to true; set false and opt each intended container in with traefik.enable=true. - docker-scope (REASONED; docker): Discovery is daemon-wide, not Compose-project scoped; eligible unlabelled containers get routers unless excluded. Missing usable ports skip service creation. - docker-socket (REASONED; docker): The Docker socket gives daemon control despite :ro; use a restricted socket proxy to limit API access. - tls-floor (REASONED; traefik-docs, traefik-v3): Dynamic default TLS options set minVersion VersionTLS12. - route (REASONED; traefik-docs, docker): Docker labels select the app host, websecure, letsencrypt resolver and backend port 3000. - backend-isolation (REASONED; docker): Publish only Traefik 80/443, not the app's 3000, to prevent middleware/TLS bypass. - basic (REASONED; traefik-docs, traefik-v3, httpd-source, owasp-source): Attach bcrypt basicAuth to the router; Apache httpd 2.4.69 htpasswd -nB defaults to bcrypt cost 5, while -C 12 selects cost 12; OWASP at 72865b1e recommends 10 or more for legacy bcrypt. - compose-hash (REASONED; traefik-docs, traefik-v3): Double each dollar sign in Compose hash labels; file-provider hashes need no such escaping. - file-provider (REASONED; traefik-docs, traefik-v3): Load dynamic files with providers.file.directory/filename and reference their middleware from Docker as app-auth@file. - mfa (REASONED; traefik-docs, traefik-v3, cloudflare-source): Basic is single-factor; use forwardAuth with Authelia/oauth2-proxy or front the site with Cloudflare Access and require MFA in its governing Access setting (policy over application over organization). - basicauth-advisory (REASONED; traefik-basicauth-source): GHSA-6765-c87h-8mrf (low): in Traefik v3.6.11-v3.6.24 and v3.7.0-v3.7.9 a colliding BasicAuth singleflight key could authenticate a request as another user; v3.6.25 and v3.7.10 fix it by keying on user plus password. v3.7.17 also refuses a middleware with no users and checks unknown users against a configured hash. Run v3.7.10+ (v3.6.25+ on 3.6); v3.5.0/v3.7.17 are outside the affected range. - body-limit (REASONED; middleware): Attach buffering.maxRequestBodyBytes=10485760 alongside existing auth/routing labels; it bounds accepted body size. - buffer-storage (REASONED; middleware): memRequestBodyBytes defaults to 1048576 and controls the memory-to-disk threshold independently of maximum accepted body size. - rate-limit (REASONED; middleware): average=10 and burst=20 configure a rate cap, per source by default. - inflight (REASONED; middleware): inflightreq.amount=10 bounds concurrent requests; sourceCriterion defaults to request host rather than client, so configure a per-client cap explicitly. - middleware-order (REASONED; traefik-docs, middleware, traefik-basicauth-source): Rate/inflight precede auth to count rejected logins: Traefik v3.7.17 BasicAuth returns on failure without calling the next handler; buffering follows admission controls to avoid buffering rejected uploads. - dashboard (REASONED; traefik-docs): Keep api.insecure and unprotected api@internal routers off public entry points or protect the dashboard with auth. - swarm (REASONED; swarm): Standalone Docker guidance does not cover Swarm; v3 uses providers.swarm and its separate exposedByDefault also defaults true. - verify-redirect (REASONED; traefik-docs): HTTP should redirect to HTTPS. - verify-auth (REASONED; traefik-docs): Unauthenticated HTTPS should return 401 once auth is attached. - verify-size (REASONED; middleware, traefik-docs): Authenticated 1M must reach the app and 11M return 413; 401/000 invalidate the control. Remove app-body in isolation to rule out backend refusal. - observed-429 (DEMONSTRATED; middleware): The guide records only a 429 response; no limiter attribution or exposed/fixed demonstration is established; recorded on an earlier form of the flood probe, before it took a guarded host and ran inside the first block's single subshell. - verify-rate (REASONED; middleware, traefik-basicauth-source): Concurrent unauthenticated probes can reach pre-auth limiters; 429 alone cannot distinguish rate, inflight or upstream refusal. Compare each limiter alone against both disabled. - verify-isolation (REASONED; docker): Compose ps covers one project; ss misses NAT-only publication without a userland proxy. Neither finds unintended routes through Traefik. - verify-acme (REASONED; traefik-docs): Inspect ACME startup errors; failed issuance can leave a self-signed TRAEFIK DEFAULT CERT. - canary-route (REASONED; docker, traefik-docs): On a disposable deployment, unlabelled traefik/whoami has one HTTP port; use its generated Host rule and plain HTTP even on :443 because the generated router requests no TLS. - verify-canary (REASONED; docker, curl): Calibrate exposedByDefault true with 200 and the canary Hostname body; false should give 404. Missing discovery/provider also gives 404, unreachable backend 502/504. - verify-app-control (REASONED; docker, traefik-docs, curl): In the same run, the app must return 401 or known authenticated 200 while the canary gives 404; app 401 proves routing, not proxy-auth attachment. Keep TLS verification. ````json { "schema": 1, "checked": "2026-11-08", "documentation_checked": "2026-09", "body_sha256": "89a96f8706a6e33822fac81892bf0592e94745a585b0283d0f47ed0f5cb7c781", "components": { "traefik-v3": { "name": "Traefik v3 documentation", "basis": "v3.7", "sources": { "s0b6ac3975f40": "https://doc.traefik.io/traefik/v3.7/reference/install-configuration/entrypoints/", "se6cd04436adc": "https://doc.traefik.io/traefik/v3.7/reference/install-configuration/tls/certificate-resolvers/acme/", "s53640550c66d": "https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/tls/tls-options/", "s67a26fc82498": "https://doc.traefik.io/traefik/v3.7/reference/install-configuration/providers/others/file/", "s8bce7ead3813": "https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/middlewares/basicauth/", "s8a5b3bd688d0": "https://doc.traefik.io/traefik/v3.7/reference/routing-configuration/http/middlewares/forwardauth/" } }, "traefik-source": { "name": "Traefik ACME storage source", "basis": "v3.7.17", "sources": { "s6ac13e6baddc": "https://github.com/traefik/traefik/blob/v3.7.17/pkg/provider/acme/local_store.go#L172" } }, "traefik-basicauth-source": { "name": "Traefik BasicAuth source", "basis": "v3.7.17", "sources": { "s47f86b3ccaea": "https://github.com/traefik/traefik/blob/v3.7.17/pkg/middlewares/auth/basic_auth.go#L82-L119", "sa3b8ca37a702": "https://github.com/traefik/traefik/blob/v3.7.17/pkg/middlewares/auth/basic_auth.go#L48-L55", "sd275dbbf08d8": "https://github.com/traefik/traefik/blob/v3.7.17/pkg/middlewares/auth/basic_auth.go#L122-L143", "s08f357bbcbd2": "https://github.com/traefik/traefik/security/advisories/GHSA-6765-c87h-8mrf" } }, "traefik-docs": { "name": "Traefik documentation (rolling)", "basis": "unknown", "sources": { "scbc0f2135c78": "https://doc.traefik.io/traefik/" } }, "docker": { "name": "Traefik Docker provider", "basis": "unknown", "sources": { "se23fe4fe79fa": "https://doc.traefik.io/traefik/reference/install-configuration/providers/docker/" } }, "swarm": { "name": "Traefik Swarm provider", "basis": "unknown", "sources": { "s256d46f3f558": "https://doc.traefik.io/traefik/reference/install-configuration/providers/swarm/" } }, "middleware": { "name": "Traefik middleware documentation", "basis": "unknown", "sources": { "sd987195d64dd": "https://doc.traefik.io/traefik/middlewares/http/buffering/", "sd20dcba7ebca": "https://doc.traefik.io/traefik/middlewares/http/inflightreq/", "scf1b1e35ef5d": "https://doc.traefik.io/traefik/middlewares/http/ratelimit/" } }, "httpd-source": { "name": "Apache httpd htpasswd source", "basis": "2.4.69", "sources": { "sf711558bdf3c": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45", "s4aa0f7f341fb": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L292-L295", "s4482c55d79a2": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L382", "s309ecdd34379": "https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L128" } }, "cloudflare-source": { "name": "Cloudflare Access MFA documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sdcac732541da": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81" } }, "curl": { "name": "curl write-out minimum", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "owasp-source": { "name": "OWASP Password Storage Cheat Sheet", "basis": "72865b1e3b5754246a101778dc00b37f9c233373", "sources": { "s4531e65b6502": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13" } } }, "claims": { "entrypoints": {"text": "For Traefik v2/v3, web :80 redirects to HTTPS websecure :443 and an ACME resolver obtains/renews certificates.", "components": ["traefik-docs", "traefik-v3"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:s0b6ac3975f40", "traefik-v3:se6cd04436adc"], "status": "REASONED"}, "acme-storage": {"text": "Persist acme.json across restarts with mode 600 to retain certificates and avoid repeated issuance/rate limits.", "components": ["traefik-docs", "traefik-v3", "traefik-source"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:se6cd04436adc", "traefik-source:s6ac13e6baddc"], "status": "REASONED"}, "acme-challenges": {"text": "TLS-ALPN requires inbound 443; HTTP-01 uses 80, while DNS supports wildcards without inbound ports.", "components": ["traefik-docs", "traefik-v3"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:se6cd04436adc"], "status": "REASONED"}, "docker-default": {"text": "Docker discovery defaults exposedByDefault to true; set false and opt each intended container in with traefik.enable=true.", "components": ["docker"], "sources": ["docker:se23fe4fe79fa"], "status": "REASONED"}, "docker-scope": {"text": "Discovery is daemon-wide, not Compose-project scoped; eligible unlabelled containers get routers unless excluded. Missing usable ports skip service creation.", "components": ["docker"], "sources": ["docker:se23fe4fe79fa"], "status": "REASONED"}, "docker-socket": {"text": "The Docker socket gives daemon control despite :ro; use a restricted socket proxy to limit API access.", "components": ["docker"], "sources": ["docker:se23fe4fe79fa"], "status": "REASONED"}, "tls-floor": {"text": "Dynamic default TLS options set minVersion VersionTLS12.", "components": ["traefik-docs", "traefik-v3"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:s53640550c66d"], "status": "REASONED"}, "route": {"text": "Docker labels select the app host, websecure, letsencrypt resolver and backend port 3000.", "components": ["traefik-docs", "docker"], "sources": ["traefik-docs:scbc0f2135c78", "docker:se23fe4fe79fa"], "status": "REASONED"}, "backend-isolation": {"text": "Publish only Traefik 80/443, not the app's 3000, to prevent middleware/TLS bypass.", "components": ["docker"], "sources": ["docker:se23fe4fe79fa"], "status": "REASONED"}, "basic": {"text": "Attach bcrypt basicAuth to the router; Apache httpd 2.4.69 htpasswd -nB defaults to bcrypt cost 5, while -C 12 selects cost 12; OWASP at 72865b1e recommends 10 or more for legacy bcrypt.", "components": ["traefik-docs", "traefik-v3", "httpd-source", "owasp-source"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:s8bce7ead3813", "httpd-source:sf711558bdf3c", "httpd-source:s4aa0f7f341fb", "httpd-source:s4482c55d79a2", "httpd-source:s309ecdd34379", "owasp-source:s4531e65b6502"], "status": "REASONED"}, "compose-hash": {"text": "Double each dollar sign in Compose hash labels; file-provider hashes need no such escaping.", "components": ["traefik-docs", "traefik-v3"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:s8bce7ead3813"], "status": "REASONED"}, "file-provider": {"text": "Load dynamic files with providers.file.directory/filename and reference their middleware from Docker as app-auth@file.", "components": ["traefik-docs", "traefik-v3"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:s67a26fc82498"], "status": "REASONED"}, "mfa": {"text": "Basic is single-factor; use forwardAuth with Authelia/oauth2-proxy or front the site with Cloudflare Access and require MFA in its governing Access setting (policy over application over organization).", "components": ["traefik-docs", "traefik-v3", "cloudflare-source"], "sources": ["traefik-docs:scbc0f2135c78", "traefik-v3:s8a5b3bd688d0", "cloudflare-source:sdcac732541da"], "status": "REASONED"}, "basicauth-advisory": {"text": "GHSA-6765-c87h-8mrf (low): in Traefik v3.6.11-v3.6.24 and v3.7.0-v3.7.9 a colliding BasicAuth singleflight key could authenticate a request as another user; v3.6.25 and v3.7.10 fix it by keying on user plus password. v3.7.17 also refuses a middleware with no users and checks unknown users against a configured hash. Run v3.7.10+ (v3.6.25+ on 3.6); v3.5.0/v3.7.17 are outside the affected range.", "components": ["traefik-basicauth-source"], "sources": ["traefik-basicauth-source:sd275dbbf08d8", "traefik-basicauth-source:sa3b8ca37a702", "traefik-basicauth-source:s08f357bbcbd2"], "status": "REASONED"}, "body-limit": {"text": "Attach buffering.maxRequestBodyBytes=10485760 alongside existing auth/routing labels; it bounds accepted body size.", "components": ["middleware"], "sources": ["middleware:sd987195d64dd"], "status": "REASONED"}, "buffer-storage": {"text": "memRequestBodyBytes defaults to 1048576 and controls the memory-to-disk threshold independently of maximum accepted body size.", "components": ["middleware"], "sources": ["middleware:sd987195d64dd"], "status": "REASONED"}, "rate-limit": {"text": "average=10 and burst=20 configure a rate cap, per source by default.", "components": ["middleware"], "sources": ["middleware:scf1b1e35ef5d"], "status": "REASONED"}, "inflight": {"text": "inflightreq.amount=10 bounds concurrent requests; sourceCriterion defaults to request host rather than client, so configure a per-client cap explicitly.", "components": ["middleware"], "sources": ["middleware:sd20dcba7ebca"], "status": "REASONED"}, "middleware-order": {"text": "Rate/inflight precede auth to count rejected logins: Traefik v3.7.17 BasicAuth returns on failure without calling the next handler; buffering follows admission controls to avoid buffering rejected uploads.", "components": ["traefik-docs", "middleware", "traefik-basicauth-source"], "sources": ["traefik-docs:scbc0f2135c78", "middleware:sd987195d64dd", "middleware:scf1b1e35ef5d", "middleware:sd20dcba7ebca", "traefik-basicauth-source:s47f86b3ccaea"], "status": "REASONED"}, "dashboard": {"text": "Keep api.insecure and unprotected api@internal routers off public entry points or protect the dashboard with auth.", "components": ["traefik-docs"], "sources": ["traefik-docs:scbc0f2135c78"], "status": "REASONED"}, "swarm": {"text": "Standalone Docker guidance does not cover Swarm; v3 uses providers.swarm and its separate exposedByDefault also defaults true.", "components": ["swarm"], "sources": ["swarm:s256d46f3f558"], "status": "REASONED"}, "verify-redirect": {"text": "HTTP should redirect to HTTPS.", "components": ["traefik-docs"], "sources": ["traefik-docs:scbc0f2135c78"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "Unauthenticated HTTPS should return 401 once auth is attached.", "components": ["traefik-docs"], "sources": ["traefik-docs:scbc0f2135c78"], "status": "REASONED", "verify": [1]}, "verify-size": {"text": "Authenticated 1M must reach the app and 11M return 413; 401/000 invalidate the control. Remove app-body in isolation to rule out backend refusal.", "components": ["middleware", "traefik-docs"], "sources": ["middleware:sd987195d64dd", "traefik-docs:scbc0f2135c78"], "status": "REASONED", "verify": [1]}, "observed-429": {"text": "The guide records only a 429 response; no limiter attribution or exposed/fixed demonstration is established; recorded on an earlier form of the flood probe, before it took a guarded host and ran inside the first block's single subshell.", "components": ["middleware"], "sources": ["middleware:scf1b1e35ef5d", "middleware:sd20dcba7ebca"], "status": "DEMONSTRATED", "evidence": "A 429 appeared. That is all this shows."}, "verify-rate": {"text": "Concurrent unauthenticated probes can reach pre-auth limiters; 429 alone cannot distinguish rate, inflight or upstream refusal. Compare each limiter alone against both disabled.", "components": ["middleware", "traefik-basicauth-source"], "sources": ["middleware:scf1b1e35ef5d", "middleware:sd20dcba7ebca", "traefik-basicauth-source:s47f86b3ccaea"], "status": "REASONED", "verify": [1]}, "verify-isolation": {"text": "Compose ps covers one project; ss misses NAT-only publication without a userland proxy. Neither finds unintended routes through Traefik.", "components": ["docker"], "sources": ["docker:se23fe4fe79fa"], "status": "REASONED", "verify": [1]}, "verify-acme": {"text": "Inspect ACME startup errors; failed issuance can leave a self-signed TRAEFIK DEFAULT CERT.", "components": ["traefik-docs"], "sources": ["traefik-docs:scbc0f2135c78"], "status": "REASONED", "verify": [1]}, "canary-route": {"text": "On a disposable deployment, unlabelled traefik/whoami has one HTTP port; use its generated Host rule and plain HTTP even on :443 because the generated router requests no TLS.", "components": ["docker", "traefik-docs"], "sources": ["docker:se23fe4fe79fa", "traefik-docs:scbc0f2135c78"], "status": "REASONED", "verify": [2]}, "verify-canary": {"text": "Calibrate exposedByDefault true with 200 and the canary Hostname body; false should give 404. Missing discovery/provider also gives 404, unreachable backend 502/504.", "components": ["docker", "curl"], "sources": ["docker:se23fe4fe79fa", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2]}, "verify-app-control": {"text": "In the same run, the app must return 401 or known authenticated 200 while the canary gives 404; app 401 proves routing, not proxy-auth attachment. Keep TLS verification.", "components": ["docker", "traefik-docs", "curl"], "sources": ["docker:se23fe4fe79fa", "traefik-docs:scbc0f2135c78", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> nodejs.md ====================================================================== # Node.js and Express: TLS and authentication Node.js runs JavaScript on servers, and Express is a Node.js framework for web apps and APIs. This guide helps your AI assistant deploy them securely, including loopback behind a TLS proxy, a managed platform's required listener or HTTPS in Node, plus Express proxy trust, authentication and verified outbound TLS. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/nodejs.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Node.js API documentation v26.13.0, express-rate-limit minimum v8, otplib v12.0.1, qrcode v1.5.4, node.bcrypt.js v6.0.0, node-argon2 v0.45.1, Linux man-pages ip(7) 6.19, and Linux man-pages capabilities(7) 6.19, plus 1 more recorded basis; 3 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 22 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. This guide's preferred production layout: bind the Node app to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) or behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). For managed HTTP ingress, this guide advises using the deployment's required listener rather than `127.0.0.1`; TLS termination depends on the platform and configuration, as detailed in section 1 ([paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). Node can also terminate TLS itself, shown below. Get a certificate per [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). ## 1. HTTPS directly in Node ```js const https = require('node:https'); const fs = require('node:fs'); const express = require('express'); const app = express(); const options = { key: fs.readFileSync('/etc/ssl/private/server.key'), cert: fs.readFileSync('/etc/ssl/certs/server.crt'), // certificate plus chain }; https.createServer(options, app).listen(443); // Port 80 exists only to redirect. Redirect to a FIXED canonical host; never reflect // req.headers.host, which the client controls (a forged Host, or a cache in front of :80, // would turn this into an open redirect). Set PUBLIC_HOST to your canonical hostname (no // scheme or path); the app refuses to start without it rather than emit https://undefined/. const CANONICAL_HOST = process.env.PUBLIC_HOST; if (!CANONICAL_HOST) throw new Error('PUBLIC_HOST must be set to the canonical hostname'); require('node:http').createServer((req, res) => { // req.url is the raw request target; only an origin-form path is safe to append, so fall // back to '/' for absolute-form or asterisk-form (OPTIONS *) targets. const path = req.url.startsWith('/') ? req.url : '/'; res.writeHead(301, { Location: `https://${CANONICAL_HOST}${path}` }); res.end(); }).listen(80); ``` On Linux, binding ports below 1024 requires a process, root or not, that has `CAP_NET_BIND_SERVICE` in the user namespace governing its network namespace ([ip(7)](https://man7.org/linux/man-pages/man7/ip.7.html), [capabilities(7)](https://man7.org/linux/man-pages/man7/capabilities.7.html); stated by ip(7) for IPv4; IPv6 is not covered by the listed Sources); this guide advises against running the app as root and prefers the proxy layout. The `listen(443)`/`listen(80)` above bind every interface, which is intended only for this Node-terminates-TLS layout. This guide's advice for managed platforms: do not add the `listen(443)`/`listen(80)` listeners above; bind only the listener required by your deployment. The documented HTTP ingress mappings are: - Render web services must bind `0.0.0.0`. Render recommends the `PORT` environment variable (at the time of writing, default port 10000, configurable), can usually detect another bound port, and requires the public HTTP server to use `PORT` when multiple ports are bound ([web services](https://render.com/docs/web-services)). - Heroku assigns each web process its port through `PORT` ([runtime principles](https://devcenter.heroku.com/articles/runtime-principles)), and its Node.js deployment page says the web process must bind to that port or the dyno does not start ([deploying Node.js](https://devcenter.heroku.com/articles/deploying-nodejs)). Fir Private Spaces networking uses IPv6 by default; Heroku warns that IPv4-only assumptions do not bind properly and recommends an IPv6 or dual-stack listener, typically `::` ([generations](https://devcenter.heroku.com/articles/generations)). Heroku's HTTP routing page lists IPv6 as unsupported in the Cedar generation without naming a listener address ([HTTP routing](https://devcenter.heroku.com/articles/http-routing)). This guide's assessment: none of the Heroku pages checked (runtime principles, HTTP routing, generations, dynos, Procfile, Gunicorn deployment, Node.js deployment, the Python and Node.js getting-started tutorials and Node.js best practices, all listed in Sources) states a listener address for Cedar. This guide's assessment: apart from the Fir recommendation above, the addresses those Heroku pages show are local-development URLs and sample log output. That absence of a stated Cedar listener address is not evidence that Heroku accepts any listener address. - Fly Proxy HTTP services must be reachable over IPv4: use `0.0.0.0` and match the configured `internal_port` to the app listener ([service binding](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42)). IPv4-mapped IPv6 wildcard behaviour depends on the VM and runtime ([wildcard qualification](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92)). - Railway domains route to a detected or selected internal target port ([target ports](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146)). Railway says web servers should bind `0.0.0.0` and listen on the automatically injected `PORT` ([listener and port](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51)). Its edge proxy (tcp-proxy) terminates TLS; traffic is then forwarded over Railway's internal network to the deployment ([request flow](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43)). Render's load balancer terminates inbound HTTPS and forwards HTTP to the web service ([web services](https://render.com/docs/web-services)). Heroku's HTTP routing page describes a load balancer offering SSL termination and routers forwarding HTTP to web dynos; it notes that both Private Spaces generations have different routers, so this is not a complete account of Private Spaces routing ([HTTP routing](https://devcenter.heroku.com/articles/http-routing)). Fly Proxy's `tls` handler terminates TLS and forwards plaintext ([TLS handler](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176)); without handlers it passes TCP through, allowing the app to terminate TLS itself ([TCP passthrough](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97)). This guide's advice: plain HTTP is acceptable only behind protected ingress that terminates TLS; application authentication is still required ([paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). ## 2. Behind a proxy: tell Express about it ```js // The app binds to 127.0.0.1 behind a same-host reverse proxy (see caddy.md / nginx.md), // so trust only loopback addresses. Never use a blanket `true`. app.set('trust proxy', 'loopback'); // Remote proxy instead? Trust its exact address or subnet: // app.set('trust proxy', '10.0.0.5'); // single proxy IP // app.set('trust proxy', '10.0.0.0/24'); // proxy subnet ``` `trust proxy` controls how Express derives `req.ip`, `req.ips`, `req.hostname`/`req.host` (from `X-Forwarded-Host`), and `req.protocol`/`req.secure` (from `X-Forwarded-Proto`), so a forged header can fake the client IP (defeating rate limits and logging), the hostname, or the HTTPS status. Never set it to `true`: that trusts the leftmost `X-Forwarded-For` entry, which the client controls, so clients can forge all of these values unless the last trusted proxy strips or overwrites them. A hop count such as `1` is safe only if every path to the app crosses exactly that many proxies; if a shorter path exists, a client sitting fewer hops away can forge the same headers. Whichever value you use, configure the proxy itself to overwrite inbound `X-Forwarded-*` headers rather than pass them through. Security headers, including Strict-Transport-Security, via helmet: ```js const helmet = require('helmet'); app.use(helmet()); ``` ## 3. Authentication Follow [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). The pieces most Node projects need: Password hashing: this guide recommends `argon2id` (the `argon2` package) for new applications and keeping `bcrypt` for existing bcrypt systems only where Argon2 and scrypt are unavailable, per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). OWASP recommends Argon2id and limits bcrypt to that legacy scope, with a work factor of at least 10 and a 72-byte password limit (see the pinned Password Storage passages in Sources). The `argon2` package at v0.45.1 supports Argon2id and uses it by default. The example uses the documented `bcrypt` v6.0.0 CommonJS import, `hash(password, cost)` and `compare(password, hash)` APIs. Without a callback, these asynchronous methods return Promises when Promise support is available and support `await`; `compare` resolves to whether the password matches the hash. A numeric second argument to `hash` generates a salt using that cost; the documented work is `2^cost` hashing iterations. Cost 12 is this guide's example choice, not an OWASP mandate or a measured optimum for your server: ```js const bcrypt = require('bcrypt'); const hash = await bcrypt.hash(password, 12); const ok = await bcrypt.compare(password, hash); ``` Sessions with hardened cookies (express-session): ```js const session = require('express-session'); app.use(session({ secret: process.env.SESSION_SECRET, // long random value from the environment resave: false, saveUninitialized: false, // store: a production session store (see below); the default MemoryStore is for development only cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 }, // milliseconds })); ``` Express says the default `MemoryStore` is not designed for production (it leaks memory and does not scale past one process): pass `store:` a production store such as [connect-redis](https://www.npmjs.com/package/connect-redis) or [connect-pg-simple](https://www.npmjs.com/package/connect-pg-simple), and set `cookie.maxAge` so sessions expire, since no maximum age is set by default. Rate-limit the login route with a current express-rate-limit (v8 or newer masks each IPv6 client to a `/56` subnet by default, closing a v7 bypass where a client rotated addresses within its own IPv6 allocation). An IP limit alone does not stop credential-stuffing from many addresses, so pair it with per-account controls ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)): ```js const rateLimit = require('express-rate-limit'); app.use('/login', rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 })); ``` This guide advises: load API keys and tokens from `process.env`, never from literals in the source. Generate them per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Where you check them yourself, compare equal-byte-length byte inputs with `crypto.timingSafeEqual`; it accepts `ArrayBuffer`, `Buffer`, `TypedArray` or `DataView` inputs, throws on different byte lengths, and does not make surrounding code timing-safe. The use of `process.env` for secrets and the ban on source literals are this guide's advice; Node.js documents `process.env` as the user environment and the comparison API's input requirements. MFA: add TOTP with [otplib](https://github.com/yeojz/otplib) plus the [qrcode](https://www.npmjs.com/package/qrcode) package for enrolment QR codes, or front the app with Cloudflare Access and require independent MFA in its effective policy; requirements and options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Cloudflare Access independent MFA prompts for an additional authentication method before granting application access, without relying on the IdP's MFA configuration. Its MFA settings have precedence **policy > application > organization**; application and policy settings can turn MFA off (see the pinned independent-MFA and MFA-requirements passages in Sources). This guide requires checking that the effective policy requires MFA and testing that access without the second factor is denied. This guide also requires that the origin be unreachable around Access. Cloudflare describes Access as an authentication layer between users and the origin, warns that publishing a tunnel route without an Access application exposes it to anyone, and requires other origin-IP protection for an already publicly routable application (see the pinned self-hosted application passages in Sources). These pages do not establish a deployed MFA policy or this application's origin isolation; those remain deployment test requirements, not observed results. ## 4. Client-side TLS discipline - This guide advises: never set `NODE_TLS_REJECT_UNAUTHORIZED=0`, which disables certificate validation for TLS connections in the Node.js process, and never pass `rejectUnauthorized: false`, which disables server-certificate verification for that client connection. Never configure an `https.Agent` with `rejectUnauthorized: false`: requests using that agent inherit its TLS option. The agent reach is this guide's assessment of Node.js's documented request, agent and TLS options. - For an internal CA or self-signed server, this guide advises instead: set `NODE_EXTRA_CA_CERTS=/path/ca.crt` before starting Node.js, pointing at a PEM file of trusted certificates (see [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)). Node.js adds these to its well-known root CAs at launch; changing the variable at runtime has no effect on the current process. An explicit `ca` option prevents use of both the well-known and extra CAs for that TLS or HTTPS client or server, and Node.js ignores the variable when run as setuid root or with Linux file capabilities. ## 5. Verify (REASONED: TLS, headers, authentication, listener and proxy-header expectations follow the Sources below and linked deployment guidance; no deployment run or outcome is recorded in this guide. This metadata-only review has no deployed Node application, proxy or managed ingress to test. The block needs bash: in a shell without bash's shopt, such as dash, it refuses with status 2 before probing. At the end it exits 2 when ss is not installed only if no other part found something or was inconclusive, because a finding and an inconclusive result both outrank that refusal.) ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_PROTECTED_PATH' 'REPLACE_WITH_SESSION_COOKIE_FILE' # the deployment's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then a REAL protected route's path without its leading slash, then the path of a file holding a valid session cookie: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|/*|*[[:space:][:cntrl:]]*) echo "substitute a protected path, without its leading slash, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|-|*REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute the path of a file holding a valid session cookie inside the quotes on the set -- line above; not probing"; exit 2 ;; *'='*) echo "cookie file path must not contain =; not probing"; exit 2 ;; esac [ -f "$3" ] || { echo "the session cookie file is unreadable; not probing"; exit 2; } [ -r "$3" ] || { echo "the session cookie file is unreadable; not probing"; exit 2; } set +e # errexit off here, so a check that ends nonzero is recorded below instead of ending the block set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused to run), which outranks 0 set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sI --noproxy '*' -w '\n%{http_code} %{redirect_url}' "http://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then the headers, the HTTP code and the redirect URL set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" # curl's exit status, the headers, then the HTTP code and the redirect URL printf '%s' "$6" # expect a redirect to an https:// Location: 301 from section 1's code, while a fronting proxy or platform may use another 3xx case "$5:$7" in 0:3??" https://"*) ;; 0:*) echo "HTTP ${7%% *} with redirect URL '${7#* }' is not the expected redirect to an https:// Location; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the redirect request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac curl -q -g -sI --noproxy '*' "https://$1/" # succeeds without -k; shows helmet's headers case "$?" in 0) ;; *) echo "curl exit $?: the HTTPS request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # Auth: probe a REAL protected route (not a placeholder like /api, which often 404s), show the # status, and disable client proxies so a denial is attributable. A denial from ANY layer (the app, # or a proxy/Access in front) is the pass; the positive control proves auth actually gates rather # than the route being broken for everyone. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code} %{redirect_url}\n' "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code and the redirect URL printf '%s\n' "${6%% *}" # expect 401 or 403 with no credentials case "$5:$6" in "0:401 "*|"0:403 "*) ;; 0:2??" "*) echo "FINDING: the protected path answered ${6%% *} with no credentials"; set -- "$1" "$2" "$3" 1 ;; 0:3??" "?*) echo "unclassified observation: with no credentials the protected path answered ${6%% *} with a redirect to '${6#* }'; a redirect to the login page of a gate in front of the app is that gate's denial and the pass, and a redirect anywhere else is not, so judge it yourself" ;; 0:*) echo "HTTP ${6%% *} with no credentials is neither the 401 or 403 pass nor a 2xx finding; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the request with no credentials failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' -b "$3" "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # expect 200 with a valid session (cookie read from a file, not argv; # for a bearer-token route name a header file on the set -- line and use -H @"$3" instead of -b) case "$5:$6" in 0:200) ;; 0:*) echo "HTTP $6 with the session cookie is not the expected 200, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the request with the session cookie failed, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac if command -v ss >/dev/null; then ss -tlnp # read every listener; a same-host proxy layout binds 127.0.0.1 only, on managed # ingress the platform's required address and port from section 1 case "$?" in 0) ;; *) echo "ss -tlnp failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac else echo "ss is not installed here; not listing sockets" case "$4" in 0) set -- "$1" "$2" "$3" 2 ;; esac fi # trust proxy: add a temporary route that echoes req.ip, then remove it after this check # app.get('/whoami', (req, res) => res.send(req.ip)) set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' -H 'X-Forwarded-For: 203.0.113.9' "https://$1/whoami"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then the response body printf '%s\n' "$6" # req.ip must be your real client IP, never 203.0.113.9: echoing the # forged value means `trust proxy` is too broad and trusts a # client-set header. This checks req.ip scoping only, not that the # proxy strips headers, and does not cover X-Forwarded-Host or -Proto. # An overwriting proxy can also mask a too-broad setting, so a clean # result here does not prove the trust boundary by itself case "$5:$6" in 0:*203.0.113.9*) echo "FINDING: /whoami echoed the forged 203.0.113.9, so trust proxy trusts a client-set header"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "unclassified observation: /whoami did not echo 203.0.113.9; read its answer above, because a clean result does not prove the trust boundary by itself" ;; *) echo "curl exit $5: the /whoami request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; the HTTPS headers, the Location of a redirect answered with no credentials, the listener list and a clean /whoami answer are not classified, so judge them yourself" exit "$4" ) ``` ## Sources (checked September 2026) - Node.js HTTPS module: https://nodejs.org/api/https.html - Express behind proxies: https://expressjs.com/en/guide/behind-proxies/ - express-session (MemoryStore warning, cookie.maxAge, compatible stores): https://expressjs.com/en/resources/middleware/session/ - helmet: https://helmet.js.org/ - express-rate-limit changelog (v8 masks IPv6 clients to a `/56` subnet by default, closing the v7 subnet-rotation bypass; the `limit` option name): https://express-rate-limit.mintlify.app/reference/changelog - Node HTTP `message.url` and the caution to validate a client-supplied `Host` header: https://nodejs.org/api/http.html - otplib TOTP enrolment and QR-code generation (v12.0.1, checked October 2026): https://github.com/yeojz/otplib/blob/v12.0.1/README.md#L371-L372 - qrcode enrolment image generation (v1.5.4, checked October 2026): https://github.com/soldair/node-qrcode/blob/v1.5.4/README.md#L125-L129 - Node.js `process.env` (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/process.md#L1516-L1534 - Node.js `crypto.timingSafeEqual` (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/crypto.md#L6701-L6740 - Node.js `NODE_EXTRA_CA_CERTS` (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/cli.md#L4087-L4107 - Node.js `NODE_TLS_REJECT_UNAUTHORIZED` (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/cli.md#L4475-L4479 - Node.js `tls.connect` client `rejectUnauthorized` (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/tls.md#L1786-L1789 - Node.js `https.Agent` options (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/https.md#L70-L105 - Node.js `https.request` TLS options (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/https.md#L460-L474 - Node.js HTTPS agent example (v26.13.0, checked November 2026): https://github.com/nodejs/node/blob/v26.13.0/doc/api/https.md#L535-L551 - Linux ip(7) privileged-port rule (Linux man-pages 6.19, checked October 2026): https://man7.org/linux/man-pages/man7/ip.7.html - Linux capabilities(7) CAP_NET_BIND_SERVICE definition (Linux man-pages 6.19, checked October 2026): https://man7.org/linux/man-pages/man7/capabilities.7.html - Render web-service listener and TLS termination (checked October 2026): https://render.com/docs/web-services - Heroku HTTP routing, including IPv6 unsupported in the Cedar generation (checked October 2026): https://devcenter.heroku.com/articles/http-routing - Heroku web-process port assignment (checked October 2026): https://devcenter.heroku.com/articles/runtime-principles - Heroku Node.js deployment, web process must bind to PORT or the dyno does not start (checked October 2026): https://devcenter.heroku.com/articles/deploying-nodejs - Heroku dynos, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/dynos - Heroku Procfile, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/procfile - Heroku Gunicorn deployment, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/python-gunicorn - Heroku Getting Started with Python, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/getting-started-with-python - Heroku Getting Started with Node.js, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/getting-started-with-nodejs - Heroku Node.js best practices, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/node-best-practices - Heroku Fir Private Spaces networking and listener recommendation (checked October 2026): https://devcenter.heroku.com/articles/generations. The article states: "Fir utilizes IPv6 as the default and recommended connection protocol for networking within Heroku Private Spaces." It warns: "If you designed your application, its dependencies, or connected services within a space assuming only IPv4 connectivity, they don’t bind properly on Fir." It recommends: "Ensure IPv6 compatibility by correctly binding to an IPv6 listener or the dual-stack address listener in your app’s language (typically ::) to leverage Fir’s networking configuration." This guide's assessment: these sentences concern networking within Heroku Private Spaces; they do not establish Cedar's listener address or a deployed application's listener configuration. - Fly TCP passthrough (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97 - Fly TLS termination (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176 - Fly public service binding (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42 - Fly IPv4 and IPv6 wildcard qualification (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92 - Railway target ports (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146 - Railway web-server listener and injected port (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51 - Railway edge-proxy TLS termination and internal forwarding (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43 - OWASP password storage cheat sheet Argon2id recommendation (commit 72865b1e, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L11 - OWASP password storage cheat sheet legacy bcrypt cost and input limit (commit 72865b1e, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13 - OWASP password storage cheat sheet bcrypt legacy scope (commit 72865b1e, checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L136-L138 - node.bcrypt.js CommonJS import (v6.0.0, checked October 2026): https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L104 - node.bcrypt.js Promises and async/await (v6.0.0, checked October 2026): https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L148-L181 - node.bcrypt.js hash and compare APIs (v6.0.0, checked October 2026): https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L248-L262 - node.bcrypt.js cost meaning (v6.0.0, checked October 2026): https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L268 - node-argon2 Argon2id support and default (v0.45.1, checked October 2026): https://github.com/ranisalt/node-argon2/blob/v0.45.1/README.md#L12-L13 - Cloudflare documentation independent MFA (c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24 - Cloudflare documentation MFA requirements and precedence (c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81 - Cloudflare documentation self-hosted authentication layer (c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16 - Cloudflare documentation origin connection and protection (c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - node-v26: Node.js API documentation (v26.13.0) - node: Node.js documentation (unknown) - express: Express proxy documentation (unknown) - session: express-session documentation (unknown) - helmet: Helmet documentation (unknown) - rate: express-rate-limit minimum (v8) - otplib: otplib (v12.0.1) - qrcode: qrcode (v1.5.4) - render: Render documentation (unknown) - heroku: Heroku documentation (unknown) - fly-pinned: Fly.io pinned documentation (2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9) - railway: Railway documentation (72354493198302677fb6be6c692dd75c64cae2c6) - bcrypt: node.bcrypt.js (v6.0.0) - argon2: node-argon2 (v0.45.1) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - linux-ip: Linux man-pages ip(7) (6.19) - linux-capabilities: Linux man-pages capabilities(7) (6.19) - owasp: OWASP password storage cheat sheet (commit 72865b1e) Claims (full source mapping in the JSON block below): - tls (REASONED; node): Direct HTTPS uses a certificate plus chain and private key on all-interface 443. - redirect (REASONED; node): All-interface 80 redirects with 301 to required PUBLIC_HOST; only origin-form request paths are appended. - bind (REASONED; node, express, render, heroku, fly-pinned, railway): Guide advice: 127.0.0.1 for a same-host proxy, no extra managed listeners, app auth required; plain HTTP needs protected TLS ingress, absent on Fly without handlers. Railway advises 0.0.0.0 on injected PORT with edge-proxy TLS; Heroku web processes must bind PORT, IPv6/dual-stack advised for Fir Private Spaces; checked pages state no Cedar address (guide's assessment, not proof any address works). - privilege (REASONED; node, linux-ip, linux-capabilities): On Linux, binding ports below 1024 requires a process, root or not, that has CAP_NET_BIND_SERVICE in the user namespace governing its network namespace (stated by ip(7) for IPv4; IPv6 is not covered by the listed Sources); this guide advises against running the app as root and prefers the proxy layout. - proxy (REASONED; express): Trust loopback or an exact remote proxy address/subnet; true and variable-length hop paths permit forged client IP, host and scheme unless headers are overwritten. - headers (REASONED; helmet): Install helmet() for security headers including HSTS. - password (REASONED; session, owasp, bcrypt, argon2): Guide advice: prefer Argon2id for new apps (argon2 v0.45.1 supports it); retain bcrypt only for legacy systems without Argon2/scrypt, with its 72-byte limit and example cost 12 (OWASP minimum 10). bcrypt v6.0.0 hash/compare return Promises without callbacks when supported; numeric hash cost generates a salt and means 2^cost iterations. - session-secret (REASONED; session): Load a long random SESSION_SECRET from the environment. - session-save (REASONED; session): Set resave=false and saveUninitialized=false. - cookies (REASONED; session): Set Secure, HttpOnly, SameSite=lax and an eight-hour cookie maxAge in milliseconds. - store (REASONED; session): Default MemoryStore is development-only, leaks memory and cannot scale past one process; supply a production store. - expiry (REASONED; session): No cookie maximum age is set by default; set cookie.maxAge. - login-limit (REASONED; rate): Use a 20-request, 15-minute login limit; v8 or newer defaults to IPv6 /56 grouping, closing the v7 rotation bypass; pair IP limits with account controls. - tokens (REASONED; node, node-v26): This guide advises loading API keys and tokens from process.env, never from source literals, and comparing self-checked secret values with crypto.timingSafeEqual using equal-byte-length byte inputs; process.env exposes the user environment, while timingSafeEqual's input requirements and surrounding-code limit are documented. - mfa (REASONED; session, otplib, qrcode, cf-docs): Add otplib/qrcode TOTP or require Cloudflare Access independent MFA in the effective policy (policy > application > organization). This guide requires an origin unreachable around Access and testing enforcement; cited pages do not establish a deployed policy or origin isolation. - tls-validation (REASONED; node, node-v26): This guide advises never setting NODE_TLS_REJECT_UNAUTHORIZED=0 for the Node.js process's TLS connections or rejectUnauthorized=false for a client TLS connection or an HTTPS agent whose requests use it; Node.js documents the TLS validation effects, and the agent reach is this guide's assessment of the documented options flow. - client-ca (REASONED; node, node-v26): This guide advises setting NODE_EXTRA_CA_CERTS to a PEM file before launching Node.js for an internal CA or self-signed server. NODE_EXTRA_CA_CERTS extends well-known roots, is read only at launch, and is ignored when Node.js runs as setuid root or with Linux file capabilities; neither that variable nor the well-known roots is used by a TLS or HTTPS client or server given an explicit ca option. - verify-redirect (REASONED; node): HTTP should return 301 with an HTTPS Location. - verify-tls (REASONED; node, helmet): HTTPS must succeed without -k and show Helmet headers. - verify-auth (REASONED; session): A real protected route denies anonymous requests with 401/403 and accepts a valid session with 200; either app or fronting auth may enforce the denial. - verify-bind (REASONED; node, express): Inspect every listener for loopback behind a same-host proxy or the managed platform address and port. - verify-proxy (REASONED; express): Temporary /whoami must not echo forged X-Forwarded-For; this checks req.ip only and an overwriting proxy can mask excessive trust. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "c50b3aeebd7579f769a7964691ef7cea4fd9de42c9c33d24bd64f898df966181", "components": { "node-v26": { "name": "Node.js API documentation", "basis": "v26.13.0", "sources": { "sf09cf29cc9d8": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/process.md#L1516-L1534", "s3e5d1c15a3f3": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/crypto.md#L6701-L6740", "s80afd76fddeb": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/cli.md#L4087-L4107", "scab27787c92d": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/cli.md#L4475-L4479", "s6fc2c13cce46": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/tls.md#L1786-L1789", "se8ba2dcc9331": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/https.md#L70-L105", "sef8437aa9f3c": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/https.md#L460-L474", "s0e68f247d473": "https://github.com/nodejs/node/blob/v26.13.0/doc/api/https.md#L535-L551" } }, "node": { "name": "Node.js documentation", "basis": "unknown", "sources": { "s87db42190978": "https://nodejs.org/api/https.html", "sdb035b84e308": "https://nodejs.org/api/http.html" } }, "express": { "name": "Express proxy documentation", "basis": "unknown", "sources": { "s4e09c522e530": "https://expressjs.com/en/guide/behind-proxies/" } }, "session": { "name": "express-session documentation", "basis": "unknown", "sources": { "safd9f9d15304": "https://expressjs.com/en/resources/middleware/session/" } }, "helmet": { "name": "Helmet documentation", "basis": "unknown", "sources": { "sc1865315478e": "https://helmet.js.org/" } }, "rate": { "name": "express-rate-limit minimum", "basis": "v8", "sources": { "s303dcc57a0df": "https://express-rate-limit.mintlify.app/reference/changelog" } }, "otplib": { "name": "otplib", "basis": "v12.0.1", "sources": { "sdb2cd721be64": "https://github.com/yeojz/otplib/blob/v12.0.1/README.md#L371-L372" } }, "qrcode": { "name": "qrcode", "basis": "v1.5.4", "sources": { "sc505636608da": "https://github.com/soldair/node-qrcode/blob/v1.5.4/README.md#L125-L129" } }, "render": { "name": "Render documentation", "basis": "unknown", "sources": { "s1820ac471b6c": "https://render.com/docs/web-services" } }, "heroku": { "name": "Heroku documentation", "basis": "unknown", "sources": { "s370d4beec29a": "https://devcenter.heroku.com/articles/runtime-principles", "s211f5fe76042": "https://devcenter.heroku.com/articles/generations", "s0c5bf300cef4": "https://devcenter.heroku.com/articles/http-routing", "s110a083f7129": "https://devcenter.heroku.com/articles/deploying-nodejs", "s41677c652b96": "https://devcenter.heroku.com/articles/dynos", "s9f4dec283014": "https://devcenter.heroku.com/articles/procfile", "s8a2465cbd6c2": "https://devcenter.heroku.com/articles/python-gunicorn", "sb941efbc6dd5": "https://devcenter.heroku.com/articles/getting-started-with-python", "sa2690945ce1c": "https://devcenter.heroku.com/articles/getting-started-with-nodejs", "s3563a06d3934": "https://devcenter.heroku.com/articles/node-best-practices" } }, "fly-pinned": { "name": "Fly.io pinned documentation", "basis": "2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9", "sources": { "s62fec91543a1": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42", "s61c1e50d2c11": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92", "s254c8c8a5edd": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97", "s32999aa4c58c": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176" } }, "railway": { "name": "Railway documentation", "basis": "72354493198302677fb6be6c692dd75c64cae2c6", "sources": { "sac1383056b65": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146", "s0462eef65b1b": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51", "sa9d326046a7f": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43" } }, "bcrypt": { "name": "node.bcrypt.js", "basis": "v6.0.0", "sources": { "se94486b19d69": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L104", "s228d1e5e8827": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L148-L181", "s60f2c824a85c": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L248-L262", "s55a49cad0981": "https://github.com/kelektiv/node.bcrypt.js/blob/v6.0.0/README.md#L268" } }, "argon2": { "name": "node-argon2", "basis": "v0.45.1", "sources": { "s76b67b13ea1b": "https://github.com/ranisalt/node-argon2/blob/v0.45.1/README.md#L12-L13" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24", "sdcac732541da": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L35-L81", "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16", "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54" } }, "linux-ip": { "name": "Linux man-pages ip(7)", "basis": "6.19", "sources": { "s612fed1df1ed": "https://man7.org/linux/man-pages/man7/ip.7.html" } }, "linux-capabilities": { "name": "Linux man-pages capabilities(7)", "basis": "6.19", "sources": { "s3c3a13034c04": "https://man7.org/linux/man-pages/man7/capabilities.7.html" } }, "owasp": { "name": "OWASP password storage cheat sheet", "basis": "commit 72865b1e", "sources": { "s790d2fc1475e": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L11", "s4531e65b6502": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13", "sad2af32bee2f": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L136-L138" } } }, "claims": { "tls": {"text": "Direct HTTPS uses a certificate plus chain and private key on all-interface 443.", "components": ["node"], "sources": ["node:s87db42190978"], "status": "REASONED"}, "redirect": {"text": "All-interface 80 redirects with 301 to required PUBLIC_HOST; only origin-form request paths are appended.", "components": ["node"], "sources": ["node:sdb035b84e308"], "status": "REASONED"}, "bind": {"text": "Guide advice: 127.0.0.1 for a same-host proxy, no extra managed listeners, app auth required; plain HTTP needs protected TLS ingress, absent on Fly without handlers. Railway advises 0.0.0.0 on injected PORT with edge-proxy TLS; Heroku web processes must bind PORT, IPv6/dual-stack advised for Fir Private Spaces; checked pages state no Cedar address (guide's assessment, not proof any address works).", "components": ["node", "express", "render", "heroku", "fly-pinned", "railway"], "sources": ["node:s87db42190978", "express:s4e09c522e530", "render:s1820ac471b6c", "heroku:s370d4beec29a", "heroku:s211f5fe76042", "heroku:s0c5bf300cef4", "heroku:s110a083f7129", "heroku:s41677c652b96", "heroku:s9f4dec283014", "heroku:s8a2465cbd6c2", "heroku:sb941efbc6dd5", "heroku:sa2690945ce1c", "heroku:s3563a06d3934", "fly-pinned:s62fec91543a1", "fly-pinned:s61c1e50d2c11", "fly-pinned:s254c8c8a5edd", "fly-pinned:s32999aa4c58c", "railway:sac1383056b65", "railway:s0462eef65b1b", "railway:sa9d326046a7f"], "status": "REASONED"}, "privilege": {"text": "On Linux, binding ports below 1024 requires a process, root or not, that has CAP_NET_BIND_SERVICE in the user namespace governing its network namespace (stated by ip(7) for IPv4; IPv6 is not covered by the listed Sources); this guide advises against running the app as root and prefers the proxy layout.", "components": ["node", "linux-ip", "linux-capabilities"], "sources": ["node:s87db42190978", "linux-ip:s612fed1df1ed", "linux-capabilities:s3c3a13034c04"], "status": "REASONED"}, "proxy": {"text": "Trust loopback or an exact remote proxy address/subnet; true and variable-length hop paths permit forged client IP, host and scheme unless headers are overwritten.", "components": ["express"], "sources": ["express:s4e09c522e530"], "status": "REASONED"}, "headers": {"text": "Install helmet() for security headers including HSTS.", "components": ["helmet"], "sources": ["helmet:sc1865315478e"], "status": "REASONED"}, "password": {"text": "Guide advice: prefer Argon2id for new apps (argon2 v0.45.1 supports it); retain bcrypt only for legacy systems without Argon2/scrypt, with its 72-byte limit and example cost 12 (OWASP minimum 10). bcrypt v6.0.0 hash/compare return Promises without callbacks when supported; numeric hash cost generates a salt and means 2^cost iterations.", "components": ["session", "owasp", "bcrypt", "argon2"], "sources": ["session:safd9f9d15304", "owasp:s790d2fc1475e", "owasp:s4531e65b6502", "owasp:sad2af32bee2f", "bcrypt:se94486b19d69", "bcrypt:s228d1e5e8827", "bcrypt:s60f2c824a85c", "bcrypt:s55a49cad0981", "argon2:s76b67b13ea1b"], "status": "REASONED"}, "session-secret": {"text": "Load a long random SESSION_SECRET from the environment.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"}, "session-save": {"text": "Set resave=false and saveUninitialized=false.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"}, "cookies": {"text": "Set Secure, HttpOnly, SameSite=lax and an eight-hour cookie maxAge in milliseconds.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"}, "store": {"text": "Default MemoryStore is development-only, leaks memory and cannot scale past one process; supply a production store.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"}, "expiry": {"text": "No cookie maximum age is set by default; set cookie.maxAge.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED"}, "login-limit": {"text": "Use a 20-request, 15-minute login limit; v8 or newer defaults to IPv6 /56 grouping, closing the v7 rotation bypass; pair IP limits with account controls.", "components": ["rate"], "sources": ["rate:s303dcc57a0df"], "status": "REASONED"}, "tokens": {"text": "This guide advises loading API keys and tokens from process.env, never from source literals, and comparing self-checked secret values with crypto.timingSafeEqual using equal-byte-length byte inputs; process.env exposes the user environment, while timingSafeEqual's input requirements and surrounding-code limit are documented.", "components": ["node", "node-v26"], "sources": ["node:s87db42190978", "node-v26:sf09cf29cc9d8", "node-v26:s3e5d1c15a3f3"], "status": "REASONED"}, "mfa": {"text": "Add otplib/qrcode TOTP or require Cloudflare Access independent MFA in the effective policy (policy > application > organization). This guide requires an origin unreachable around Access and testing enforcement; cited pages do not establish a deployed policy or origin isolation.", "components": ["session", "otplib", "qrcode", "cf-docs"], "sources": ["session:safd9f9d15304", "otplib:sdb2cd721be64", "qrcode:sc505636608da", "cf-docs:s7e793d13cf31", "cf-docs:sdcac732541da", "cf-docs:sfddb92e438ca", "cf-docs:s7cafe1b17afd"], "status": "REASONED"}, "tls-validation": {"text": "This guide advises never setting NODE_TLS_REJECT_UNAUTHORIZED=0 for the Node.js process's TLS connections or rejectUnauthorized=false for a client TLS connection or an HTTPS agent whose requests use it; Node.js documents the TLS validation effects, and the agent reach is this guide's assessment of the documented options flow.", "components": ["node", "node-v26"], "sources": ["node:s87db42190978", "node-v26:scab27787c92d", "node-v26:s6fc2c13cce46", "node-v26:se8ba2dcc9331", "node-v26:sef8437aa9f3c", "node-v26:s0e68f247d473"], "status": "REASONED"}, "client-ca": {"text": "This guide advises setting NODE_EXTRA_CA_CERTS to a PEM file before launching Node.js for an internal CA or self-signed server. NODE_EXTRA_CA_CERTS extends well-known roots, is read only at launch, and is ignored when Node.js runs as setuid root or with Linux file capabilities; neither that variable nor the well-known roots is used by a TLS or HTTPS client or server given an explicit ca option.", "components": ["node", "node-v26"], "sources": ["node:s87db42190978", "node-v26:s80afd76fddeb"], "status": "REASONED"}, "verify-redirect": {"text": "HTTP should return 301 with an HTTPS Location.", "components": ["node"], "sources": ["node:sdb035b84e308"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "HTTPS must succeed without -k and show Helmet headers.", "components": ["node", "helmet"], "sources": ["node:s87db42190978", "helmet:sc1865315478e"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "A real protected route denies anonymous requests with 401/403 and accepts a valid session with 200; either app or fronting auth may enforce the denial.", "components": ["session"], "sources": ["session:safd9f9d15304"], "status": "REASONED", "verify": [1]}, "verify-bind": {"text": "Inspect every listener for loopback behind a same-host proxy or the managed platform address and port.", "components": ["node", "express"], "sources": ["node:s87db42190978", "express:s4e09c522e530"], "status": "REASONED", "verify": [1]}, "verify-proxy": {"text": "Temporary /whoami must not echo forged X-Forwarded-For; this checks req.ip only and an overwriting proxy can mask excessive trust.", "components": ["express"], "sources": ["express:s4e09c522e530"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> python.md ====================================================================== # Python web apps: TLS and authentication Flask, FastAPI and Django are Python web frameworks, served by Uvicorn or Gunicorn. This guide helps your AI assistant deploy them securely, including loopback behind a TLS proxy or a managed platform's required listener, Django's HTTPS settings, authentication, and `http.server` only as a last resort, for curated files over SSH. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/python.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Django documentation 6.1, Python http.server documentation 3.14.4, Python http.server source v3.14.10, Python standard library documentation 3.14, CPython ssl source and documentation v3.14.10, CPython ssl source v3.14.9, and CPython secrets documentation and source v3.14.10, plus 8 more recorded bases; 5 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 24 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Covers Flask, FastAPI/Uvicorn, Gunicorn, and Django. This guide's preferred production layout: bind the app server to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) or behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). For managed HTTP ingress, bind the listener required by your deployment; TLS termination depends on the platform and configuration described below ([paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). The app servers can also terminate TLS themselves, shown below. Certificates: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). ## 1. TLS per server Flask's built-in server (development only; it is not a production server, TLS or not): ```python app.run(host="127.0.0.1", port=8443, ssl_context=("cert.pem", "key.pem")) # ssl_context="adhoc" generates a throwaway self-signed cert; requires the cryptography package ``` Gunicorn (Flask/Django/WSGI in production): ```bash gunicorn --bind 0.0.0.0:8443 \ --certfile /etc/ssl/certs/server.crt \ --keyfile /etc/ssl/private/server.key \ app:app ``` Uvicorn (FastAPI/ASGI): ```bash uvicorn main:app --host 0.0.0.0 --port 8443 \ --ssl-certfile /etc/ssl/certs/server.crt \ --ssl-keyfile /etc/ssl/private/server.key ``` This guide's advice: Bind to `0.0.0.0` only when either (a) the process itself terminates TLS with authentication in place, or (b) a platform terminates TLS at its ingress and reaches your app over a private or container network. Otherwise, on a host you expose directly, keep `127.0.0.1` and terminate TLS in a reverse proxy. This guide's advice for managed platforms: do not add extra listeners; bind only the listener required by your deployment. The documented HTTP ingress mappings are: - Render web services must bind `0.0.0.0`. Render recommends the `PORT` environment variable (at the time of writing, default port 10000, configurable), can usually detect another bound port, and requires the public HTTP server to use `PORT` when multiple ports are bound ([web services](https://render.com/docs/web-services)). - Heroku assigns each web process its port through `PORT` ([runtime principles](https://devcenter.heroku.com/articles/runtime-principles)), and Heroku's Node.js deployment page says the web process must bind to that port or the dyno does not start ([deploying Node.js](https://devcenter.heroku.com/articles/deploying-nodejs)). Fir Private Spaces networking uses IPv6 by default; Heroku warns that IPv4-only assumptions do not bind properly and recommends an IPv6 or dual-stack listener, typically `::` ([generations](https://devcenter.heroku.com/articles/generations)). Heroku's HTTP routing page lists IPv6 as unsupported in the Cedar generation without naming a listener address ([HTTP routing](https://devcenter.heroku.com/articles/http-routing)). This guide's assessment: none of the Heroku pages checked (runtime principles, HTTP routing, generations, dynos, Procfile, Gunicorn deployment, Node.js deployment, the Python and Node.js getting-started tutorials and Node.js best practices, all listed in Sources) states a listener address for Cedar. This guide's assessment of those pages: apart from the Fir recommendation above, the addresses they show are local-development URLs and sample log output. That absence is not evidence that Heroku accepts any listener address. - Fly Proxy HTTP services must be reachable over IPv4: use `0.0.0.0` and match the configured `internal_port` to the app listener ([service binding](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42)). IPv4-mapped IPv6 wildcard behaviour depends on the VM and runtime ([wildcard qualification](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92)). - Railway domains route to a detected or selected internal target port ([target ports](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146)). Railway says web servers should bind `0.0.0.0` and listen on the automatically injected `PORT` ([listener and port](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51)). Its edge proxy (tcp-proxy) terminates TLS; traffic is then forwarded over Railway's internal network to the deployment ([request flow](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43)). Render's load balancer terminates inbound HTTPS and forwards HTTP to the web service ([web services](https://render.com/docs/web-services)). Heroku's HTTP routing page describes a load balancer offering SSL termination and routers forwarding HTTP to web dynos; it notes that both Private Spaces generations have different routers, so this is not a complete account of Private Spaces routing ([HTTP routing](https://devcenter.heroku.com/articles/http-routing)). Fly Proxy's `tls` handler terminates TLS and forwards plaintext ([TLS handler](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176)); without handlers it passes TCP through, allowing the app to terminate TLS itself ([TCP passthrough](https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97)). This guide's advice: plain HTTP is acceptable only behind protected ingress that terminates TLS; application authentication is still required ([paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). ## 2. Django settings for HTTPS ```python SECURE_SSL_REDIRECT = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # only behind a proxy that STRIPS the client's value and sets its own SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_HSTS_SECONDS = 3600 # start small; raise to 31536000 (1 year) after HTTPS is confirmed working SECURE_HSTS_INCLUDE_SUBDOMAINS = False # True ONLY after every subdomain also serves valid HTTPS; the commitment is irreversible for SECURE_HSTS_SECONDS ``` `SECURE_PROXY_SSL_HEADER` must be set only when a proxy you control, or a managed platform that reliably guarantees this, strips any client-supplied `X-Forwarded-Proto` from every request and sets it itself from the real connection scheme; otherwise a client can spoof it and make Django treat plain HTTP as HTTPS. Leave it unset if you are not behind such a proxy, and confirm the app is reachable only through that proxy, not directly. Run `python manage.py check --deploy` and fix what it reports. ## 3. Authentication Follow [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Framework specifics: - Keep Django's built-in password hashing; do not replace it with custom code (this guide's advice). - If your Flask or FastAPI application implements its own password accounts, provide its account storage and hash passwords with `argon2-cffi` or `bcrypt` (this guide's advice; follow the algorithm and legacy-bcrypt constraints in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)): ```python from argon2 import PasswordHasher ph = PasswordHasher() hash_ = ph.hash(password) ph.verify(hash_, password) # raises on mismatch ``` The [argon2-cffi 25.1.0 example](https://github.com/hynek/argon2-cffi/blob/25.1.0/README.md#L11-L28) documents these `PasswordHasher`, `hash` and `verify` calls and a `VerifyMismatchError` on mismatch. The [pinned bcrypt example](https://github.com/pyca/bcrypt/blob/2b4ba9ac84df972e8e81311d09559af8dc82ef33/README.rst#L62-L76) documents `hashpw(password, gensalt())` and `checkpw(password, hashed)`; these API examples do not establish framework user-store capabilities. - Generate tokens and secrets with the standard library, and load them from the environment (this guide's advice): ```python import secrets token = secrets.token_urlsafe(32) ``` In [CPython v3.14.10](https://github.com/python/cpython/blob/v3.14.10/Doc/library/secrets.rst#L91-L103), `token_urlsafe(nbytes=None)` returns URL-safe Base64 text from `nbytes` random bytes; the argument counts input bytes, not output characters. The explicit `32` above is this guide's example choice, not a size suitable for every secret. If the argument is omitted or `None`, it uses `DEFAULT_ENTROPY`, [32 bytes at this pin](https://github.com/python/cpython/blob/v3.14.10/Lib/secrets.py#L31-L45); the [documented default can change](https://github.com/python/cpython/blob/v3.14.10/Doc/library/secrets.rst#L125-L130), including in maintenance releases. - FastAPI's `fastapi.security` classes (`HTTPBearer`, `APIKeyHeader`, `OAuth2AuthorizationCodeBearer`, and so on) extract the credential from the request and declare the OpenAPI security scheme. The `OpenIdConnect` helper is documented as a stub that does not implement the scheme or use the discovery URL. The checks those classes perform vary by helper (an API-key helper checks only that a value is present; the HTTP and OAuth2 bearer helpers may also check the scheme), but none of those classes authenticate the credential (no signature, issuer, audience, or expiry check). Use them to extract the token, then validate it (signature, issuer, audience, expiry) with an OIDC library such as Authlib, and authorize per [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md). - Rate-limit login routes (this guide's advice; for example with a proxy-level limit or a library such as slowapi for Starlette/FastAPI ASGI apps' HTTP endpoints; endpoints using slowapi's `limit` decorator must take an explicit `request` argument, and slowapi does not support WebSocket endpoints). - MFA: add TOTP with [pyotp](https://github.com/pyauth/pyotp) plus the [qrcode](https://pypi.org/project/qrcode/) package for enrolment QR codes; [django-otp](https://pypi.org/project/django-otp/) integrates this into Django. Requirements and options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 4. Client-side TLS discipline Never ship `verify=False` (requests/httpx) or `ssl._create_unverified_context` (this guide's advice). For an internal CA, point the client at it instead (this guide's advice): ```bash export REQUESTS_CA_BUNDLE=/path/ca.crt # requests export SSL_CERT_FILE=/path/ca.crt # HTTPX with verify=True and trust_env=True; OpenSSL default CA file for ssl when its default verify paths apply (see caveat below) ``` HTTPX 0.28.1's SSL guide says `SSL_CERT_FILE` and `SSL_CERT_DIR` are not automatically used and shows an explicit context; its code path contradicts that: its sync and async transports pass `verify` and `trust_env` to `create_ssl_context`, which reads `SSL_CERT_FILE` when both are true. Its environment guide says environment variables are used by default but does not list `SSL_CERT_FILE`. This guide's advice: use the export above for that HTTPX code path; pass an explicit CA context for other configurations. With no CA specified and verification enabled, CPython's `ssl.create_default_context()` tries to load default CAs (CPython notes this may fail silently). `ssl.get_default_verify_paths()` reports OpenSSL's default verify paths and environment-variable names. At CPython v3.14.10, [`SSLContext.load_default_certs()`](https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L542-L548) calls `set_default_verify_paths()`, whose [`_ssl.c` implementation](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L4677-L4696) calls OpenSSL's `SSL_CTX_set_default_verify_paths()`. [`get_default_verify_paths()` in `_ssl.c`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L5943-L5984) gets the file and directory environment-variable names from OpenSSL's `X509_get_default_cert_file_env()` and `X509_get_default_cert_dir_env()`; [`ssl.py`](https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L390-L405) returns those names and the resolved paths. [OpenSSL openssl-4.0.3](https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man3/SSL_CTX_load_verify_locations.pod#L39-L49) documents that `SSL_CTX_set_default_verify_paths()` honours both `SSL_CERT_FILE` and `SSL_CERT_DIR` for its default CA file and directory, subject to its environment rules: [these variables are security-sensitive for CA loading](https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man7/openssl-env.pod#L289-L298), and [security-sensitive environment variables are not consulted for set-user-ID or set-group-ID executables](https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man7/openssl-env.pod#L9-L13). On Windows builds, where `sys.platform` is `"win32"`, [`ssl.py` imports `enum_certificates`](https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L257-L258) and the same [`load_default_certs()`](https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L542-L548) first loads certificates from the [`CA` and `ROOT` system stores](https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L439-L439) before the default-path call; on other platforms it makes that call alone. [`_load_windows_store_certs()`](https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L529-L540) loads each `x509_asn` certificate whose trust is `True` or includes the requested purpose's OID, and catches an `SSLError` for a certificate that fails to load and a `PermissionError` while enumerating the store, issuing a warning for each. In `_ssl.c`, [`ssl_collect_certificates()`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6144-L6191) collects the named store, read-only, from whichever of the local-machine, local-machine enterprise, local-machine group-policy, current-user, current-user group-policy, services and users system store locations it can open and add. [`enum_certificates`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6193-L6291) returns each certificate with its encoding and a trust value that is `True` when [neither an enhanced key usage property nor an extension is found](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6089-L6142), and otherwise a set of OIDs. This guide's reading: the Windows store loading does not depend on `SSL_CERT_FILE` or `SSL_CERT_DIR`, and neither `ssl.py` nor `_ssl.c` at v3.14.10 has macOS-specific store code, so in these files a macOS build makes only the default-path call into OpenSSL. At v3.14.10, [the clinic header defines the `enum_certificates` and `enum_crls` method-table macros](https://github.com/python/cpython/blob/v3.14.10/Modules/clinic/_ssl.c.h#L2811-L2980) only when `_MSC_VER` is defined, and defines both as empty otherwise. `_ssl.c` compiles [both functions and their Windows store helpers](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6061-L6380) only under `_MSC_VER`, so without it [the module method table](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6389-L6390) lists neither function. This guide's assessment: because `ssl.py` imports both names whenever `sys.platform` is `"win32"`, a `win32` build compiled without `_MSC_VER` is expected to fail at that import rather than make the default-path call alone; whether such a build exists, and what it does, needs a run. `_ssl.c` includes [`_ssl/debughelpers.c`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L428-L428), and [`_ssl/misc.c` and `_ssl/cert.c`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L1836-L1837). At v3.14.10 it also includes [`_ssl/distrusted_spki.h`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3083-L3083), which [defines the `_distrusted_spki` list](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/distrusted_spki.h#L23-L73) as 8 SHA-256 digests under the comment "Compromised eMudhra intermediates", each commented with one eMudhra CA name, such as `CN=EM OV TLS CA - G3B,O=eMudhra BV,C=NL`. This guide did not check that each digest is the key of the CA its comment names. At v3.14.10, `_ssl.c` [passes a verify callback to `SSL_CTX_set_verify()`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3167-L3167), and [that callback](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3116-L3141), which its comment says is called once per certificate, checks each certificate in the chain. When OpenSSL's own check of a certificate passed and [the SHA-256 of its SubjectPublicKeyInfo](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3085-L3114) is on that list, the callback sets `X509_V_ERR_CERT_REJECTED` and returns 0, so verification of that certificate fails. In the 3.14 line this check is new in v3.14.10: at v3.14.9, `_ssl.c` [passes `NULL`, not a callback, to `SSL_CTX_set_verify()`](https://github.com/python/cpython/blob/v3.14.9/Modules/_ssl.c#L3098-L3098), and this guide did not read other release lines. This guide's reading: the check does not depend on where the CAs came from, so it applies whichever CA source was loaded, the default paths, the Windows stores or `load_verify_locations`; loading a CA lets OpenSSL build a chain to it, but does not by itself mean a certificate in that chain is accepted. This guide's reading: [`cert.c`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/cert.c#L1-L251), [`misc.c`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/misc.c#L1-L34) and [`debughelpers.c`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/debughelpers.c#L1-L240) hold no CA-loading, default-path or system-store code. By the same reading, the [clinic header](https://github.com/python/cpython/blob/v3.14.10/Modules/clinic/_ssl.c.h#L1-L2981) supplies wrappers for the CA-loading, default-path and system-store methods and functions `load_verify_locations`, `set_default_verify_paths`, `get_default_verify_paths`, `enum_certificates` and `enum_crls`. They delegate to the implementations in `_ssl.c` (the header's lines 1581, 1850, 2690, 2884 and 2965). At v3.14.10, [`_set_verify_mode()`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3143-L3169) passes the distrusted-key callback whatever the mode, and `_ssl.c` calls it when it creates [a client or server context](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3285-L3298), from [the `verify_mode` setter](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3604-L3618), and from [the `check_hostname` setter](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3998-L4015) when it turns on hostname checking for a context whose mode is `CERT_NONE`. This guide's reading: the check covers a server verifying client certificates as well as a client verifying a server. A server socket that allows TLS 1.3 post-handshake authentication [passes its current verify callback back](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L936-L954) when it adds `SSL_VERIFY_POST_HANDSHAKE`, under the comment "Keep ctx verify callback (distrusted SPKI check)". This guide's reading: neither `_ssl.c` nor the header offers an `ssl` setting, environment variable or build condition that shows, removes or extends the list or the callback; the rest of the CPython tree was not searched. What `verify_mode = CERT_NONE` does to this check is not established by these files: `_set_verify_mode()` passes that mode to OpenSSL as `SSL_VERIFY_NONE`, and [`ssl.rst`](https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L483-L492) says that under `CERT_NONE` validation errors do not abort a client socket's handshake and a server does not request a client certificate. [The header's comment](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/distrusted_spki.h#L1-L17) calls the list a last line of defence, says the list is meant to block keys only while a platform root store's removal of trust is still propagating, and says to block another key by adding its digest to the list. This guide's inference, not run: when the check refuses a certificate, the verifying side sees `ssl.SSLCertVerificationError`, because `_ssl.c` [chooses that class](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L723-L733) when OpenSSL reports a failed certificate verification, [takes `verify_code` and `verify_message`](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L517-L553) from OpenSSL's verify result, here `X509_V_ERR_CERT_REJECTED`, and [sets them on the exception](https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L619-L626). What the peer whose certificate was refused sees is not established. This guide's inference: that CPython default-path call uses those OpenSSL overrides when linked to a library with the cited behaviour, subject to the executable exclusions above. This guide's assessment: a CPython build may link a different OpenSSL release from `openssl-4.0.3`; these lines do not establish which release a given build links, which CAs OpenSSL's default paths or the Windows stores hold on a given host, or whether a deployed configuration takes this path and loads its intended CAs. At v3.14.10, the Windows build script [`PCbuild/get_externals.bat`](https://github.com/python/cpython/blob/v3.14.10/PCbuild/get_externals.bat#L57-L57) names `openssl-3.5.9` as its OpenSSL source library and [`openssl-bin-3.5.9`](https://github.com/python/cpython/blob/v3.14.10/PCbuild/get_externals.bat#L82-L82) as its OpenSSL binaries. The macOS installer build script [`Mac/BuildScript/build-installer.py`](https://github.com/python/cpython/blob/v3.14.10/Mac/BuildScript/build-installer.py#L249-L252) names `OpenSSL 3.5.9`, with a tarball URL from the `openssl-3.5.9` release. This guide's OpenSSL citations above are at `openssl-4.0.3`, a different release from the OpenSSL 3.5.9 that these build files name. At `openssl-3.5.9`, [`SSL_CTX_load_verify_locations`](https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man3/SSL_CTX_load_verify_locations.pod#L39-L49) documents the same `SSL_CERT_DIR` and `SSL_CERT_FILE` overrides of the default CA directory and file as the cited `openssl-4.0.3` lines. The two releases' `openssl-env` pages differ: [the 3.5.9 page](https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man7/openssl-env.pod#L9-L12) says the environment is "usually not consulted" when the executable is set-user-ID or set-group-ID. Its [`SSL_CERT_DIR` and `SSL_CERT_FILE` entry](https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man7/openssl-env.pod#L168-L173) does not call either variable security-sensitive. This guide's assessment: only that documentation was read, not OpenSSL 3.5.9's code, so whether 3.5.9 consults `SSL_CERT_FILE` and `SSL_CERT_DIR` for set-user-ID or set-group-ID executables is not established. Which OpenSSL release a given installed, distribution or source build links still needs observation, for example of `ssl.OPENSSL_VERSION` on that installation. ## 5. Do not quick-share files with `http.server` `python -m http.server` is a common quick-share suggestion, and it is the wrong one on any reachable host. By default it binds every interface (as of Python 3.14.10), and it serves the current directory: source, `.env`, private keys, and database dumps are all downloadable by anyone who can reach the port. It also follows symbolic links, so a link in that directory hands out files from outside it, and the Python docs mark the module as not suitable for production. If you have no alternative, bind loopback and serve a directory that holds only what you mean to share: ```bash cd /path/to/a/directory/with/nothing/private || exit python -m http.server -b 127.0.0.1 8000 ``` Reach it through an SSH tunnel, including one that runs over your tailnet, never a public bind. ## 6. Verify (REASONED: TLS, authentication and listener expectations follow the Sources below and linked deployment guidance; no deployment run or outcome is recorded in this guide. This metadata-only review has no deployed Python application, proxy or managed ingress to test. The block needs bash: in a shell without bash's shopt, such as dash, it refuses with status 2 before probing. At the end it exits 2 when ss is not installed only if no other part found something or was inconclusive, because a finding and an inconclusive result both outrank that refusal.) ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_PROTECTED_PATH' # the deployment's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then a real protected path without its leading slash: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|/*|*[[:space:][:cntrl:]]*) echo "substitute a protected path, without its leading slash, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused to run), which outranks 0 curl -q -g -sSI --noproxy '*' "https://$1/" || # public HTTPS endpoint: must succeed without -k; if not, investigate the cert, chain, hostname, or your client trust store, never -k { echo "curl exit $?: the HTTPS request failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } # An unauthenticated request to a real protected path must be REJECTED and a valid credential ACCEPTED. --noproxy # disables YOUR client proxy, not a reverse proxy in front of the app, so if the app sits behind an auth proxy this # pair cannot tell whether the app or the proxy enforced it: confirm the layer by probing the app directly or in its logs. set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # no credentials: expect 401/403 case "$4:$5" in 0:401|0:403) ;; 0:2??) echo "FINDING: the protected path answered $5 with no credentials"; set -- "$1" "$2" 1 ;; 0:*) echo "HTTP $5 without credentials is neither the 401/403 pass nor a 2xx finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request without credentials failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -H @cred.txt -o /dev/null -w '%{http_code}\n' "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$5" # valid credential from a file (cred.txt holds a full header line, e.g. "Authorization: Bearer ..."): expect 2xx case "$4:$5" in 0:2??) ;; 0:*) echo "HTTP $5 with the credential is not the expected 2xx, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request with the credential failed (is cred.txt in this directory?); inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac if command -v ss >/dev/null; then ss -tlnp || # every listener: loopback for a same-host reverse proxy, or the platform's required address and port from section 1 on managed ingress { echo "ss -tlnp failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } else echo "ss is not installed here; not listing sockets" case "$3" in 0) set -- "$1" "$2" 2 ;; esac fi echo "block exit $3; the HTTPS headers and the listener list above are not classified, so judge them yourself: read every listener against section 1" exit "$3" ) ``` ## Sources (checked September 2026) - Python `http.server` (binds all interfaces by default, serves the current directory, follows symlinks, not for production) (Python 3.14.4): https://docs.python.org/3/library/http.server.html - Gunicorn documentation (settings reference: bind, certfile, keyfile, ca_certs): https://gunicorn.org/reference/settings/ - Uvicorn settings reference: https://github.com/Kludex/uvicorn/blob/5ac6265a01ff6dcadb0e4250152c3deaf8a168a9/docs/settings.md - Django deployment checklist: https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/ - argon2-cffi: https://argon2-cffi.readthedocs.io/en/stable/ - argon2-cffi PasswordHasher, hash and verify mismatch example (25.1.0, checked October 2026): https://github.com/hynek/argon2-cffi/blob/25.1.0/README.md#L11-L28 - Python bcrypt hashpw, gensalt and checkpw example (commit 2b4ba9ac84df972e8e81311d09559af8dc82ef33, checked October 2026): https://github.com/pyca/bcrypt/blob/2b4ba9ac84df972e8e81311d09559af8dc82ef33/README.rst#L62-L76 - Werkzeug serving (`ssl_context="adhoc"` requires cryptography): https://werkzeug.palletsprojects.com/en/stable/serving/ - FastAPI security reference: https://fastapi.tiangolo.com/reference/security/ ; `OpenIdConnect` source (stub warning): https://github.com/fastapi/fastapi/blob/31bbb380748ccead62fc0f42dbf4273f11dadccf/fastapi/security/open_id_connect_url.py - `http.server` binds with `bind=None` and `AI_PASSIVE`, the wildcard address (pinned tag v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/http/server.py#L1326-L1338 - `python -m http.server`'s `-b/--bind` has no default value, and its help says "default: all interfaces" (pinned tag v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/http/server.py#L1375-L1377 - Python 3.14 secrets.token_urlsafe (series documentation, checked October 2026): https://docs.python.org/3.14/library/secrets.html - CPython token_urlsafe input bytes and Base64 output (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Doc/library/secrets.rst#L91-L103 - CPython secrets default entropy and token_bytes fallback (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/secrets.py#L31-L45 - CPython secrets default may change in maintenance releases (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Doc/library/secrets.rst#L125-L130 - Python 3.14 os.getenv environment loading (series documentation, checked October 2026): https://docs.python.org/3.14/library/os.html#os.getenv - PyOTP one-time passwords and MFA (v2.9.0, checked October 2026): https://github.com/pyauth/pyotp/blob/v2.9.0/README.rst#L4-L5 - qrcode enrolment image generation (v8.2, checked October 2026): https://github.com/lincolnloop/python-qrcode/blob/v8.2/README.rst#L41-L44 - django-otp Django authentication integration (1.6.1, checked October 2026): https://pypi.org/project/django-otp/1.6.1/ - Requests REQUESTS_CA_BUNDLE trust roots (v2.32.3, checked October 2026): https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L238-L239 - slowapi Starlette/FastAPI scope (v0.1.10, checked October 2026): https://github.com/laurentS/slowapi/blob/v0.1.10/README.md#L1-L3 - slowapi limit decorator, endpoint request argument and WebSocket limitation (v0.1.10, checked October 2026): https://github.com/laurentS/slowapi/blob/v0.1.10/README.md#L24-L50 - HTTPX environment-variable default and trust_env (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/docs/environment_variables.md#L3-L7 - HTTPX environment guide variable list, which omits SSL_CERT_FILE (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/docs/environment_variables.md#L9-L53 - HTTPX SSL guide, verify=False example (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/docs/advanced/ssl.md#L12-L17 - HTTPX SSL guide, conflicting SSL_CERT_FILE statement (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/docs/advanced/ssl.md#L72-L86 - HTTPX SSL context selection with verify=True and trust_env=True (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/httpx/_config.py#L23-L40 - HTTPX sync transport passes verify and trust_env to create_ssl_context (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/httpx/_transports/default.py#L133-L154 - HTTPX async transport passes verify and trust_env to create_ssl_context (0.28.1, checked October 2026): https://github.com/encode/httpx/blob/0.28.1/httpx/_transports/default.py#L278-L298 - CPython ssl get_default_verify_paths (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L390-L405 - CPython ssl load_default_certs (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L542-L548 - CPython ssl Windows-only import of enum_certificates and enum_crls (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L257-L258 - CPython ssl Windows CA and ROOT store names (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L439-L439 - CPython ssl _load_windows_store_certs purpose filter and warnings (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L529-L540 - CPython ssl create_default_context conditional default-CA load (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L730-L736 - CPython ssl _create_unverified_context (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L744-L754 - CPython ssl documentation for default verify paths and OpenSSL environment keys (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L421-L433 - CPython ssl documentation for default CAs and silent failure (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L1650-L1657 - CPython ssl set_default_verify_paths calls OpenSSL SSL_CTX_set_default_verify_paths (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L4677-L4696 - CPython ssl default CA paths and environment-variable names from OpenSSL (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L5943-L5984 - CPython ssl Windows enhanced key usage trust value (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6089-L6142 - CPython ssl Windows system store locations collected read-only (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6144-L6191 - CPython ssl enum_certificates returns certificate, encoding and trust (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6193-L6291 - CPython ssl clinic enum_certificates and enum_crls method-table macros, defined only under _MSC_VER and empty otherwise (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/clinic/_ssl.c.h#L2811-L2980 - CPython ssl clinic header wrappers, including those for the CA-loading, default-path and system-store methods and functions, which delegate to the _ssl.c implementations (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/clinic/_ssl.c.h#L1-L2981 - CPython ssl Windows store helpers, enum_certificates and enum_crls compiled only under _MSC_VER (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6061-L6380 - CPython ssl module method table entries for enum_certificates and enum_crls (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6389-L6390 - CPython ssl include of _ssl/debughelpers.c (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L428-L428 - CPython ssl includes of _ssl/misc.c and _ssl/cert.c (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L1836-L1837 - CPython ssl include of _ssl/distrusted_spki.h (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3083-L3083 - CPython ssl _distrusted_spki list of 8 SHA-256 digests commented as compromised eMudhra intermediates (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/distrusted_spki.h#L23-L73 - CPython ssl distrusted_spki.h comment: last line of defence, and how to block another key (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/distrusted_spki.h#L1-L17 - CPython ssl SHA-256 of a certificate's SubjectPublicKeyInfo compared with the _distrusted_spki list (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3085-L3114 - CPython ssl verify callback rejecting a listed key with X509_V_ERR_CERT_REJECTED (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3116-L3141 - CPython ssl verify callback passed to SSL_CTX_set_verify (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3167-L3167 - CPython ssl _set_verify_mode passes the verify callback for every verify mode (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3143-L3169 - CPython ssl new client and server contexts call _set_verify_mode (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3285-L3298 - CPython ssl verify_mode setter calls _set_verify_mode (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3604-L3618 - CPython ssl check_hostname setter calls _set_verify_mode with CERT_REQUIRED when it turns on hostname checking for a CERT_NONE context (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3998-L4015 - CPython ssl documentation for CERT_NONE: validation errors do not abort a client socket's handshake, and a server does not request a client certificate (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L483-L492 - CPython ssl server post-handshake authentication passes the current verify callback back to SSL_set_verify (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L936-L954 - CPython ssl SSLCertVerificationError chosen for an OpenSSL certificate verify failure (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L723-L733 - CPython ssl verify_code and verify_message taken from OpenSSL's verify result (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L517-L553 - CPython ssl verify_code and verify_message set on SSLCertVerificationError (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L619-L626 - CPython ssl passes NULL, not a verify callback, to SSL_CTX_set_verify (v3.14.9, checked November 2026): https://github.com/python/cpython/blob/v3.14.9/Modules/_ssl.c#L3098-L3098 - CPython ssl _ssl/cert.c certificate objects, with no CA-loading, default-path or system-store code (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/cert.c#L1-L251 - CPython ssl _ssl/misc.c memory BIO conversion, with no CA-loading, default-path or system-store code (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/misc.c#L1-L34 - CPython ssl _ssl/debughelpers.c message and keylog callbacks, with no CA-loading, default-path or system-store code (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/debughelpers.c#L1-L240 - CPython Windows build OpenSSL source library openssl-3.5.9 in get_externals.bat (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/PCbuild/get_externals.bat#L57-L57 - CPython Windows build OpenSSL binaries openssl-bin-3.5.9 in get_externals.bat (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/PCbuild/get_externals.bat#L82-L82 - CPython macOS installer build OpenSSL 3.5.9 and its tarball URL (v3.14.10, checked November 2026): https://github.com/python/cpython/blob/v3.14.10/Mac/BuildScript/build-installer.py#L249-L252 - OpenSSL default CA file and directory environment overrides (openssl-4.0.3, checked October 2026): https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man3/SSL_CTX_load_verify_locations.pod#L39-L49 - OpenSSL security-sensitive environment variables excluded for set-user-ID or set-group-ID executables (openssl-4.0.3, checked October 2026): https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man7/openssl-env.pod#L9-L13 - OpenSSL SSL_CERT_FILE and SSL_CERT_DIR security-sensitive classification, with the openssl-rehash exception for SSL_CERT_DIR (openssl-4.0.3, checked October 2026): https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man7/openssl-env.pod#L289-L298 - OpenSSL default CA file and directory environment overrides (openssl-3.5.9, checked November 2026): https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man3/SSL_CTX_load_verify_locations.pod#L39-L49 - OpenSSL environment usually not consulted for set-user-ID or set-group-ID executables (openssl-3.5.9, checked November 2026): https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man7/openssl-env.pod#L9-L12 - OpenSSL SSL_CERT_DIR and SSL_CERT_FILE entry, with no security-sensitive designation (openssl-3.5.9, checked November 2026): https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man7/openssl-env.pod#L168-L173 - Requests verify=False disables certificate verification (v2.32.3, checked October 2026): https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L241-L243 - Render web-service listener and TLS termination (checked October 2026): https://render.com/docs/web-services - Heroku HTTP routing, including IPv6 unsupported in the Cedar generation (checked October 2026): https://devcenter.heroku.com/articles/http-routing - Heroku web-process port assignment (checked October 2026): https://devcenter.heroku.com/articles/runtime-principles - Heroku Node.js deployment, web process must bind to PORT or the dyno does not start (checked October 2026): https://devcenter.heroku.com/articles/deploying-nodejs - Heroku dynos, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/dynos - Heroku Procfile, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/procfile - Heroku Gunicorn deployment, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/python-gunicorn - Heroku Getting Started with Python, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/getting-started-with-python - Heroku Getting Started with Node.js, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/getting-started-with-nodejs - Heroku Node.js best practices, one of the pages behind this guide's Cedar listener assessment (checked October 2026): https://devcenter.heroku.com/articles/node-best-practices - Heroku Fir Private Spaces networking and listener recommendation (checked October 2026): https://devcenter.heroku.com/articles/generations. The article states: "Fir utilizes IPv6 as the default and recommended connection protocol for networking within Heroku Private Spaces." It warns: "If you designed your application, its dependencies, or connected services within a space assuming only IPv4 connectivity, they don’t bind properly on Fir." It recommends: "Ensure IPv6 compatibility by correctly binding to an IPv6 listener or the dual-stack address listener in your app’s language (typically ::) to leverage Fir’s networking configuration." This guide's assessment: these sentences concern networking within Heroku Private Spaces; they do not establish Cedar's listener address or a deployed application's listener configuration. - Fly TCP passthrough (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97 - Fly TLS termination (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176 - Fly public service binding (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42 - Fly IPv4 and IPv6 wildcard qualification (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92 - Railway target ports (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146 - Railway web-server listener and injected port (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51 - Railway edge-proxy TLS termination and internal forwarding (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - django: Django documentation (6.1) - python: Python http.server documentation (3.14.4) - python-pin: Python http.server source (v3.14.10) - stdlib-docs: Python standard library documentation (3.14) - cpython-ssl: CPython ssl source and documentation (v3.14.10) - cpython-ssl-3149: CPython ssl source (v3.14.9) - cpython-secrets: CPython secrets documentation and source (v3.14.10) - gunicorn: Gunicorn documentation (unknown) - uvicorn: Uvicorn source (5ac6265a01ff6dcadb0e4250152c3deaf8a168a9) - fastapi: FastAPI documentation (unknown) - oidc-stub: FastAPI OpenIdConnect source (31bbb380748ccead62fc0f42dbf4273f11dadccf) - bcrypt-api: Python bcrypt API example (2b4ba9ac84df972e8e81311d09559af8dc82ef33) - werkzeug: Werkzeug documentation (unknown) - argon: argon2-cffi documentation (unknown) - pyotp: PyOTP (v2.9.0) - qrcode: qrcode (v8.2) - django-otp: django-otp (1.6.1) - requests: Requests (v2.32.3) - slowapi: slowapi source (v0.1.10) - httpx: HTTPX source and documentation (0.28.1) - openssl: OpenSSL documentation (openssl-4.0.3) - openssl-359: OpenSSL documentation (openssl-3.5.9) - argon-api: argon2-cffi API example (25.1.0) - render: Render documentation (unknown) - heroku: Heroku documentation (unknown) - fly-pinned: Fly.io pinned documentation (2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9) - railway: Railway documentation (72354493198302677fb6be6c692dd75c64cae2c6) Claims (full source mapping in the JSON block below): - bind (REASONED; gunicorn, uvicorn, render, heroku, fly-pinned, railway): Guide advice: loopback behind same-host TLS proxies, no extra managed listeners, wildcard only for authenticated TLS ingress, direct or private; app auth required. Railway: 0.0.0.0 on injected PORT, edge TLS; Fly w/o handlers: TCP passthrough; Heroku web processes must bind PORT, IPv6/dual-stack advised for Fir Private Spaces; checked pages state no Cedar address (guide's view, not proof any address works). - flask-tls (REASONED; werkzeug): Flask development server binds 127.0.0.1:8443 with cert/key ssl_context; adhoc requires cryptography and remains development-only. - gunicorn-tls (REASONED; gunicorn): Gunicorn binds 0.0.0.0:8443 with --certfile and --keyfile for direct TLS. - uvicorn-tls (REASONED; uvicorn): Uvicorn binds 0.0.0.0:8443 with --ssl-certfile and --ssl-keyfile for direct TLS. - django-redirect (REASONED; django): Set SECURE_SSL_REDIRECT=True. - django-proxy (REASONED; django): Set SECURE_PROXY_SSL_HEADER only behind a trusted proxy that strips and replaces X-Forwarded-Proto; prevent direct bypass. - django-cookies (REASONED; django): Set SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE true. - django-hsts (REASONED; django): Begin HSTS at 3600 seconds and include-subdomains false; raise duration and scope only after valid HTTPS is confirmed. - deploy-check (REASONED; django): Run manage.py check --deploy and fix its findings. - password (REASONED; django, argon, argon-api, bcrypt-api): Keep Django's built-in hashing; do not replace it with custom code (guide advice). For Flask/FastAPI apps implementing local password accounts, this guide advises account storage and argon2-cffi or bcrypt under authentication.md's constraints; argon2-cffi 25.1.0's PasswordHasher.verify raises VerifyMismatchError on mismatch, and these APIs do not establish framework user-store capabilities. - secrets (REASONED; django, stdlib-docs, cpython-secrets): Generate secrets.token_urlsafe(32) and load secrets from the environment (guide advice); 32 counts random input bytes and is this guide's example choice, not a universal size. CPython v3.14.10 uses DEFAULT_ENTROPY (32 bytes) when omitted or None, but that default may change, including in maintenance releases. - credential-extraction (REASONED; fastapi): FastAPI security helpers extract credentials and declare schemes; presence/scheme checks do not verify signature, issuer, audience or expiry. - oidc (REASONED; fastapi, oidc-stub): OpenIdConnect is a stub and does not use its discovery URL; validate extracted tokens with an OIDC library and authorize separately. - login-limit (REASONED; fastapi, slowapi): Rate-limiting login routes and a proxy-level limit are this guide's advice. slowapi v0.1.10 documents limits for Starlette/FastAPI HTTP endpoints; endpoints using its limit decorator must take an explicit request argument, and WebSocket endpoints are not supported. - mfa (REASONED; django, pyotp, qrcode, django-otp): Use pyotp and qrcode, django-otp for Django, or linked MFA guidance. - client-validation (REASONED; python, requests, httpx, cpython-ssl): Never ship requests/httpx verify=False or ssl._create_unverified_context; HTTPX documents verify=False and CPython defines _create_unverified_context. The prohibition is this guide's advice. - client-ca (REASONED; cpython-ssl, cpython-ssl-3149, openssl, openssl-359): ssl gives OpenSSL default paths/env vars; verifying create_default_context w/o CA tries defaults (may fail silently), after _MSC_VER win32 CA/ROOT stores; ssl.py, _ssl.c, clinic header, 3 read _ssl/ includes lack macOS store code. 3.14.10: 8 listed eMudhra CA keys fail cert verification, any CA source. SSL_CERT_FILE/DIR use inferred per cited OpenSSL; linked OpenSSL, stores, deployment unverified. - client-ca-httpx (REASONED; httpx): HTTPX 0.28.1's transport passes verify and trust_env to create_ssl_context, which reads SSL_CERT_FILE when verify=True and trust_env=True; its SSL guide contradicts that source. Its environment guide says environment variables are used by default but does not list SSL_CERT_FILE. - client-ca-advice (REASONED; requests, httpx, cpython-ssl, openssl, openssl-359): REQUESTS_CA_BUNDLE selects a requests CA. openssl-4.0.3 honours SSL_CERT_FILE/SSL_CERT_DIR for its default CA file/directory except in set-user-ID/set-group-ID executables (openssl-3.5.9 docs: whole environment usually not consulted). This guide's advice: use the exports for requests, the stated HTTPX path and CPython's qualified default-path call, or an explicit context for other configurations. - http-server-bind (REASONED; python-pin): As of Python 3.14.10, http.server binds every interface by default; explicitly bind 127.0.0.1:8000. - http-server-files (REASONED; python): http.server serves the current directory and follows symlinks outside it; it is unsuitable for production. Share only a non-private directory through an SSH tunnel. - verify-tls (REASONED; gunicorn, uvicorn): Public HTTPS must succeed without -k; certificate, chain, hostname and trust failures need investigation. - verify-auth (REASONED; django, fastapi): A real protected route denies no credentials with 401/403 and accepts valid file-supplied credentials with 2xx; logs or direct probing distinguish app from proxy enforcement. - verify-bind (REASONED; gunicorn, uvicorn): Inspect every listener for loopback behind a same-host proxy or the required managed-ingress address and port. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "32048bf47a61a7c8b01bac34c0340deac63450b4ec8c9ec1af63b2a43d00b065", "components": { "django": { "name": "Django documentation", "basis": "6.1", "sources": { "s7289e1c111ae": "https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/" } }, "python": { "name": "Python http.server documentation", "basis": "3.14.4", "sources": { "s8fae94406709": "https://docs.python.org/3/library/http.server.html" } }, "python-pin": { "name": "Python http.server source", "basis": "v3.14.10", "sources": { "s4f2ce0f2d249": "https://github.com/python/cpython/blob/v3.14.10/Lib/http/server.py#L1326-L1338", "sff1a6765fff4": "https://github.com/python/cpython/blob/v3.14.10/Lib/http/server.py#L1375-L1377" } }, "stdlib-docs": { "name": "Python standard library documentation", "basis": "3.14", "sources": { "s971d128efc65": "https://docs.python.org/3.14/library/secrets.html", "sdad376a711c3": "https://docs.python.org/3.14/library/os.html#os.getenv" } }, "cpython-ssl": { "name": "CPython ssl source and documentation", "basis": "v3.14.10", "sources": { "s75d453b1f46c": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L390-L405", "s3a116911a8c3": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L542-L548", "se68659ffc532": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L730-L736", "sf3f2da11f578": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L744-L754", "s991f2eca1452": "https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L421-L433", "sb2149c4d150d": "https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L1650-L1657", "s582b7959104d": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L4677-L4696", "s698db797c917": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L5943-L5984", "s4ce22e36a10d": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L257-L258", "s2fb5328e9280": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L439-L439", "sf4ca40d7d2c7": "https://github.com/python/cpython/blob/v3.14.10/Lib/ssl.py#L529-L540", "s3090ebe73d48": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6089-L6142", "s5b3f1f7bb55a": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6144-L6191", "sa67df222bc40": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6193-L6291", "s403133e21b82": "https://github.com/python/cpython/blob/v3.14.10/Modules/clinic/_ssl.c.h#L2811-L2980", "s15a452345fdf": "https://github.com/python/cpython/blob/v3.14.10/Modules/clinic/_ssl.c.h#L1-L2981", "s67ad0451c2e4": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6061-L6380", "s5c1119a02258": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L6389-L6390", "s6321f4512a60": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L428-L428", "s1c0c355f821f": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L1836-L1837", "sa9ca6235a1a9": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3083-L3083", "s5e29fb3c3ce9": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3085-L3114", "s57ce05cdea81": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3116-L3141", "sf01a7ef966ec": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3167-L3167", "s53e0dbce9da5": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/distrusted_spki.h#L23-L73", "s69dc2acbb937": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/distrusted_spki.h#L1-L17", "s9ae41359aa34": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3143-L3169", "s5edff147f433": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3285-L3298", "s682c08f22389": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3604-L3618", "s6d08dc565fc8": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L3998-L4015", "sadefae68ec7b": "https://github.com/python/cpython/blob/v3.14.10/Doc/library/ssl.rst#L483-L492", "s1d76d4454ec2": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L936-L954", "s391cda9a0090": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L723-L733", "sd1770e02b78d": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L517-L553", "sc6f21a0db06f": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl.c#L619-L626", "s38ff06b93c8e": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/cert.c#L1-L251", "s797ee692f3e3": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/misc.c#L1-L34", "s4fa35850ded9": "https://github.com/python/cpython/blob/v3.14.10/Modules/_ssl/debughelpers.c#L1-L240", "s394797cf1988": "https://github.com/python/cpython/blob/v3.14.10/PCbuild/get_externals.bat#L57-L57", "sa129690241f0": "https://github.com/python/cpython/blob/v3.14.10/PCbuild/get_externals.bat#L82-L82", "s6ca732f7e53d": "https://github.com/python/cpython/blob/v3.14.10/Mac/BuildScript/build-installer.py#L249-L252" } }, "cpython-ssl-3149": { "name": "CPython ssl source", "basis": "v3.14.9", "sources": { "s65ba2864e2d4": "https://github.com/python/cpython/blob/v3.14.9/Modules/_ssl.c#L3098-L3098" } }, "cpython-secrets": { "name": "CPython secrets documentation and source", "basis": "v3.14.10", "sources": { "s416f4713b5f7": "https://github.com/python/cpython/blob/v3.14.10/Doc/library/secrets.rst#L91-L103", "sfb3ebebb7112": "https://github.com/python/cpython/blob/v3.14.10/Lib/secrets.py#L31-L45", "sfd1f5e77a0e1": "https://github.com/python/cpython/blob/v3.14.10/Doc/library/secrets.rst#L125-L130" } }, "gunicorn": { "name": "Gunicorn documentation", "basis": "unknown", "sources": { "s72daf362300c": "https://gunicorn.org/reference/settings/" } }, "uvicorn": { "name": "Uvicorn source", "basis": "5ac6265a01ff6dcadb0e4250152c3deaf8a168a9", "sources": { "s02429a4a09d9": "https://github.com/Kludex/uvicorn/blob/5ac6265a01ff6dcadb0e4250152c3deaf8a168a9/docs/settings.md" } }, "fastapi": { "name": "FastAPI documentation", "basis": "unknown", "sources": { "s077aff608a5c": "https://fastapi.tiangolo.com/reference/security/" } }, "oidc-stub": { "name": "FastAPI OpenIdConnect source", "basis": "31bbb380748ccead62fc0f42dbf4273f11dadccf", "sources": { "s876e3d51e7ac": "https://github.com/fastapi/fastapi/blob/31bbb380748ccead62fc0f42dbf4273f11dadccf/fastapi/security/open_id_connect_url.py" } }, "bcrypt-api": { "name": "Python bcrypt API example", "basis": "2b4ba9ac84df972e8e81311d09559af8dc82ef33", "sources": { "s340a2923b6da": "https://github.com/pyca/bcrypt/blob/2b4ba9ac84df972e8e81311d09559af8dc82ef33/README.rst#L62-L76" } }, "werkzeug": { "name": "Werkzeug documentation", "basis": "unknown", "sources": { "sd5066c63a3ca": "https://werkzeug.palletsprojects.com/en/stable/serving/" } }, "argon": { "name": "argon2-cffi documentation", "basis": "unknown", "sources": { "s685ac81e6157": "https://argon2-cffi.readthedocs.io/en/stable/" } }, "pyotp": { "name": "PyOTP", "basis": "v2.9.0", "sources": { "sc118b8ed3bdd": "https://github.com/pyauth/pyotp/blob/v2.9.0/README.rst#L4-L5" } }, "qrcode": { "name": "qrcode", "basis": "v8.2", "sources": { "s8247ddfed3d3": "https://github.com/lincolnloop/python-qrcode/blob/v8.2/README.rst#L41-L44" } }, "django-otp": { "name": "django-otp", "basis": "1.6.1", "sources": { "sa5c0f7097219": "https://pypi.org/project/django-otp/1.6.1/" } }, "requests": { "name": "Requests", "basis": "v2.32.3", "sources": { "s21c6be6f6df4": "https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L238-L239", "s17c5a0a7ffb2": "https://github.com/psf/requests/blob/v2.32.3/docs/user/advanced.rst#L241-L243" } }, "slowapi": { "name": "slowapi source", "basis": "v0.1.10", "sources": { "sf150aa58aa31": "https://github.com/laurentS/slowapi/blob/v0.1.10/README.md#L1-L3", "s4ca3e1e80817": "https://github.com/laurentS/slowapi/blob/v0.1.10/README.md#L24-L50" } }, "httpx": { "name": "HTTPX source and documentation", "basis": "0.28.1", "sources": { "s52608d6c5e47": "https://github.com/encode/httpx/blob/0.28.1/docs/environment_variables.md#L3-L7", "sd33b79237122": "https://github.com/encode/httpx/blob/0.28.1/docs/environment_variables.md#L9-L53", "s928d85a8d13e": "https://github.com/encode/httpx/blob/0.28.1/docs/advanced/ssl.md#L12-L17", "s3734fbccdf40": "https://github.com/encode/httpx/blob/0.28.1/docs/advanced/ssl.md#L72-L86", "s30dd14bf172e": "https://github.com/encode/httpx/blob/0.28.1/httpx/_config.py#L23-L40", "s4a1f3b43f184": "https://github.com/encode/httpx/blob/0.28.1/httpx/_transports/default.py#L133-L154", "sc5a61a9b2f64": "https://github.com/encode/httpx/blob/0.28.1/httpx/_transports/default.py#L278-L298" } }, "openssl": { "name": "OpenSSL documentation", "basis": "openssl-4.0.3", "sources": { "s592eb65718de": "https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man3/SSL_CTX_load_verify_locations.pod#L39-L49", "sfd19784d8a21": "https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man7/openssl-env.pod#L9-L13", "s42c9a65f2ad7": "https://github.com/openssl/openssl/blob/openssl-4.0.3/doc/man7/openssl-env.pod#L289-L298" } }, "openssl-359": { "name": "OpenSSL documentation", "basis": "openssl-3.5.9", "sources": { "s5408e859b283": "https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man3/SSL_CTX_load_verify_locations.pod#L39-L49", "sa9824d577783": "https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man7/openssl-env.pod#L9-L12", "scfb918b988fe": "https://github.com/openssl/openssl/blob/openssl-3.5.9/doc/man7/openssl-env.pod#L168-L173" } }, "argon-api": { "name": "argon2-cffi API example", "basis": "25.1.0", "sources": { "s193f86db0a79": "https://github.com/hynek/argon2-cffi/blob/25.1.0/README.md#L11-L28" } }, "render": { "name": "Render documentation", "basis": "unknown", "sources": { "s1820ac471b6c": "https://render.com/docs/web-services" } }, "heroku": { "name": "Heroku documentation", "basis": "unknown", "sources": { "s370d4beec29a": "https://devcenter.heroku.com/articles/runtime-principles", "s211f5fe76042": "https://devcenter.heroku.com/articles/generations", "s0c5bf300cef4": "https://devcenter.heroku.com/articles/http-routing", "s110a083f7129": "https://devcenter.heroku.com/articles/deploying-nodejs", "s41677c652b96": "https://devcenter.heroku.com/articles/dynos", "s9f4dec283014": "https://devcenter.heroku.com/articles/procfile", "s8a2465cbd6c2": "https://devcenter.heroku.com/articles/python-gunicorn", "sb941efbc6dd5": "https://devcenter.heroku.com/articles/getting-started-with-python", "sa2690945ce1c": "https://devcenter.heroku.com/articles/getting-started-with-nodejs", "s3563a06d3934": "https://devcenter.heroku.com/articles/node-best-practices" } }, "fly-pinned": { "name": "Fly.io pinned documentation", "basis": "2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9", "sources": { "s62fec91543a1": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42", "s61c1e50d2c11": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92", "s254c8c8a5edd": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97", "s32999aa4c58c": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176" } }, "railway": { "name": "Railway documentation", "basis": "72354493198302677fb6be6c692dd75c64cae2c6", "sources": { "sac1383056b65": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146", "s0462eef65b1b": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51", "sa9d326046a7f": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43" } } }, "claims": { "bind": {"text": "Guide advice: loopback behind same-host TLS proxies, no extra managed listeners, wildcard only for authenticated TLS ingress, direct or private; app auth required. Railway: 0.0.0.0 on injected PORT, edge TLS; Fly w/o handlers: TCP passthrough; Heroku web processes must bind PORT, IPv6/dual-stack advised for Fir Private Spaces; checked pages state no Cedar address (guide's view, not proof any address works).", "components": ["gunicorn", "uvicorn", "render", "heroku", "fly-pinned", "railway"], "sources": ["gunicorn:s72daf362300c", "uvicorn:s02429a4a09d9", "render:s1820ac471b6c", "heroku:s370d4beec29a", "heroku:s211f5fe76042", "heroku:s0c5bf300cef4", "heroku:s110a083f7129", "heroku:s41677c652b96", "heroku:s9f4dec283014", "heroku:s8a2465cbd6c2", "heroku:sb941efbc6dd5", "heroku:sa2690945ce1c", "heroku:s3563a06d3934", "fly-pinned:s62fec91543a1", "fly-pinned:s61c1e50d2c11", "fly-pinned:s254c8c8a5edd", "fly-pinned:s32999aa4c58c", "railway:sac1383056b65", "railway:s0462eef65b1b", "railway:sa9d326046a7f"], "status": "REASONED"}, "flask-tls": {"text": "Flask development server binds 127.0.0.1:8443 with cert/key ssl_context; adhoc requires cryptography and remains development-only.", "components": ["werkzeug"], "sources": ["werkzeug:sd5066c63a3ca"], "status": "REASONED"}, "gunicorn-tls": {"text": "Gunicorn binds 0.0.0.0:8443 with --certfile and --keyfile for direct TLS.", "components": ["gunicorn"], "sources": ["gunicorn:s72daf362300c"], "status": "REASONED"}, "uvicorn-tls": {"text": "Uvicorn binds 0.0.0.0:8443 with --ssl-certfile and --ssl-keyfile for direct TLS.", "components": ["uvicorn"], "sources": ["uvicorn:s02429a4a09d9"], "status": "REASONED"}, "django-redirect": {"text": "Set SECURE_SSL_REDIRECT=True.", "components": ["django"], "sources": ["django:s7289e1c111ae"], "status": "REASONED"}, "django-proxy": {"text": "Set SECURE_PROXY_SSL_HEADER only behind a trusted proxy that strips and replaces X-Forwarded-Proto; prevent direct bypass.", "components": ["django"], "sources": ["django:s7289e1c111ae"], "status": "REASONED"}, "django-cookies": {"text": "Set SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE true.", "components": ["django"], "sources": ["django:s7289e1c111ae"], "status": "REASONED"}, "django-hsts": {"text": "Begin HSTS at 3600 seconds and include-subdomains false; raise duration and scope only after valid HTTPS is confirmed.", "components": ["django"], "sources": ["django:s7289e1c111ae"], "status": "REASONED"}, "deploy-check": {"text": "Run manage.py check --deploy and fix its findings.", "components": ["django"], "sources": ["django:s7289e1c111ae"], "status": "REASONED"}, "password": {"text": "Keep Django's built-in hashing; do not replace it with custom code (guide advice). For Flask/FastAPI apps implementing local password accounts, this guide advises account storage and argon2-cffi or bcrypt under authentication.md's constraints; argon2-cffi 25.1.0's PasswordHasher.verify raises VerifyMismatchError on mismatch, and these APIs do not establish framework user-store capabilities.", "components": ["django", "argon", "argon-api", "bcrypt-api"], "sources": ["django:s7289e1c111ae", "argon:s685ac81e6157", "argon-api:s193f86db0a79", "bcrypt-api:s340a2923b6da"], "status": "REASONED"}, "secrets": {"text": "Generate secrets.token_urlsafe(32) and load secrets from the environment (guide advice); 32 counts random input bytes and is this guide's example choice, not a universal size. CPython v3.14.10 uses DEFAULT_ENTROPY (32 bytes) when omitted or None, but that default may change, including in maintenance releases.", "components": ["django", "stdlib-docs", "cpython-secrets"], "sources": ["django:s7289e1c111ae", "stdlib-docs:s971d128efc65", "stdlib-docs:sdad376a711c3", "cpython-secrets:s416f4713b5f7", "cpython-secrets:sfb3ebebb7112", "cpython-secrets:sfd1f5e77a0e1"], "status": "REASONED"}, "credential-extraction": {"text": "FastAPI security helpers extract credentials and declare schemes; presence/scheme checks do not verify signature, issuer, audience or expiry.", "components": ["fastapi"], "sources": ["fastapi:s077aff608a5c"], "status": "REASONED"}, "oidc": {"text": "OpenIdConnect is a stub and does not use its discovery URL; validate extracted tokens with an OIDC library and authorize separately.", "components": ["fastapi", "oidc-stub"], "sources": ["fastapi:s077aff608a5c", "oidc-stub:s876e3d51e7ac"], "status": "REASONED"}, "login-limit": {"text": "Rate-limiting login routes and a proxy-level limit are this guide's advice. slowapi v0.1.10 documents limits for Starlette/FastAPI HTTP endpoints; endpoints using its limit decorator must take an explicit request argument, and WebSocket endpoints are not supported.", "components": ["fastapi", "slowapi"], "sources": ["fastapi:s077aff608a5c", "slowapi:sf150aa58aa31", "slowapi:s4ca3e1e80817"], "status": "REASONED"}, "mfa": {"text": "Use pyotp and qrcode, django-otp for Django, or linked MFA guidance.", "components": ["django", "pyotp", "qrcode", "django-otp"], "sources": ["django:s7289e1c111ae", "pyotp:sc118b8ed3bdd", "qrcode:s8247ddfed3d3", "django-otp:sa5c0f7097219"], "status": "REASONED"}, "client-validation": {"text": "Never ship requests/httpx verify=False or ssl._create_unverified_context; HTTPX documents verify=False and CPython defines _create_unverified_context. The prohibition is this guide's advice.", "components": ["python", "requests", "httpx", "cpython-ssl"], "sources": ["python:s8fae94406709", "requests:s17c5a0a7ffb2", "httpx:s928d85a8d13e", "cpython-ssl:sf3f2da11f578"], "status": "REASONED"}, "client-ca": {"text": "ssl gives OpenSSL default paths/env vars; verifying create_default_context w/o CA tries defaults (may fail silently), after _MSC_VER win32 CA/ROOT stores; ssl.py, _ssl.c, clinic header, 3 read _ssl/ includes lack macOS store code. 3.14.10: 8 listed eMudhra CA keys fail cert verification, any CA source. SSL_CERT_FILE/DIR use inferred per cited OpenSSL; linked OpenSSL, stores, deployment unverified.", "components": ["cpython-ssl", "cpython-ssl-3149", "openssl", "openssl-359"], "sources": ["cpython-ssl:s75d453b1f46c", "cpython-ssl:s3a116911a8c3", "cpython-ssl:se68659ffc532", "cpython-ssl:s991f2eca1452", "cpython-ssl:sb2149c4d150d", "cpython-ssl:s582b7959104d", "cpython-ssl:s698db797c917", "cpython-ssl:s4ce22e36a10d", "cpython-ssl:s2fb5328e9280", "cpython-ssl:sf4ca40d7d2c7", "cpython-ssl:s3090ebe73d48", "cpython-ssl:s5b3f1f7bb55a", "cpython-ssl:sa67df222bc40", "cpython-ssl:s403133e21b82", "cpython-ssl:s15a452345fdf", "cpython-ssl:s67ad0451c2e4", "cpython-ssl:s5c1119a02258", "cpython-ssl:s6321f4512a60", "cpython-ssl:s1c0c355f821f", "cpython-ssl:sa9ca6235a1a9", "cpython-ssl:s5e29fb3c3ce9", "cpython-ssl:s57ce05cdea81", "cpython-ssl:sf01a7ef966ec", "cpython-ssl:s53e0dbce9da5", "cpython-ssl:s69dc2acbb937", "cpython-ssl:s9ae41359aa34", "cpython-ssl:s5edff147f433", "cpython-ssl:s682c08f22389", "cpython-ssl:s6d08dc565fc8", "cpython-ssl:sadefae68ec7b", "cpython-ssl:s1d76d4454ec2", "cpython-ssl:s391cda9a0090", "cpython-ssl:sd1770e02b78d", "cpython-ssl:sc6f21a0db06f", "cpython-ssl-3149:s65ba2864e2d4", "cpython-ssl:s38ff06b93c8e", "cpython-ssl:s797ee692f3e3", "cpython-ssl:s4fa35850ded9", "cpython-ssl:s394797cf1988", "cpython-ssl:sa129690241f0", "cpython-ssl:s6ca732f7e53d", "openssl:s592eb65718de", "openssl:sfd19784d8a21", "openssl:s42c9a65f2ad7", "openssl-359:s5408e859b283", "openssl-359:sa9824d577783", "openssl-359:scfb918b988fe"], "status": "REASONED"}, "client-ca-httpx": {"text": "HTTPX 0.28.1's transport passes verify and trust_env to create_ssl_context, which reads SSL_CERT_FILE when verify=True and trust_env=True; its SSL guide contradicts that source. Its environment guide says environment variables are used by default but does not list SSL_CERT_FILE.", "components": ["httpx"], "sources": ["httpx:s52608d6c5e47", "httpx:sd33b79237122", "httpx:s3734fbccdf40", "httpx:s30dd14bf172e", "httpx:s4a1f3b43f184", "httpx:sc5a61a9b2f64"], "status": "REASONED"}, "client-ca-advice": {"text": "REQUESTS_CA_BUNDLE selects a requests CA. openssl-4.0.3 honours SSL_CERT_FILE/SSL_CERT_DIR for its default CA file/directory except in set-user-ID/set-group-ID executables (openssl-3.5.9 docs: whole environment usually not consulted). This guide's advice: use the exports for requests, the stated HTTPX path and CPython's qualified default-path call, or an explicit context for other configurations.", "components": ["requests", "httpx", "cpython-ssl", "openssl", "openssl-359"], "sources": ["requests:s21c6be6f6df4", "httpx:s3734fbccdf40", "httpx:s30dd14bf172e", "httpx:s4a1f3b43f184", "httpx:sc5a61a9b2f64", "cpython-ssl:s3a116911a8c3", "cpython-ssl:se68659ffc532", "cpython-ssl:s582b7959104d", "openssl:s592eb65718de", "openssl:sfd19784d8a21", "openssl:s42c9a65f2ad7", "openssl-359:s5408e859b283", "openssl-359:sa9824d577783"], "status": "REASONED"}, "http-server-bind": {"text": "As of Python 3.14.10, http.server binds every interface by default; explicitly bind 127.0.0.1:8000.", "components": ["python-pin"], "sources": ["python-pin:s4f2ce0f2d249", "python-pin:sff1a6765fff4"], "status": "REASONED"}, "http-server-files": {"text": "http.server serves the current directory and follows symlinks outside it; it is unsuitable for production. Share only a non-private directory through an SSH tunnel.", "components": ["python"], "sources": ["python:s8fae94406709"], "status": "REASONED"}, "verify-tls": {"text": "Public HTTPS must succeed without -k; certificate, chain, hostname and trust failures need investigation.", "components": ["gunicorn", "uvicorn"], "sources": ["gunicorn:s72daf362300c", "uvicorn:s02429a4a09d9"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "A real protected route denies no credentials with 401/403 and accepts valid file-supplied credentials with 2xx; logs or direct probing distinguish app from proxy enforcement.", "components": ["django", "fastapi"], "sources": ["django:s7289e1c111ae", "fastapi:s077aff608a5c"], "status": "REASONED", "verify": [1]}, "verify-bind": {"text": "Inspect every listener for loopback behind a same-host proxy or the required managed-ingress address and port.", "components": ["gunicorn", "uvicorn"], "sources": ["gunicorn:s72daf362300c", "uvicorn:s02429a4a09d9"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> docker.md ====================================================================== # Docker and Compose: exposure, TLS, and authentication Docker runs apps in containers, packages bundling an app with what it needs, and Compose starts several containers from one file. This guide helps your AI assistant configure a deployment securely, including only the TLS proxy public, every other port kept private, and secrets kept out of images. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/docker.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Docker Engine minimum boundary 28.0, Moby Docker service unit v28.3.3, Docker firewalld reload fix 28.3.3, systemd unit ordering v262, Git ignore rules v2.49.0, PostgreSQL 17, Caddy v2.11.4, and nginx authentication source release-1.26.3, plus 3 more recorded bases; 8 further sources are pinned to a commit and 8 carry no recorded version. Metadata reviewed 2026-10-19; documentation checked September 2026. The guide records 27 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Containers are where accidental exposure happens most. Two Docker behaviours cause it: 1. `ports: - "3000:3000"` (or `-p 3000:3000`) publishes on every host address, `0.0.0.0` and `[::]`, at the time of writing. 2. On Linux, Docker programs iptables/nftables directly, so published ports are reachable **even when UFW or firewalld says the port is blocked**. A `ufw deny 3000` rule does not protect a published container port. ## 1. Publish nothing except the TLS proxy Bind anything that must be reachable from the host to loopback, and give everything else no `ports:` entry at all; containers on the same Compose network reach each other by service name without published ports. ```yaml services: app: build: . # no ports: entry; only the proxy is published db: image: postgres:17 # no ports: entry; the app reaches it at db:5432 on the internal network ``` Where a host-published port is genuinely needed for local access: ```yaml ports: - "127.0.0.1:3000:3000" ``` That host-IP restriction requires Docker Engine 28.0 or later, with the firewalld-reload caveat below. Before 28.0, under the default bridge configuration, a neighbour on the same layer-2 segment could reach a port mapped to a loopback address, a remote host could reach a container on a published port despite the host-IP binding, and an unpublished container port was reachable by routing directly to the container if the host's forwarding policy allowed it; 28.0 fixed all three. Check the running Engine version (`docker version --format '{{.Server.Version}}'`, not the client's). The instruction for an older engine is to treat the address in a publish string as a convenience, not a boundary, and to restrict the port in the `DOCKER-USER` chain below or upstream of the host (a cloud security group or a network ACL), since a host firewall like UFW or firewalld does not reach a published port (see the top of this guide). On Linux, when Docker Engine runs in the host's network namespace with firewalld running, Engine 28.2.x and 28.3.0 through 28.3.2 failed to restore container-address filtering rules after a firewalld reload. A remote host with a route to the bridge network could then reach published ports at container addresses, including loopback-only publications; unpublished ports remained filtered. Where firewalld is used, run Engine 28.3.3 or later, which fixes this regression (CVE-2025-54388, GHSA-x4rx-4gw3-53p4). Rootless Mode and Docker Desktop are unaffected by this regression. As this guide's assessment, where a port must be published on a routable interface, because other hosts need it but the whole internet does not, the most reliable restriction is upstream of the host, in a cloud security group or a network ACL: it does not depend on Docker's firewall backend, its userland proxy, or the host's boot order. See [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) for sourced AWS security-group, Google Cloud VPC firewall and Azure NSG rules; network-ACL configuration is not directly sourced here. On the host itself, filter it in the `DOCKER-USER` iptables chain (Docker's iptables backend only; its nftables backend has no such chain), whose rules Docker evaluates before its own accept rules. Match only new connections, so the rule does not also drop the replies to connections your own containers open, which arrive on the same interface and would otherwise lose their outbound access: ```bash # IPv4, run as root; replace ext_if with your external interface and the subnet with your own iptables -I DOCKER-USER -i ext_if -m conntrack --ctstate NEW ! -s 192.0.2.0/24 -j DROP ``` This runs after destination NAT, so an ordinary destination match sees a container's internal IP and port, not the published host IP or port; it also covers every published port arriving on that interface, so scope it to one service with conntrack's `--ctorigdstport` and `--ctdir ORIGINAL` if you need to. As this guide's reasoning, `iptables` covers IPv4 only. Cover native IPv6 forwarding with an equivalent `ip6tables` `DOCKER-USER` rule, but note a second path: publishing without a host IP on an IPv4-only bridge with `--userland-proxy=true` (the default) also makes the port answer on the host's IPv6 addresses through Docker's userland proxy (it maps IPv6 to the container's IPv4). As this guide's reasoning, that connection terminates on a host process, so it traverses the host `INPUT` chain, not `FORWARD`, and a `DOCKER-USER` rule (which lives in `FORWARD`) never sees it. Close that path by publishing to a specific address (`-p 127.0.0.1:3000:3000` or a chosen IPv4), disabling the userland proxy, or restricting the host's IPv6 `INPUT`. Probe IPv4 and IPv6 separately, each from an allowed and a disallowed source. Confirm with `sudo iptables -nvL DOCKER-USER`, watching its counters, and by probing the port from a host outside the allowed subnet, expecting a timeout, and one inside it, expecting a connection. As this guide's assessment, the rule does not survive a reboot and is not in place while the host boots; persist just the DOCKER-USER rules with a startup unit ordered after the Docker service, not a blanket `iptables-persistent` save (which, as this guide's assessment, also captures Docker's own dynamic chains and can break container networking on the next boot). The instruction where the boot-time window matters is to rely on the upstream control. ## 2. Terminate TLS in one proxy container Caddy is the least configuration ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)); nginx ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) and Traefik ([traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md)) work the same way. A complete pattern: ```yaml services: app: build: . caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config volumes: caddy_data: caddy_config: ``` `Caddyfile`: ```caddyfile app.example.com { reverse_proxy app:3000 } ``` Caddy obtains and renews the certificate automatically ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) explains the ACME requirements). As this guide's recommendation, hosts with no inbound ports but a domain you can put on Cloudflare should use [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md); run the `cloudflared` connector as a container on the app's Compose network and point it at `http://app:3000`. As this guide's recommendation, hosts with no domain at all should use [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) (Serve for private tailnet access or Funnel for public access), or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) for internal use. Docker documents service-name lookup on the shared Compose network; the `http://app:3000` origin is this guide's inference from that lookup and [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)'s HTTP origin example, while the fetched Cloudflare origin parameters show an HTTPS service URL rather than this literal HTTP value. This guide does not show the tunnel-token setup; [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) gives the protected `--token-file` path. ## 3. Authentication and secrets - As this guide's assessment, the proxy is the natural place for a first authentication gate (basic auth per the proxy guides, or Cloudflare Access); the application still needs its own login for anything multi-user ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). The proxy is also where MFA attaches for human-facing services ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)): Cloudflare Access policies can require a second factor, and an Authelia portal can handle authentication behind Caddy `forward_auth`, nginx `auth_request` or Traefik `forwardAuth`. Require MFA in the governing policy or portal; as this guide's assessment, basic auth alone is single-factor. - As this guide's advice, pass secrets at runtime through environment files or Docker/Compose secrets. Docker's Compose documentation advises against using environment variables to pass sensitive information such as passwords into containers and recommends secrets instead, noting that environment variables are often available to all processes, that access to them can be hard to track, and that they can be printed in logs when debugging errors. The same page's `env_file` section also describes a separate environment file as a more organized and secure way to manage sensitive information, because the file need not sit in the project root. As this guide's assessment, environment files are the weaker of the two runtime methods named here. Compose mounts each secret as a file under `/run/secrets/`, named after the secret, supports secrets on Linux containers only, and grants access per service. Docker Swarm secrets are available only to swarm services, not to standalone containers; Docker documents them as encrypted in transit and at rest in the swarm, accessible only to services granted explicit access while their tasks run, and, in Linux containers, mounted decrypted in an in-memory filesystem, by default under `/run/secrets/`. Docker also documents that a container's environment is not set until the service configuration has an explicit entry for it, such as the `environment` or `env_file` attribute. Docker also documents that the `.env` file in the project directory, the default method for setting variables, defines variables for interpolation in the Compose file, which an `environment` entry such as `DEBUG=${DEBUG}` can then pass into the container. As this guide's reasoning about Compose configuration, defining a value in `.env` does not by itself put it in the container's environment. The cited examples pass values through an `environment` entry or load an environment file through an `env_file` entry; interpolation can also place values elsewhere in the Compose file, such as an image tag. Never bake secrets into the image. Never put secrets in Dockerfile `ENV` or `ARG`: Docker documents that both are exposed in the final image; `ENV` also persists in containers from the image, and build arguments may persist in image metadata, provenance attestations and image history. As this guide's assessment, baking a key into an image ships it to every registry the image touches. `docker image history` shows the image's history, including creation commands; as this guide's assessment, the cited reference does not establish that every argument value is shown. For build-time secrets, use a temporary secret mount for the build instruction and do not copy or print the secret into the image. - As this guide's advice, keep `.env` in `.gitignore` and untrack it if already tracked (Git ignores matching untracked files, while already tracked files are unaffected), and run containers as a non-root user (`USER` in the Dockerfile); as this guide's assessment, this means a compromised app is not root in the container. As this guide's advice, non-root is the start, not the whole of workload hardening: dropping capabilities (`cap_drop`), `--security-opt no-new-privileges` (or Compose `security_opt`), a read-only root filesystem (Compose `read_only`), and never mounting the Docker socket are in [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md). Docker documents these option names, that only trusted users should control the daemon, and that Unix permissions can limit access to its control socket; the linked guide's hardening consequences, including the mounted-socket path, remain this guide's assessment. Git v2.49.0 documents that `git rm --cached` removes paths only from the index and leaves working tree files alone. As this guide's advice, rotate any secret that was ever committed; as this guide's reasoning, untracking does not remove the file from earlier commits. The cited Git lines describe index and working tree behaviour and do not discuss earlier commits or secret rotation. - Databases in containers still need their own TLS and authentication when anything outside the Compose network connects: see [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md), [mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md), and [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md). ## 4. Verify REASONED: publication, redirect, authentication and external isolation checks; no exposed/fixed run is recorded in this guide. Expectations follow the cited Docker networking sources and linked proxy guides; the authoring environment has no authorized deployment or external probe hosts. For the unauthenticated API probe, expect 401 from Caddy or nginx basic auth; Caddy `forward_auth` admits auth-service 2xx, but its rejection status is not established by the fetched lines. Expect 401/403 from nginx `auth_request`; the cited Authelia nginx configuration converts its 401 to a 302 login redirect. Traefik `forwardAuth` passes through its auth service's non-2xx status, including a login redirect; Traefik `basicAuth` calls `RequireAuth`, whose response the fetched lines do not show, so 401 is this guide's expectation rather than a status established by the fetched middleware. For Cloudflare Access, this guide expects a 302 login redirect in the configuration described in [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md); the fetched Cloudflare lines do not establish that status. A 3xx passes only when its printed location is the gate's own login portal (for Cloudflare Access, your team's `*.cloudflareaccess.com`), never the application's own login. The command fails on any 2xx; any status other than the gate's expected rejection or a 3xx to its portal is not a pass; and an authorized request must succeed separately. The redirect and unauthenticated API curls take the app's host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains; this guide records no earlier run of the guard or the classification. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the block's current form. Stub runs with stub commands and no network, including `ss` passing, failing and missing, in a clean shell and under an inherited `readonly`, `declare -i` and `set -e`, covered its earlier form, which lowercased the host with `tr` before refusing reserved names. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and its host part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. In curl-stub runs of the current form on 2026-11-01 with no network, bash refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. In those runs a host name outside the reserved names reached both curl requests. Under dash, stub runs of the current form refused every host name with 2 and the bash message before either curl request, after its container and socket listings ran, and the block exited 2. The block exits with one status: 1 for a finding, 3 when a part fails or is inconclusive, 2 when its guard refuses the host name or `ss` is not installed and no other part found something or was inconclusive, and otherwise 0, leaving the lists, the redirect headers and any non-2xx `/api` answer for you to judge. ### Exposure checks ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input, or ss is not installed), which outranks 0 docker compose ps # project-scoped: only the proxy shows a published (0.0.0.0/[::] or a host IP) binding; review every container on the host, IPv6 included case "$?" in 0) ;; *) echo "docker compose ps failed; inconclusive"; set -- 3 ;; esac if command -v ss >/dev/null; then # a missing ss is a refusal, ranked as above ss -tulnp # host sockets (TCP and UDP); socket visibility varies by Docker version and config, so a socket listing alone does not prove reachability case "$?" in 0) ;; *) echo "ss -tulnp failed; inconclusive"; set -- 3 ;; esac else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your TLS proxy serves the app on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above set -- "$1" "$2" "$(if curl -q -g -sI --noproxy '*' -w '\n%{http_code} %{redirect_url}' "http://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the headers, the HTTP code and the redirect URL set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the headers, then the HTTP code and the redirect URL printf '%s' "$4" # expect a redirect to https:// with Caddy automatic HTTPS, or after configuring nginx or Traefik to redirect case "$3:$5" in 0:3??" https://"*) ;; 0:*) echo "HTTP ${5%% *} with redirect URL '${5#* }' is not a redirect to https://; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the redirect request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w 'http=%{http_code} loc=%{redirect_url}\n' "https://$1/api"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its http= and loc= line set -- "$1" "$2" "$3" "$4" "${4%% *}" # curl's exit status, the line, then its http= field printf '%s' "$4" # unauthenticated: inspect the gate's rejection and portal location; then confirm an authorized request succeeds case "$3:$5" in 0:http=2*) echo "FAIL: unauthenticated /api returned ${5#http=}"; set -- "$1" 1 ;; 0:*) ;; *) echo "curl exit $3: the unauthenticated /api request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the container and socket lists, the redirect headers, and a non-2xx /api status with its location are not classified, so judge them yourself" exit "$1" ) ``` REASONED: preceding block; publication and socket inventory, HTTPS redirect, unauthenticated API rejection and authorized API success follow the cited Docker networking sources and linked proxy guides, as do the external isolation probes this paragraph describes; no Docker runtime or external probe hosts are available, and no exposed/fixed result is recorded. Test from a second machine on a different network where possible; the UFW bypass means testing the firewall from the host itself proves nothing about published ports. The `curl` checks above exercise the proxy's redirect and auth, not the backend, so also probe each restricted host address and published port directly (for example port 3000) from an allowed and a disallowed source, IPv4 and IPv6 separately: a loopback-bound or firewalled backend must show unreachable while the proxy stays reachable. REASONED: this firewalld reload check follows the Impact section of GHSA-x4rx-4gw3-53p4 and the pinned v28.3.3 rule-restoration source. It has not been demonstrated here because the authoring environment has no Docker runtime, firewalld or external test host. For a loopback-published HTTP backend using default NAT bridge filtering, confirm that the backend answers locally. From a second host with a route through the Docker host to the bridge subnet, run `curl -q -g --noproxy '*' --connect-timeout 3 --max-time 5 -v http://172.17.0.2:3000/`, replacing the address and port with the actual container address and container port. Repeat with a fresh connection after running `sudo firewall-cmd --reload` on the Docker host. Without another control masking the regression, an affected host changes from blocked to connectable; a fixed host remains blocked. Any successful TCP connection, including an HTTP 401 or 403 response, demonstrates reachability. A failed probe alone does not establish that the Engine is patched: confirm the route, target and backend health. Repeat for configured IPv6 addresses and the restricted host-address publications, and confirm that the TLS proxy remains reachable. ## Sources (checked September 2026) - Docker build variables (`ENV` persists in containers; build arguments may persist in metadata, provenance and history), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/variables.md#L17-L52 - Docker build secrets (temporary mounts for build instructions), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L22 - Docker image history command (image history and creation-command output), at 4e9a5751518ed8223a8dcde53693badddd72604f: https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_image_history.yaml#L1-L118 - Docker Compose secrets (environment-variable risks, file under `/run/secrets/`, Linux containers only, per-service access), at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/use-secrets.md#L16-L28 - Docker Compose container environment (explicit entry required; advice against environment variables for sensitive information), at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/set-environment-variables.md#L11-L15 - Docker Compose `env_file` attribute and separate environment files, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/set-environment-variables.md#L61-L71 - Docker Compose interpolation into the Compose file (image-tag example), at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/variable-interpolation.md#L13-L38 - Docker Compose `.env` file for interpolation in the project directory, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/variable-interpolation.md#L78-L80 - Docker Compose `.env` variable referenced in the `environment` attribute, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/variable-interpolation.md#L105-L117 - Docker Swarm secrets (swarm services only, encrypted in transit and at rest, explicit access), at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/swarm/secrets.md#L11-L34 - Docker Swarm secret mount in Linux containers (in-memory filesystem, default path under `/run/secrets/`), at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/swarm/secrets.md#L87-L92 - Git v2.49.0 ignore rules for untracked and tracked files (checked October 2026): https://github.com/git/git/blob/v2.49.0/Documentation/gitignore.adoc#L15-L18 - Git v2.49.0 `git rm --cached` (index removal leaves working tree files alone; checked October 2026): https://github.com/git/git/blob/v2.49.0/Documentation/git-rm.adoc#L67-L70 - Docker Dockerfile `USER` instruction, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/_vendor/github.com/moby/buildkit/frontend/dockerfile/docs/reference.md#L2448-L2463 - Docker Compose `cap_drop` option, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/reference/compose-file/services.md#L224-L233 - Docker Compose `read_only` option, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/reference/compose-file/services.md#L1892-L1894 - Docker Compose `security_opt` and `no-new-privileges` spellings, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/reference/compose-file/services.md#L2008-L2021 - Docker run `--security-opt no-new-privileges`, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L2495-L2502 - Docker daemon control-socket access and host filesystem control, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/_index.md#L84-L112 - Docker iptables and ip6tables daemon options, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/firewall-iptables.md#L8-L15 - Docker iptables DOCKER-USER and FORWARD chains, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/firewall-iptables.md#L17-L42 - Docker IPv6-to-IPv4 userland-proxy mapping, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L60-L62 - Docker port publishing with an explicit host address, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L33-L38 - Docker firewall rules and warning against modifying Docker-created rules, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L11-L15 - Moby Docker service unit, at v28.3.3 (checked October 2026): https://github.com/moby/moby/blob/v28.3.3/contrib/init/systemd/docker.service#L1-L9 - systemd After= unit ordering, at v262 (checked October 2026): https://github.com/systemd/systemd/blob/v262/man/systemd.unit.xml#L826-L838 - Docker packet filtering and firewalls: https://docs.docker.com/engine/network/packet-filtering-firewalls/ - Docker with iptables, for the `DOCKER-USER` chain (processed before Docker's own rules; matches container addresses after DNAT): https://docs.docker.com/engine/network/firewall-iptables/ - Docker Engine 28.0 release notes, for the published-port and loopback-mapping hardening: https://docs.docker.com/engine/release-notes/28/ - Docker Engine 28.3.3 security fix: https://docs.docker.com/engine/release-notes/28/#2833 and affected-version advisory (OSV copy of the July 2025 Moby advisory): https://osv.dev/vulnerability/GHSA-x4rx-4gw3-53p4 - Moby 28.3.3 (pinned tag v28.3.3), `reapplyPerPortIptables` restores endpoint rules after firewalld reload: https://raw.githubusercontent.com/moby/moby/v28.3.3/libnetwork/drivers/bridge/port_mapping_linux.go - Compose networking: https://docs.docker.com/compose/how-tos/networking/ - Docker port publishing (with no host address, "the Docker daemon publishes ports to all host addresses (0.0.0.0 and [::])"): https://docs.docker.com/engine/network/port-publishing/ - PostgreSQL 17 connection settings and default TCP port (checked October 2026): https://www.postgresql.org/docs/17/runtime-config-connection.html - Caddy image fba2853501d36e8a72f946ac8cb7ff64d07e48f2 data/config paths, exposed ports and Caddyfile command (checked October 2026). Item sources (checked October 2026): https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L44-L45, https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L56-L59, https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L63 - Caddy v2.11.4 automatic certificate management and reverse_proxy syntax (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L62-L67, https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go#L60 - Caddy v2.11.4 authentication handler and 401 rejection (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/caddyauth/caddyauth.go#L84-L120 - Caddy v2.11.4 basic-auth missing-credential path (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/caddyauth/basicauth.go#L142-L163 - Caddy v2.11.4 forward_auth accepted-response handler (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go#L174-L194 - Caddy v2.11.4 automatic redirect setup (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L234-L240 - Caddy v2.11.4 HTTPS redirect route (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L544-L575 - nginx release-1.26.3 missing basic-auth credentials (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_basic_module.c#L122-L129 - nginx release-1.26.3 basic-auth 401 challenge (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_basic_module.c#L317-L348 - nginx release-1.26.3 auth_request status handling (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_request_module.c#L135-L182 - Traefik v3.5.0 basicAuth failure path (checked October 2026): https://github.com/traefik/traefik/blob/v3.5.0/pkg/middlewares/auth/basic_auth.go#L67-L99 - Traefik v3.5.0 forwardAuth keeps the auth service redirect (checked October 2026): https://github.com/traefik/traefik/blob/v3.5.0/pkg/middlewares/auth/forward.go#L93-L99 - Traefik v3.5.0 forwardAuth rejection status passthrough (checked October 2026): https://github.com/traefik/traefik/blob/v3.5.0/pkg/middlewares/auth/forward.go#L233-L262 - Cloudflare Access MFA policy enforcement (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85 - Cloudflare Access self-hosted application protection (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54 - Authelia v4.39.28 supported proxy integration (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L24-L32 - Authelia v4.39.28 nginx portal redirect (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L537-L541 - AWS security-group rules (rolling reference reused from `cloud-firewalls.md`; not rechecked here): https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html - Google Cloud VPC firewall rules (rolling reference reused from `cloud-firewalls.md`; not rechecked here): https://docs.cloud.google.com/firewall/docs/using-firewalls - Azure NSG rule properties (rolling reference reused from `cloud-firewalls.md`; not rechecked here): https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get?view=rest-virtualnetwork-2026-01-01 - cloudflared 2026.9.3 tunnel daemon without firewall openings (checked October 2026): https://github.com/cloudflare/cloudflared/blob/2026.9.3/README.md#L1-L5 - cloudflared 2026.9.3 Docker image (checked October 2026): https://github.com/cloudflare/cloudflared/blob/2026.9.3/README.md#L26-L33 - cloudflared 2026.9.3 container entry point (checked October 2026): https://github.com/cloudflare/cloudflared/blob/2026.9.3/Dockerfile#L40-L42 - Cloudflare Tunnel origin service URL example at c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L31-L49 - Cloudflare Tunnel published application domain prerequisite at c896795b8e8bab68d05ae78d64c9cbcda1fdb104 (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel.mdx#L27-L37 - Docker Compose service-name lookup at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/networking.md#L13-L17 - Tailscale Serve private tailnet entry (rolling reference reused from `tailscale.md`; not rechecked here): https://tailscale.com/docs/reference/tailscale-cli/serve - Tailscale Funnel public entry (rolling reference reused from `tailscale.md`; not rechecked here): https://tailscale.com/docs/features/tailscale-funnel - OpenSSL documentation index (rolling reference reused from `self-signed.md`; not rechecked here): https://docs.openssl.org/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - boundary: Docker Engine minimum boundary (28.0) - moby-unit: Moby Docker service unit (v28.3.3) - reload: Docker firewalld reload fix (28.3.3) - docker-build: Docker build and image history documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - docker-secrets: Docker runtime secrets and environment documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - engine: Docker Engine networking (unknown) - engine-pinned: Docker Engine networking documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - compose: Compose networking (unknown) - docker-workload: Docker workload documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - reload-advisory: Docker firewalld reload advisory (unknown) - compose-pinned: Docker Compose networking documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - systemd-unit: systemd unit ordering (v262) - git-ignore: Git ignore rules (v2.49.0) - postgresql: PostgreSQL (17) - caddy-image: Caddy image (fba2853501d36e8a72f946ac8cb7ff64d07e48f2) - caddy: Caddy (v2.11.4) - nginx-auth: nginx authentication source (release-1.26.3) - traefik-auth: Traefik authentication source (v3.5.0) - cloudflare-access: Cloudflare Access documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - authelia-proxy: Authelia proxy integration (v4.39.28) - aws-firewall: AWS security group rules (unknown) - gcp-firewall: Google Cloud VPC firewall rules (unknown) - azure-firewall: Azure network security group rules (unknown) - cloudflared-image: cloudflared container image (2026.9.3) - cloudflare-tunnel: Cloudflare Tunnel documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - tailscale-entry: Tailscale entry documentation (unknown) - self-signed-entry: OpenSSL documentation (rolling) (unknown) Claims (full source mapping in the JSON block below): - publish (REASONED; engine): Omitting the host address publishes 3000:3000 on 0.0.0.0 and [::]. - host-firewall (REASONED; engine): Docker programs firewall rules directly; UFW or firewalld blocking a host port does not establish published-port isolation. - private-network (REASONED; compose, postgresql): Omit app/database ports; Compose peers use service names. The example uses postgres:17 at db:5432. - loopback (REASONED; engine, boundary, reload, reload-advisory): Publish local access as 127.0.0.1:3000:3000; check the running server version. Engine 28.0 is the minimum boundary, with 28.3.3 or later required where firewalld is used. - old-loopback (REASONED; boundary): Before 28.0, same-L2 neighbours could reach loopback publications under the default bridge configuration. - old-host-bind (REASONED; boundary): Before 28.0, remote hosts could reach published container ports despite the host-IP binding. - old-unpublished (REASONED; boundary): Before 28.0, direct routing could reach unpublished container ports; 28.0 fixed the stated default-bridge exposures. - upstream (REASONED; engine, aws-firewall, gcp-firewall, azure-firewall): Use an upstream security group or network ACL for routable publications and boot-time protection. AWS security groups, Google Cloud VPC firewall rules and Azure NSGs have rolling sources; network-ACL configuration and boot-time independence remain this guide's assessment. - docker-user (REASONED; engine): On the iptables backend, DOCKER-USER precedes Docker accepts; the nftables backend has no such chain. Match NEW traffic from outside the allowed subnet. - dnat (REASONED; engine): DOCKER-USER sees post-DNAT container destinations; conntrack original destination port and ORIGINAL direction scope a published service. - ipv6 (REASONED; engine, engine-pinned): As this guide's reasoning, native IPv6 forwarding needs ip6tables; Docker documents IPv6-to-IPv4 userland-proxy mapping when no host IP is given, the bridge is IPv4-only and `--userland-proxy=true` (the default), and places DOCKER-USER in FORWARD. As this guide's reasoning, the proxy path terminates on host INPUT and bypasses DOCKER-USER. - proxy-path (REASONED; engine, engine-pinned): Close the userland-proxy path with an explicit publish address, disabled userland proxy or IPv6 INPUT restrictions; test both address families and sources. Docker documents the explicit-address option and conditional userland-proxy mapping; the INPUT restriction and probes are this guide's reasoning and advice. - persistence (REASONED; engine, engine-pinned, moby-unit, systemd-unit): As this guide's advice, persist only DOCKER-USER rules after Docker startup, avoiding blanket dynamic-chain saves. Moby identifies docker.service and systemd documents After= ordering; as this guide's assessment, rule non-persistence, the boot-time gap, what iptables-persistent captures and its potential to break container networking have no direct source here. - tls (REASONED; compose, caddy-image, caddy): Example caddy:2 publishes 80:80 and 443:443, mounts Caddyfile read-only and persists data/config, proxying app:3000 with automatic certificates. - alternate-entry (REASONED; compose, cloudflared-image, cloudflare-tunnel, compose-pinned, tailscale-entry, self-signed-entry): As this guide's advice, hosts with no inbound ports and a domain they can put on Cloudflare use a cloudflared container, while hosts with no domain at all use Tailscale or internal self-signed TLS. Cloudflare documents a tunnel daemon that needs no firewall openings, a container image, an HTTPS origin service URL and a domain prerequisite; Docker documents Compose service-name lookup. The http://app:3000 origin target on a shared Compose network is this guide's inference, not shown literally by the fetched Cloudflare lines. The tunnel-token setup is not shown here; the Tailscale and OpenSSL references are rolling, and the self-signed fallback remains this guide's advice. - auth (REASONED; compose, caddy, nginx-auth, traefik-auth, cloudflare-access, authelia-proxy): As this guide's assessment, proxy authentication and human MFA supplement application login. Caddy and nginx basic authentication reject missing credentials; nginx auth_request returns 401/403; Traefik basicAuth calls RequireAuth and forwardAuth passes through non-2xx; Cloudflare Access policies can require MFA, and Authelia can sit behind the proxy. Require MFA in the governing policy or portal; as this guide's assessment, basic auth alone is single-factor. The Caddy forward_auth rejection status, exact Traefik basicAuth status and Cloudflare Access anonymous status are not established by these fetched lines. - secrets (REASONED; compose, docker-build, docker-secrets, git-ignore): Use runtime env files (guide advice; weaker: guide assessment), Compose secrets (Linux only) or Swarm secrets (swarm services only). Guide advice: keep .env in .gitignore (untracked files only); untrack if tracked. No Dockerfile ENV/ARG secrets; use temporary build secret mounts. Guide assessment: never bake/copy/print secrets into images; history source does not establish that every argument value is shown. - env-git (REASONED; git-ignore): As this guide's advice, keep `.env` in `.gitignore` and untrack it if already tracked; Git ignores matching untracked files but not tracked files, and `git rm --cached` removes paths only from the index, leaving working tree files alone. As this guide's advice, rotate any secret ever committed; as this guide's reasoning, untracking does not remove it from earlier commits. - workload (REASONED; compose, docker-workload): Guide advice: use non-root Dockerfile USER, drop capabilities, use no-new-privileges and a read-only root filesystem, never mount Docker's socket. Docker documents option names, that only trusted users should control the daemon and that Unix permissions can limit socket access; guide assessments: with non-root USER a compromised app is not container root; linked guide's hardening effects, mounted-socket path included. - database (REASONED; compose): Database TLS and authentication remain necessary for connections from outside the Compose network; database vendor sources are in linked guides. - verify-publish (REASONED; compose, engine): Compose ps is project-scoped; inventory every host container and TCP/UDP socket, including IPv6. Socket visibility alone does not prove reachability. - verify-redirect (REASONED; compose, caddy): Verify that HTTP redirects to HTTPS: Caddy v2.11.4 configures an automatic HTTPS redirect; as this guide's configuration-dependent expectation, nginx and Traefik require a configured redirect. This is an expectation, not an observed deployment result. - verify-auth (REASONED; compose, caddy, nginx-auth, traefik-auth, authelia-proxy, cloudflare-access): Verify unauthenticated HTTPS /api rejection with printed status and redirect location; the probe itself fails on any 2xx. A 3xx passes only when the location is the gate's own login portal, never the application's own login; verify authorized success separately. Caddy basic auth and nginx auth_basic expect 401; Caddy forward_auth admits auth-service 2xx, but its rejection status is not established by the fetched lines. nginx auth_request returns 401 or 403, while the cited Authelia nginx configuration turns 401 into 302 to its portal. Traefik forwardAuth passes through a rejecting auth service's status, including a login redirect; Traefik basicAuth's 401 is this guide's expectation because the fetched code calls RequireAuth, whose response these lines do not show. Cloudflare Access's anonymous 302 login redirect is this guide's assessment per cloudflare.md, not established by the fetched Cloudflare lines. - verify-isolation (REASONED; engine): Probe each restricted host address and publication from allowed/disallowed sources in both families; backend isolation must coexist with proxy reachability. - firewalld-reload (REASONED; reload, reload-advisory): Engine 28.2.x and 28.3.0 through 28.3.2 fail to restore container-address filtering after firewalld reload; 28.3.3 fixes CVE-2025-54388 (GHSA-x4rx-4gw3-53p4). - firewalld-scope (REASONED; reload, reload-advisory): The regression affects Linux Engine in the host network namespace with firewalld: routed remote hosts can reach published container ports, including loopback publications; unpublished ports stay filtered. Rootless Mode and Docker Desktop are unaffected. - verify-firewalld-reload (REASONED; reload, reload-advisory): Confirm local backend health, then curl the container address from a second host with a bridge-subnet route before and after sudo firewall-cmd --reload. Without masking controls, affected hosts change from blocked to connectable; fixed hosts stay blocked. Any TCP connection, including HTTP 401/403, shows reachability; failure alone does not prove patching. Check route, target, IPv6, restricted host publications and continued proxy reachability. ````json { "schema": 1, "checked": "2026-10-19", "documentation_checked": "2026-09", "body_sha256": "f40702b6f597c06a0367b442aae713ad9ac6641527781245184947d9f7cc3837", "components": { "boundary": { "name": "Docker Engine minimum boundary", "basis": "28.0", "sources": { "s50eb099eac95": "https://docs.docker.com/engine/release-notes/28/" } }, "moby-unit": { "name": "Moby Docker service unit", "basis": "v28.3.3", "sources": { "s6aa0d2fb34cf": "https://github.com/moby/moby/blob/v28.3.3/contrib/init/systemd/docker.service#L1-L9" } }, "reload": { "name": "Docker firewalld reload fix", "basis": "28.3.3", "sources": { "s567a65842b29": "https://docs.docker.com/engine/release-notes/28/#2833", "seaf4197aafaa": "https://raw.githubusercontent.com/moby/moby/v28.3.3/libnetwork/drivers/bridge/port_mapping_linux.go" } }, "docker-build": { "name": "Docker build and image history documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sd8afa286c173": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/variables.md#L17-L52", "s07e00829f121": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/build/building/secrets.md#L13-L22", "s6283b172a5c6": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_image_history.yaml#L1-L118" } }, "docker-secrets": { "name": "Docker runtime secrets and environment documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "s81d857265c7c": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/use-secrets.md#L16-L28", "sd9dc0a9c4c0d": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/set-environment-variables.md#L11-L15", "sb9dd4c26439c": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/set-environment-variables.md#L61-L71", "sb412f7379ceb": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/variable-interpolation.md#L13-L38", "s65e97e494341": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/variable-interpolation.md#L78-L80", "s04a382307f48": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/environment-variables/variable-interpolation.md#L105-L117", "s05ba8904bab4": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/swarm/secrets.md#L11-L34", "s9c3b613c7e5a": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/swarm/secrets.md#L87-L92" } }, "engine": { "name": "Docker Engine networking", "basis": "unknown", "sources": { "s351180c6678f": "https://docs.docker.com/engine/network/packet-filtering-firewalls/", "s2fecb6db5480": "https://docs.docker.com/engine/network/firewall-iptables/", "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } }, "engine-pinned": { "name": "Docker Engine networking documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sd22373a822c3": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/firewall-iptables.md#L8-L15", "s024cfadb4bb8": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/firewall-iptables.md#L17-L42", "s1c82ff316a7e": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L60-L62", "sb3dacfbaf7e1": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L33-L38", "sc244607b20e4": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L11-L15" } }, "compose": { "name": "Compose networking", "basis": "unknown", "sources": { "sae565a19136c": "https://docs.docker.com/compose/how-tos/networking/" } }, "docker-workload": { "name": "Docker workload documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "s2fa3b3cd70e6": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/_vendor/github.com/moby/buildkit/frontend/dockerfile/docs/reference.md#L2448-L2463", "s8000f273883e": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/reference/compose-file/services.md#L224-L233", "sb94d21792cd4": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/reference/compose-file/services.md#L1892-L1894", "s28ce092b2589": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/reference/compose-file/services.md#L2008-L2021", "s738280652c95": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L2495-L2502", "secebea1a69ab": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/_index.md#L84-L112" } }, "reload-advisory": { "name": "Docker firewalld reload advisory", "basis": "unknown", "sources": { "s424f3c4f8285": "https://osv.dev/vulnerability/GHSA-x4rx-4gw3-53p4" } }, "compose-pinned": { "name": "Docker Compose networking documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "seb7ab83ee20e": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/compose/how-tos/networking.md#L13-L17" } }, "systemd-unit": { "name": "systemd unit ordering", "basis": "v262", "sources": { "s71dce3a476cd": "https://github.com/systemd/systemd/blob/v262/man/systemd.unit.xml#L826-L838" } }, "git-ignore": { "name": "Git ignore rules", "basis": "v2.49.0", "sources": { "s5f377f0fdbd8": "https://github.com/git/git/blob/v2.49.0/Documentation/gitignore.adoc#L15-L18", "sa05912acacd1": "https://github.com/git/git/blob/v2.49.0/Documentation/git-rm.adoc#L67-L70" } }, "postgresql": { "name": "PostgreSQL", "basis": "17", "sources": { "s44c2e898e585": "https://www.postgresql.org/docs/17/runtime-config-connection.html" } }, "caddy-image": { "name": "Caddy image", "basis": "fba2853501d36e8a72f946ac8cb7ff64d07e48f2", "sources": { "sbbb7746f7545": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L44-L45", "sb126b380b9cf": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L56-L59", "seea3a2adc92a": "https://github.com/caddyserver/caddy-docker/blob/fba2853501d36e8a72f946ac8cb7ff64d07e48f2/2.11/alpine/Dockerfile#L63" } }, "caddy": { "name": "Caddy", "basis": "v2.11.4", "sources": { "s5520ee906a7f": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L62-L67", "sfa5c7dc047dc": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go#L60", "s32fb97ff942b": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/caddyauth/caddyauth.go#L84-L120", "sd698097c71f4": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/caddyauth/basicauth.go#L142-L163", "s87dc43d9819d": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go#L174-L194", "sd267d297d661": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L234-L240", "s32a5119bce5b": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/autohttps.go#L544-L575" } }, "nginx-auth": { "name": "nginx authentication source", "basis": "release-1.26.3", "sources": { "s44cecab17a23": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_basic_module.c#L122-L129", "s91b029555086": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_basic_module.c#L317-L348", "s3df6e64cd6a3": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_auth_request_module.c#L135-L182" } }, "traefik-auth": { "name": "Traefik authentication source", "basis": "v3.5.0", "sources": { "s5f9a13d720c8": "https://github.com/traefik/traefik/blob/v3.5.0/pkg/middlewares/auth/basic_auth.go#L67-L99", "sfe588a6dd6a3": "https://github.com/traefik/traefik/blob/v3.5.0/pkg/middlewares/auth/forward.go#L93-L99", "sbc20b015d928": "https://github.com/traefik/traefik/blob/v3.5.0/pkg/middlewares/auth/forward.go#L233-L262" } }, "cloudflare-access": { "name": "Cloudflare Access documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s6f6104362214": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85", "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54" } }, "authelia-proxy": { "name": "Authelia proxy integration", "basis": "v4.39.28", "sources": { "s5ca02c783843": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/support.md#L24-L32", "s31a83fce2b6e": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/integration/proxies/nginx.md#L537-L541" } }, "aws-firewall": { "name": "AWS security group rules", "basis": "unknown", "sources": { "s5594f61a25d6": "https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html" } }, "gcp-firewall": { "name": "Google Cloud VPC firewall rules", "basis": "unknown", "sources": { "s1c06c98a99c9": "https://docs.cloud.google.com/firewall/docs/using-firewalls" } }, "azure-firewall": { "name": "Azure network security group rules", "basis": "unknown", "sources": { "s3d55deda04f8": "https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get?view=rest-virtualnetwork-2026-01-01" } }, "cloudflared-image": { "name": "cloudflared container image", "basis": "2026.9.3", "sources": { "se79f4a693e7a": "https://github.com/cloudflare/cloudflared/blob/2026.9.3/README.md#L1-L5", "s8b0cac0c9e3c": "https://github.com/cloudflare/cloudflared/blob/2026.9.3/README.md#L26-L33", "s5c722efb03b3": "https://github.com/cloudflare/cloudflared/blob/2026.9.3/Dockerfile#L40-L42" } }, "cloudflare-tunnel": { "name": "Cloudflare Tunnel documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s253af0f0386c": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/partials/cloudflare-one/tunnel/origin-parameters.mdx#L31-L49", "sbd8e87e6c172": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel.mdx#L27-L37" } }, "tailscale-entry": { "name": "Tailscale entry documentation", "basis": "unknown", "sources": { "s5b9a9b15ae80": "https://tailscale.com/docs/reference/tailscale-cli/serve", "s43c0c1d7112d": "https://tailscale.com/docs/features/tailscale-funnel" } }, "self-signed-entry": { "name": "OpenSSL documentation (rolling)", "basis": "unknown", "sources": { "s211b1e6e4aa4": "https://docs.openssl.org/" } } }, "claims": { "publish": {"text": "Omitting the host address publishes 3000:3000 on 0.0.0.0 and [::].", "components": ["engine"], "sources": ["engine:s1e53417c513d"], "status": "REASONED"}, "host-firewall": {"text": "Docker programs firewall rules directly; UFW or firewalld blocking a host port does not establish published-port isolation.", "components": ["engine"], "sources": ["engine:s351180c6678f"], "status": "REASONED"}, "private-network": {"text": "Omit app/database ports; Compose peers use service names. The example uses postgres:17 at db:5432.", "components": ["compose", "postgresql"], "sources": ["compose:sae565a19136c", "postgresql:s44c2e898e585"], "status": "REASONED"}, "loopback": {"text": "Publish local access as 127.0.0.1:3000:3000; check the running server version. Engine 28.0 is the minimum boundary, with 28.3.3 or later required where firewalld is used.", "components": ["engine", "boundary", "reload", "reload-advisory"], "sources": ["engine:s1e53417c513d", "boundary:s50eb099eac95", "reload:s567a65842b29", "reload-advisory:s424f3c4f8285"], "status": "REASONED"}, "old-loopback": {"text": "Before 28.0, same-L2 neighbours could reach loopback publications under the default bridge configuration.", "components": ["boundary"], "sources": ["boundary:s50eb099eac95"], "status": "REASONED"}, "old-host-bind": {"text": "Before 28.0, remote hosts could reach published container ports despite the host-IP binding.", "components": ["boundary"], "sources": ["boundary:s50eb099eac95"], "status": "REASONED"}, "old-unpublished": {"text": "Before 28.0, direct routing could reach unpublished container ports; 28.0 fixed the stated default-bridge exposures.", "components": ["boundary"], "sources": ["boundary:s50eb099eac95"], "status": "REASONED"}, "upstream": {"text": "Use an upstream security group or network ACL for routable publications and boot-time protection. AWS security groups, Google Cloud VPC firewall rules and Azure NSGs have rolling sources; network-ACL configuration and boot-time independence remain this guide's assessment.", "components": ["engine", "aws-firewall", "gcp-firewall", "azure-firewall"], "sources": ["engine:s351180c6678f", "aws-firewall:s5594f61a25d6", "gcp-firewall:s1c06c98a99c9", "azure-firewall:s3d55deda04f8"], "status": "REASONED"}, "docker-user": {"text": "On the iptables backend, DOCKER-USER precedes Docker accepts; the nftables backend has no such chain. Match NEW traffic from outside the allowed subnet.", "components": ["engine"], "sources": ["engine:s351180c6678f", "engine:s2fecb6db5480"], "status": "REASONED"}, "dnat": {"text": "DOCKER-USER sees post-DNAT container destinations; conntrack original destination port and ORIGINAL direction scope a published service.", "components": ["engine"], "sources": ["engine:s2fecb6db5480"], "status": "REASONED"}, "ipv6": {"text": "As this guide's reasoning, native IPv6 forwarding needs ip6tables; Docker documents IPv6-to-IPv4 userland-proxy mapping when no host IP is given, the bridge is IPv4-only and `--userland-proxy=true` (the default), and places DOCKER-USER in FORWARD. As this guide's reasoning, the proxy path terminates on host INPUT and bypasses DOCKER-USER.", "components": ["engine", "engine-pinned"], "sources": ["engine:s2fecb6db5480", "engine:s1e53417c513d", "engine-pinned:sd22373a822c3", "engine-pinned:s024cfadb4bb8", "engine-pinned:s1c82ff316a7e"], "status": "REASONED"}, "proxy-path": {"text": "Close the userland-proxy path with an explicit publish address, disabled userland proxy or IPv6 INPUT restrictions; test both address families and sources. Docker documents the explicit-address option and conditional userland-proxy mapping; the INPUT restriction and probes are this guide's reasoning and advice.", "components": ["engine", "engine-pinned"], "sources": ["engine:s1e53417c513d", "engine:s2fecb6db5480", "engine-pinned:sb3dacfbaf7e1", "engine-pinned:s1c82ff316a7e", "engine-pinned:s024cfadb4bb8"], "status": "REASONED"}, "persistence": {"text": "As this guide's advice, persist only DOCKER-USER rules after Docker startup, avoiding blanket dynamic-chain saves. Moby identifies docker.service and systemd documents After= ordering; as this guide's assessment, rule non-persistence, the boot-time gap, what iptables-persistent captures and its potential to break container networking have no direct source here.", "components": ["engine", "engine-pinned", "moby-unit", "systemd-unit"], "sources": ["engine:s351180c6678f", "engine:s2fecb6db5480", "engine-pinned:sc244607b20e4", "engine-pinned:s024cfadb4bb8", "moby-unit:s6aa0d2fb34cf", "systemd-unit:s71dce3a476cd"], "status": "REASONED"}, "tls": {"text": "Example caddy:2 publishes 80:80 and 443:443, mounts Caddyfile read-only and persists data/config, proxying app:3000 with automatic certificates.", "components": ["compose", "caddy-image", "caddy"], "sources": ["compose:sae565a19136c", "caddy-image:sbbb7746f7545", "caddy-image:sb126b380b9cf", "caddy-image:seea3a2adc92a", "caddy:s5520ee906a7f", "caddy:sfa5c7dc047dc"], "status": "REASONED"}, "alternate-entry": {"text": "As this guide's advice, hosts with no inbound ports and a domain they can put on Cloudflare use a cloudflared container, while hosts with no domain at all use Tailscale or internal self-signed TLS. Cloudflare documents a tunnel daemon that needs no firewall openings, a container image, an HTTPS origin service URL and a domain prerequisite; Docker documents Compose service-name lookup. The http://app:3000 origin target on a shared Compose network is this guide's inference, not shown literally by the fetched Cloudflare lines. The tunnel-token setup is not shown here; the Tailscale and OpenSSL references are rolling, and the self-signed fallback remains this guide's advice.", "components": ["compose", "cloudflared-image", "cloudflare-tunnel", "compose-pinned", "tailscale-entry", "self-signed-entry"], "sources": ["compose:sae565a19136c", "cloudflared-image:se79f4a693e7a", "cloudflared-image:s8b0cac0c9e3c", "cloudflared-image:s5c722efb03b3", "cloudflare-tunnel:s253af0f0386c", "cloudflare-tunnel:sbd8e87e6c172", "compose-pinned:seb7ab83ee20e", "tailscale-entry:s5b9a9b15ae80", "tailscale-entry:s43c0c1d7112d", "self-signed-entry:s211b1e6e4aa4"], "status": "REASONED"}, "auth": {"text": "As this guide's assessment, proxy authentication and human MFA supplement application login. Caddy and nginx basic authentication reject missing credentials; nginx auth_request returns 401/403; Traefik basicAuth calls RequireAuth and forwardAuth passes through non-2xx; Cloudflare Access policies can require MFA, and Authelia can sit behind the proxy. Require MFA in the governing policy or portal; as this guide's assessment, basic auth alone is single-factor. The Caddy forward_auth rejection status, exact Traefik basicAuth status and Cloudflare Access anonymous status are not established by these fetched lines.", "components": ["compose", "caddy", "nginx-auth", "traefik-auth", "cloudflare-access", "authelia-proxy"], "sources": ["compose:sae565a19136c", "caddy:s32fb97ff942b", "caddy:sd698097c71f4", "caddy:s87dc43d9819d", "nginx-auth:s44cecab17a23", "nginx-auth:s91b029555086", "nginx-auth:s3df6e64cd6a3", "traefik-auth:s5f9a13d720c8", "traefik-auth:sfe588a6dd6a3", "traefik-auth:sbc20b015d928", "cloudflare-access:s6f6104362214", "cloudflare-access:s7cafe1b17afd", "authelia-proxy:s5ca02c783843"], "status": "REASONED"}, "secrets": {"text": "Use runtime env files (guide advice; weaker: guide assessment), Compose secrets (Linux only) or Swarm secrets (swarm services only). Guide advice: keep .env in .gitignore (untracked files only); untrack if tracked. No Dockerfile ENV/ARG secrets; use temporary build secret mounts. Guide assessment: never bake/copy/print secrets into images; history source does not establish that every argument value is shown.", "components": ["compose", "docker-build", "docker-secrets", "git-ignore"], "sources": ["compose:sae565a19136c", "docker-build:sd8afa286c173", "docker-build:s07e00829f121", "docker-build:s6283b172a5c6", "docker-secrets:s81d857265c7c", "docker-secrets:sd9dc0a9c4c0d", "docker-secrets:sb9dd4c26439c", "docker-secrets:sb412f7379ceb", "docker-secrets:s65e97e494341", "docker-secrets:s04a382307f48", "docker-secrets:s05ba8904bab4", "docker-secrets:s9c3b613c7e5a", "git-ignore:s5f377f0fdbd8"], "status": "REASONED"}, "env-git": {"text": "As this guide's advice, keep `.env` in `.gitignore` and untrack it if already tracked; Git ignores matching untracked files but not tracked files, and `git rm --cached` removes paths only from the index, leaving working tree files alone. As this guide's advice, rotate any secret ever committed; as this guide's reasoning, untracking does not remove it from earlier commits.", "components": ["git-ignore"], "sources": ["git-ignore:s5f377f0fdbd8", "git-ignore:sa05912acacd1"], "status": "REASONED"}, "workload": {"text": "Guide advice: use non-root Dockerfile USER, drop capabilities, use no-new-privileges and a read-only root filesystem, never mount Docker's socket. Docker documents option names, that only trusted users should control the daemon and that Unix permissions can limit socket access; guide assessments: with non-root USER a compromised app is not container root; linked guide's hardening effects, mounted-socket path included.", "components": ["compose", "docker-workload"], "sources": ["compose:sae565a19136c", "docker-workload:s2fa3b3cd70e6", "docker-workload:s8000f273883e", "docker-workload:sb94d21792cd4", "docker-workload:s28ce092b2589", "docker-workload:s738280652c95", "docker-workload:secebea1a69ab"], "status": "REASONED"}, "database": {"text": "Database TLS and authentication remain necessary for connections from outside the Compose network; database vendor sources are in linked guides.", "components": ["compose"], "sources": ["compose:sae565a19136c"], "status": "REASONED"}, "verify-publish": {"text": "Compose ps is project-scoped; inventory every host container and TCP/UDP socket, including IPv6. Socket visibility alone does not prove reachability.", "components": ["compose", "engine"], "sources": ["compose:sae565a19136c", "engine:s1e53417c513d"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "Verify that HTTP redirects to HTTPS: Caddy v2.11.4 configures an automatic HTTPS redirect; as this guide's configuration-dependent expectation, nginx and Traefik require a configured redirect. This is an expectation, not an observed deployment result.", "components": ["compose", "caddy"], "sources": ["compose:sae565a19136c", "caddy:sd267d297d661", "caddy:s32a5119bce5b"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "Verify unauthenticated HTTPS /api rejection with printed status and redirect location; the probe itself fails on any 2xx. A 3xx passes only when the location is the gate's own login portal, never the application's own login; verify authorized success separately. Caddy basic auth and nginx auth_basic expect 401; Caddy forward_auth admits auth-service 2xx, but its rejection status is not established by the fetched lines. nginx auth_request returns 401 or 403, while the cited Authelia nginx configuration turns 401 into 302 to its portal. Traefik forwardAuth passes through a rejecting auth service's status, including a login redirect; Traefik basicAuth's 401 is this guide's expectation because the fetched code calls RequireAuth, whose response these lines do not show. Cloudflare Access's anonymous 302 login redirect is this guide's assessment per cloudflare.md, not established by the fetched Cloudflare lines.", "components": ["compose", "caddy", "nginx-auth", "traefik-auth", "authelia-proxy", "cloudflare-access"], "sources": ["compose:sae565a19136c", "caddy:s32fb97ff942b", "caddy:sd698097c71f4", "caddy:s87dc43d9819d", "nginx-auth:s44cecab17a23", "nginx-auth:s91b029555086", "nginx-auth:s3df6e64cd6a3", "traefik-auth:s5f9a13d720c8", "traefik-auth:sfe588a6dd6a3", "traefik-auth:sbc20b015d928", "authelia-proxy:s31a83fce2b6e", "cloudflare-access:s7cafe1b17afd"], "status": "REASONED", "verify": [1]}, "verify-isolation": {"text": "Probe each restricted host address and publication from allowed/disallowed sources in both families; backend isolation must coexist with proxy reachability.", "components": ["engine"], "sources": ["engine:s351180c6678f", "engine:s2fecb6db5480", "engine:s1e53417c513d"], "status": "REASONED", "verify": [1]}, "firewalld-reload": {"text": "Engine 28.2.x and 28.3.0 through 28.3.2 fail to restore container-address filtering after firewalld reload; 28.3.3 fixes CVE-2025-54388 (GHSA-x4rx-4gw3-53p4).", "components": ["reload", "reload-advisory"], "sources": ["reload:s567a65842b29", "reload-advisory:s424f3c4f8285", "reload:seaf4197aafaa"], "status": "REASONED"}, "firewalld-scope": {"text": "The regression affects Linux Engine in the host network namespace with firewalld: routed remote hosts can reach published container ports, including loopback publications; unpublished ports stay filtered. Rootless Mode and Docker Desktop are unaffected.", "components": ["reload", "reload-advisory"], "sources": ["reload:s567a65842b29", "reload-advisory:s424f3c4f8285"], "status": "REASONED"}, "verify-firewalld-reload": {"text": "Confirm local backend health, then curl the container address from a second host with a bridge-subnet route before and after sudo firewall-cmd --reload. Without masking controls, affected hosts change from blocked to connectable; fixed hosts stay blocked. Any TCP connection, including HTTP 401/403, shows reachability; failure alone does not prove patching. Check route, target, IPv6, restricted host publications and continued proxy reachability.", "components": ["reload", "reload-advisory"], "sources": ["reload-advisory:s424f3c4f8285", "reload:seaf4197aafaa"], "status": "REASONED"} } } ```` ====================================================================== ==> kubernetes.md ====================================================================== # Kubernetes: Gateway API TLS and authentication Kubernetes runs containerized applications across a cluster of machines. This guide helps your AI assistant configure cluster exposure securely, including a Gateway API entry point that terminates TLS with automatic certificates, authentication at that entry point, no casually public Services, and a protected control plane. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/kubernetes.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Kubernetes kubelet/kubeadm source v1.37.1, Envoy Gateway v1.9.1, cert-manager Gateway support minimum 1.15, and GKE new-cluster read-only-port boundary 1.32; 1 further source is pinned to a commit and 12 carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 50 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. The cluster equivalents of this repository's rules: nothing reaches a workload except through the TLS-terminating entry point (a Gateway API `Gateway`), and no Service becomes public through a casual `type: LoadBalancer` or `NodePort`; the entry point's own Service is the only exception. **If you run ingress-nginx today, migrate.** Earlier versions of this guide built on ingress-nginx. The Kubernetes project retired it in March 2026: per the Kubernetes Steering and Security Response Committees, "there will be no more releases for bug fixes, security patches, or any updates of any kind after the project is retired", and "choosing to remain with Ingress NGINX after its retirement leaves you and your users vulnerable to attack" (as of September 2026; statement linked in Sources). Detect it with cluster-admin permissions: `kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx`. Any pod returned means migration is required; the `nginx.ingress.kubernetes.io/*` annotations die with the controller. Kubernetes recommends Gateway API over Ingress (whose API is now frozen) and links a migration guide from its Gateway API page. The rest of this guide is the Gateway API form of the old rules. ## 1. Gateway API with a maintained implementation Gateway API is a set of CRDs, not part of core Kubernetes; a controller you install implements them. This guide uses [Envoy Gateway](https://gateway.envoyproxy.io/): `helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.9.1 -n envoy-gateway-system --create-namespace` (version current at the time of writing; the default chart also installs the Gateway API CRDs). The chart does not create a `GatewayClass`; the quickstart applies one separately, and the `Gateway` below refers to it by name, so apply it first and confirm that the controller accepted it: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: eg spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller ``` ```bash kubectl get gatewayclass eg -o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}' # True ``` Traefik's Gateway API provider (`providers.kubernetesGateway`) and Cilium (`gatewayAPI.enabled=true`, requires kube-proxy replacement) are maintained alternatives, linked in Sources. The `Gateway` is the entry point; keep its HTTP listener only for redirects and ACME challenges: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: eg annotations: { cert-manager.io/cluster-issuer: letsencrypt } # section 2 spec: gatewayClassName: eg listeners: - { name: http, protocol: HTTP, port: 80 } - name: https protocol: HTTPS port: 443 hostname: app.example.com tls: { mode: Terminate, certificateRefs: [{ kind: Secret, name: app-tls }] } ``` ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: { name: app } spec: parentRefs: [{ name: eg, sectionName: https }] # sectionName binds the route to one listener hostnames: [app.example.com] rules: [{ backendRefs: [{ name: app, port: 80 }] }] # a ClusterIP Service --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: { name: app-redirect } spec: parentRefs: [{ name: eg, sectionName: http }] hostnames: [app.example.com] rules: [{ filters: [{ type: RequestRedirect, requestRedirect: { scheme: https, statusCode: 301 } }] }] ``` ## 2. Automatic certificates with cert-manager Install [cert-manager](https://cert-manager.io/docs/) with Gateway API support turned on: `--set config.gatewayAPI.enabled=true` on its Helm chart (cert-manager 1.15 and later per its docs; the Gateway API CRDs must exist before cert-manager starts, or restart its Deployment afterwards). Define an ACME issuer once, with the HTTP-01 solver pointed at the Gateway (adjust `namespace` to where the Gateway lives). The `cert-manager.io/cluster-issuer` (or `cert-manager.io/issuer`) annotation goes on the Gateway, not on routes: cert-manager creates one Certificate per Secret named in the HTTPS listeners, with `dnsNames` taken from each listener's `hostname`, issues into `app-tls`, and renews it. ```yaml apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: { name: letsencrypt } spec: acme: email: admin@example.com server: https://acme-v02.api.letsencrypt.org/directory privateKeySecretRef: { name: letsencrypt-account } solvers: - http01: gatewayHTTPRoute: { parentRefs: [{ name: eg, namespace: default, kind: Gateway }] } ``` ## 3. Authentication at the entry point Gateway API defines no authentication filter; each implementation adds its own. Envoy Gateway's `SecurityPolicy` attaches basic auth to a Gateway, HTTPRoute, or GRPCRoute from a Secret holding an htpasswd file. Its docs state that only SHA hashes are supported, which falls short of the bcrypt rule in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md): treat it as a gate over TLS with long random passwords, and keep the application's own login in place. At each of the block's two hidden prompts, paste the password alone: a multi-line paste feeds every line after the first to the next prompt or to your shell, which runs it. ```bash # -b would take the password from the command line, where `ps` and your shell history can # read it. Apache's own page says of it: "This option should be used with extreme care, # since the password is clearly visible on the command line. For script use see the -i # option." -i reads it from stdin instead. Envoy Gateway's example uses -b; this does not. # The hidden prompts keep the password out of shell history and set +x keeps it out of set -x # tracing. Stdin closes the argv channel only: the account owner and root can still read it in the # subshell and htpasswd. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n PASSWORD confirm && unset -v PASSWORD confirm; } 2>/dev/null || { echo 'cannot clear PASSWORD or confirm in this shell; not creating the secret'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not creating the secret'; exit 2; } IFS= read -r -s -p 'basic auth password: ' PASSWORD < /dev/tty || exit 2 echo IFS= read -r -s -p 'again, to confirm: ' confirm < /dev/tty || exit 2 echo [ -n "$PASSWORD" ] || { echo 'no password supplied; not creating the secret'; exit 2; } [ "$PASSWORD" = "$confirm" ] || { echo 'the two entries differ; not creating the secret'; exit 2; } set -o pipefail # so a failure anywhere in a pipeline below, not only in htpasswd, fails it # htpasswd -c writes a file. It goes in a private directory from mktemp -d (mode 0700), not the # current one. The EXIT trap removes that directory when the subshell reaches its end, whatever # kubectl returns, or leaves through one of the `exit` lines below, and the HUP, INT and TERM # traps after it turn a hangup, Ctrl-C or SIGTERM into such an exit. SIGKILL leaves the directory # and its .htpasswd behind, as can a signal before those traps are set, and in some measured runs # where the shell running the block was the terminal's session leader and was itself ended by the # signal (run directly as the terminal's process, without job control), so its exit hung up the # terminal, a hangup or SIGTERM did too; if that happens, remove it from your temporary directory # by hand. set -- "$(mktemp -d)" [ -d "$1" ] || { echo 'cannot create a private directory; not creating the secret'; exit 2; } trap 'rm -rf -- "$1"' EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM printf '%s' "$PASSWORD" | htpasswd -cis "$1/.htpasswd" admin || { echo 'password-file creation failed; not creating the secret'; exit 2; } # -i does no verification, so the block asks twice and refuses a mismatch; `-vi` below then # confirms the file holds that value, and the secret is created only if it does. Paste the block by # itself: the shell reads the whole subshell before either `read` runs. Without bracketed paste, # lines pasted after the closing `)` feed the two prompts instead, and the block refuses unless both # are identical; with bracketed paste, they run as commands once the block finishes. printf '%s' "$PASSWORD" | htpasswd -vi "$1/.htpasswd" admin || { echo 'htpasswd could not verify the file; not creating the secret'; exit 2; } # Sources has no kubectl reference for the key name this gives the file in the Secret, and the # runs recorded below used a stub kubectl, so before Envoy Gateway uses app-basic-auth, confirm # that the Secret has a .htpasswd key, listing its key names only and never printing its value. kubectl create secret generic app-basic-auth --from-file="$1/.htpasswd" ) ``` The block's runs on 2026-10-13 (UTC) used Bash 5.3.9 on a pty, a dummy password typed at the hidden prompts, stub `htpasswd` and `kubectl` executables, and logging wrappers around the real `mktemp` and `rm`. The block removed its directory in the 2026-10-13 runs after normal completion, a refused creation, a refused verification and a failed `kubectl`, and after Ctrl-C, SIGHUP or SIGTERM sent to its process group while `kubectl` waited, both pasted into an interactive shell and run as a script; SIGKILL left the directory and its `.htpasswd` behind. The block, when the shell running it was the terminal's session leader (run directly as the terminal's process, without job control), removed the directory after 50 Ctrl-C, 49 SIGHUP and 50 SIGTERM runs in 50 repeats, and after 20, 19 and 17 in an earlier 20 repeats of the same commands. Fifty repeats of the block without the HUP, INT and TERM traps, in the same session-leader setup, removed the directory after 50, 2 and 1. The session-leader shell was itself ended by the signal in the SIGHUP and SIGTERM runs, so its exit hung up the terminal. The dummy password reached the `htpasswd` stub only on stdin, never in any stub's argv, the `bash -x` trace or the terminal output. Stubs stood in for the cluster, so these runs do not show which key `kubectl` gives the file in the Secret. `-s` is SHA-1, which Apache describes as "insecure by today's standards", and it is not a choice here: Envoy Gateway's basic auth documentation says "only SHA hash algorithm is supported for now", so `-B` for bcrypt produces a file it cannot read. Treat this credential accordingly. It is a single shared secret protecting an entry point, it is only as strong as its own length, and a long random value is doing all of the work. Where that is not enough, the `oidc` block below moves the decision to an identity provider instead. ```yaml apiVersion: gateway.envoyproxy.io/v1alpha1 kind: SecurityPolicy metadata: { name: app-basic-auth } spec: targetRefs: [{ group: gateway.networking.k8s.io, kind: HTTPRoute, name: app }] basicAuth: { users: { name: app-basic-auth } } ``` For SSO, the same `SecurityPolicy` takes an `oidc` block (`provider.issuer`, `clientID`, a `clientSecret` Secret, `redirectURL`) so Envoy Gateway sends users to an OpenID Connect provider; enforce MFA at that provider ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). The portable alternative for any Gateway implementation is oauth2-proxy deployed in the cluster as the route's backend in front of the app, per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Authelia does not proxy traffic; the proxy calls its authorization endpoint, so it needs an implementation with external authorization. On Envoy Gateway the same `SecurityPolicy` takes an `extAuth.http` block whose `backendRefs` point at the Authelia Service and whose `path` is `/api/authz/ext-authz/`, per Authelia's Envoy Gateway page. Publishing through [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) is the other option. ## 4. Cluster posture - Expose workloads through the Gateway only; the Service Envoy Gateway creates for it in `envoy-gateway-system` is the one `LoadBalancer` in the cluster. - Databases stay `type: ClusterIP` (the default) and never get a route; NetworkPolicies limit which pods reach them, and their guides' TLS and auth still apply inside the cluster ([postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md), [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md), [mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md)). - Store credentials in Secrets (or an external secrets operator), not ConfigMaps or env literals in manifests committed to git ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ## 5. The control plane is a separate exposure Sections 1 to 4 cover how traffic reaches your workloads. None of it touches the cluster's own management surface, and a reader can apply every one of them while the API server answers the whole internet. **Managed clusters start public.** AWS documents that "[b]y default, this API server endpoint is public to the internet" for EKS. Restrict it: EKS supports private endpoint access and CIDR restrictions on the public one, GKE calls the same control "authorized networks", and AKS calls it authorized IP ranges. Whichever you run, the question to answer is which addresses can reach the API server, and the default answer is everyone. GKE needs a second look, because it has two control-plane endpoints and authorized networks only govern one. The vendor says authorized networks "provide an IP-based firewall that controls access to the GKE control plane", and that reaching the DNS-based endpoint is a different question entirely: "To access the control plane endpoint, you need to configure IAM roles and policies, and authentication tokens." So a GKE reader who restricts authorized networks exactly as this section says, and then probes the address in their kubeconfig, can still have a DNS-based endpoint answering from anywhere, gated by IAM alone. Check both, and if you do not use the DNS-based endpoint, disable it rather than leaving it to IAM. **A kubeconfig may be a credential, or only a pointer to one.** A file with an embedded token, or with a client certificate and its key, is the credential: anyone holding it has whatever it is bound to, with no second factor. A file that names an exec credential plugin is usually not, because the plugin fetches a fresh token when it runs, which is how EKS works with `aws eks get-token`, and copying it without the provider credentials the plugin depends on confers nothing. Usually, not always: the exec block carries `args` and `env`, and Kubernetes documents `env` as defining "additional environment variables to expose to the process", so a plugin can be handed its own credentials right there in the file. Read the whole block before you decide it is only a pointer. Open yours and find out which kind it is before you decide how to handle it. Treat the credential-bearing kind as a secret ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)), scope every kubeconfig with RBAC rather than handing out cluster-admin, and prefer the plugin form. **Self-managed clusters expose more ports.** Kubernetes documents the control plane's inbound ports as 6443 for the API server, 2379 and 2380 for etcd, 10250 for the kubelet API, 10259 for the scheduler and 10257 for the controller manager. Worker nodes have their own inbound list on the same page: 10250 again for the kubelet API, 10256 for kube-proxy health, and the NodePort range 30000 to 32767 over TCP and UDP. These are defaults, not fixtures. The same page notes that "One common example is API server port that is sometimes switched to 443", which is the port the managed providers serve on, so read the port out of your own kubeconfig rather than assuming 6443. Only the API server has any business being reachable beyond the cluster, and only from addresses you list. Kubernetes states that "By default, the API server stores plain-text representations of resources into etcd, with no at-rest encryption", so what is on that disk is every Secret in the cluster in the clear. Reaching 2379 is not the same as reading it, because Kubernetes' operating-etcd guide says "Once etcd is configured correctly, only clients with valid certificates can access it"; the point is that the certificate is the entire boundary, and there is no second one behind it. Kubernetes puts the consequence plainly: "Access to etcd is equivalent to root permission in the cluster so ideally only the API server should have access to it." Require client certificates on both the client and peer ports, and keep the client listener off any public interface. Do not simply move it to the private address: kubeadm points the API server at loopback, `127.0.0.1:2379` or `::1` depending on the address family it advertises, and configures etcd to listen there as well as on the advertised address, so dropping the loopback listener breaks the API server on that node. Keep whichever one your cluster configured rather than the literal written here. Keep issuance narrow rather than absolute, because kubeadm issues an `etcd-healthcheck-client` certificate of its own and backups need one too; the rule is that you should be able to name every holder. A firewall rule in front of an etcd still listening on `0.0.0.0` is one misconfiguration away from the same outcome. **The kubelet's defaults depend on how it is configured.** At Kubernetes v1.37.1 (commit `f78e722310e50bcaca9276be22276d9e91d91308`), the legacy command-line defaults are `--anonymous-auth=true`, `--authentication-token-webhook=false` and `--authorization-mode=AlwaysAllow`. The v1beta1 `KubeletConfiguration` defaults instead are `authentication.anonymous.enabled: false`, `authentication.webhook.enabled: true` and `authorization.mode: Webhook`. These are defaults for omitted settings, not enforced policy. An explicitly supplied command-line flag overrides the configuration file, as the pinned `--config` help states, and drop-in files in a `--config-dir` directory override both the defaults and the `--config` file, as the pinned `--config-dir` help states. Read the configuration file, any drop-in directory and the node's actual startup arguments together. Where flags configure these controls, set `--anonymous-auth=false`, `--authentication-token-webhook=true` and `--authorization-mode=Webhook`. The authentication documentation in Sources explains the resulting anonymous identity and webhook checks. kubeadm's v1.37.1 `KubeletConfiguration` sets anonymous authentication to false, token webhook authentication to true and authorization to `Webhook`, and leaves `readOnlyPort` at 0. Its source warns when a user supplies different values for these controls; it preserves those overrides rather than forcing the recommended values. A default kubeadm configuration therefore disables anonymous access and the read-only service, but kubeadm's involvement alone does not prove a node is safe. The separately pinned EKS AMI bootstrap cited below writes `Anonymous.Enabled: false`, `Mode: "Webhook"` and `ReadOnlyPort: 0`; do not generalize that snapshot to every managed distribution. **Both ports need a bind and network boundary.** The v1.37.1 default `address` is `0.0.0.0`, with the secured API on port 10250. The read-only server takes a bind address from its caller; that caller is outside the offline source subset, so its wiring to `address` was not verified here. The pinned `--address` help describes both `0.0.0.0` and `::` as listening on all interfaces and IP address families. Do not interpret `0.0.0.0` as an IPv4-only restriction: IPv6 can be included where the host supports dual-stack wildcard sockets. Bind `address` (or `--address`) to the node's intended private address and restrict access to the control plane and other authorized cluster clients. Check both address families. Port 10250 can expose container execution through its debugging handlers, so authentication does not make public reachability appropriate. **Disable the separate read-only service.** The command-line reference gives `--read-only-port` a default of 10255; the pinned legacy-default code assigns `ports.KubeletReadOnlyPort`, which `pkg/cluster/ports/ports.go:37` sets to 10255. The v1beta1 `readOnlyPort` field defaults to 0 (disabled). Set `readOnlyPort: 0` in the configuration file or `--read-only-port=0`, and remove any overriding startup flag that enables it. In v1.37.1 the read-only server uses plain HTTP and installs no authentication or authorization filter. It exposes `/pods` (pod specifications), `/stats/summary`, `/metrics`, `/metrics/cadvisor`, `/metrics/resource`, `/metrics/probes` and health checks. This version has no `/spec` handler; do not rely on historical endpoint lists. Securing 10250 does not secure 10255. GKE's cited documentation says the read-only port is disabled by default in **new clusters running 1.32 or later**. That is not proof that every older cluster still has it enabled, or that an upgraded cluster has it disabled. Check the provider's effective node configuration. The provider citations are retained context; this offline source audit did not recheck their current contents. ## Verify REASONED: following block; workload exposure and Gateway TLS/authentication, per the guide's cited Envoy Gateway tasks, secure gateways and TLS listener, and Kubernetes ports and protocols. No Kubernetes cluster or external probe hosts are available; local listeners and isolated network namespaces are denied. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics on an earlier form of this block, which also held the API reachability check and the node scans, before its app checks took the host on a guarded `set --` line. The form before this change runs every part in one subshell and classifies the redirect, the request without credentials and the credentialed positive control, and it lowercased the app host and the redirect line with `tr` before refusing a reserved name and comparing the redirect. On that form, bash -n exited 0 and stub runs with kubectl and curl stubbed gave the statuses its comments describe; the repository's gate suite runs ShellCheck 0.11.0 over every block, and no live demonstration was re-run. The current form refuses a reserved name, and compares the redirect's Location with the host, under bash's `nocasematch` option instead of through `tr`, and its app-check part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by kubectl and curl stub runs on 2026-11-01 with no network. In bash, that part refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probes. In those runs a 301 to the host in another letter case, with or without `:443`, passed the redirect check, also with `tr` absent or failing, and a 301 to another host was inconclusive. That part compares the redirect's Location with the host in a nested subshell that unsets LC_ALL and sets LC_ALL=C, because `nocasematch` folds case through the locale and a Turkish or Azerbaijani locale does not fold `I` to `i`; the C locale folds A to Z only, so the Location matches the host in any letter case in every locale, and a non-ASCII letter must match exactly. Its `grep -i` for the basic-auth challenge runs as `LC_ALL=C grep -i` for the same reason, so it does not miss an all-capital `WWW-AUTHENTICATE` header in a Turkish locale. Its requests, listing and other matches keep your locale, which curl uses to convert a non-ASCII host name. If LC_ALL is read-only, that part says so and exits 2 before any request, after trying `unset` in a subshell of its own. In stub runs on 2026-11-01 without the LC_ALL lines, LC_ALL=tr_TR.UTF-8 or LANG=tr_TR.UTF-8 made a 301 from `WIKI.ACME.TEST` to `https://wiki.acme.test/`, or from `wiki.acme.test` to `https://WIKI.ACME.TEST:443/`, inconclusive with 3, and let a credentialed answer carrying `WWW-AUTHENTICATE: Basic` pass the positive control. With them, both 301s passed and that answer was inconclusive under C, C.UTF-8 and both Turkish settings, also under an inherited `nocasematch`, `set -a` or `set -e`, a 301 to another host stayed inconclusive, and a read-only LC_ALL gave 2 and its message with no curl call. With real curl pointed at a missing Unix socket, so that nothing was looked up or connected, a non-ASCII host name reached curl's connect step with a UTF-8 locale set in LC_ALL, in LANG or in both, also under `set -a`, as before this change. Under dash, that part refused every host name with 2 and the bash message before its probes, and the whole block exited 3, because dash reads the first part's `$'\n'` differently and that part reported an inconclusive result. Five YAML blocks parsed. Live exposed and fixed outcomes described below remain unobserved. **REASONED, not demonstrated:** the live checks below need a cluster, which is unavailable; the authoring host forbids opening listeners without an isolated network namespace, and has none. The existing commands state the expected workload and API outcomes: exposed endpoints answer untrusted sources, while fixed workloads use the authenticated Gateway. The node-scan and kubelet expectations, with their sources, are stated after the node-scan block, in the marker for that block. ```bash # Run this where kubectl reaches the cluster. Every part runs in one subshell and the block exits once: # 1 for a finding, 3 for a failed command or an inconclusive result, 2 if the app checks refused their # input, and 0 otherwise. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 set -- "$1" "$(if kubectl get svc -A; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the Service list, then kubectl's exit status if printf '%s' "$2" | grep -E 'NodePort|LoadBalancer'; then :; else # only the Gateway's Service case "$?" in 1) ;; *) echo "grep could not read the Service list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi [ "$3" = 0 ] || { echo "kubectl get svc exit $3; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } kubectl get gateway/eg -o jsonpath='{.status.addresses[0].value}' || # the public address; DNS points here { echo "kubectl get gateway exit $?; inconclusive"; [ "$1" = 1 ] || set -- 3; } kubectl get certificate -A || # Ready=True { echo "kubectl get certificate exit $?; inconclusive"; [ "$1" = 1 ] || set -- 3; } # The node scans in the third Verify block, two blocks below, need every node's public addresses, worker nodes included. kubectl get nodes -o wide || # control-plane nodes and workers alike, but see below { echo "kubectl get nodes exit $?; inconclusive"; [ "$1" = 1 ] || set -- 3; } kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.addresses[*].address}{"\n"}{end}' || { echo "kubectl get nodes exit $?; inconclusive"; [ "$1" = 1 ] || set -- 3; } # -o wide prints only the FIRST external address per node # App checks, run directly with client proxies disabled (--noproxy '*' on every request). ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your Gateway listener and HTTPRoute serve, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again ( unset -v LC_ALL ) 2>/dev/null || { echo "LC_ALL is read-only in this shell, so the redirect's Location cannot be compared with your host name; not probing"; exit 2; } # unset only in a subshell of its own, so every command in this part keeps your locale set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sI -w 'http=%{http_code} location=%{redirect_url}\n' "http://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the headers and the http= line printf '%s' "$4" # 301 with Location: https:// and your host name set -- "$1" "$2" "$3" "${4%$'\n'}" set -- "$1" "$2" "$3" "${4##*$'\n'}" ( # a nested subshell, so only this comparison runs in the C locale and every other command keeps your locale { unset -v LC_ALL; } 2>/dev/null || exit 4 LC_ALL=C # nocasematch folds case by locale, and the C locale folds A to Z only, as host names need shopt -s nocasematch || exit 5 # the Location's scheme and host name ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$3:$4" in "0:http=301 location=https://$1/"*|"0:http=301 location=https://$1:443/"*) exit 0 ;; 0:*) exit 6 ;; esac exit 7 ) case "$?" in # the comparison's result; the messages are printed out here, in your locale 0) ;; 4) echo "LC_ALL is read-only in this shell, so the redirect's Location cannot be compared with your host name; not probing"; exit 2 ;; 5) echo "this block needs bash; run it in bash; not probing"; exit 2 ;; 6) echo "the plain HTTP request did not get a 301 to https://$1/; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; 7) echo "curl exit $3: the plain HTTP request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "the redirect comparison did not finish; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS -o /dev/null -w 'http=%{http_code}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the http= line printf '%s\n' "$4" # no credentials: 401 where basic auth is set case "$3:$4" in 0:http=401) ;; 0:http=2??) echo "FINDING: / answered without credentials, so the gateway's basic auth does not cover it"; set -- "$1" 1 ;; 0:*) echo "the request without credentials got neither the 401 pass nor a 2xx finding; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the request without credentials failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac # Positive control: confirm the SecurityPolicy attached, and that a valid basic-auth credential (from a # mode-0600 ~/.netrc-style file, never argv) is ACCEPTED by the gateway, so it reaches the app rather than # drawing the gateway's basic-auth 401. The app's own login may then answer, so the test is that you NO # LONGER get the gateway's 401 with `WWW-Authenticate: Basic`, not that `/` returns 2xx. kubectl get securitypolicy app-basic-auth -o yaml || # an Accepted "True" condition under .status.ancestors (PolicyStatus) { echo "kubectl get securitypolicy exit $?; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } if [ -r "$HOME/.secureconfig-app.netrc" ]; then set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS --netrc-file "$HOME/.secureconfig-app.netrc" -D - -o /dev/null "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response headers printf '%s\n' "$4" if [ "$3" != 0 ]; then echo "curl exit $3: the credentialed request failed, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 elif printf '%s\n' "$4" | LC_ALL=C grep -i '^www-authenticate: *basic' > /dev/null; then # LC_ALL=C for grep alone, so -i folds A to Z only, in every locale echo "the gateway's basic-auth challenge answered the credentialed request, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 else case "$?" in 1) ;; *) echo "grep could not read the response headers; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac fi else # a guard: the positive control needs its credential file echo "no readable ~/.secureconfig-app.netrc for the positive control: create it, mode 0600, with a valid credential; not probing with it" case "$2" in 0) set -- "$1" 2 ;; esac fi exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the Service, Gateway, Certificate, node and SecurityPolicy listings and the response headers are not classified, so read them against the comments above" exit "$1" ) ``` REASONED: following block; API targeting and access, per the guide's cited Kubernetes ports and protocols, Amazon EKS cluster endpoint access and curl exit codes. No Kubernetes cluster or external probe hosts are available. ShellCheck 0.11.0 and bash -n exited 0 without diagnostics on the earlier single Verify block that held this check. Both tools also exited 0 without diagnostics on an intermediate form whose check read HOST, PORT and URL with `read` and its field separator fixed to a space; stub runs of that form under `bash -u -c`, with the parser, nc and curl stubbed, reached both probes with the parsed URL for a normal parse and with `IFS=:` set beforehand. The same runs reached neither probe for a failed parse, a parser output without its URL field, a readonly `HOST`, `PORT`, `URL`, `PARSED` or `IFS=:`, or an integer `PARSED` after a failed parse; an integer `PARSED` given a parsed endpoint ended that shell with an arithmetic error before either probe. Fed on standard input to `bash -u -s`, the integer and readonly `PARSED` cases with a parsed endpoint abandoned the check, reached neither probe and let the next line run. A later stub run of that intermediate form with `readonly IFS=t` set beforehand reached both probes with mis-split fields, curl receiving ` 443 https://api.test/version`. Both tools exited 0 without diagnostics on the form that followed, whose check splits the parsed endpoint by parameter expansion rather than `read` and needs a non-empty HOST, an all-digit PORT, a URL that starts with https:// or http:// and holds no space, and the three fields to rejoin to the parsed line. Stub runs of that form under `bash -u -c`, with the parser, nc and curl stubbed, reached both probes with host api.test, port 443 and URL https://api.test/version for a normal parse, with `IFS=:` set beforehand and with `readonly IFS=t` set beforehand. The same runs printed the failure message and reached neither probe for a failed parse, a parser output without its URL field, a nonnumeric port and an integer `PARSED` after a failed parse; a readonly `URL` or `PARSED` abandoned the check with an error, reached neither probe and let the next line run. An integer `HOST` given a single-label name, and an integer `PARSED` given a parsed endpoint, each ended that shell with an unbound-variable or arithmetic error before either probe; under `bash +u -c` the integer `HOST` case printed the failure message instead, and fed to `bash -u -s` the integer and readonly `PARSED` cases abandoned the check and let the next line run. All of these runs covered forms whose check ran at top level, where its parse-failure arm ended with status 0, and none covered the later form that ran the check in a subshell whose parse-failure arm exited 3. The current form runs the whole block in one subshell, holds the endpoint in positional parameters instead of named variables, refuses with status 2 when the kubeconfig cannot be read or parsed, and classifies the nc and curl results. On the current form, bash -n exited 0, and stub runs with kubectl, nc and curl stubbed and the real parser gave the statuses its comments describe; the repository's gate suite runs ShellCheck 0.11.0 over every block, and no live demonstration was re-run. The embedded endpoint parser printed expected host, port and URL for five valid cases, rejected empty input with cannot parse an API server out of: and rejected a nonnumeric port with ValueError. Live reachability outcomes described below remain unobserved. ```bash # The API server endpoint, taken WHOLE. Do not rebuild it with :6443. Managed providers # serve the API on 443, and a probe of 6443 times out against a cluster that is answering # the internet on 443, which reads as a pass. # From a machine OUTSIDE any allowed range, against a cluster you are authorized to test. # This step needs python3 and nc on the machine you run it from, neither of which the # cluster provides for you. The node scans in the next block also need nmap there. # Run this block in a fresh shell: a function or alias named like a command it calls runs # in place of that command. ( # the whole block in one subshell, so an exit or an error below ends only this check set +e # errexit off here, so a probe that fails is classified below instead of ending the block if command -v python3 > /dev/null && command -v nc > /dev/null; then :; else echo "this check needs python3 and nc on the machine you run it from; not probing" exit 2 fi set -- "$(if kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # Whether a schemeless server means HTTP or HTTPS is decided by the TLS settings in the # same kubeconfig, on the cluster entry and the user entry both, so read those rather # than guessing. set -- "$1" "$(if kubectl config view --minify -o jsonpath='{.clusters[0].cluster.certificate-authority}{.clusters[0].cluster.certificate-authority-data}{.clusters[0].cluster.insecure-skip-tls-verify}{.users[0].user.client-certificate}{.users[0].user.client-certificate-data}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # A context with no user entry would make the TLS jsonpath error out; every real one names # a user, and kubectl says so if yours does not. case "${1##*$'\n'}:${2##*$'\n'}" in 0:0) set -- "${1%$'\n'*}" "${2%$'\n'*}" ;; # the server, then the TLS settings *) echo "kubectl config view could not read the API server or its TLS settings from your kubeconfig; not probing"; exit 2 ;; esac # Neutralize the proxy settings, all of them. curl reads ALL_PROXY and all_proxy as well as # the per-scheme variables, and it reads ~/.curlrc, which can also turn verification off. unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy NO_PROXY no_proxy || { echo "cannot clear the proxy variables in this shell; not probing"; exit 2; } # First at the TCP layer, because this is the only part with a clean answer. Did anything # accept a connection? parse_api_server() { # a function, not a multiline $(...), so the guard checker keeps its scope tracking for this block python3 - "$1" "$2" <<'PY' import sys, urllib.parse raw, tls = sys.argv[1].strip(), sys.argv[2].strip() if "://" not in raw: # Kubernetes starts at http:// and moves to https:// only when the config carries a # CA or insecure-skip-tls-verify on the cluster entry, or a client certificate on the # user entry. Guessing https here sent the probe to 443 against a cluster the client # would reach on 80. raw = ("https://" if tls else "http://") + raw p = urllib.parse.urlsplit(raw) if not p.hostname: sys.exit("cannot parse an API server out of: " + sys.argv[1]) print(p.hostname, p.port or (80 if p.scheme == "http" else 443), p.scheme + "://" + p.netloc) PY } # Parsed with python3 rather than cut with sed. A bracketed IPv6 address breaks on the # colons, and the failure looks exactly like the clean drop you were hoping for. # A bare host with no scheme is also legal in a kubeconfig, and the scheme it implies depends # on the TLS settings beside it rather than on a convention, so those are read and the URL the # probe uses is the normalized one. Getting that wrong sends both layers of this check at a # port the cluster was never using, where a refusal proves nothing. # "succeeded" means something is listening and reachable from here, whatever it does next. # Both probes run only when the parse succeeded and parameter expansion, not `read`, split its # output into a non-empty host, an all-digit port and a URL that starts with https:// or http:// # and holds no space, and only when those three fields rejoin to exactly the parsed line, so a # failure skips both and curl never reaches an empty URL. The fields stay in positional # parameters, so no variable inherited from your shell can alter them. set -- "$(if parse_api_server "$1" "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%$'\n'*}" # the parser's exit status, then its output set -- "$1" "${2%$'\n'}" # the status, then the parsed line set -- "$1" "$2" "${2%% *}" "${2#* }" # the status, the parsed line, the host, then the port and URL set -- "$1" "$2" "$3" "${4%% *}" "${4#* }" # the status, the parsed line, the host, the port, the URL if [ "$1" = 0 ] && [ "$3 $4 $5" = "$2" ] && [ -n "$3" ] && [ -n "${4##*[!0123456789]*}" ] && case $5 in *' '*) false ;; https://?*|http://?*) true ;; *) false ;; esac; then set -- "$3" "$4" "$5" # the host, the port, the URL else echo "could not parse the API server endpoint into a host, a numeric port and an http or https URL; skipping both probes" >&2 exit 2 fi echo "$3" # guard-conventions: allow probe of the API server endpoint read from your own kubeconfig; no reader-substituted placeholder in this probe's argv if nc -vz -w 3 "$1" "$2"; then set -- "$1" "$2" "$3" 0; else set -- "$1" "$2" "$3" "$?"; fi # the 4th value is nc's exit status # Then at the HTTP layer, with verification left ON, under the same check as the TCP probe. # guard-conventions: allow probe of the API server endpoint read from your own kubeconfig; no reader-substituted placeholder in this probe's argv set -- "$1" "$2" "$3" "$4" "$(if curl -q -g --noproxy '*' -sS -o /dev/null --connect-timeout 3 -m 10 -w '%{http_code} time_connect=%{time_connect}\n' "$3/version"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # nc's exit status, curl's exit status, then the HTTP code and time_connect printf '%s\n' "$6"; echo "curl exit $5" # Exit 0 with ANY status, 200, 401, 403 and 404 alike, means it answered. # So does exit 60: curl reached a TLS peer, received a certificate and refused to trust it. # That is not necessarily a completed handshake, and it does not need to be: something on # that address answered in TLS, and the 000 printed beside it is not a pass. # Exit 7 is "failed to connect", which is the outcome you want. Exit 28 is a TIMEOUT and it # is NOT clean evidence either way: a dropped packet and an endpoint that accepted the # connection and then stalled both produce it. If you get 28, the time_connect printed beside # the HTTP code tells you which one you had: it stays 0.000000 only when no connection # completed, so a 28 passes only then, and only when nc did not connect either. # Any other exit is neither, and two are worth naming. # Exit 6 is a name that did not resolve. Do not read that as proof of a private cluster: # EKS documents that a private-only endpoint is still "resolved by public DNS servers to a # private IP address from the VPC", so a correctly private cluster usually resolves fine # and simply refuses the connection. Exit 6 means your resolver had no answer, which is # worth understanding before you call it anything. # And a TLS-intercepting middlebox on your own network answers every outbound 443 with its own # certificate, so it prints exit 60 whether or not the cluster is reachable. Exit 60 means # SOMETHING answered, and on a network like that it may not be the thing you aimed at; the # unset above defeats a configured proxy and nothing defeats a transparent one except # testing from somewhere else. case "$4:$5:$6" in 0:*) echo "FINDING: nc connected to port $2, so something accepts TCP connections there from this host"; set -- 1 ;; *:0:*|*:60:*) echo "FINDING: curl exit $5, so something answered at $3 from this host"; set -- 1 ;; *:7:*|*:28:*' time_connect=0.000000') echo "nc did not connect and curl exit $5: nothing accepted a connection from this host"; set -- 0 ;; *:28:*) echo "curl exit 28 after a connection completed (time_connect above is not 0.000000), so something accepted the connection and then stalled; inconclusive"; set -- 3 ;; *:6:*) echo "curl exit 6: the name did not resolve, which is not proof of a private cluster; inconclusive"; set -- 3 ;; *) echo "curl exit $5 is neither an answer nor a refused or dropped connection; inconclusive"; set -- 3 ;; esac echo "block exit $1; a 0 says only that this host, at this moment, over this address family, could not reach the endpoint, so read the paragraphs below before you rely on it" exit "$1" ) ``` ```bash # Scan the control-plane nodes FIRST, because a worker shows 6443 and 2379 closed while # the control-plane host serves both, and a scan of a worker alone therefore passes on an # exposed control plane. Then scan every node that has a public address, worker nodes # included: 10250 and 10256 are worker ports, 10250 runs commands in containers, and a # kubeadm or k3s cluster built on cloud instances commonly gives every node a public # address. A managed cluster hides the control-plane nodes from you, but not its workers, # and the kubectl get nodes lines in the first Verify block tell you which nodes have a # public address. Scan the ones that do. # Paste the whole block once per node, with its IPv4 address and its IPv6 address, or NONE for a # node with no IPv6 address, substituted inside the quotes. A scan that names no host, or a name # that failed to resolve, scanned nothing and is inconclusive, never closed. ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a scan that fails is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ONE_NODE_ADDRESS' 'REPLACE_WITH_ITS_IPV6_ADDRESS_OR_NONE' # one public address of the node, then the same node's IPv6 address or NONE: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|-*|*[[:space:][:cntrl:]]*) echo "substitute one public address of the node inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|-*|*[[:space:][:cntrl:]]*) echo "substitute the node's IPv6 address, or NONE if it has none, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # From outside, every listed port should be closed or filtered: 10250 runs commands in # containers, and 2379 is the whole of etcd. set -- "$1" "$2" "$3" "$(if nmap -Pn -p 6443,2379,2380,10250,10255,10256,10257,10259 "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # nmap's exit status, then its report printf '%s\n' "$5" if [ "$4" != 0 ]; then echo "nmap exit $4: the IPv4 scan failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 elif printf '%s\n' "$5" | grep -E '^[0123456789]+/tcp +open( |$)' > /dev/null; then echo "FINDING: a listed port is open on $1 from this host"; set -- "$1" "$2" 1 else case "$?:$5" in 1:*'Nmap done: 1 IP address (1 host up)'*) ;; 1:*) echo "the IPv4 scan named no host or the name did not resolve, so it scanned nothing; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "grep could not read the IPv4 scan report; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac fi if [ "$2" != NONE ]; then # the same scan over IPv6 set -- "$1" "$2" "$3" "$(if nmap -6 -Pn -p 6443,2379,2380,10250,10255,10256,10257,10259 "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # nmap's exit status, then its report printf '%s\n' "$5" if [ "$4" != 0 ]; then echo "nmap exit $4: the IPv6 scan failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 elif printf '%s\n' "$5" | grep -E '^[0123456789]+/tcp +open( |$)' > /dev/null; then echo "FINDING: a listed port is open on $2 from this host"; set -- "$1" "$2" 1 else case "$?:$5" in 1:*'Nmap done: 1 IP address (1 host up)'*) ;; 1:*) echo "the IPv6 scan named no host or the name did not resolve, so it scanned nothing; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "grep could not read the IPv6 scan report; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac fi else echo "IPv6 not scanned: NONE was given for this node's IPv6 address" fi echo "block exit $3; a 0 says only that every listed port was closed or filtered from this host, so keep the authorized positive control described below" exit "$3" ) ``` REASONED: preceding block; node, kubelet and etcd scans with complete address coverage (the Gateway, service and node inventory, TLS/authentication and API reachability checks sit in the two blocks before it), per the guide's cited Kubernetes ports and protocols; Kubernetes, operating etcd clusters; Kubernetes v1.37.1 server; Kubernetes v1.37.1 source, v1beta1 defaults; and Nmap host discovery. No cluster, node inventory or external probe hosts are available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics on the earlier single block, before these node scans were split out and guarded. On the current form, which takes the node's IPv4 address and its IPv6 address or NONE on one guarded `set --` line and classifies the scan report, bash -n exited 0 and stub runs with nmap stubbed gave the statuses its comments describe; the repository's gate suite runs ShellCheck 0.11.0 over every block. No live scan ran: exposed endpoints answer untrusted sources; in the fixed state, restricted infrastructure stays reachable only from allowed sources, across all listed hosts and address families. For the node scans, an exposed kubelet is expected to show 10250 open and, when enabled, 10255 open; after private binding and network restrictions, neither should be reachable from an untrusted network, while an authorized cluster client must still reach 10250. With `readOnlyPort: 0`, 10255 should have no kubelet listener even from that authorized client. Check every configured address family and retain an authorized positive control so an unavailable node is not mistaken for successful isolation. The pinned listener/default sources below support these expectations; no live result is claimed. One name is not one address and one node is not the cluster. Repeat the scan for every node that has a public address, worker nodes included, and every address each one answers on, in both families: a host filtered on IPv4 while it answers on IPv6 passes every check above and is still reachable. The `kubectl get svc` line catches NodePort Services that exist, but nothing here probes the 30000 to 32767 NodePort range itself, over TCP or UDP, so scan that too if you run self-managed nodes with public addresses. And a cluster whose etcd runs on its own machines has hosts that `kubectl get nodes` never lists, because they carry no kubelet; take their client and peer addresses from wherever you configured them and scan those too. REASONED: multi-vantage corroboration and effective provider ranges, per the guide's cited Amazon EKS cluster endpoint access, GKE control plane network isolation, including how authorized networks work and AKS API server authorized IP ranges. No cluster configuration or separate probe networks are available. Related shell checks exited 0 without diagnostics on earlier forms of the Verify blocks above, in which the API-server check ran at top level. The second Verify block, the API reachability check, now runs that check in one subshell; bash -n and stub runs covered that form, as the marker before that block records, and no live demonstration was re-run. The endpoint parser, which that change left unchanged, produced the expected host, port and URL for five valid fixtures and rejected two invalid inputs. No live reachability was observed: exposed configuration admits an untrusted source; fixed configuration restricts every intended boundary while an allowed control still works. A failed probe alone remains inconclusive. Be clear about what a pass here is worth. It says that this host, at this moment, over this address family, could not reach that endpoint. It does not say the cluster is restricted, because your own egress, a stale kubeconfig context, a resolver, a tunnel that is down, or a transient failure at the far end all produce the same result, and because an allowlist that admits one network you forgot about is still an allowlist that admits it. Run it from more than one place before you believe it, and read the allowed ranges out of the provider's own configuration rather than inferring them from a probe. ## Sources (checked September 2026) - Ingress NGINX: Statement from the Kubernetes Steering and Security Response Committees (retirement, detection command): https://kubernetes.io/blog/2026/01/29/ingress-nginx-statement/ . Kubernetes sources: Kubernetes docs, Gateway API (migration guide from Ingress): https://kubernetes.io/docs/concepts/services-networking/gateway/ ; Kubernetes docs, Ingress (the project recommends Gateway; the Ingress API is frozen): https://kubernetes.io/docs/concepts/services-networking/ingress/ - Gateway API getting started (CRD install): https://gateway-api.sigs.k8s.io/guides/getting-started/introduction/ ; TLS: https://gateway-api.sigs.k8s.io/guides/user-guides/tls/ ; HTTP routing: https://gateway-api.sigs.k8s.io/guides/user-guides/http-routing/ ; redirects: https://gateway-api.sigs.k8s.io/guides/user-guides/http-redirect-rewrite/ - Envoy Gateway (rolling documentation, checked September 2026): https://gateway.envoyproxy.io/ ; Helm install: https://gateway.envoyproxy.io/v1.9/install/install-helm/ ; quickstart: https://gateway.envoyproxy.io/v1.9/tasks/quickstart/ - Envoy Gateway v1.9.1 quickstart manifest (GatewayClass `controllerName`): https://github.com/envoyproxy/gateway/releases/download/v1.9.1/quickstart.yaml - Envoy Gateway tasks, secure gateways and TLS listener (rolling documentation, checked September 2026): https://gateway.envoyproxy.io/docs/tasks/security/secure-gateways/ . Envoy Gateway documentation sources (1 of 2): basic auth: https://gateway.envoyproxy.io/docs/tasks/security/basic-auth/ ; OIDC: https://gateway.envoyproxy.io/docs/tasks/security/oidc/. Envoy Gateway documentation sources (2 of 2): external authorization (`extAuth`): https://gateway.envoyproxy.io/docs/tasks/security/ext-auth/ ; HTTP redirect: https://gateway.envoyproxy.io/docs/tasks/traffic/http-redirect/ - htpasswd, for `-i` rather than `-b` and what SHA-1 costs: https://httpd.apache.org/docs/2.4/programs/htpasswd.html - Authelia: proxy integration (the proxy calls the authorization endpoint): https://www.authelia.com/integration/proxies/introduction/ ; Envoy Gateway `SecurityPolicy` example: https://www.authelia.com/integration/kubernetes/envoy/gateway/ - kubectl JSONPath filter syntax: https://kubernetes.io/docs/reference/kubectl/jsonpath/ - curl exit codes, used to read the API server probe (6 could not resolve, 7 failed to connect, 28 timed out, 60 peer certificate not trusted): https://curl.se/libcurl/c/libcurl-errors.html - curl manual (`--noproxy '*'` disables proxies; `--netrc-file` reads credentials from a file): https://curl.se/docs/manpage.html - Nmap host discovery (`-Pn`): https://nmap.org/book/man-host-discovery.html ; port specification (`-p`): https://nmap.org/book/man-port-specification.html ; IPv6 scanning (`-6`): https://nmap.org/book/man-misc-options.html - Kubernetes ports and protocols (6443 API server, 2379 and 2380 etcd, 10250 kubelet, 10259 scheduler, 10257 controller manager): https://kubernetes.io/docs/reference/networking/ports-and-protocols/ - Kubernetes kubelet authentication and authorization (unrejected requests treated as anonymous, `--anonymous-auth`, `--authorization-mode=Webhook`): https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/ - Kubernetes v1.37.1 source, legacy flag defaults (anonymous true, webhook false, AlwaysAllow, read-only port constant): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L196-L224 . Kubernetes v1.37.1 sources (1 of 3): explicit flags override the file (`--config` help): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L281. Kubernetes v1.37.1 sources (2 of 3): drop-in `--config-dir` files override defaults and the file (`--config-dir` help): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L282. Kubernetes v1.37.1 sources (3 of 3): wildcard address families and read-only flag semantics: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L373-L398 ; read-only port constant 10255: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/cluster/ports/ports.go#L32-L37 - Kubernetes v1.37.1 source, v1beta1 defaults (`address`, secured port, anonymous false, webhook true, Webhook authorization): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/apis/config/v1beta1/defaults.go#L85-L102 . Kubernetes v1.37.1 sources: field contract (`0.0.0.0`, 10250, `readOnlyPort: 0`): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/staging/src/k8s.io/kubelet/config/v1beta1/types.go#L160-L176 - Kubernetes v1.37.1 kubeadm configuration (read-only port 0, anonymous false, webhook true): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/kubelet.go#L33-L48 . Kubernetes v1.37.1 sources (1 of 2): serialization: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/kubelet.go#L95-L100. Kubernetes v1.37.1 sources (2 of 2): authentication/authorization defaults and override warnings: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/kubelet.go#L144-L188 ; warning text: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/utils.go#L66-L71 - Kubernetes v1.37.1 server (secured address/port, read-only HTTP with nil auth): https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L181-L250 . Kubernetes v1.37.1 sources (1 of 3): conditional auth filter and debugging handlers: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L339-L375. Kubernetes v1.37.1 sources (2 of 3): auth-required container run and exec handlers: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L577-L610 ; read-only handlers, with no `/spec` registration: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L477-L572. Kubernetes v1.37.1 sources (3 of 3): `/stats/summary`: https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/stats/handler.go#L109-L133 - Kubernetes command-line and configuration references (usage context; the legacy port constant 10255 is pinned above in pkg/cluster/ports/ports.go): https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/ ; https://kubernetes.io/docs/reference/config-api/kubelet-config.v1beta1/ - Amazon EKS AMI node bootstrap, the default `KubeletConfiguration` it writes (`Anonymous.Enabled: false`, `Mode: "Webhook"`, `ReadOnlyPort: 0`): https://github.com/awslabs/amazon-eks-ami/blob/6caf8311a3c6a8da71ac7e5e83f9c2e06287039a/nodeadm/internal/kubelet/config.go - Kubernetes kubeconfig API reference (`ExecConfig`, whose `env` "defines additional environment variables to expose to the process"): https://kubernetes.io/docs/reference/config-api/kubeconfig.v1/ - Kubernetes encrypting confidential data at rest ("By default, the API server stores plain-text representations of resources into etcd, with no at-rest encryption"): https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/ - Kubernetes, operating etcd clusters, including securing communication and limiting access ("Access to etcd is equivalent to root permission in the cluster"): https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/ - GKE, disable the kubelet read-only port (disabled by default only in new clusters running 1.32 or later): https://docs.cloud.google.com/kubernetes-engine/docs/how-to/disable-kubelet-readonly-port - Amazon EKS cluster endpoint access ("[b]y default, this API server endpoint is public to the internet"; private endpoint DNS, "resolved by public DNS servers to a private IP address from the VPC"): https://docs.aws.amazon.com/eks/latest/userguide/cluster-endpoint.html - GKE control plane network isolation, including how authorized networks work: https://docs.cloud.google.com/kubernetes-engine/docs/concepts/network-isolation#how_authorized_networks_work - AKS API server authorized IP ranges: https://learn.microsoft.com/en-us/azure/aks/api-server-authorized-ip-ranges - cert-manager Gateway API usage (enabling support, annotations; since cert-manager 1.15, Gateway API support no longer requires a feature gate but still requires explicit enablement): https://cert-manager.io/docs/usage/gateway/ ; ACME HTTP-01 `gatewayHTTPRoute` solver: https://cert-manager.io/docs/configuration/acme/http01/ - Traefik Kubernetes Gateway API provider: https://doc.traefik.io/traefik/reference/install-configuration/providers/kubernetes/kubernetes-gateway/ ; Cilium Gateway API support: https://docs.cilium.io/en/stable/network/servicemesh/gateway-api/gateway-api/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - kubelet: Kubernetes kubelet/kubeadm source (v1.37.1) - kubernetes: Kubernetes documentation (unknown) - gateway: Gateway API documentation (unknown) - envoy: Envoy Gateway (v1.9.1) - cert-manager: cert-manager Gateway support minimum (1.15) - apache: Apache htpasswd documentation (unknown) - authelia: Authelia documentation (unknown) - eks: Amazon EKS documentation (unknown) - eks-ami: Amazon EKS AMI bootstrap (6caf8311a3c6a8da71ac7e5e83f9c2e06287039a) - gke: GKE documentation (unknown) - gke-min: GKE new-cluster read-only-port boundary (1.32) - aks: AKS documentation (unknown) - traefik: Traefik documentation (unknown) - cilium: Cilium documentation (unknown) - envoy-rolling: Envoy Gateway documentation (rolling) (unknown) - curl: curl documentation (unknown) - nmap: Nmap documentation (unknown) Claims (full source mapping in the JSON block below): - ingress-migration (REASONED; kubernetes): ingress-nginx retired in March 2026 with no subsequent updates; detect its labeled pods and migrate. Gateway API is recommended and Ingress API is frozen. - gateway-install (REASONED; envoy, gateway, envoy-rolling): Install Envoy Gateway chart v1.9.1 and its default Gateway CRDs; separately create GatewayClass eg with the documented controllerName and confirm Accepted=True. - gateway-alternatives (REASONED; traefik, cilium): Traefik uses providers.kubernetesGateway; Cilium uses gatewayAPI.enabled=true and requires kube-proxy replacement. - gateway-tls (REASONED; gateway, envoy-rolling): Gateway HTTPS listener 443 terminates TLS using app-tls for app.example.com; HTTP 80 is for redirect and ACME challenges. - gateway-route (REASONED; gateway): HTTPRoute app attaches only to the HTTPS listener via sectionName and forwards to the ClusterIP app Service on port 80. - gateway-redirect (REASONED; gateway, envoy-rolling): The HTTP route uses RequestRedirect with scheme https and statusCode 301. - cert-manager-enable (REASONED; cert-manager): From cert-manager 1.15, enable config.gatewayAPI.enabled; install Gateway CRDs before startup or restart cert-manager afterwards. - cert-manager-issue (REASONED; cert-manager): Gateway issuer annotations produce Certificates per HTTPS-listener Secret using listener hostnames; ACME gatewayHTTPRoute parentRefs selects the challenge Gateway. - basic-auth-policy (REASONED; gateway, envoy-rolling): Gateway API has no standard auth filter; Envoy SecurityPolicy attaches Basic auth to Gateway/HTTPRoute/GRPCRoute using an htpasswd Secret. - basic-auth-hash (REASONED; apache, envoy-rolling): Envoy supports SHA hashes only; htpasswd -s is weak SHA-1, so use long random passwords over TLS and keep application login. - basic-auth-input (REASONED; apache, envoy-rolling): htpasswd -i reads stdin, unlike argv-exposing -b; confirm twice, create with -cis, verify with -vi and only then create the Kubernetes Secret. - oidc (REASONED; envoy-rolling): Envoy SecurityPolicy oidc uses issuer, clientID, clientSecret and redirectURL; enforce MFA at the identity provider. - external-auth (REASONED; authelia, envoy-rolling): Authelia is an authorization endpoint, not a traffic proxy; Envoy extAuth.http targets its Service and /api/authz/ext-authz/. - workload-exposure (REASONED; envoy-rolling, kubernetes): Expose workloads only through the Gateway LoadBalancer; keep databases on ClusterIP without routes and enforce pod access with NetworkPolicies plus database TLS/auth. - secrets (REASONED; kubernetes): Store credentials in Secrets or an external operator, not committed ConfigMaps or environment literals; kubeconfigs containing credentials also need secret handling. - eks-endpoint (REASONED; eks): EKS API defaults public; restrict public CIDRs or use private access. Private-only endpoints can resolve publicly to private VPC addresses. - gke-endpoints (REASONED; gke): GKE authorized networks constrain IP-based access, not its separately IAM-gated DNS endpoint; inspect both and disable unused DNS access. - aks-endpoint (REASONED; aks): Restrict AKS API access with authorized IP ranges; workload Gateway protection does not secure the control plane. - kubeconfig (REASONED; kubernetes): Embedded tokens or client keys confer their RBAC access without another factor; exec-plugin configs may be only pointers, but args/env can themselves hold credentials. - api-port (REASONED; kubernetes, eks): API server commonly uses 6443, but providers may use 443; use the complete kubeconfig endpoint and its actual port. - etcd-ports (REASONED; kubernetes): etcd client/peer defaults are 2379/2380; require certificates, restrict holders and private access, and retain loopback listeners used by the local API server. - etcd-storage (REASONED; kubernetes): API resources default to plaintext storage in etcd; valid client certificates are the boundary and etcd access is equivalent to cluster root. - scheduler-port (REASONED; kubernetes): Scheduler default 10259 must remain restricted to cluster clients. - controller-port (REASONED; kubernetes): Controller-manager default 10257 must remain restricted to cluster clients. - proxy-port (REASONED; kubernetes): Worker kube-proxy health default 10256 needs separate node exposure checks. - nodeports (REASONED; kubernetes): NodePort defaults span 30000 to 32767 TCP/UDP; Service listings do not actively probe that range. - kubelet-legacy-anonymous (REASONED; kubelet): Legacy kubelet flags default anonymous-auth=true; explicitly disable it where flags control authentication. - kubelet-legacy-webhook (REASONED; kubelet): Legacy authentication-token-webhook defaults false; explicitly enable it where flags configure authentication. - kubelet-legacy-authz (REASONED; kubelet, kubernetes): Legacy authorization-mode defaults AlwaysAllow; set Webhook where flags configure authorization. - kubelet-file-anonymous (REASONED; kubelet): v1beta1 KubeletConfiguration instead defaults authentication.anonymous.enabled=false. - kubelet-file-webhook (REASONED; kubelet): v1beta1 KubeletConfiguration defaults authentication.webhook.enabled=true. - kubelet-file-authz (REASONED; kubelet): v1beta1 KubeletConfiguration defaults authorization.mode=Webhook. - kubelet-precedence (REASONED; kubelet): Explicit flags override --config; --config-dir drop-ins override defaults and the file. Inspect all effective settings together. - kubeadm (REASONED; kubelet): kubeadm sets anonymous false, token webhook true, Webhook authorization and readOnlyPort 0; user overrides are preserved with warnings. - eks-bootstrap (REASONED; eks-ami): The pinned EKS AMI bootstrap writes Anonymous.Enabled=false, Mode=Webhook and ReadOnlyPort=0; do not generalize to all managed nodes. - kubelet-bind (REASONED; kubelet): Secured kubelet defaults to address 0.0.0.0 on 10250; wildcard help includes both families. Bind private addresses and restrict authorized callers. - kubelet-exec (REASONED; kubelet): 10250 debugging handlers can execute in containers; authentication does not justify public reachability. - readonly-default (REASONED; kubelet): Legacy read-only port defaults 10255, while v1beta1 readOnlyPort defaults 0; explicitly disable it and remove enabling overrides. - readonly-auth (REASONED; kubelet): Read-only server uses plain HTTP with no authentication or authorization filter; its caller-supplied bind wiring was outside the inspected source subset. - readonly-paths (REASONED; kubelet): Read-only handlers expose /pods, /stats/summary, metrics variants and health; v1.37.1 has no /spec handler. Securing 10250 does not secure 10255. - gke-readonly (REASONED; gke-min): GKE disables the read-only port by default only for new clusters running 1.32+; inspect effective configuration on older or upgraded clusters. - verify-services (REASONED; kubernetes, envoy-rolling, cert-manager): Only the Gateway should have NodePort/LoadBalancer exposure; verify its public address/DNS and Certificate Ready=True. - verify-redirect (REASONED; gateway): HTTP should return 301 to the HTTPS application URL. - verify-basic (REASONED; curl, envoy-rolling): Anonymous Basic-protected access should return 401; require SecurityPolicy Accepted=True and a valid credential reaching the app without the gateway Basic challenge. - verify-api-target (REASONED; kubernetes): Use the whole kubeconfig endpoint, preserving IPv6 and inferring a schemeless endpoint from both cluster and user TLS settings, not a hardcoded 6443. - verify-proxies (REASONED; curl): Disable curl config and configured proxies for direct probes; a transparent TLS middlebox can still answer for an unreachable target. - verify-api-tcp (REASONED; kubernetes, eks): Outside allowed ranges, nc success proves something accepted TCP; corroborate failed probes with provider ranges and multiple vantage points. - verify-api-http (REASONED; curl, eks): Any HTTP status with curl exit 0 means answered; exit 60 means a TLS peer answered, 7 failed connect, 28 is ambiguous timeout and 6 is DNS failure, not privacy proof. - verify-node-scan (REASONED; kubernetes, nmap): Scan control-plane hosts first and every public node address in both families; workers alone miss API/etcd and separate etcd hosts are absent from kubectl nodes. - verify-kubelet (REASONED; kubelet): Untrusted clients should reach neither kubelet port after isolation, while authorized clients still reach 10250; readOnlyPort=0 should leave no 10255 listener even for authorized clients. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "08dbbdd50a94f03ed62e67c53f597cce1df7cbf385c8ff1c300c0dc0d0f2d215", "components": { "kubelet": { "name": "Kubernetes kubelet/kubeadm source", "basis": "v1.37.1", "sources": { "sc6056d6413a0": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L196-L224", "s227d2a259fd3": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L373-L398", "s568cd52cc674": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/apis/config/v1beta1/defaults.go#L85-L102", "s2daf96dabbdf": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L281", "s24ba323c3dab": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubelet/app/options/options.go#L282", "sd81692a1876f": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/kubelet.go#L33-L48", "s0eb30acf2f46": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/kubelet.go#L95-L100", "s1c86aaef7307": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/kubelet.go#L144-L188", "sb98bd327db92": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/cmd/kubeadm/app/componentconfigs/utils.go#L66-L71", "s3fc405855b4a": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/staging/src/k8s.io/kubelet/config/v1beta1/types.go#L160-L176", "s98cf98130201": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L181-L250", "s8bea69523ed9": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L339-L375", "sa02a895a475c": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/cluster/ports/ports.go#L32-L37", "s6b753b247f8a": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L477-L572", "sd00d4cdb9276": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/stats/handler.go#L109-L133", "s02441dcae3cf": "https://github.com/kubernetes/kubernetes/blob/f78e722310e50bcaca9276be22276d9e91d91308/pkg/kubelet/server/server.go#L577-L610" } }, "kubernetes": { "name": "Kubernetes documentation", "basis": "unknown", "sources": { "s0f11d6ed39b7": "https://kubernetes.io/blog/2026/01/29/ingress-nginx-statement/", "sca23283c5a9b": "https://kubernetes.io/docs/concepts/services-networking/gateway/", "sd8c8df3b9ddc": "https://kubernetes.io/docs/concepts/services-networking/ingress/", "se41a53ea6778": "https://kubernetes.io/docs/reference/config-api/kubeconfig.v1/", "sae633285120e": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/", "sd59fb2f35cd7": "https://kubernetes.io/docs/reference/networking/ports-and-protocols/", "s01f081fed0ff": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/", "s49a29258961b": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/", "s04f0c3099fd7": "https://kubernetes.io/docs/reference/kubectl/jsonpath/" } }, "gateway": { "name": "Gateway API documentation", "basis": "unknown", "sources": { "se0e45c61dc65": "https://gateway-api.sigs.k8s.io/guides/getting-started/introduction/", "se1f28d98d168": "https://gateway-api.sigs.k8s.io/guides/user-guides/tls/", "s4a971425e1d1": "https://gateway-api.sigs.k8s.io/guides/user-guides/http-routing/", "s8d02fbc742ba": "https://gateway-api.sigs.k8s.io/guides/user-guides/http-redirect-rewrite/" } }, "envoy": { "name": "Envoy Gateway", "basis": "v1.9.1", "sources": { "s6412abbdfdbd": "https://github.com/envoyproxy/gateway/releases/download/v1.9.1/quickstart.yaml" } }, "cert-manager": { "name": "cert-manager Gateway support minimum", "basis": "1.15", "sources": { "s72bdb97928a8": "https://cert-manager.io/docs/usage/gateway/", "s8d82244c2d6d": "https://cert-manager.io/docs/configuration/acme/http01/" } }, "apache": { "name": "Apache htpasswd documentation", "basis": "unknown", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "authelia": { "name": "Authelia documentation", "basis": "unknown", "sources": { "s0cb4f49871b5": "https://www.authelia.com/integration/proxies/introduction/", "scfa9e2cc2ef5": "https://www.authelia.com/integration/kubernetes/envoy/gateway/" } }, "eks": { "name": "Amazon EKS documentation", "basis": "unknown", "sources": { "s9724ce882930": "https://docs.aws.amazon.com/eks/latest/userguide/cluster-endpoint.html" } }, "eks-ami": { "name": "Amazon EKS AMI bootstrap", "basis": "6caf8311a3c6a8da71ac7e5e83f9c2e06287039a", "sources": { "sbe1fce93c7b2": "https://github.com/awslabs/amazon-eks-ami/blob/6caf8311a3c6a8da71ac7e5e83f9c2e06287039a/nodeadm/internal/kubelet/config.go" } }, "gke": { "name": "GKE documentation", "basis": "unknown", "sources": { "s0f44d89575ed": "https://docs.cloud.google.com/kubernetes-engine/docs/concepts/network-isolation#how_authorized_networks_work" } }, "gke-min": { "name": "GKE new-cluster read-only-port boundary", "basis": "1.32", "sources": { "s4c7be4adcab4": "https://docs.cloud.google.com/kubernetes-engine/docs/how-to/disable-kubelet-readonly-port" } }, "aks": { "name": "AKS documentation", "basis": "unknown", "sources": { "s897dd27ab9a1": "https://learn.microsoft.com/en-us/azure/aks/api-server-authorized-ip-ranges" } }, "traefik": { "name": "Traefik documentation", "basis": "unknown", "sources": { "s06f2ee45331e": "https://doc.traefik.io/traefik/reference/install-configuration/providers/kubernetes/kubernetes-gateway/" } }, "cilium": { "name": "Cilium documentation", "basis": "unknown", "sources": { "sacaa7a78461d": "https://docs.cilium.io/en/stable/network/servicemesh/gateway-api/gateway-api/" } }, "envoy-rolling": { "name": "Envoy Gateway documentation (rolling)", "basis": "unknown", "sources": { "se833317feea7": "https://gateway.envoyproxy.io/docs/tasks/security/secure-gateways/", "sb5fa4f4ffa1a": "https://gateway.envoyproxy.io/docs/tasks/security/basic-auth/", "s7fdd76c5d549": "https://gateway.envoyproxy.io/docs/tasks/security/oidc/", "s100049c74adf": "https://gateway.envoyproxy.io/docs/tasks/security/ext-auth/", "s9b1d646cb718": "https://gateway.envoyproxy.io/docs/tasks/traffic/http-redirect/", "s881f08174c54": "https://gateway.envoyproxy.io/v1.9/install/install-helm/", "s9ad0f418b20b": "https://gateway.envoyproxy.io/v1.9/tasks/quickstart/" } }, "curl": { "name": "curl documentation", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html", "s7d4a6e627b1b": "https://curl.se/libcurl/c/libcurl-errors.html" } }, "nmap": { "name": "Nmap documentation", "basis": "unknown", "sources": { "sb7d0e7eb5141": "https://nmap.org/book/man-host-discovery.html", "s1818abeb3f92": "https://nmap.org/book/man-port-specification.html", "s79c95712dd8d": "https://nmap.org/book/man-misc-options.html" } } }, "claims": { "ingress-migration": {"text": "ingress-nginx retired in March 2026 with no subsequent updates; detect its labeled pods and migrate. Gateway API is recommended and Ingress API is frozen.", "components": ["kubernetes"], "sources": ["kubernetes:s0f11d6ed39b7", "kubernetes:sca23283c5a9b", "kubernetes:sd8c8df3b9ddc"], "status": "REASONED"}, "gateway-install": {"text": "Install Envoy Gateway chart v1.9.1 and its default Gateway CRDs; separately create GatewayClass eg with the documented controllerName and confirm Accepted=True.", "components": ["envoy", "gateway", "envoy-rolling"], "sources": ["envoy-rolling:s881f08174c54", "envoy:s6412abbdfdbd", "gateway:se0e45c61dc65"], "status": "REASONED"}, "gateway-alternatives": {"text": "Traefik uses providers.kubernetesGateway; Cilium uses gatewayAPI.enabled=true and requires kube-proxy replacement.", "components": ["traefik", "cilium"], "sources": ["traefik:s06f2ee45331e", "cilium:sacaa7a78461d"], "status": "REASONED"}, "gateway-tls": {"text": "Gateway HTTPS listener 443 terminates TLS using app-tls for app.example.com; HTTP 80 is for redirect and ACME challenges.", "components": ["gateway", "envoy-rolling"], "sources": ["gateway:se1f28d98d168", "envoy-rolling:se833317feea7"], "status": "REASONED"}, "gateway-route": {"text": "HTTPRoute app attaches only to the HTTPS listener via sectionName and forwards to the ClusterIP app Service on port 80.", "components": ["gateway"], "sources": ["gateway:s4a971425e1d1"], "status": "REASONED"}, "gateway-redirect": {"text": "The HTTP route uses RequestRedirect with scheme https and statusCode 301.", "components": ["gateway", "envoy-rolling"], "sources": ["gateway:s8d02fbc742ba", "envoy-rolling:s9b1d646cb718"], "status": "REASONED"}, "cert-manager-enable": {"text": "From cert-manager 1.15, enable config.gatewayAPI.enabled; install Gateway CRDs before startup or restart cert-manager afterwards.", "components": ["cert-manager"], "sources": ["cert-manager:s72bdb97928a8"], "status": "REASONED"}, "cert-manager-issue": {"text": "Gateway issuer annotations produce Certificates per HTTPS-listener Secret using listener hostnames; ACME gatewayHTTPRoute parentRefs selects the challenge Gateway.", "components": ["cert-manager"], "sources": ["cert-manager:s72bdb97928a8", "cert-manager:s8d82244c2d6d"], "status": "REASONED"}, "basic-auth-policy": {"text": "Gateway API has no standard auth filter; Envoy SecurityPolicy attaches Basic auth to Gateway/HTTPRoute/GRPCRoute using an htpasswd Secret.", "components": ["gateway", "envoy-rolling"], "sources": ["gateway:s4a971425e1d1", "envoy-rolling:sb5fa4f4ffa1a"], "status": "REASONED"}, "basic-auth-hash": {"text": "Envoy supports SHA hashes only; htpasswd -s is weak SHA-1, so use long random passwords over TLS and keep application login.", "components": ["apache", "envoy-rolling"], "sources": ["envoy-rolling:sb5fa4f4ffa1a", "apache:scde9004bb967"], "status": "REASONED"}, "basic-auth-input": {"text": "htpasswd -i reads stdin, unlike argv-exposing -b; confirm twice, create with -cis, verify with -vi and only then create the Kubernetes Secret.", "components": ["apache", "envoy-rolling"], "sources": ["apache:scde9004bb967", "envoy-rolling:sb5fa4f4ffa1a"], "status": "REASONED"}, "oidc": {"text": "Envoy SecurityPolicy oidc uses issuer, clientID, clientSecret and redirectURL; enforce MFA at the identity provider.", "components": ["envoy-rolling"], "sources": ["envoy-rolling:s7fdd76c5d549"], "status": "REASONED"}, "external-auth": {"text": "Authelia is an authorization endpoint, not a traffic proxy; Envoy extAuth.http targets its Service and /api/authz/ext-authz/.", "components": ["authelia", "envoy-rolling"], "sources": ["authelia:s0cb4f49871b5", "authelia:scfa9e2cc2ef5", "envoy-rolling:s100049c74adf"], "status": "REASONED"}, "workload-exposure": {"text": "Expose workloads only through the Gateway LoadBalancer; keep databases on ClusterIP without routes and enforce pod access with NetworkPolicies plus database TLS/auth.", "components": ["envoy-rolling", "kubernetes"], "sources": ["envoy-rolling:s9ad0f418b20b", "kubernetes:sca23283c5a9b"], "status": "REASONED"}, "secrets": {"text": "Store credentials in Secrets or an external operator, not committed ConfigMaps or environment literals; kubeconfigs containing credentials also need secret handling.", "components": ["kubernetes"], "sources": ["kubernetes:se41a53ea6778", "kubernetes:sae633285120e"], "status": "REASONED"}, "eks-endpoint": {"text": "EKS API defaults public; restrict public CIDRs or use private access. Private-only endpoints can resolve publicly to private VPC addresses.", "components": ["eks"], "sources": ["eks:s9724ce882930"], "status": "REASONED"}, "gke-endpoints": {"text": "GKE authorized networks constrain IP-based access, not its separately IAM-gated DNS endpoint; inspect both and disable unused DNS access.", "components": ["gke"], "sources": ["gke:s0f44d89575ed"], "status": "REASONED"}, "aks-endpoint": {"text": "Restrict AKS API access with authorized IP ranges; workload Gateway protection does not secure the control plane.", "components": ["aks"], "sources": ["aks:s897dd27ab9a1"], "status": "REASONED"}, "kubeconfig": {"text": "Embedded tokens or client keys confer their RBAC access without another factor; exec-plugin configs may be only pointers, but args/env can themselves hold credentials.", "components": ["kubernetes"], "sources": ["kubernetes:se41a53ea6778"], "status": "REASONED"}, "api-port": {"text": "API server commonly uses 6443, but providers may use 443; use the complete kubeconfig endpoint and its actual port.", "components": ["kubernetes", "eks"], "sources": ["kubernetes:sd59fb2f35cd7", "eks:s9724ce882930"], "status": "REASONED"}, "etcd-ports": {"text": "etcd client/peer defaults are 2379/2380; require certificates, restrict holders and private access, and retain loopback listeners used by the local API server.", "components": ["kubernetes"], "sources": ["kubernetes:sd59fb2f35cd7", "kubernetes:s01f081fed0ff"], "status": "REASONED"}, "etcd-storage": {"text": "API resources default to plaintext storage in etcd; valid client certificates are the boundary and etcd access is equivalent to cluster root.", "components": ["kubernetes"], "sources": ["kubernetes:sae633285120e", "kubernetes:s01f081fed0ff"], "status": "REASONED"}, "scheduler-port": {"text": "Scheduler default 10259 must remain restricted to cluster clients.", "components": ["kubernetes"], "sources": ["kubernetes:sd59fb2f35cd7"], "status": "REASONED"}, "controller-port": {"text": "Controller-manager default 10257 must remain restricted to cluster clients.", "components": ["kubernetes"], "sources": ["kubernetes:sd59fb2f35cd7"], "status": "REASONED"}, "proxy-port": {"text": "Worker kube-proxy health default 10256 needs separate node exposure checks.", "components": ["kubernetes"], "sources": ["kubernetes:sd59fb2f35cd7"], "status": "REASONED"}, "nodeports": {"text": "NodePort defaults span 30000 to 32767 TCP/UDP; Service listings do not actively probe that range.", "components": ["kubernetes"], "sources": ["kubernetes:sd59fb2f35cd7"], "status": "REASONED"}, "kubelet-legacy-anonymous": {"text": "Legacy kubelet flags default anonymous-auth=true; explicitly disable it where flags control authentication.", "components": ["kubelet"], "sources": ["kubelet:sc6056d6413a0", "kubelet:s227d2a259fd3"], "status": "REASONED"}, "kubelet-legacy-webhook": {"text": "Legacy authentication-token-webhook defaults false; explicitly enable it where flags configure authentication.", "components": ["kubelet"], "sources": ["kubelet:sc6056d6413a0"], "status": "REASONED"}, "kubelet-legacy-authz": {"text": "Legacy authorization-mode defaults AlwaysAllow; set Webhook where flags configure authorization.", "components": ["kubelet", "kubernetes"], "sources": ["kubelet:sc6056d6413a0", "kubernetes:s49a29258961b"], "status": "REASONED"}, "kubelet-file-anonymous": {"text": "v1beta1 KubeletConfiguration instead defaults authentication.anonymous.enabled=false.", "components": ["kubelet"], "sources": ["kubelet:s568cd52cc674"], "status": "REASONED"}, "kubelet-file-webhook": {"text": "v1beta1 KubeletConfiguration defaults authentication.webhook.enabled=true.", "components": ["kubelet"], "sources": ["kubelet:s568cd52cc674"], "status": "REASONED"}, "kubelet-file-authz": {"text": "v1beta1 KubeletConfiguration defaults authorization.mode=Webhook.", "components": ["kubelet"], "sources": ["kubelet:s568cd52cc674"], "status": "REASONED"}, "kubelet-precedence": {"text": "Explicit flags override --config; --config-dir drop-ins override defaults and the file. Inspect all effective settings together.", "components": ["kubelet"], "sources": ["kubelet:s2daf96dabbdf", "kubelet:s24ba323c3dab"], "status": "REASONED"}, "kubeadm": {"text": "kubeadm sets anonymous false, token webhook true, Webhook authorization and readOnlyPort 0; user overrides are preserved with warnings.", "components": ["kubelet"], "sources": ["kubelet:sd81692a1876f", "kubelet:s0eb30acf2f46", "kubelet:s1c86aaef7307", "kubelet:sb98bd327db92"], "status": "REASONED"}, "eks-bootstrap": {"text": "The pinned EKS AMI bootstrap writes Anonymous.Enabled=false, Mode=Webhook and ReadOnlyPort=0; do not generalize to all managed nodes.", "components": ["eks-ami"], "sources": ["eks-ami:sbe1fce93c7b2"], "status": "REASONED"}, "kubelet-bind": {"text": "Secured kubelet defaults to address 0.0.0.0 on 10250; wildcard help includes both families. Bind private addresses and restrict authorized callers.", "components": ["kubelet"], "sources": ["kubelet:s568cd52cc674", "kubelet:s3fc405855b4a", "kubelet:s227d2a259fd3", "kubelet:s98cf98130201"], "status": "REASONED"}, "kubelet-exec": {"text": "10250 debugging handlers can execute in containers; authentication does not justify public reachability.", "components": ["kubelet"], "sources": ["kubelet:s8bea69523ed9", "kubelet:s3fc405855b4a", "kubelet:s98cf98130201", "kubelet:s02441dcae3cf"], "status": "REASONED"}, "readonly-default": {"text": "Legacy read-only port defaults 10255, while v1beta1 readOnlyPort defaults 0; explicitly disable it and remove enabling overrides.", "components": ["kubelet"], "sources": ["kubelet:sc6056d6413a0", "kubelet:sa02a895a475c", "kubelet:s3fc405855b4a"], "status": "REASONED"}, "readonly-auth": {"text": "Read-only server uses plain HTTP with no authentication or authorization filter; its caller-supplied bind wiring was outside the inspected source subset.", "components": ["kubelet"], "sources": ["kubelet:s98cf98130201", "kubelet:s8bea69523ed9"], "status": "REASONED"}, "readonly-paths": {"text": "Read-only handlers expose /pods, /stats/summary, metrics variants and health; v1.37.1 has no /spec handler. Securing 10250 does not secure 10255.", "components": ["kubelet"], "sources": ["kubelet:s6b753b247f8a", "kubelet:sd00d4cdb9276"], "status": "REASONED"}, "gke-readonly": {"text": "GKE disables the read-only port by default only for new clusters running 1.32+; inspect effective configuration on older or upgraded clusters.", "components": ["gke-min"], "sources": ["gke-min:s4c7be4adcab4"], "status": "REASONED"}, "verify-services": {"text": "Only the Gateway should have NodePort/LoadBalancer exposure; verify its public address/DNS and Certificate Ready=True.", "components": ["kubernetes", "envoy-rolling", "cert-manager"], "sources": ["kubernetes:s04f0c3099fd7", "envoy-rolling:s9ad0f418b20b", "cert-manager:s72bdb97928a8"], "status": "REASONED", "verify": [1]}, "verify-redirect": {"text": "HTTP should return 301 to the HTTPS application URL.", "components": ["gateway"], "sources": ["gateway:s8d02fbc742ba"], "status": "REASONED", "verify": [1]}, "verify-basic": {"text": "Anonymous Basic-protected access should return 401; require SecurityPolicy Accepted=True and a valid credential reaching the app without the gateway Basic challenge.", "components": ["curl", "envoy-rolling"], "sources": ["envoy-rolling:sb5fa4f4ffa1a", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-api-target": {"text": "Use the whole kubeconfig endpoint, preserving IPv6 and inferring a schemeless endpoint from both cluster and user TLS settings, not a hardcoded 6443.", "components": ["kubernetes"], "sources": ["kubernetes:se41a53ea6778", "kubernetes:sd59fb2f35cd7"], "status": "REASONED", "verify": [2]}, "verify-proxies": {"text": "Disable curl config and configured proxies for direct probes; a transparent TLS middlebox can still answer for an unreachable target.", "components": ["curl"], "sources": ["curl:s2b2686afaf41", "curl:s7d4a6e627b1b"], "status": "REASONED", "verify": [2]}, "verify-api-tcp": {"text": "Outside allowed ranges, nc success proves something accepted TCP; corroborate failed probes with provider ranges and multiple vantage points.", "components": ["kubernetes", "eks"], "sources": ["kubernetes:sd59fb2f35cd7", "eks:s9724ce882930"], "status": "REASONED", "verify": [2]}, "verify-api-http": {"text": "Any HTTP status with curl exit 0 means answered; exit 60 means a TLS peer answered, 7 failed connect, 28 is ambiguous timeout and 6 is DNS failure, not privacy proof.", "components": ["curl", "eks"], "sources": ["curl:s7d4a6e627b1b", "eks:s9724ce882930"], "status": "REASONED", "verify": [2]}, "verify-node-scan": {"text": "Scan control-plane hosts first and every public node address in both families; workers alone miss API/etcd and separate etcd hosts are absent from kubectl nodes.", "components": ["kubernetes", "nmap"], "sources": ["kubernetes:sd59fb2f35cd7", "kubernetes:s01f081fed0ff", "nmap:sb7d0e7eb5141", "nmap:s1818abeb3f92", "nmap:s79c95712dd8d"], "status": "REASONED", "verify": [1, 3]}, "verify-kubelet": {"text": "Untrusted clients should reach neither kubelet port after isolation, while authorized clients still reach 10250; readOnlyPort=0 should leave no 10255 listener even for authorized clients.", "components": ["kubelet"], "sources": ["kubelet:s98cf98130201", "kubelet:s3fc405855b4a"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> gitops-controllers.md ====================================================================== # GitOps controllers: Argo CD and Flux Argo CD and Flux are GitOps tools: they watch a Git repository and keep a Kubernetes cluster matching it, using whatever permissions they are granted. This guide helps your AI assistant configure each securely, including guarded admin access, reviewed changes to the repositories they deploy, narrow credentials, and restricted egress. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/gitops-controllers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Argo CD v3.5.3 source (pinned to a commit), Flux OIDC introduction v2.9, Dex source v2.45.1, Redis source 8.2.3, curl curl-8_14_1, go-grpc-middleware source v2.3.3, and go-grpc-middleware providers/prometheus source v1.1.0; 4 further sources are pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-10-19; documentation checked September 2026. The guide records 57 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Argo CD and Flux reconcile a git repository into a Kubernetes cluster, so both hold cluster-admin-equivalent power: whoever can drive the controller, or change what it reconciles, can deploy anything it can reach, across the clusters it is registered against and to the extent the controller's Kubernetes RBAC, its registered-cluster credentials, and project restrictions allow. Three exposures follow from that and apply to both. The blast radius is the cluster (or clusters), so administrative access deserves the same care as the Kubernetes API itself ([kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md)). Git is an authorization boundary: whoever can merge to a reconciled repository, or redirect a source, exercises the controller's permissions, so platform repositories need reviewed changes and separation from tenant repositories. And the credentials the controllers hold (git deploy keys, cluster Secrets) should be read-only and narrowly scoped where the work allows. Handle every credential class per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md): bootstrap tokens, git and registry credentials (Flux `OCIRepository`/`HelmRepository` `.spec.secretRef`), cluster credentials, webhook secrets, and decryption keys. Never commit plaintext credentials or base64-only Secret manifests; encrypted SOPS files and SealedSecret resources may be committed, but Sealed Secrets is a separate controller and SOPS/age decryption keys (Flux references them from the Kustomization) must be protected and never committed. For Argo CD, prefer destination-cluster secret operators, because config-management plugins can surface decrypted values through Redis and the repo-server. The tools differ in one way that shapes the rest: Argo CD runs a network-facing API and UI with its own accounts, while Flux has no standalone management UI or user-login API of its own, and is driven through Kubernetes custom resources under the API server's authentication and RBAC. Flux's controllers still expose in-cluster HTTP endpoints, and a separately installed Flux dashboard brings its own login and exposure to harden. Control-plane exposure itself, the API server and etcd, stays in [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md). Egress is a surface too: both controllers fetch from Git, container and chart registries, the Kubernetes API, identity/KMS, and notification targets, and Argo CD's repository allowlist does not constrain remote bases or Helm chart dependencies while its webhook path has an SSRF consideration. Two steps therefore follow: restrict controller egress to the destinations each deployment needs and apply the metadata protections in [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md), keeping only the metadata access the identity mechanism requires (for Flux tenant reconciliation, `--no-remote-bases=true` where applicable). ## Argo CD The install generates an initial `admin` password and stores it in cleartext in a Secret named `argocd-initial-admin-secret`, in the `password` field. Rotate it and then delete the Secret: `argocd account update-password` changes the password, and the vendor says the Secret "can safely be deleted" afterward since it only holds the bootstrap value. Deleting the Secret does not by itself rotate the credential, so change the password first. Give `argocd account update-password` the current and new passwords at the tool's prompt, not as arguments: `--current-password` and `--new-password` would put them in its argv. Sources has no reference for the command prompting (its Argo CD user management entry is described as covering disabling admin and changing the password), so confirm that it does before relying on it. A prompt keeps the passwords out of the command text, shell history and shell tracing; the account owner and root can still read them in the `argocd` process while it runs. `admin` is the only built-in user and a superuser with unrestricted access; once you have configured SSO (OIDC or Dex) and granted named roles, disable it with `admin.enabled: "false"` in the `argocd-cm` ConfigMap, and pair Argo CD logins with your identity provider's MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). The quickstart does not expose the API and UI outside the cluster; `argocd-server` is a ClusterIP Service, and reaching it is a deliberate act through a LoadBalancer, an Ingress, a Gateway route, or `kubectl port-forward`. When you do route to it, give it real TLS. The server ships a self-signed certificate, and its `--insecure` flag runs the server with no TLS of its own; that is meant for a setup where an ingress terminates trusted TLS in front and reaches the backend over a protected network, not for anything a client reaches directly. The client flag `argocd login --insecure` is a separate setting that skips certificate verification on the client side, and is unsafe against any real deployment. At Argo CD v3.5.3 (commit `c9c369efcc5b2a0bd720803f8d14a1c3eaddf579`), the server defaults to `0.0.0.0:8080` in the pod (its Service maps `80` and `443` to it), with separate metrics on `0.0.0.0:8083`; front it per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). Keep `argocd-server` as a ClusterIP and do not publish the origin through a public LoadBalancer, NodePort, or alternate route; reach it only through a restricted, authenticated TLS ingress (or private administrative access), and protect the CLI/gRPC path as well as the browser/REST one, since the Service carries both on `443`. `server.insecure` in `argocd-cmd-params-cm` is the ConfigMap form of `--insecure` and defaults to `"false"`; it disables the backend's TLS, not authentication, so allow it only behind trusted TLS termination on a protected network. ClusterIP is not a pod-network access control. The v3.5.3 component inventory also includes: | Component | Listener and bind evidence | Base manifest exposure | | --- | --- | --- | | Repo-server | gRPC `0.0.0.0:8081`; HTTP metrics and health `0.0.0.0:8084` | Both container ports and Service ports | | Application controller | Plain-HTTP metrics, health and file-gated pprof on `0.0.0.0:8082`; the host is hardcoded and only `--metrics-port` (default `8082`) sets the port | Container port and `argocd-metrics` Service | | ApplicationSet controller | Webhook `:7000`, metrics `:8080`, health probes `:8081`; empty hosts are wildcard binds | Container and Service ports `7000` and `8080`; probe port `8081` has neither declaration | | Notifications controller | HTTP metrics `0.0.0.0:9001` | Metrics Service and TCP liveness probe; no `containerPort` declaration | | Dex | Generated `web.https` (or `web.http` with TLS disabled) `0.0.0.0:5556`, `grpc.addr` `0.0.0.0:5557` and telemetry `http` `0.0.0.0:5558` | All three container and Service ports; the wrapper starts Dex only with a nonempty generated configuration | | Redis | Port `6379`; the Deployment has no bind override, and the `redis:8.2.3-alpine` tag, taken as built from docker-library-redis bcbc6921 (tag v8.2.3), uses Redis defaults of IPv4 wildcard (`*`) and optional IPv6 wildcard (`-::*`), with protected mode patched off. The described Redis wildcard bind defaults and protected-mode patch hold for the deployed `public.ecr.aws/docker/library/redis:8.2.3-alpine` image only if that registry tag was built from that recipe (on 2026-10-24 it served Docker Hub's index, whose producer annotations name that recipe; build NOT ESTABLISHED) | Container port and Service | Repo-server's metrics/health mux and notifications' metrics handler have no authentication wrapper and use HTTP, so a reachable pod-network peer can request them without an Argo CD login. Repo-server gRPC defaults to TLS (`--disable-tls=false`), but the command documents skipping mTLS when `/app/config/reposerver/mtls/client-ca.crt` is absent. The base Deployment optionally mounts `argocd-repo-server-mtls` for that client CA, separately from `argocd-repo-server-tls` for the serving certificate. A serving certificate alone does not require a client identity. The server requires and verifies client certificates when a client CA is loaded; without that CA it uses TLS without client-certificate authentication. If either serving certificate file is missing or cannot be stat'ed, the helper generates a session self-signed certificate; a present but invalid pair fails loading. The gRPC interceptor chain shows logging, metrics, recovery and unary error sanitization, with no authorization interceptor. The registered repository service's struct has no authenticator or enforcer field, and its handlers read no caller identity: `ListRefs`, for example, discards its call context, and a search of the non-test `reposerver/` code finds no peer, incoming-metadata, `AuthInfo`, claims or `Enforce` reads, including in handlers such as `GenerateManifest` that pass the context on. So, from this source reading, any client that completes the transport handshake can call every method; its one `PermissionDenied` reports Helm repositories that the request's own project source list does not permit. The server also registers gRPC reflection, health and version services. Isolate repository operations and cached manifests from unrelated pods. Redis is not passwordless in these base manifests: the `secret-init` init container runs `argocd admin redis-initial-password`, and Redis receives `--requirepass` from the `auth` key of the `argocd-redis` Secret. This is an init container, not a separate Job. Holding the password in a Secret does not keep it off the command line: as an argument, it is in the Redis process's argv from its start until the process overwrites its arguments, if it does, readable through `ps` and `/proc//cmdline` by other local accounts on the node as well as by the same account and root, and command-line auditing on the node can record it beyond that. Where a process overwrites its own arguments, they stay readable until the overwrite runs. Redis 8.2.3 has no password-specific redaction of its start-up arguments. On Linux it copies its arguments to the heap at start-up and, with `set-proc-title` at its default `yes`, its first title update zeroes the original argument area and writes the process title there; that runs before Redis opens its listeners. From this source reading, the password stays in `/proc//cmdline` until then and in the process's heap copies afterwards. This is not a live observation. Treat every account that can read the node's processes as able to read the Redis password. The base Deployment does pass the value through the `REDIS_PASSWORD` container environment variable, which the `--requirepass $(REDIS_PASSWORD)` argument references, so it is also readable through `/proc//environ` by the same account and root. From this source reading, Redis 8.2.3's title initialization extends the range that its first title update zeroes over any environment strings laid out contiguously after the argument strings. Redis 8.2.3's title initialization, in the same source reading, also rebuilds the environment before that update, through `setenv()` from the original strings (after `clearenv()` where glibc is detected, otherwise after pointing `environ` at a new empty array). The result, in this source reading, is that the `REDIS_PASSWORD` variable stays set in the process while an original `REDIS_PASSWORD=` string inside that range is zeroed with the arguments. Whether the environment strings lie directly after the arguments, and what `/proc//environ` returns after the overwrite, are NOT ESTABLISHED; this is not a live observation. No Redis TLS is configured by these manifests, and Argo CD's Redis client defaults to `--redis-use-tls=false`; protect that traffic and Secret as well as the cached data. The initializer generates a 16-character random password, creates the Secret if absent, then checks that its password key exists. The base Redis kustomization renames the `redis` image to `public.ecr.aws/docker/library/redis`, keeping the tag, and the rendered `install.yaml` names `public.ecr.aws/docker/library/redis:8.2.3-alpine`. The pinned sources do not establish which build recipe that registry tag comes from (NOT ESTABLISHED), so the reading that follows holds for that deployed image only if the registry tag was built from docker-library-redis bcbc6921. Docker Official Images' `library/redis` file, at the official-images commit that added the tag, lists `8.2.3-alpine` with `GitRepo` redis/docker-library-redis, `GitCommit` bcbc6921, `GitFetch` `refs/tags/v8.2.3` and `Directory` `alpine`, and its last revision listing the tag keeps the same `GitCommit`. The official-images README says Bashbrew fetches each entry's `GitRepo` at its `GitCommit`, tags the built image with the library file's name, and also rebuilds listed tags when their base image is updated. That identifies the recipe for Docker Hub's `redis:8.2.3-alpine`, not a fixed image: the file records no digests, and a base-image rebuild keeps the tag. It does not establish that the ECR registry tag, or any pull of either tag, is that build (NOT ESTABLISHED). As registry data reading, ECR and Docker Hub served byte-identical `8.2.3-alpine` indexes, `sha256:08ad0b1d280850169a790dba1393ff7a90aef951fc19632cf4d3ce4f78e679ba`, on 2026-10-24, and each image entry's annotations name docker-library-redis commit bcbc6921, its `alpine` directory and version `8.2.3-alpine`. From those identical indexes, this guide's assessment is that the ECR tag was then Docker Hub's image. Those annotations are producer metadata and tags move, so, in this guide's assessment, they are consistent with that recipe without establishing that build or any later pull (NOT ESTABLISHED). The `redis:8.2.3-alpine` tag, taken as built from docker-library-redis bcbc6921 (tag v8.2.3), starts Redis without a config file; Redis 8.2.3 defines `CONFIG_DEFAULT_BINDADDR` as `*` and `-::*` and initializes its bind list from it. The `-` makes an unavailable IPv6 address optional, and the image build patches protected mode off. This is a source-derived default, not a live socket observation. Dex's wrapper defaults to TLS for its HTTP endpoint. The generator replaces the `web`, `grpc` and `telemetry` mappings: `web.https` uses `/tmp/tls.crt` and `/tmp/tls.key`, or `web.http` is used with `--disable-tls`; `grpc` contains only `addr: 0.0.0.0:5557`, with no TLS or client-authentication settings, and telemetry uses HTTP on `0.0.0.0:5558`. HTTP TLS does not secure the gRPC listener. The `ghcr.io/dexidp/dex:v2.45.1` tag, taken as built from Dex v2.45.1, has a `GRPC` struct that maps `TLSCert` to the `tlsCert` configuration key; its server creates gRPC transport credentials only when that field is set. Argo CD's generated `grpc` mapping contains only `addr`. Its gRPC options therefore show no TLS or authentication interceptor for this configuration. The wrapper runs `dex serve /tmp/dex.yaml` inside that container. Dex v2.45.1's release workflow runs on pushed tags matching `v[0-9]+.[0-9]+.[0-9]+` and builds the checked-out tree, tagging the alpine variant of `ghcr.io/` plus the repository name through a `type=semver,pattern={{raw}}` rule. Its release job calls that workflow with `publish: true`, under which the workflow pushes the image, signs `ghcr.io/` plus the repository name at the pushed digest with `cosign sign` using the GitHub OIDC token, and pushes a build provenance attestation for that digest; this gives a verification path. As registry data reading, on 2026-10-24 `ghcr.io` served `v2.45.1` as the index `sha256:8499afd690c437f52301efd2b05b2455da5bd2dfc20332cd697dc9937f808462`, and the `cosign` signature tag and referrers lookups for that digest returned 404, which does not establish that no signature exists elsewhere. As attestation data reading, GitHub's attestations API served one SLSA provenance statement for that digest, naming `.github/workflows/release.yaml` at `refs/tags/v2.45.1` and commit 11d2eeb5, which, by a `git ls-remote` reading of the same day, that tag names directly. As verification reading dated 2026-10-24, with gh 2.101.0, `gh attestation verify --owner dexidp` verified that statement's Sigstore bundle, and its certificate names the issuer `https://token.actions.githubusercontent.com`, the signer workflow `.github/workflows/artifacts.yaml` at `refs/tags/v2.45.1`, called from `release.yaml`, source repository dexidp/dex at commit 11d2eeb5 and run 22625708938. So, in this guide's assessment, the image at that digest came from that release run. The verification checks the bundle against the Sigstore public-good trust root and the `--owner dexidp` constraint, not the workflow file's content, which rests on the source reading above, and tags move, so it does not establish what a later pull of `v2.45.1` returns. The registered Dex API handlers read no caller identity; `GetClient` returns a client's secret and `CreateClient` stores a new client. Argo CD's generator adds its static client with the Dex OAuth2 client secret, and Dex serves static clients from the storage that API reads, so, from this source reading, any caller that reaches `5557` can read that secret and create clients. The Dex NetworkPolicy, which admits only the server to `5557`, is the remaining barrier where a CNI enforces it. Server metrics on `8083` use a plain HTTP server whose `/metrics` handler is registered through `promhttp` with no authentication wrapper. The socket binds to `server.ListenHost`, following `--address` (default `0.0.0.0`), not the separate metrics host; setting `--metrics-address` does not restrict this socket in v3.5.3. The same mux registers `/debug/pprof/`, `/debug/pprof/cmdline`, `/debug/pprof/profile`, `/debug/pprof/symbol` and `/debug/pprof/trace`. Each returns `401` unless the file selected by `ARGOCD_ENABLE_PROFILER_FILE_PATH` (default `/home/argocd/params/profiler.enabled`) contains exactly `true`, with no newline. The base server Deployment mounts the `server.profile.enabled` key from `argocd-cmd-params-cm` at that path; `profiler.enabled` is the filename, not the ConfigMap key. When enabled, these pprof endpoints require no caller authentication: anyone who can reach `8083` can use them. This is an enable switch, not an identity check. Repo-server's `8084` mux also registers the same file-gated pprof handlers. Keep profiling disabled except during controlled diagnostics, and restrict both metrics listeners to authorized monitoring and diagnostic callers. API authentication protects neither. The base kustomizations include NetworkPolicies. Repo-server gRPC admits the server, application controller, notifications controller and ApplicationSet controller in the same namespace; Redis admits the server, repo-server and application controller; Dex HTTP/gRPC admits the server. These restrictions need an enforcing CNI. They are not a blanket isolation policy: the server policy allows all ingress, while the other metrics policies admit pods from every namespace, and the ApplicationSet policy also admits its webhook from every namespace. Replace or narrow those allowances to the required ingress, component and monitoring callers, per [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md). NetworkPolicy allows are additive, so an additional restrictive policy does not cancel an existing allow-all rule. Restrict egress separately. A container-port declaration is metadata, not a firewall; its absence does not close a pod socket. For v3.5.3, the base command-parameters ConfigMap omits both server switches; their CLI defaults are false. The base RBAC ConfigMap omits `policy.default` rather than declaring a role. The fetched RBAC loader passes the ConfigMap `policy.default` value to the enforcer; an omitted key yields an empty string, so this enforcer skips its additive default-role check. In `argocd-server`, the enforcer is built for `argocd-rbac-cm` with the built-in policy and a claims enforcer, and its `EnableEnforce` switch receives the inverse of the server's disable-auth option. The server starts the ConfigMap policy loader. By default, its gRPC interceptor chains authenticate each call with the server's `Authenticate` function unless authentication is disabled, storing the caller's claims (an empty claims value for an anonymous caller when anonymous access is enabled). From this source reading, three services override that authentication function for every method: the session and version services accept unauthenticated callers, and the settings service accepts them only for `Get`, returning any `Authenticate` error for its other methods. For the calls they accept unauthenticated, each attempts authentication to populate claims and ignores its error; the session service's comment says the server's authentication would run without its override. The server imports its authentication interceptors from `go-grpc-middleware/v2`, which Argo CD's `go.mod` pins at v2.3.3. Argo CD's `go.sum` lines for it match the `Sum` and `GoModSum` hashes a `go mod download` run for this guide on 23 October 2026 (go1.26.5, GOPROXY `proxy.golang.org`, GOSUMDB `sum.golang.org`) recorded over the downloaded module zip. That match ties `go.sum` to that zip and the tree extracted from it, not to the v2.3.3 git tag tree the cited links point at. In v2.3.3, the unary and stream server auth interceptors each call a service's `AuthFuncOverride`, with the full method name, in place of the default authentication function when the service value they receive implements it, and return its error before the handler runs. Argo CD's `go.mod` requires grpc-go v1.81.1, and Argo CD's `go.sum` lines for it match the same run's hashes for its downloaded zip. From this source reading of grpc-go v1.81.1, `grpc.NewServer` combines the `ChainUnaryInterceptor` interceptors into one, registration stores each service's implementation, and each unary call passes that implementation and the combined interceptor to the generated method handler. In the same reading, the chain hands every interceptor the same `UnaryServerInfo`, whose `Server` field grpc-go documents as the read-only service implementation the user provides. Argo CD's generated session, version and settings handlers put the value they receive in `Server`, those three services have no streaming methods, and `argocd-server` registers its `*session.Server`, `*version.Server` and `*settings.Server` values, the types that define the overrides. Argo CD's own bug21955 workaround interceptor, first in its unary chain, was read and discards its `*grpc.UnaryServerInfo` parameter. Between that workaround and the auth interceptor the chain runs v2.3.3's logging interceptor, then the metrics interceptor of `go-grpc-middleware/providers/prometheus` v1.1.0, the version `go.mod` pins (its `go.sum` lines also match that run's hashes). Read in the module trees extracted from those zips, both wrap one shared v2.3.3 interceptor that reads only `info.FullMethod`, builds its call metadata from that string and calls the handler with the context its reportable returns, writing nothing to `UnaryServerInfo` or its `Server` field. So the auth interceptor receives those values. For each linked tag, a `diff -rq` made for this guide on 23 October 2026 reported no file whose contents differ between a clone of that tag (for providers/prometheus, the clone's `providers/prometheus` module directory) and the module tree extracted from its downloaded zip; the Sources introduction gives this guide's source reading of each path present on one side only. The comparison covers file contents per path; it recomputes no `h1:` hash from the git trees and says nothing about other tags or file modes. For the metrics interceptor, the shared interceptor's v2.3.3 version is this guide's inference, not observed in a build. The providers/prometheus v1.1.0 module imports it from `go-grpc-middleware/v2`, which its own `go.mod` requires at v2.1.0. Minimal version selection builds one version of that module, the highest its module graph requires, and Argo CD's `go.mod` requires v2.3.3, the only v2 version its `go.sum` lists. The API services receive the enforcer: for example, application `List` keeps only applications that pass a `get` check, and `Create` checks `create`. Per-handler coverage and effective runtime overrides were not traced or tested. Two settings decide who reaches the API without an account. Anonymous access is disabled by default (`users.anonymous.enabled`); keep it off, because an anonymous caller is granted whatever role `policy.default` names. Leave `policy.default` empty, as in the base `argocd-rbac-cm` ConfigMap: the inspected enforcer grants no default-role permissions to a user who matches no role, and access is granted through explicit roles and group mappings instead. Anonymous access is not the only client-auth switch: keep `server.disable.auth: "false"` in the `argocd-cmd-params-cm` ConfigMap (its default), and inspect the running server's effective arguments and environment for any override that turns client authentication off, because disabling anonymous access does not compensate for authentication being disabled outright. Argo CD RBAC grants through the default role are additive and cannot be revoked by a later subject rule, so a permissive default is especially hard to walk back. Define the actual permissions in `policy.csv` in `argocd-rbac-cm`: a `p` rule such as `p, role:team, applications, get, team-project/*, allow` and a `g` rule mapping the intended IdP group to that role, then confirm the group reaches its project and not an unrelated one. Separately, `argocd-server` serves an inbound git-webhook handler at `/api/webhook` that accepts events without authentication unless you configure a webhook shared secret. The webhook handler triggers application refreshes and reconciliations rather than deploying attacker-supplied manifests, but leaving it open to spoofed events is a reconciliation and resource-exhaustion vector once the server is routed externally, so set the secret whenever it is reachable. Finally, the `argocd-manager` ServiceAccount that Argo CD installs on a registered cluster is bound to an admin-level ClusterRole, so driving Argo CD is deployment power over every cluster it manages. Three steps follow: scope who can act through project roles, move applications into restricted `AppProject`s, and tighten the permissive built-in `default` project itself rather than only moving applications out of it, since it still governs any new application placed there. AppProjects constrain actions through Argo CD but do not shrink the Kubernetes credentials a compromised controller holds, so also reduce the controller and each registered cluster's `argocd-manager` service-account write permissions to the namespaces and resource kinds actually needed (keeping the read access the install mode requires). A repository writer exercises whatever its reconciliation can do, not inherently every registered cluster. Argo CD's Redis cache and repo-server can also hold generated manifests, which matter when a config-management plugin injects secrets into them; restrict access to them with enforced NetworkPolicies, the same isolation Flux's artifacts need. Treat Argo CD access as cluster-admin access. ## Flux Flux has no standalone user-login or management API of its own: it is a set of in-cluster controllers that reconcile git into the cluster, driven through Kubernetes custom resources under the API server's authentication and RBAC. There is no login surface of its own to harden, but its controllers still expose in-cluster HTTP endpoints (below), and a separately installed Flux dashboard brings its own login and exposure. Its controllers do expose in-cluster listeners (the webhook receiver, the source-controller's artifact server, metrics, health), so an `ss` inside a controller's own network namespace still shows sockets; that is expected, not a finding. The surfaces that matter are three. Git repository credentials, referenced as Secrets by the sources, are the primary one: use read-only deploy credentials for pull-only reconciliation, keep tenant repositories separate from platform repositories, and narrow the RBAC on the Secrets that hold them. Bootstrap credentials are separate from reconciliation ones: `flux bootstrap github --token-auth` leaves the PAT in the cluster as the `flux-system` Secret in the `flux-system` namespace, while `--token-auth=false` provisions an SSH deploy key that is read-only by default. Three steps follow: prefer the narrowly scoped key, restrict access to that Secret, and pass any bootstrap token through stdin or the prompt, never Git or logs. Typed at the prompt, the token stays out of shell history and `set -x` tracing. Piped in, a token typed literally into the feeding command enters shell history, and with `set -x` on also the trace; a token the feeding command expands from a variable reaches the trace with `set -x` on, but history records only the variable's name, which does not carry its value (a command that assigned the value literally is in history itself). Either way stdin closes the argv channel only: the account owner and root can still read the token in the `flux` process. Image automation is the exception to read-only: the image-automation-controller commits manifest changes back to Git and needs write access (`--read-write-key=true` when it uses the bootstrap deploy key), so scope that credential to the one repository, protect the deployment branch, use a dedicated update branch, and restrict who may edit `ImageUpdateAutomation` objects. The source-controller then serves the fetched artifacts to the other controllers over an internal HTTP listener, and Flux relies on Kubernetes NetworkPolicies, not a ClusterIP alone, to keep that access to Flux components; apply them, since a CNI that enforces NetworkPolicy is what actually isolates the artifacts. The notification-controller webhook receiver is the deliberate network surface. The controller handles webhook requests on port `9292`, and its `webhook-receiver` Service exposes them on port `80`, forwarding to `9292`; it is reachable from outside only if you put a LoadBalancer, Ingress, or Gateway route in front of it. When you do, the receiver type is what authenticates the request, not merely the presence of a `secretRef`: a `generic` receiver validates nothing, `generic-hmac` and the GitHub, Bitbucket and Nexus types verify an HMAC signature against the referenced secret, and `gitlab` compares the `X-Gitlab-Token` header against it. The `generic-oidc` type validates a bearer token against configured OIDC providers rather than a secret (new in Flux 2.9, and it rejects `.spec.secretRef`), so, when you do, constrain it with `.spec.oidcProviders[].validations` and a specific `.audience` (which otherwise defaults to `notification-controller`), since a public issuer can otherwise mint valid tokens for unrelated callers. Choose a validating type, terminate TLS at the ingress, and confine what a webhook can trigger with cross-namespace reference policies. The third surface is Kubernetes RBAC. Only the kustomize-controller and helm-controller are bound to `cluster-admin`, because they apply resources, though every controller holds meaningful permissions including Secret access. Restrict tenant reconciliation to scoped ServiceAccounts: Flux offers `--no-cross-namespace-refs` to stop a tenant referencing another namespace's sources, `--default-service-account` to fall back to a named account when a reconciliation omits its own `spec.serviceAccountName` (it does not override an explicit one), and related lockdown settings. Two steps follow: enable the Flux lockdown settings your tenancy needs, and protect the platform git sources that feed the privileged controllers. ## Verify REASONED: section scope and authoring provenance; local marking review (October 2026, Bash 5.3.9) ran bash -n and ShellCheck 0.11.0 -s bash on the three Verify shell blocks then present, each with exit 0 and no diagnostics. That review covered every block in an earlier form: the Argo CD API and Flux webhook blocks before their curls took the host on a guarded `set --` line, and all three before each block ended with one exit status for everything it runs. The four current blocks are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by the October and November 2026 stub runs recorded at each block. The stub outcomes and expected exposed and fixed results are recorded at their individual checks. The expected exposed and fixed results are given per the guide's cited Argo CD RBAC (anonymous access, policy.default in argocd-rbac-cm), Argo CD Repo-server component selectors and metrics allowance, Argo CD Server allow-all ingress policy, Argo CD Profiler paths and exact file-content gate, and Flux notification Receivers (types and payload validation) entries. A live Argo CD or Flux stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a Kubernetes cluster is also required. The authoring prerequisites and inconclusive outcomes are described in the unchanged note. Every probe below is REASONED, not demonstrated: the authoring environment has no container runtime; also, the authoring host forbids opening listeners without an isolated network namespace, and has none; there is no Kubernetes cluster. None was stood up in its exposed and fixed states. Each names its expected exposed and fixed result so it discriminates against a live install, based on the cited vendor documentation and pinned source readings. A login page, a redirect, a `404`, an HTML body, a TLS error, a `kubectl` `Forbidden`, or a missing-CRD error is inconclusive, never the fixed state. REASONED: the Argo CD API pair in the block below, per the cited Argo CD documentation and pinned sources, with the backlog scope note and the token-paste instruction for it; the scope note restates backlog row 2.28, and the paste-alone warning follows CONTRIBUTING rule 7's `read` exception. No container runtime, isolated network namespace or Kubernetes cluster is available, so no Argo CD response was observed. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 without diagnostics. In a scratch-local review, the block ran from a file with curl, kubectl, sudo and ss stubbed, no network and a pseudo-terminal. Unchanged placeholders and substituted dummy inputs each printed 'stub curl' twice, 'stub kubectl' six times, 'stub sudo' and 'stub ss' (file exit 0). An empty token printed 'supply the token; not probing'; readonly tok printed 'a readonly tok is set in this shell; not probing'; readonly IFS printed 'a readonly IFS is set in this shell; not probing'. Each refusal still printed the anonymous 'stub curl' and all inventory stub lines, and the file exited 0 after inventory continued. These stub outputs do not verify Argo CD authorization; the live comparison remains anonymous 401 versus application JSON listing the expected application with a valid token. The marking and scratch-local reviews above covered the block's earlier form, which named argocd.example.com directly and also held the bootstrap Secret check and cluster inventory that the next block now runs. The block now takes the Argo CD host, the CA file and an application name on a guarded `set --` line that refuses an unsubstituted value and example.com, example.net, example.org and their subdomains. It refuses with 2 before any request when python3 is not installed, and python3 parses each 2xx body: a response names the application only when it is an application list, a JSON object whose `items` array holds an item with that `metadata.name`. It records a finding (1) when the anonymous request answers 2xx with such a list, and 3 when a request fails, the anonymous answer is neither 401 nor such a list, the token-bearing answer is not a 200 with such a list, or python3 cannot parse a body, so HTML, invalid JSON and JSON that is not such a list are inconclusive. A refusal in its token part gives 2, and the block exits 0 only for an anonymous 401 beside that token-bearing 200. October 2026 stub runs under bash 5.3.9 with curl stubbed, the real python3 parsing and the token typed at a pseudo-terminal covered the guarded form while its reserved-name check lowercased the host with `tr`. In those runs an anonymous 401 beside a token-bearing 200 whose list held the application exited 0; an anonymous 200 whose list held it exited 1, also when the token-bearing request failed or answered 403 or the token was empty or readonly. A 200 at either request holding HTML, invalid JSON, `{"error":"demo"}` for an application named demo, a list naming only another application with demo in an annotation or `spec.name`, a null or non-array `items`, or a top-level array exited 3. So did an anonymous login page, 302 or curl failure, a token-bearing 403 or failure, a failing python3 and no readable /dev/tty. A missing python3, each `set --` refusal, and beside an anonymous 401 an empty or readonly token or a readonly IFS exited 2; an inherited `set -e`, `set -u` or `declare -i tok` left these results unchanged. The current form instead matches reserved names in any letter case through bash's `nocasematch` option, and is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by November 2026 stub runs under bash 5.3.9 with curl stubbed and no network. In those runs eight reserved names in lower, upper and mixed case, bare and as subdomains, each exited 2 with the reserved-name message before any request; two reserved names in upper or mixed case also did with `tr` absent from PATH, and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and a host outside those names reached the anonymous request. Under dash, which lacks bash's `shopt`, stub runs of the current form refused reserved and other host names alike with status 2 and its message that the block needs bash, before any request. Backlog row 2.28 retains the remaining limits: whether the deployed Redis tag was built from the recipe its index annotations name, whether Redis's title overwrite also clears the `REDIS_PASSWORD` environment copy, and per-handler RBAC coverage in `argocd-server`. Those are not traced here, and the cited source readings are not live observations. In the block below, paste the Argo CD bearer token alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash # Argo CD API auth, matched pair against the SAME endpoint. Use your real CA; never skip verification. Anonymous (no # token) must answer 401 fixed (application JSON exposed); with a valid bearer token it must return JSON # listing the expected application - the positive control a fronting proxy's 401 cannot fake. Repeat both # against the ORIGIN from an authorized location (keep the TLS hostname), and test the gRPC/CLI path too. ( # the whole block in one subshell, so one exit status covers both requests set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_ARGOCD_HOST' 'REPLACE_WITH_YOUR_CA_FILE' 'REPLACE_WITH_AN_APPLICATION_NAME' # the Argo CD server's host name, the name its TLS certificate covers, with no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the path of the CA file that issued that certificate, then the name of an application a valid token lists: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the Argo CD server's host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute the path of the CA file that issued the Argo CD server's certificate inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "the CA file named on the set -- line above is not readable here; not probing"; exit 2; } case "$3" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the name of an application a valid token lists (lowercase letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac command -v python3 > /dev/null || { echo "python3 is not installed here; not probing"; exit 2; } # python3 parses each response below set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --cacert "$2" \ -w '\nanon http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1/api/v1/applications"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then the response and its anon line printf '%s' "$6" case "$5:${6##*anon http=}" in 0:401' '*) ;; 0:2??' '*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(printf '%s' "${6%$'\n'anon http=*}" | python3 -c 'import json, sys; items = (lambda data: data.get("items", False) if isinstance(data, dict) else False)(json.load(sys.stdin)); sys.exit(12 if items is not None and not isinstance(items, list) else 10 if any(isinstance(item, dict) and isinstance(item.get("metadata"), dict) and item.get("metadata").get("name") == sys.argv[1] for item in items or ()) else 11)' "$3"; printf '%s' "$?")" # python3 exits 10 when an item's metadata.name in the application list is $3, 11 for a list without it, 12 for other JSON and nonzero otherwise case "$7" in 10) echo "FINDING: the anonymous request was answered with application JSON naming $3"; set -- "$1" "$2" "$3" 1 ;; 11|12) echo "the anonymous request was answered with a 2xx that does not name $3 (a login page or a proxy's page is not the fixed state); inconclusive"; set -- "$1" "$2" "$3" 3 ;; *) echo "python3 exit $7: the anonymous 2xx response could not be parsed as JSON; inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac ;; 0:*) echo "the anonymous request was answered with neither 401 nor application JSON (a login page, a redirect or a 404 is not the fixed state); inconclusive"; set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the anonymous request failed (a TLS error is not the fixed state); inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac # token from `argocd account generate-token` (or a login session); read it without echo, pass via stdin. # Run in a subshell with tracing, allexport and errexit OFF, so an inherited `set -x` cannot echo the token, # an inherited `set -a` cannot export it (read -s and the stdin header prevent neither), and an inherited # `set -e` cannot end the block early. # The hidden prompt keeps the token out of shell history; stdin closes the argv channel only, so the # account owner and root can still read the token in the subshell and curl. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n tok && unset -v tok; } 2>/dev/null || { echo 'a readonly tok is set in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } IFS= read -r -s -p 'Argo CD bearer token: ' tok < /dev/tty || { echo 'the hidden prompt could not read the token; inconclusive'; exit 3; }; echo case "$tok" in ''|*[[:cntrl:]]*) echo 'supply the token; not probing'; exit 2 ;; esac set -- "$1" "$2" "$3" "$(if printf 'Authorization: Bearer %s\n' "$tok" \ | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --cacert "$2" \ -H @- -w '\nauth http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1/api/v1/applications"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then the response and its auth line printf '%s' "$5" case "$4:${5##*auth http=}" in 0:200' '*) set -- "$1" "$2" "$3" "$4" "$5" "$(printf '%s' "${5%$'\n'auth http=*}" | python3 -c 'import json, sys; items = (lambda data: data.get("items", False) if isinstance(data, dict) else False)(json.load(sys.stdin)); sys.exit(12 if items is not None and not isinstance(items, list) else 10 if any(isinstance(item, dict) and isinstance(item.get("metadata"), dict) and item.get("metadata").get("name") == sys.argv[1] for item in items or ()) else 11)' "$3"; printf '%s' "$?")" # the same parse and statuses as above case "$6" in 10) echo "the token-bearing request was answered with 200 naming $3"; exit 0 ;; 11|12) echo "the token-bearing request was answered with 200 but does not name $3, so the positive control failed; inconclusive"; exit 3 ;; *) echo "python3 exit $6: the token-bearing response could not be parsed as JSON, so the positive control failed; inconclusive"; exit 3 ;; esac ;; 0:*) echo "the token-bearing request was not answered with 200, so the positive control failed; inconclusive"; exit 3 ;; *) echo "curl exit $4: the token-bearing request failed, so the positive control failed; inconclusive"; exit 3 ;; esac ) case "$?:$4" in 0:*) ;; 1:*) set -- "$1" "$2" "$3" 1 ;; 2:0) set -- "$1" "$2" "$3" 2 ;; 2:*|*:1) ;; *) set -- "$1" "$2" "$3" 3 ;; esac # fold the token part's status into the block's, ranked as above echo "block exit $4; the response bodies, the origin and gRPC/CLI repeats, and the argocd-cm and argocd-rbac-cm settings are not classified, so judge them yourself" exit "$4" ) ``` REASONED: following block; the bootstrap Secret check and cluster inventory, per the guide's cited Argo CD getting started (initial admin secret), Argo CD Server allow-all ingress policy and Flux security model entries; no Kubernetes cluster is available, so no listing was read. Run it where kubectl reaches the cluster; its `ss` line speaks only for the node and network namespace it runs in. It records a finding (1) when kubectl lists argocd-initial-admin-secret, whose presence the comment in the block reads as retained bootstrap material, and treats kubectl's `(NotFound)` error as absence, which alone does not prove the password changed. It records 3 when another kubectl error or a failed `sudo ss -tlnp` occurs, including a Forbidden or a missing-CRD error, which this section reads as inconclusive; on a cluster without the Gateway API CRDs the route listing can fail that way, so list `ingress -A` alone there and read it. A missing `ss` gives 2 only when nothing else was recorded (an earlier finding stays 1 and an earlier failure 3), and every listing is left for you to judge. This form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by October 2026 stub runs with kubectl, sudo and ss stubbed: an absent Secret with every listing succeeding exited 0, a listed Secret exited 1 (also when a later listing, `sudo` or `ss` failed), and a Forbidden or a failed listing, `sudo` or `ss` exited 3. A missing `ss` alone exited 2, beside a listed Secret 1, and beside a Forbidden or a failed listing 3. ```bash ( # the whole block in one subshell, so one exit status covers every command in it set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused to run), which outranks 0 # The bootstrap admin Secret should be gone once the password was rotated (its presence proves retained # bootstrap material; its absence alone does not prove the password changed). This prints no secret. set -- "$1" "$(if kubectl -n argocd get secret argocd-initial-admin-secret -o name 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # kubectl's exit status, then its output printf '%s\n' "$3" case "$2:$3" in 0:*secret/argocd-initial-admin-secret*) echo "FINDING: argocd-initial-admin-secret is still present, so the bootstrap material is retained"; set -- 1 ;; 0:*) echo "kubectl listed something other than argocd-initial-admin-secret; inconclusive"; set -- 3 ;; *'(NotFound)'*) echo "argocd-initial-admin-secret is absent; that alone does not prove the admin password changed" ;; *) echo "kubectl exit $2: the bootstrap Secret could not be read (a Forbidden is not the fixed state); inconclusive"; set -- 3 ;; esac # Network inventory: management access should have no external route. List Services, Ingresses, and # Gateway routes, not only the named Service, since another Service, a NodePort, or an HTTPRoute can # reach the same pods. kubectl -n argocd get svc -o wide || { echo "kubectl exit $?: the argocd Service listing failed; inconclusive"; [ "$1" = 1 ] || set -- 3; } kubectl -n argocd get networkpolicy -o yaml || { echo "kubectl exit $?: the argocd NetworkPolicy listing failed; inconclusive"; [ "$1" = 1 ] || set -- 3; } kubectl -n argocd get pods -o wide || { echo "kubectl exit $?: the argocd pod listing failed; inconclusive"; [ "$1" = 1 ] || set -- 3; } kubectl -n flux-system get svc -o wide || { echo "kubectl exit $?: the flux-system Service listing failed; inconclusive"; [ "$1" = 1 ] || set -- 3; } kubectl get ingress,httproute,grpcroute,tlsroute -A || # also check NodePort Services and host-network/hostPort pods { echo "kubectl exit $?: the route listing failed (a missing-CRD error is not the fixed state; without the Gateway API CRDs, list ingress -A alone and read it); inconclusive"; [ "$1" = 1 ] || set -- 3; } if command -v ss > /dev/null; then sudo ss -tlnp || # speaks only for the node and network namespace it runs in, not the whole cluster { echo "sudo ss -tlnp exit $?: the listener inventory failed; inconclusive"; [ "$1" = 1 ] || set -- 3; } else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi echo "block exit $1; the Service, NetworkPolicy, pod and route listings and the listener table are not classified, so judge them yourself" exit "$1" ) ``` REASONED: internal socket and policy inventory and the run records of the repo-server metrics probe below, whose declaration is the paragraph after that block; local marking review (October 2026, Bash 5.3.9) ran bash -n and ShellCheck 0.11.0 -s bash with exit 0 and no diagnostics. With stubbed curl, placeholder, empty and invalid addresses printed 'supply the actual IPv4 pod address; not probing' (exit 2); shortened and wrong-marker assignments printed 'incomplete probe; not probing' (exit 2). The quoted-count and unquoted-$# control runs, with stubbed curl, IFS=0 and 20 values (marker plus 19), differed: the quoted count printed 'incomplete probe; not probing' (exit 2), while the unquoted-$# control reached the curl stub and printed 'stub curl' (exit 0). A valid fixture with stubbed curl printed 'stub curl' (exit 0). Those runs covered the block's earlier form, which took no pod role and ended with curl's own status. The current form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by October 2026 stub runs with curl stubbed. In those runs, at the monitoring pod HTTP 200 exited 0 and HTTP 403 or a timeout exited 3; at the unrelated pod HTTP 200 or 403 exited 1 and a timeout or refusal exited 3; a missing address, a missing or unknown pod role, a wrong marker, a shortened `set --` line and 20 values under IFS=0 each exited 2. A live repo-server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a Kubernetes cluster with a supporting CNI, actual component sockets and authorized and unrelated pod vantages is also required. The base policy should return HTTP 200 metrics at both vantages; narrowed policy should return no HTTP response at the unrelated pod while the monitoring pod still returns metrics, per the guide's cited Argo CD Repo-server HTTP metrics and health mux and Argo CD Repo-server component selectors and metrics allowance entries. For internal isolation, inventory actual sockets in each component's network namespace with `ss -tlnp`, including ports absent from container declarations. Review rendered policies and CNI enforcement; a NetworkPolicy object's presence alone proves neither reachability nor denial. The following probe is REASONED for the same missing isolation and cluster capabilities above. Run it inside an authorized monitoring pod and an unrelated pod in another namespace, against the same actual repo-server pod IP. With the base metrics allowance, expect HTTP 200 and Prometheus metrics from both. After narrowing the allowance, expect no HTTP response from the unrelated pod while the monitoring pod still receives those metrics. A timeout alone, a missing curl binary, or a failed positive control is inconclusive. Put monitoring or unrelated on the block's `set --` line for the pod it runs in. At the monitoring pod it exits 0 for HTTP 200 and 3 otherwise; at the unrelated pod it exits 1 for any HTTP response and 3 for none, never 0, because no answer there is the narrowed result only beside a monitoring-pod run that exits 0. The pinned repo-server mux and NetworkPolicy below supply the exposed-state basis. ```bash ( # a subshell, so the exits below end only the subshell set -- secureconfig-probe REPLACE_WITH_REPO_SERVER_POD_IP REPLACE_WITH_monitoring_OR_unrelated # the pod's IPv4 address, then monitoring or unrelated for the pod this runs in [ "${1-}" = secureconfig-probe ] || { echo 'incomplete probe; not probing'; exit 2; } [ "$#" -eq 3 ] || { echo 'incomplete probe; not probing'; exit 2; } shift case "${1-}" in ''|*REPLACE_WITH*|*[!0123456789.]*) echo 'supply the actual IPv4 pod address; not probing'; exit 2 ;; esac case "$2" in monitoring|unrelated) ;; *) echo 'supply monitoring or unrelated for the pod this runs in; not probing'; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:8084/metrics"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its http= line printf '%s' "$4" case "$2:$3:${4##*http=}" in monitoring:0:200' '*) echo "the monitoring pod received HTTP 200"; set -- 0 ;; monitoring:*) echo "the monitoring pod did not receive HTTP 200, so the positive control failed; inconclusive"; set -- 3 ;; unrelated:0:*) echo "FINDING: the unrelated pod received an HTTP response, so the metrics allowance still admits it"; set -- 1 ;; unrelated:7:*|unrelated:28:*) echo "no HTTP response at the unrelated pod: the narrowed result only beside a monitoring-pod run of this block that exits 0, and a timeout alone is inconclusive, so this pod never exits 0 here"; set -- 3 ;; *) echo "curl exit $3: the request failed before any HTTP response; inconclusive"; set -- 3 ;; esac echo "block exit $1; the response body is not classified, so read it for Prometheus metrics yourself" exit "$1" ) ``` REASONED: preceding block; the preceding repo-server guard and this server metrics and profiler comparison; local marking review (October 2026, Bash 5.3.9) ran bash -n and ShellCheck 0.11.0 -s bash on the guard's earlier form with exit 0 and no diagnostics, and that form's stubbed curl and rejection results, with the current form's checks, are recorded in the preceding paragraph. A live Argo CD server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a Kubernetes cluster with a supporting CNI, the mounted profiler file, and monitoring and unrelated pod vantages is also required. Base metrics should return HTTP 200 at both vantages; narrowed policy should yield no HTTP response at the unrelated pod while the monitoring pod still receives metrics. From the allowed pod the profiler should return 401 disabled, 200 for exact true, and 401 for true plus newline, with metrics still answering. The allowed pod's expected profiler and metrics results are stated per the guide's cited Argo CD Repo-server component selectors and metrics allowance, Argo CD Server unauthenticated HTTP metrics mux and profiler registration, Argo CD Server allow-all ingress policy, and Argo CD Profiler paths and exact file-content gate entries. For server metrics, repeat the guarded pod-IP probe above with the actual server pod IP and port `8083`, keeping `/metrics`. This check is also REASONED for the missing isolated network namespace and Kubernetes cluster. Before narrowing the server's allow-all policy, expect HTTP 200 and Prometheus metrics without credentials from both pods; afterwards only the authorized monitoring pod should receive them. From that allowed pod, repeat with `/debug/pprof/cmdline`: expect `401` with profiling disabled, then HTTP 200 and the process command line when the mounted file contains exactly `true`. In an isolated demonstration, confirm that `true` followed by a newline returns `401` again. Keep `/metrics` as the positive control throughout; a failed control is inconclusive. The block's status classifies `/metrics` only: for the profiler path, read the printed `http=` code yourself, since a `401` there is the expected disabled result rather than a failed control. Return profiling to disabled after the demonstration. The pinned metrics mux, profiler wrapper and server NetworkPolicy below distinguish these states. REASONED: the paired Argo CD `401` reading for the Argo CD API block earlier in this section, the Flux receiver checks for the block below, the receiver status and rejection reading and the pass criteria of the guarded checks, per the guide's cited Argo CD anonymous-access and RBAC entries, Flux notification Receivers entry and Python HMAC entries. The paste-alone warning follows CONTRIBUTING rule 7's `read` exception. No live Argo CD or Flux installation is available, so no status, rejection, receiver response or reconciliation was observed. During marking review (October 2026) the Flux block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 without diagnostics. In a scratch-local review, the block ran from a file with curl and python3 stubbed, no network and a pseudo-terminal. Unchanged placeholders and substituted dummy inputs each printed 'stub curl' twice (exit 0); an empty secret printed 'supply the HMAC secret; not probing' (exit 2); readonly hmac and readonly sig each printed 'a readonly hmac or sig is set in this shell; not probing' (exit 2); readonly IFS printed 'a readonly IFS is set in this shell; not probing' (exit 2). The python3 stub supplied a fixed signature, so the HMAC computation is not verified. In the local marking review, a python3 stub exiting 1 and one printing no output each printed 'signature generation failed; not probing' (exit 2). With no readable /dev/tty, stdout was empty and Bash printed `: line 16: /dev/tty: No such device or address` on stderr (exit 2). The stub outputs do not verify receiver authorization or reconciliation; the live comparison remains rejection without a signature versus acceptance and reconciliation with a valid signature over the same body. The marking and scratch-local reviews above covered the Flux block's earlier form, which named flux-webhook.example.com in its `url` assignment and ended with the signed request's own curl status. The block now takes the receiver's host and its path after `/hook/` on a guarded `set --` line, checked before the secret prompt, that refuses an unsubstituted value and example.com, example.net, example.org and their subdomains. It records a finding (1) when the unsigned request is accepted with a 2xx, reads a 4xx or 5xx answer to it as a rejection, and records 3 when a request fails, the unsigned answer is anything else, or the signed request is not accepted with a 2xx. A failed prompt read or signature now exits 3 rather than 2. October 2026 stub runs under bash 5.3.9 with curl stubbed, the real python3 signing and the secret typed at a pseudo-terminal covered that form while its reserved-name check lowercased the host with `tr`. In those runs an unsigned 401 or 500 beside a signed 200 exited 0, and the stub received the body on stdin and the expected HMAC header. An unsigned 200 exited 1, also when the signed request failed; an unsigned 302 or curl failure, a signed 401, a failing or silent python3 and no readable /dev/tty exited 3; and an empty secret, a readonly hmac or IFS and each `set --` refusal exited 2. The current form instead matches reserved names in any letter case through bash's `nocasematch` option, and is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by November 2026 stub runs under bash 5.3.9 with curl stubbed and no network. In those runs eight reserved names in lower, upper and mixed case, bare and as subdomains, each exited 2 with the reserved-name message before the secret prompt; two reserved names in upper or mixed case also did with `tr` absent from PATH, and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and a host outside those names reached the secret prompt. Under dash, which lacks bash's `shopt`, stub runs of the current form refused reserved and other host names alike with status 2 and its message that the block needs bash, before the secret prompt. Read the receiver's status without printing the default table, full status, condition messages, or `.status.webhookPath` (status messages can carry the generated path, itself a capability for a `generic` receiver): select the name and the Ready condition explicitly. The rejection status is type- and version-dependent, so read it as rejected rather than a fixed code. A malformed-payload error or a proxy-only rejection is inconclusive; repeat against the receiver origin from an authorized location. Because these checks discriminate on a response rather than on silence, an unsubstituted value cannot read as a pass; the guarded `set --` lines in the Argo CD and Flux blocks refuse an unsubstituted host and keep the requests off reserved example hosts. A bare reachability probe of your own public address, whose pass is nothing answering, needs the guarded-subshell form for a further reason: an unsubstituted address could otherwise time out and read as closed. A bare `401` proves less than it looks: a fronting authentication proxy can return it while the underlying `users.anonymous.enabled` is still on, so pair the probe with an authenticated request through the same route that should succeed, and read the anonymous and `policy.default` settings from `argocd-cm`/`argocd-rbac-cm` directly. For Flux, test the webhook with an **unauthenticated** request, not merely an unsigned body, since a valid `X-Gitlab-Token` or OIDC token authenticates without a signature. Paste the receiver's HMAC secret alone at the block's hidden prompt: a multi-line paste leaves every line after the first for your shell to run. For a `generic-hmac` receiver the signature is `X-Signature: sha256=`; send identical bytes without it (must be rejected) and then with it (must be accepted and trigger the reconciliation): ```bash # Read the HMAC key without echo. # Python's hmac reads the key from stdin; only the non-secret request body is passed in argv. # The hidden prompt keeps the key out of shell history and set +x keeps it out of set -x tracing. # Stdin closes the argv channel only: the account owner and root can still read the key in the # subshell and python3. # The signature, a replayable HMAC for that exact body, goes to curl on stdin as a header line (-H @-) # and the fixed, non-secret body as an argument. Stdin closes the argv channel only: the account owner # and root can still read the signature in the subshell and curl. ( # one subshell, so one exit status covers both requests trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_RECEIVER_HOST' 'REPLACE_WITH_RECEIVER_PATH' # the public host name the Flux receiver is served on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the receiver's path after /hook/: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the receiver's public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*/*|*[?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the receiver's path after /hook/ (no slash, query, fragment or percent sign) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac { unset -n hmac && unset -v hmac; } 2>/dev/null || { echo 'a readonly hmac is set in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } IFS= read -r -s -p 'receiver HMAC secret: ' hmac < /dev/tty || { echo 'the hidden prompt could not read the HMAC secret; inconclusive'; exit 3; }; echo [ -n "$hmac" ] || { echo 'supply the HMAC secret; not probing'; exit 2; } set -- "$1" "$2" '{"ref":"refs/heads/main"}' # the fixed, non-secret request body set -- "$1" "$2" "$3" "$(set -o pipefail; if printf '%s' "$hmac" | python3 -c \ 'import hmac, sys; print(hmac.new(sys.stdin.buffer.read(), sys.argv[1].encode(), "sha256").hexdigest())' "$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # the signature with Python's newline, then the signer's exit status [ "$5" = 0 ] || { echo 'signature generation failed; not probing'; exit 3; } set -- "$1" "$2" "$3" "${4%$'\n'}" 0 # the signature, then the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 [ -n "$4" ] || { echo 'signature generation failed; not probing'; exit 3; } set -- "$1" "$2" "$3" "$4" "$5" "$(if printf '%s' "$3" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -X POST --data-binary @- -w '\nno-sig http=%{http_code}\n' "https://$1/hook/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # expect rejected set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # curl's exit status, then the response and its no-sig line printf '%s' "$7" case "$6:${7##*no-sig http=}" in 0:2??*) echo "FINDING: the receiver accepted the body without a signature"; set -- "$1" "$2" "$3" "$4" 1 ;; 0:4??*|0:5??*) echo "the unsigned request was rejected; read it as rejected rather than as a fixed code" ;; 0:*) echo "the unsigned request was answered with neither acceptance nor a 4xx or 5xx rejection; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "curl exit $6: the unsigned request failed; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$(if printf 'X-Signature: sha256=%s\n' "$4" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -X POST --data-binary "$3" -H @- -w '\nsigned http=%{http_code}\n' "https://$1/hook/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # expect accepted set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # curl's exit status, then the response and its signed line printf '%s' "$7" case "$6:${7##*signed http=}" in 0:2??*) echo "the signed request was accepted; confirm the reconciliation it should trigger" ;; 0:*) echo "the signed request was not accepted with a 2xx, so the positive control failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "curl exit $6: the signed request failed, so the positive control failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "block exit $5; the response bodies, the reconciliation the signed request should trigger, and whether a rejection came from the receiver or a proxy are not classified, so judge them yourself" exit "$5" ) ``` ## Sources (checked September 2026) - Python HMAC calculation and binary stdin: https://docs.python.org/3/library/hmac.html and https://docs.python.org/3/library/sys.html#sys.stdin - [curl curl-8_14_1 custom headers (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md). - curl curl-8_14_1 (checked October 2026): [--data-binary posts data exactly as specified, with newlines and carriage returns preserved and @- reading stdin (4-32)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/data-binary.md#L4-L32); [--data sends a POST request and strips carriage returns and newlines from @file input (4-49)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/data.md#L4-L49). Argo CD source claims below were checked offline at v3.5.3, commit `c9c369efcc5b2a0bd720803f8d14a1c3eaddf579`. Dex v2.45.1, Redis 8.2.3, docker-library-redis v8.2.3 (commit `bcbc692165ce2241e194035041c5b5e01d2e1a5f`) and Docker Official Images' `library/redis` file (official-images commits `1ff8d41b352de957628be66246d3af2996948c75` and `f80a67f648fe5f70e6708772537c48237dfbe39b`) and README (commit `326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d`) were checked offline in October 2026. So were go-grpc-middleware v2.3.3 (commit `390bcef25adebe4b0c7dbb365230c0a856737afe`), go-grpc-middleware providers/prometheus v1.1.0 (read in the module tree extracted from its downloaded zip; commit `53040665d19bab33e444905822557433511bb981`, to which its tag peeled in a clone made for this guide on 23 October 2026) and grpc-go v1.81.1 (commit `caf0772c2bcb8bc15d43eb53448e921f34f0b7e8`). The go-grpc-middleware, providers/prometheus and grpc-go versions are the ones Argo CD's `go.mod` pins; Argo CD's `go.sum` lines for all three match the `Sum` and `GoModSum` hashes a `go mod download` run for this guide on 23 October 2026 (go1.26.5, GOPROXY `proxy.golang.org`, GOSUMDB `sum.golang.org`) recorded over the downloaded module zips. On the same day, `diff -rq` reported no file whose contents differ between each module tree extracted from those zips and a clone of its tag at the commit above (for providers/prometheus, the clone's `providers/prometheus` module directory). In this guide's source reading, each path on one side only is, or holds only, a nested module directory with its own `go.mod`, which the go command leaves out of a module zip, or is the providers/prometheus zip's `LICENSE`, which the go command copies from the repository root into a subdirectory module with none (byte-identical to the root file in a comparison made for this change). That compares file contents per path; it does not recompute the `h1:` hashes from the git trees and says nothing about other tags or file modes. Retained readthedocs links provide operational guidance; they were not rechecked online. Remaining source limits are identified above. The `redis:8.2.3-alpine` index that ECR and Docker Hub served (`sha256:08ad0b1d280850169a790dba1393ff7a90aef951fc19632cf4d3ce4f78e679ba`), the `ghcr.io/dexidp/dex:v2.45.1` index (`sha256:8499afd690c437f52301efd2b05b2455da5bd2dfc20332cd697dc9937f808462`), that digest's `cosign` signature tag and referrers lookups, and GitHub's attestation for it were read as data fetched on 2026-10-24. No image was pulled or run. As verification reading dated 2026-10-24, gh 2.101.0's `gh attestation verify` verified that attestation's Sigstore bundle; the Dex prose above says what that does and does not show. - Application-controller metrics port handoff: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-application-controller/commands/argocd_application_controller.go#L192-L209 - Application-controller metrics flag: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-application-controller/commands/argocd_application_controller.go#L272-L273 - Application-controller hardcoded metrics bind host: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/controller/appcontroller.go#L321-L323 - Application-controller metrics server start: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/controller/appcontroller.go#L996 - Application-controller metrics, file-gated pprof and health mux and HTTP server: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/controller/metrics/metrics.go#L187-L220 - Repo-server TLS and client CA setup: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/server.go#L49-L85 - Repo-server gRPC interceptor chain and registration: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/server.go#L94-L150 - Repo-server request-derived Helm repository PermissionDenied: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L1385-L1401 - Repo-server repository service fields, with no authenticator or enforcer: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L87-L106 - Repo-server ListRefs handler, which discards its call context: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L227-L247 - Repo-server GenerateManifest handler: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L631-L706 - Repo-server certificate fallback: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/tls/tls.go#L428-L506 - Dex image tag: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/dex/argocd-dex-server-deployment.yaml#L38-L42 - Dex gRPC configuration field and JSON tag: https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/config.go#L255-L265 - Dex gRPC TLS options: https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L151-L199 - Dex gRPC listener and registration: https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L553-L572 - Dex API GetClient secret response and CreateClient: https://github.com/dexidp/dex/blob/v2.45.1/server/api.go#L53-L104 - Dex static client loading and storage wrapper: https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L209-L235 - Dex server storage handoff: https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L363 - Dex static client lookup: https://github.com/dexidp/dex/blob/v2.45.1/storage/static.go#L25-L39 - Dex release workflow tag trigger: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/release.yaml#L3-L15 - Dex image checkout, name and tag rules: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L52-L91 - Dex image build from the checked-out tree: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L126-L142 - Dex release job calling the artifacts workflow with `publish: true` and attestation, package and OIDC token write permissions: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/release.yaml#L10-L24 - Dex image push only when publishing: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L103-L109 - Dex `cosign sign` of the pushed `ghcr.io` image digest with the GitHub OIDC token: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L145-L148 - Dex build provenance attestation pushed for the `ghcr.io` image digest: https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L206-L212 - Redis initial password implementation: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd/commands/admin/redis_initial_password.go#L22-L89 - Redis image protected-mode patch: https://github.com/redis/docker-library-redis/blob/bcbc692165ce2241e194035041c5b5e01d2e1a5f/alpine/Dockerfile#L81-L87 - Redis image entrypoint and command: https://github.com/redis/docker-library-redis/blob/bcbc692165ce2241e194035041c5b5e01d2e1a5f/alpine/Dockerfile#L151-L154 - Redis entrypoint argument handling: https://github.com/redis/docker-library-redis/blob/bcbc692165ce2241e194035041c5b5e01d2e1a5f/alpine/docker-entrypoint.sh#L93-L111 - Docker Official Images `redis:8.2.3-alpine` stanza and `GitRepo` (official-images commit 1ff8d41b352de957628be66246d3af2996948c75, which added the tag; checked October 2026): https://github.com/docker-library/official-images/blob/1ff8d41b352de957628be66246d3af2996948c75/library/redis#L7-L19 - Docker Official Images last revision listing `redis:8.2.3-alpine`, with the same `GitCommit` as at official-images commit 1ff8d41b352de957628be66246d3af2996948c75 (checked October 2026): https://github.com/docker-library/official-images/blob/f80a67f648fe5f70e6708772537c48237dfbe39b/library/redis#L39-L43 - Docker Official Images README (official-images commit 326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d; checked October 2026) on library-file tags being built only through a library-file update or a base-image update, and removed tags staying on Docker Hub unmaintained: https://github.com/docker-library/official-images/blob/326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d/README.md#L369 - Docker Official Images README (official-images commit 326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d; checked October 2026) on Bashbrew fetching `GitRepo` at `GitCommit` and naming tags after the library file: https://github.com/docker-library/official-images/blob/326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d/README.md#L413-L415 - Redis default bind addresses: https://github.com/redis/redis/blob/8.2.3/src/server.h#L142-L143 - Redis default bind initialization: https://github.com/redis/redis/blob/8.2.3/src/server.c#L2199-L2221 - Redis TCP listener bind handoff: https://github.com/redis/redis/blob/8.2.3/src/server.c#L2983-L2996 - Redis optional bind prefix handling: https://github.com/redis/redis/blob/8.2.3/src/server.c#L2638-L2673 - Redis argument copy and title initialization at start-up: https://github.com/redis/redis/blob/8.2.3/src/server.c#L7369-L7408 - Redis title update before listener initialization: https://github.com/redis/redis/blob/8.2.3/src/server.c#L7593-L7607 - Redis process title setter: https://github.com/redis/redis/blob/8.2.3/src/server.c#L7172-L7184 - Redis argument copy and argument-area overwrite: https://github.com/redis/redis/blob/8.2.3/src/setproctitle.c#L164-L316 - Redis environment clearing and rebuild through `setenv()` before the title overwrite: https://github.com/redis/redis/blob/8.2.3/src/setproctitle.c#L87-L161 - Redis glibc detection defining `HAVE_CLEARENV` for the environment rebuild: https://github.com/redis/redis/blob/8.2.3/src/setproctitle.c#L53-L55 - Redis Linux title replacement build switch: https://github.com/redis/redis/blob/8.2.3/src/config.h#L199-L204 - Redis set-proc-title default: https://github.com/redis/redis/blob/8.2.3/src/config.c#L3088 - RBAC default role storage and enforcement handoff: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L314-L346 - RBAC policy ConfigMap key names (`policy.default`): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L35-L42 - RBAC ConfigMap loader: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L454-L467 - RBAC default-role value: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L539-L547 - RBAC default-role enforcement: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L399-L425 - RBAC enforcer construction in argocd-server: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L350-L359 - RBAC policy loader start in argocd-server: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L674-L675 - RBAC policy loader callback in argocd-server: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L892-L907 - argocd-server gRPC authentication interceptors: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L961-L985 - argocd-server bug21955 workaround interceptor, first in the unary chain, discarding its `*grpc.UnaryServerInfo` parameter. That it comes first in that chain is this guide's source assessment, nothing built or run, for Argo CD c9c369ef's linux/amd64 default-tag package graph with cgo on and test-only imports excluded, in which no package calls grpc-go's global server options hook. The argocd-server bug21955 workaround interceptor sources: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1715 and https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L973-L974 - argocd-server API services receiving the enforcer: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1029-L1091 - argocd-server gRPC service registration: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L992-L1004 - argocd-server service set types, including `*session.Server`, `*settings.Server` and `*version.Server`: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1012-L1027 - argocd-server call authentication and claims context: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1537-L1576 - argocd-server session service authentication override: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/session/session.go#L94-L102 - argocd-server version service authentication override: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/version/version.go#L75-L82 - argocd-server settings service authentication override for Get: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/settings/settings.go#L193-L202 - argocd-server import of the `go-grpc-middleware/v2` auth interceptors: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L37 - Argo CD `go.mod` pin of `go-grpc-middleware/v2` at v2.3.3 (its `go.sum` lines match the recorded download hashes): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.mod#L69 - Argo CD `go.mod` pin of `go-grpc-middleware/providers/prometheus` at v1.1.0 (its `go.sum` lines match the recorded download hashes): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.mod#L68 - Argo CD `go.sum` entries for go-grpc-middleware providers/prometheus v1.1.0 and go-grpc-middleware/v2 v2.3.3, matching the recorded download hashes: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.sum#L521-L524 - go-grpc-middleware v2.3.3 (commit 390bcef25adebe4b0c7dbb365230c0a856737afe; checked October 2026) override interface and unary auth interceptor calling a service's `AuthFuncOverride` in place of the default function: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L26-L50 - go-grpc-middleware v2.3.3 (commit 390bcef25adebe4b0c7dbb365230c0a856737afe; checked October 2026) stream auth interceptor calling a service's `AuthFuncOverride` in place of the default function: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L52-L70 - argocd-server unary interceptor chain order: the bug21955 workaround, then logging, then metrics, then auth: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L973-L985 - go-grpc-middleware v2.3.3 logging unary server interceptor built on the shared interceptor (read October 2026 in the module tree extracted from its `go.sum`-matched zip): https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/logging/interceptors.go#L209-L212 - go-grpc-middleware v2.3.3 shared unary server interceptor, reading only `info.FullMethod` and calling the handler with the context its reportable returns, writing nothing to `UnaryServerInfo` (read October 2026 in that extraction): https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/server.go#L16-L28 - go-grpc-middleware v2.3.3 server call metadata built from the full-method string (read October 2026 in that extraction): https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/callmeta.go#L38-L45 - go-grpc-middleware providers/prometheus v1.1.0 metrics unary server interceptor built on the shared interceptor, which this guide infers is v2.3.3's in Argo CD's build (read October 2026 in the module tree extracted from its `go.sum`-matched zip): https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/providers/prometheus/server_metrics.go#L150-L155 - go-grpc-middleware providers/prometheus v1.1.0 (commit 53040665d19bab33e444905822557433511bb981; checked October 2026) import of the shared interceptor's package from `go-grpc-middleware/v2`: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/providers/prometheus/server_metrics.go#L7 - go-grpc-middleware providers/prometheus v1.1.0 (commit 53040665d19bab33e444905822557433511bb981; checked October 2026) `go.mod` requirement of `go-grpc-middleware/v2` at v2.1.0, below the v2.3.3 that Argo CD's `go.mod` requires: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/providers/prometheus/go.mod#L6 - Argo CD generated session service registration, unary handlers setting `UnaryServerInfo.Server`, and descriptor with no streams: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/pkg/apiclient/session/session.pb.go#L417-L494 - Argo CD generated version service registration, unary handler setting `UnaryServerInfo.Server`, and descriptor with no streams: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/pkg/apiclient/version/version.pb.go#L262-L295 - Argo CD generated settings service registration, unary handlers setting `UnaryServerInfo.Server`, and descriptor with no streams: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/pkg/apiclient/settings/settings.pb.go#L932-L987 - Argo CD `go.mod` requirement of grpc-go v1.81.1 (its `go.sum` lines match the recorded download hashes): https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.mod#L115 - Argo CD `go.sum` entries for grpc-go v1.81.1, matching the recorded download hashes: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.sum#L1259-L1260 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) nested modules, each directory's own `go.mod`, accounting for the tag clone's git-only directories in the module zip comparison. The grpc-go v1.81.1 nested-module sources: https://github.com/grpc/grpc-go/blob/v1.81.1/cmd/protoc-gen-go-grpc/go.mod#L1, https://github.com/grpc/grpc-go/blob/v1.81.1/examples/go.mod#L1, https://github.com/grpc/grpc-go/blob/v1.81.1/gcp/observability/go.mod#L1 and https://github.com/grpc/grpc-go/blob/v1.81.1/interop/observability/go.mod#L1. More grpc-go v1.81.1 nested-module sources: https://github.com/grpc/grpc-go/blob/v1.81.1/interop/xds/go.mod#L1, https://github.com/grpc/grpc-go/blob/v1.81.1/security/advancedtls/go.mod#L1, https://github.com/grpc/grpc-go/blob/v1.81.1/stats/opencensus/go.mod#L1 and https://github.com/grpc/grpc-go/blob/v1.81.1/test/tools/go.mod#L1 - go-grpc-middleware v2.3.3 (commit 390bcef25adebe4b0c7dbb365230c0a856737afe; checked October 2026) nested modules, each directory's own `go.mod`, accounting for the tag clone's git-only directories in the module zip comparison. The go-grpc-middleware v2.3.3 nested-module sources: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/.bingo/go.mod#L1 and https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/examples/go.mod#L1. More go-grpc-middleware v2.3.3 nested-module sources: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/logging/examples/go.mod#L1 and https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/providers/prometheus/go.mod#L1 - go-grpc-middleware providers/prometheus v1.1.0 (commit 53040665d19bab33e444905822557433511bb981; checked October 2026) repository-root `LICENSE`, the zip-only file in the module zip comparison, since the tag's `providers/prometheus` directory has none: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/LICENSE - Go module zip construction at go1.26.5 (read October 2026 in a local go1.26.5 toolchain tree, not compared with upstream; the Go version the proxy built the module zips with is not recorded): [the go command adds the repository-root LICENSE to the zip of a subdirectory module that has none](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/modfetch/coderepo.go#L1170-L1175); [module zip creation omits files in any directory below the module root that holds its own go.mod](https://github.com/golang/go/blob/go1.26.5/src/cmd/vendor/golang.org/x/mod/zip/zip.go#L232-L298). - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) `ChainUnaryInterceptor` server option: https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L481-L489 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) `NewServer` combining the configured interceptors: https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L691-L710 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) global server options, which `NewServer` applies before the caller's (lines 693 to 695 of the `#L691-L710` item), appended only by the internal `AddGlobalServerOptions` hook. The `AddGlobalServerOptions` hook's comment (`internal/internal.go` lines 85 to 86) says the 1.0 release of the separate `gcp/observability` module uses it. The grpc-go v1.81.1 global server options source: https://github.com/grpc/grpc-go/blob/caf0772c2bcb8bc15d43eb53448e921f34f0b7e8/server.go#L79-L84 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) `stats/opentelemetry/csm`'s `EnableObservability`, within the grpc module the only caller of the `AddGlobalServerOptions` hook outside tests. Neither `csm` nor `gcp/observability` is in Argo CD c9c369ef's 24 October 2026 linux/amd64 default-tag `go list -deps ./...` graph with cgo on and without test-only imports (source assessment, nothing built or run): https://github.com/grpc/grpc-go/blob/caf0772c2bcb8bc15d43eb53448e921f34f0b7e8/stats/opentelemetry/csm/observability.go#L41-L57 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) service registration storing the implementation: https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L757-L793 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) unary interceptor chain passing one `UnaryServerInfo` to every interceptor: https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L1212-L1245 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) unary call passing the stored implementation and the combined interceptor to the method handler: https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L1430 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) dispatch of a registered service's unary method: https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L1853-L1857 - grpc-go v1.81.1 (commit caf0772c2bcb8bc15d43eb53448e921f34f0b7e8; checked October 2026) `UnaryServerInfo.Server` documented as the read-only service implementation the user provides: https://github.com/grpc/grpc-go/blob/v1.81.1/interceptor.go#L65-L72 - Application List RBAC filter: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/application/application.go#L288-L330 - Application Create RBAC check: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/application/application.go#L348-L356 - Argo CD Listener constants: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/common/common.go#L75-L92 - Argo CD Server CLI bind, TLS and authentication defaults: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-server/commands/argocd_server.go#L307-L323 - Argo CD Server main and metrics socket binding: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L512-L535 - Argo CD Server metrics served separately: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L668-L677 - Argo CD Server unauthenticated HTTP metrics mux and profiler registration: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/metrics/metrics.go#L74-L100 - Argo CD Profiler paths and exact file-content gate: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/profile/profile.go#L11-L30 - Argo CD Server command-parameters mount: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-deployment.yaml#L406-L408 - Argo CD Server profiler ConfigMap key and filename: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-deployment.yaml#L475-L481 - Argo CD Anonymous access setting: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/settings/settings.go#L1631-L1638 - Argo CD Base command-parameters ConfigMap: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/config/argocd-cmd-params-cm.yaml#L1-L7 - Argo CD Base RBAC ConfigMap with no default role: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/config/argocd-rbac-cm.yaml#L1-L7 - Argo CD Server authentication ConfigMap wiring: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-deployment.yaml#L121-L126 - Argo CD Server Service mapping: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-service.yaml#L9-L20 - Argo CD Repo-server HTTP metrics and health mux: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-repo-server/commands/argocd_repo_server.go#L180-L212 - Argo CD Repo-server addresses and TLS/client-CA defaults: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-repo-server/commands/argocd_repo_server.go#L257-L280 - Argo CD Separate optional repo-server TLS and mTLS Secrets: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/repo-server/argocd-repo-server-deployment.yaml#L367-L384 - Argo CD Application-controller metrics port and health probe: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/application-controller/argocd-application-controller-statefulset.yaml#L360-L365 - Argo CD ApplicationSet wildcard listeners: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-applicationset-controller/commands/applicationset_controller.go#L294-L296 - Argo CD ApplicationSet Service ports: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/applicationset-controller/argocd-applicationset-controller-service.yaml#L9-L20 - Argo CD Notifications HTTP metrics bind and handler: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-notification/commands/argocd_notification.go#L149-L154 - Argo CD Notifications metrics Service: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/notification/argocd-notifications-controller-metrics-service.yaml#L9-L16 - Argo CD Dex TLS setup and conditional startup: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-dex/commands/argocd_dex.go#L82-L119 - Argo CD Dex HTTP TLS default: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-dex/commands/argocd_dex.go#L143-L146 - Argo CD Dex configuration generation, including wildcard binds and gRPC without TLS/client-auth settings: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/dex/config.go#L16-L152 - Argo CD Dex declared Service ports: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/dex/argocd-dex-server-service.yaml#L9-L25 - Argo CD Redis password initialization and required Secret: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/redis/argocd-redis-deployment.yaml#L18-L58 - Argo CD Redis image rename in the base kustomization: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/redis/kustomization.yaml#L12-L14 - Argo CD Redis image in the rendered install manifest: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/install.yaml#L32485 - Argo CD Redis client TLS is opt-in: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/cache/cache.go#L233-L238 - Argo CD Server allow-all ingress policy: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-network-policy.yaml#L9-L16 - Argo CD Repo-server component selectors and metrics allowance: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/repo-server/argocd-repo-server-network-policy.yaml#L9-L35 - Argo CD Redis component selectors: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/redis/argocd-redis-network-policy.yaml#L9-L28 - Argo CD Dex component selectors and metrics allowance: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/dex/argocd-dex-server-network-policy.yaml#L9-L29 - Argo CD Application-controller metrics allowance: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/application-controller/argocd-application-controller-network-policy.yaml#L9-L19 - Argo CD ApplicationSet webhook and metrics allowance: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/applicationset-controller/argocd-applicationset-controller-network-policy.yaml#L9-L22 - Argo CD Notifications metrics allowance: https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/notification/argocd-notifications-controller-network-policy.yaml#L9-L20 - Argo CD getting started (initial admin secret, server exposure): https://argo-cd.readthedocs.io/en/stable/getting_started/ - Argo CD user management (disable admin, change password): https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/ - Argo CD RBAC (anonymous access, policy.default in argocd-rbac-cm): https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/ - Argo CD ingress and TLS termination: https://argo-cd.readthedocs.io/en/stable/operator-manual/ingress/ - Argo CD git webhook configuration (/api/webhook, shared secret): https://argo-cd.readthedocs.io/en/stable/operator-manual/webhook/ - Argo CD AppProjects: https://argo-cd.readthedocs.io/en/stable/user-guide/projects/ - Flux security model and best practices (NetworkPolicy artifact isolation, controller permissions): https://fluxcd.io/flux/security/best-practices/ - Flux notification Receivers (types and payload validation): https://fluxcd.io/flux/components/notification/receivers/ - Flux v2.9 release (generic-oidc receiver introduced): https://fluxcd.io/blog/2026/06/flux-v2.9.0/ - Flux webhook receivers guide (port 9292, webhook-receiver Service): https://fluxcd.io/flux/guides/webhook-receivers/ - Flux multitenancy configuration (cross-namespace and service-account lockdown): https://fluxcd.io/flux/installation/configuration/multitenancy/ - Argo CD cluster RBAC and security (narrowing `argocd-manager` privileges, remote-bases/SSRF): https://argo-cd.readthedocs.io/en/stable/operator-manual/security/ - Argo CD secret management (destination-cluster operators, repo-server/Redis exposure): https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/ - Flux GitHub bootstrap (`--token-auth` PAT Secret, `--read-write-key` for image automation): https://fluxcd.io/flux/installation/bootstrap/github/ - Flux image update automations (Git write-back): https://fluxcd.io/flux/components/image/imageupdateautomations/ - Flux SOPS/age decryption: https://fluxcd.io/flux/guides/mozilla-sops/ - Flux OCIRepository and HelmRepository credential references (`.spec.secretRef`): https://fluxcd.io/flux/components/source/ocirepositories/#secret-reference - Flux controller permissions (which controllers hold cluster-admin, Secret access): https://fluxcd.io/flux/security/#controller-permissions - Argo CD repo-server bind defaults (v3.5.3; commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579; checked October 2026): https://github.com/argoproj/argo-cd/blob/v3.5.3/common/common.go#L89-L90 - Argo CD repo-server listener flags and default wiring (v3.5.3; commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579; checked October 2026): https://github.com/argoproj/argo-cd/blob/v3.5.3/cmd/argocd-repo-server/commands/argocd_repo_server.go#L257-L260 - Argo CD Redis password initializer command (v3.5.3; commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579; checked October 2026): https://github.com/argoproj/argo-cd/blob/v3.5.3/cmd/argocd/commands/admin/redis_initial_password.go#L41-L42 - Argo CD additive default-role enforcement (v3.5.3; commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579; checked October 2026): https://github.com/argoproj/argo-cd/blob/v3.5.3/util/rbac/rbac.go#L401-L406 - Argo CD default-role ConfigMap loading (v3.5.3; commit c9c369efcc5b2a0bd720803f8d14a1c3eaddf579; checked October 2026): https://github.com/argoproj/argo-cd/blob/v3.5.3/util/rbac/rbac.go#L540-L541 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - argo: Argo CD v3.5.3 source (c9c369efcc5b2a0bd720803f8d14a1c3eaddf579) - flux-oidc: Flux OIDC introduction (v2.9) - argo-docs: Argo CD documentation (unknown) - flux: Flux documentation (unknown) - dex: Dex source (v2.45.1) - redis-image: docker-library-redis v8.2.3 source (bcbc692165ce2241e194035041c5b5e01d2e1a5f) - redis-source: Redis source (8.2.3) - curl: curl (curl-8_14_1) - python: Python documentation (unknown) - grpc-middleware: go-grpc-middleware source (v2.3.3) - grpc-middleware-prom: go-grpc-middleware providers/prometheus source (v1.1.0) - grpc-go: grpc-go v1.81.1 source (caf0772c2bcb8bc15d43eb53448e921f34f0b7e8) - official-images: Docker Official Images library definitions (1ff8d41b352de957628be66246d3af2996948c75) - official-images-docs: Docker Official Images README (326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d) Claims (full source mapping in the JSON block below): - git-boundary (REASONED; argo-docs, flux): Repository writers exercise reconciliation permissions; review platform changes, separate tenants and scope credentials. - secret-storage (REASONED; argo-docs, flux): Protect bootstrap, registry, cluster, webhook and decryption credentials; commit only encrypted secrets and keep SOPS/age keys out of Git. - egress (REASONED; argo-docs, flux): Restrict fetch, API, identity and notification egress; Argo repository allowlists do not constrain remote bases or Helm dependencies. - argo-bootstrap (REASONED; argo-docs): Rotate the initial admin password before deleting argocd-initial-admin-secret; deletion alone does not rotate it. - argo-sso (REASONED; argo-docs): The built-in admin is unrestricted; configure named SSO roles and IdP MFA, then set admin.enabled=false. - argo-origin (REASONED; argo-docs, argo): Keep the ClusterIP origin private behind authenticated TLS and protect both REST/browser and CLI/gRPC routes. - argo-server (REASONED; argo): Server defaults to 0.0.0.0:8080; the Service maps 80 and 443 to it. - argo-tls (REASONED; argo, argo-docs): Server --insecure/server.insecure defaults false and disables backend TLS, not authentication; use only behind protected TLS termination. - argo-client-tls (REASONED; argo-docs): argocd login --insecure skips client certificate verification; the server initially uses a self-signed certificate. - argo-metrics (REASONED; argo): Server HTTP metrics on 8083 are unauthenticated and bind to --address, not --metrics-address. - argo-profiler (REASONED; argo): Server and repo-server pprof paths return 401 unless the profiler file contains exactly true with no newline; enabled profiling has no caller authentication. - argo-profiler-file (REASONED; argo): ARGOCD_ENABLE_PROFILER_FILE_PATH defaults to /home/argocd/params/profiler.enabled; the server mounts ConfigMap key server.profile.enabled there. - repo-grpc (REASONED; argo): Repo-server gRPC binds 0.0.0.0:8081 with TLS by default; mTLS is skipped without the client CA, separate from the serving certificate. - repo-http (REASONED; argo): Repo-server HTTP metrics and health bind 0.0.0.0:8084 without an authentication wrapper. - repo-limits (REASONED; argo): Repo-server requires verified client certificates with a loaded client CA; missing or unstatable serving files trigger a session self-signed certificate, while an invalid present pair errors. No authorization interceptor appears, and the registered service handlers read no caller identity, so, from this source reading, any client completing the transport handshake can call its methods. - controller-metrics (REASONED; argo): Application-controller metrics, health and file-gated pprof share one plain-HTTP mux on a hardcoded 0.0.0.0 host; --metrics-port (default 8082) sets only the port, and the command defines no bind-address flag. - applicationset-webhook (REASONED; argo): ApplicationSet webhook binds :7000; its container and Service declare 7000. - applicationset-metrics (REASONED; argo): ApplicationSet metrics bind :8080; its container and Service declare 8080. - applicationset-health (REASONED; argo): ApplicationSet health probes bind :8081 without a container or Service port declaration; missing declarations do not close sockets. - notifications (REASONED; argo): Notifications HTTP metrics bind 0.0.0.0:9001 without authentication; a metrics Service and TCP liveness probe exist without containerPort. - dex-http (REASONED; argo): Dex starts only with nonempty generated configuration; HTTP defaults to TLS on 0.0.0.0:5556 using /tmp/tls.crt and /tmp/tls.key. - dex-grpc (REASONED; argo, dex): Generated Dex gRPC uses 0.0.0.0:5557 without TLS or client-auth settings; Dex v2.45.1 source sets no transport credentials or authentication interceptor for this configuration, its API handlers read no caller identity, and GetClient returns client secrets, including Argo CD's static client. This assumes the deployed ghcr.io/dexidp/dex:v2.45.1 tag was built from Dex v2.45.1. - dex-image (REASONED; argo, dex): Dex v2.45.1's release workflow builds pushed vX.Y.Z tags from the checked-out tree, tags the alpine variant through its semver rule, and, when publishing, cosign-signs and attests the pushed ghcr.io digest. Argo CD's base names ghcr.io/dexidp/dex:v2.45.1; on 2026-10-24 gh 2.101.0 verified its then digest's SLSA statement as signed by artifacts.yaml at 11d2eeb5, which this guide assesses built it. - dex-telemetry (REASONED; argo): Dex telemetry uses HTTP on 0.0.0.0:5558; all three Dex ports have Services. - redis-password (REASONED; argo, redis-image, redis-source): Redis receives --requirepass from argocd-redis/auth; secret-init generates and checks that Secret. The redis:8.2.3-alpine tag, taken as built from docker-library-redis v8.2.3, uses Redis wildcard bind defaults and patches protected mode off; this holds for the deployed public.ecr.aws image only if it was built from that recipe. - redis-image-name (REASONED; argo, official-images, official-images-docs): The base Redis kustomization renames redis to public.ecr.aws/docker/library/redis, keeping 8.2.3-alpine, as the rendered install.yaml shows; Docker Official Images lists redis:8.2.3-alpine as built from docker-library-redis bcbc6921's alpine directory. On 2026-10-24 ECR served Docker Hub's tag index, whose producer annotations name that recipe; that build and later pulls are not established. - redis-argv (REASONED; argo, redis-source): Redis 8.2.3 has no password-specific redaction of its start-up arguments; on Linux, with set-proc-title at its default yes, its first title update zeroes the original argument area and any environment strings directly after it before listeners open, leaving heap and setenv() copies. Whether that clears the REDIS_PASSWORD environment copy is not established. - redis-tls (REASONED; argo): Base manifests configure no Redis TLS and the Argo Redis client defaults --redis-use-tls=false; protect traffic, Secret and cache. - component-policies (REASONED; argo): Base policies restrict repo gRPC, Redis and Dex callers by component; enforcement needs a supporting CNI. - broad-policies (REASONED; argo): Server policy allows all ingress; other metrics and ApplicationSet webhook allowances admit every namespace. Narrow existing additive allowances and egress separately. - argo-anonymous (REASONED; argo, argo-docs): users.anonymous.enabled defaults off; anonymous callers otherwise inherit policy.default. - argo-disable-auth (REASONED; argo): server.disable.auth defaults false; inspect effective args and environment because anonymous-off does not compensate for disabled authentication. - argo-default-role (REASONED; argo, argo-docs): Base RBAC ConfigMap omits policy.default; the inspected loader supplies an empty role to the enforcer, which skips its additive default-role check. Default-role grants are additive and cannot be revoked by a later subject rule; define scoped policy.csv roles and group mappings. - argo-rbac-wiring (REASONED; argo): argocd-server builds its enforcer for argocd-rbac-cm with the built-in policy and a claims enforcer, starts the ConfigMap loader, authenticates gRPC calls by default and stores claims, and passes the enforcer to API services; for example, application List filters on get and Create checks create. Per-handler coverage and runtime overrides remain unverified. - argo-auth-overrides (REASONED; argo, grpc-middleware, grpc-go, grpc-middleware-prom): argocd-server's gRPC interceptors authenticate calls by default unless auth is disabled, but, from this source reading, the session, version and settings services override that function for every method and accept unauthenticated callers, settings only for Get. Pinned go-grpc-middleware v2.3.3 calls an override on the service value grpc-go v1.81.1 passes; as read, the interceptors ahead keep it. - argo-auth-chain-ahead (REASONED; argo, grpc-middleware, grpc-middleware-prom): Ahead of auth, argocd-server's unary chain runs the bug21955 workaround, v2.3.3 logging and providers/prometheus v1.1.0 metrics interceptors; as read (for metrics, inferred), none writes to UnaryServerInfo or its Server field. In the extracted modules, the latter two wrap one shared interceptor (v2.3.3; for metrics, this guide's inference) that reads only info.FullMethod and calls the handler. - argo-grpc-middleware-pin (REASONED; argo, grpc-middleware, grpc-middleware-prom, grpc-go): Argo CD's go.sum lines for go-grpc-middleware/v2 v2.3.3, providers/prometheus v1.1.0 and grpc-go v1.81.1 match hashes a go mod download recorded over their zips on 2026-10-23. A diff -rq of each extracted zip against its tag's module directory reported no differing file; one-sided paths are or hold nested modules, or are the root LICENSE in the prometheus zip; no h1: hash was recomputed from git. - argo-webhook (REASONED; argo-docs): /api/webhook accepts unauthenticated refresh events without a shared secret; configure one to limit spoofed reconciliation and resource exhaustion. - argo-projects (REASONED; argo-docs): Restrict AppProjects including the built-in default project; project constraints do not reduce a compromised controller's Kubernetes credentials. - argo-cluster-rbac (REASONED; argo-docs): Reduce controller and registered-cluster argocd-manager write permissions to needed namespaces/resources while retaining required read access. - flux-api (REASONED; flux): Flux has no standalone management login/API; Kubernetes authenticates its custom-resource operations, while separate dashboards need their own controls. - flux-bootstrap (REASONED; flux): GitHub bootstrap --token-auth retains a PAT in flux-system; --token-auth=false creates a read-only SSH deploy key by default. Protect bootstrap input and Secrets. - flux-writeback (REASONED; flux): Image automation needs Git write access, including --read-write-key=true for the bootstrap key; scope the repository and protect update branches and objects. - flux-artifacts (REASONED; flux): Source-controller serves fetched artifacts over internal HTTP; enforced NetworkPolicy, not ClusterIP alone, isolates them. - flux-receiver-port (REASONED; flux): Webhook receiver listens on 9292 behind Service port 80; external exposure needs a route and TLS termination. - flux-receiver-types (REASONED; flux): generic validates nothing; generic-hmac, GitHub, Bitbucket and Nexus use HMAC, while gitlab compares X-Gitlab-Token. - flux-oidc (REASONED; flux, flux-oidc): generic-oidc, introduced in Flux 2.9, rejects secretRef and validates bearer tokens; constrain issuer validations and audience, whose default is notification-controller. - flux-rbac (REASONED; flux): Only kustomize-controller and helm-controller hold cluster-admin; other controllers still have meaningful permissions including Secret access. - flux-lockdown (REASONED; flux): Use scoped reconciliation ServiceAccounts, --no-cross-namespace-refs and --no-remote-bases; --default-service-account fills an omission, not an explicit serviceAccountName. - verify-argo (REASONED; argo-docs, argo, curl): Anonymous application listing should fail with 401 while a valid token lists the expected application; repeat at origin and CLI/gRPC and inspect effective RBAC. - verify-bootstrap (REASONED; argo-docs): Absence of the bootstrap Secret proves removal of that material, not password rotation. - verify-inventory (REASONED; argo, flux): Inventory Services, routes, host ports, policies and component sockets; node-local ss and policy objects alone do not establish cluster isolation. - verify-repo-metrics (REASONED; argo): Base policy permits HTTP 200 metrics to monitoring and unrelated pods; narrowed policy should admit only monitoring, with a successful positive control. - verify-server-metrics (REASONED; argo): Repeat on server 8083: only authorized monitoring should receive metrics after narrowing ingress; a failed control is inconclusive. - verify-profiler (REASONED; argo): From an allowed pod, pprof cmdline should return 401 disabled, 200 for exact true, then 401 for true plus newline; keep metrics as control. - verify-flux (REASONED; flux, python, curl): Identical generic-hmac bodies without and with X-Signature should be rejected and accepted with reconciliation; status is type/version dependent. The cited curl curl-8_14_1 lines show --data-binary posting data exactly as specified, preserving newlines and carriage returns that --data strips from @file input, reading @- from stdin and @filename from a file, and posting like --data's POST request. - verify-receiver-status (REASONED; flux): Read receiver name and Ready condition without exposing webhookPath or condition messages; origin checks distinguish receiver rejection from proxy rejection. ````json { "schema": 1, "checked": "2026-10-19", "documentation_checked": "2026-09", "body_sha256": "b93332fac2381126c55dd1cf869f3ff85ee57aa99959e15fc102c28f872a09af", "components": { "argo": { "name": "Argo CD v3.5.3 source", "basis": "c9c369efcc5b2a0bd720803f8d14a1c3eaddf579", "sources": { "sddf4853ad503": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-service.yaml#L9-L20", "s6e8c96d23e05": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-server/commands/argocd_server.go#L307-L323", "s96065cc09776": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L512-L535", "s7cc690d1bf69": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L668-L677", "s028a4769d6c3": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/metrics/metrics.go#L74-L100", "sc52bbd14bff4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/profile/profile.go#L11-L30", "s98b27313f615": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-repo-server/commands/argocd_repo_server.go#L180-L212", "sb0648e746fb9": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-deployment.yaml#L406-L408", "s2f5af86c62d1": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-deployment.yaml#L475-L481", "s9bca471ac6d9": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-repo-server/commands/argocd_repo_server.go#L257-L280", "s4bbc848d9923": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/repo-server/argocd-repo-server-deployment.yaml#L367-L384", "sf98b6bc4f5dc": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/application-controller/argocd-application-controller-statefulset.yaml#L360-L365", "sf4dd846c82c4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/common/common.go#L75-L92", "s49a7c83d9371": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-applicationset-controller/commands/applicationset_controller.go#L294-L296", "s97a588d8a83f": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/applicationset-controller/argocd-applicationset-controller-service.yaml#L9-L20", "s0a85b38dfca8": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-notification/commands/argocd_notification.go#L149-L154", "s5e141e32ed9c": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/notification/argocd-notifications-controller-metrics-service.yaml#L9-L16", "s8efcc66f9cb4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-dex/commands/argocd_dex.go#L82-L119", "s30c391d9dc23": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-dex/commands/argocd_dex.go#L143-L146", "se477de2fdfa0": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/dex/config.go#L16-L152", "s56c41c02ba8b": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/dex/argocd-dex-server-service.yaml#L9-L25", "s996c72c5db28": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/redis/argocd-redis-deployment.yaml#L18-L58", "s34909d7a1932": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/cache/cache.go#L233-L238", "s1a13cc06db6d": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/repo-server/argocd-repo-server-network-policy.yaml#L9-L35", "s4bbf38e83770": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/redis/argocd-redis-network-policy.yaml#L9-L28", "sadccbbcffcd8": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/dex/argocd-dex-server-network-policy.yaml#L9-L29", "s743c8187cbd7": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-network-policy.yaml#L9-L16", "s49f63c21c3cd": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/application-controller/argocd-application-controller-network-policy.yaml#L9-L19", "sa258c4109d24": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/applicationset-controller/argocd-applicationset-controller-network-policy.yaml#L9-L22", "sa49c30e332ed": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/notification/argocd-notifications-controller-network-policy.yaml#L9-L20", "s68b67007da6b": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/settings/settings.go#L1631-L1638", "s01a52daf078f": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/server/argocd-server-deployment.yaml#L121-L126", "s210b24f04535": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/config/argocd-cmd-params-cm.yaml#L1-L7", "s0f7f1b3404d9": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/config/argocd-rbac-cm.yaml#L1-L7", "s85445da68ce7": "https://github.com/argoproj/argo-cd/blob/v3.5.3/common/common.go#L89-L90", "s357d253fc789": "https://github.com/argoproj/argo-cd/blob/v3.5.3/cmd/argocd-repo-server/commands/argocd_repo_server.go#L257-L260", "s9df1243dde48": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-application-controller/commands/argocd_application_controller.go#L192-L209", "s0abbc26d61e4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd-application-controller/commands/argocd_application_controller.go#L272-L273", "sfcaf2e03154d": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/server.go#L49-L85", "s25a5da2c5e7c": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/server.go#L94-L150", "s73653423b85d": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/tls/tls.go#L428-L506", "s7c7bf274e3ef": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/dex/argocd-dex-server-deployment.yaml#L38-L42", "s08939ffb906f": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/cmd/argocd/commands/admin/redis_initial_password.go#L22-L89", "s3fcdf1601776": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L454-L467", "se987196f20c7": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L539-L547", "s3cb98aa5348d": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L399-L425", "s9bfcf07a6bcc": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L314-L346", "s32323471d785": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/util/rbac/rbac.go#L35-L42", "sb5104fa6ab35": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/controller/appcontroller.go#L321-L323", "sbe428a31febd": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/controller/appcontroller.go#L996", "s326dd2512e89": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/controller/metrics/metrics.go#L187-L220", "s5e687a6ecca2": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L1385-L1401", "s5a0cd7608f62": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/base/redis/kustomization.yaml#L12-L14", "s9a8d62f416f8": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/manifests/install.yaml#L32485", "s0cfce81f2017": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L350-L359", "s19cc383608d3": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L674-L675", "s44b42ce894fe": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L892-L907", "s751b24797922": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L961-L985", "s0a3fee6c3102": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1029-L1091", "s2bf01b29602c": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1537-L1576", "s11dddf48ac64": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/application/application.go#L288-L330", "sab95c8799d63": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/application/application.go#L348-L356", "s27142bd7b0a7": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/session/session.go#L94-L102", "sab27d21a6f4f": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/version/version.go#L75-L82", "sdb49a2a191ec": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/settings/settings.go#L193-L202", "sa198755acef1": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L87-L106", "sdc5ce38b60a4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L227-L247", "s63b5417fbbd0": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/reposerver/repository/repository.go#L631-L706", "s19e9d1a1d4d2": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L37", "sd94bce49cbb4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.mod#L69", "sde82351720c4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L992-L1004", "s6b0b042ccc35": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1012-L1027", "sabf68ca51b16": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/pkg/apiclient/session/session.pb.go#L417-L494", "s0f7c55b64e47": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/pkg/apiclient/version/version.pb.go#L262-L295", "s074e2945a0f4": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/pkg/apiclient/settings/settings.pb.go#L932-L987", "seed01984c64f": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.mod#L115", "s6d4515def993": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L1715", "sc491b902058a": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.mod#L68", "s774ef5b5febe": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.sum#L521-L524", "s984568b27d03": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/go.sum#L1259-L1260", "se1742d8dba7c": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L973-L985", "s73f95d35b6a3": "https://github.com/argoproj/argo-cd/blob/c9c369efcc5b2a0bd720803f8d14a1c3eaddf579/server/server.go#L973-L974" } }, "flux-oidc": { "name": "Flux OIDC introduction", "basis": "v2.9", "sources": { "s1ea193b2b3cc": "https://fluxcd.io/blog/2026/06/flux-v2.9.0/" } }, "argo-docs": { "name": "Argo CD documentation", "basis": "unknown", "sources": { "s51e21b74ece6": "https://argo-cd.readthedocs.io/en/stable/operator-manual/security/", "s02463a4fb268": "https://argo-cd.readthedocs.io/en/stable/operator-manual/secret-management/", "s2efb9ee633af": "https://argo-cd.readthedocs.io/en/stable/getting_started/", "sc2590df86f4e": "https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/", "sf5f1c41f89bd": "https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/", "s3767d400e364": "https://argo-cd.readthedocs.io/en/stable/operator-manual/ingress/", "sdac90731b97d": "https://argo-cd.readthedocs.io/en/stable/operator-manual/webhook/", "s9357e3b59e84": "https://argo-cd.readthedocs.io/en/stable/user-guide/projects/" } }, "flux": { "name": "Flux documentation", "basis": "unknown", "sources": { "s493859f3b848": "https://fluxcd.io/flux/security/best-practices/", "s4dcd744a2444": "https://fluxcd.io/flux/guides/mozilla-sops/", "sd8618bbbc628": "https://fluxcd.io/flux/components/source/ocirepositories/#secret-reference", "sf3f80d9941c8": "https://fluxcd.io/flux/installation/configuration/multitenancy/", "s0669b973542c": "https://fluxcd.io/flux/security/#controller-permissions", "s13bd7f0204b0": "https://fluxcd.io/flux/installation/bootstrap/github/", "s8759854c1bdf": "https://fluxcd.io/flux/components/image/imageupdateautomations/", "s0acf42beed45": "https://fluxcd.io/flux/guides/webhook-receivers/", "s35bb9a844dcf": "https://fluxcd.io/flux/components/notification/receivers/" } }, "dex": { "name": "Dex source", "basis": "v2.45.1", "sources": { "s3b9512b79dea": "https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L151-L199", "s46eb2feaa84f": "https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L553-L572", "s55b453f266f3": "https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/config.go#L255-L265", "s7cc71fede65a": "https://github.com/dexidp/dex/blob/v2.45.1/server/api.go#L53-L104", "s4da116cf17b5": "https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L209-L235", "s68762857b08b": "https://github.com/dexidp/dex/blob/v2.45.1/cmd/dex/serve.go#L363", "s397fba865163": "https://github.com/dexidp/dex/blob/v2.45.1/storage/static.go#L25-L39", "sb068439020cf": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/release.yaml#L3-L15", "s94b27f414052": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L52-L91", "sf5d6527e4e92": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L126-L142", "s1e1a36027f42": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/release.yaml#L10-L24", "s5b7f1f0efe07": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L103-L109", "s53fa204808c2": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L145-L148", "s5fca5a23981a": "https://github.com/dexidp/dex/blob/v2.45.1/.github/workflows/artifacts.yaml#L206-L212" } }, "redis-image": { "name": "docker-library-redis v8.2.3 source", "basis": "bcbc692165ce2241e194035041c5b5e01d2e1a5f", "sources": { "s67a2f7553c9a": "https://github.com/redis/docker-library-redis/blob/bcbc692165ce2241e194035041c5b5e01d2e1a5f/alpine/Dockerfile#L81-L87", "s30643c91686d": "https://github.com/redis/docker-library-redis/blob/bcbc692165ce2241e194035041c5b5e01d2e1a5f/alpine/Dockerfile#L151-L154", "sd04db8453288": "https://github.com/redis/docker-library-redis/blob/bcbc692165ce2241e194035041c5b5e01d2e1a5f/alpine/docker-entrypoint.sh#L93-L111" } }, "redis-source": { "name": "Redis source", "basis": "8.2.3", "sources": { "s667fc16bf18f": "https://github.com/redis/redis/blob/8.2.3/src/server.h#L142-L143", "saa497d27f72f": "https://github.com/redis/redis/blob/8.2.3/src/server.c#L2199-L2221", "s0f96763525b7": "https://github.com/redis/redis/blob/8.2.3/src/server.c#L2638-L2673", "s9b50a7c52721": "https://github.com/redis/redis/blob/8.2.3/src/server.c#L2983-L2996", "sfc4931194892": "https://github.com/redis/redis/blob/8.2.3/src/server.c#L7369-L7408", "sad31935cbfd5": "https://github.com/redis/redis/blob/8.2.3/src/server.c#L7593-L7607", "s798e4cb2e0da": "https://github.com/redis/redis/blob/8.2.3/src/server.c#L7172-L7184", "s514f74f25aeb": "https://github.com/redis/redis/blob/8.2.3/src/setproctitle.c#L164-L316", "sc2c8077e667c": "https://github.com/redis/redis/blob/8.2.3/src/config.h#L199-L204", "s41802d8e64f5": "https://github.com/redis/redis/blob/8.2.3/src/config.c#L3088", "s8623193029f8": "https://github.com/redis/redis/blob/8.2.3/src/setproctitle.c#L87-L161", "sa8561a476c47": "https://github.com/redis/redis/blob/8.2.3/src/setproctitle.c#L53-L55" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s3db522258e44": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md", "s9537bb790553": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/data-binary.md#L4-L32", "sd3b039bafbf6": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/data.md#L4-L49" } }, "python": { "name": "Python documentation", "basis": "unknown", "sources": { "s7a54556e5dac": "https://docs.python.org/3/library/hmac.html", "s31fccd7d50e5": "https://docs.python.org/3/library/sys.html#sys.stdin" } }, "grpc-middleware": { "name": "go-grpc-middleware source", "basis": "v2.3.3", "sources": { "s9ec5783f5bab": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L26-L50", "s4413e7e06e93": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/auth/auth.go#L52-L70", "s38deb43fcd65": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/logging/interceptors.go#L209-L212", "se4ca59a60a0e": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/server.go#L16-L28", "sf6f4e6a293d3": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/callmeta.go#L38-L45", "s71c21e4cdbaf": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/.bingo/go.mod#L1", "sa4598ce2f73d": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/examples/go.mod#L1", "sf540911a60b0": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/interceptors/logging/examples/go.mod#L1", "s7225a1c63c9c": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/v2.3.3/providers/prometheus/go.mod#L1" } }, "grpc-middleware-prom": { "name": "go-grpc-middleware providers/prometheus source", "basis": "v1.1.0", "sources": { "sbb37d9bfd450": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/providers/prometheus/server_metrics.go#L150-L155", "s11f89487025a": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/LICENSE", "s070c3fe7ccfe": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/providers/prometheus/server_metrics.go#L7", "s451c5bbaca48": "https://github.com/grpc-ecosystem/go-grpc-middleware/blob/providers/prometheus/v1.1.0/providers/prometheus/go.mod#L6" } }, "grpc-go": { "name": "grpc-go v1.81.1 source", "basis": "caf0772c2bcb8bc15d43eb53448e921f34f0b7e8", "sources": { "sd6f265b97017": "https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L481-L489", "s7f712ff2fcd0": "https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L691-L710", "s0fdc41207dd8": "https://github.com/grpc/grpc-go/blob/caf0772c2bcb8bc15d43eb53448e921f34f0b7e8/server.go#L79-L84", "s5a39fca6ceeb": "https://github.com/grpc/grpc-go/blob/caf0772c2bcb8bc15d43eb53448e921f34f0b7e8/stats/opentelemetry/csm/observability.go#L41-L57", "se04d5024dfba": "https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L757-L793", "s9b3b1bb96432": "https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L1212-L1245", "se661d06557fb": "https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L1430", "s9e56a9214565": "https://github.com/grpc/grpc-go/blob/v1.81.1/server.go#L1853-L1857", "sb4ab78a762fd": "https://github.com/grpc/grpc-go/blob/v1.81.1/interceptor.go#L65-L72", "s342208064b4f": "https://github.com/grpc/grpc-go/blob/v1.81.1/cmd/protoc-gen-go-grpc/go.mod#L1", "s90e76e9ef9cf": "https://github.com/grpc/grpc-go/blob/v1.81.1/examples/go.mod#L1", "sb410d3246985": "https://github.com/grpc/grpc-go/blob/v1.81.1/gcp/observability/go.mod#L1", "s5b23c5ca5576": "https://github.com/grpc/grpc-go/blob/v1.81.1/interop/observability/go.mod#L1", "s763fdf228f24": "https://github.com/grpc/grpc-go/blob/v1.81.1/interop/xds/go.mod#L1", "s53ec4d2497b4": "https://github.com/grpc/grpc-go/blob/v1.81.1/security/advancedtls/go.mod#L1", "s11e1ee6bc55c": "https://github.com/grpc/grpc-go/blob/v1.81.1/stats/opencensus/go.mod#L1", "s144519a15f3a": "https://github.com/grpc/grpc-go/blob/v1.81.1/test/tools/go.mod#L1" } }, "official-images": { "name": "Docker Official Images library definitions", "basis": "1ff8d41b352de957628be66246d3af2996948c75", "sources": { "s385d77aa2bdf": "https://github.com/docker-library/official-images/blob/1ff8d41b352de957628be66246d3af2996948c75/library/redis#L7-L19", "s8ab40bf97d9f": "https://github.com/docker-library/official-images/blob/f80a67f648fe5f70e6708772537c48237dfbe39b/library/redis#L39-L43" } }, "official-images-docs": { "name": "Docker Official Images README", "basis": "326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d", "sources": { "s79cc3f715062": "https://github.com/docker-library/official-images/blob/326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d/README.md#L369", "sb8f4661bdd1f": "https://github.com/docker-library/official-images/blob/326d6f326e8ed6ea7b66fcc53c6b78dd22334a2d/README.md#L413-L415" } } }, "claims": { "git-boundary": {"text": "Repository writers exercise reconciliation permissions; review platform changes, separate tenants and scope credentials.", "components": ["argo-docs", "flux"], "sources": ["argo-docs:s51e21b74ece6", "flux:s493859f3b848"], "status": "REASONED"}, "secret-storage": {"text": "Protect bootstrap, registry, cluster, webhook and decryption credentials; commit only encrypted secrets and keep SOPS/age keys out of Git.", "components": ["argo-docs", "flux"], "sources": ["argo-docs:s02463a4fb268", "flux:s4dcd744a2444", "flux:sd8618bbbc628"], "status": "REASONED"}, "egress": {"text": "Restrict fetch, API, identity and notification egress; Argo repository allowlists do not constrain remote bases or Helm dependencies.", "components": ["argo-docs", "flux"], "sources": ["argo-docs:s51e21b74ece6", "flux:sf3f80d9941c8"], "status": "REASONED"}, "argo-bootstrap": {"text": "Rotate the initial admin password before deleting argocd-initial-admin-secret; deletion alone does not rotate it.", "components": ["argo-docs"], "sources": ["argo-docs:s2efb9ee633af", "argo-docs:sc2590df86f4e"], "status": "REASONED"}, "argo-sso": {"text": "The built-in admin is unrestricted; configure named SSO roles and IdP MFA, then set admin.enabled=false.", "components": ["argo-docs"], "sources": ["argo-docs:sc2590df86f4e", "argo-docs:sf5f1c41f89bd"], "status": "REASONED"}, "argo-origin": {"text": "Keep the ClusterIP origin private behind authenticated TLS and protect both REST/browser and CLI/gRPC routes.", "components": ["argo-docs", "argo"], "sources": ["argo-docs:s2efb9ee633af", "argo-docs:s3767d400e364", "argo:sddf4853ad503"], "status": "REASONED"}, "argo-server": {"text": "Server defaults to 0.0.0.0:8080; the Service maps 80 and 443 to it.", "components": ["argo"], "sources": ["argo:s6e8c96d23e05", "argo:sddf4853ad503", "argo:sf4dd846c82c4"], "status": "REASONED"}, "argo-tls": {"text": "Server --insecure/server.insecure defaults false and disables backend TLS, not authentication; use only behind protected TLS termination.", "components": ["argo", "argo-docs"], "sources": ["argo:s6e8c96d23e05", "argo-docs:s3767d400e364"], "status": "REASONED"}, "argo-client-tls": {"text": "argocd login --insecure skips client certificate verification; the server initially uses a self-signed certificate.", "components": ["argo-docs"], "sources": ["argo-docs:s2efb9ee633af", "argo-docs:s3767d400e364"], "status": "REASONED"}, "argo-metrics": {"text": "Server HTTP metrics on 8083 are unauthenticated and bind to --address, not --metrics-address.", "components": ["argo"], "sources": ["argo:s96065cc09776", "argo:s7cc690d1bf69", "argo:s028a4769d6c3", "argo:sf4dd846c82c4", "argo:s6e8c96d23e05"], "status": "REASONED"}, "argo-profiler": {"text": "Server and repo-server pprof paths return 401 unless the profiler file contains exactly true with no newline; enabled profiling has no caller authentication.", "components": ["argo"], "sources": ["argo:sc52bbd14bff4", "argo:s028a4769d6c3", "argo:s98b27313f615"], "status": "REASONED"}, "argo-profiler-file": {"text": "ARGOCD_ENABLE_PROFILER_FILE_PATH defaults to /home/argocd/params/profiler.enabled; the server mounts ConfigMap key server.profile.enabled there.", "components": ["argo"], "sources": ["argo:sc52bbd14bff4", "argo:sb0648e746fb9", "argo:s2f5af86c62d1"], "status": "REASONED"}, "repo-grpc": {"text": "Repo-server gRPC binds 0.0.0.0:8081 with TLS by default; mTLS is skipped without the client CA, separate from the serving certificate.", "components": ["argo"], "sources": ["argo:s9bca471ac6d9", "argo:s4bbc848d9923", "argo:sf4dd846c82c4", "argo:s85445da68ce7", "argo:s357d253fc789"], "status": "REASONED"}, "repo-http": {"text": "Repo-server HTTP metrics and health bind 0.0.0.0:8084 without an authentication wrapper.", "components": ["argo"], "sources": ["argo:s98b27313f615", "argo:s9bca471ac6d9", "argo:sf4dd846c82c4", "argo:s85445da68ce7", "argo:s357d253fc789"], "status": "REASONED"}, "repo-limits": {"text": "Repo-server requires verified client certificates with a loaded client CA; missing or unstatable serving files trigger a session self-signed certificate, while an invalid present pair errors. No authorization interceptor appears, and the registered service handlers read no caller identity, so, from this source reading, any client completing the transport handshake can call its methods.", "components": ["argo"], "sources": ["argo:sfcaf2e03154d", "argo:s25a5da2c5e7c", "argo:s73653423b85d", "argo:s5e687a6ecca2", "argo:sa198755acef1", "argo:sdc5ce38b60a4", "argo:s63b5417fbbd0"], "status": "REASONED"}, "controller-metrics": {"text": "Application-controller metrics, health and file-gated pprof share one plain-HTTP mux on a hardcoded 0.0.0.0 host; --metrics-port (default 8082) sets only the port, and the command defines no bind-address flag.", "components": ["argo"], "sources": ["argo:sf98b6bc4f5dc", "argo:s9df1243dde48", "argo:s0abbc26d61e4", "argo:sf4dd846c82c4", "argo:sb5104fa6ab35", "argo:sbe428a31febd", "argo:s326dd2512e89", "argo:sc52bbd14bff4"], "status": "REASONED"}, "applicationset-webhook": {"text": "ApplicationSet webhook binds :7000; its container and Service declare 7000.", "components": ["argo"], "sources": ["argo:s49a7c83d9371", "argo:s97a588d8a83f"], "status": "REASONED"}, "applicationset-metrics": {"text": "ApplicationSet metrics bind :8080; its container and Service declare 8080.", "components": ["argo"], "sources": ["argo:s49a7c83d9371", "argo:s97a588d8a83f"], "status": "REASONED"}, "applicationset-health": {"text": "ApplicationSet health probes bind :8081 without a container or Service port declaration; missing declarations do not close sockets.", "components": ["argo"], "sources": ["argo:s49a7c83d9371"], "status": "REASONED"}, "notifications": {"text": "Notifications HTTP metrics bind 0.0.0.0:9001 without authentication; a metrics Service and TCP liveness probe exist without containerPort.", "components": ["argo"], "sources": ["argo:s0a85b38dfca8", "argo:s5e141e32ed9c"], "status": "REASONED"}, "dex-http": {"text": "Dex starts only with nonempty generated configuration; HTTP defaults to TLS on 0.0.0.0:5556 using /tmp/tls.crt and /tmp/tls.key.", "components": ["argo"], "sources": ["argo:s8efcc66f9cb4", "argo:s30c391d9dc23", "argo:se477de2fdfa0"], "status": "REASONED"}, "dex-grpc": {"text": "Generated Dex gRPC uses 0.0.0.0:5557 without TLS or client-auth settings; Dex v2.45.1 source sets no transport credentials or authentication interceptor for this configuration, its API handlers read no caller identity, and GetClient returns client secrets, including Argo CD's static client. This assumes the deployed ghcr.io/dexidp/dex:v2.45.1 tag was built from Dex v2.45.1.", "components": ["argo", "dex"], "sources": ["argo:se477de2fdfa0", "argo:s56c41c02ba8b", "argo:s7c7bf274e3ef", "dex:s3b9512b79dea", "dex:s46eb2feaa84f", "dex:s55b453f266f3", "dex:s7cc71fede65a", "dex:s4da116cf17b5", "dex:s68762857b08b", "dex:s397fba865163"], "status": "REASONED"}, "dex-image": {"text": "Dex v2.45.1's release workflow builds pushed vX.Y.Z tags from the checked-out tree, tags the alpine variant through its semver rule, and, when publishing, cosign-signs and attests the pushed ghcr.io digest. Argo CD's base names ghcr.io/dexidp/dex:v2.45.1; on 2026-10-24 gh 2.101.0 verified its then digest's SLSA statement as signed by artifacts.yaml at 11d2eeb5, which this guide assesses built it.", "components": ["argo", "dex"], "sources": ["argo:s7c7bf274e3ef", "dex:sb068439020cf", "dex:s94b27f414052", "dex:sf5d6527e4e92", "dex:s1e1a36027f42", "dex:s5b7f1f0efe07", "dex:s53fa204808c2", "dex:s5fca5a23981a"], "status": "REASONED"}, "dex-telemetry": {"text": "Dex telemetry uses HTTP on 0.0.0.0:5558; all three Dex ports have Services.", "components": ["argo"], "sources": ["argo:se477de2fdfa0", "argo:s56c41c02ba8b"], "status": "REASONED"}, "redis-password": {"text": "Redis receives --requirepass from argocd-redis/auth; secret-init generates and checks that Secret. The redis:8.2.3-alpine tag, taken as built from docker-library-redis v8.2.3, uses Redis wildcard bind defaults and patches protected mode off; this holds for the deployed public.ecr.aws image only if it was built from that recipe.", "components": ["argo", "redis-image", "redis-source"], "sources": ["argo:s996c72c5db28", "argo:s08939ffb906f", "argo:s5a0cd7608f62", "argo:s9a8d62f416f8", "redis-image:s67a2f7553c9a", "redis-image:s30643c91686d", "redis-image:sd04db8453288", "redis-source:s667fc16bf18f", "redis-source:saa497d27f72f", "redis-source:s0f96763525b7", "redis-source:s9b50a7c52721"], "status": "REASONED"}, "redis-image-name": {"text": "The base Redis kustomization renames redis to public.ecr.aws/docker/library/redis, keeping 8.2.3-alpine, as the rendered install.yaml shows; Docker Official Images lists redis:8.2.3-alpine as built from docker-library-redis bcbc6921's alpine directory. On 2026-10-24 ECR served Docker Hub's tag index, whose producer annotations name that recipe; that build and later pulls are not established.", "components": ["argo", "official-images", "official-images-docs"], "sources": ["argo:s996c72c5db28", "argo:s5a0cd7608f62", "argo:s9a8d62f416f8", "official-images:s385d77aa2bdf", "official-images:s8ab40bf97d9f", "official-images-docs:sb8f4661bdd1f", "official-images-docs:s79cc3f715062"], "status": "REASONED"}, "redis-argv": {"text": "Redis 8.2.3 has no password-specific redaction of its start-up arguments; on Linux, with set-proc-title at its default yes, its first title update zeroes the original argument area and any environment strings directly after it before listeners open, leaving heap and setenv() copies. Whether that clears the REDIS_PASSWORD environment copy is not established.", "components": ["argo", "redis-source"], "sources": ["argo:s996c72c5db28", "redis-source:sfc4931194892", "redis-source:sad31935cbfd5", "redis-source:s798e4cb2e0da", "redis-source:s514f74f25aeb", "redis-source:sc2c8077e667c", "redis-source:s41802d8e64f5", "redis-source:s8623193029f8", "redis-source:sa8561a476c47"], "status": "REASONED"}, "redis-tls": {"text": "Base manifests configure no Redis TLS and the Argo Redis client defaults --redis-use-tls=false; protect traffic, Secret and cache.", "components": ["argo"], "sources": ["argo:s996c72c5db28", "argo:s34909d7a1932"], "status": "REASONED"}, "component-policies": {"text": "Base policies restrict repo gRPC, Redis and Dex callers by component; enforcement needs a supporting CNI.", "components": ["argo"], "sources": ["argo:s1a13cc06db6d", "argo:s4bbf38e83770", "argo:sadccbbcffcd8"], "status": "REASONED"}, "broad-policies": {"text": "Server policy allows all ingress; other metrics and ApplicationSet webhook allowances admit every namespace. Narrow existing additive allowances and egress separately.", "components": ["argo"], "sources": ["argo:s743c8187cbd7", "argo:s49f63c21c3cd", "argo:sa258c4109d24", "argo:sa49c30e332ed"], "status": "REASONED"}, "argo-anonymous": {"text": "users.anonymous.enabled defaults off; anonymous callers otherwise inherit policy.default.", "components": ["argo", "argo-docs"], "sources": ["argo:s68b67007da6b", "argo-docs:sf5f1c41f89bd"], "status": "REASONED"}, "argo-disable-auth": {"text": "server.disable.auth defaults false; inspect effective args and environment because anonymous-off does not compensate for disabled authentication.", "components": ["argo"], "sources": ["argo:s6e8c96d23e05", "argo:s01a52daf078f", "argo:s210b24f04535"], "status": "REASONED"}, "argo-default-role": {"text": "Base RBAC ConfigMap omits policy.default; the inspected loader supplies an empty role to the enforcer, which skips its additive default-role check. Default-role grants are additive and cannot be revoked by a later subject rule; define scoped policy.csv roles and group mappings.", "components": ["argo", "argo-docs"], "sources": ["argo:s0f7f1b3404d9", "argo:s3fcdf1601776", "argo:se987196f20c7", "argo:s3cb98aa5348d", "argo:s9bfcf07a6bcc", "argo:s32323471d785", "argo-docs:sf5f1c41f89bd"], "status": "REASONED"}, "argo-rbac-wiring": {"text": "argocd-server builds its enforcer for argocd-rbac-cm with the built-in policy and a claims enforcer, starts the ConfigMap loader, authenticates gRPC calls by default and stores claims, and passes the enforcer to API services; for example, application List filters on get and Create checks create. Per-handler coverage and runtime overrides remain unverified.", "components": ["argo"], "sources": ["argo:s0cfce81f2017", "argo:s19cc383608d3", "argo:s44b42ce894fe", "argo:s751b24797922", "argo:s0a3fee6c3102", "argo:s2bf01b29602c", "argo:s11dddf48ac64", "argo:sab95c8799d63", "argo:s3fcdf1601776", "argo:se987196f20c7"], "status": "REASONED"}, "argo-auth-overrides": {"text": "argocd-server's gRPC interceptors authenticate calls by default unless auth is disabled, but, from this source reading, the session, version and settings services override that function for every method and accept unauthenticated callers, settings only for Get. Pinned go-grpc-middleware v2.3.3 calls an override on the service value grpc-go v1.81.1 passes; as read, the interceptors ahead keep it.", "components": ["argo", "grpc-middleware", "grpc-go", "grpc-middleware-prom"], "sources": ["argo:s751b24797922", "argo:s6d4515def993", "argo:s73f95d35b6a3", "argo:s2bf01b29602c", "argo:s27142bd7b0a7", "argo:sab27d21a6f4f", "argo:sdb49a2a191ec", "argo:s19e9d1a1d4d2", "argo:sd94bce49cbb4", "argo:sc491b902058a", "argo:se1742d8dba7c", "grpc-middleware:s9ec5783f5bab", "grpc-middleware:s4413e7e06e93", "grpc-middleware:s38deb43fcd65", "grpc-middleware:se4ca59a60a0e", "grpc-middleware:sf6f4e6a293d3", "grpc-middleware-prom:sbb37d9bfd450", "argo:sde82351720c4", "argo:s6b0b042ccc35", "argo:sabf68ca51b16", "argo:s0f7c55b64e47", "argo:s074e2945a0f4", "argo:seed01984c64f", "grpc-go:sd6f265b97017", "grpc-go:s7f712ff2fcd0", "grpc-go:se04d5024dfba", "grpc-go:s9b3b1bb96432", "grpc-go:se661d06557fb", "grpc-go:s9e56a9214565", "grpc-go:sb4ab78a762fd", "grpc-go:s0fdc41207dd8", "grpc-go:s5a39fca6ceeb"], "status": "REASONED"}, "argo-auth-chain-ahead": {"text": "Ahead of auth, argocd-server's unary chain runs the bug21955 workaround, v2.3.3 logging and providers/prometheus v1.1.0 metrics interceptors; as read (for metrics, inferred), none writes to UnaryServerInfo or its Server field. In the extracted modules, the latter two wrap one shared interceptor (v2.3.3; for metrics, this guide's inference) that reads only info.FullMethod and calls the handler.", "components": ["argo", "grpc-middleware", "grpc-middleware-prom"], "sources": ["argo:se1742d8dba7c", "argo:s6d4515def993", "argo:s73f95d35b6a3", "argo:sc491b902058a", "argo:sd94bce49cbb4", "grpc-middleware:s38deb43fcd65", "grpc-middleware:se4ca59a60a0e", "grpc-middleware:sf6f4e6a293d3", "grpc-middleware-prom:sbb37d9bfd450", "argo:s774ef5b5febe", "grpc-middleware-prom:s070c3fe7ccfe", "grpc-middleware-prom:s451c5bbaca48"], "status": "REASONED"}, "argo-grpc-middleware-pin": {"text": "Argo CD's go.sum lines for go-grpc-middleware/v2 v2.3.3, providers/prometheus v1.1.0 and grpc-go v1.81.1 match hashes a go mod download recorded over their zips on 2026-10-23. A diff -rq of each extracted zip against its tag's module directory reported no differing file; one-sided paths are or hold nested modules, or are the root LICENSE in the prometheus zip; no h1: hash was recomputed from git.", "components": ["argo", "grpc-middleware", "grpc-middleware-prom", "grpc-go"], "sources": ["argo:sd94bce49cbb4", "argo:sc491b902058a", "argo:seed01984c64f", "argo:s774ef5b5febe", "argo:s984568b27d03", "grpc-middleware:s9ec5783f5bab", "grpc-middleware:s4413e7e06e93", "grpc-middleware-prom:sbb37d9bfd450", "grpc-go:sb4ab78a762fd", "grpc-go:s342208064b4f", "grpc-go:s90e76e9ef9cf", "grpc-go:sb410d3246985", "grpc-go:s5b23c5ca5576", "grpc-go:s763fdf228f24", "grpc-go:s53ec4d2497b4", "grpc-go:s11e1ee6bc55c", "grpc-go:s144519a15f3a", "grpc-middleware:s71c21e4cdbaf", "grpc-middleware:sa4598ce2f73d", "grpc-middleware:sf540911a60b0", "grpc-middleware:s7225a1c63c9c", "grpc-middleware-prom:s11f89487025a"], "status": "REASONED"}, "argo-webhook": {"text": "/api/webhook accepts unauthenticated refresh events without a shared secret; configure one to limit spoofed reconciliation and resource exhaustion.", "components": ["argo-docs"], "sources": ["argo-docs:sdac90731b97d"], "status": "REASONED"}, "argo-projects": {"text": "Restrict AppProjects including the built-in default project; project constraints do not reduce a compromised controller's Kubernetes credentials.", "components": ["argo-docs"], "sources": ["argo-docs:s9357e3b59e84", "argo-docs:s51e21b74ece6"], "status": "REASONED"}, "argo-cluster-rbac": {"text": "Reduce controller and registered-cluster argocd-manager write permissions to needed namespaces/resources while retaining required read access.", "components": ["argo-docs"], "sources": ["argo-docs:s51e21b74ece6"], "status": "REASONED"}, "flux-api": {"text": "Flux has no standalone management login/API; Kubernetes authenticates its custom-resource operations, while separate dashboards need their own controls.", "components": ["flux"], "sources": ["flux:s493859f3b848", "flux:s0669b973542c"], "status": "REASONED"}, "flux-bootstrap": {"text": "GitHub bootstrap --token-auth retains a PAT in flux-system; --token-auth=false creates a read-only SSH deploy key by default. Protect bootstrap input and Secrets.", "components": ["flux"], "sources": ["flux:s13bd7f0204b0"], "status": "REASONED"}, "flux-writeback": {"text": "Image automation needs Git write access, including --read-write-key=true for the bootstrap key; scope the repository and protect update branches and objects.", "components": ["flux"], "sources": ["flux:s13bd7f0204b0", "flux:s8759854c1bdf"], "status": "REASONED"}, "flux-artifacts": {"text": "Source-controller serves fetched artifacts over internal HTTP; enforced NetworkPolicy, not ClusterIP alone, isolates them.", "components": ["flux"], "sources": ["flux:s493859f3b848"], "status": "REASONED"}, "flux-receiver-port": {"text": "Webhook receiver listens on 9292 behind Service port 80; external exposure needs a route and TLS termination.", "components": ["flux"], "sources": ["flux:s0acf42beed45"], "status": "REASONED"}, "flux-receiver-types": {"text": "generic validates nothing; generic-hmac, GitHub, Bitbucket and Nexus use HMAC, while gitlab compares X-Gitlab-Token.", "components": ["flux"], "sources": ["flux:s35bb9a844dcf"], "status": "REASONED"}, "flux-oidc": {"text": "generic-oidc, introduced in Flux 2.9, rejects secretRef and validates bearer tokens; constrain issuer validations and audience, whose default is notification-controller.", "components": ["flux", "flux-oidc"], "sources": ["flux:s35bb9a844dcf", "flux-oidc:s1ea193b2b3cc"], "status": "REASONED"}, "flux-rbac": {"text": "Only kustomize-controller and helm-controller hold cluster-admin; other controllers still have meaningful permissions including Secret access.", "components": ["flux"], "sources": ["flux:s0669b973542c"], "status": "REASONED"}, "flux-lockdown": {"text": "Use scoped reconciliation ServiceAccounts, --no-cross-namespace-refs and --no-remote-bases; --default-service-account fills an omission, not an explicit serviceAccountName.", "components": ["flux"], "sources": ["flux:sf3f80d9941c8"], "status": "REASONED"}, "verify-argo": {"text": "Anonymous application listing should fail with 401 while a valid token lists the expected application; repeat at origin and CLI/gRPC and inspect effective RBAC.", "components": ["argo-docs", "argo", "curl"], "sources": ["argo-docs:sf5f1c41f89bd", "argo:s6e8c96d23e05", "curl:s3db522258e44"], "status": "REASONED", "verify": [1]}, "verify-bootstrap": {"text": "Absence of the bootstrap Secret proves removal of that material, not password rotation.", "components": ["argo-docs"], "sources": ["argo-docs:s2efb9ee633af"], "status": "REASONED", "verify": [2]}, "verify-inventory": {"text": "Inventory Services, routes, host ports, policies and component sockets; node-local ss and policy objects alone do not establish cluster isolation.", "components": ["argo", "flux"], "sources": ["argo:s743c8187cbd7", "flux:s493859f3b848"], "status": "REASONED", "verify": [2]}, "verify-repo-metrics": {"text": "Base policy permits HTTP 200 metrics to monitoring and unrelated pods; narrowed policy should admit only monitoring, with a successful positive control.", "components": ["argo"], "sources": ["argo:s98b27313f615", "argo:s1a13cc06db6d"], "status": "REASONED", "verify": [3]}, "verify-server-metrics": {"text": "Repeat on server 8083: only authorized monitoring should receive metrics after narrowing ingress; a failed control is inconclusive.", "components": ["argo"], "sources": ["argo:s028a4769d6c3", "argo:s743c8187cbd7"], "status": "REASONED"}, "verify-profiler": {"text": "From an allowed pod, pprof cmdline should return 401 disabled, 200 for exact true, then 401 for true plus newline; keep metrics as control.", "components": ["argo"], "sources": ["argo:sc52bbd14bff4", "argo:s028a4769d6c3"], "status": "REASONED"}, "verify-flux": {"text": "Identical generic-hmac bodies without and with X-Signature should be rejected and accepted with reconciliation; status is type/version dependent. The cited curl curl-8_14_1 lines show --data-binary posting data exactly as specified, preserving newlines and carriage returns that --data strips from @file input, reading @- from stdin and @filename from a file, and posting like --data's POST request.", "components": ["flux", "python", "curl"], "sources": ["flux:s35bb9a844dcf", "python:s7a54556e5dac", "python:s31fccd7d50e5", "curl:s3db522258e44", "curl:s9537bb790553", "curl:sd3b039bafbf6"], "status": "REASONED", "verify": [4]}, "verify-receiver-status": {"text": "Read receiver name and Ready condition without exposing webhookPath or condition messages; origin checks distinguish receiver rejection from proxy rejection.", "components": ["flux"], "sources": ["flux:s35bb9a844dcf"], "status": "REASONED"} } } ```` ====================================================================== ==> self-hosted-ci-runners.md ====================================================================== # Self-hosted CI runners: GitHub Actions and GitLab Runner GitHub Actions self-hosted runners and GitLab Runner run CI jobs on machines you manage. This guide helps your AI assistant configure runners securely, including limits on whose code can start jobs, untrusted jobs on isolated disposable workers apart from deployment runners, narrow credentials, and egress enforced outside the job. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/self-hosted-ci-runners.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against GitHub Actions documentation (no recorded version), GitHub REST API 2026-03-10, GitLab Runner source v19.5.0, and Python tomllib minimum 3.11; 3 further sources carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 37 claims: 1 demonstrated by a check that ran and 36 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A self-hosted runner exists to execute CI job commands on your own machine, so the trust question is not a bind address but who can cause code to run on it. Both GitHub Actions runners and GitLab Runner are outbound-only for job transport: they poll their service over HTTPS (configure GitLab Runner to verify the certificate) and open no inbound job-polling port, so no inbound opening is needed to receive jobs. Autoscaled GitLab workers can still take private management connections from their runner manager, and a custom GitHub autoscaler can run a separate webhook receiver; restrict any such management listener to its manager and validate webhook signatures before processing. The controls are eligibility (whose code the runner will run), credentials (what a job can reach), executor privilege (how isolated the job is from the host), and lifecycle (what survives after a job). Get those wrong and an untrusted job is remote code execution with the runner's permissions and network position: it can steal the credentials the job legitimately receives, poison caches and tools that later trusted builds consume, and pivot from the runner's place in your network, including the cloud metadata endpoint ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). Isolate runners from production and from each other, and handle every token as [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) describes. Run the job-executing process under a dedicated account with the least OS privilege it needs, keep administrator and provisioning credentials off the worker, and treat rootful Docker access (including `docker` group membership) as host administration confined to a disposable VM. Enforce worker egress outside the job's control: allow the CI, artifact, registry, DNS and dependency destinations it needs and deny production networks, runner-management services, other workers, and unused metadata endpoints over IPv4 and IPv6. IMDSv2 and metadata headers do not stop arbitrary job code that can request its own token, so test egress from the actual job environment, including connections that bypass any proxy ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). ## GitHub Actions self-hosted runner GitHub's own guidance is to "only use self-hosted runners with private repositories", because a fork of a public repository "can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow". Keep self-hosted runners off public repositories. Runner groups are the control: only private repositories can use a runner group by default, and the override that admits public repositories should stay off. An ordinary fork `pull_request` run receives a read-only `GITHUB_TOKEN` and, on the default fork settings, none of your other secrets, but the code still executes on your host, so it can already read host-resident credentials, files, and reachable services; host persistence (a poisoned tool or harvested credential left for a later, more privileged job on the same machine) only compounds it. The `pull_request_target` and `workflow_run` triggers can access secrets and a write token, so never check out or execute untrusted pull-request code (or scripts from untrusted artifacts or caches) in those privileged workflows; a fresh ephemeral runner does not protect credentials supplied to the same job. Keep privileged workflows on separate, trusted, ephemeral workers, and review the private-repository fork-workflow settings, which can themselves grant write tokens and secrets. Several credentials pass through a runner, and they are not interchangeable. The registration token is a short-lived bootstrap value (documented one-hour lifetime); generate it on demand, never bake it into an image, and note that its expiry does not deregister a runner already registered with it. Keep the admin-scoped credential that generates it (a GitHub App installation or a PAT) off the runner host and in a separate management service, since it outranks anything the runner holds. The permission and lifetime rule for that admin-scoped credential is to use the endpoint's documented minimum (organization registration or JIT endpoints need `Self-hosted runners: write`, repository endpoints need `Administration: write`) and to expire or revoke that admin-scoped credential when no longer needed. A JIT configuration (the `encoded_jit_config` the generate-jitconfig API returns) provisions a runner that executes at most one job; protect the whole response. Once configured, the runner stores its own authentication credential locally, so protect the install directory and any backups. `GITHUB_TOKEN` is a per-job token scoped to the workflow's repository; set the default workflow permissions to read-only and grant more only to the trusted job with an explicit `permissions:` key. Treat OIDC as another job credential: grant `id-token: write` only to the deployment job that needs it, keep it out of the default permissions, and configure the cloud provider to validate the issuer, audience and a narrowly scoped subject for the intended repository and trusted branch or protected environment ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)). Those OIDC controls matter because any code in an authorized job can mint the OIDC token and use the cloud credentials it buys. Run the runner service as a dedicated unprivileged user, never root and never in the `docker` group (membership is root-equivalent), because a job executes with that account's authority ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). Prefer ephemeral or JIT runners so one job cannot poison the next, run untrusted CI on a pool kept separate from deployment and signing runners, and remember that deregistering an ephemeral runner does not destroy its disk: tearing down the machine is your job. Labels only route jobs to runners; they do not authorize, so never treat a label as a security boundary. ## GitLab Runner The executor decides how much of the host a job controls, and it is the first thing to get right. The shell executor runs the job directly as the runner's user with no isolation. The Docker executor is a real boundary only when it is unprivileged: GitLab warns that with `privileged = true` "you are effectively disabling all the container's security mechanisms", exposing the host to privilege escalation and container breakout. Mounting `/var/run/docker.sock` (or reaching a rootful daemon over TCP) hands over the daemon's host even with `privileged = false`. For untrusted work use an unprivileged Docker or a hardened Kubernetes executor on isolated disposable workers, never shell on a shared host, never the socket, and never `services_privileged`; build images without the host daemon rather than mounting it. Deny job access to the host Docker daemon, sensitive host mounts, host PID/network namespaces, unnecessary devices and elevated capabilities. The Kubernetes executor needs the following controls: enforce non-privileged containers, disable privilege escalation, restrict the service-account RBAC, and disable automatic service-account-token mounting where jobs do not need it, and prevent job configuration from overriding those restrictions. Containers share the host kernel, so use separate disposable VMs or nodes where that boundary is insufficient. Make workers disposable, one job each, with an autoscaling executor, since a persistent worker lets one job poison the next. The tokens are distinct credentials. The legacy registration token was disabled by default in GitLab 17.0; removal is currently scheduled for GitLab 20.0. Use the runner authentication token, which lives in `config.toml`, so protect that file and its backups and rotate the token on any exposure, because a cloned runner using the same token silently steals the jobs meant for the original. The access token that creates a runner (through `POST /user/runners`, needing the `create_runner` scope) is a separate, more powerful credential: keep it off the worker, and set runner scope and protection at creation rather than assuming legacy registration arguments still configure them. `CI_JOB_TOKEN` carries the triggering user's access to the resources it supports for the job's lifetime, and it is also `CI_REGISTRY_PASSWORD` and is embedded in `CI_REPOSITORY_URL`, so restrict inbound job-token access on each target project that holds sensitive resources. That inbound job-token allowlist controls whose job tokens may reach the target, not where this job's token may go, and some resources stay reachable outside it unless you also restrict their feature visibility. An instance-wide runner on an installation with public projects accepts strangers' code just as a public-repository GitHub runner does. A project-scoped runner limits which project can assign it, not the provenance of the code it runs: a parent-project member can launch a fork merge-request pipeline using the fork's configuration with parent-project resources, so review that code before launching it or disable `ci_allow_fork_pipelines_to_run_in_parent_project`. Any user with the Developer role can compromise a non-ephemeral runner, so use protected runners with `access_level: ref_protected` for protected refs and keep them separate from the pool that tests forked merge requests. The runner opens no inbound listener unless you configure one. The Prometheus metrics endpoint (set through `listen_address` or `gitlab-runner run --listen-address`, and also via Helm/Operator config) has no built-in authorization and also exposes profiling endpoints; `9252` is the allocated port, not proof of the configured one. The optional `[session_server]` listener uses TLS and must be reachable by GitLab at its configured address (`8093` is an example), so restrict it to the required GitLab traffic rather than only to monitoring clients. Enable neither unless needed, and firewall each to the clients that need it when you do. ## Verify REASONED: Both runners are outbound-only job pollers, so most checks are posture and REST audits, not reachability probes. Every check that needs a live registered runner or worker is reasoned, not demonstrated: the authoring environment has no container runtime, registered test runner, or cloud test infrastructure, so those outcomes are derived from the cited vendor pages rather than observed. Offline outcomes are recorded below: `tomllib` fixtures, jq filters on synthetic REST-shaped JSON, a PyYAML parse of the probe workflow, a stub `glab` run, the canary commands, plain ss -tln without sudo listing this host's listeners, the guarded listener block with stub `sudo` and `curl`, and bash -n and ShellCheck syntax checks. Those records say which form each run covered; the current blocks are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by the October 2026 stub runs recorded at each block. Require successful, complete listings; authentication errors, unavailable endpoints, and incomplete pagination are inconclusive. Run the REST and config checks from an administrator workstation using stored CLI credentials. REASONED: eligibility semantics per the guide's cited GitHub self-hosted runner groups REST API and runner groups and access entries; no GitHub organization or registered test runner is available in the authoring environment, so no listing was read. At marking time the three jq filters in the block below, run with jq 1.8.1 on synthetic REST-shaped JSON, printed the named fields, including allows_public_repositories true for an exposed group and false for a fixed one; the gh client's own --jq handling was not exercised. GitHub eligibility: `visibility: all` does not by itself mean public access, and `allows_public_repositories` is a separate control, so list every runner group, then each selected group's repositories, then every public repository's own runners (a repository-level runner belongs to no group). Those filter runs predate the block's guarded values and exit status, though its three filters are unchanged. REASONED: following block; GitHub eligibility audit follows the cited runner-group and runner REST documentation; no registered test runner or cloud test infrastructure was available. The block takes your organization's login, a runner group ID and a public repository's owner and name on a guarded `set --` line. It records a finding (1) when any runner group shows `allows_public_repositories` true, the exposed reading above, and 3 when a listing fails or the group listing shows no such value; the groups' other fields, the group's repositories and the repository's own runners are left for you to judge. This form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by October 2026 stub runs with gh stubbed: a group listing showing only false exited 0, one group showing true exited 1 (also when a later listing failed), a failed listing or a listing without the field exited 3, and each `set --` refusal exited 2 before any gh call. ```bash ( # the whole block in one subshell, so one exit status covers all three listings set +e # errexit off here, so a listing that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORG' 'REPLACE_WITH_GROUP_ID' 'REPLACE_WITH_OWNER' 'REPLACE_WITH_REPO' # your organization's login, a runner group ID (the first listing prints them), then the owner and name of a public repository: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-]*) echo "substitute your organization's login (letters, digits and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|*[!0123456789]*) echo "substitute a runner group ID (digits) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-]*) echo "substitute the repository owner's login (letters, digits and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$4" in ""|.|..|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo "substitute the repository name (letters, digits, dots, underscores and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$3" "$4" "$5" "$(if gh api -X GET --paginate -H 'X-GitHub-Api-Version: 2026-03-10' "/orgs/$1/actions/runner-groups" --jq '.runner_groups[] | {id, name, visibility, allows_public_repositories, inherited, restricted_to_workflows, selected_workflows}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # gh's exit status, then the group listing printf '%s\n' "$7" case "$6:$7" in 0:*'"allows_public_repositories":true'*|0:*'"allows_public_repositories": true'*) echo "FINDING: a runner group allows public repositories"; set -- "$1" "$2" "$3" "$4" 1 ;; 0:*'"allows_public_repositories":false'*|0:*'"allows_public_repositories": false'*) ;; 0:*) echo "the group listing showed no allows_public_repositories value; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "gh exit $6: the runner-group listing failed; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac gh api -X GET --paginate -H 'X-GitHub-Api-Version: 2026-03-10' "/orgs/$1/actions/runner-groups/$2/repositories" --jq '.repositories[] | {id, full_name, visibility}' || { echo "gh exit $?: the group's repository listing failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3; } gh api -X GET --paginate -H 'X-GitHub-Api-Version: 2026-03-10' "/repos/$3/$4/actions/runners" --jq '.runners[] | {id, name, status}' || { echo "gh exit $?: the repository's runner listing failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3; } echo "block exit $5; each group's other fields, the group's repositories and the repository's own runners are not classified, so judge them yourself" exit "$5" ) ``` REASONED: following block; the repository review in this paragraph and the organization and repository workflow-permission reads in the block below, per the guide's cited GitHub GITHUB_TOKEN permissions and GitHub self-hosted runner groups and access entries; no GitHub organization or repository is available in the authoring environment, so no setting was read. At marking time the block passed bash -n and ShellCheck 0.11.0, and its jq filter on synthetic JSON printed write and true for an exposed posture and read and false for a fixed one. Those runs covered the block's earlier form. The current form takes your organization's login and a repository's owner and name on a guarded `set --` line, records a finding (1) when either read shows write or true and 3 when a read fails or shows neither value, and exits 0 for read and false in both. It reports `can_approve_pull_request_reviews` true as a finding (a finding unless your workflows require it), so where they do, the block exits 1 and that FINDING line is the expected setting for you to judge. It is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by October 2026 stub runs with gh stubbed: read and false in both exited 0, write or true in either exited 1, a failed read or unrecognized output exited 3, and each `set --` refusal exited 2. Check every selected repository's visibility and trust level, including inherited groups: a public repository must have neither a repository-level self-hosted runner nor access through a group, and private or internal repositories still need review of who can submit executable workflow code. Then confirm the default token posture and that workflow approval of pull requests is off unless required (a workflow can still raise its own permissions with an explicit `permissions:` key, and OIDC `id-token` is separate): ```bash ( # the whole block in one subshell, so one exit status covers both reads set +e # errexit off here, so a read that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORG' 'REPLACE_WITH_OWNER' 'REPLACE_WITH_REPO' # your organization's login, then the owner and name of a repository: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-]*) echo "substitute your organization's login (letters, digits and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-]*) echo "substitute the repository owner's login (letters, digits and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|.|..|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo "substitute the repository name (letters, digits, dots, underscores and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$3" "$4" "$(if gh api -H 'X-GitHub-Api-Version: 2026-03-10' "/orgs/$1/actions/permissions/workflow" --jq '{default_workflow_permissions, can_approve_pull_request_reviews}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # gh's exit status, then the organization's settings printf '%s\n' "$6" case "$5:$6" in 0:*'"default_workflow_permissions":"write"'*|0:*'"default_workflow_permissions": "write"'*|0:*'"can_approve_pull_request_reviews":true'*|0:*'"can_approve_pull_request_reviews": true'*) echo "FINDING: the organization's default token can write or workflows can approve pull requests"; set -- "$1" "$2" "$3" 1 ;; 0:*'"default_workflow_permissions":"read"'*|0:*'"default_workflow_permissions": "read"'*) case "$6" in *'"can_approve_pull_request_reviews":false'*|*'"can_approve_pull_request_reviews": false'*) ;; *) echo "the organization's settings showed no can_approve_pull_request_reviews value; inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac ;; 0:*) echo "the organization's settings showed no default_workflow_permissions value; inconclusive"; set -- "$1" "$2" "$3" 3 ;; *) echo "gh exit $5: the organization's workflow-permission read failed; inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if gh api -H 'X-GitHub-Api-Version: 2026-03-10' "/repos/$2/$3/actions/permissions/workflow" --jq '{default_workflow_permissions, can_approve_pull_request_reviews}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # gh's exit status, then the repository's settings printf '%s\n' "$6" case "$5:$6" in 0:*'"default_workflow_permissions":"write"'*|0:*'"default_workflow_permissions": "write"'*|0:*'"can_approve_pull_request_reviews":true'*|0:*'"can_approve_pull_request_reviews": true'*) echo "FINDING: the repository's default token can write or workflows can approve pull requests"; set -- "$1" "$2" "$3" 1 ;; 0:*'"default_workflow_permissions":"read"'*|0:*'"default_workflow_permissions": "read"'*) case "$6" in *'"can_approve_pull_request_reviews":false'*|*'"can_approve_pull_request_reviews": false'*) ;; *) echo "the repository's settings showed no can_approve_pull_request_reviews value; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac ;; 0:*) echo "the repository's settings showed no default_workflow_permissions value; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "gh exit $5: the repository's workflow-permission read failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; each workflow's own permissions: key and its id-token grant are not shown here, so review them yourself" exit "$4" ) ``` REASONED: the eligibility workflow's expected behavior follows the cited vendor pages and is not demonstrated; the paragraph after the workflow below declares the workflow. The eligibility listing shows configuration; in an isolated test organization use a disposable runner with no sensitive credentials or internal network access, install this workflow in a public test repository, and open a controlled fork pull request. ```yaml name: runner-eligibility-probe on: pull_request permissions: {} jobs: probe: runs-on: [self-hosted, ci-audit-probe] steps: - run: printf '%s\n' eligibility-probe ``` REASONED: preceding block; the eligibility-probe workflow above and the assignment comparison in this paragraph, per the guide's cited GitHub self-hosted runner groups and access, workflow-jobs REST API and events that trigger workflows entries. No isolated test organization, public test repository or disposable runner is available in the authoring environment, so no fork run was assigned or refused. At marking time PyYAML 6.0.3 parsed the workflow with empty permissions and the self-hosted and ci-audit-probe labels, reading its on key as boolean true under YAML 1.1, so this checked structure only, and the job-listing jq filter below printed a runner_id for an assigned synthetic job and null for an unassigned one. That filter run predates the listing block's guarded owner, repository and run ID values; that block now exits 3 when the listing fails and otherwise 0 with every job left for you to read, and it is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by October 2026 stub runs (a listing exited 0, a failed listing 3, each refusal 2). Assign `ci-audit-probe` to the test runner. With eligibility deliberately exposed the approved run must execute on it; after applying the private-repository restriction an equivalent fork run must not be assigned to it, and the same harmless job from an allowed private repository confirms the runner is still available. Inspect the assignment, and treat a skipped workflow, pending approval, offline runner, or queue timeout as inconclusive: ```bash ( # a subshell, so the exits below end only the subshell set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_OWNER' 'REPLACE_WITH_REPO' 'REPLACE_WITH_RUN_ID' # the owner and name of the repository the probe ran in, then the run's numeric ID: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-]*) echo "substitute the repository owner's login (letters, digits and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|.|..|*REPLACE_WITH_*|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo "substitute the repository name (letters, digits, dots, underscores and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|*[!0123456789]*) echo "substitute the run's numeric ID inside the quotes on the set -- line above; not probing"; exit 2 ;; esac gh api --paginate -H 'X-GitHub-Api-Version: 2026-03-10' "/repos/$1/$2/actions/runs/$3/jobs" --jq '.jobs[] | {id, status, conclusion, runner_id, runner_group_id}' || { echo "gh exit $?: the job listing failed; inconclusive"; exit 3; } echo "block exit 0; each job's status, conclusion and runner assignment are not classified, so judge them yourself" ) ``` REASONED: preceding block; assignment discrimination in the job listing above follows the cited workflow-jobs API and runner-group documentation, and no registered test runner or cloud test infrastructure was available. Following the cited GitLab and Docker documentation, inspect the configuration each runner service actually loads (custom paths and deployment-generated config included); no live runner or daemon was tested. The inspection omits the runner `token` key, but reported fields are not redacted. `null` means absent from the inspected table only, not a secure default. Unexpected types are inconclusive until corrected, except for the two autoscaler keys, where anything other than integer `1` is flagged. Empty output, read/parse/structure errors and partial output before an error are inconclusive; exit 0 alone is not a safety verdict. `privileged = true`, `services_privileged = true`, or a host Docker socket mounted into a job is a finding. Unset `services_privileged` inherits `privileged`, so its `null` does not mean unprivileged services. The reported `docker.host` selects the runner manager's Docker endpoint; resolve an absent value through the service's `DOCKER_HOST` environment or the default Unix socket. Inspect the daemon's privilege, authentication and network controls, and compare access from the actual job environment with an authorized management client. Flag job access to a rootful daemon; a `tcp://` value alone does not establish that access. For an Instance or Docker Autoscaler executor, flag either `capacity_per_instance` or `max_use_count` being absent or not integer `1`; require both explicitly set to integer `1`, not a boolean, float or string. For Kubernetes, flag `privileged = true`; require `allow_privilege_escalation`, and where jobs do not need tokens `automount_service_account_token`, explicitly set to boolean `false` (a printed `null` means the key is unset, so the requirement is not met); check the `service_account` name against the intended RBAC restrictions. DEMONSTRATED: following block; the extracted Python program ran without sudo on 20 synthetic config.toml cases using Python 3.14.4. Docker fixtures printed true/true, a socket mount and a TCP host versus false/false, empty volumes and a null host; both exited 0. Autoscaler limits printed null/null, 1/2 and 2/1 versus 1/1, all exit 0. Kubernetes fixtures printed true versus false for the three booleans and the supplied service_account. Wrong-type strings, booleans and floats retained their JSON types; misplaced privileged printed docker.privileged null. Empty and runner-less files printed nothing and exited 0. Missing files, malformed TOML and wrong section types exited 1; the second-runner structure error followed one printed runner. Two valid runners printed indices 0 and 1. The runner token key was omitted; a dummy token copied into service_account was printed. These observations cover file inspection only. Those runs covered the block's earlier form, whose program printed these lines, ended with a traceback on any read, parse or structure error, and left the block's status to Python. The current program prints the same lines, then a FINDING line for each docker privileged or services_privileged true, docker.sock volume, Kubernetes runner with privileged true or without allow_privilege_escalation false, and Instance or Docker Autoscaler limit other than integer 1. It prints an inconclusive line for a wrong type in a classified Docker or Kubernetes key, an error or an empty runner list; the block exits 1 for any finding, otherwise 3 for any inconclusive line or when sudo or python3 does not run the program, otherwise 0. In October 2026 the current program ran without sudo on 21 synthetic config.toml cases using Python 3.14.4. The Docker privileged and socket-mount cases, the Kubernetes privileged and unset-escalation cases, the autoscaler null, 1/2, true and 1.0 cases, and a privileged runner followed by a structure error printed FINDING lines and exited 10. The fixed Docker, Kubernetes and autoscaler 1/1 cases, two fixed runners and a misplaced privileged key exited 0. Wrong-type strings, empty and runner-less files, a missing file, malformed TOML and a wrong section type printed inconclusive lines and exited 13, and no case printed the runner token. With sudo and python3 stubbed, the whole block turned 10, 13 and 0 into 1, 3 and 0, and exited 3 when sudo or python3 failed. The current form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by these October 2026 fixture and stub runs, which, like the earlier runs, demonstrate synthetic file inspection only. ```bash ( # a subshell, so the exits below end only the subshell if sudo python3 - /etc/gitlab-runner/config.toml <<'PYTOML' import json, sys, tomllib findings, doubts, count = list(), list(), 0 try: with open(sys.argv[1], "rb") as source: config = tomllib.load(source) for index, runner in enumerate(config.get("runners", [])): fields = { "docker": ("privileged", "services_privileged", "host", "volumes", "volumes_from", "devices", "cap_add", "security_opt", "pid_mode", "network_mode"), "kubernetes": ("privileged", "allow_privilege_escalation", "automount_service_account_token", "service_account"), "autoscaler": ("capacity_per_instance", "max_use_count"), } report = {"runner_index": index, "executor": runner.get("executor")} for section, keys in fields.items(): report[section] = {key: runner.get(section, {}).get(key) for key in keys} print(json.dumps(report)) count += 1 docker, kubernetes, autoscaler = report.get("docker"), report.get("kubernetes"), report.get("autoscaler") for key in ("privileged", "services_privileged"): if docker.get(key) is True: findings.append(f"runner {index}: docker.{key} is true") elif docker.get(key) is not None and docker.get(key) is not False: doubts.append(f"runner {index}: docker.{key} is not a boolean") volumes = docker.get("volumes") if volumes is not None and not (isinstance(volumes, list) and all(isinstance(volume, str) for volume in volumes)): doubts.append(f"runner {index}: docker.volumes is not a list of strings") elif any("docker.sock" in volume for volume in volumes or ()): findings.append(f"runner {index}: a docker.volumes entry mounts docker.sock") if report.get("executor") == "kubernetes": if kubernetes.get("privileged") is True: findings.append(f"runner {index}: kubernetes.privileged is true") elif kubernetes.get("privileged") is not None and kubernetes.get("privileged") is not False: doubts.append(f"runner {index}: kubernetes.privileged is not a boolean") if kubernetes.get("allow_privilege_escalation") is None or kubernetes.get("allow_privilege_escalation") is True: findings.append(f"runner {index}: kubernetes.allow_privilege_escalation is not explicitly false") elif kubernetes.get("allow_privilege_escalation") is not False: doubts.append(f"runner {index}: kubernetes.allow_privilege_escalation is not a boolean") if report.get("executor") in ("instance", "docker-autoscaler"): for key in ("capacity_per_instance", "max_use_count"): if type(autoscaler.get(key)) is not int or autoscaler.get(key) != 1: findings.append(f"runner {index}: autoscaler.{key} is {json.dumps(autoscaler.get(key))}, not integer 1") except Exception as error: doubts.append(f"read, parse or structure error: {type(error).__name__}: {error}") if count == 0 and not doubts: doubts.append("no [[runners]] entry was read") for line in findings: print("FINDING: " + line) for line in doubts: print("inconclusive: " + line) sys.exit(10 if findings else 13 if doubts else 0) PYTOML then set -- 0; else set -- "$?"; fi # Python exits 10 for a finding and 13 for an inconclusive result; any other nonzero status means sudo or python3 did not run the program case "$1" in 0) echo "block exit 0; no classified finding, and the host, volumes_from, devices, cap_add, security_opt, pid_mode, network_mode, automount_service_account_token and service_account values are not classified, so judge them yourself"; exit 0 ;; 10) echo "block exit 1; the values this check does not classify are left for you to judge as well"; exit 1 ;; 13) echo "block exit 3; inconclusive, and the values this check does not classify are left for you to judge"; exit 3 ;; *) echo "sudo python3 exit $1: the configuration was not inspected; inconclusive"; exit 3 ;; esac ) ``` REASONED: the ref_protected requirement in this paragraph and the run records of the job-token scope, allowlist and runner reads in the block below, per the guide's cited GitLab project job-token scopes API and GitLab Runner security entries; no GitLab instance, project or registered runner is available in the authoring environment, so nothing was read. The paragraph after that block declares the block. At marking time the block's earlier form, which had no guard, passed bash -n and ShellCheck 0.11.0 and, against a stub glab, made four calls with its placeholders passed through unchanged. The current form takes the GitLab host, the project ID and a release runner's ID on a guarded `set --` line, records a finding (1) when the runner read shows not_protected and 3 when a read fails or the runner read shows neither value, and leaves the job-token scope and allowlists for you to judge. It is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and by October 2026 stub runs with glab stubbed: ref_protected exited 0, not_protected exited 1 (also when an earlier read failed), a failed read or a runner without access_level exited 3, and each `set --` refusal exited 2 before any glab call. A release runner at not_protected is the exposed state and ref_protected the fixed one. Read the GitLab job-token allowlist and runner scope from the API and require a release runner's `access_level` to be `ref_protected`, comparing against an isolated control with unrestricted inbound access or `not_protected`; `gitlab-runner verify` only confirms authentication, nothing about isolation. ```bash ( # the whole block in one subshell, so one exit status covers all four reads set +e # errexit off here, so a read that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_GITLAB_HOST' 'REPLACE_WITH_PROJECT_ID' 'REPLACE_WITH_RUNNER_ID' # your GitLab host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the numeric project ID, then a release runner's numeric ID: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your GitLab host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$(printf '%s' "$1" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')" in # host names ignore letter case; no named variable, so a declare -u in your shell cannot alter it example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|*[!0123456789]*) echo "substitute the numeric project ID inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|*[!0123456789]*) echo "substitute the release runner's numeric ID inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 glab api --hostname "$1" "projects/$2/job_token_scope" || { echo "glab exit $?: the job-token scope read failed; inconclusive"; set -- "$1" "$2" "$3" 3; } glab api --hostname "$1" --paginate "projects/$2/job_token_scope/allowlist" || { echo "glab exit $?: the project allowlist read failed; inconclusive"; set -- "$1" "$2" "$3" 3; } glab api --hostname "$1" --paginate "projects/$2/job_token_scope/groups_allowlist" || { echo "glab exit $?: the group allowlist read failed; inconclusive"; set -- "$1" "$2" "$3" 3; } set -- "$1" "$2" "$3" "$4" "$(if glab api --hostname "$1" "runners/$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # glab's exit status, then the runner printf '%s\n' "$6" case "$5:$6" in 0:*'"access_level":"not_protected"'*|0:*'"access_level": "not_protected"'*) echo "FINDING: the release runner's access_level is not_protected"; set -- "$1" "$2" "$3" 1 ;; 0:*'"access_level":"ref_protected"'*|0:*'"access_level": "ref_protected"'*) ;; 0:*) echo "the runner read showed neither ref_protected nor not_protected; inconclusive"; set -- "$1" "$2" "$3" 3 ;; *) echo "glab exit $5: the runner read failed; inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; the job-token scope and both allowlists are not classified, so judge them yourself" exit "$4" ) ``` REASONED: preceding block; the GitLab reads in the block above, per the guide's cited GitLab project job-token scopes API and GitLab Runner security entries, and the lifecycle and contamination checks in this paragraph, per the cited GitHub self-hosted runners REST API, GitLab Instance and Docker Autoscaler executors, and GitHub Actions security hardening entries. No registered runner, worker infrastructure or GitLab instance is available in the authoring environment. At marking time both canary commands passed bash -n and ShellCheck 0.11.0, and with job A and job B sharing one home directory job B printed its FAIL line and exited 1, while in a fresh home directory it printed the canary-absent line and exited 0; this exercises the marker logic only, not worker reuse or destruction. Lifecycle and contamination are REASONED from the cited vendor pages: an ephemeral or JIT GitHub runner should be gone from a fully paginated `gh api /orgs/REPLACE_WITH_ORG/actions/runners` after a harmless job, but deregistration is not proof the machine was destroyed, and a GitLab runner manager can stay registered while its workers are replaced. The check those reasons call for is to verify termination and storage disposal through the infrastructure provider. Test reuse with two sequential harmless jobs: job A runs `printf '%s\n' ci-isolation-canary > "$HOME/.ci-isolation-canary"`, and job B runs `if test -e "$HOME/.ci-isolation-canary"; then echo 'FAIL: previous job state survived'; exit 1; else echo 'canary absent; corroborate worker destruction'; fi`. A deliberately persistent worker must find the marker; the hardened case uses a newly provisioned worker with disposal records and no marker. Marker absence alone is insufficient, and worker destruction does not remove shared caches or artifacts, so review those separately. REASONED: listener inventory and reachability comparison per the guide's cited GitLab Runner monitoring and advanced configuration entries; no deployed metrics or session-server endpoint and no external vantage are available in the authoring environment, so no probe was sent. At marking time plain ss -tln without sudo listed this host's listeners, and the guarded probe's earlier form passed bash -n and ShellCheck 0.11.0 and, run with stub sudo and curl under bash and bash -u, refused the unsubstituted, empty, embedded-placeholder, ftp, two-value and missing set -- line cases with its not-probing messages and exit 2, and passed http and https URLs to the stub curl. In that form the inventory and the probe shared one block and the probe took no network value; they are now two blocks, each covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by the October 2026 stub runs recorded with it. Finally, `ss` is an inventory only; inspect the relevant container and pod namespaces and published ports too. If a metrics or session-server listener is enabled, run the guarded block against the exact configured URL from its permitted client network: monitoring for metrics, GitLab for the session server. Configure trusted certificate verification for HTTPS, including the runner-generated session-server certificate. Require the expected positive-control response, then repeat from a disallowed network; any HTTP response from the disallowed network proves reachability, while a timeout, DNS error, or connection failure is inconclusive without a matching firewall deny record. This reachability comparison is reasoned, since no deployed endpoint or external vantage was available. Substitute the URL, and permitted or disallowed for the network you run it from, inside the single quotes and paste the whole block; do not bypass TLS certificate verification. REASONED: following block; the listener inventory follows the cited GitLab monitoring and session-server documentation; no deployed endpoint was available. Run it on the runner host: it refuses with status 2 when `ss` is missing, exits 3 when `sudo ss -tlnp` fails, and otherwise exits 0 with the whole table left for you to read. October 2026 stub runs with sudo and ss stubbed gave those three statuses. ```bash ( # a subshell, so the exits below end only the subshell command -v ss > /dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } sudo ss -tlnp || { echo "sudo ss -tlnp exit $?: the listener inventory failed; inconclusive"; exit 3; } echo "block exit 0; the listener table is not classified, so judge it yourself" ) ``` REASONED: following block; the reachability comparison follows the cited GitLab monitoring and session-server documentation; no deployed endpoint or external vantage was available. From the permitted network it exits 0 for any HTTP response, whose code you compare with the expected one, and 3 for none. From a disallowed network it exits 1 for any HTTP response and 3 for none, never 0 there, because a failure is inconclusive without a matching firewall deny record. October 2026 stub runs with curl stubbed gave each of those four outcomes, and each `set --` refusal exited 2 before any curl call. ```bash ( # a subshell, so the exits below end only the subshell set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_METRICS_URL' 'REPLACE_WITH_permitted_OR_disallowed' # the exact configured URL, then permitted or disallowed for the network you run this from: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'exactly one URL and one network required; not probing'; exit 2; } case "$1" in *REPLACE_WITH_*|'') echo 'substitute the URL inside the quotes; not probing'; exit 2 ;; esac case "$1" in http://*|https://*) ;; *) echo 'HTTP or HTTPS URL required; not probing'; exit 2 ;; esac case "$2" in permitted|disallowed) ;; *) echo 'substitute permitted or disallowed inside the quotes; not probing'; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its http= line printf '%s' "$4" case "$2:$3" in permitted:0) echo "the permitted network received an HTTP response; compare it with the expected positive-control response"; set -- 0 ;; permitted:*) echo "curl exit $3: the permitted network received no HTTP response, so the positive control failed; inconclusive"; set -- 3 ;; disallowed:0) echo "FINDING: the disallowed network received an HTTP response, so the listener is reachable from it"; set -- 1 ;; *) echo "curl exit $3: no HTTP response from the disallowed network, which is inconclusive without a matching firewall deny record, so this network never exits 0 here"; set -- 3 ;; esac echo "block exit $1; the HTTP code against the expected response and any firewall deny record are not classified, so judge them yourself" exit "$1" ) ``` ## Sources (checked September 2026) Version boundary: GitHub.com documentation checked 18 September 2026, with REST examples pinned to API version `2026-03-10`; GitLab documentation is rolling, checked that date. GitLab Runner source for the `pid_mode` key is pinned to tag v19.5.0, checked October 2026. The legacy GitLab registration token was disabled by default in GitLab 17.0 and is currently scheduled for removal in GitLab 20.0, and the Instance and Docker Autoscaler executors became generally available in Runner 17.1; run a currently supported patched release. No live product-version compatibility matrix was exercised. - GitHub Actions security hardening and secure use: https://docs.github.com/en/actions/reference/security/secure-use - GitHub self-hosted runner groups and access (private-only default): https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access - GitHub self-hosted runners REST API (generate-jitconfig, listings) (GitHub REST API 2026-03-10): https://docs.github.com/en/rest/actions/self-hosted-runners - GitHub GITHUB_TOKEN permissions: https://docs.github.com/en/actions/concepts/security/github_token - GitHub events that trigger workflows (pull_request_target, workflow_run): https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows - GitLab Runner security (privileged executor, tokens): https://docs.gitlab.com/runner/security/ - GitLab Runner Docker executor: https://docs.gitlab.com/runner/executors/docker/ - GitLab CI_JOB_TOKEN and the job-token allowlist: https://docs.gitlab.com/ci/jobs/ci_job_token/ - GitLab runner authentication tokens and the creation workflow: https://docs.gitlab.com/ci/runners/new_creation_workflow/ - GitLab Runner monitoring (metrics port 9252, no built-in authorization): https://docs.gitlab.com/runner/monitoring/ - GitLab Runner advanced configuration (session_server, Docker controls and autoscaler limits). The GitLab Runner advanced configuration sources: https://docs.gitlab.com/runner/configuration/advanced-configuration/. A partial automated page read dated 2026-10-12, not a recheck of this September 2026 list, corroborated the inspected Docker and autoscaler keys except `pid_mode`, which was reported only from rolling GitLab Runner `common/config.go` on `main`; the next item cites `pid_mode` from GitLab Runner source pinned at tag v19.5.0. - GitLab Runner `common/config.go` DockerConfig `PidMode` field (toml `pid_mode`, "Run the container in a specific PID space"), tag v19.5.0 at 70f5c12dad80bfd8ab426843f38cae72fddc4e75 (checked October 2026). The GitLab Runner v19.5.0 `common/config.go` sources: https://gitlab.com/gitlab-org/gitlab-runner/-/blob/70f5c12dad80bfd8ab426843f38cae72fddc4e75/common/config.go#L330. The same field is at L310 at tag v19.1.0 (5eb085abb18b9c9614ba6e12951ece859d092272). It is absent at tags v18.11.4 (5b1ba4d9a0dec0a0a67ba4b88fa88e7f21b1060f), v19.0.0 (d4bac16c639ce0e7a778f1e88ca05b6d18dde57a), v19.0.1 (c2831b75a3ff0782dca8f64498cbc6f71c76819e), v19.0.2 (02fcb2d71c5d50f2f0ab92f9fd685706d3729b94) and v19.0.3 (e951e4926504fcfce3399542ff56029f77798c5a). No other tag before v19.1.0 was checked, so the tag that introduced the key is not established. - GitHub self-hosted runner groups REST API (group visibility, allows_public_repositories, group repositories) (GitHub REST API 2026-03-10): https://docs.github.com/en/rest/actions/self-hosted-runner-groups - GitHub Actions OIDC (id-token permission, issuer/audience/subject claims): https://docs.github.com/en/actions/reference/security/oidc - GitHub workflow-jobs REST API (runner assignment: runner_id, runner_group_id) (GitHub REST API 2026-03-10): https://docs.github.com/en/rest/actions/workflow-jobs - GitLab ID token authentication (id_tokens, aud): https://docs.gitlab.com/ci/secrets/id_token_authentication/ - GitLab Users API, create a runner (create_runner scope): https://docs.gitlab.com/api/users/#create-a-runner-linked-to-a-user - GitLab Kubernetes executor (privileged, privilege escalation, service-account RBAC and token mounting): https://docs.gitlab.com/runner/executors/kubernetes/ - GitLab Instance and Docker Autoscaler executors (capacity_per_instance, max_use_count): https://docs.gitlab.com/runner/executors/docker_autoscaler/ - GitLab project job-token scopes API (inbound allowlist): https://docs.gitlab.com/api/project_job_token_scopes/ - Docker Engine security (rootful daemon, docker group as host administration): https://docs.docker.com/engine/security/ - AWS EC2 instance metadata service (IMDSv2 does not stop authorized in-job requests): https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html - Python tomllib (offline config.toml inspection, Python 3.11+): https://docs.python.org/3.11/library/tomllib.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - github: GitHub Actions documentation (unknown) - github-api: GitHub REST API (2026-03-10) - gitlab: GitLab rolling documentation (unknown) - gitlab-runner: GitLab Runner source (v19.5.0) - docker: Docker Engine documentation (unknown) - python: Python tomllib minimum (3.11) - aws: AWS EC2 documentation (unknown) Claims (full source mapping in the JSON block below): - job-transport (REASONED; github, gitlab): Both runners poll over HTTPS without an inbound job-polling port; verify GitLab TLS and restrict separate autoscaler management or webhook listeners. - worker-account (REASONED; github, gitlab, docker): Use a dedicated least-privileged account, keep provisioning credentials off workers and confine rootful Docker access to disposable VMs. - worker-egress (REASONED; github, gitlab, aws): Enforce job egress outside job control, isolate production and other workers, and deny unused metadata over both families; IMDSv2 does not stop arbitrary authorized job code. - github-eligibility (REASONED; github): Keep self-hosted runners off public repositories; runner groups admit only private repositories by default and the public override should stay off. - fork-token (REASONED; github): Ordinary fork pull_request jobs normally receive a read-only GITHUB_TOKEN and no other secrets, but can still read host credentials and reachable services. - privileged-triggers (REASONED; github): pull_request_target and workflow_run can access secrets and write tokens; never execute untrusted PR code, artifacts or caches in these jobs. - private-forks (REASONED; github): Review private-repository fork settings, which can grant write tokens and secrets; separate trusted privileged workers from untrusted CI. - registration-token (REASONED; github-api): Registration tokens last one hour; generate on demand, never bake into images, and do not confuse expiry with runner deregistration. - registration-manager (REASONED; github-api): Keep the App/PAT generating registration or JIT credentials off workers; organization endpoints need Self-hosted runners: write, repository endpoints Administration: write. - jit (REASONED; github-api): Protect encoded_jit_config and the whole response; JIT runners execute at most one job. - runner-credential (REASONED; github): Protect the installed runner authentication credential, directory and backups. - github-job-token (REASONED; github): GITHUB_TOKEN is repository-scoped and per-job; set read-only defaults and grant only trusted jobs additional permissions. - oidc (REASONED; github): Grant id-token: write only where needed; the cloud trust must restrict issuer, audience and repository/branch or protected-environment subject. - github-lifecycle (REASONED; github, github-api): Prefer ephemeral/JIT workers and destroy their machines and storage; deregistration does not destroy disks, and labels route rather than authorize jobs. - shell-executor (REASONED; gitlab): The GitLab shell executor runs as the runner user without isolation; avoid it for untrusted jobs on shared hosts. - docker-executor (REASONED; gitlab, docker): Use unprivileged Docker; privileged=true or services_privileged and host Docker socket/TCP access can expose the host despite privileged=false. - executor-host-access (REASONED; gitlab, gitlab-runner): Deny sensitive host mounts, host PID/network namespaces, unnecessary devices and capabilities; build without the host daemon. - kubernetes-executor (REASONED; gitlab): Enforce nonprivileged containers, no privilege escalation, scoped ServiceAccounts and no unnecessary token mounting; prevent job overrides. - gitlab-disposal (REASONED; gitlab): Containers share the kernel; use disposable separate VMs/nodes as needed and one-job autoscaling workers. Instance and Docker Autoscaler GA is recorded as Runner 17.1. - gitlab-registration (REASONED; gitlab): Legacy registration tokens were disabled by default in GitLab 17.0, with removal scheduled for 20.0; use runner authentication tokens. - gitlab-runner-token (REASONED; gitlab): Protect and rotate the runner authentication token in config.toml and backups; a cloned runner can steal assigned jobs. - gitlab-creation (REASONED; gitlab): POST /user/runners needs create_runner scope; keep that access token off workers and set scope/protection at creation. - gitlab-job-token (REASONED; gitlab): CI_JOB_TOKEN carries supported triggering-user access during the job and appears as CI_REGISTRY_PASSWORD and inside CI_REPOSITORY_URL. - gitlab-allowlist (REASONED; gitlab): Target-project inbound job-token allowlists limit who may access that target, not job egress; also restrict resource feature visibility. - gitlab-forks (REASONED; gitlab): Instance runners on public installations accept strangers; project scope does not establish code trust. Review parent-launched fork pipelines or disable ci_allow_fork_pipelines_to_run_in_parent_project. - gitlab-protected (REASONED; gitlab): Developer access can compromise persistent runners; release runners need ref_protected and a pool separate from fork-merge-request testing. - gitlab-metrics (REASONED; gitlab): Optional listen_address metrics and profiling have no built-in authorization; 9252 is allocated, not proof of the configured port. - gitlab-session (REASONED; gitlab): Optional session_server uses TLS and must admit required GitLab traffic at its configured address; 8093 is an example, not an established default. - verify-eligibility (REASONED; github-api): Fully paginate groups, selected repositories and public repositories' own runners; visibility=all alone is not public eligibility, and inherited access needs review. - verify-permissions (REASONED; github): Inspect organization and repository workflow-token defaults and PR approval settings; explicit workflow permissions can raise grants and id-token is separate. - verify-assignment (REASONED; github-api, github): A controlled fork workflow should run when exposed and lose assignment when restricted, while an allowed private job still runs; skipped/pending/offline/timeout is inconclusive. - verify-config (DEMONSTRATED; python, gitlab, gitlab-runner): Python 3.14.4 synthetic fixtures reported exposed/fixed Docker, Kubernetes and autoscaler values; wrong types passed through, misplaced keys printed null, and empty/error cases differed in output and exit status. Those runs covered an earlier form; the current form's findings and exit statuses were also run on synthetic fixtures. - verify-daemon (REASONED; gitlab, docker): Resolve docker.host through DOCKER_HOST or the default socket when absent; flag actual job access to a rootful daemon, not tcp:// alone. - verify-autoscaler (REASONED; gitlab): For Instance/Docker Autoscaler require capacity_per_instance=1 and max_use_count=1 explicitly as integers; absent or other values are findings. - verify-gitlab-api (REASONED; gitlab): Read inbound allowlists and require release-runner ref_protected against exposed controls; gitlab-runner verify proves authentication only. - verify-lifecycle (REASONED; github-api, gitlab, github): Check complete deregistration listings and infrastructure disposal; persistent-worker canary must survive in the control, but absence alone does not prove destruction or clean shared caches. - verify-listeners (REASONED; gitlab): Inventory namespaces and publications, require the permitted-client response, then test disallowed access; any HTTP response proves reachability, failure needs corroborating firewall denial. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "2cc98970efba9b419c804f38bf722b88e7fb41263ab7677c3c260e6734512247", "components": { "github": { "name": "GitHub Actions documentation", "basis": "unknown", "sources": { "s215dc07e3642": "https://docs.github.com/en/actions/reference/security/secure-use", "s92e8e4820392": "https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access", "sbbcb15f795a8": "https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows", "s4561d20e7042": "https://docs.github.com/en/actions/concepts/security/github_token", "s80fe11d8c90d": "https://docs.github.com/en/actions/reference/security/oidc" } }, "github-api": { "name": "GitHub REST API", "basis": "2026-03-10", "sources": { "s8217448953a4": "https://docs.github.com/en/rest/actions/self-hosted-runners", "sd36db9ea1031": "https://docs.github.com/en/rest/actions/self-hosted-runner-groups", "sbeee4a47110c": "https://docs.github.com/en/rest/actions/workflow-jobs" } }, "gitlab": { "name": "GitLab rolling documentation", "basis": "unknown", "sources": { "seb30e3937183": "https://docs.gitlab.com/runner/security/", "sde661ff4762e": "https://docs.gitlab.com/runner/executors/docker_autoscaler/", "s265254bc5592": "https://docs.gitlab.com/runner/executors/docker/", "s47b236d63dc4": "https://docs.gitlab.com/runner/configuration/advanced-configuration/", "s1579c499c75b": "https://docs.gitlab.com/runner/executors/kubernetes/", "sd2cbff5d5d6e": "https://docs.gitlab.com/ci/runners/new_creation_workflow/", "se430cbabc011": "https://docs.gitlab.com/api/users/#create-a-runner-linked-to-a-user", "saef0483b7be7": "https://docs.gitlab.com/ci/jobs/ci_job_token/", "s699c1c198119": "https://docs.gitlab.com/api/project_job_token_scopes/", "sbc009ff2866a": "https://docs.gitlab.com/runner/monitoring/" } }, "gitlab-runner": { "name": "GitLab Runner source", "basis": "v19.5.0", "sources": { "s46d58158c809": "https://gitlab.com/gitlab-org/gitlab-runner/-/blob/70f5c12dad80bfd8ab426843f38cae72fddc4e75/common/config.go#L330" } }, "docker": { "name": "Docker Engine documentation", "basis": "unknown", "sources": { "s18f59818e451": "https://docs.docker.com/engine/security/" } }, "python": { "name": "Python tomllib minimum", "basis": "3.11", "sources": { "s37be9e98ea29": "https://docs.python.org/3.11/library/tomllib.html" } }, "aws": { "name": "AWS EC2 documentation", "basis": "unknown", "sources": { "s82aca706445c": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html" } } }, "claims": { "job-transport": {"text": "Both runners poll over HTTPS without an inbound job-polling port; verify GitLab TLS and restrict separate autoscaler management or webhook listeners.", "components": ["github", "gitlab"], "sources": ["github:s215dc07e3642", "gitlab:seb30e3937183", "gitlab:sde661ff4762e"], "status": "REASONED"}, "worker-account": {"text": "Use a dedicated least-privileged account, keep provisioning credentials off workers and confine rootful Docker access to disposable VMs.", "components": ["github", "gitlab", "docker"], "sources": ["github:s215dc07e3642", "gitlab:seb30e3937183", "docker:s18f59818e451"], "status": "REASONED"}, "worker-egress": {"text": "Enforce job egress outside job control, isolate production and other workers, and deny unused metadata over both families; IMDSv2 does not stop arbitrary authorized job code.", "components": ["github", "gitlab", "aws"], "sources": ["github:s215dc07e3642", "gitlab:seb30e3937183", "aws:s82aca706445c"], "status": "REASONED"}, "github-eligibility": {"text": "Keep self-hosted runners off public repositories; runner groups admit only private repositories by default and the public override should stay off.", "components": ["github"], "sources": ["github:s92e8e4820392", "github:s215dc07e3642"], "status": "REASONED"}, "fork-token": {"text": "Ordinary fork pull_request jobs normally receive a read-only GITHUB_TOKEN and no other secrets, but can still read host credentials and reachable services.", "components": ["github"], "sources": ["github:s215dc07e3642", "github:sbbcb15f795a8"], "status": "REASONED"}, "privileged-triggers": {"text": "pull_request_target and workflow_run can access secrets and write tokens; never execute untrusted PR code, artifacts or caches in these jobs.", "components": ["github"], "sources": ["github:sbbcb15f795a8", "github:s215dc07e3642"], "status": "REASONED"}, "private-forks": {"text": "Review private-repository fork settings, which can grant write tokens and secrets; separate trusted privileged workers from untrusted CI.", "components": ["github"], "sources": ["github:s215dc07e3642"], "status": "REASONED"}, "registration-token": {"text": "Registration tokens last one hour; generate on demand, never bake into images, and do not confuse expiry with runner deregistration.", "components": ["github-api"], "sources": ["github-api:s8217448953a4"], "status": "REASONED"}, "registration-manager": {"text": "Keep the App/PAT generating registration or JIT credentials off workers; organization endpoints need Self-hosted runners: write, repository endpoints Administration: write.", "components": ["github-api"], "sources": ["github-api:s8217448953a4"], "status": "REASONED"}, "jit": {"text": "Protect encoded_jit_config and the whole response; JIT runners execute at most one job.", "components": ["github-api"], "sources": ["github-api:s8217448953a4"], "status": "REASONED"}, "runner-credential": {"text": "Protect the installed runner authentication credential, directory and backups.", "components": ["github"], "sources": ["github:s215dc07e3642"], "status": "REASONED"}, "github-job-token": {"text": "GITHUB_TOKEN is repository-scoped and per-job; set read-only defaults and grant only trusted jobs additional permissions.", "components": ["github"], "sources": ["github:s4561d20e7042"], "status": "REASONED"}, "oidc": {"text": "Grant id-token: write only where needed; the cloud trust must restrict issuer, audience and repository/branch or protected-environment subject.", "components": ["github"], "sources": ["github:s80fe11d8c90d"], "status": "REASONED"}, "github-lifecycle": {"text": "Prefer ephemeral/JIT workers and destroy their machines and storage; deregistration does not destroy disks, and labels route rather than authorize jobs.", "components": ["github", "github-api"], "sources": ["github:s215dc07e3642", "github:s92e8e4820392", "github-api:s8217448953a4"], "status": "REASONED"}, "shell-executor": {"text": "The GitLab shell executor runs as the runner user without isolation; avoid it for untrusted jobs on shared hosts.", "components": ["gitlab"], "sources": ["gitlab:seb30e3937183"], "status": "REASONED"}, "docker-executor": {"text": "Use unprivileged Docker; privileged=true or services_privileged and host Docker socket/TCP access can expose the host despite privileged=false.", "components": ["gitlab", "docker"], "sources": ["gitlab:s265254bc5592", "gitlab:seb30e3937183", "docker:s18f59818e451"], "status": "REASONED"}, "executor-host-access": {"text": "Deny sensitive host mounts, host PID/network namespaces, unnecessary devices and capabilities; build without the host daemon.", "components": ["gitlab", "gitlab-runner"], "sources": ["gitlab:s265254bc5592", "gitlab:s47b236d63dc4", "gitlab-runner:s46d58158c809"], "status": "REASONED"}, "kubernetes-executor": {"text": "Enforce nonprivileged containers, no privilege escalation, scoped ServiceAccounts and no unnecessary token mounting; prevent job overrides.", "components": ["gitlab"], "sources": ["gitlab:s1579c499c75b"], "status": "REASONED"}, "gitlab-disposal": {"text": "Containers share the kernel; use disposable separate VMs/nodes as needed and one-job autoscaling workers. Instance and Docker Autoscaler GA is recorded as Runner 17.1.", "components": ["gitlab"], "sources": ["gitlab:sde661ff4762e", "gitlab:seb30e3937183"], "status": "REASONED"}, "gitlab-registration": {"text": "Legacy registration tokens were disabled by default in GitLab 17.0, with removal scheduled for 20.0; use runner authentication tokens.", "components": ["gitlab"], "sources": ["gitlab:sd2cbff5d5d6e"], "status": "REASONED"}, "gitlab-runner-token": {"text": "Protect and rotate the runner authentication token in config.toml and backups; a cloned runner can steal assigned jobs.", "components": ["gitlab"], "sources": ["gitlab:seb30e3937183", "gitlab:sd2cbff5d5d6e"], "status": "REASONED"}, "gitlab-creation": {"text": "POST /user/runners needs create_runner scope; keep that access token off workers and set scope/protection at creation.", "components": ["gitlab"], "sources": ["gitlab:se430cbabc011", "gitlab:sd2cbff5d5d6e"], "status": "REASONED"}, "gitlab-job-token": {"text": "CI_JOB_TOKEN carries supported triggering-user access during the job and appears as CI_REGISTRY_PASSWORD and inside CI_REPOSITORY_URL.", "components": ["gitlab"], "sources": ["gitlab:saef0483b7be7"], "status": "REASONED"}, "gitlab-allowlist": {"text": "Target-project inbound job-token allowlists limit who may access that target, not job egress; also restrict resource feature visibility.", "components": ["gitlab"], "sources": ["gitlab:saef0483b7be7", "gitlab:s699c1c198119"], "status": "REASONED"}, "gitlab-forks": {"text": "Instance runners on public installations accept strangers; project scope does not establish code trust. Review parent-launched fork pipelines or disable ci_allow_fork_pipelines_to_run_in_parent_project.", "components": ["gitlab"], "sources": ["gitlab:seb30e3937183"], "status": "REASONED"}, "gitlab-protected": {"text": "Developer access can compromise persistent runners; release runners need ref_protected and a pool separate from fork-merge-request testing.", "components": ["gitlab"], "sources": ["gitlab:seb30e3937183", "gitlab:sd2cbff5d5d6e"], "status": "REASONED"}, "gitlab-metrics": {"text": "Optional listen_address metrics and profiling have no built-in authorization; 9252 is allocated, not proof of the configured port.", "components": ["gitlab"], "sources": ["gitlab:sbc009ff2866a"], "status": "REASONED"}, "gitlab-session": {"text": "Optional session_server uses TLS and must admit required GitLab traffic at its configured address; 8093 is an example, not an established default.", "components": ["gitlab"], "sources": ["gitlab:s47b236d63dc4"], "status": "REASONED"}, "verify-eligibility": {"text": "Fully paginate groups, selected repositories and public repositories' own runners; visibility=all alone is not public eligibility, and inherited access needs review.", "components": ["github-api"], "sources": ["github-api:sd36db9ea1031", "github-api:s8217448953a4"], "status": "REASONED", "verify": [1]}, "verify-permissions": {"text": "Inspect organization and repository workflow-token defaults and PR approval settings; explicit workflow permissions can raise grants and id-token is separate.", "components": ["github"], "sources": ["github:s4561d20e7042", "github:s80fe11d8c90d"], "status": "REASONED", "verify": [2]}, "verify-assignment": {"text": "A controlled fork workflow should run when exposed and lose assignment when restricted, while an allowed private job still runs; skipped/pending/offline/timeout is inconclusive.", "components": ["github-api", "github"], "sources": ["github-api:sbeee4a47110c", "github:s92e8e4820392"], "status": "REASONED", "verify": [3]}, "verify-config": {"text": "Python 3.14.4 synthetic fixtures reported exposed/fixed Docker, Kubernetes and autoscaler values; wrong types passed through, misplaced keys printed null, and empty/error cases differed in output and exit status. Those runs covered an earlier form; the current form's findings and exit statuses were also run on synthetic fixtures.", "components": ["python", "gitlab", "gitlab-runner"], "sources": ["python:s37be9e98ea29", "gitlab:s47b236d63dc4", "gitlab:s1579c499c75b", "gitlab-runner:s46d58158c809"], "status": "DEMONSTRATED", "verify": [4], "evidence": "the extracted Python program ran without sudo on 20 synthetic config.toml cases using Python 3.14.4. Docker fixtures printed true/true, a socket mount and a TCP host versus false/false, empty volumes and a null host; both exited 0. Autoscaler limits printed null/null, 1/2 and 2/1 versus 1/1, all exit 0. Kubernetes fixtures printed true versus false for the three booleans and the supplied service_account. Wrong-type strings, booleans and floats retained their JSON types; misplaced privileged printed docker.privileged null. Empty and runner-less files printed nothing and exited 0. Missing files, malformed TOML and wrong section types exited 1; the second-runner structure error followed one printed runner. Two valid runners printed indices 0 and 1. The runner token key was omitted; a dummy token copied into service_account was printed. These observations cover file inspection only. Those runs covered the block's earlier form, whose program printed these lines, ended with a traceback on any read, parse or structure error, and left the block's status to Python. The current program prints the same lines, then a FINDING line for each docker privileged or services_privileged true, docker.sock volume, Kubernetes runner with privileged true or without allow_privilege_escalation false, and Instance or Docker Autoscaler limit other than integer 1. It prints an inconclusive line for a wrong type in a classified Docker or Kubernetes key, an error or an empty runner list; the block exits 1 for any finding, otherwise 3 for any inconclusive line or when sudo or python3 does not run the program, otherwise 0. In October 2026 the current program ran without sudo on 21 synthetic config.toml cases using Python 3.14.4. The Docker privileged and socket-mount cases, the Kubernetes privileged and unset-escalation cases, the autoscaler null, 1/2, true and 1.0 cases, and a privileged runner followed by a structure error printed FINDING lines and exited 10. The fixed Docker, Kubernetes and autoscaler 1/1 cases, two fixed runners and a misplaced privileged key exited 0. Wrong-type strings, empty and runner-less files, a missing file, malformed TOML and a wrong section type printed inconclusive lines and exited 13, and no case printed the runner token. With sudo and python3 stubbed, the whole block turned 10, 13 and 0 into 1, 3 and 0, and exited 3 when sudo or python3 failed. The current form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by these October 2026 fixture and stub runs, which, like the earlier runs, demonstrate synthetic file inspection only."}, "verify-daemon": {"text": "Resolve docker.host through DOCKER_HOST or the default socket when absent; flag actual job access to a rootful daemon, not tcp:// alone.", "components": ["gitlab", "docker"], "sources": ["gitlab:s265254bc5592", "docker:s18f59818e451"], "status": "REASONED"}, "verify-autoscaler": {"text": "For Instance/Docker Autoscaler require capacity_per_instance=1 and max_use_count=1 explicitly as integers; absent or other values are findings.", "components": ["gitlab"], "sources": ["gitlab:sde661ff4762e"], "status": "REASONED"}, "verify-gitlab-api": {"text": "Read inbound allowlists and require release-runner ref_protected against exposed controls; gitlab-runner verify proves authentication only.", "components": ["gitlab"], "sources": ["gitlab:s699c1c198119", "gitlab:seb30e3937183"], "status": "REASONED", "verify": [5]}, "verify-lifecycle": {"text": "Check complete deregistration listings and infrastructure disposal; persistent-worker canary must survive in the control, but absence alone does not prove destruction or clean shared caches.", "components": ["github-api", "gitlab", "github"], "sources": ["github-api:s8217448953a4", "gitlab:sde661ff4762e", "github:s215dc07e3642"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory namespaces and publications, require the permitted-client response, then test disallowed access; any HTTP response proves reachability, failure needs corroborating firewall denial.", "components": ["gitlab"], "sources": ["gitlab:sbc009ff2866a", "gitlab:s47b236d63dc4"], "status": "REASONED", "verify": [6, 7]} } } ```` ====================================================================== ==> frontend-frameworks.md ====================================================================== # Full-stack JS frameworks: SvelteKit, Nuxt, Vite SvelteKit and Nuxt are JavaScript frameworks for web apps with server code; Vite is a build tool. This guide helps your AI assistant deploy them securely, including SvelteKit's loopback bind and proxy trust, no secrets in public config, auth on every server entry point, and no Vite server in production. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/frontend-frameworks.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against SvelteKit adapter-node @sveltejs/adapter-node@5.5.7, SvelteKit routing and hooks documentation @sveltejs/kit@2.70.3, Nuxt lockfile v4.0.0, Nuxt documentation 4.x, Nuxt output source v4.0.0, Vite bind source v8.3.1, and h3 request and Node adapter source at release commit 1.15.3, plus 2 more recorded bases; 1 further source is pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 20 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. SvelteKit, Nuxt, and Vite-based apps built by AI assistants inherit the same traps as [nextjs.md](https://github.com/jposluns/secureconfig/blob/main/nextjs.md): a server bind that can default wide open, a proxy that has to be explicitly trusted before secure cookies and correct origins work, and a public-env prefix that ships anything given it straight to the browser. Each framework also has more than one server entry point (endpoints, server routes, load functions), and a check placed in only one of them leaves the others open, exactly as with Next.js layouts versus Server Actions. The server-bind defaults are that SvelteKit's Node adapter binds `0.0.0.0` as of adapter-node 5.5.7, though Vite's dev server defaults to `localhost` as of Vite 8.3.1. ## SvelteKit (`adapter-node`) The built server "will accept connections on `0.0.0.0` using port 3000" by default (host and port as of adapter-node 5.5.7; the port default applies only when `SOCKET_PATH` is unset or empty, and none of this applies when systemd socket activation hands the process a socket); override with `HOST` and `PORT`: ```bash HOST=127.0.0.1 PORT=3000 node build ``` Behind a reverse proxy, set `ORIGIN` (for example `ORIGIN=https://app.example.com`) so SvelteKit computes the correct origin for redirects and cookies. `PROTOCOL_HEADER` and `HOST_HEADER` (for example `x-forwarded-proto` and `x-forwarded-host`) let it read the real scheme and host from the proxy; the docs caution to set these only behind a trusted reverse proxy, since an untrusted client could otherwise spoof them. `ADDRESS_HEADER` and `XFF_DEPTH` do the same for the client's real IP. CSRF: `csrf.checkOrigin` (default `true`; deprecated in the pinned `@sveltejs/kit@2.70.3` source, and marked removed in 3.0 in the current svelte.dev reference) checks the `Origin` header for a POST, PUT, PATCH, or DELETE form submission whose `Content-Type` is `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`, and verifies that it matches the server's origin. The pinned type documentation describes the check only for form submissions with those three content types, so do not rely on it for a JSON body or any other content type reaching a `+server.js` endpoint or form action; give those requests their own origin or session check. The pinned type documentation points to `csrf.trustedOrigins` instead: an allowlist (default empty) of specific origins permitted to submit forms cross site; only `'*'` trusts every origin, and that documentation calls it generally not recommended. Public env: the pinned `@sveltejs/kit@2.70.3` type documentation describes `env.publicPrefix` (default `PUBLIC_`) as the prefix that signals an environment variable is safe to expose to client-side code, and points to `$env/static/public` and `$env/dynamic/public`; treat every variable with that prefix as public and never give a secret that prefix. Keep secrets in modules SvelteKit treats as server-only, either `$env/static/private` / `$env/dynamic/private`, a `.server.js` filename, or anything under `$lib/server/` ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)); SvelteKit statically traces import chains and fails the build if client code imports one, even through a dynamic `import()`. None of this gates a request for you by default. The pinned SvelteKit load documentation and server/client source show layout and page `load` functions run concurrently unless a child calls `await parent()`, and a layout `load` does not run on every request. A `+layout.server.js` guard therefore cannot be relied on to protect a sibling `+page.server.js` load function before it uses protected data; the routing documentation says `+layout` has no effect on `+server.js`, and the form-actions documentation says actions are exported from `+page.server.js`. SvelteKit page dispatch calls form actions before page loads, so a layout guard cannot be relied on to protect a form action. Guide advice: check the session before protected work in each page load, form action and `+server.js` endpoint, the same pattern as the Next.js Data Access Layer in [nextjs.md](https://github.com/jposluns/secureconfig/blob/main/nextjs.md). The pinned hooks documentation describes `handle` in `hooks.server.js` for server requests, including prerendering, and returning a `Response` before `resolve` renders the route. The runtime invokes `handle` after checks that can return first, including cross-origin remote and form requests, hash-routing rejections and rerouted prerendered resources; it therefore does not run for every request received by the SvelteKit server. In adapter-node 5.5.7, client assets and already prerendered pages are served before SSR and do not reach `handle` through that adapter. Guide advice: use `handle` to check the session and block unauthorized SvelteKit requests that reach it; a hook that only redirects unauthenticated page navigation or only attaches identity to `event.locals` leaves directly reached `+server.js` endpoints and form actions open unless they check it. For a client-initiated remote function request, the runtime takes the page path and search from request headers and resolves `event.url`, `route` and `params` in `handle` from that path; the pinned hooks documentation describes these as calling-page fields rather than the generated endpoint. The pinned remote-functions documentation says the same about these `RequestEvent` properties inside remote functions. Both warn never to use these fields to authorize data access because request values can be manipulated. Guide advice: do not rely on those fields alone to decide what is authorized; check the session and enforce authorization for the specific resource where it is served, since a session check alone does not authorize access to that resource. Wire real authentication with an identity provider or library per [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), not a client-side redirect alone. ## Nuxt (Nitro server routes) Files under `server/api/`, `server/routes/`, and `server/middleware/` are auto-registered Nitro handlers exported with `defineEventHandler()`, and a handler in `server/middleware/` runs before every other server route. Nuxt ships no built-in authentication. A middleware handler can enforce access if it throws (for example `createError` with a 401 or 403) or otherwise ends the request there; one that only attaches `event.context.auth` for other handlers to read has not enforced anything, and every handler that reads it still has to check it itself: ```ts // server/api/admin.ts: the route checks for itself; the middleware only sets event.context.auth export default defineEventHandler((event) => { if (!event.context.auth?.user) { event.node.res.statusCode = 401 return { message: 'Not authenticated' } } }) ``` `runtimeConfig` splits the same way as Next's env prefix: keys directly on `runtimeConfig` are "only available within server-side"; keys under `runtimeConfig.public` are "also exposed to the client-side." Environment variables override both, using an uppercase `NUXT_` prefix with underscores between key segments: private keys just need `NUXT_` (for example `NUXT_API_SECRET`), public keys need `NUXT_PUBLIC_` (for example `NUXT_PUBLIC_API_BASE`). The docs warn not to "expose runtime config keys to the client-side by either rendering them or passing them to `useState`" even when they are private on the server. ## Vite (dev and preview servers) Both are development tooling, not a production server: the `vite preview` docs say plainly "do not use this as a production server as it's not designed for it." Deploy the built `dist/` behind a real server, CDN, or your platform's hosting per [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md) instead. `server.host` defaults to `'localhost'` (as of Vite 8.3.1); setting it to `true` or `0.0.0.0` makes the dev server listen on all addresses, including the LAN, which is fine for testing from a phone on a trusted network but should not be left on elsewhere. `server.allowedHosts` defaults to `[]`, which still auto-permits localhost, `.localhost`, and IP addresses; setting it to `true` disables the check entirely, and the docs warn this "allows any website to send requests to your dev server and download your source code and content" (DNS rebinding). Prefer an explicit hostname allowlist over `true`. ## Verify REASONED: output-path coverage per the cited SvelteKit adapter-node, Nitro and Vite pinned sources; no framework application or production build is available in the authoring environment, so the paths were checked against those sources rather than a built tree. Run from the project root after a production build. The paths below cover default SvelteKit adapter-node output (`build/client` plus the separate `build/prerendered`), Nuxt/Nitro public output (`.output/public`, including copied public assets and prerendered pages), and a plain Vite client build (`dist`, including copied public assets). They exclude adapter-node server output and Nitro `.output/server`. REASONED: scan coverage and the dist/server classification per the design of the block itself and the cited output-path sources; at marking time the scan ran against local fixture trees, where a secret planted under dist/assets was reported as a FINDING, a tree without it reported clean, and a plant only under dist/server was reported as server output. The same marking-time fixture run also showed that, with no listed directory, the block refused as inconclusive, and that, with dist/assets a symlink to an external fixture directory holding the planted value, the scan followed the link and reported dist/assets/x.js as a FINDING. These fixture runs covered an earlier form, whose FINDING and error arms ended with status 0; none was re-run on the current form, whose FINDING arm exits 1 and whose error arm exits 3. The current form is covered only by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs with real grep on fixture trees while it was drafted. No real framework production build is available in the authoring environment, so none was scanned. Before running, adjust the scan block's directory list for your actual adapter, Nitro preset or configured output directories. The block always scans all of `dist`, following symlinks, so it never misses a browser-served file. In a Vite SSR layout, where `dist/server` holds the server build, a result whose matches are all under `dist/server/` is reported as server output rather than as a client leak, and any other result is a FINDING, with its `dist/server/` matches labelled as server output. Confirm that `dist/server` is server-only in your deployment before treating a server-output result as clean. Include any separately deployed static assets and prerendered HTML, and, apart from `dist` (scanned whole, as described above), never select a parent containing server-only output. This file scan does not inspect dynamically rendered HTML or API responses. REASONED: following block; private-route denial and Host handling, with the proxy cookie and redirect checks in the prose after the next block; the client-secret scan is the next block. No deployed framework application or run outcome is recorded here; expectations are reasoned from the guide and its cited framework documentation. The private-route and spoofed-Host requests take the application's host name once, on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains. The block exits 1 if `/api/private` answers a 2xx without a session cookie, 3 if it answers anything else but 401 or if either request fails, and otherwise 0; a curl exit 52 on the spoofed-Host request, where the server closes without a response as nginx's `return 444` does, is not a failure. That request pins HTTP/1.1 with `--http1.1`, because exit 52 is curl's HTTP/1.1 reading of that close, and curl may otherwise negotiate HTTP/2 with a listener that sets `http2 on`, as the HTTPS server block in [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) does. The spoofed-Host result stays for you to judge. The block's current form is covered only by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs while it was drafted; it has not been run against a deployment. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the deployed application's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set +e # errexit off here, so a request that fails is recorded below instead of ending the block set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$(if curl -q -g -si -w '\n%{http_code}' "https://$1/api/private"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the response, then the HTTP code if [ -n "$4" ]; then printf '%s\n' "${4%%$'\n'*}"; fi # the status line: 401 with no session cookie, on all three frameworks case "$3:$5" in 0:401) ;; 0:2??) echo "FINDING: /api/private answered $5 with no session cookie"; set -- "$1" 1 ;; 0:*) echo "HTTP $5 from /api/private is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the /api/private request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac # The spoofed Host header evil.example.com is a deliberately foreign value, so it stays as written. set -- "$1" "$2" "$(if curl -q -g -si --http1.1 "https://$1/" -H "Host: evil.example.com"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response if [ -n "$4" ]; then printf '%s\n' "${4%%$'\n'*}"; fi # the deployed application must not trust a spoofed Host case "$3" in 0|52) ;; # a status line, or none when the server closes without a response (nginx's return 444 gives curl exit 52 over the HTTP/1.1 that --http1.1 pins above) *) echo "curl exit $3: the spoofed-Host request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the spoofed-Host status line, or its absence, is not classified, so judge it as the Host paragraph below the next block says" exit "$2" ) ``` ```bash # The browser-served paths are the four directory checks at the top of the block; adapt them for custom output or SSR before running. # Search client assets and prerendered pages for the literal secret with a fixed-string match. The secret is prompted # (input hidden) and reaches grep on stdin (-f - reads the patterns from stdin), never grep's argv; -l prints only # file names, so a finding does not echo the secret. -R follows symlinks, so a linked asset is searched too; -D skip # skips FIFOs, sockets and devices, so a linked pipe cannot stall the scan. Only the listed directories that exist # are searched; adapter-node can produce two. The block refuses when none exists. grep's exit status is read # separately, so an inherited `set -e` cannot abort on a clean result: 0 is a finding, 1 is clean, anything else is # an error, with grep's own diagnostics left visible. A file grep lists alongside an error is classified as with 0, # so a match outside dist/server/ is still a finding, and the error makes any other result inconclusive. Paste this # subshell by itself: without bracketed paste, a line pasted after its closing ) becomes the search value instead, # and its clean result then means nothing. # The block exits 1 for a finding, 3 if the hidden prompt, ls or grep fails, 2 if a check refuses (no listed # directory, a readonly name or IFS, or an empty secret), and 0 for a clean or server-output-only result. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- # the directories to search, kept in the positional parameters rather than a named variable if [ -d build/client ]; then set -- "$@" build/client; fi if [ -d build/prerendered ]; then set -- "$@" build/prerendered; fi if [ -d .output/public ]; then set -- "$@" .output/public; fi if [ -d dist ]; then set -- "$@" dist; fi [ "$#" -gt 0 ] || { echo 'no listed browser-served output directory here; build first and check the paths; not scanning'; exit 2; } { unset -n client_secret && unset -v client_secret; } 2>/dev/null || { echo 'cannot initialize secret input; not scanning'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not scanning'; exit 2; } if IFS= read -r -s -p 'Secret value to search for (input hidden), then Enter: ' client_secret < /dev/tty; then printf '\n' else printf '\n'; echo 'secret input failed; not scanning'; exit 3 # every check above passed, so the failed prompt is inconclusive fi case "$client_secret" in ''|*[[:cntrl:]]*) echo 'the full secret value is required; not scanning'; exit 2 ;; esac set -- 0 "$@" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the directories ls -d -- "${@:2}" || { echo "ls exit $?: the output directories could not be listed; inconclusive"; set -- 3 "${@:2}"; } # confirm browser-served output paths # File names are not secret: capture them so matches under dist/server/ (a Vite SSR server build) are reported apart. set -- "$1" "$(if printf '%s\n' "$client_secret" | grep -RlF -D skip -f - -- "${@:2}"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:2}" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" "${@:3}" # status, grep's exit status, the matching file names, then the directories searched case "$2:$3" in 0:*|*:?*) # grep's 0, or any status with files listed: grep exits 2 on a read error even after listing a match # FINDING is the default; only a positively classified all-dist/server/ result is reported as server output. printf '%s' "$3" if printf '%s' "$3" | grep -aq '^dist/server/' && ! printf '%s' "$3" | grep -aqv '^dist/server/'; then echo "server output only: every match is under dist/server/, which a Vite SSR build does not serve to browsers; confirm dist/server is server-only in your deployment before treating this as clean" else echo "FINDING: the secret is in the built client output" if printf '%s' "$3" | grep -aq '^dist/server/'; then echo "(of the files listed, those under dist/server/ are server output)"; fi set -- 1 "${@:2}" fi if [ "$2" != 0 ]; then echo "grep also exited $2, so some files were not searched; a FINDING above stands, and any other result is inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; fi ;; 1:*) echo "clean: secret not found in ${*:4}" ;; *) echo "error: grep exited $2, result inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac echo "block exit $1; a server-output-only or clean result is not proof, so judge it as the paragraph after this block says" exit "$1" ) ``` REASONED: preceding block; the output-path listing and the client-secret scan, per the cited framework documentation and GNU grep manual entries; the private-route and spoofed-Host requests are in the block before it. The scan subshell ran at marking time against local fixture trees, where a planted secret produced the FINDING line and a clean fixture tree reported clean. On 2026-10-13 (UTC), with Bash 5.3.9 and a dummy secret typed at a pty's hidden prompt, the scan subshell traced two lines under `bash -x` (`trap -` and `set +x +a +e`), neither holding the dummy, where the same subshell with `set +x` removed traced 32 lines, one of them holding the dummy; an interactive Bash given the subshell wrote a history file holding the block's lines and not the dummy. The fixture, bash -x and history runs of the scan subshell recorded here covered an earlier form, whose FINDING and error arms ended with status 0; none was re-run on the current form, whose FINDING arm exits 1 and whose error arm exits 3. The current form, which also lists its directories itself, holds its captures in positional parameters and exits 3 when the hidden prompt fails, is covered only by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs while it was drafted. The account-owner and transformation limits stated in this paragraph are documented, not demonstrated. The secret reaches grep on stdin, never argv; the hidden prompt keeps it out of shell history and the block's `set +x` keeps it out of `set -x` tracing, but this does not hide it from the account owner or root. Paste the secret alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. A clean grep result here is evidence, not proof: it means the literal value did not match in the directories searched, not that the secret cannot be present in some other form. A bundler could split, encode, or otherwise transform it, so check the exit code and confirm the directory actually exists rather than reading silence alone as clean. REASONED: Host, proxy cookie, redirect and trusted-proxy checks per the cited SvelteKit adapter-node and core source, Vite server-options documentation, Nuxt runtime config documentation, the pinned Nuxt 4.0.0 package manifest and lockfile, pinned nitropack 2.12.0 and h3 1.15.3 sources, and Node v22.22.1 HTTP documentation. No reverse proxy or deployed adapter is available in the authoring environment, so the outcomes of the Host, proxy cookie, redirect and trusted-proxy checks were not observed. The deployed application must not trust a spoofed Host. The supplied Vite allowlist reference covers its dev server. Guide assessment: for this probe, exposed means the application responds to the spoofed Host, so curl prints its first response line. Fixed means a fronting proxy or server refuses the unknown Host before the application; apply the default-server refusal pattern in [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) to the probed HTTPS listener. With a refusal response, curl prints its status line; with nginx return 444 closing without a response, curl prints no status line. Neither a status line nor silence alone proves which layer handled the request or whether the application builds URLs or redirects from Host. Correlate proxy and application logs with the probe and a working allowed-host control; inspect response headers and generated URLs separately to judge Host use. Adapter-node 5.5.7 uses `ORIGIN` or configured/request headers for the request origin; without `ORIGIN` or `PROTOCOL_HEADER`, it defaults the protocol to `https` and uses the request `Host` header unless `HOST_HEADER` supplies one. Behind a reverse proxy, confirm cookies still carry `Secure` and redirects use an `https://` `Location` once `ORIGIN` (SvelteKit) is set. `NUXT_PUBLIC_...` is the public runtime-config prefix, exposed straight to the client bundle; it is not a trusted-proxy or secure-cookie setting and does nothing for this check. Nuxt 4.0.0 pins nitropack 2.12.0. For a non-static production build with no preset named or matched, nitropack falls back to `node-server`, whose entry runtime passes the h3 app through h3's `toNodeListener` to a Node HTTP or HTTPS server. In the traced adapter-node 5.5.7 handler and SvelteKit Node request conversion, `ORIGIN` or configured/request headers form the request URL without a Host allowlist. In the traced nitropack 2.12.0 node-server and app construction, the h3 app reaches Node HTTP or HTTPS without a Host allowlist. Nuxt v4.0.0 resolves h3 1.15.3: its traced request utilities read `Host` or optionally `x-forwarded-host` and construct a URL without an allowlist; the release commit exports `toNodeListener`, which creates an event and calls `app.handler`, matching the adapter Nitro imports. Node v22.22.1 documents default rejection of an HTTP/1.1 request missing `Host`; that is not an allowed-host list. Guide assessment: these source paths do not establish a universal Host rejection outcome, how the hosting platform treats Host and forwarded headers, or a deployed cookie and redirect outcome. Guide assessment: Nuxt's own trusted-proxy and origin handling come from its deployment preset and hosting platform rather than one documented app-level variable. Guide advice: verify the equivalent behaviour against the deployed Nuxt preset and hosting platform, or whichever adapter you deploy with. ## Common mistakes - Treating a SvelteKit `handle` hook that only redirects an unauthenticated page navigation, or a Nuxt `server/middleware/` that only attaches `event.context.auth`, as if it already enforced access, while the `+server.js` endpoint, form action, or `server/api/` route trusts that it happened and skips its own check. - Leaving `server.allowedHosts` at `true`, or `server.host` wide open, past a local demo. - Naming a secret `PUBLIC_...` or `NUXT_PUBLIC_...` out of habit from a genuinely public value. ## Sources (checked September 2026) - SvelteKit adapter-node origin configuration and proxy header variables (pinned tag @sveltejs/adapter-node@5.5.7; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L17-L24 - SvelteKit adapter-node request origin passed to getRequest (pinned tag @sveltejs/adapter-node@5.5.7; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L95-L105 - SvelteKit adapter-node protocol, Host and port header fallback (pinned tag @sveltejs/adapter-node@5.5.7; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L209-L239 - Nuxt 4.0.0 pins nitropack 2.12.0 (pinned tag v4.0.0; checked October 2026): https://github.com/nuxt/nuxt/blob/v4.0.0/packages/nuxt/package.json#L104 - nitropack 2.12.0 non-static production node-server fallback when no preset is named or matched (pinned release commit 65e514d873da1d1d9edd4d9576d48d90b13e0fac; checked October 2026): https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/presets/_resolve.ts#L85-L90 - nitropack 2.12.0 production preset auto-detection for a build with no preset named (pinned release commit 65e514d873da1d1d9edd4d9576d48d90b13e0fac; checked October 2026): https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/core/config/loader.ts#L134-L141 - nitropack 2.12.0 h3 dependency range (pinned release commit 65e514d873da1d1d9edd4d9576d48d90b13e0fac; checked October 2026): https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/package.json#L131 - nitropack 2.12.0 node-server preset entry (pinned release commit 65e514d873da1d1d9edd4d9576d48d90b13e0fac; checked October 2026): https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/presets/node/preset.ts#L16-L29 - nitropack 2.12.0 node-server uses h3 toNodeListener with Node HTTP or HTTPS server (pinned release commit 65e514d873da1d1d9edd4d9576d48d90b13e0fac; checked October 2026): https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/presets/node/runtime/node-server.ts#L1-L23 - nitropack 2.12.0 h3 app construction and request hook (pinned release commit 65e514d873da1d1d9edd4d9576d48d90b13e0fac; checked October 2026): https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/runtime/internal/app.ts#L59-L128 - SvelteKit pinned routing: +layout has no effect on +server.js (pinned tag @sveltejs/kit@2.70.3): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/10-routing.md#L291-L332 - SvelteKit pinned load: layout/page concurrency and auth implications (pinned tag @sveltejs/kit@2.70.3): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/20-load.md#L702-L714 - SvelteKit pinned form actions in +page.server.js (pinned tag @sveltejs/kit@2.70.3): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/30-form-actions.md#L1-L5 - SvelteKit pinned handle scope and remote-function request metadata (pinned tag @sveltejs/kit@2.70.3): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/30-advanced/20-hooks.md#L15-L36 - SvelteKit pinned remote-function RequestEvent metadata (pinned tag @sveltejs/kit@2.70.3): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/60-remote-functions.md#L1300-L1303 - SvelteKit page action dispatch before load (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/page/index.js#L70-L78 - SvelteKit parallel server and universal load scheduling (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/page/index.js#L178-L250 - SvelteKit invalidated server load nodes (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/data/index.js#L39-L97 - SvelteKit client load invalidation (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/client/client.js#L1217-L1235 - SvelteKit checks before handle (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L73-L106 - SvelteKit remote request page URL from headers (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L111-L130 - SvelteKit route and params from resolved path (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L333-L343 - SvelteKit handle invocation (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L456-L498 - SvelteKit rerouted prerendered resource branch (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L274-L306 - SvelteKit Node request conversion (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/exports/node/index.js#L121-L160 - h3 request Host lookup and optional forwarded Host (pinned 1.15.3 release commit; checked October 2026): https://github.com/h3js/h3/blob/3d0f4d582d1ffa7390bf3a6d6748aaf9ce3ab9e6/src/utils/request.ts#L275-L286 - h3 request URL and optional forwarded Host (pinned 1.15.3 release commit; checked October 2026): https://github.com/h3js/h3/blob/3d0f4d582d1ffa7390bf3a6d6748aaf9ce3ab9e6/src/utils/request.ts#L333-L344 - h3 toNodeListener Node adapter (pinned 1.15.3 release commit; checked October 2026): https://github.com/h3js/h3/blob/3d0f4d582d1ffa7390bf3a6d6748aaf9ce3ab9e6/src/adapters/node.ts#L55-L89 - Node HTTP missing Host option (pinned tag v22.22.1; checked October 2026): https://github.com/nodejs/node/blob/v22.22.1/doc/api/http.md#L3653-L3657 - Nuxt h3 lockfile resolution (pinned tag v4.0.0; checked October 2026): https://github.com/nuxt/nuxt/blob/v4.0.0/pnpm-lock.yaml#L5204-L5205 - SvelteKit adapter-node output defaults and client and prerendered writes (pinned tag @sveltejs/adapter-node@5.5.7): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/index.js#L24-L38 - SvelteKit adapter-node server chunks under build/server (pinned tag @sveltejs/adapter-node@5.5.7): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/index.js#L118 - SvelteKit adapter-node serves the separate prerendered directory (pinned tag @sveltejs/adapter-node@5.5.7): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L69 - SvelteKit adapter-node serves client files and prerendered pages before SSR (pinned tag @sveltejs/adapter-node@5.5.7): https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L243 - Nuxt 4.x public build assets passed to Nitro (additional source pin v4.0.0): https://github.com/nuxt/nuxt/blob/v4.0.0/packages/nuxt/src/core/nitro.ts#L163-L170 - Nuxt static dist alias targets Nitro public output (additional source pin v4.0.0): https://github.com/nuxt/nuxt/blob/v4.0.0/packages/nuxt/src/core/nitro.ts#L653-L659 - Nitro 2.12.0, the nitropack version Nuxt v4.0.0 pins, output defaults .output, .output/server and .output/public (published build, lines 67 to 69): https://cdn.jsdelivr.net/npm/nitropack@2.12.0/dist/core/index.mjs - Nitro output defaults and prerender destination (rolling documentation, checked September 2026): https://nitro.build/config - Nitro public assets copied to production public output (rolling documentation, checked September 2026): https://nitro.build/docs/assets - Vite default build.outDir 'dist' (pinned tag v8.3.1): https://github.com/vitejs/vite/blob/v8.3.1/packages/vite/src/node/build.ts#L395 - Vite public assets copied to build output (pinned tag v8.3.1): https://github.com/vitejs/vite/blob/v8.3.1/packages/vite/src/node/config.ts#L388-L396 - Vite build output directory and public-copy defaults: https://vite.dev/config/build-options - Vite SSR uses separate client and server builds: https://vite.dev/guide/ssr - SvelteKit adapter-node: https://svelte.dev/docs/kit/adapter-node - SvelteKit server-only modules: https://svelte.dev/docs/kit/server-only-modules - SvelteKit current `$env/static/public` reference, which now says the module was deprecated in SvelteKit 3 (rolling documentation, checked October 2026): https://svelte.dev/docs/kit/$env-static-public - SvelteKit `csrf.checkOrigin` (default `true`, deprecated) and `csrf.trustedOrigins` (default empty; `'*'` trusts every origin and is generally not recommended) configuration types (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/exports/public.d.ts#L423-L450 - SvelteKit `env.publicPrefix` (default `PUBLIC_`) configuration type (pinned tag @sveltejs/kit@2.70.3; checked October 2026): https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/exports/public.d.ts#L466-L470 - SvelteKit current `@sveltejs/kit/vite` reference, which marks `csrf.checkOrigin` deprecated and removed in 3.0 (rolling documentation, checked October 2026): https://svelte.dev/docs/kit/@sveltejs-kit-vite - Nuxt runtime config: https://nuxt.com/docs/4.x/guide/going-further/runtime-config - Nuxt server directory structure: https://nuxt.com/docs/4.x/directory-structure/server - Vite server options (`server.host`, `server.allowedHosts`): https://vite.dev/config/server-options - Vite CLI (`vite preview`): https://vite.dev/guide/cli - Vite resolves an unset `server.host` to `'localhost'` (pinned tag v8.3.1): https://github.com/vitejs/vite/blob/v8.3.1/packages/vite/src/node/utils.ts#L1010-L1022 - SvelteKit adapter-node `host = env('HOST', '0.0.0.0')` and `port = env('PORT', !path && '3000')`, with `path = env('SOCKET_PATH', false)`, where `env()` returns a variable's value whenever it is present, even empty, and the socket-activation branch that listens on the passed descriptor instead (pinned tag @sveltejs/adapter-node@5.5.7). SvelteKit adapter-node 5.5.7 sources: https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/index.js#L10-L12, https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/env.js#L46-L50 and https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/index.js#L57-L60 - GNU grep `-f -` ("When file is '-', read patterns from standard input"), `-l` ("print the name of each input file from which output would normally have been printed") and its exit status (0 if a line is selected, 1 if none, 2 on error). GNU grep manual sources: https://www.gnu.org/software/grep/manual/html_node/Matching-Control.html , https://www.gnu.org/software/grep/manual/html_node/General-Output-Control.html and https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - adapter: SvelteKit adapter-node (@sveltejs/adapter-node@5.5.7) - svelte-kit-pin: SvelteKit routing and hooks documentation (@sveltejs/kit@2.70.3) - nuxt-lock: Nuxt lockfile (v4.0.0) - nuxt: Nuxt documentation (4.x) - nuxt-output: Nuxt output source (v4.0.0) - vite-pin: Vite bind source (v8.3.1) - svelte: SvelteKit documentation (unknown) - vite: Vite documentation (unknown) - h3-pin: h3 request and Node adapter source at release commit (1.15.3) - node-http: Node HTTP documentation (v22.22.1) - nitro-output: Nitro output defaults (2.12.0) - nitro-node: nitropack 2.12.0 source (65e514d873da1d1d9edd4d9576d48d90b13e0fac) - nitro-output-docs: Nitro documentation (rolling) (unknown) - grep: GNU grep (unknown) Claims (full source mapping in the JSON block below): - adapter-bind (REASONED; adapter, svelte): adapter-node defaults 0.0.0.0:3000 only with SOCKET_PATH unset/empty and no systemd socket; set HOST=127.0.0.1 PORT=3000. - adapter-origin (REASONED; svelte): Set ORIGIN to external HTTPS origin; trust PROTOCOL_HEADER/HOST_HEADER only behind a trusted proxy. - adapter-ip (REASONED; svelte): ADDRESS_HEADER and XFF_DEPTH configure proxy-derived client IP and require trusted proxy handling. - csrf-check (REASONED; svelte, svelte-kit-pin): csrf.checkOrigin defaults true, deprecated; documented for cross-origin POST/PUT/PATCH/DELETE form content types only; give JSON/other content origin/session checks. - csrf-trust (REASONED; svelte, svelte-kit-pin): csrf.trustedOrigins defaults empty; list specific form origins, with wildcard trust discouraged. - svelte-public (REASONED; svelte-kit-pin): Pinned 2.70.3 types: env.publicPrefix (default PUBLIC_) marks env variables safe to expose to client-side code; keep secrets out. - svelte-private (REASONED; svelte): Private env modules, .server.js and lib/server are server-only; client import chains, including dynamic imports, fail builds. - svelte-routes (REASONED; svelte-kit-pin): Pinned docs and source show layout `load` does not run on every request, layout/page `load` run concurrently unless a child calls `await parent()`, `+layout` has no effect on `+server.js`, and form actions run before page loads. Guide advice: do not rely on a layout guard to protect a sibling page load, form action or directly reached endpoint; check the session before protected work in each. - svelte-handle (REASONED; svelte-kit-pin, adapter): Pinned hooks docs describe `handle` for SvelteKit server requests, including prerendering; runtime source calls it after checks that can reject requests before the hook and lets it return before `resolve`. In adapter-node 5.5.7, client assets and already prerendered pages are served before SSR, so they do not reach `handle` through that adapter. For client-initiated remote function requests, runtime source takes the calling-page path and search from request headers and resolves `route` and `params` from that path; pinned remote-functions docs describe the same fields on `RequestEvent` inside remote functions, and both docs warn never to use these request-derived fields to authorize data access. Guide advice: use `handle` to block unauthorized SvelteKit requests that reach it; a redirect-only or `event.locals`-only hook leaves directly reached endpoints and form actions open unless they check the session; do not rely on calling-page fields alone, and authorize each specific resource where served because a session check alone does not authorize it. - nuxt-handlers (REASONED; nuxt): Nuxt auto-registers server/api, server/routes and middleware; middleware runs first, but context.auth alone enforces nothing. Throw/end requests or check each handler. - nuxt-auth (REASONED; nuxt): Nuxt ships no built-in authentication; enforce access server-side, not with client redirects. - nuxt-private (REASONED; nuxt): Direct runtimeConfig keys are server-only with NUXT_ overrides; rendering or passing to useState can disclose them. - nuxt-public (REASONED; nuxt): runtimeConfig.public is client-exposed with NUXT_PUBLIC_ overrides, not proxy/cookie configuration. - vite-production (REASONED; vite): Vite dev/preview is development tooling; deploy dist through a production server, CDN or platform. - vite-bind (REASONED; vite-pin, vite): Vite 8.3.1 server.host defaults localhost; true/0.0.0.0 exposes all addresses and should not remain beyond trusted-network tests. - vite-hosts (REASONED; vite): server.allowedHosts defaults empty with localhost, .localhost and IPs allowed; true disables protection and permits DNS rebinding. Prefer explicit names. - verify-private (REASONED; nuxt): Guide expects anonymous /api/private 401; enforcement needs app/server handlers, not client redirects. - verify-secret (REASONED; grep, adapter, nuxt-output, nitro-output, vite-pin, vite, nitro-output-docs): Scan browser-served output: adapter-node build/client and build/prerendered, Nuxt .output/public, Vite dist, scanned whole following symlinks; only a result with every match under dist/server/ is reported as server output, else FINDING with dist/server/ matches labelled. Adjust paths for custom layouts/SSR; reject absence. Prompt full literal secret; grep stdin: 0 finding, 1 no match, others errors. Clean cannot rule out transformed secrets or runtime responses; hidden input and set +x keep the secret from argv, history and tracing, not the account owner or root. - verify-host (REASONED; vite, adapter, nitro-node, svelte-kit-pin, h3-pin, node-http, nuxt-lock): The deployed application must not trust a spoofed Host. The supplied Vite allowlist reference covers its dev server. Guide assessment: for this probe, exposed means the application responds to the spoofed Host, so curl prints its first response line. Fixed means a fronting proxy or server refuses the unknown Host before the application; apply the default-server refusal pattern in [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) to the probed HTTPS listener. With a refusal response, curl prints its status line; with nginx return 444 closing without a response, curl prints no status line. Neither a status line nor silence alone proves which layer handled the request or whether the application builds URLs or redirects from Host. Correlate proxy and application logs with the probe and a working allowed-host control; inspect response headers and generated URLs separately to judge Host use. In the traced adapter-node 5.5.7 and SvelteKit Node request conversion, ORIGIN or configured/request headers form the request URL; these files do not apply a Host allowlist. In the traced nitropack 2.12.0 node-server and app construction, the h3 app is passed to Node HTTP or HTTPS without a Host allowlist. Nuxt v4.0.0 resolves h3 1.15.3; its traced request utilities read Host or optionally forwarded Host and construct a URL without an allowlist; the release commit exports toNodeListener, which creates an event and calls app.handler, matching the adapter Nitro imports. Node v22.22.1 documents a default missing-Host rejection for HTTP/1.1, not an allowed-host list. Guide assessment: these source paths do not establish a universal Host rejection outcome or how the hosting platform treats Host and forwarded headers. - verify-proxy (REASONED; svelte, nuxt, adapter, nuxt-output, nitro-node): Check Secure cookies and HTTPS redirects with SvelteKit ORIGIN; verify Nuxt for its deployment preset and hosting platform, not NUXT_PUBLIC_. Adapter-node 5.5.7 uses ORIGIN or configured/request headers for the request origin; with ORIGIN and PROTOCOL_HEADER unset, it defaults to https and uses the request Host header unless HOST_HEADER supplies one. Nuxt 4.0.0 pins nitropack 2.12.0, which falls back to its node-server preset for a non-static production build with no preset named or matched; that preset passes the h3 app through toNodeListener to a Node HTTP or HTTPS server. Guide assessment: Nuxt's own trusted-proxy and origin handling come from its deployment preset and hosting platform rather than one documented app-level variable; the cited lines do not establish how h3 1.x or the hosting platform treats Host and forwarded headers or a deployed cookie and redirect outcome. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "796c9b7dff8bcb7e4bc3e49e9e9d5cc60ef6d44329bae0b6044fe2a413807ad5", "components": { "adapter": { "name": "SvelteKit adapter-node", "basis": "@sveltejs/adapter-node@5.5.7", "sources": { "s011f4e44c91a": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/env.js#L46-L50", "s93f13e6b8d31": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/index.js#L10-L12", "sd23cac9a730f": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/index.js#L57-L60", "s200d6e800c74": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/index.js#L24-L38", "sf17c5646623c": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/index.js#L118", "s1ffeda9bbb37": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L69", "s8d4fa8e7426b": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L243", "sfd51397a510e": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L17-L24", "s6152d66c6b19": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L95-L105", "s7b3a11637166": "https://github.com/sveltejs/kit/blob/%40sveltejs/adapter-node%405.5.7/packages/adapter-node/src/handler.js#L209-L239" } }, "svelte-kit-pin": { "name": "SvelteKit routing and hooks documentation", "basis": "@sveltejs/kit@2.70.3", "sources": { "sca07fbe19546": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/10-routing.md#L291-L332", "sa9bbf285a91c": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/20-load.md#L702-L714", "s380cc33507e0": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/30-form-actions.md#L1-L5", "sf5b873616d3c": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/30-advanced/20-hooks.md#L15-L36", "s5ad7a525e0ea": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/documentation/docs/20-core-concepts/60-remote-functions.md#L1300-L1303", "s6a78ad19d884": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/page/index.js#L70-L78", "s0969ea98a4f1": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/page/index.js#L178-L250", "sffcf647d5328": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/data/index.js#L39-L97", "sfb110948d2ca": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/client/client.js#L1217-L1235", "s67fe32e7a18d": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L73-L106", "sc4708064d4fd": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L111-L130", "s95d5c7dd7a50": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L333-L343", "sca363a35f3fc": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L456-L498", "s89773d0c39e7": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/runtime/server/respond.js#L274-L306", "sd1c295d2cf56": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/exports/node/index.js#L121-L160", "s029a2e9edbca": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/exports/public.d.ts#L423-L450", "s8e93f5865bbc": "https://github.com/sveltejs/kit/blob/%40sveltejs/kit%402.70.3/packages/kit/src/exports/public.d.ts#L466-L470" } }, "nuxt-lock": { "name": "Nuxt lockfile", "basis": "v4.0.0", "sources": { "se77b46c1e3ad": "https://github.com/nuxt/nuxt/blob/v4.0.0/pnpm-lock.yaml#L5204-L5205" } }, "nuxt": { "name": "Nuxt documentation", "basis": "4.x", "sources": { "se2d1e4a2e433": "https://nuxt.com/docs/4.x/directory-structure/server", "s32e447cb5ef4": "https://nuxt.com/docs/4.x/guide/going-further/runtime-config" } }, "nuxt-output": { "name": "Nuxt output source", "basis": "v4.0.0", "sources": { "sa65d64f55ca1": "https://github.com/nuxt/nuxt/blob/v4.0.0/packages/nuxt/src/core/nitro.ts#L163-L170", "se1cb3e5b4959": "https://github.com/nuxt/nuxt/blob/v4.0.0/packages/nuxt/src/core/nitro.ts#L653-L659", "sd00a53729513": "https://github.com/nuxt/nuxt/blob/v4.0.0/packages/nuxt/package.json#L104" } }, "vite-pin": { "name": "Vite bind source", "basis": "v8.3.1", "sources": { "s36532f775f7f": "https://github.com/vitejs/vite/blob/v8.3.1/packages/vite/src/node/utils.ts#L1010-L1022", "s8ba1c2b868b7": "https://github.com/vitejs/vite/blob/v8.3.1/packages/vite/src/node/config.ts#L388-L396", "s484948ac14b6": "https://github.com/vitejs/vite/blob/v8.3.1/packages/vite/src/node/build.ts#L395" } }, "svelte": { "name": "SvelteKit documentation", "basis": "unknown", "sources": { "s59abf03bae6a": "https://svelte.dev/docs/kit/adapter-node", "s23f255e94668": "https://svelte.dev/docs/kit/@sveltejs-kit-vite", "se2fdc4fd5885": "https://svelte.dev/docs/kit/server-only-modules" } }, "vite": { "name": "Vite documentation", "basis": "unknown", "sources": { "sae9dd90908fc": "https://vite.dev/config/server-options", "s16927b810cff": "https://vite.dev/guide/cli", "s7c70a12e43ed": "https://vite.dev/config/build-options", "sba6dbfdd7590": "https://vite.dev/guide/ssr" } }, "h3-pin": { "name": "h3 request and Node adapter source at release commit", "basis": "1.15.3", "sources": { "scfe5cba40964": "https://github.com/h3js/h3/blob/3d0f4d582d1ffa7390bf3a6d6748aaf9ce3ab9e6/src/utils/request.ts#L275-L286", "sdbe7e781611f": "https://github.com/h3js/h3/blob/3d0f4d582d1ffa7390bf3a6d6748aaf9ce3ab9e6/src/utils/request.ts#L333-L344", "s218970f40ae0": "https://github.com/h3js/h3/blob/3d0f4d582d1ffa7390bf3a6d6748aaf9ce3ab9e6/src/adapters/node.ts#L55-L89" } }, "node-http": { "name": "Node HTTP documentation", "basis": "v22.22.1", "sources": { "sdc846082a7e3": "https://github.com/nodejs/node/blob/v22.22.1/doc/api/http.md#L3653-L3657" } }, "nitro-output": { "name": "Nitro output defaults", "basis": "2.12.0", "sources": { "sbe9fd1f61fe0": "https://cdn.jsdelivr.net/npm/nitropack@2.12.0/dist/core/index.mjs" } }, "nitro-node": { "name": "nitropack 2.12.0 source", "basis": "65e514d873da1d1d9edd4d9576d48d90b13e0fac", "sources": { "sd56c769d6dae": "https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/presets/_resolve.ts#L85-L90", "s32975278fc3f": "https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/presets/node/preset.ts#L16-L29", "sdfda9cd3ce7b": "https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/presets/node/runtime/node-server.ts#L1-L23", "sdf38c24e1f80": "https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/runtime/internal/app.ts#L59-L128", "s91ab58ddc148": "https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/src/core/config/loader.ts#L134-L141", "s0c8ca22542d2": "https://github.com/nitrojs/nitro/blob/65e514d873da1d1d9edd4d9576d48d90b13e0fac/package.json#L131" } }, "nitro-output-docs": { "name": "Nitro documentation (rolling)", "basis": "unknown", "sources": { "s47fff9b24fba": "https://nitro.build/config", "s51e38e411bfa": "https://nitro.build/docs/assets" } }, "grep": { "name": "GNU grep", "basis": "unknown", "sources": { "sea43e82b0753": "https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html", "s2c1251092e66": "https://www.gnu.org/software/grep/manual/html_node/General-Output-Control.html", "sd00017f70dae": "https://www.gnu.org/software/grep/manual/html_node/Matching-Control.html" } } }, "claims": { "adapter-bind": {"text": "adapter-node defaults 0.0.0.0:3000 only with SOCKET_PATH unset/empty and no systemd socket; set HOST=127.0.0.1 PORT=3000.", "components": ["adapter", "svelte"], "sources": ["adapter:s011f4e44c91a", "adapter:s93f13e6b8d31", "adapter:sd23cac9a730f", "svelte:s59abf03bae6a"], "status": "REASONED"}, "adapter-origin": {"text": "Set ORIGIN to external HTTPS origin; trust PROTOCOL_HEADER/HOST_HEADER only behind a trusted proxy.", "components": ["svelte"], "sources": ["svelte:s59abf03bae6a"], "status": "REASONED"}, "adapter-ip": {"text": "ADDRESS_HEADER and XFF_DEPTH configure proxy-derived client IP and require trusted proxy handling.", "components": ["svelte"], "sources": ["svelte:s59abf03bae6a"], "status": "REASONED"}, "csrf-check": {"text": "csrf.checkOrigin defaults true, deprecated; documented for cross-origin POST/PUT/PATCH/DELETE form content types only; give JSON/other content origin/session checks.", "components": ["svelte", "svelte-kit-pin"], "sources": ["svelte:s23f255e94668", "svelte-kit-pin:s029a2e9edbca"], "status": "REASONED"}, "csrf-trust": {"text": "csrf.trustedOrigins defaults empty; list specific form origins, with wildcard trust discouraged.", "components": ["svelte", "svelte-kit-pin"], "sources": ["svelte:s23f255e94668", "svelte-kit-pin:s029a2e9edbca"], "status": "REASONED"}, "svelte-public": {"text": "Pinned 2.70.3 types: env.publicPrefix (default PUBLIC_) marks env variables safe to expose to client-side code; keep secrets out.", "components": ["svelte-kit-pin"], "sources": ["svelte-kit-pin:s8e93f5865bbc"], "status": "REASONED"}, "svelte-private": {"text": "Private env modules, .server.js and lib/server are server-only; client import chains, including dynamic imports, fail builds.", "components": ["svelte"], "sources": ["svelte:se2fdc4fd5885"], "status": "REASONED"}, "svelte-routes": {"text": "Pinned docs and source show layout `load` does not run on every request, layout/page `load` run concurrently unless a child calls `await parent()`, `+layout` has no effect on `+server.js`, and form actions run before page loads. Guide advice: do not rely on a layout guard to protect a sibling page load, form action or directly reached endpoint; check the session before protected work in each.", "components": ["svelte-kit-pin"], "sources": ["svelte-kit-pin:sca07fbe19546", "svelte-kit-pin:sa9bbf285a91c", "svelte-kit-pin:s380cc33507e0", "svelte-kit-pin:sfb110948d2ca", "svelte-kit-pin:sffcf647d5328", "svelte-kit-pin:s0969ea98a4f1", "svelte-kit-pin:s6a78ad19d884"], "status": "REASONED"}, "svelte-handle": {"text": "Pinned hooks docs describe `handle` for SvelteKit server requests, including prerendering; runtime source calls it after checks that can reject requests before the hook and lets it return before `resolve`. In adapter-node 5.5.7, client assets and already prerendered pages are served before SSR, so they do not reach `handle` through that adapter. For client-initiated remote function requests, runtime source takes the calling-page path and search from request headers and resolves `route` and `params` from that path; pinned remote-functions docs describe the same fields on `RequestEvent` inside remote functions, and both docs warn never to use these request-derived fields to authorize data access. Guide advice: use `handle` to block unauthorized SvelteKit requests that reach it; a redirect-only or `event.locals`-only hook leaves directly reached endpoints and form actions open unless they check the session; do not rely on calling-page fields alone, and authorize each specific resource where served because a session check alone does not authorize it.", "components": ["svelte-kit-pin", "adapter"], "sources": ["svelte-kit-pin:sf5b873616d3c", "svelte-kit-pin:s5ad7a525e0ea", "svelte-kit-pin:s67fe32e7a18d", "svelte-kit-pin:sc4708064d4fd", "svelte-kit-pin:s95d5c7dd7a50", "svelte-kit-pin:sca363a35f3fc", "svelte-kit-pin:s89773d0c39e7", "adapter:s1ffeda9bbb37", "adapter:s8d4fa8e7426b"], "status": "REASONED"}, "nuxt-handlers": {"text": "Nuxt auto-registers server/api, server/routes and middleware; middleware runs first, but context.auth alone enforces nothing. Throw/end requests or check each handler.", "components": ["nuxt"], "sources": ["nuxt:se2d1e4a2e433"], "status": "REASONED"}, "nuxt-auth": {"text": "Nuxt ships no built-in authentication; enforce access server-side, not with client redirects.", "components": ["nuxt"], "sources": ["nuxt:se2d1e4a2e433"], "status": "REASONED"}, "nuxt-private": {"text": "Direct runtimeConfig keys are server-only with NUXT_ overrides; rendering or passing to useState can disclose them.", "components": ["nuxt"], "sources": ["nuxt:s32e447cb5ef4"], "status": "REASONED"}, "nuxt-public": {"text": "runtimeConfig.public is client-exposed with NUXT_PUBLIC_ overrides, not proxy/cookie configuration.", "components": ["nuxt"], "sources": ["nuxt:s32e447cb5ef4"], "status": "REASONED"}, "vite-production": {"text": "Vite dev/preview is development tooling; deploy dist through a production server, CDN or platform.", "components": ["vite"], "sources": ["vite:s16927b810cff"], "status": "REASONED"}, "vite-bind": {"text": "Vite 8.3.1 server.host defaults localhost; true/0.0.0.0 exposes all addresses and should not remain beyond trusted-network tests.", "components": ["vite-pin", "vite"], "sources": ["vite-pin:s36532f775f7f", "vite:sae9dd90908fc"], "status": "REASONED"}, "vite-hosts": {"text": "server.allowedHosts defaults empty with localhost, .localhost and IPs allowed; true disables protection and permits DNS rebinding. Prefer explicit names.", "components": ["vite"], "sources": ["vite:sae9dd90908fc"], "status": "REASONED"}, "verify-private": {"text": "Guide expects anonymous /api/private 401; enforcement needs app/server handlers, not client redirects.", "components": ["nuxt"], "sources": ["nuxt:se2d1e4a2e433"], "status": "REASONED", "verify": [1]}, "verify-secret": {"text": "Scan browser-served output: adapter-node build/client and build/prerendered, Nuxt .output/public, Vite dist, scanned whole following symlinks; only a result with every match under dist/server/ is reported as server output, else FINDING with dist/server/ matches labelled. Adjust paths for custom layouts/SSR; reject absence. Prompt full literal secret; grep stdin: 0 finding, 1 no match, others errors. Clean cannot rule out transformed secrets or runtime responses; hidden input and set +x keep the secret from argv, history and tracing, not the account owner or root.", "components": ["grep", "adapter", "nuxt-output", "nitro-output", "vite-pin", "vite", "nitro-output-docs"], "sources": ["grep:sea43e82b0753", "grep:s2c1251092e66", "grep:sd00017f70dae", "adapter:s200d6e800c74", "adapter:sf17c5646623c", "adapter:s1ffeda9bbb37", "adapter:s8d4fa8e7426b", "nuxt-output:sa65d64f55ca1", "nuxt-output:se1cb3e5b4959", "nitro-output:sbe9fd1f61fe0", "nitro-output-docs:s47fff9b24fba", "nitro-output-docs:s51e38e411bfa", "vite-pin:s8ba1c2b868b7", "vite-pin:s484948ac14b6", "vite:s7c70a12e43ed", "vite:sba6dbfdd7590"], "status": "REASONED", "verify": [2]}, "verify-host": {"text": "The deployed application must not trust a spoofed Host. The supplied Vite allowlist reference covers its dev server. Guide assessment: for this probe, exposed means the application responds to the spoofed Host, so curl prints its first response line. Fixed means a fronting proxy or server refuses the unknown Host before the application; apply the default-server refusal pattern in [nginx.md](nginx.md) to the probed HTTPS listener. With a refusal response, curl prints its status line; with nginx return 444 closing without a response, curl prints no status line. Neither a status line nor silence alone proves which layer handled the request or whether the application builds URLs or redirects from Host. Correlate proxy and application logs with the probe and a working allowed-host control; inspect response headers and generated URLs separately to judge Host use. In the traced adapter-node 5.5.7 and SvelteKit Node request conversion, ORIGIN or configured/request headers form the request URL; these files do not apply a Host allowlist. In the traced nitropack 2.12.0 node-server and app construction, the h3 app is passed to Node HTTP or HTTPS without a Host allowlist. Nuxt v4.0.0 resolves h3 1.15.3; its traced request utilities read Host or optionally forwarded Host and construct a URL without an allowlist; the release commit exports toNodeListener, which creates an event and calls app.handler, matching the adapter Nitro imports. Node v22.22.1 documents a default missing-Host rejection for HTTP/1.1, not an allowed-host list. Guide assessment: these source paths do not establish a universal Host rejection outcome or how the hosting platform treats Host and forwarded headers.", "components": ["vite", "adapter", "nitro-node", "svelte-kit-pin", "h3-pin", "node-http", "nuxt-lock"], "sources": ["vite:sae9dd90908fc", "adapter:sfd51397a510e", "adapter:s6152d66c6b19", "adapter:s7b3a11637166", "nitro-node:sd56c769d6dae", "nitro-node:s32975278fc3f", "nitro-node:sdfda9cd3ce7b", "nitro-node:s0c8ca22542d2", "svelte-kit-pin:sd1c295d2cf56", "h3-pin:scfe5cba40964", "h3-pin:sdbe7e781611f", "h3-pin:s218970f40ae0", "node-http:sdc846082a7e3", "nuxt-lock:se77b46c1e3ad"], "status": "REASONED", "verify": [1]}, "verify-proxy": {"text": "Check Secure cookies and HTTPS redirects with SvelteKit ORIGIN; verify Nuxt for its deployment preset and hosting platform, not NUXT_PUBLIC_. Adapter-node 5.5.7 uses ORIGIN or configured/request headers for the request origin; with ORIGIN and PROTOCOL_HEADER unset, it defaults to https and uses the request Host header unless HOST_HEADER supplies one. Nuxt 4.0.0 pins nitropack 2.12.0, which falls back to its node-server preset for a non-static production build with no preset named or matched; that preset passes the h3 app through toNodeListener to a Node HTTP or HTTPS server. Guide assessment: Nuxt's own trusted-proxy and origin handling come from its deployment preset and hosting platform rather than one documented app-level variable; the cited lines do not establish how h3 1.x or the hosting platform treats Host and forwarded headers or a deployed cookie and redirect outcome.", "components": ["svelte", "nuxt", "adapter", "nuxt-output", "nitro-node"], "sources": ["svelte:s59abf03bae6a", "nuxt:s32e447cb5ef4", "adapter:sfd51397a510e", "adapter:s6152d66c6b19", "adapter:s7b3a11637166", "nuxt-output:sd00a53729513", "nitro-node:sd56c769d6dae", "nitro-node:s32975278fc3f", "nitro-node:sdfda9cd3ce7b", "nitro-node:sdf38c24e1f80", "nitro-node:s91ab58ddc148", "nitro-node:s0c8ca22542d2"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> container-hardening.md ====================================================================== # Containers: non-root, dropped capabilities, read-only root, and network segmentation Container hardening means running each container with as few rights as possible, so an attacker inside one gains little. This guide helps your AI assistant apply it, including non-root users, dropped capabilities and read-only root filesystems in Docker, Compose and Kubernetes, plus NetworkPolicy where the cluster's network plugin enforces it. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/container-hardening.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Docker documentation (no recorded version), Docker Compose source and reference v2.32.4, Kubernetes Pod Security Admission 1.34, kubectl source v1.34.0, Linux process status and capabilities manuals man-pages-6.19, JSON Merge Patch RFC 7396, and BusyBox source 1_36_1; 4 further sources are pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 23 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A model server, chat UI, or proxy is the thing most likely to face untrusted input, so treat its container as compromised eventually and limit what that gets an attacker: not root, not the host's capabilities, not a writable filesystem, and not a route to the rest of the network. ## Docker and Compose - **Run as non-root.** `USER [:]` in the Dockerfile sets the default user for later `RUN` instructions and for `ENTRYPOINT`/`CMD` at runtime (per the Dockerfile reference); a user with no primary group runs with the `root` group, so set both. Compose's `user:` overrides that per service; unset in both places, the container runs as root (per the Compose file reference). - **Read-only root filesystem.** `read_only: true` on a Compose service creates it with a read-only root filesystem; mount a small `tmpfs` for any path the process must write to. - **Drop capabilities.** `cap_drop: [ALL]` removes every Linux capability; add back only the specific one a service needs with `cap_add`. - **No privilege escalation.** `security_opt: [no-new-privileges:true]` stops a `setuid` binary from gaining more privilege than the process already has (this maps to Docker's engine-level `--security-opt no-new-privileges`; the spellings `no-new-privileges`, `no-new-privileges=true`, and `no-new-privileges:true` are equivalent). - **Never mount the Docker socket into a container.** In Docker's default rootful configuration, control of the daemon lets a user start a container that can alter the host filesystem without restriction; membership of the `docker` group grants root-level privileges. Rootless mode runs the daemon and containers without root privileges. Docker documents that a container with the mounted `/var/run/docker.sock` and a Docker client can control the host daemon, and documents the `--privileged` and host bind-mount options. This guide's assessment: a container holding the socket can start a privileged sibling or bind-mount host `/` and escape to the host; Rootless mode narrows the host privilege available through this path but does not make the mount safe. ```yaml services: app: build: . user: "10001:10001" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: [no-new-privileges:true] ``` ## Kubernetes securityContext The same controls, per-Pod or per-container, in the Kubernetes securityContext documentation: ```yaml spec: containers: - name: app securityContext: runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: [ALL] seccompProfile: type: RuntimeDefault ``` `runAsNonRoot: true` refuses to start the container if its effective user is root; pin `runAsUser` and `runAsGroup` too, since an unset primary group defaults to GID 0 (the runtime default). `seccompProfile.type: RuntimeDefault` applies the runtime's default syscall filter instead of running unconfined. Enforce this with Pod Security Admission's `restricted` level, set as the namespace label `pod-security.kubernetes.io/enforce: restricted` (per the Pod Security Standards documentation); the label applies to that namespace only, not the whole cluster. `restricted` requires, among its controls: no privileged containers, no host namespaces or host ports, non-root execution, all capabilities dropped, and a seccomp profile that is not `Unconfined`. A Pod violating any of these is rejected at admission, not merely flagged, for Pods created after the label is set; adding the label to a namespace that already runs violating Pods does not evict them (it only warns), so recreate those workloads. The `readOnlyRootFilesystem: true` setting above is a separate, per-container recommendation this guide makes; it is good practice, but it is not one of the controls `restricted` itself requires. ## Network segmentation A NetworkPolicy is additive and does nothing without an enforcing CNI (per the Kubernetes NetworkPolicy documentation; confirm yours enforces it before relying on this). Start default-deny, then allow only the specific path the app needs: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: default-deny-all } spec: podSelector: {} policyTypes: [Ingress, Egress] --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: allow-app-egress-to-db } spec: podSelector: { matchLabels: { role: app } } policyTypes: [Egress] egress: - to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } } }] ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }] - to: [{ podSelector: { matchLabels: { role: db } } }] ports: [{ protocol: TCP, port: 5432 }] --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: allow-db-ingress-from-app } spec: podSelector: { matchLabels: { role: db } } policyTypes: [Ingress] ingress: - from: [{ podSelector: { matchLabels: { role: app } } }] ports: [{ protocol: TCP, port: 5432 }] ``` A connection needs both sides to allow it: the egress policy on the source pod and the ingress policy on the destination pod (per the Kubernetes NetworkPolicy documentation). With all three policies applied, an `app` pod can resolve names through the cluster's DNS and reach the `db` pod on port 5432; the `db` pod accepts connections only from pods labeled `role: app` on port 5432; every other path the namespace's own policies govern is refused. (NetworkPolicy handling of `hostNetwork` pods and of traffic on a pod's own node is implementation-dependent, and the DNS rule above allows port 53 to every pod in `kube-system`, not only CoreDNS, so narrow the DNS peer selector to your resolver where you can.) Databases still need their own TLS and auth on top ([postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md), [mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md), [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md)); a NetworkPolicy is a layer, not a substitute. ## Verify REASONED: identity, filesystem, runtime security context and segmentation checks; no exposed/fixed run is recorded in this guide. Expectations follow the cited Docker, Kubernetes and Linux man-pages sources; the authoring environment has no authorized Docker workload or Kubernetes cluster. This guide's assessment: the Compose `app` service's image must supply id, sh and touch, and the Pod's container image must supply id, sh and grep -E for these commands; the exact touch error depends on the Compose `app` service's image and probe path. ```bash ( # the whole block in one subshell, so one exit status covers every part of it set -- 0 "$(if docker compose exec app id; then printf '\n0'; else printf '\n%s' "$?"; fi)" # uid is not 0 (run from the Compose project; this guide advises using the actual container name with bare `docker exec` rather than the `app` service name) set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), id's exit status, then what it printed printf '%s' "$3" case "$2:$3" in "0:uid=0("*|"0:uid=0 "*) echo "FINDING: the app service runs as uid 0"; set -- 1 ;; 0:uid=*) set -- 0 ;; 0:*) echo "id printed no uid= value; inconclusive"; set -- 3 ;; *) echo "docker compose exec exit $2: id did not run in the app service; inconclusive"; set -- 3 ;; esac docker compose exec app sh -c 'touch /app/probe && echo WRITABLE || echo blocked' || { echo "docker compose exec exit $?: the write probe did not run; inconclusive"; [ "$1" = 1 ] || set -- 3; } # with read_only: true expect "blocked" (typically EROFS; the exact error is image-dependent). But `touch` also # fails on a MISSING or non-writable /app (ENOENT/EACCES), which prints # "blocked" on a WRITABLE fs too, and this line exits 0 either way (read the # printed word, it is not an exit-status test), so prove the discrimination: # set read_only: false and recreate (docker compose up -d --force-recreate # app), run the same line, confirm it prints WRITABLE (the path exists and # the user owns it), then restore read_only, recreate again, and confirm # "blocked". Probe a path the container user owns, not `/` (a non-root # user cannot write `/` on a writable fs either) and not a tmpfs you # mounted (writable by design) echo "block exit $1; the WRITABLE or blocked word above is not classified: compare it across the read_only: false and read_only: true runs yourself, as the comments describe" exit "$1" ) ``` ```bash # REASONED: the declared and runtime security context checks follow the cited Kubernetes, kubectl and # Linux man-pages sources; no authorized Kubernetes cluster is available here. ( # the whole block in one subshell, so one exit status covers every part of it set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 kubectl get pod app -o jsonpath='{.spec.containers[0].securityContext}' || # the DECLARED context, not runtime proof; also inspect the running process: { echo "kubectl get exit $?: the declared securityContext could not be read; inconclusive"; set -- 3; } set -- "$1" "$(if kubectl exec app -- sh -c 'id; grep -E "NoNewPrivs|Seccomp|CapEff|CapBnd" /proc/1/status'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # expect uid!=0, NoNewPrivs:1, Seccomp:2 (a filter is active, not 0=unconfined), and CapEff+CapBnd 0000000000000000 (this guide's expected runtime state under drop:[ALL] is zero effective AND bounding sets; CapEff alone is not enough). Assumes the container's own PID namespace; with shareProcessNamespace PID 1 is the pod sandbox (shown as /pause in the cited example), so pick the app PID and inspect its /proc//status set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the block's status so far, kubectl's exit status, then what it printed printf '%s' "$3" # The checks below read PID 1; with shareProcessNamespace that is the pod sandbox, so there this block's status is not the app's. if [ "$2" != 0 ]; then echo "kubectl exec exit $2: the running process could not be inspected; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" else case "$3" in "uid=0("*|"uid=0 "*) echo "FINDING: the process runs as uid 0"; set -- 1 "$2" "$3" ;; uid=*) ;; *) echo "id printed no uid= value; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac case "$3" in *$'\n'"NoNewPrivs:"$'\t'"1"$'\n'*) ;; *$'\n'"NoNewPrivs:"$'\t'"0"$'\n'*) echo "FINDING: NoNewPrivs is 0, so privilege escalation is not blocked"; set -- 1 "$2" "$3" ;; *) echo "no NoNewPrivs value of 0 or 1 was read; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac case "$3" in *$'\n'"Seccomp:"$'\t'"2"$'\n'*) ;; *$'\n'"Seccomp:"$'\t'"0"$'\n'*) echo "FINDING: Seccomp is 0, so the process is unconfined"; set -- 1 "$2" "$3" ;; *) echo "no Seccomp value of 2 or 0 was read; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac case "$3" in *$'\n'"CapEff:"$'\t'"0000000000000000"$'\n'*) ;; *$'\n'"CapEff:"$'\t'*) echo "FINDING: CapEff is not zero, so the process keeps effective capabilities"; set -- 1 "$2" "$3" ;; *) echo "no CapEff value was read; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac case "$3" in *$'\n'"CapBnd:"$'\t'"0000000000000000"$'\n'*) ;; *$'\n'"CapBnd:"$'\t'*) echo "FINDING: CapBnd is not zero, so the bounding set still holds capabilities"; set -- 1 "$2" "$3" ;; *) echo "no CapBnd value was read; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi echo "block exit $1; the declared securityContext is not classified, and the runtime values come from PID 1, which with shareProcessNamespace is the pod sandbox, so then judge the app PID's status yourself" exit "$1" ) ``` ```bash # REASONED: the NetworkPolicy probes follow the cited Kubernetes NetworkPolicy, kubectl, RFC 7396 and # BusyBox sources; no authorized Kubernetes cluster is available here. # resolve the db Service's ClusterIP once and probe that same IP from both pods below; the # role: other pod has no DNS egress under the policies above, so a probe by hostname would fail on # name resolution rather than on the NetworkPolicy, and access by IP could still work even if # the policy were not enforcing anything # A probe pod needs its own admission-compliant securityContext under the restricted PSA level (Pod Security # Standards). This guide's advice: make a real TCP connect to the db's actual port (a Postgres port does not # speak HTTP, so wget cannot test it). # This guide's advice: quote the address into the JSON: unquoted, `10.96.0.5` is a bare token and the array is not valid # JSON, so kubectl rejects the override and neither probe below runs. Keep the override's container name # matching the pod name (this guide's advice): kubectl v1.34.0 `kubectl run --overrides` uses an RFC 7396 JSON # merge patch by default (`--override-type=merge`), which replaces the whole `containers` array with the one in your override, so the # override's container name is the one that remains (RFC 7396). # kubectl run passes the pod name to its generator, generates the Pod, then applies the override; run.go # does not show the generator's container-naming rule. Keep the names matching to avoid confusion (this guide's advice). # BusyBox 1.36.1 source offers -z (zero-I/O scanning) in the 1.10-compatible nc applet with NC_EXTRA, and -w sets # a connect timeout. At official-images d6d62c71, the amd64 busybox:1.36 image is the glibc variant published # (imported, `Builder: oci-import`) from artefacts committed at docker-library/busybox c1375496, and its root # filesystem has bin/nc as a link to the BusyBox binary. That commit's Dockerfile.builder applies one patch before # `make defconfig` (no-cbq.patch, which changes only networking/tc.c and no nc file or NC option), and its config # step changes no NC option. This guide's inference: that build produced the image's root filesystem. Tags move, so # this tag-to-commit mapping holds as of that official-images commit, and only amd64's mapping was examined. # Binary reading, not run: on 2026-10-24 Docker Hub served busybox:1.36 as the index # busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662, which lists nine linux images # (armv6 musl, the others glibc); in each, bin/nc links to a BusyBox v1.36.1 binary that carries the nc getopt32 # option string `^np:s:ubvw:+lki:o:z` and not the small nc's `lp:w:i:f:e:`. # Source reading of 1.36.1: the binary string matches nc_bloaty.c's option string with NC_110_COMPAT, NC_SERVER # and NC_EXTRA, its only match in the directories read. # This guide's inference: each image's nc option parser accepts -z and a nonnegative integer -w. That is an # inference because a matched string literal does not show which function references it, no disassembly was done, # and the source clone that was read checks out only the networking, include, libbb and scripts directories, so # whether the string occurs elsewhere in BusyBox was not checked. # The usage text each binary carries also lists -z and -w SEC, but BusyBox generates usage text from the build # configuration, so it corroborates those options without showing the parser. # This guide's assessment: the index's amd64 entry names docker-library/busybox revision c1375496 and version # 1.36.1-glibc, which agrees with the mapping above but does not prove that build made the image. # This guide's advice: neither reading shows run-time behaviour, and tags move, so confirm that the image you pull # accepts `nc -z -w 3` and makes a TCP connect before relying on the probe. # This block exits 1 if probe-forbidden connects, 2 if the db Service has no ClusterIP to probe (an empty or # None ClusterIP, or one containing characters other than hexadecimal digits, dots and colons), 3 if the # ClusterIP cannot be read or the probe-permitted positive control fails, and otherwise 0, leaving # probe-forbidden's kubectl output for you to read. ( # the whole block in one subshell, so one exit status covers every part of it set -- "$(if kubectl get svc db -o jsonpath='{.spec.clusterIP}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%$'\n'*}" # kubectl's exit status, then the ClusterIP [ "$1" = 0 ] || { echo "kubectl get svc exit $1: the db Service's ClusterIP could not be read; inconclusive"; exit 3; } case "$2" in ""|None|*[!0123456789abcdefABCDEF.:]*) echo "the db Service's ClusterIP is empty, None or not an address (a headless Service has none to probe); not probing"; exit 2 ;; esac # Build the override per pod: printf uses its format once for each pod name and ClusterIP pair, one line each set -- "$2" 0 "$(printf '{"spec":{"securityContext":{"runAsNonRoot":true,"runAsUser":10001,"runAsGroup":10001,"seccompProfile":{"type":"RuntimeDefault"}},"containers":[{"name":"%s","image":"busybox:1.36","securityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}},"command":["nc","-z","-w","3","%s","5432"]}]}}\n' probe-permitted "$2" probe-forbidden "$2")" set -- "$1" "$2" "${3%%$'\n'*}" "${3#*$'\n'}" # the ClusterIP, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then each pod's override if kubectl run probe-permitted --rm -it --restart=Never --image=busybox:1.36 --labels=role=app \ --overrides="$3" -- true; then :; else echo "kubectl run exit $?: the probe-permitted positive control failed, so probe-forbidden below proves nothing; inconclusive"; set -- "$1" 3 "$3" "$4" fi # from a pod labeled role=app, by IP: must succeed FIRST. This is the # positive control. If it fails, the policy, the override, or this # image's `nc` is the problem, and the forbidden probe below proves # nothing: a refusal you cannot distinguish from a broken probe is # not evidence if kubectl run probe-forbidden --rm -it --restart=Never --image=busybox:1.36 --labels=role=other \ --overrides="$4" -- true; then echo "FINDING: probe-forbidden reached $1:5432 from a pod without role=app"; set -- "$1" 1 "$3" "$4" else echo "kubectl run exit $?: probe-forbidden did not connect" fi # from a pod without that label, same IP: must time out or be refused # at TCP, not fail on DNS. Read this as an END-TO-END denial: role=other # cannot reach the db, but the refusal could be its own default-deny # egress OR the db's ingress (either alone would refuse it), so it # proves the combined segmentation, not either layer in isolation. # Isolating which layer blocks (e.g. that the db ingress admits ONLY # role=app) needs more elaborate per-layer probes, each with its own # positive control, and is CNI-dependent, so validate the whole policy # set on your CNI rather than reasoning rule by rule echo "block exit $2; when probe-forbidden fails, its output above must show a TCP refusal or timeout rather than a kubectl or admission error, so read it yourself" exit "$2" ) ``` ## Sources (checked September 2026) - Docker Dockerfile reference (`USER`): https://docs.docker.com/reference/dockerfile/ - Docker Compose file reference (`user`, `read_only`, `cap_add`, `cap_drop`, `security_opt`): https://docs.docker.com/reference/compose-file/ - Docker run security options (`--security-opt no-new-privileges` and its engine behaviour): https://docs.docker.com/reference/cli/docker/container/run/ - Docker daemon control-socket access and host filesystem control, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/_index.md#L84-L112 - Docker group root-level privileges, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/install/linux-postinstall.md#L29-L39 - Docker Rootless mode daemon and container privileges, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/rootless/_index.md#L8-L21 - Docker run `--privileged` host control, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L1330-L1350 - Docker run mounted socket and client access to the host daemon, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L1460-L1467 - Docker run `--mount` host-directory bind mounts, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L1495-L1515 - Kubernetes: Configure a security context for a Pod or Container: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ - Kubernetes: Pod Security Standards (`restricted` level, Pod Security Admission labels): https://kubernetes.io/docs/concepts/security/pod-security-standards/ - Kubernetes: Network Policies: https://kubernetes.io/docs/concepts/services-networking/network-policies/ - Kubernetes 1.34 Pod Security Admission enforcement (checked October 2026): https://v1-34.docs.kubernetes.io/docs/concepts/security/pod-security-admission/ - Kubernetes 1.34 namespace labels and warnings for existing Pods (checked October 2026): https://v1-34.docs.kubernetes.io/docs/tasks/configure-pod-container/enforce-standards-namespace-labels/ - kubectl 1.34.0 source, run name parameter (checked October 2026): https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go#L293-L310 - kubectl 1.34.0 source, kubectl run registers the override flags (checked October 2026): https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go#L103-L194 - kubectl 1.34.0 source, generation before override (checked October 2026): https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go#L591-L622 - kubectl 1.34.0 source, default override method (checked October 2026): https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/util/override_options.go#L29-L90 - RFC 7396, JSON Merge Patch section 2 (checked October 2026): https://www.rfc-editor.org/rfc/rfc7396#section-2 - BusyBox 1.36.1 source, nc configuration and compatibility path (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L30-L59 - BusyBox 1.36.1 source, compatible nc options (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L61-L93 - BusyBox 1.36.1 source, compatible nc connect and exit behaviour (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L888-L922 - BusyBox 1.36.1 source, nc getopt32 option string that the BusyBox binary in each of the nine linux images of busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662 (busybox:1.36 as Docker Hub served it on 2026-10-24) carries, read without running it (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L793-L801 - BusyBox 1.36.1 source, small nc option string, which none of those nine binaries carries (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L133-L138 - BusyBox 1.36.1 source, getopt32 `o:+` option parameter, a nonnegative integer processed with `xatoi_positive()`, as in nc's `w:+` (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/libbb/getopt32.c#L67-L69 - Docker Official Images busybox library at d6d62c71a83290283aa58c5041a156415cfd37d9, image repository and amd64 commit (checked October 2026): https://github.com/docker-library/official-images/blob/d6d62c71a83290283aa58c5041a156415cfd37d9/library/busybox#L5-L11 - Docker Official Images busybox library at d6d62c71a83290283aa58c5041a156415cfd37d9, 1.36 tags and amd64 glibc directory (checked October 2026): https://github.com/docker-library/official-images/blob/d6d62c71a83290283aa58c5041a156415cfd37d9/library/busybox#L111-L113 - docker-library/busybox at c1375496373e76f680b1ef5f713500e98921a45c, glibc build patch step before `make defconfig` (checked October 2026): https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/latest-1/glibc/Dockerfile.builder#L51-L59 - docker-library/busybox at c1375496373e76f680b1ef5f713500e98921a45c, no-cbq patch, which changes only `networking/tc.c` (checked October 2026): https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/.patches/no-cbq.patch - docker-library/busybox at c1375496373e76f680b1ef5f713500e98921a45c, glibc build configuration (checked October 2026): https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/latest-1/glibc/Dockerfile.builder#L68-L113 - docker-library/busybox at c1375496373e76f680b1ef5f713500e98921a45c, amd64 glibc root filesystem archive with `bin/nc` (checked October 2026): https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/latest-1/glibc/amd64/rootfs.tar.gz - Docker Compose v2.32.4 exec service target (checked October 2026): https://github.com/docker/compose/blob/v2.32.4/docs/reference/compose_exec.md#L1-L7 - Docker Compose v2.32.4 exec project and service options (checked October 2026): https://github.com/docker/compose/blob/v2.32.4/cmd/compose/exec.go#L84-L110 - Docker Compose v2.32.4 up detach and force-recreate flags (checked October 2026): https://github.com/docker/compose/blob/v2.32.4/cmd/compose/up.go#L145-L155 - Linux man-pages man-pages-6.19 process status fields (checked October 2026): https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_status.5?h=man-pages-6.19#n315 - Linux man-pages man-pages-6.19 capability bounding set (checked October 2026): https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man7/capabilities.7?h=man-pages-6.19#n1367 - kubectl v1.34.0 exec Pod and container examples (checked October 2026): https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/exec/exec.go#L52-L56 - Kubernetes website 47132eb5367cfdcba0a3b33a0b3ffc81185bd7e8 shared process namespace and pod sandbox (checked October 2026): https://github.com/kubernetes/website/blob/47132eb5367cfdcba0a3b33a0b3ffc81185bd7e8/content/en/docs/tasks/configure-pod-container/share-process-namespace.md#L115-L119 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - docker: Docker documentation (unknown) - docker-daemon: Docker daemon security documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - compose-2324: Docker Compose source and reference (v2.32.4) - kubernetes-website: Kubernetes process namespace documentation (47132eb5367cfdcba0a3b33a0b3ffc81185bd7e8) - kubernetes: Kubernetes documentation (unknown) - kubernetes-134: Kubernetes Pod Security Admission (1.34) - kubectl-134: kubectl source (v1.34.0) - procfs-619: Linux process status and capabilities manuals (man-pages-6.19) - rfc7396: JSON Merge Patch (RFC 7396) - busybox-1361: BusyBox source (1_36_1) - busybox-library: Docker Official Images busybox library (d6d62c71a83290283aa58c5041a156415cfd37d9) - busybox-image: Docker busybox image build files (c1375496373e76f680b1ef5f713500e98921a45c) Claims (full source mapping in the JSON block below): - docker-user (REASONED; docker): Dockerfile USER sets build/runtime identity; set user and group, since no primary group uses root. Compose user overrides it; unset in both runs as root. - docker-rootfs (REASONED; docker): Compose read_only makes the root filesystem read-only; provide tmpfs only for required writable paths. - docker-capabilities (REASONED; docker): cap_drop: [ALL] removes capabilities; add back only specific required capabilities with cap_add. - docker-escalation (REASONED; docker): no-new-privileges prevents setuid privilege gain; the guide lists bare, equals-true and colon-true spellings as equivalent. - docker-socket (REASONED; docker, docker-daemon): Never mount /var/run/docker.sock. In Docker's default rootful configuration, daemon control lets a user start a container that can alter the host filesystem without restriction, and docker-group membership grants root-level privileges; Rootless mode runs the daemon and containers without root privileges. Docker documents mounted-socket daemon control with a Docker client, and documents --privileged and host bind mounts; a socket-holding container starting a privileged sibling or bind-mounting host / to escape is this guide's assessment. Rootless mode narrowing the available host privilege but not making the mount safe is this guide's assessment. - kube-user (REASONED; kubernetes): runAsNonRoot rejects root; pin runAsUser and runAsGroup to 10001, with the guide noting runtime-default GID 0 if unset. - kube-rootfs (REASONED; kubernetes): Set readOnlyRootFilesystem per container; this is a separate recommendation, not a restricted Pod Security requirement. - kube-escalation (REASONED; kubernetes): Set allowPrivilegeEscalation: false in the container securityContext. - kube-capabilities (REASONED; kubernetes): Drop ALL capabilities in the container securityContext. - kube-seccomp (REASONED; kubernetes): RuntimeDefault selects the runtime syscall filter instead of Unconfined. - admission (REASONED; kubernetes, kubernetes-134): Namespace enforce: restricted rejects new violating Pods: privileged mode, host namespaces/ports, root, missing capability drops or Unconfined seccomp. - existing-pods (REASONED; kubernetes, kubernetes-134): Adding the namespace label warns about existing violating Pods without evicting them; recreate workloads. - policy-semantics (REASONED; kubernetes): NetworkPolicies are additive and require an enforcing CNI; both source egress and destination ingress must allow a connection. - default-deny (REASONED; kubernetes): The empty podSelector with Ingress and Egress isolates all pods selected in the namespace before narrow allowances. - dns (REASONED; kubernetes): The app egress rule permits UDP/TCP 53 to all kube-system pods, not just CoreDNS; narrow it to the actual resolver. - database-path (REASONED; kubernetes): Allow app egress to role: db and db ingress from role: app on TCP 5432; other paths governed by these namespace policies are refused. - policy-limits (REASONED; kubernetes): hostNetwork and same-node traffic handling are implementation-dependent; database TLS/authentication remain separate controls. - verify-user (REASONED; docker, compose-2324): Compose exec targets the app service in the resolved project and id must report a nonzero UID; using the actual container name for bare docker exec is this guide's advice. - verify-rootfs (REASONED; docker, compose-2324): With read_only false, touch on an existing owned path must print WRITABLE; after docker compose up -d --force-recreate with read_only true, it must print blocked. ENOENT or EACCES can also print blocked on a writable filesystem, so blocked alone is not proof of a read-only root. This guide's assessment: the errno, including typical EROFS, depends on the image and path; the probe's exit 0 does not discriminate. - verify-context (REASONED; kubernetes, kubectl-134, procfs-619, kubernetes-website): kubectl get reads the declared context; kubectl exec inspects the application process for nonzero UID, NoNewPrivs:1, Seccomp:2 and zero effective/bounding capabilities. Clearing CapBnd under drop:[ALL] is this guide's expected runtime state; with shareProcessNamespace, PID 1 belongs to the pod sandbox, so select the application PID. - verify-probe (REASONED; kubernetes, kubectl-134, rfc7396, busybox-1361): Resolve the db ClusterIP once; keep matching container names and restricted-compliant overrides for busybox:1.36 probes. Pinned kubectl and BusyBox sources explain merge and nc flags; no probe run is recorded, so this guide advises confirming that the image accepts nc -z -w 3 and makes a TCP connect before relying on it. - probe-image (REASONED; busybox-1361, busybox-library, busybox-image): As of official-images d6d62c71 (tags move), amd64 busybox:1.36 is the glibc variant imported from docker-library/busybox c1375496 artefacts; this guide infers that commit's build made it. Binary reading, not run: each of the nine linux images in the index served on 2026-10-24 has a BusyBox whose nc option string matches nc_bloaty.c's with NC_EXTRA; this guide infers its parser accepts -z and -w. - verify-segmentation (REASONED; kubernetes): The role: app TCP probe must succeed first; role: other to the same IP must fail at TCP, not DNS. This tests combined segmentation, not either policy layer independently. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "1aebec7da57b179cd121a6e4a50acab780d17b39af6f7438353687adf082b3c0", "components": { "docker": { "name": "Docker documentation", "basis": "unknown", "sources": { "s6ca35cbd69e7": "https://docs.docker.com/reference/dockerfile/", "s8b7485a246e4": "https://docs.docker.com/reference/compose-file/", "s5d6294cf605e": "https://docs.docker.com/reference/cli/docker/container/run/" } }, "docker-daemon": { "name": "Docker daemon security documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "secebea1a69ab": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/_index.md#L84-L112", "s79490cd2f3b2": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/install/linux-postinstall.md#L29-L39", "s8eab393a61c6": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/security/rootless/_index.md#L8-L21", "s94d57fcbfafe": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L1330-L1350", "s372ddf9da1f0": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L1460-L1467", "see4d326cd914": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/data/cli/engine/docker_container_run.yaml#L1495-L1515" } }, "compose-2324": { "name": "Docker Compose source and reference", "basis": "v2.32.4", "sources": { "s906b5822e9c4": "https://github.com/docker/compose/blob/v2.32.4/docs/reference/compose_exec.md#L1-L7", "sfe4c568a9887": "https://github.com/docker/compose/blob/v2.32.4/cmd/compose/exec.go#L84-L110", "s1bf114813e47": "https://github.com/docker/compose/blob/v2.32.4/cmd/compose/up.go#L145-L155" } }, "kubernetes-website": { "name": "Kubernetes process namespace documentation", "basis": "47132eb5367cfdcba0a3b33a0b3ffc81185bd7e8", "sources": { "s3df8336a2aa8": "https://github.com/kubernetes/website/blob/47132eb5367cfdcba0a3b33a0b3ffc81185bd7e8/content/en/docs/tasks/configure-pod-container/share-process-namespace.md#L115-L119" } }, "kubernetes": { "name": "Kubernetes documentation", "basis": "unknown", "sources": { "sb78ea91d0308": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", "s1a2d717974c7": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", "s953450b4076f": "https://kubernetes.io/docs/concepts/services-networking/network-policies/" } }, "kubernetes-134": { "name": "Kubernetes Pod Security Admission", "basis": "1.34", "sources": { "s9b9aaf5067e2": "https://v1-34.docs.kubernetes.io/docs/concepts/security/pod-security-admission/", "sa25682f200f0": "https://v1-34.docs.kubernetes.io/docs/tasks/configure-pod-container/enforce-standards-namespace-labels/" } }, "kubectl-134": { "name": "kubectl source", "basis": "v1.34.0", "sources": { "s0906dc78482b": "https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go#L293-L310", "s1adb8cde69c0": "https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go#L103-L194", "sc98b92ba3ebf": "https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/run/run.go#L591-L622", "s2ffc04de1d63": "https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/util/override_options.go#L29-L90", "sf5417ce59241": "https://github.com/kubernetes/kubernetes/blob/v1.34.0/staging/src/k8s.io/kubectl/pkg/cmd/exec/exec.go#L52-L56" } }, "procfs-619": { "name": "Linux process status and capabilities manuals", "basis": "man-pages-6.19", "sources": { "sa94440d59945": "https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_status.5?h=man-pages-6.19#n315", "s74035196d5f7": "https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man7/capabilities.7?h=man-pages-6.19#n1367" } }, "rfc7396": { "name": "JSON Merge Patch", "basis": "RFC 7396", "sources": { "sda39f95d0890": "https://www.rfc-editor.org/rfc/rfc7396#section-2" } }, "busybox-1361": { "name": "BusyBox source", "basis": "1_36_1", "sources": { "s14b89daa008d": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L30-L59", "s34bdee292f79": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L61-L93", "s8a53fb647996": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L888-L922", "s77dbf800f462": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L793-L801", "s7333a3b4590b": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L133-L138", "s43d921981bf2": "https://github.com/mirror/busybox/blob/1_36_1/libbb/getopt32.c#L67-L69" } }, "busybox-library": { "name": "Docker Official Images busybox library", "basis": "d6d62c71a83290283aa58c5041a156415cfd37d9", "sources": { "s080d20eae28c": "https://github.com/docker-library/official-images/blob/d6d62c71a83290283aa58c5041a156415cfd37d9/library/busybox#L5-L11", "s72f8958126e8": "https://github.com/docker-library/official-images/blob/d6d62c71a83290283aa58c5041a156415cfd37d9/library/busybox#L111-L113" } }, "busybox-image": { "name": "Docker busybox image build files", "basis": "c1375496373e76f680b1ef5f713500e98921a45c", "sources": { "s0844cb594c98": "https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/latest-1/glibc/Dockerfile.builder#L51-L59", "s58988a470c6b": "https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/.patches/no-cbq.patch", "s26842ca97d63": "https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/latest-1/glibc/Dockerfile.builder#L68-L113", "s6fd2add44b43": "https://github.com/docker-library/busybox/blob/c1375496373e76f680b1ef5f713500e98921a45c/latest-1/glibc/amd64/rootfs.tar.gz" } } }, "claims": { "docker-user": {"text": "Dockerfile USER sets build/runtime identity; set user and group, since no primary group uses root. Compose user overrides it; unset in both runs as root.", "components": ["docker"], "sources": ["docker:s6ca35cbd69e7", "docker:s8b7485a246e4"], "status": "REASONED"}, "docker-rootfs": {"text": "Compose read_only makes the root filesystem read-only; provide tmpfs only for required writable paths.", "components": ["docker"], "sources": ["docker:s8b7485a246e4"], "status": "REASONED"}, "docker-capabilities": {"text": "cap_drop: [ALL] removes capabilities; add back only specific required capabilities with cap_add.", "components": ["docker"], "sources": ["docker:s8b7485a246e4"], "status": "REASONED"}, "docker-escalation": {"text": "no-new-privileges prevents setuid privilege gain; the guide lists bare, equals-true and colon-true spellings as equivalent.", "components": ["docker"], "sources": ["docker:s5d6294cf605e", "docker:s8b7485a246e4"], "status": "REASONED"}, "docker-socket": {"text": "Never mount /var/run/docker.sock. In Docker's default rootful configuration, daemon control lets a user start a container that can alter the host filesystem without restriction, and docker-group membership grants root-level privileges; Rootless mode runs the daemon and containers without root privileges. Docker documents mounted-socket daemon control with a Docker client, and documents --privileged and host bind mounts; a socket-holding container starting a privileged sibling or bind-mounting host / to escape is this guide's assessment. Rootless mode narrowing the available host privilege but not making the mount safe is this guide's assessment.", "components": ["docker", "docker-daemon"], "sources": ["docker:s5d6294cf605e", "docker-daemon:secebea1a69ab", "docker-daemon:s79490cd2f3b2", "docker-daemon:s8eab393a61c6", "docker-daemon:s94d57fcbfafe", "docker-daemon:s372ddf9da1f0", "docker-daemon:see4d326cd914"], "status": "REASONED"}, "kube-user": {"text": "runAsNonRoot rejects root; pin runAsUser and runAsGroup to 10001, with the guide noting runtime-default GID 0 if unset.", "components": ["kubernetes"], "sources": ["kubernetes:sb78ea91d0308"], "status": "REASONED"}, "kube-rootfs": {"text": "Set readOnlyRootFilesystem per container; this is a separate recommendation, not a restricted Pod Security requirement.", "components": ["kubernetes"], "sources": ["kubernetes:sb78ea91d0308", "kubernetes:s1a2d717974c7"], "status": "REASONED"}, "kube-escalation": {"text": "Set allowPrivilegeEscalation: false in the container securityContext.", "components": ["kubernetes"], "sources": ["kubernetes:sb78ea91d0308", "kubernetes:s1a2d717974c7"], "status": "REASONED"}, "kube-capabilities": {"text": "Drop ALL capabilities in the container securityContext.", "components": ["kubernetes"], "sources": ["kubernetes:sb78ea91d0308", "kubernetes:s1a2d717974c7"], "status": "REASONED"}, "kube-seccomp": {"text": "RuntimeDefault selects the runtime syscall filter instead of Unconfined.", "components": ["kubernetes"], "sources": ["kubernetes:sb78ea91d0308", "kubernetes:s1a2d717974c7"], "status": "REASONED"}, "admission": {"text": "Namespace enforce: restricted rejects new violating Pods: privileged mode, host namespaces/ports, root, missing capability drops or Unconfined seccomp.", "components": ["kubernetes", "kubernetes-134"], "sources": ["kubernetes:s1a2d717974c7", "kubernetes-134:s9b9aaf5067e2"], "status": "REASONED"}, "existing-pods": {"text": "Adding the namespace label warns about existing violating Pods without evicting them; recreate workloads.", "components": ["kubernetes", "kubernetes-134"], "sources": ["kubernetes:s1a2d717974c7", "kubernetes-134:sa25682f200f0"], "status": "REASONED"}, "policy-semantics": {"text": "NetworkPolicies are additive and require an enforcing CNI; both source egress and destination ingress must allow a connection.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "default-deny": {"text": "The empty podSelector with Ingress and Egress isolates all pods selected in the namespace before narrow allowances.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "dns": {"text": "The app egress rule permits UDP/TCP 53 to all kube-system pods, not just CoreDNS; narrow it to the actual resolver.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "database-path": {"text": "Allow app egress to role: db and db ingress from role: app on TCP 5432; other paths governed by these namespace policies are refused.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "policy-limits": {"text": "hostNetwork and same-node traffic handling are implementation-dependent; database TLS/authentication remain separate controls.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "verify-user": {"text": "Compose exec targets the app service in the resolved project and id must report a nonzero UID; using the actual container name for bare docker exec is this guide's advice.", "components": ["docker", "compose-2324"], "sources": ["docker:s6ca35cbd69e7", "docker:s8b7485a246e4", "compose-2324:s906b5822e9c4", "compose-2324:sfe4c568a9887"], "status": "REASONED", "verify": [1]}, "verify-rootfs": {"text": "With read_only false, touch on an existing owned path must print WRITABLE; after docker compose up -d --force-recreate with read_only true, it must print blocked. ENOENT or EACCES can also print blocked on a writable filesystem, so blocked alone is not proof of a read-only root. This guide's assessment: the errno, including typical EROFS, depends on the image and path; the probe's exit 0 does not discriminate.", "components": ["docker", "compose-2324"], "sources": ["docker:s8b7485a246e4", "compose-2324:s1bf114813e47"], "status": "REASONED", "verify": [1]}, "verify-context": {"text": "kubectl get reads the declared context; kubectl exec inspects the application process for nonzero UID, NoNewPrivs:1, Seccomp:2 and zero effective/bounding capabilities. Clearing CapBnd under drop:[ALL] is this guide's expected runtime state; with shareProcessNamespace, PID 1 belongs to the pod sandbox, so select the application PID.", "components": ["kubernetes", "kubectl-134", "procfs-619", "kubernetes-website"], "sources": ["kubernetes:sb78ea91d0308", "kubectl-134:sf5417ce59241", "procfs-619:sa94440d59945", "procfs-619:s74035196d5f7", "kubernetes-website:s3df8336a2aa8"], "status": "REASONED", "verify": [2]}, "verify-probe": {"text": "Resolve the db ClusterIP once; keep matching container names and restricted-compliant overrides for busybox:1.36 probes. Pinned kubectl and BusyBox sources explain merge and nc flags; no probe run is recorded, so this guide advises confirming that the image accepts nc -z -w 3 and makes a TCP connect before relying on it.", "components": ["kubernetes", "kubectl-134", "rfc7396", "busybox-1361"], "sources": ["kubernetes:sb78ea91d0308", "kubernetes:s1a2d717974c7", "kubernetes:s953450b4076f", "kubectl-134:s0906dc78482b", "kubectl-134:s1adb8cde69c0", "kubectl-134:sc98b92ba3ebf", "kubectl-134:s2ffc04de1d63", "rfc7396:sda39f95d0890", "busybox-1361:s14b89daa008d", "busybox-1361:s34bdee292f79", "busybox-1361:s8a53fb647996"], "status": "REASONED", "verify": [3]}, "probe-image": {"text": "As of official-images d6d62c71 (tags move), amd64 busybox:1.36 is the glibc variant imported from docker-library/busybox c1375496 artefacts; this guide infers that commit's build made it. Binary reading, not run: each of the nine linux images in the index served on 2026-10-24 has a BusyBox whose nc option string matches nc_bloaty.c's with NC_EXTRA; this guide infers its parser accepts -z and -w.", "components": ["busybox-1361", "busybox-library", "busybox-image"], "sources": ["busybox-1361:s14b89daa008d", "busybox-1361:s34bdee292f79", "busybox-1361:s77dbf800f462", "busybox-1361:s7333a3b4590b", "busybox-1361:s43d921981bf2", "busybox-library:s080d20eae28c", "busybox-library:s72f8958126e8", "busybox-image:s0844cb594c98", "busybox-image:s58988a470c6b", "busybox-image:s26842ca97d63", "busybox-image:s6fd2add44b43"], "status": "REASONED", "verify": [3]}, "verify-segmentation": {"text": "The role: app TCP probe must succeed first; role: other to the same IP must fail at TCP, not DNS. This tests combined segmentation, not either policy layer independently.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> nextjs.md ====================================================================== # Next.js: authentication that actually gates Route Handlers, Server Actions, and data Next.js is a React framework for web apps that combine pages and server code. This guide helps your AI assistant secure it, including auth checks next to the data at every entry point, hardened session cookies, a loopback bind when self-hosted, and no secrets in the browser bundle. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/nextjs.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Next.js listener source v16.3.6, Node.js listener source v22.22.1, and Commander 12.1.0; 6 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 32 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A Next.js app has many entry points: pages, layouts, Route Handlers (`app/**/route.ts`), and every exported Server Action, which the Next.js docs describe as reachable by a direct POST whether or not your UI calls it. A check that lives only in a layout or in `proxy.ts` (the file Next.js 16 renamed from `middleware.ts`) leaves open the entry points it does not cover (a layout guards no Route Handler or Server Action; a `proxy.ts` matcher guards only the paths it matches), and authorization still belongs next to the data. Next.js supplies cookies and sessions as primitives, not a login system; the login is yours or a library's. ## 1. Bind privately; TLS comes from the platform or the proxy On Vercel and similar platforms the platform terminates TLS; nothing to configure ([paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). Self-hosted, `next start` listens on every interface on port 3000 by default (as of v16.3.6 its `--port` defaults to 3000 and it passes no hostname to Node's `listen`, which binds `::` where IPv6 is available and `0.0.0.0` otherwise; its `-H` option's help text still says `(default: 0.0.0.0)`). The step for a self-hosted app is to bind to loopback and put a reverse proxy in front, which the Next.js self-hosting guide itself recommends, with TLS and headers per [nodejs.md](https://github.com/jposluns/secureconfig/blob/main/nodejs.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). ```bash next build && next start -H 127.0.0.1 -p 3000 # or PORT=3000; PORT cannot be set in .env ``` ## 2. Put the check where the data is The Next.js authentication guide's rule: create a Data Access Layer (DAL) with a `verifySession()` function and call it from every data request, Server Action, and Route Handler. Its reasons, paraphrased from the authentication and data-security guides: - Layouts do not re-render on client navigation, so a session check there does not run on every route change, and a layout that returns `null` does not stop nested segments or Server Actions from running. - Server Actions and Route Handlers get "the same security considerations as public-facing API endpoints"; a page-level check "does not extend to the Server Actions defined within it". - Proxy "should not be your only line of defense": use it for optimistic redirects that read the cookie only, never database lookups. A `matcher` change can silently remove Proxy coverage from a Server Action. ```ts // app/lib/dal.ts import 'server-only' import { cache } from 'react' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { decrypt } from '@/app/lib/session' export const verifySession = cache(async () => { const session = await decrypt((await cookies()).get('session')?.value) if (!session?.userId) redirect('/login') return { isAuth: true, userId: session.userId, role: session.role } }) // app/api/admin/route.ts: the handler checks for itself (return 401 here instead of redirecting if you prefer) export async function GET() { const session = await verifySession() if (session.role !== 'admin') return new Response(null, { status: 403 }) } // app/actions.ts: so does every Server Action; the page that renders the form does not count 'use server' export async function deleteRecord(formData: FormData) { const session = await verifySession() if (session.role !== 'admin') return null } ``` Also check ownership of the specific resource inside the DAL (authorization, not only authentication), and return only the fields the client needs. The snippets above are fragments: `session.role` must come from your trusted user store (not from the cookie alone for sensitive checks), and each handler still returns its real success response, so wire in your own imports and data calls. ## 3. Sessions: signed payload, hardened cookie ```bash openssl rand -base64 32 # SESSION_SECRET, set in the environment; never with a NEXT_PUBLIC_ prefix ``` ```ts // app/lib/session.ts (the guide's stateless session, using jose) import 'server-only' import { SignJWT, jwtVerify } from 'jose' import { cookies } from 'next/headers' const key = new TextEncoder().encode(process.env.SESSION_SECRET) export async function createSession(userId: string, role: string) { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const session = await new SignJWT({ userId, role, expiresAt }) .setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime('7d').sign(key) ;(await cookies()).set('session', session, { httpOnly: true, secure: true, expires: expiresAt, sameSite: 'lax', path: '/' }) } ``` `decrypt()` is `jwtVerify(session, key, { algorithms: ['HS256'] })`, returning the payload or `undefined`. Cookies can only be set or deleted in a Server Function or Route Handler; logout is `(await cookies()).delete('session')`. Keep the payload to the user ID and role, no PII. For sensitive operations the guide recommends database sessions verified against the store, not the cookie alone. ## 4. Keep secrets out of the client bundle - `NEXT_PUBLIC_*` variables are inlined into the JavaScript sent to the browser at `next build`; every other variable is server-only BY DEFAULT, but a variable placed in `next.config.js`'s `env` key is also inlined into the client bundle regardless of prefix, so never put a secret there. A secret with a `NEXT_PUBLIC_` prefix is published to every visitor ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Only the DAL should read `process.env`; `.env*` files stay in `.gitignore`. - More than one self-hosted instance: set `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` (base64, 16, 24, or 32 decoded bytes) at build so all instances share the Server Action closure key. The docs say not to rely on that encryption to hide secrets. - Server Actions abort when `Origin` does not match `Host` (or `X-Forwarded-Host`). Behind a proxy whose host differs from the public domain, list the public origins in `experimental.serverActions.allowedOrigins` in `next.config.js`. ## 5. Libraries **Auth.js** (https://authjs.dev/): `AUTH_SECRET` is the one mandatory variable; `npx auth secret` writes it to `.env.local`. ```ts // auth.ts import NextAuth from "next-auth" export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [] }) // app/api/auth/[...nextauth]/route.ts import { handlers } from "@/auth" export const { GET, POST } = handlers ``` Providers are configured from `AUTH__ID`, `AUTH__SECRET`, and for OIDC `AUTH__ISSUER`; provider setup and the allowlist check are in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) and [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md). Behind a reverse proxy set `AUTH_TRUST_HOST=true` (automatic on Vercel). Server code calls `const session = await auth()`. Wrapping a Route Handler with `auth(...)` only fills in `req.auth`; nothing refuses the request for you, so the handler must check the session, then authorize, then touch data: ```ts // app/api/notes/route.ts import { NextResponse } from "next/server" import { auth } from "@/auth" // isAllowed and loadNotes are your app's own functions export const GET = auth(async function GET(req) { if (!req.auth) return NextResponse.json({ message: "Not authenticated" }, { status: 401 }) // no automatic 401 if (!isAllowed(req.auth.user)) return NextResponse.json({ message: "Forbidden" }, { status: 403 }) // your allowlist return NextResponse.json(await loadNotes(req.auth.user)) }) ``` `export { auth as proxy }` in `proxy.ts` is the optimistic layer, but with an empty `providers` and no `callbacks.authorized` it authorizes every request by default, so add an `authorized` callback (or a redirecting wrapper) if you use it to gate; Auth.js says not to rely on it exclusively regardless. MFA is not part of this configuration; enforce it at the identity provider ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). **Better Auth** (https://better-auth.com/docs/introduction): `BETTER_AUTH_SECRET` (32+ characters, `openssl rand -base64 32`) and `BETTER_AUTH_URL`; the placeholder default secret throws in production. ```ts // lib/auth.ts import { betterAuth } from "better-auth" import { nextCookies } from "better-auth/next-js" import { twoFactor } from "better-auth/plugins" export const auth = betterAuth({ baseURL: "https://app.example.com", // set explicitly; the docs advise against request inference trustedOrigins: ["https://app.example.com"], // scoped origin/CSRF + callbackURL validation; the general check skips GET/HEAD/OPTIONS (some endpoints still validate callbackURL on GET); NOT an app-route gate, so keep authn+authz per route emailAndPassword: { enabled: true }, // default false plugins: [twoFactor(), nextCookies()], // nextCookies lets Server Actions set the session cookie }) // app/api/auth/[...all]/route.ts import { toNextJsHandler } from "better-auth/next-js" export const { GET, POST } = toNextJsHandler(auth) ``` Server-side check: assign and REJECT - `const session = await auth.api.getSession({ headers: await headers() })`, then reject before authorizing. The rejection differs by surface: a Route Handler returns `new Response(null, { status: 401 })`; a Server Component redirects or throws; a Server Action redirects, throws, or returns a serializable error, since a `Response` is not a valid Server Component render result or Server Action return value. Do this in Route Handlers, Server Actions, and Server Components; calling `getSession` and discarding its result gates nothing. `getSessionCookie(request)` in `proxy.ts` only proves a cookie exists; Better Auth says to check in each page and route. `twoFactor()` makes TOTP (default), emailed or SMS OTP, and backup codes AVAILABLE, not enrolled or enforced: users must enroll, emailed or SMS OTP additionally needs `otpOptions.sendOTP`, and requiring 2FA for an operation is a separate check. Add `twoFactorClient()` from `better-auth/client/plugins` on the client and run the schema migration the plugin page documents before enabling it. ## 6. Vercel: Deployment Protection is not user authentication Deployment Protection controls who can open a deployment URL: Vercel Authentication admits Vercel users with access to the project; Passport, Password Protection, and Trusted IPs are Enterprise or paid add-on options. Standard Protection covers previews and generated URLs but not production domains; selecting the All Deployments scope for Vercel Authentication extends it to production, and Vercel's changelog of 9 September 2026 makes that free on every plan including Hobby, where protecting production previously required a paid Advanced Deployment Protection add-on (at the time of writing). Availability is not activation: the production domain stays public until All Deployments is configured. It gates your team's previews; your users are not Vercel users, so production still needs section 2 ([paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md)). ## Verify REASONED: following block; the listener inventory, with the anonymous route, authorized route and client-secret checks in the three blocks after it, each marked in its own first line. No deployed application or session controls are available, so no route or session outcome is recorded here; expectations are reasoned from the cited Next.js, auth-library, curl and grep sources. On 2026-10-13 (UTC), with Bash 5.3.9, a stubbed curl and dummy values typed at a pty's hidden prompts, the cookie and secret-scan subshells each traced two lines under `bash -x` (`trap -` and `set +x +a +e`), none holding a dummy value; with `set +x` removed, the dummies appeared in 3 and 1 trace lines. An interactive Bash given each subshell wrote a history file holding the block's lines and no dummy value, and the stub received each cookie only on stdin. Those bash -x, history and stub runs covered earlier forms, when all four checks shared one block: the cookie subshell before it took the application's host on a guarded `set --` line, and the secret scan while its FINDING and error arms ended with status 0. The repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block cover the current forms of the four blocks. Stub runs on 2026-10-31 with Bash 5.3.9, stub curl and ss, real grep on a scratch build directory, a pty for the hidden prompts and no network ran the guards and every status below, on the current forms of the listener inventory and secret scan and on the earlier form of the two route checks, which lowercased the host with `tr` before refusing reserved names. The tracing and history runs were not repeated. The route checks now refuse a reserved name under bash's `nocasematch` option instead of through `tr`, and refuse with 2, saying the block needs bash, when `shopt` fails, as it does in dash. That current form is covered by curl-stub runs on 2026-11-01 with no network and no terminal. In bash, each route check refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. A host name outside the reserved names reached the anonymous check's two curl calls and the authorized check's hidden prompt, which failed with 3 without a terminal. Under dash, stub runs of the current form refused every host name in each route check with 2 and the bash message before any curl call or prompt, except that under an inherited `set -e` the authorized check ended with status 1 at its `trap` line, which dash rejects, as it did before this change. Each block exits 2 when a guard refuses and 3 when a command it attempts fails, a hidden-prompt read included. The listener inventory refuses with status 2 when `ss` is not installed, and an `ss` that runs and fails gives 3. The secret scan exits 1 when grep finds the secret and 0 when it does not; the route answers and the listener table are left unclassified, and each block's last line names what it leaves for you to judge. Paste each cookie value and the secret alone at its hidden prompt: a multi-line paste feeds every line after the first to the next prompt or to your shell, which runs it. ```bash ( # a subshell, so the exits below end only the subshell set +e # errexit off here, so a failed ss is reported below instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } # a missing ss is a guard refusal; an ss that runs and fails is 3 below ss -tlnp # read every listener; 3000 on 127.0.0.1 only when self-hosted. ss shows the BIND, not the # firewall or the platform ingress, so confirm external reachability separately case "$?" in 0) echo "unclassified observation: the listener table above is not classified, so judge it yourself"; exit 0 ;; *) echo "ss -tlnp exit $?: there is no listener table to judge; inconclusive"; exit 3 ;; esac ) ``` ```bash # REASONED: anonymous route checks follow the cited Next.js authentication guide and curl manual; no deployed application is available, so no route outcome is recorded. # Protected routes WITHOUT a cookie. This guide's verifySession() redirects to /login, so an unauthenticated # Route Handler or page returns a 3xx to /login (or 401/403 if you chose the "return 401" variant); a # streamed redirect can instead be a 200 whose body is a client-side redirect shell, not the protected content, so read the BODY, not the status # alone. --noproxy so no client proxy answers. Save each body and confirm it carries NO protected payload: ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a failed request is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your Next.js app is served on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 3 if a request fails, otherwise 0, because you judge the answers curl -q -g -sS --noproxy '*' -o admin-nocookie.txt -w 'http=%{http_code} loc=%{redirect_url}\n' "https://$1/api/admin" # 3xx to /login, or 401/403; admin-nocookie.txt must hold no admin data case "$?" in 0) ;; *) echo "curl exit $?: the /api/admin request failed; inconclusive"; set -- "$1" 3 ;; esac curl -q -g -sS --noproxy '*' -o dash-nocookie.txt -w 'http=%{http_code} loc=%{redirect_url}\n' "https://$1/dashboard" # 3xx with loc=/login, or a 200 redirect shell; dash-nocookie.txt must NOT contain dashboard content (a streamed redirect saves a shell, not the login page) case "$?" in 0) ;; *) echo "curl exit $?: the /dashboard request failed; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; the status codes, redirect targets and the bodies saved in admin-nocookie.txt and dash-nocookie.txt are not classified, so judge them yourself" exit "$2" ) ``` ```bash # REASONED: authorized route controls and hidden cookie input follow the cited Next.js authentication guide and curl manual; no deployed application or session cookies are available. # POSITIVE controls, so a denial above is the auth check firing and not a route broken for everyone. Put the # app's host name and then the cookie's NAME on the set -- line - this guide's example uses `session`, Better Auth uses # `better-auth.session_token` (or `__Secure-better-auth.session_token` with secure cookies) - and paste a # real, freshly issued cookie VALUE at each hidden prompt. Test the admin route with an admin AND a non-admin # cookie (authorization, not only authentication), and confirm each success body is the real protected # content, not a login page. Each cookie reaches curl on stdin (--header @-), never argv. The hidden prompt # keeps the cookies out of shell history and the block's set +x keeps them out of set -x tracing; neither hides # them from the account owner or root. Paste this subshell by itself: without bracketed paste, lines pasted # after its closing ) are read as the cookies instead. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' 'session' # the same app host name as the no-cookie check above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the cookie NAME: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs exactly 2 values; not probing'; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ''|*[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-]*) echo 'the set -- line takes the cookie NAME second (letters, digits, . _ -); not probing'; exit 2 ;; esac { unset -n cookie_value && unset -v cookie_value; } 2>/dev/null || { echo 'cannot initialize cookie input; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 3 if a request fails, otherwise 0, because you judge the answers IFS= read -r -s -p 'ADMIN session cookie VALUE (input hidden), then Enter: ' cookie_value < /dev/tty || { printf '\n'; echo 'admin cookie input failed; not probing'; exit 3; } printf '\n' case "$cookie_value" in ''|*[[:cntrl:]]*) echo 'a valid admin session cookie value is required; not probing'; exit 2 ;; esac printf 'Cookie: %s=%s\n' "$2" "$cookie_value" | curl -q -g -sS --noproxy '*' -o dash-auth.txt -w 'http=%{http_code}\n' --header @- "https://$1/dashboard" # expect 200; dash-auth.txt is the dashboard, not /login case "$?" in 0) ;; *) echo "curl exit $?: the admin /dashboard request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac printf 'Cookie: %s=%s\n' "$2" "$cookie_value" | curl -q -g -sS --noproxy '*' -o admin-auth.txt -w 'http=%{http_code}\n' --header @- "https://$1/api/admin" # admin cookie: 200 case "$?" in 0) ;; *) echo "curl exit $?: the admin /api/admin request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac IFS= read -r -s -p 'NON-ADMIN session cookie VALUE (input hidden), then Enter: ' cookie_value < /dev/tty || { printf '\n'; echo 'non-admin cookie input failed; not probing'; exit 3; } printf '\n' case "$cookie_value" in ''|*[[:cntrl:]]*) echo 'a valid non-admin session cookie value is required; not probing'; case "$3" in 0) exit 2 ;; esac; exit "$3" ;; esac printf 'Cookie: %s=%s\n' "$2" "$cookie_value" | curl -q -g -sS --noproxy '*' -o admin-nonadmin.txt -w 'http=%{http_code} loc=%{redirect_url}\n' --header @- "https://$1/api/admin" # a non-admin cookie here must be 403 or a /login redirect, and admin-nonadmin.txt must hold no admin data; this means nothing unless the admin run got 200 case "$?" in 0) ;; *) echo "curl exit $?: the non-admin /api/admin request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac echo "block exit $3; the status codes, redirect targets and the bodies saved in dash-auth.txt, admin-auth.txt and admin-nonadmin.txt are not classified, so judge them yourself" exit "$3" ) ``` ```bash # REASONED: the client-bundle secret scan follows the cited Next.js environment-variable guide and GNU grep manual; no application build is available. # Secret scan of the client bundle: search the FULL secret (not a prefix), require the build to exist, and # read grep's exit status separately (run inline under set -e, a clean grep's exit 1 would abort the block). # Paste the session-signing secret at the hidden prompt; it reaches grep on stdin (-f - reads the patterns # from stdin), never argv, with the same limits as above. Paste this subshell by itself too: without # bracketed paste, a line pasted after its closing ) is searched for instead, and its clean result then means nothing. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n session_secret && unset -v session_secret; } 2>/dev/null || { echo 'cannot initialize secret input; not scanning'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not scanning'; exit 2; } [ -d .next/static ] || { echo 'no .next/static here: run next build first; not scanning'; exit 2; } IFS= read -r -s -p 'SESSION_SECRET value (input hidden), then Enter: ' session_secret < /dev/tty || { printf '\n'; echo 'secret input failed; not scanning'; exit 3; } printf '\n' case "$session_secret" in ''|*[[:cntrl:]]*) echo 'the full secret is required; not scanning'; exit 2 ;; esac if printf '%s\n' "$session_secret" | grep -rIlF -f - -- .next/static; then echo "FINDING: the secret is in the browser bundle"; set -- 1 else set -- "$?" # grep's exit status, taken in the else branch, so no named variable holds it case "$1" in 1) echo "clean: secret not found in the bundle"; set -- 0 ;; *) echo "error: grep exited $1, result inconclusive"; set -- 3 ;; esac fi echo "block exit $1; a clean grep is evidence, not proof: an encoded or split value, your other secrets and the Server Action steps below are not checked here, so judge them yourself" exit "$1" ) # a clean grep is evidence not proof (a bundler can split or encode a value); scan for your other secrets too. # Server Action authorization: create a FRESH disposable record before EACH attempt (an authorized mutation # changes the precondition - after a delete, a replay would 'not find' the record and look refused when it # was merely gone). 1) authorized: run it and confirm the store changed (positive control). 2) recreate the # record, replay the SAME request with no cookie, then as an unauthorized user - each must be refused by an # authorization error (not not-found) with NO change to the store (check the store directly, not the UI). ``` ## Common mistakes - Checking the session in `app/layout.tsx` or `proxy.ts` only; a Server Action or `route.ts` outside its matcher coverage is still open. - A Proxy `matcher` that excludes `/api`, with nothing in the Route Handlers themselves. - Naming a secret `NEXT_PUBLIC_*`, or treating Vercel Authentication as production login. ## Sources (checked September 2026) - Next.js authentication guide: https://nextjs.org/docs/app/guides/authentication ; data security guide: https://nextjs.org/docs/app/guides/data-security - Next.js `proxy.js`: https://nextjs.org/docs/app/api-reference/file-conventions/proxy ; `route.js`: https://nextjs.org/docs/app/api-reference/file-conventions/route ; `cookies`: https://nextjs.org/docs/app/api-reference/functions/cookies - Next.js environment variables: https://nextjs.org/docs/app/guides/environment-variables ; CLI (`next start` defaults): https://nextjs.org/docs/app/api-reference/cli/next ; self-hosting: https://nextjs.org/docs/app/guides/self-hosting - Auth.js protecting resources (Route Handler `req.auth` check): https://authjs.dev/getting-started/session-management/protecting - Auth.js installation: https://authjs.dev/getting-started/installation ; deployment (`AUTH_SECRET`, `AUTH_TRUST_HOST`, provider variables): https://authjs.dev/getting-started/deployment ; protecting resources: https://authjs.dev/getting-started/session-management/protecting - Better Auth introduction: https://better-auth.com/docs/introduction ; installation: https://better-auth.com/docs/installation ; options: https://better-auth.com/docs/reference/options ; Next.js integration: https://better-auth.com/docs/integrations/next ; two-factor plugin: https://better-auth.com/docs/plugins/2fa - Vercel Deployment Protection: https://vercel.com/docs/deployment-protection - Vercel changelog, protect production deployments for free on every plan (9 September 2026): https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan - Next.js `next start` declares `-H, --hostname` with help text only and no default value, unlike `--port`'s `.default(3000)` just above it, both under `.command('start')` (pinned tag v16.3.6): https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/bin/next.ts#L436-L458 - Next.js `next start` copies `options.hostname` into `hostname` unchanged (pinned tag v16.3.6): https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/cli/next-start.ts#L44 - Next.js `next start` passes that `hostname` to `startServer({ ... hostname, ... })` (pinned tag v16.3.6): https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/cli/next-start.ts#L84-L87 - Next.js `startServer` destructures `hostname` from `serverOptions` with no default (pinned tag v16.3.6): https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/server/lib/start-server.ts#L187-L196 - Next.js calls `server.listen(port, hostname)` (pinned tag v16.3.6): https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/server/lib/start-server.ts#L309 - Node.js `server.listen()` without a host binds the unspecified IPv6 address `::` when available, otherwise `0.0.0.0`: https://github.com/nodejs/node/blob/v22.22.1/doc/api/net.md - Commander 12.1.0 (the version Next.js v16.3.6 pins): an option that takes a value is `undefined` unless specified on the command line: https://github.com/tj/commander.js/blob/v12.1.0/Readme.md#L203-L205 - Next.js v16.3.6 pins `commander` at `12.1.0` (pinned tag v16.3.6): https://github.com/vercel/next.js/blob/v16.3.6/packages/next/package.json#L246 - curl `--header @-` (header lines read from stdin): https://curl.se/docs/manpage.html#-H . GNU grep `-f -` ("When file is '-', read patterns from standard input"), `-l` ("print the name of each input file from which output would normally have been printed") and its exit status (0 if a line is selected, 1 if none, 2 on error). GNU grep sources: https://www.gnu.org/software/grep/manual/html_node/Matching-Control.html , https://www.gnu.org/software/grep/manual/html_node/General-Output-Control.html and https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - next-pin: Next.js listener source (v16.3.6) - next: Next.js documentation (unknown) - node: Node.js listener source (v22.22.1) - commander: Commander (12.1.0) - authjs: Auth.js (unknown) - better: Better Auth (unknown) - vercel: Vercel documentation (unknown) - curl: curl (unknown) - grep: GNU grep (unknown) Claims (full source mapping in the JSON block below): - entrypoints (REASONED; next): Pages, layouts, handlers and exported Actions are distinct entry points; Actions accept direct POST. Next.js 16 renamed middleware.ts to proxy.ts; session primitives are not login. - start-default (REASONED; next-pin, node, commander): v16.3.6 next start defaults 3000 and no hostname: Node binds :: when available, else 0.0.0.0 despite -H help. Pinned Commander leaves unspecified value options undefined. - private-tls (REASONED; next): Build then next start -H 127.0.0.1 -p 3000 with TLS proxy; PORT cannot be set in .env. Hosted TLS comes from the platform. - dal (REASONED; next): Call DAL verifySession for every data request, handler and Action; authorize resource ownership and return only needed fields. - layouts (REASONED; next): Layouts do not rerender on every navigation; returning null does not stop nested segments or Actions. - proxy (REASONED; next): Proxy is an optimistic cookie-only redirect layer, not sole authorization; avoid database lookups and account for matcher changes. - role (REASONED; next): Handler/action examples independently require admin; sensitive roles come from trusted user storage. Fragments need imports, data calls and real success responses. - session-secret (REASONED; next): Generate a 32-byte random SESSION_SECRET in server environment, never NEXT_PUBLIC_. - session-jwt (REASONED; next): jose session signs HS256 with seven-day expiry; decrypt verifies only HS256 and returns payload/undefined. User ID/role only, no PII; sensitive work uses database-verified sessions. - session-cookie (REASONED; next): Set HttpOnly, Secure, SameSite=Lax, path / and seven-day expiry; only Server Functions/handlers set/delete cookies, with logout deleting session. - public-env (REASONED; next): NEXT_PUBLIC_ is inlined at build; next.config.js env is client-inlined regardless of prefix. Read secrets only in DAL and ignore .env files. - action-key (REASONED; next): Self-hosted instances share NEXT_SERVER_ACTIONS_ENCRYPTION_KEY at build, base64 decoding to 16/24/32 bytes; closure encryption is not secret protection. - action-origin (REASONED; next): Actions abort when Origin mismatches Host/X-Forwarded-Host; proxies can configure experimental.serverActions.allowedOrigins. - authjs-secret (REASONED; authjs): AUTH_SECRET is mandatory; npx auth secret writes .env.local. - authjs-providers (REASONED; authjs): Configure provider ID/SECRET and OIDC ISSUER variables, real providers and linked allowlist checks. - authjs-trust (REASONED; authjs): Set AUTH_TRUST_HOST=true behind a reverse proxy; Vercel sets trust automatically. - authjs-route (REASONED; authjs): auth() reads sessions; wrapped handlers only populate req.auth and must deny missing sessions and failed authorization before data. - authjs-proxy (REASONED; authjs): auth as proxy with empty providers and no authorized callback permits all requests; add callback/wrapper and resource checks. Enforce MFA at the identity provider. - better-secret (REASONED; better): BETTER_AUTH_SECRET needs 32+ characters; production rejects the placeholder default. Set BETTER_AUTH_URL/baseURL explicitly, not request inference. - better-origins (REASONED; better): trustedOrigins scopes origin/CSRF and callbackURL checks, not app-route access; general checks skip GET/HEAD/OPTIONS with endpoint-specific GET callback checks. - better-password (REASONED; better): Better Auth emailAndPassword defaults disabled; enable explicitly when used. - better-next (REASONED; better): nextCookies enables Action session-cookie writes; toNextJsHandler exports auth GET/POST routes. - better-session (REASONED; better): Reject missing getSession before authorization: handler 401, component redirect/throw, action redirect/throw/serializable error. Discarding results or checking cookie existence gates nothing. - better-mfa (REASONED; better): twoFactor makes TOTP, emailed/SMS OTP and backups available, not enrolled/enforced; OTP needs sendOTP, client needs twoFactorClient and migration, operations need separate 2FA checks. - vercel-methods (REASONED; vercel): Deployment Protection admits project-authorized Vercel users; Passport, Password Protection and Trusted IPs need Enterprise/paid add-ons, not app-user authentication. - vercel-scope (REASONED; vercel): Standard Protection excludes production domains; enable All Deployments. Cited 9 September 2026 announcement makes Vercel Authentication production scope free on all plans including Hobby. - verify-bind (REASONED; next): ss inventories loopback 3000; it does not prove firewall/platform ingress isolation. - verify-anonymous (REASONED; next): Anonymous admin/dashboard must contain no protected data: login 3xx, chosen 401/403, or streamed 200 redirect shell require reading saved bodies. - verify-authorized (REASONED; next): Authorized dashboard/admin controls must return real content; non-admin must deny admin data, meaningful only after admin success. - verify-cookie-input (REASONED; curl): Prompt cookie values without echo and send through curl stdin; hidden prompt and set +x keep them from argv, history and tracing, not the account owner or root; assumes clean shell and separate subshell pastes. - verify-secret (REASONED; next, grep): Require .next/static, prompt full literal secret and send to grep stdin: 0 leak, 1 no literal match, other codes inconclusive. Encoded/split secrets can escape. - verify-action (REASONED; next): Fresh disposable record per Action attempt; authorized mutation changes store, anonymous/unauthorized replays deny without changes, not merely not-found. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "b108993995a4c20ab2cc52ad62f6d0153d1eeb233e9f9f1367ccffead5951804", "components": { "next-pin": { "name": "Next.js listener source", "basis": "v16.3.6", "sources": { "s11ffc231f0c3": "https://github.com/vercel/next.js/blob/v16.3.6/packages/next/package.json#L246", "s3b0feef8ec0f": "https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/bin/next.ts#L436-L458", "s0ef9150d51e4": "https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/cli/next-start.ts#L44", "sefdefa3425c6": "https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/cli/next-start.ts#L84-L87", "s66bd2ed9c346": "https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/server/lib/start-server.ts#L187-L196", "s2b4479b3c1d2": "https://github.com/vercel/next.js/blob/v16.3.6/packages/next/src/server/lib/start-server.ts#L309" } }, "next": { "name": "Next.js documentation", "basis": "unknown", "sources": { "s066f8a176a52": "https://nextjs.org/docs/app/api-reference/cli/next", "scd5749a71a79": "https://nextjs.org/docs/app/api-reference/file-conventions/proxy", "sc2a39ce10fe1": "https://nextjs.org/docs/app/api-reference/file-conventions/route", "sfbc4364fb5cc": "https://nextjs.org/docs/app/api-reference/functions/cookies", "sa45045bebccb": "https://nextjs.org/docs/app/guides/authentication", "s23a087193901": "https://nextjs.org/docs/app/guides/data-security", "s964651d4eed4": "https://nextjs.org/docs/app/guides/environment-variables", "s353c30dff99f": "https://nextjs.org/docs/app/guides/self-hosting" } }, "node": { "name": "Node.js listener source", "basis": "v22.22.1", "sources": { "sfb3e306c102d": "https://github.com/nodejs/node/blob/v22.22.1/doc/api/net.md" } }, "commander": { "name": "Commander", "basis": "12.1.0", "sources": { "s408eb603ae2e": "https://github.com/tj/commander.js/blob/v12.1.0/Readme.md#L203-L205" } }, "authjs": { "name": "Auth.js", "basis": "unknown", "sources": { "sb4ac5f50ec66": "https://authjs.dev/getting-started/deployment", "s42365460cb4c": "https://authjs.dev/getting-started/installation", "s46bb80456d4f": "https://authjs.dev/getting-started/session-management/protecting" } }, "better": { "name": "Better Auth", "basis": "unknown", "sources": { "s981751bcb554": "https://better-auth.com/docs/installation", "s2ac2e0926f25": "https://better-auth.com/docs/integrations/next", "s8677a74455a1": "https://better-auth.com/docs/plugins/2fa", "s4b3a514c43d9": "https://better-auth.com/docs/reference/options" } }, "vercel": { "name": "Vercel documentation", "basis": "unknown", "sources": { "s7836e043434a": "https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan", "se6089d5c4e96": "https://vercel.com/docs/deployment-protection" } }, "curl": { "name": "curl", "basis": "unknown", "sources": { "s5345a985007a": "https://curl.se/docs/manpage.html#-H" } }, "grep": { "name": "GNU grep", "basis": "unknown", "sources": { "sea43e82b0753": "https://www.gnu.org/software/grep/manual/html_node/Exit-Status.html", "s2c1251092e66": "https://www.gnu.org/software/grep/manual/html_node/General-Output-Control.html", "sd00017f70dae": "https://www.gnu.org/software/grep/manual/html_node/Matching-Control.html" } } }, "claims": { "entrypoints": {"text": "Pages, layouts, handlers and exported Actions are distinct entry points; Actions accept direct POST. Next.js 16 renamed middleware.ts to proxy.ts; session primitives are not login.", "components": ["next"], "sources": ["next:scd5749a71a79", "next:sc2a39ce10fe1", "next:sa45045bebccb", "next:s23a087193901"], "status": "REASONED"}, "start-default": {"text": "v16.3.6 next start defaults 3000 and no hostname: Node binds :: when available, else 0.0.0.0 despite -H help. Pinned Commander leaves unspecified value options undefined.", "components": ["next-pin", "node", "commander"], "sources": ["next-pin:s3b0feef8ec0f", "next-pin:s0ef9150d51e4", "next-pin:sefdefa3425c6", "next-pin:s66bd2ed9c346", "next-pin:s2b4479b3c1d2", "node:sfb3e306c102d", "commander:s408eb603ae2e", "next-pin:s11ffc231f0c3"], "status": "REASONED"}, "private-tls": {"text": "Build then next start -H 127.0.0.1 -p 3000 with TLS proxy; PORT cannot be set in .env. Hosted TLS comes from the platform.", "components": ["next"], "sources": ["next:s066f8a176a52", "next:s353c30dff99f"], "status": "REASONED"}, "dal": {"text": "Call DAL verifySession for every data request, handler and Action; authorize resource ownership and return only needed fields.", "components": ["next"], "sources": ["next:sa45045bebccb", "next:s23a087193901"], "status": "REASONED"}, "layouts": {"text": "Layouts do not rerender on every navigation; returning null does not stop nested segments or Actions.", "components": ["next"], "sources": ["next:sa45045bebccb", "next:s23a087193901"], "status": "REASONED"}, "proxy": {"text": "Proxy is an optimistic cookie-only redirect layer, not sole authorization; avoid database lookups and account for matcher changes.", "components": ["next"], "sources": ["next:scd5749a71a79", "next:sa45045bebccb"], "status": "REASONED"}, "role": {"text": "Handler/action examples independently require admin; sensitive roles come from trusted user storage. Fragments need imports, data calls and real success responses.", "components": ["next"], "sources": ["next:sa45045bebccb", "next:s23a087193901"], "status": "REASONED"}, "session-secret": {"text": "Generate a 32-byte random SESSION_SECRET in server environment, never NEXT_PUBLIC_.", "components": ["next"], "sources": ["next:sa45045bebccb", "next:s964651d4eed4"], "status": "REASONED"}, "session-jwt": {"text": "jose session signs HS256 with seven-day expiry; decrypt verifies only HS256 and returns payload/undefined. User ID/role only, no PII; sensitive work uses database-verified sessions.", "components": ["next"], "sources": ["next:sa45045bebccb"], "status": "REASONED"}, "session-cookie": {"text": "Set HttpOnly, Secure, SameSite=Lax, path / and seven-day expiry; only Server Functions/handlers set/delete cookies, with logout deleting session.", "components": ["next"], "sources": ["next:sfbc4364fb5cc", "next:sa45045bebccb"], "status": "REASONED"}, "public-env": {"text": "NEXT_PUBLIC_ is inlined at build; next.config.js env is client-inlined regardless of prefix. Read secrets only in DAL and ignore .env files.", "components": ["next"], "sources": ["next:s964651d4eed4", "next:s23a087193901"], "status": "REASONED"}, "action-key": {"text": "Self-hosted instances share NEXT_SERVER_ACTIONS_ENCRYPTION_KEY at build, base64 decoding to 16/24/32 bytes; closure encryption is not secret protection.", "components": ["next"], "sources": ["next:s353c30dff99f", "next:s23a087193901"], "status": "REASONED"}, "action-origin": {"text": "Actions abort when Origin mismatches Host/X-Forwarded-Host; proxies can configure experimental.serverActions.allowedOrigins.", "components": ["next"], "sources": ["next:s23a087193901", "next:s353c30dff99f"], "status": "REASONED"}, "authjs-secret": {"text": "AUTH_SECRET is mandatory; npx auth secret writes .env.local.", "components": ["authjs"], "sources": ["authjs:sb4ac5f50ec66", "authjs:s42365460cb4c"], "status": "REASONED"}, "authjs-providers": {"text": "Configure provider ID/SECRET and OIDC ISSUER variables, real providers and linked allowlist checks.", "components": ["authjs"], "sources": ["authjs:sb4ac5f50ec66", "authjs:s42365460cb4c"], "status": "REASONED"}, "authjs-trust": {"text": "Set AUTH_TRUST_HOST=true behind a reverse proxy; Vercel sets trust automatically.", "components": ["authjs"], "sources": ["authjs:sb4ac5f50ec66"], "status": "REASONED"}, "authjs-route": {"text": "auth() reads sessions; wrapped handlers only populate req.auth and must deny missing sessions and failed authorization before data.", "components": ["authjs"], "sources": ["authjs:s46bb80456d4f"], "status": "REASONED"}, "authjs-proxy": {"text": "auth as proxy with empty providers and no authorized callback permits all requests; add callback/wrapper and resource checks. Enforce MFA at the identity provider.", "components": ["authjs"], "sources": ["authjs:s46bb80456d4f"], "status": "REASONED"}, "better-secret": {"text": "BETTER_AUTH_SECRET needs 32+ characters; production rejects the placeholder default. Set BETTER_AUTH_URL/baseURL explicitly, not request inference.", "components": ["better"], "sources": ["better:s981751bcb554", "better:s4b3a514c43d9"], "status": "REASONED"}, "better-origins": {"text": "trustedOrigins scopes origin/CSRF and callbackURL checks, not app-route access; general checks skip GET/HEAD/OPTIONS with endpoint-specific GET callback checks.", "components": ["better"], "sources": ["better:s4b3a514c43d9"], "status": "REASONED"}, "better-password": {"text": "Better Auth emailAndPassword defaults disabled; enable explicitly when used.", "components": ["better"], "sources": ["better:s4b3a514c43d9"], "status": "REASONED"}, "better-next": {"text": "nextCookies enables Action session-cookie writes; toNextJsHandler exports auth GET/POST routes.", "components": ["better"], "sources": ["better:s2ac2e0926f25"], "status": "REASONED"}, "better-session": {"text": "Reject missing getSession before authorization: handler 401, component redirect/throw, action redirect/throw/serializable error. Discarding results or checking cookie existence gates nothing.", "components": ["better"], "sources": ["better:s2ac2e0926f25"], "status": "REASONED"}, "better-mfa": {"text": "twoFactor makes TOTP, emailed/SMS OTP and backups available, not enrolled/enforced; OTP needs sendOTP, client needs twoFactorClient and migration, operations need separate 2FA checks.", "components": ["better"], "sources": ["better:s8677a74455a1"], "status": "REASONED"}, "vercel-methods": {"text": "Deployment Protection admits project-authorized Vercel users; Passport, Password Protection and Trusted IPs need Enterprise/paid add-ons, not app-user authentication.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96"], "status": "REASONED"}, "vercel-scope": {"text": "Standard Protection excludes production domains; enable All Deployments. Cited 9 September 2026 announcement makes Vercel Authentication production scope free on all plans including Hobby.", "components": ["vercel"], "sources": ["vercel:s7836e043434a", "vercel:se6089d5c4e96"], "status": "REASONED"}, "verify-bind": {"text": "ss inventories loopback 3000; it does not prove firewall/platform ingress isolation.", "components": ["next"], "sources": ["next:s066f8a176a52", "next:s353c30dff99f"], "status": "REASONED", "verify": [1]}, "verify-anonymous": {"text": "Anonymous admin/dashboard must contain no protected data: login 3xx, chosen 401/403, or streamed 200 redirect shell require reading saved bodies.", "components": ["next"], "sources": ["next:sa45045bebccb"], "status": "REASONED", "verify": [2]}, "verify-authorized": {"text": "Authorized dashboard/admin controls must return real content; non-admin must deny admin data, meaningful only after admin success.", "components": ["next"], "sources": ["next:sa45045bebccb"], "status": "REASONED", "verify": [3]}, "verify-cookie-input": {"text": "Prompt cookie values without echo and send through curl stdin; hidden prompt and set +x keep them from argv, history and tracing, not the account owner or root; assumes clean shell and separate subshell pastes.", "components": ["curl"], "sources": ["curl:s5345a985007a"], "status": "REASONED", "verify": [3]}, "verify-secret": {"text": "Require .next/static, prompt full literal secret and send to grep stdin: 0 leak, 1 no literal match, other codes inconclusive. Encoded/split secrets can escape.", "components": ["next", "grep"], "sources": ["next:s964651d4eed4", "grep:sea43e82b0753", "grep:s2c1251092e66", "grep:sd00017f70dae"], "status": "REASONED", "verify": [4]}, "verify-action": {"text": "Fresh disposable record per Action attempt; authorized mutation changes store, anonymous/unauthorized replays deny without changes, not merely not-found.", "components": ["next"], "sources": ["next:sa45045bebccb", "next:s23a087193901"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> go.md ====================================================================== # Go: TLS and authentication with net/http Go is a programming language whose standard `net/http` package can serve web apps and APIs. This guide helps your AI assistant deploy a Go service securely, including a loopback bind behind a TLS proxy or HTTPS in Go itself, authentication, private diagnostic endpoints, and request limits with server timeouts. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/go.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Go certificate-store qualification 1.27, Go header-count qualification 1.27, Go per-request deadline minimum 1.20+, Go Request.Host source 1.27.0, Go environment source go1.27.0, go-oidc documentation v3, pquerna/otp v1.5.0, and Authelia source v4.39.28; 1 further source is pinned to a commit and 8 carry no recorded version. Metadata reviewed 2026-10-19; documentation checked September 2026. The guide records 38 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Preferred production layout: bind the Go server to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) or behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). `net/http` can also terminate TLS itself, shown below. Certificates: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). ## 1. HTTPS directly in Go ```go const canonicalHost = "app.example.com" // r.Host comes from the client; never reflect it in the redirect. go http.ListenAndServe(":80", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // port 80 only redirects target := r.URL.RequestURI() // Opaque or absolute-form targets can yield non-path values; only append an origin-form path. if len(target) == 0 || target[0] != '/' { target = "/" } http.Redirect(w, r, "https://"+canonicalHost+target, http.StatusMovedPermanently) })) srv := &http.Server{ Addr: ":443", Handler: mux, ReadHeaderTimeout: 10 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, } log.Fatal(srv.ListenAndServeTLS("/etc/ssl/certs/server.crt", "/etc/ssl/private/server.key")) // cert file: leaf, then intermediates ``` Set `canonicalHost` to your canonical hostname (no scheme or path) and append only an origin-form path (falling back to `/` for non-path values from opaque or absolute-form targets), so neither a forged Host nor a crafted request target can turn this into an open redirect. crypto/tls already defaults to a TLS 1.2 minimum (as of September 2026); setting `MinVersion` keeps that true when a config is copied or a default shifts. Binding ports below 1024 needs root or `CAP_NET_BIND_SERVICE` on Linux's usual configuration (the per-namespace `net.ipv4.ip_unprivileged_port_start` defaults to 1024 but is configurable), one more reason to prefer the proxy layout. ## 2. Behind a proxy ```go srv := &http.Server{Addr: "127.0.0.1:8080", Handler: mux, ReadHeaderTimeout: 10 * time.Second} log.Fatal(srv.ListenAndServe()) ``` `http.Server` has no trusted-proxy setting: `r.RemoteAddr` is the proxy, and `X-Forwarded-For` and `X-Forwarded-Proto` are ordinary request headers that anyone who can reach the port could set. Read them only when the listener is loopback-only and the proxy overwrites them, and set `Strict-Transport-Security` and the other headers at the proxy per [headers.md](https://github.com/jposluns/secureconfig/blob/main/headers.md). ## 3. Authentication Follow [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Password hashing with `golang.org/x/crypto/bcrypt`: ```go hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) // DefaultCost is 10; input above 72 bytes is rejected err = bcrypt.CompareHashAndPassword(hash, []byte(password)) // nil on match ``` `golang.org/x/crypto/argon2` is the alternative: `argon2.IDKey(password, salt, time, memory, threads, keyLen)` returns raw bytes, so you store the random salt and the parameters next to the result. Its documentation carries the RFC 9106 parameter sets (`time=1, memory=2 GiB, threads=4`, or `time=3, memory=64 MiB, threads=4` where memory is short). Session cookie with the flags set: ```go http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) ``` Rate-limit the login handler with `golang.org/x/time/rate` (one limiter here; key a map of limiters by the proxy-supplied client address for per-client limits): ```go var loginLimiter = rate.NewLimiter(rate.Every(3*time.Second), 5) // about 20 per minute, burst 5 if !loginLimiter.Allow() { http.Error(w, "too many requests", http.StatusTooManyRequests); return } ``` Guide advice: API keys and tokens come from the environment, never from literals in the source; generate them per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Go go1.27.0 documents [`os.Getenv(key)` and `os.LookupEnv(key)`](https://github.com/golang/go/blob/go1.27.0/src/os/env.go#L98-L115): `Getenv` returns the value, or an empty string when unset; `LookupEnv` returns the value (possibly empty) and `true` when present, or an empty string and `false` when unset. If configuration needs to distinguish an unset token from an empty one, use the boolean returned by `LookupEnv`. These APIs load values; token generation stays delegated to [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). SSO: `github.com/coreos/go-oidc/v3/oidc` pairs with `golang.org/x/oauth2`; `oidc.NewProvider(ctx, issuer)` discovers the provider and `provider.Verifier(&oidc.Config{ClientID: clientID})` checks ID token signature, issuer, audience, and expiry. The allowlist checks that follow are in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md). MFA: this guide advises app-level TOTP with [pquerna/otp](https://github.com/pquerna/otp) (v1.5.0), or a fronting identity layer such as Cloudflare Access or Authelia; requirements in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Cloudflare Access [independent MFA](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24) can require a TOTP authenticator or WebAuthn security key after IdP login; its [MFA settings](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85) require organization-level enablement before application or policy configuration, and policy settings override application settings, which override organization settings. Authelia v4.39.28 [passkey login](https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/webauthn.md#L69-L75) counts as a single factor and, by default, prompts for a password when the request requires MFA. Guide advice: require MFA in the effective policy and test that access without the second factor is denied; these citations do not establish MFA enforcement by default or enforcement in your Go deployment. ## 4. Client-side TLS discipline Never set `InsecureSkipVerify: true`; the crypto/tls docs state it accepts any certificate and any host name, a machine-in-the-middle position unless you supply your own checks through `VerifyConnection` or `VerifyPeerCertificate` (which is not a deployment pattern to reach for casually). For an internal CA, trust the CA instead (see [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) for producing the file): `SSL_CERT_FILE=/path/ca.crt` (or `SSL_CERT_DIR`) overrides the system locations that `x509.SystemCertPool` reads, or add it in code: ```go pool, err := x509.SystemCertPool() if err != nil { log.Fatal(err) } pem, err := os.ReadFile("/path/ca.crt") if err != nil { log.Fatal(err) } if !pool.AppendCertsFromPEM(pem) { log.Fatal("no certificate parsed from /path/ca.crt") } client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}} ``` On Go 1.27, setting `SSL_CERT_FILE` or `SSL_CERT_DIR` bypasses the platform certificate-verification APIs on macOS and Windows unless you also set `GODEBUG=x509sslcertoverrideplatform=0`. ## 5. Keep diagnostic endpoints private Importing `net/http/pprof` for its side effect registers `/debug/pprof/` (profiles, traces, `cmdline`, and requests that force a GC) on `http.DefaultServeMux`, and `expvar` registers `/debug/vars` (exposing `cmdline` and `memstats`) the same way. Neither package starts a listener by itself, but `http.ListenAndServe(addr, nil)`, or any server with a nil handler, serves the default mux, so a single stray import turns a public port into an information-disclosure and resource-exhaustion surface. Give the application its own mux and never mount diagnostics on it: ```go mux := http.NewServeMux() // the app mux; assign it to Server.Handler, not the default mux // ... register application routes on mux ... ``` If you need pprof or expvar, register them on a separate mux and serve it on loopback only, never through the public proxy: ```go diag := http.NewServeMux() diag.HandleFunc("/debug/pprof/", pprof.Index) // import "net/http/pprof"; Index serves the named runtime profiles but not profile/trace/cmdline/symbol diag.HandleFunc("/debug/pprof/profile", pprof.Profile) diag.HandleFunc("/debug/pprof/trace", pprof.Trace) diag.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) diag.HandleFunc("/debug/pprof/symbol", pprof.Symbol) diag.Handle("/debug/vars", expvar.Handler()) go func() { log.Fatal((&http.Server{Addr: "127.0.0.1:6060", Handler: diag, ReadHeaderTimeout: 10 * time.Second}).ListenAndServe()) }() ``` ## 6. Bound request resources Cap request bodies per endpoint so a large or slow upload cannot exhaust memory. `http.MaxBytesReader` wraps the body and makes reads past the limit fail; it does not send the 413 for you, so handle the error: ```go r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB example policy, choose per endpoint dec := json.NewDecoder(r.Body) // build the decoder AFTER wrapping, or the cap is not applied if err := dec.Decode(&v); err != nil { // v is the endpoint's destination value var mbe *http.MaxBytesError if errors.As(err, &mbe) { http.Error(w, "request body too large", http.StatusRequestEntityTooLarge); return } http.Error(w, "bad request", http.StatusBadRequest); return } ``` Header size is a separate control: `Server.MaxHeaderBytes` defaults to `1 << 20` (1 MiB, covering the request line and headers, not the body), and Go 1.27 adds `Server.MaxHeaderValueCount` (default 500) to bound the number of header values. Neither substitutes for the body limit above; set the body limit where you read untrusted input. ## 7. Set server deadlines The `ReadHeaderTimeout` in the examples above bounds only header reading; add the rest of the `http.Server` deadlines so a slow body, a slow reader, or an idle keep-alive connection cannot hold a goroutine open: ```go srv := &http.Server{ Addr: "127.0.0.1:8080", Handler: mux, ReadHeaderTimeout: 10 * time.Second, // slow-header / Slowloris ReadTimeout: 30 * time.Second, // whole request incl. body WriteTimeout: 60 * time.Second, // slow readers IdleTimeout: 60 * time.Second, // keep-alive between requests } ``` These are a starting policy for short, bounded requests, not Go defaults. For `ReadTimeout` and `WriteTimeout`, a zero or negative value disables that timeout; for `ReadHeaderTimeout` and `IdleTimeout`, zero instead falls back to `ReadTimeout`, so a negative value disables them, as does zero when `ReadTimeout` is itself zero or negative. Give the port-80 redirect server the same deadlines (the `http.ListenAndServe` helper sets none). For genuinely long uploads or streaming, keep the server defaults generous and set a per-request budget with `http.NewResponseController(w)` (`SetReadDeadline`/`SetWriteDeadline`, Go 1.20+), handling the unsupported-operation error. ## 8. Security headers when Go terminates TLS Section 2 sets `Strict-Transport-Security` and the rest at the proxy. When Go terminates its own TLS instead, set them in a middleware wrapper that runs before the handler commits the response, and send HSTS only over TLS: ```go func secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") if r.TLS != nil { w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") } next.ServeHTTP(w, r) }) } ``` Defer the full header catalogue and values to [headers.md](https://github.com/jposluns/secureconfig/blob/main/headers.md); do not send HSTS over plain HTTP. Install it with `srv.Handler = secure(mux)` (or wrap your router); defining the wrapper alone adds no headers. (Go enables HTTP/2 automatically for HTTPS servers, and a `Server.HTTP2` field (`HTTP2Config`) tunes it on current releases, but leave the maintained cipher and curve defaults and the TLS 1.2 minimum from section 1 alone unless you have a specific requirement.) ## 9. Verify (REASONED: TLS, authentication, listener and diagnostic expectations follow the Go Sources below; no deployment run or outcome is recorded in this guide. This metadata-only review has no deployed Go service, proxy or outside-network probe host.) ```bash ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a check that ends nonzero is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' # the deployment's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (ss is missing, recorded only while nothing else was), which outranks 0 curl -q -g -sI "http://$1/" # expect 301 with a https:// Location set -- "$1" "$2" "$?" # the 3rd value is that request's exit status [ "$3" = 0 ] || { echo "curl exit $3: the redirect request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } curl -q -g -sI "https://$1/" # succeeds without -k set -- "$1" "$2" "$?" # the 3rd value is that request's exit status [ "$3" = 0 ] || { echo "curl exit $3: the HTTPS request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } set -- "$1" "$2" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' "https://$1/api"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" # 401 or 403 without credentials case "$3:$4" in 0:401|0:403) ;; 0:2??) echo "FINDING: /api answered $4 without credentials"; set -- "$1" 1 ;; 0:*) echo "HTTP $4 from /api is neither the 401 or 403 pass nor a 2xx finding; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the /api request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac if command -v ss >/dev/null; then ss -tlnp # read every listener; REPLACE_WITH_BINARY_NAME: behind a proxy: 127.0.0.1 only set -- "$1" "$2" "$?" # the 3rd value is ss's exit status [ "$3" = 0 ] || { echo "ss -tlnp exit $3: the listener list failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } else echo "ss is not installed here; not listing sockets" case "$2" in 0) set -- "$1" 2 ;; esac # the refusal is the block's status only while no other part found something or was inconclusive fi set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' http://127.0.0.1:6060/debug/pprof/; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" # local availability only: 200 here just means you enabled a diagnostic listener (section 5). What matters is off-host reachability, checked next. case "$3" in 0) ;; 7) echo "curl exit 7: nothing answers on loopback port 6060 at 127.0.0.1; the listener list above, if one was printed, shows any 6060 listener on another address" ;; *) echo "curl exit $3: the local diagnostic request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' "https://$1/debug/pprof/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" # diagnostics must never be routed on your PUBLIC app: expect 404, never 200 case "$3:$4" in 0:404) ;; 0:200) echo "FINDING: /debug/pprof/ answered 200 on your public app"; set -- "$1" 1 ;; 0:*) echo "HTTP $4 from /debug/pprof/ is neither the 404 pass nor the 200 finding; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the /debug/pprof/ request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the redirect and HTTPS headers, any listener list printed above and the local diagnostic answer are not classified, so judge them yourself" exit "$2" ) ``` REASONED: the missing-`ss` handling in the block above, per CONTRIBUTING rule 6 and the block's own text. When `command -v ss` finds no `ss`, the block prints `ss is not installed here; not listing sockets`, skips the listener list and still runs its other probes. That refusal makes the block's status 2 only while no other part has found something or been inconclusive, so a finding still exits 1 and an inconclusive part 3, whether it comes before or after the refusal, and a block that would otherwise pass exits 2, never 0. A `ss` that runs and fails is inconclusive and records 3. ```bash ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so the probe's status is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_SERVER_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the server's public address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w 'diag_6060 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:6060/debug/pprof/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # run from a machine OUTSIDE the server's network: the diagnostic listener (section 5) must be UNREACHABLE (a refusal or timeout, never a 200) set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # curl's exit status, then its diag_6060 line printf '%s\n' "$3" case "$3" in 'diag_6060 http=000 '*) case "$2" in 7|28) set -- "$1" 0 ;; # a refusal or a timeout: the pass *) echo "curl exit $2 is neither a refusal nor a timeout; inconclusive"; set -- "$1" 3 ;; esac ;; *) echo "FINDING: the diagnostic listener answered HTTP from outside, so it is reachable"; set -- "$1" 1 ;; esac echo "block exit $2; the err text is not classified: a refusal or timeout passes only if err names the server's address, not a local socket or resolver error, so judge it yourself" exit "$2" ) ``` ## Sources (checked September 2026) - net/http (Server, ListenAndServeTLS, Cookie, SameSite, Redirect, Transport.TLSClientConfig): https://pkg.go.dev/net/http - net/http Request.Host (Go 1.27.0, supplied by the client): https://pkg.go.dev/net/http@go1.27.0#Request - crypto/tls (Config.MinVersion, InsecureSkipVerify, RootCAs): https://pkg.go.dev/crypto/tls - crypto/x509 (SystemCertPool, SSL_CERT_FILE, AppendCertsFromPEM) (Go 1.27): https://pkg.go.dev/crypto/x509@go1.27.0 - golang.org/x/crypto/bcrypt: https://pkg.go.dev/golang.org/x/crypto/bcrypt - golang.org/x/crypto/argon2: https://pkg.go.dev/golang.org/x/crypto/argon2 - golang.org/x/time/rate: https://pkg.go.dev/golang.org/x/time/rate - go-oidc: https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc - net/http/pprof and expvar register on DefaultServeMux: https://pkg.go.dev/net/http/pprof - net/http MaxBytesReader, MaxBytesError, Server.MaxHeaderBytes / MaxHeaderValueCount (Go 1.27): https://pkg.go.dev/net/http@go1.27.0#MaxBytesReader - net/http Server timeouts (ReadTimeout, WriteTimeout, IdleTimeout, ReadHeaderTimeout): https://pkg.go.dev/net/http#Server - net/http ResponseController (per-request deadlines) (Go 1.20+): https://pkg.go.dev/net/http#ResponseController - expvar (registers /debug/vars on the default mux): https://pkg.go.dev/expvar - Linux ip-sysctl, ip_unprivileged_port_start (the privileged-port threshold is configurable): https://docs.kernel.org/networking/ip-sysctl.html#ip-unprivileged-port-start - pquerna/otp TOTP support (v1.5.0, checked October 2026): https://github.com/pquerna/otp/blob/v1.5.0/README.md#L9 - Go environment loading (`go1.27.0`, checked October 2026), Getenv and LookupEnv including unset and empty values; token generation remains delegated to authentication.md: https://github.com/golang/go/blob/go1.27.0/src/os/env.go#L98-L115 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, independent MFA and TOTP/WebAuthn factors (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, MFA enforcement, policy precedence and organization-level prerequisite (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85 - Authelia source `v4.39.28`, single-factor passkey login and password prompt when a request requires MFA (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/webauthn.md#L69-L75 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - x509: Go certificate-store qualification (1.27) - headers: Go header-count qualification (1.27) - deadlines: Go per-request deadline minimum (1.20+) - request: Go Request.Host source (1.27.0) - go-env: Go environment source (go1.27.0) - http: Go net/http documentation (unknown) - tls: Go crypto/tls documentation (unknown) - bcrypt: Go bcrypt documentation (unknown) - argon: Go Argon2 documentation (unknown) - rate: Go rate documentation (unknown) - pprof: Go pprof documentation (unknown) - expvar: Go expvar documentation (unknown) - oidc: go-oidc documentation (v3) - otp: pquerna/otp (v1.5.0) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - authelia-source: Authelia source (v4.39.28) - linux: Linux port-threshold documentation (unknown) Claims (full source mapping in the JSON block below): - tls (REASONED; http, tls): Direct TLS listens on :443 using the leaf-plus-intermediates certificate and private key; ReadHeaderTimeout is 10 seconds. - redirect (REASONED; http, request): Port :80 sends a 301 to https:// plus the fixed canonicalHost and an origin-form request path; empty or non-path RequestURI values fall back to /. Never build the destination from r.Host. - tls-min (REASONED; tls): crypto/tls defaults to TLS 1.2 minimum as of September 2026; explicitly set MinVersion=VersionTLS12. - privilege (REASONED; linux): Linux privileged-port threshold defaults to 1024 but is configurable per namespace; root or CAP_NET_BIND_SERVICE is needed below it in the usual configuration. - proxy (REASONED; http): Bind 127.0.0.1:8080 behind a TLS proxy; net/http has no proxy trust setting, so accept forwarded headers only from the isolated, overwriting proxy. - proxy-headers (REASONED; http): Set HSTS and other security headers at the proxy per the linked headers guide. - bcrypt (REASONED; bcrypt): Use bcrypt cost 12; DefaultCost is 10, inputs over 72 bytes are rejected and CompareHashAndPassword returns nil on match. - argon (REASONED; argon): IDKey returns raw bytes; retain random salt and parameters. RFC 9106 choices are time=1/memory=2 GiB/threads=4 or time=3/memory=64 MiB/threads=4. - cookies (REASONED; http): Set session cookie Path=/, Secure, HttpOnly and SameSite=Lax. - login-limit (REASONED; rate): A limiter every three seconds with burst five allows about 20/minute; use separate limiters keyed by trusted client address for per-client limits. - tokens (REASONED; oidc, go-env): Guide advice: load tokens from the environment, never source literals, and generate them per authentication.md; generation stays delegated. Go go1.27.0 Getenv returns an empty string when unset or empty; use LookupEnv's boolean to distinguish them (false when unset, true when present). - oidc (REASONED; oidc): NewProvider discovers the issuer; Verifier with ClientID checks ID-token signature, issuer, audience and expiry; apply separate allowlists. - mfa (REASONED; oidc, otp, cf-docs, authelia-source): Guide advice: use app-level TOTP with pquerna/otp v1.5.0 or a fronting layer such as Cloudflare Access or Authelia; follow mfa.md. Configured Access policies or Authelia's MFA-required passkey flow can add a factor; the citations do not establish default MFA or deployed enforcement. - client-validation (REASONED; tls): InsecureSkipVerify accepts any certificate and hostname unless custom VerifyConnection/VerifyPeerCertificate checks replace validation. - client-ca (REASONED; tls, x509): Use SystemCertPool plus a checked AppendCertsFromPEM result and RootCAs, or override CA locations with SSL_CERT_FILE/SSL_CERT_DIR. - platform-ca (REASONED; x509): Go 1.27 certificate-file/directory overrides bypass macOS/Windows platform verification unless GODEBUG=x509sslcertoverrideplatform=0 is also set. - pprof (REASONED; pprof): Importing pprof registers /debug/pprof/ on DefaultServeMux; profiles, traces, cmdline and forced GC can disclose data or exhaust resources. - expvar (REASONED; expvar): Importing expvar registers /debug/vars with cmdline and memstats on DefaultServeMux. - diag-bind (REASONED; http, pprof, expvar): Imports do not start listeners; nil handlers serve DefaultServeMux. Give the app its own mux and diagnostics a separate 127.0.0.1:6060 mux, never public proxy routing. - body-limit (REASONED; headers): Wrap the body with MaxBytesReader before decoding; the example 1 MiB policy requires explicit MaxBytesError handling to return 413. - header-size (REASONED; http): MaxHeaderBytes defaults to 1 MiB for request line and headers, independently of body limits. - header-count (REASONED; headers): Go 1.27 adds MaxHeaderValueCount with default 500; this is separate from body limits. - read-timeout (REASONED; http): ReadTimeout bounds the whole request including body; example 30 seconds, zero or negative disables it. - write-timeout (REASONED; http): WriteTimeout bounds writing; example 60 seconds, zero or negative disables it. - header-timeout (REASONED; http): ReadHeaderTimeout bounds headers; example 10 seconds, zero falls back to ReadTimeout and negative disables it. - idle-timeout (REASONED; http): IdleTimeout bounds keep-alive idle time; example 60 seconds, zero falls back to ReadTimeout and negative disables it. - redirect-timeout (REASONED; http): Apply deadlines to the redirect server too; ListenAndServe sets none. - request-deadlines (REASONED; deadlines): For long uploads/streaming, use generous server budgets and Go 1.20+ ResponseController read/write deadlines, handling unsupported operations. - headers (REASONED; http): Install secure(mux) before response commit for nosniff and TLS-only HSTS max-age=63072000 with includeSubDomains. - http2 (REASONED; http, tls): HTTPS enables HTTP/2 automatically; Server.HTTP2 tunes it on current releases; retain maintained cipher/curve defaults. - verify-redirect (REASONED; http): HTTP should return 301 with an HTTPS Location. - verify-tls (REASONED; http, tls): HTTPS must succeed without -k. - verify-auth (REASONED; http): The guide expects /api to return 401/403 without credentials; it records no valid-credential positive control. - verify-bind (REASONED; http): Inspect every listener for loopback-only application binding behind the proxy. - verify-diag-local (REASONED; pprof): A local 6060/debug/pprof/ response of 200 establishes local availability only. - verify-diag-route (REASONED; http, pprof): The public application /debug/pprof/ route should return 404, never 200. - verify-diag-external (REASONED; http, pprof): From outside the server network, the actual public IP on 6060 must be unreachable; the guide expects refusal or timeout, never 200. - request-host (REASONED; request): Go 1.27.0 Request.Host is supplied by the client; a fixed canonical hostname plus an origin-form path prevents forged Host or crafted request targets from selecting the redirect destination. ````json { "schema": 1, "checked": "2026-10-19", "documentation_checked": "2026-09", "body_sha256": "5c4b33e6d16d118aaf133645b369cb3b46099ff732119bf81755e5b05f7b12a5", "components": { "x509": { "name": "Go certificate-store qualification", "basis": "1.27", "sources": { "se65b937f9e1e": "https://pkg.go.dev/crypto/x509@go1.27.0" } }, "headers": { "name": "Go header-count qualification", "basis": "1.27", "sources": { "sa4a9473fc6f8": "https://pkg.go.dev/net/http@go1.27.0#MaxBytesReader" } }, "deadlines": { "name": "Go per-request deadline minimum", "basis": "1.20+", "sources": { "s251e0db38b90": "https://pkg.go.dev/net/http#ResponseController" } }, "request": { "name": "Go Request.Host source", "basis": "1.27.0", "sources": { "sfdeabbeca746": "https://pkg.go.dev/net/http@go1.27.0#Request" } }, "go-env": { "name": "Go environment source", "basis": "go1.27.0", "sources": { "s41d67ab4a28d": "https://github.com/golang/go/blob/go1.27.0/src/os/env.go#L98-L115" } }, "http": { "name": "Go net/http documentation", "basis": "unknown", "sources": { "s75405f80decc": "https://pkg.go.dev/net/http", "s6ab9e32b7795": "https://pkg.go.dev/net/http#Server" } }, "tls": { "name": "Go crypto/tls documentation", "basis": "unknown", "sources": { "se2152a9c28fc": "https://pkg.go.dev/crypto/tls" } }, "bcrypt": { "name": "Go bcrypt documentation", "basis": "unknown", "sources": { "s99bb1157593c": "https://pkg.go.dev/golang.org/x/crypto/bcrypt" } }, "argon": { "name": "Go Argon2 documentation", "basis": "unknown", "sources": { "s95fa46eac674": "https://pkg.go.dev/golang.org/x/crypto/argon2" } }, "rate": { "name": "Go rate documentation", "basis": "unknown", "sources": { "s5c3fdae5a0af": "https://pkg.go.dev/golang.org/x/time/rate" } }, "pprof": { "name": "Go pprof documentation", "basis": "unknown", "sources": { "s0246034dddd1": "https://pkg.go.dev/net/http/pprof" } }, "expvar": { "name": "Go expvar documentation", "basis": "unknown", "sources": { "se49529035a0c": "https://pkg.go.dev/expvar" } }, "oidc": { "name": "go-oidc documentation", "basis": "v3", "sources": { "s60cc1d7a8fe6": "https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc" } }, "otp": { "name": "pquerna/otp", "basis": "v1.5.0", "sources": { "scdd6f451714e": "https://github.com/pquerna/otp/blob/v1.5.0/README.md#L9" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24", "s6f6104362214": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85" } }, "authelia-source": { "name": "Authelia source", "basis": "v4.39.28", "sources": { "s38726e46de86": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/webauthn.md#L69-L75" } }, "linux": { "name": "Linux port-threshold documentation", "basis": "unknown", "sources": { "s12e5a0911ec2": "https://docs.kernel.org/networking/ip-sysctl.html#ip-unprivileged-port-start" } } }, "claims": { "tls": {"text": "Direct TLS listens on :443 using the leaf-plus-intermediates certificate and private key; ReadHeaderTimeout is 10 seconds.", "components": ["http", "tls"], "sources": ["http:s75405f80decc", "tls:se2152a9c28fc"], "status": "REASONED"}, "redirect": {"text": "Port :80 sends a 301 to https:// plus the fixed canonicalHost and an origin-form request path; empty or non-path RequestURI values fall back to /. Never build the destination from r.Host.", "components": ["http", "request"], "sources": ["http:s75405f80decc", "request:sfdeabbeca746"], "status": "REASONED"}, "tls-min": {"text": "crypto/tls defaults to TLS 1.2 minimum as of September 2026; explicitly set MinVersion=VersionTLS12.", "components": ["tls"], "sources": ["tls:se2152a9c28fc"], "status": "REASONED"}, "privilege": {"text": "Linux privileged-port threshold defaults to 1024 but is configurable per namespace; root or CAP_NET_BIND_SERVICE is needed below it in the usual configuration.", "components": ["linux"], "sources": ["linux:s12e5a0911ec2"], "status": "REASONED"}, "proxy": {"text": "Bind 127.0.0.1:8080 behind a TLS proxy; net/http has no proxy trust setting, so accept forwarded headers only from the isolated, overwriting proxy.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED"}, "proxy-headers": {"text": "Set HSTS and other security headers at the proxy per the linked headers guide.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED"}, "bcrypt": {"text": "Use bcrypt cost 12; DefaultCost is 10, inputs over 72 bytes are rejected and CompareHashAndPassword returns nil on match.", "components": ["bcrypt"], "sources": ["bcrypt:s99bb1157593c"], "status": "REASONED"}, "argon": {"text": "IDKey returns raw bytes; retain random salt and parameters. RFC 9106 choices are time=1/memory=2 GiB/threads=4 or time=3/memory=64 MiB/threads=4.", "components": ["argon"], "sources": ["argon:s95fa46eac674"], "status": "REASONED"}, "cookies": {"text": "Set session cookie Path=/, Secure, HttpOnly and SameSite=Lax.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED"}, "login-limit": {"text": "A limiter every three seconds with burst five allows about 20/minute; use separate limiters keyed by trusted client address for per-client limits.", "components": ["rate"], "sources": ["rate:s5c3fdae5a0af"], "status": "REASONED"}, "tokens": {"text": "Guide advice: load tokens from the environment, never source literals, and generate them per authentication.md; generation stays delegated. Go go1.27.0 Getenv returns an empty string when unset or empty; use LookupEnv's boolean to distinguish them (false when unset, true when present).", "components": ["oidc", "go-env"], "sources": ["oidc:s60cc1d7a8fe6", "go-env:s41d67ab4a28d"], "status": "REASONED"}, "oidc": {"text": "NewProvider discovers the issuer; Verifier with ClientID checks ID-token signature, issuer, audience and expiry; apply separate allowlists.", "components": ["oidc"], "sources": ["oidc:s60cc1d7a8fe6"], "status": "REASONED"}, "mfa": {"text": "Guide advice: use app-level TOTP with pquerna/otp v1.5.0 or a fronting layer such as Cloudflare Access or Authelia; follow mfa.md. Configured Access policies or Authelia's MFA-required passkey flow can add a factor; the citations do not establish default MFA or deployed enforcement.", "components": ["oidc", "otp", "cf-docs", "authelia-source"], "sources": ["oidc:s60cc1d7a8fe6", "otp:scdd6f451714e", "cf-docs:s7e793d13cf31", "cf-docs:s6f6104362214", "authelia-source:s38726e46de86"], "status": "REASONED"}, "client-validation": {"text": "InsecureSkipVerify accepts any certificate and hostname unless custom VerifyConnection/VerifyPeerCertificate checks replace validation.", "components": ["tls"], "sources": ["tls:se2152a9c28fc"], "status": "REASONED"}, "client-ca": {"text": "Use SystemCertPool plus a checked AppendCertsFromPEM result and RootCAs, or override CA locations with SSL_CERT_FILE/SSL_CERT_DIR.", "components": ["tls", "x509"], "sources": ["tls:se2152a9c28fc", "x509:se65b937f9e1e"], "status": "REASONED"}, "platform-ca": {"text": "Go 1.27 certificate-file/directory overrides bypass macOS/Windows platform verification unless GODEBUG=x509sslcertoverrideplatform=0 is also set.", "components": ["x509"], "sources": ["x509:se65b937f9e1e"], "status": "REASONED"}, "pprof": {"text": "Importing pprof registers /debug/pprof/ on DefaultServeMux; profiles, traces, cmdline and forced GC can disclose data or exhaust resources.", "components": ["pprof"], "sources": ["pprof:s0246034dddd1"], "status": "REASONED"}, "expvar": {"text": "Importing expvar registers /debug/vars with cmdline and memstats on DefaultServeMux.", "components": ["expvar"], "sources": ["expvar:se49529035a0c"], "status": "REASONED"}, "diag-bind": {"text": "Imports do not start listeners; nil handlers serve DefaultServeMux. Give the app its own mux and diagnostics a separate 127.0.0.1:6060 mux, never public proxy routing.", "components": ["http", "pprof", "expvar"], "sources": ["http:s75405f80decc", "pprof:s0246034dddd1", "expvar:se49529035a0c"], "status": "REASONED"}, "body-limit": {"text": "Wrap the body with MaxBytesReader before decoding; the example 1 MiB policy requires explicit MaxBytesError handling to return 413.", "components": ["headers"], "sources": ["headers:sa4a9473fc6f8"], "status": "REASONED"}, "header-size": {"text": "MaxHeaderBytes defaults to 1 MiB for request line and headers, independently of body limits.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED"}, "header-count": {"text": "Go 1.27 adds MaxHeaderValueCount with default 500; this is separate from body limits.", "components": ["headers"], "sources": ["headers:sa4a9473fc6f8"], "status": "REASONED"}, "read-timeout": {"text": "ReadTimeout bounds the whole request including body; example 30 seconds, zero or negative disables it.", "components": ["http"], "sources": ["http:s6ab9e32b7795"], "status": "REASONED"}, "write-timeout": {"text": "WriteTimeout bounds writing; example 60 seconds, zero or negative disables it.", "components": ["http"], "sources": ["http:s6ab9e32b7795"], "status": "REASONED"}, "header-timeout": {"text": "ReadHeaderTimeout bounds headers; example 10 seconds, zero falls back to ReadTimeout and negative disables it.", "components": ["http"], "sources": ["http:s6ab9e32b7795"], "status": "REASONED"}, "idle-timeout": {"text": "IdleTimeout bounds keep-alive idle time; example 60 seconds, zero falls back to ReadTimeout and negative disables it.", "components": ["http"], "sources": ["http:s6ab9e32b7795"], "status": "REASONED"}, "redirect-timeout": {"text": "Apply deadlines to the redirect server too; ListenAndServe sets none.", "components": ["http"], "sources": ["http:s75405f80decc", "http:s6ab9e32b7795"], "status": "REASONED"}, "request-deadlines": {"text": "For long uploads/streaming, use generous server budgets and Go 1.20+ ResponseController read/write deadlines, handling unsupported operations.", "components": ["deadlines"], "sources": ["deadlines:s251e0db38b90"], "status": "REASONED"}, "headers": {"text": "Install secure(mux) before response commit for nosniff and TLS-only HSTS max-age=63072000 with includeSubDomains.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED"}, "http2": {"text": "HTTPS enables HTTP/2 automatically; Server.HTTP2 tunes it on current releases; retain maintained cipher/curve defaults.", "components": ["http", "tls"], "sources": ["http:s75405f80decc", "tls:se2152a9c28fc"], "status": "REASONED"}, "verify-redirect": {"text": "HTTP should return 301 with an HTTPS Location.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "HTTPS must succeed without -k.", "components": ["http", "tls"], "sources": ["http:s75405f80decc", "tls:se2152a9c28fc"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "The guide expects /api to return 401/403 without credentials; it records no valid-credential positive control.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED", "verify": [1]}, "verify-bind": {"text": "Inspect every listener for loopback-only application binding behind the proxy.", "components": ["http"], "sources": ["http:s75405f80decc"], "status": "REASONED", "verify": [1]}, "verify-diag-local": {"text": "A local 6060/debug/pprof/ response of 200 establishes local availability only.", "components": ["pprof"], "sources": ["pprof:s0246034dddd1"], "status": "REASONED", "verify": [1]}, "verify-diag-route": {"text": "The public application /debug/pprof/ route should return 404, never 200.", "components": ["http", "pprof"], "sources": ["http:s75405f80decc", "pprof:s0246034dddd1"], "status": "REASONED", "verify": [1]}, "verify-diag-external": {"text": "From outside the server network, the actual public IP on 6060 must be unreachable; the guide expects refusal or timeout, never 200.", "components": ["http", "pprof"], "sources": ["http:s75405f80decc", "pprof:s0246034dddd1"], "status": "REASONED", "verify": [2]}, "request-host": {"text": "Go 1.27.0 Request.Host is supplied by the client; a fixed canonical hostname plus an origin-form path prevents forged Host or crafted request targets from selecting the redirect destination.", "components": ["request"], "sources": ["request:sfdeabbeca746"], "status": "REASONED"} } } ```` ====================================================================== ==> dotnet.md ====================================================================== # ASP.NET Core and Kestrel: TLS and authentication ASP.NET Core runs web applications and APIs on the Kestrel server. This guide helps your AI assistant deploy them securely: HTTPS in Kestrel or behind a proxy, authentication, production errors and diagnostics, request and connection budgets, persistent Data Protection keys, and auxiliary exposure. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/dotnet.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against ASP.NET Core documentation .NET 10, ASP.NET Core rate-limiter minimum .NET 7, ASP.NET Core .NET 10, ASP.NET Core source v10.0.0, ASP.NET Core proxy hardening 8.0.17, NET runtime source v10.0.0, WebApplicationBuilder precedence .NET 7, and OpenSSL documentation 3.0, plus 1 more recorded basis; 1 further source is pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-10-05; documentation checked September 2026. The guide records 71 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Preferred production layout: bind Kestrel to loopback and terminate TLS in a reverse proxy ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) or behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Kestrel can also terminate TLS itself, shown below. Certificates: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). ## 1. HTTPS directly in Kestrel `appsettings.json` with a PKCS12 file (for PEM files, `Path` is the certificate and `KeyPath` the private key; the docs warn against a plaintext `Password` here, so supply it from the environment or a secret store): ```json { "Kestrel": { "Endpoints": { "Http": { "Url": "http://*:80" }, "Https": { "Url": "https://*:443", "Certificate": { "Path": "/etc/ssl/private/server.pfx", "Password": "REPLACE_WITH_LONG_RANDOM_VALUE" } } } } } ``` ```csharp builder.WebHost.ConfigureKestrel(serverOptions => serverOptions.ConfigureHttpsDefaults(listenOptions => listenOptions.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13)); // default SslProtocols.None = OS defaults builder.Services.AddHsts(options => { options.MaxAge = TimeSpan.FromDays(365); options.IncludeSubDomains = true; }); if (!app.Environment.IsDevelopment()) { app.UseHsts(); } // browsers cache HSTS; keep it out of development app.UseHttpsRedirection(); // needs the HTTPS port: ASPNETCORE_HTTPS_PORT=443 or options.HttpsPort ``` Version scope: ASP.NET Core on .NET 10 LTS. Kestrel's default `SslProtocols.None` delegates protocol selection to the operating system; it does not disable TLS. The explicit `Tls12 | Tls13` above intentionally restricts the permitted versions. In .NET 10, `HstsOptions` defaults to a 30-day `MaxAge`, `IncludeSubDomains = false`, and `Preload = false`. This guide intentionally chooses 365 days and subdomain coverage; enable that coverage only when every affected subdomain supports HTTPS. ## 2. Behind a proxy ```csharp builder.WebHost.ConfigureKestrel(o => o.ListenLocalhost(5000)); // or ASPNETCORE_URLS=http://localhost:5000 builder.Services.Configure(o => { o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; o.KnownProxies.Add(IPAddress.Parse("127.0.0.1")); }); // adds to the default loopback trust entries app.UseForwardedHeaders(); // first in the pipeline ``` In .NET 10, forwarded-header trust already includes IPv6 loopback `::1` and the IPv4 loopback network `127.0.0.0/8`; adding `127.0.0.1` does not make it the sole trusted address. `KnownNetworks` is obsolete in .NET 10: use `KnownIPNetworks`, whose entries are `System.Net.IPNetwork`, for trusted network ranges. If the deployment needs a narrower trust list, clear the default entries and populate the actual proxy addresses or networks together; never leave both lists empty, which permits any proxy. The unknown-proxy hardening introduced in ASP.NET Core 8.0.17 and 9.0.6 also applies in .NET 10: forwarded headers from unknown proxies are ignored, including when `X-Forwarded-For` processing is not enabled. Register the real proxy rather than disabling these checks. When the proxy already redirects to HTTPS and sends HSTS, leave `UseHttpsRedirection` and `UseHsts` out of the app; per the docs, redirect middleware behind a proxy without forwarded headers loops. Set the remaining security headers at the proxy per [headers.md](https://github.com/jposluns/secureconfig/blob/main/headers.md). ## 3. Authentication Follow [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). ASP.NET Core Identity hashes passwords with PBKDF2 (`PasswordHasherOptions.IterationCount`, default 100,000); keep its hasher rather than writing one. Lockout and cookie settings: ```csharp builder.Services.AddDefaultIdentity(options => { options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.AllowedForNewUsers = true; }).AddEntityFrameworkStores(); builder.Services.ConfigureApplicationCookie(options => { options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; // app cookie; test cross-site sign-in flows before choosing Strict options.ExpireTimeSpan = TimeSpan.FromHours(8); }); ``` In .NET 10, the authentication cookie defaults to `SameSite=Lax`. Choosing `Strict` can prevent an existing app cookie from accompanying a cross-site request, but it does not universally break OAuth2 or OIDC callbacks. Remote authentication correlation cookies and OIDC nonce cookies are separate cookies that default to `SameSite=None`; retain their secure cross-site settings and avoid a global cookie policy that rewrites them to `Lax` or `Strict`. Lockout counts a failure only when the sign-in call asks for it: pass `lockoutOnFailure: true` to `_signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true)`. The template passes `false`, so password failures through that call do not count toward lockout. Without Identity, the same `Cookie.*` options go on `AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => ...)`; call `app.UseAuthentication()` then `app.UseAuthorization()` before the `Map*` calls. Deny by default with `AddAuthorizationBuilder().SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())` and mark public pages `[AllowAnonymous]`. Rate-limit the login route with the built-in middleware (`Microsoft.AspNetCore.RateLimiting`, .NET 7 and later): ```csharp builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("login", o => { o.PermitLimit = 20; o.Window = TimeSpan.FromMinutes(15); o.QueueLimit = 0; }); }); app.UseRateLimiter(); // after UseRouting when policies are per endpoint app.MapPost("/login", LoginHandler).RequireRateLimiting("login"); ``` SSO: the `Microsoft.AspNetCore.Authentication.OpenIdConnect` package adds `.AddOpenIdConnect(options => { options.Authority = ...; options.ClientId = ...; options.ClientSecret = ...; options.ResponseType = OpenIdConnectResponseType.Code; })` next to `.AddCookie()`, with the cookie as `DefaultScheme` and OIDC as `DefaultChallengeScheme`; keep the secret out of `appsettings.json`. Validation and allowlist checks are in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), providers in [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md). MFA: enforce it at the identity provider, or require MFA in the fronting layer per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). This guide recommends Cloudflare Access with independent MFA as one fronting option for a self-hosted public application, provided the effective policy requires MFA and the origin cannot be reached around Access. Cloudflare documents Access as an authentication layer between users and a self-hosted origin. Its independent MFA prompts for an additional authentication method before granting access, with TOTP authenticators and WebAuthn security keys among the supported methods. The MFA requirements page requires enabling independent MFA at the organization level first and gives settings precedence as policy over application over organization; application and policy settings can require or turn off MFA. This guide therefore requires checking that the effective setting requires MFA, rather than assuming an organization default suffices. Cloudflare says a publicly routable application needs its origin IP protected by other methods. This guide reasons that the fronting layer protects the app only when requests cannot bypass it to reach the origin. These passages do not establish this deployment's effective MFA policy or origin isolation; merely adding an identity layer does not establish MFA enforcement. ## 4. Client-side TLS discipline - Never assign `HttpClientHandler.DangerousAcceptAnyServerCertificateValidator` or a `ServerCertificateCustomValidationCallback` that returns `true`; both accept any certificate for every request through that handler. - On Linux with .NET 10, install an internal CA in the distribution's trust store, or configure `SSL_CERT_FILE` to name a PEM certificate file and `SSL_CERT_DIR` to name a directory of certificates. `SSL_CERT_DIR` is not a file path; preserve the public roots the app also needs (see [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)). ## 5. Production errors and diagnostics The following applies to .NET 10 LTS. Keep the Developer Exception Page out of production: it can disclose stack traces, request details, and other diagnostic information. `WebApplication.CreateBuilder` enables it automatically in Development, so check the effective environment as well as explicit `UseDeveloperExceptionPage` calls. After creating `app`, install production exception handling early, after forwarded-header handling and before the middleware it must protect: ```csharp if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Error"); ``` Provide the `/Error` handler; it must return a generic error response without exception messages, stack traces, configuration values, or request secrets. Make it accessible when handling failures from anonymous requests, and do not restrict it to GET if other methods can fail. Preserve intentional request-rejection statuses such as `413` in the application's error handling. Set `ASPNETCORE_ENVIRONMENT=Production` in deployment configuration and check `DOTNET_ENVIRONMENT` too. With default host configuration, Production is the default when neither variable nor another environment override is supplied. Since .NET 7, `WebApplicationBuilder` gives command-line host settings and `DOTNET_` variables precedence over `ASPNETCORE_` variables. Thus `DOTNET_ENVIRONMENT=Development` can override `ASPNETCORE_ENVIRONMENT=Production`. The older `WebHost` hosting model retains different precedence. Confirm the effective environment in startup logs. For an EF Core application using `Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore`, put the database developer filter registration before `builder.Build()`, inside the Development branch: ```csharp if (builder.Environment.IsDevelopment()) { builder.Services.AddDatabaseDeveloperPageExceptionFilter(); } ``` After building the app, keep the migrations endpoint in the same environment boundary: ```csharp if (app.Environment.IsDevelopment()) { app.UseMigrationsEndPoint(); } ``` Move any existing unconditional calls into these branches. The migrations middleware processes requests to execute migrations; production database changes belong in the deployment process. ## 6. Request and connection budgets In .NET 10, `KestrelServerLimits.MaxRequestBodySize` defaults to `30_000_000` bytes; `null` removes that limit. Choose a finite budget appropriate to the routes. This example deliberately lowers the total body allowance to 1 MiB and caps ordinary connections at 100; these are example deployment choices, not framework defaults: ```csharp using Microsoft.AspNetCore.Http.Features; builder.WebHost.ConfigureKestrel(options => { options.Limits.MaxRequestBodySize = 1_048_576; options.Limits.MaxConcurrentConnections = 100; }); builder.Services.Configure(options => { options.MultipartBodyLengthLimit = 1_048_576; }); ``` For MVC actions, `[RequestSizeLimit(1_048_576)]` sets a per-request body limit. Apply overrides before the body is read. Avoid `[DisableRequestSizeLimit]`, which removes this protection rather than providing an upload-specific budget. `FormOptions.MultipartBodyLengthLimit` defaults to `134_217_728` bytes in .NET 10 and limits each multipart section body when the form is parsed. It is separate from the server's total request-body limit; multipart framing also consumes the total allowance. A multipart parsing failure is not a guarantee of an HTTP `413` response. IIS hosting changes which server enforces the body limit. In-process hosting uses `IISServerOptions.MaxRequestBodySize`, default `30_000_000`, rather than Kestrel. IIS request filtering independently applies `maxAllowedContentLength`, also default `30_000_000`, before the application limit. Changing one does not change the other. With out-of-process hosting behind the ASP.NET Core Module, IIS sets the body limit and Kestrel's body-size limit is disabled. Align the applicable IIS, proxy, application, and form limits. The following are .NET 10 `KestrelServerLimits` defaults, not suggested overrides: | Property | Default | | --- | --- | | `KeepAliveTimeout` | 130 seconds | | `RequestHeadersTimeout` | 30 seconds | | `MaxConcurrentConnections` | `null`, unlimited | | `MaxRequestHeaderCount` | 100 | | `MaxRequestHeadersTotalSize` | 32,768 bytes | | `MinRequestBodyDataRate` | 240 bytes/second, 5-second grace period | | `MinResponseDataRate` | 240 bytes/second, 5-second grace period | Keep-alive and header timeouts are not total application execution deadlines. Upgraded connections leave the ordinary connection count; budget them separately with `MaxConcurrentUpgradedConnections`, whose default is also `null`. Minimum-rate enforcement has protocol-specific behavior; HTTP/2 does not support the same per-request rate adjustments as HTTP/1.x. Several Kestrel timeout and rate checks are disabled while a debugger is attached, so demonstrate them without one. For HTTP/2, configure `options.Limits.Http2`. The .NET 10 `Http2Limits` defaults are: | Property | Default | | --- | --- | | `MaxStreamsPerConnection` | 100 | | `HeaderTableSize` | 4,096 bytes | | `MaxFrameSize` | 16,384 bytes | | `MaxRequestHeaderFieldSize` | 32,768 bytes | | `InitialConnectionWindowSize` | 1,048,576 bytes | | `InitialStreamWindowSize` | 786,432 bytes | | `KeepAlivePingDelay` | `TimeSpan.MaxValue`, pings disabled | | `KeepAlivePingTimeout` | 20 seconds | The older `8_192`, `131_072`, and `98_304` header-field, connection-window, and stream-window values found in older examples are not .NET 10 defaults. Flow-control windows bound outstanding buffered data, not the total body size. These `Http2Limits` properties do not configure HTTP/3. ## 7. Persistent Data Protection keys ASP.NET Core Data Protection protects authentication cookies and antiforgery tokens. Losing a key ring can invalidate those values after a restart or when a different replica handles the request; ephemeral storage does not weaken the cryptographic algorithm. The .NET 10 defaults depend on the hosting environment. Available user-profile storage, IIS, and Azure App Service can provide persistence; process-only keys are a fallback, not the universal default. A container's writable layer can still disappear when the container is replaced. Before `builder.Build()`, configure a durable key repository. Here, `keyEncryptionCertificate` is an `X509Certificate2` loaded through the deployment's certificate provisioning; its private key must be available to every replica that decrypts the stored keys: ```csharp using Microsoft.AspNetCore.DataProtection; builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo("/var/lib/myapp/data-protection")) .SetApplicationName("myapp-production") .ProtectKeysWithCertificate(keyEncryptionCertificate); ``` Provision that directory with permissions restricted to the application identity and mount durable storage there. Replicas need the same shared key repository, the same application name, and access to the required decryption certificates; identical directory names on separate local disks do not share keys. An external durable key repository is another option. Explicitly choosing a key repository disables automatic key encryption at rest, so configure protection such as `ProtectKeysWithCertificate` as well as persistence. Retain the keys and decryption certificates needed for still-valid cookies and tokens. Use separate repositories, access permissions, and certificate private keys for mutually untrusted applications. `SetApplicationName` separates cryptographic purposes, but a different name is not a security boundary against another application with access to the same master keys. Cookie continuity also requires compatible authentication configuration across replicas, including the cookie name and authentication scheme. Shared keys alone do not reconcile different cookie settings, session stores, or identity-validation behavior. ## 8. Auxiliary exposure In .NET 10, `KestrelServerOptions.AddServerHeader` defaults to `true`. Disable Kestrel's identifying header: ```csharp builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; }); ``` IIS and reverse proxies can add their own headers. For IIS, merge these settings into the existing `web.config`, preserving its other hosting configuration: ```xml ``` `removeServerHeader` is an IIS 10 feature and requires Windows Server version 1709 or Windows 10 version 1709 or later. Check the public response because the fronting server can supply a different header. Header removal reduces disclosure; it does not control access. For .NET 10 built-in OpenAPI generation, use the `Microsoft.AspNetCore.OpenApi` package and register `builder.Services.AddOpenApi()` before building the app. Keep document mapping in Development unless production access is deliberately authorized: ```csharp if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } ``` If production clients need the document, replace that conditional mapping with an authorized mapping: ```csharp app.MapOpenApi().RequireAuthorization(); ``` This requires the authentication and authorization setup from section 3. Use a dedicated authorization policy when only operators should see the document. Built-in OpenAPI generation does not include Swagger UI. If the app adds Swagger UI, keep its `UseSwaggerUI` registration inside a Development branch too. Protecting the document endpoint does not automatically protect separately registered UI middleware. Register health checks before building the app, then require authorization on their endpoint: ```csharp builder.Services.AddHealthChecks(); ``` ```csharp app.MapHealthChecks("/healthz").RequireAuthorization(); ``` Configure the monitoring client to authenticate, or deliberately expose a minimal liveness response through a separately controlled route. `RequireHost` matches a client-supplied Host header, which can be spoofed; it is not a network or authentication boundary. For default ASP.NET Core hosting, merge an explicit host allowlist into `appsettings.json`: ```json { "AllowedHosts": "example.com;www.example.com" } ``` Use semicolon-separated hostnames without ports. `*` permits all hosts. Host filtering validates the request host; it does not authenticate the caller or restrict listening interfaces. `ForwardedHeadersOptions.AllowedHosts` is a separate setting for forwarded host values when the proxy replaces the original Host header. ## 9. Verify (REASONED: redirect, TLS, cookie-API and listener expectations follow the Sources below; no deployment outcomes are recorded. The authoring environment has no .NET runtime, container runtime or deployed application, as recorded below.) ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' # the deployment's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (ss is missing), which outranks 0 curl -q -g --noproxy '*' -sI "http://$1/" || # expect 307 or 308 with a https:// Location { echo "curl exit $?: the redirect request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } curl -q -g --noproxy '*' -sI "https://$1/" || # succeeds without -k; shows Strict-Transport-Security { echo "curl exit $?: the HTTPS request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' "https://$1/api"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" # protected [ApiController] API using the cookie scheme: 401 without a cookie case "$3:$4" in 0:401) echo "401 without a cookie: confirm that an authorized request reaches this API before you read the 401 as a pass" ;; 0:2??) echo "FINDING: /api answered $4 without a cookie"; set -- "$1" 1 ;; 0:*) echo "HTTP $4 from /api is neither the 401 pass nor a 2xx finding (a page redirect, an OIDC or customized challenge, or a missing route); inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the /api request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac if command -v ss >/dev/null; then set -- "$1" "$2" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" printf '%s' "${3%$'\n'*}" # read every listener; dotnet: behind a proxy: 127.0.0.1 and ::1 only [ "${3##*$'\n'}" = 0 ] || { echo "ss -tlnp failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } set -- "$1" "$2" listed # the 3rd value says whether ss ran, so the last line names the listener list only when it did else echo "ss is not installed here; not listing sockets" case "$2" in 0) set -- "$1" 2 ;; esac set -- "$1" "$2" unlisted fi case "$3" in listed) echo "block exit $2; the redirect and HTTPS headers, including Strict-Transport-Security, and the listener list are not classified, so judge them yourself" ;; *) echo "block exit $2; the redirect and HTTPS headers, including Strict-Transport-Security, are not classified, so judge them yourself" ;; esac exit "$2" ) ``` For the `/api` check, the route must exist, require authorization, and use the app's cookie authentication scheme for challenge and forbid. With the default .NET 10 cookie handler, known API endpoints, including `[ApiController]` endpoints, return `401` for unauthenticated requests and `403` for authenticated users denied access. This changed in .NET 10; ordinary browser-page challenges can still redirect. An OIDC challenge scheme or customized cookie events can also change the response. Confirm that an authorized request reaches the expected API before interpreting a denial. ### Additional deployment checks (REASONED: deployment-check scope and guarded-paste/error interpretation follow the cited .NET 10 and curl behavior; no .NET runtime, deployed application, external vantage or replicas. Bash 5.3.9 bash -n accepted all six Verify shell fences with exit 0 and no output; this is syntax only, not an observed application response, and the bash -n run covered the section 9 block in its earlier form, before the section 9 host guard was added. A separate local guard record for the five child-section blocks, in order from Production error disclosure through Cookie continuity across restart and replicas: Bash 5.3.9 ran each complete block with curl replaced by printf LOCAL_CURL, using https://fixture.internal/check, an empty value, the original placeholder, set -- without arguments, and bad value. The valid URL and bad value each emitted respectively 2, 2, 1, 1 and 2 markers, empty and placeholder inputs printed "substitute the value inside the single quotes; not probing", and no arguments printed "paste the whole block, including its set -- line; not probing". The three rejected inputs emitted no marker, all 25 cases exited 0 with no stderr, and no service, listener or host was contacted; the separate local guard record's results are shell-guard observations only, not URL validation or deployment comparisons. The bash -n run and the separate local guard record covered the five child-section blocks in an earlier form, whose guard refusals ended with status 0, and no recorded run covered the later form that ended each of them with `) || echo "block exit $?"`, which kept status 0 when the block was pasted whole. The current form of all six blocks, which reports each block's result in its exit status, is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block, and the section 9 block's current form is also covered by the stub runs recorded below; no application response was observed) **REASONED, .NET 10 LTS:** The following checks have not been demonstrated: the authoring environment has no .NET runtime or container runtime, and no deployed application or replica infrastructure. Expected exposed and corrected outcomes rest on the cited .NET 10 documentation. Shell checks do not establish server behavior. REASONED: paste and status guidance, following the cited curl behavior and CONTRIBUTING.md rule 6. Paste each complete guarded block. Substitute the entire URL inside its single quotes; a literal apostrophe requires proper shell escaping. Run public-exposure checks from outside the application host and its trusted proxy network. Supply trusted CA configuration where needed; do not use `-k`. A DNS error, TLS failure, timeout, or HTTP `000` is not proof of an application-level rejection. Every block in section 9, including the one above, records each classified result and each failed request as it runs and exits once: 0 for a pass or a result it leaves unclassified, 1 for a finding, 2 for a guard refusal and 3 for a failed request or an inconclusive result, a finding outranking an inconclusive result. The first block in section 9 refuses only its listener inventory when `ss` is missing: it prints `ss is not installed here; not listing sockets` after its requests have run, then still ends with its `block exit` line, which names the listener list only when `ss` ran, and exits 2 only when none of the requests found something or was inconclusive. Stub runs (October 2026, Bash 5.3.9, scratch `ss` and `curl` stubs, no network, under an inherited `readonly` variable, `declare -i` variable and `set -e`) covered that block in an earlier form, whose `block exit` line named the listener list even when `ss` was missing. Stub runs under the same conditions then covered a later form, which lowercased the host with `tr` before refusing reserved names, and exited 0 with `ss` and the stubbed requests passing, 3 with `ss` failing, and 2 with `ss` missing; with `ss` missing, a stubbed `200` from `/api` gave 1, stubbed failed requests gave 3 and the last line did not name the listener list. Its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs on 2026-11-01 (Bash 5.3.9 and dash 0.5.12, the same kind of stubs, no network). In Bash, under the same conditions and with `tr` present or absent, those runs gave the same statuses and refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with status 2 before any request. Under dash they refused every host name with status 2 and the message that the block needs bash, before any request. Unless a guard ends the block early, each block ends with a `block exit` line that gives its status and names the output it leaves for you to judge, so status 0 alone does not mean the check passed. The cookie-continuity block also refuses with status 2, before any request, when `./dotnet-verify.cookies` is not readable in the current directory. #### Production error disclosure (REASONED: the following block tests error representations against the cited error-handling documentation; no .NET runtime or deployed test application, as recorded below.) **REASONED: no .NET runtime or deployed test application.** In an isolated deployment, provide a controlled route that throws an exception containing the harmless marker `DOTNET_VERIFY_CANARY`. Confirm that the route is reached, rather than stopped by authentication or routing. Use the public URL of that route below. With the Developer Exception Page exposed, expect a `500` response containing diagnostic detail and the marker. With the production handler, expect the intended generic `500` response, with neither the marker nor stack traces, source paths, cookies, or configuration values. Inspect both representations and correlate the request with server logs; an empty body or a proxy-generated error alone does not establish that the application's handler ran. The distinguishing vendor behavior is documented under Developer Exception Page and Exception Handler Middleware in the error-handling source. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_ERROR_TEST_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the single quotes; not probing"; exit 2 ;; esac set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS -i --connect-timeout 5 --max-time 20 \ -H 'Accept: text/plain' -w '\n%{http_code}' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$4" case "$3:$5:$4" in 0:*DOTNET_VERIFY_CANARY*) echo "FINDING: the text/plain response contains DOTNET_VERIFY_CANARY, so exception detail reaches the client"; set -- "$1" 1 ;; 0:500:*) ;; 0:*) echo "HTTP $5 for text/plain is not the expected 500, so the request may not have reached the throwing route; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the text/plain request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS -i --connect-timeout 5 --max-time 20 \ -H 'Accept: text/html' -w '\n%{http_code}' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$4" case "$3:$5:$4" in 0:*DOTNET_VERIFY_CANARY*) echo "FINDING: the text/html response contains DOTNET_VERIFY_CANARY, so exception detail reaches the client"; set -- "$1" 1 ;; 0:500:*) ;; 0:*) echo "HTTP $5 for text/html is not the expected 500, so the request may not have reached the throwing route; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the text/html request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; a 500 without the marker is not classified, so read both bodies for stack traces, source paths, cookies or configuration values and correlate the requests with server logs yourself" exit "$2" ) ``` #### Oversized-request rejection (REASONED: the following block compares body sizes against the cited Kestrel limit; no .NET runtime or deployed body-reading endpoint, as recorded below.) **REASONED: no .NET runtime or deployed body-reading endpoint.** For the section-6 example, use an isolated deployment with a POST test endpoint that accepts `application/octet-stream`, consumes the entire body before returning `204`, and preserves body-limit failures as `413`. It must be reachable without an authentication challenge for these requests. Do not point this test at a business operation or an endpoint that ignores the body. The fixed expectation is `204` at 1,048,576 bytes and `413` at 1,048,577 bytes. For the permissive comparison, raise only the tested limit above both sizes in the isolated deployment: both requests should return `204`. Keep other limits above the tested threshold when attributing the rejection to Kestrel. An upstream `413` demonstrates public enforcement, but does not prove which inner limit rejected the body. An authentication denial, parser error, `500`, or connection failure is inconclusive. The Kestrel request-body documentation describes the limit and its IIS hosting exception. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_BODY_TEST_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the single quotes; not probing"; exit 2 ;; esac set +o pipefail # pipefail off here, so each status below is curl's, not that of a head cut short when the server answers early set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$(if head -c 1048576 /dev/zero | curl -q -g --noproxy '*' -sS \ --http1.1 --connect-timeout 5 --max-time 30 \ -H 'Content-Type: application/octet-stream' -H 'Expect:' \ --data-binary @- -o /dev/null -w 'at-limit http=%{http_code}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its at-limit line printf '%s\n' "$4" case "$3:$4" in "0:at-limit http=204") ;; 0:*) echo "HTTP ${4#at-limit http=} at 1,048,576 bytes is not the expected 204, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the at-limit request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if head -c 1048577 /dev/zero | curl -q -g --noproxy '*' -sS \ --http1.1 --connect-timeout 5 --max-time 30 \ -H 'Content-Type: application/octet-stream' -H 'Expect:' \ --data-binary @- -o /dev/null -w 'over-limit http=%{http_code}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its over-limit line printf '%s\n' "$4" case "$3:$4" in "0:over-limit http=413") ;; "0:over-limit http=204") echo "FINDING: 1,048,577 bytes were accepted with 204, so no body limit rejected them"; set -- "$1" 1 ;; 0:*) echo "HTTP ${4#over-limit http=} at 1,048,577 bytes is neither the 413 pass nor the 204 finding; an authentication denial, parser error or 500 is inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the over-limit request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; which layer returned a 413 is not classified, so keep other limits above the tested threshold before you attribute the rejection to Kestrel" exit "$2" ) ``` #### Diagnostic, OpenAPI, and health access (REASONED: the following block checks endpoint exposure against the cited OpenAPI and health documentation; no deployed endpoints or external test vantage, as recorded below.) **REASONED: no deployed endpoints or external test vantage.** The error probe above checks Developer Exception Page exposure. Inventory additional diagnostic routes and repeat the following GET probe for each applicable route, the OpenAPI document, any Swagger UI and its document route, and health checks. Common configured paths include `/openapi/v1.json`, `/swagger/index.html`, `/swagger/v1/swagger.json`, and `/healthz`; use the paths actually registered by the application. In the exposed comparison, the request returns the diagnostic content, API description, UI, or health output without credentials. In the corrected deployment, Development-only routes are absent, while intentionally retained routes deny anonymous access according to their authentication scheme. An absent route normally returns `404`; fallback routing or authorization can change that response. Cookie or OIDC challenges can redirect on endpoints that are not treated as APIs. Do not follow redirects and mistake a login page for protected content. For retained routes, pair the anonymous request with an authorized request through the normal client and confirm the expected content. A health endpoint can disclose its result with `200` or `503`; neither status by itself proves authorization. A GET to a migrations path cannot prove that `UseMigrationsEndPoint` is absent: inspect its Development-only registration and test its actual behavior in an isolated database deployment. The OpenAPI and health-check sources document the mapping and authorization controls. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_AUXILIARY_ENDPOINT_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the single quotes; not probing"; exit 2 ;; esac set -- "$1" 0 "$(if curl -q -g --noproxy '*' -sS -i --connect-timeout 5 --max-time 20 "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status: 3 (the request failed) or 0 printf '%s\n' "${3%$'\n'*}" [ "${3##*$'\n'}" = 0 ] || { echo "curl exit ${3##*$'\n'}: the request failed; inconclusive"; set -- "$1" 3; } echo "block exit $2; the response status and content are not classified, so judge them against the exposed and corrected states above" exit "$2" ) ``` #### Public response headers (REASONED: the following block checks header removal against the cited Kestrel and IIS controls; no deployed Kestrel, IIS or proxy response, as recorded below.) **REASONED: no deployed Kestrel, IIS, or proxy response.** Probe a known working public route and repeat for representative redirects and errors. Before removal, direct Kestrel responses normally include `Server: Kestrel`; IIS or the proxy can supply their own identifiers. After the relevant hosting-layer changes, expect no public `Server` or `X-Powered-By` header where removal is supported and configured. Inspect all returned headers, not just the application's normal success response. The Kestrel option and IIS sources distinguish which layer controls each header. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_PUBLIC_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the single quotes; not probing"; exit 2 ;; esac set -- "$1" 0 "$(if curl -q -g --noproxy '*' -sS -D - -o /dev/null \ --connect-timeout 5 --max-time 20 "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status: 3 (the request failed) or 0 printf '%s' "${3%$'\n'*}" [ "${3##*$'\n'}" = 0 ] || { echo "curl exit ${3##*$'\n'}: the request failed; inconclusive"; set -- "$1" 3; } echo "block exit $2; the returned headers, including any Server or X-Powered-By header, are not classified, so judge them yourself" exit "$2" ) ``` #### Cookie continuity across restart and replicas (REASONED: the following block checks continuity against the cited key-lifetime and cookie-sharing documentation; no running cookie-authenticated application or replicas, as recorded below.) **REASONED: no running cookie-authenticated application or replicas.** Sign in with a test account through the application's actual login flow and save its cookies in a private Netscape-format curl cookie file named `dotnet-verify.cookies`. Use an existing protected `[ApiController]` endpoint that authenticates and challenges with the app's cookie scheme and returns the test user's identity. Run the pair below before restart, after restart, and while routing the same public hostname to each replica in turn. Confirm the selected replica in backend logs or the load balancer's controls; sticky routing to one instance does not test shared keys. Keep the cookie file unchanged, complete the test before expiry, and avoid account or security-stamp changes. The anonymous control should return `401`. The request using the original cookie should return `200` with the same authenticated identity throughout. In an isolated comparison with process-only or non-shared keys, the cookie becomes unreadable after key loss or on a replica lacking its key. Do not delete production keys to create that comparison. Cookie renewal, expiry, session-store loss, and identity validation can also cause failures, so correlate them with application logs. Data Protection key-lifetime and cookie-sharing documentation explains the key-ring and scheme requirements. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_PROTECTED_COOKIE_API_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the single quotes; not probing"; exit 2 ;; esac [ -r ./dotnet-verify.cookies ] || { echo "no readable ./dotnet-verify.cookies in this directory: save the test account's cookies there as described above; not probing"; exit 2; } set -- "$1" 0 # the 2nd value is the block's status so far: 3 (inconclusive) outranks 0 set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS -i --connect-timeout 5 --max-time 20 \ -w '\n%{http_code}' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$4" case "$3:$5" in 0:401) ;; 0:*) echo "HTTP $5 without the cookie is not the 401 the anonymous control expects; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $3: the request without the cookie failed; inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sS -i --connect-timeout 5 --max-time 20 \ -b ./dotnet-verify.cookies -w '\n%{http_code}' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$4" case "$3:$5" in 0:200) ;; 0:*) echo "HTTP $5 with the original cookie is not the expected 200: key loss is one cause, and cookie renewal, expiry, session-store loss or identity validation are others, so correlate it with application logs; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $3: the request with the cookie failed; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; the identity in the response with the cookie, and which replica answered, are not classified, so judge them yourself" exit "$2" ) ``` Also retain an antiforgery token and its accompanying cookie across a restart, then submit the application's normal protected form. Confirm success without fetching a replacement token first. A lost key ring can invalidate antiforgery tokens independently of whether another authentication mechanism signs the user back in. ## Sources (checked September 2026) - Enforce HTTPS (UseHttpsRedirection, UseHsts, HttpsPort) (.NET 10): https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl?view=aspnetcore-10.0. Kestrel endpoints (certificate config, ListenLocalhost, SslProtocols): https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0. Proxy servers (ForwardedHeadersOptions, KnownProxies): https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0 - Introduction to Identity (lockout, ConfigureApplicationCookie) (.NET 10): https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-10.0 ; PasswordHasherOptions: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.passwordhasheroptions?view=aspnetcore-10.0 - Cookie authentication without Identity (.NET 10): https://learn.microsoft.com/en-us/aspnet/core/security/authentication/cookie?view=aspnetcore-10.0 ; CookieSecurePolicy: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.cookiesecurepolicy?view=aspnetcore-10.0 - Rate limiting middleware (rate-limiting middleware in .NET 7 and later; guide targets .NET 10 LTS): https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit?view=aspnetcore-10.0 ; OpenID Connect web authentication: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-oidc-web-authentication?view=aspnetcore-10.0 - DangerousAcceptAnyServerCertificateValidator (.NET 10): https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler.dangerousacceptanyservercertificatevalidator?view=net-10.0 ; trusted roots on Linux (rolling documentation, checked September 2026): https://learn.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography - .NET 10 Kestrel TLS protocol selection: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0 - .NET 10 HstsOptions defaults: https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Middleware/HttpsPolicy/src/HstsOptions.cs - .NET 10 proxy trust configuration and loopback defaults: https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0 - .NET 10 ForwardedHeadersOptions and KnownIPNetworks: https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Middleware/HttpOverrides/src/ForwardedHeadersOptions.cs - Unknown-proxy hardening in ASP.NET Core 8.0.17 and 9.0.6: https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/8/forwarded-headers-unknown-proxies?view=aspnetcore-10.0 - .NET 10 Identity lockout and template sign-in call: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity-configuration?view=aspnetcore-10.0 - .NET 10 SameSite defaults for authentication, correlation, and nonce cookies: https://learn.microsoft.com/en-us/aspnet/core/security/samesite?view=aspnetcore-10.0 - .NET 10 cookie authentication behavior for API endpoints: https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/10/cookie-authentication-api-endpoints?view=aspnetcore-10.0 - .NET 10 native OpenSSL root-store file and directory selection: https://raw.githubusercontent.com/dotnet/runtime/v10.0.0/src/native/libs/System.Security.Cryptography.Native/pal_x509_root.c - OpenSSL CA file, directory, and environment-variable semantics: https://docs.openssl.org/3.0/man3/SSL_CTX_load_verify_locations/ - .NET 10 production exception handling and database developer diagnostics: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-10.0 - .NET 10 environment selection and Production default: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments?view=aspnetcore-10.0 - WebApplicationBuilder environment-variable precedence change in .NET 7: https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/7/environment-variable-precedence?view=aspnetcore-10.0 - .NET 10 UseMigrationsEndPoint API: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.migrationsendpointextensions.usemigrationsendpoint?view=aspnetcore-10.0 - .NET 10 Kestrel request-size enforcement, per-request overrides, IIS exception, and debugger behavior: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/options?view=aspnetcore-10.0 - .NET 10 KestrelServerLimits defaults: https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Servers/Kestrel/Core/src/KestrelServerLimits.cs - .NET 10 Http2Limits defaults: https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Servers/Kestrel/Core/src/Http2Limits.cs - .NET 10 FormOptions multipart limits: https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Http/Http/src/Features/FormOptions.cs - .NET 10 DisableRequestSizeLimitAttribute: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.disablerequestsizelimitattribute?view=aspnetcore-10.0 - .NET 10 upload limits and IIS request filtering: https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-10.0 - .NET 10 IIS in-process hosting and IISServerOptions.MaxRequestBodySize: https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/in-process-hosting?view=aspnetcore-10.0 - .NET 10 Data Protection persistence, application names, encryption, and isolation: https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-10.0 - .NET 10 environment-dependent key storage and key lifetime: https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/default-settings?view=aspnetcore-10.0 - .NET 10 ProtectKeysWithCertificate overloads: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.dataprotection.dataprotectionbuilderextensions.protectkeyswithcertificate?view=aspnetcore-10.0 - .NET 10 Cookie sharing requirements for key rings, application names, and authentication schemes: https://learn.microsoft.com/en-us/aspnet/core/security/cookie-sharing?view=aspnetcore-10.0 - .NET 10 KestrelServerOptions.AddServerHeader default: https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs - IIS custom response headers and X-Powered-By (rolling documentation, checked September 2026): https://learn.microsoft.com/en-us/iis/configuration/system.webserver/httpprotocol/customheaders/ - IIS removeServerHeader requirements and request filtering (IIS 10; Windows Server version 1709 or Windows 10 version 1709): https://learn.microsoft.com/en-us/iis/configuration/system.webserver/security/requestfiltering/ - .NET 10 OpenAPI document generation and endpoint authorization: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/aspnetcore-openapi?view=aspnetcore-10.0 - .NET 10 OpenAPI documents with Development-only Swagger UI: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/using-openapi-documents?view=aspnetcore-10.0 - .NET 10 health-check authorization and Host-header spoofing: https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-10.0 - .NET 10 host filtering and AllowedHosts: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/host-filtering?view=aspnetcore-10.0 - .NET 10 provider-side MFA requirements (checked October 2026): https://learn.microsoft.com/en-us/aspnet/core/security/authentication/mfa?view=aspnetcore-10.0 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, MFA requirements, precedence and prerequisite (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, independent MFA and supported TOTP/WebAuthn factors (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, self-hosted public application authentication layer (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, publicly routable origin protection requirement (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - docs: ASP.NET Core documentation (.NET 10) - rate-min: ASP.NET Core rate-limiter minimum (.NET 7) - net10: ASP.NET Core (.NET 10) - source: ASP.NET Core source (v10.0.0) - proxy-fix: ASP.NET Core proxy hardening (8.0.17) - runtime: NET runtime source (v10.0.0) - precedence: WebApplicationBuilder precedence (.NET 7) - dotnet-rolling: .NET documentation (rolling) (unknown) - openssl: OpenSSL documentation (3.0) - iis: IIS documentation (IIS 10) - iis-rolling: IIS documentation (rolling) (unknown) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) Claims (full source mapping in the JSON block below): - tls (REASONED; docs, net10): Kestrel serves wildcard HTTP 80 and HTTPS 443 with PKCS12 or PEM Path/KeyPath; supply certificate passwords from environment or a secret store. - tls-protocols (REASONED; net10): Default SslProtocols.None delegates to the OS; example explicitly permits only TLS 1.2 and 1.3. - hsts-age (REASONED; source): NET 10 HSTS defaults to 30 days; example selects 365 days outside Development. - hsts-subdomains (REASONED; source): HSTS IncludeSubDomains defaults false; example enables it only when all affected subdomains support HTTPS. - hsts-preload (REASONED; source): HSTS Preload defaults false. - redirect (REASONED; docs): UseHttpsRedirection needs an HTTPS port via ASPNETCORE_HTTPS_PORT=443 or HttpsPort. - proxy-bind (REASONED; docs, net10): ListenLocalhost(5000), or localhost:5000 URLs, binds behind a same-host proxy; process forwarded headers first. - proxy-default (REASONED; net10, source): NET 10 trusts ::1 and 127.0.0.0/8 by default; adding 127.0.0.1 does not narrow that list. - proxy-networks (REASONED; net10, source): NET 10 replaces obsolete KnownNetworks with KnownIPNetworks/System.Net.IPNetwork; narrow both lists together and never leave both empty. - proxy-hardening (REASONED; net10, proxy-fix): Unknown-proxy headers are ignored in 8.0.17 and 9.0.6 and NET 10, even without X-Forwarded-For processing; register the real proxy. - proxy-https (REASONED; docs): When the proxy handles redirects/HSTS, omit the app middleware; missing forwarded-header handling can cause redirect loops. - password (REASONED; docs): Identity uses PBKDF2 with default 100000 iterations; keep its password hasher. - lockout (REASONED; net10): Configure five failures, five-minute lockout and allowance for new users; PasswordSignInAsync must use lockoutOnFailure=true, while the template uses false. - cookies (REASONED; docs, net10): Configure HttpOnly, SecurePolicy.Always, SameSite=Lax and eight-hour expiry; cookie-only auth uses the same options. - samesite (REASONED; net10): NET 10 auth cookie defaults Lax; Strict can suppress cross-site app cookies without universally breaking OAuth/OIDC callbacks. - remote-cookies (REASONED; net10): Correlation and OIDC nonce cookies separately default None; preserve secure cross-site settings and avoid global rewriting. - authorization (REASONED; docs): Run authentication before authorization and Map calls; use an authenticated fallback policy and explicitly AllowAnonymous on public pages. - login-limit (REASONED; rate-min): NET 7+ built-in rate limiting: login policy permits 20 per 15 minutes, no queue, 429 rejection; endpoint policies need UseRateLimiter after routing. - oidc (REASONED; docs): Configure OpenIdConnect code flow with cookie DefaultScheme and OIDC DefaultChallengeScheme; keep client secrets outside appsettings.json. - mfa (REASONED; docs, cf-docs): Enforce MFA at the identity provider or fronting layer; this guide recommends Cloudflare Access independent MFA for a self-hosted public app only with effective MFA enforcement (policy over application over organization) and no origin bypass. The cited pages do not establish this deployment's enforcement or origin isolation. - client-validation (REASONED; docs): Never accept every certificate through DangerousAcceptAnyServerCertificateValidator or an always-true callback. - client-ca (REASONED; runtime, openssl, dotnet-rolling): On Linux NET 10 install internal roots or use SSL_CERT_FILE for a PEM file and SSL_CERT_DIR for a certificate directory; preserve needed public roots. - developer-errors (REASONED; net10): Development automatically enables the Developer Exception Page; production must exclude it and check the effective environment. - error-handler (REASONED; net10): Install UseExceptionHandler early after forwarding; provide a generic /Error route supporting failed methods/anonymous requests and preserve intentional 413 responses. - environment (REASONED; net10, precedence): Production is default absent overrides; NET 7+ WebApplicationBuilder prioritizes command-line/DOTNET_ over ASPNETCORE_, while older WebHost differs. - database-diagnostics (REASONED; net10): Register database developer exception filters before Build and migrations middleware after Build only in Development; migrations execute database changes. - body-limit (REASONED; net10, source): NET 10 Kestrel total body default is 30000000 bytes, null unlimited; example lowers it to 1048576 bytes. - body-override (REASONED; net10): RequestSizeLimit overrides before body reading; DisableRequestSizeLimit removes the protection. - multipart (REASONED; source, net10): MultipartBodyLengthLimit defaults 134217728 bytes per parsed section; example uses 1048576. Framing consumes total allowance and parsing failure need not return 413. - iis-body (REASONED; net10): In-process IISServerOptions body default is 30000000, separately from IIS maxAllowedContentLength default 30000000; out-of-process IIS disables Kestrel body limiting. - keepalive (REASONED; net10, source): Kestrel KeepAliveTimeout defaults to 130 seconds; it is not a total execution deadline. - header-timeout (REASONED; net10, source): Kestrel RequestHeadersTimeout defaults to 30 seconds; it is not a total execution deadline. - connections (REASONED; source): MaxConcurrentConnections defaults null/unlimited; example caps ordinary connections at 100. - upgraded (REASONED; net10, source): Upgraded connections leave the ordinary count; MaxConcurrentUpgradedConnections separately defaults null. - header-count (REASONED; source): Kestrel MaxRequestHeaderCount defaults 100. - header-size (REASONED; source): Kestrel MaxRequestHeadersTotalSize defaults 32768 bytes. - request-rate (REASONED; source): MinRequestBodyDataRate defaults 240 bytes/second with five-second grace. - response-rate (REASONED; source): MinResponseDataRate defaults 240 bytes/second with five-second grace. - rate-scope (REASONED; net10): HTTP/2 per-request rate adjustments differ from HTTP/1.x; several timeout/rate checks are disabled under a debugger. - h2-streams (REASONED; source): NET 10 HTTP/2 MaxStreamsPerConnection defaults 100. - h2-table (REASONED; source): NET 10 HTTP/2 HeaderTableSize defaults 4096 bytes. - h2-frame (REASONED; source): NET 10 HTTP/2 MaxFrameSize defaults 16384 bytes. - h2-header (REASONED; source): NET 10 HTTP/2 MaxRequestHeaderFieldSize defaults 32768 bytes, not the older 8192. - h2-connection-window (REASONED; source): NET 10 HTTP/2 InitialConnectionWindowSize defaults 1048576 bytes, not the older 131072. - h2-stream-window (REASONED; source): NET 10 HTTP/2 InitialStreamWindowSize defaults 786432 bytes, not the older 98304. - h2-ping-delay (REASONED; source): NET 10 HTTP/2 KeepAlivePingDelay defaults TimeSpan.MaxValue, disabling pings. - h2-ping-timeout (REASONED; source): NET 10 HTTP/2 KeepAlivePingTimeout defaults 20 seconds. - h2-scope (REASONED; net10, source): Flow-control windows bound outstanding buffered data, not total bodies; Http2Limits do not configure HTTP/3. - key-defaults (REASONED; net10): Data Protection key persistence depends on hosting: user profiles, IIS or Azure may persist; process-only fallback and disposable container layers can lose keys. - key-persistence (REASONED; net10): Persist keys durably with restricted permissions; replicas share repository, application name and decryption certificates, not merely identical directory names. - key-encryption (REASONED; net10): Choosing an explicit repository disables automatic at-rest encryption; configure ProtectKeysWithCertificate and retain needed keys/certificates. - key-isolation (REASONED; net10): Separate repositories, permissions and certificate private keys for untrusted apps; SetApplicationName is not isolation from another holder of master keys. - cookie-sharing (REASONED; net10): Cookie continuity also needs compatible cookie names, schemes, stores and identity-validation configuration. - server-header (REASONED; source): NET 10 AddServerHeader defaults true; set false and check all hosting layers since header removal does not control access. - iis-headers (REASONED; iis, iis-rolling): Remove IIS X-Powered-By and set removeServerHeader; the latter requires IIS 10 with Windows Server/Windows 10 version 1709 or later. - openapi (REASONED; net10): NET 10 built-in OpenAPI requires AddOpenApi before Build; map only in Development or RequireAuthorization with an appropriate policy. - swagger (REASONED; net10): Built-in OpenAPI has no Swagger UI; keep separately added UseSwaggerUI in Development or protect it separately from the document. - health (REASONED; net10): Register health checks and authorize /healthz; monitoring must authenticate or use a deliberately controlled minimal liveness route. - health-host (REASONED; net10): RequireHost checks a spoofable Host header and is not a network or authentication boundary. - host-filter (REASONED; net10): AllowedHosts uses semicolon-separated names without ports; wildcard permits all. It neither authenticates nor binds interfaces; forwarded-host allowlisting is separate. - verify-redirect (REASONED; docs): HTTP should return 307/308 with HTTPS Location. - verify-tls (REASONED; docs, net10): HTTPS must validate without -k and show HSTS. - verify-api (REASONED; net10): Known protected NET 10 cookie API endpoints return 401 anonymous and 403 forbidden; pages/OIDC/custom handlers may redirect. Confirm authorized success first. - verify-bind (REASONED; docs, net10): Inspect every listener for 127.0.0.1 and ::1 behind the proxy. - verify-errors (REASONED; net10): Controlled exception should expose marker/detail under development and generic 500 under production; inspect plain/HTML bodies and correlate handler execution. - verify-body (REASONED; net10): Body-consuming test route returns 204 at 1048576 bytes, 413 at 1048577; raised limit permits both. Isolate other limits and distinguish upstream rejection. - verify-aux (REASONED; net10): Probe actual diagnostic/OpenAPI/UI/health routes anonymously, then authorized; Development-only routes should be absent and retained routes protected. Status alone does not prove authorization. - verify-migrations (REASONED; net10): GET cannot prove migrations middleware absent; inspect registration and test actual behavior only in isolated database infrastructure. - verify-headers (REASONED; source, iis, iis-rolling): Inspect public success, redirect and error responses for absent Server/X-Powered-By after hosting-layer removal where supported. - verify-cookie (REASONED; net10): Anonymous cookie API control returns 401; original cookie keeps the same identity with 200 before/after restart and across confirmed replicas, before expiry and without identity changes. - verify-antiforgery (REASONED; net10): Retain an antiforgery token and cookie across restart and submit without replacement; key loss can invalidate it independently of login recovery. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-09", "body_sha256": "491cc1c3bc0a7bf2272de9ea1c5c47d04f5cd7a8202aedafa9b844145ff851f0", "components": { "docs": { "name": "ASP.NET Core documentation", "basis": ".NET 10", "sources": { "s8c762260991c": "https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl?view=aspnetcore-10.0", "s74dcd6a72d63": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0", "s5701d26de241": "https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0", "s3790c49f70b9": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity?view=aspnetcore-10.0", "s9d10c561bb48": "https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.passwordhasheroptions?view=aspnetcore-10.0", "sa3c22a38f4cb": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/cookie?view=aspnetcore-10.0", "sb6780977fa3f": "https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.cookiesecurepolicy?view=aspnetcore-10.0", "s592dc85bb71f": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-oidc-web-authentication?view=aspnetcore-10.0", "s6afc0255098f": "https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler.dangerousacceptanyservercertificatevalidator?view=net-10.0", "s2d5a930aac35": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/mfa?view=aspnetcore-10.0" } }, "rate-min": { "name": "ASP.NET Core rate-limiter minimum", "basis": ".NET 7", "sources": { "s6afb78d1fd7a": "https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit?view=aspnetcore-10.0" } }, "net10": { "name": "ASP.NET Core", "basis": ".NET 10", "sources": { "s74dcd6a72d63": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0", "s5701d26de241": "https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0", "s0d7c367ac06f": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity-configuration?view=aspnetcore-10.0", "sc0654e58fc4c": "https://learn.microsoft.com/en-us/aspnet/core/security/samesite?view=aspnetcore-10.0", "s837df4b1657f": "https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/10/cookie-authentication-api-endpoints?view=aspnetcore-10.0", "s2f7e6a8213f1": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-10.0", "s61afb89034f8": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/environments?view=aspnetcore-10.0", "s9d290374f4e8": "https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.migrationsendpointextensions.usemigrationsendpoint?view=aspnetcore-10.0", "s1ce47370d948": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/options?view=aspnetcore-10.0", "s55f298bf0048": "https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.disablerequestsizelimitattribute?view=aspnetcore-10.0", "s792d3e8f3e42": "https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads?view=aspnetcore-10.0", "s529a3d7cce08": "https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/iis/in-process-hosting?view=aspnetcore-10.0", "s6682926c8da7": "https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview?view=aspnetcore-10.0", "s66f1ab4555d6": "https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/default-settings?view=aspnetcore-10.0", "sd635cefc0a9f": "https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.dataprotection.dataprotectionbuilderextensions.protectkeyswithcertificate?view=aspnetcore-10.0", "s9463626a3d0e": "https://learn.microsoft.com/en-us/aspnet/core/security/cookie-sharing?view=aspnetcore-10.0", "s1d58219cedec": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/aspnetcore-openapi?view=aspnetcore-10.0", "s1f9d4c5e975c": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/using-openapi-documents?view=aspnetcore-10.0", "sc41b77cc06c8": "https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks?view=aspnetcore-10.0", "se101626c29d6": "https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/host-filtering?view=aspnetcore-10.0" } }, "source": { "name": "ASP.NET Core source", "basis": "v10.0.0", "sources": { "sb62cff70e3b8": "https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Middleware/HttpsPolicy/src/HstsOptions.cs", "sfc86e3f62243": "https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Middleware/HttpOverrides/src/ForwardedHeadersOptions.cs", "s903da00d32b5": "https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Servers/Kestrel/Core/src/KestrelServerLimits.cs", "sc00d8d67e93b": "https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Servers/Kestrel/Core/src/Http2Limits.cs", "s67f347ba7947": "https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Http/Http/src/Features/FormOptions.cs", "s18c44d6c2fb7": "https://raw.githubusercontent.com/dotnet/aspnetcore/v10.0.0/src/Servers/Kestrel/Core/src/KestrelServerOptions.cs" } }, "proxy-fix": { "name": "ASP.NET Core proxy hardening", "basis": "8.0.17", "sources": { "s2864662bcd21": "https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/8/forwarded-headers-unknown-proxies?view=aspnetcore-10.0" } }, "runtime": { "name": "NET runtime source", "basis": "v10.0.0", "sources": { "s29ee940c94b9": "https://raw.githubusercontent.com/dotnet/runtime/v10.0.0/src/native/libs/System.Security.Cryptography.Native/pal_x509_root.c" } }, "precedence": { "name": "WebApplicationBuilder precedence", "basis": ".NET 7", "sources": { "s10d46bc97fef": "https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/7/environment-variable-precedence?view=aspnetcore-10.0" } }, "dotnet-rolling": { "name": ".NET documentation (rolling)", "basis": "unknown", "sources": { "s640872a17a8a": "https://learn.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography" } }, "openssl": { "name": "OpenSSL documentation", "basis": "3.0", "sources": { "scac6dada5f6a": "https://docs.openssl.org/3.0/man3/SSL_CTX_load_verify_locations/" } }, "iis": { "name": "IIS documentation", "basis": "IIS 10", "sources": { "sb2eac755b583": "https://learn.microsoft.com/en-us/iis/configuration/system.webserver/security/requestfiltering/" } }, "iis-rolling": { "name": "IIS documentation (rolling)", "basis": "unknown", "sources": { "s267eddd43502": "https://learn.microsoft.com/en-us/iis/configuration/system.webserver/httpprotocol/customheaders/" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s6f6104362214": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85", "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24", "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16", "s863803bd9a1f": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L48-L48" } } }, "claims": { "tls": {"text": "Kestrel serves wildcard HTTP 80 and HTTPS 443 with PKCS12 or PEM Path/KeyPath; supply certificate passwords from environment or a secret store.", "components": ["docs", "net10"], "sources": ["docs:s74dcd6a72d63", "net10:s74dcd6a72d63"], "status": "REASONED"}, "tls-protocols": {"text": "Default SslProtocols.None delegates to the OS; example explicitly permits only TLS 1.2 and 1.3.", "components": ["net10"], "sources": ["net10:s74dcd6a72d63"], "status": "REASONED"}, "hsts-age": {"text": "NET 10 HSTS defaults to 30 days; example selects 365 days outside Development.", "components": ["source"], "sources": ["source:sb62cff70e3b8"], "status": "REASONED"}, "hsts-subdomains": {"text": "HSTS IncludeSubDomains defaults false; example enables it only when all affected subdomains support HTTPS.", "components": ["source"], "sources": ["source:sb62cff70e3b8"], "status": "REASONED"}, "hsts-preload": {"text": "HSTS Preload defaults false.", "components": ["source"], "sources": ["source:sb62cff70e3b8"], "status": "REASONED"}, "redirect": {"text": "UseHttpsRedirection needs an HTTPS port via ASPNETCORE_HTTPS_PORT=443 or HttpsPort.", "components": ["docs"], "sources": ["docs:s8c762260991c"], "status": "REASONED"}, "proxy-bind": {"text": "ListenLocalhost(5000), or localhost:5000 URLs, binds behind a same-host proxy; process forwarded headers first.", "components": ["docs", "net10"], "sources": ["docs:s74dcd6a72d63", "net10:s5701d26de241"], "status": "REASONED"}, "proxy-default": {"text": "NET 10 trusts ::1 and 127.0.0.0/8 by default; adding 127.0.0.1 does not narrow that list.", "components": ["net10", "source"], "sources": ["net10:s5701d26de241", "source:sfc86e3f62243"], "status": "REASONED"}, "proxy-networks": {"text": "NET 10 replaces obsolete KnownNetworks with KnownIPNetworks/System.Net.IPNetwork; narrow both lists together and never leave both empty.", "components": ["net10", "source"], "sources": ["net10:s5701d26de241", "source:sfc86e3f62243"], "status": "REASONED"}, "proxy-hardening": {"text": "Unknown-proxy headers are ignored in 8.0.17 and 9.0.6 and NET 10, even without X-Forwarded-For processing; register the real proxy.", "components": ["net10", "proxy-fix"], "sources": ["net10:s5701d26de241", "proxy-fix:s2864662bcd21"], "status": "REASONED"}, "proxy-https": {"text": "When the proxy handles redirects/HSTS, omit the app middleware; missing forwarded-header handling can cause redirect loops.", "components": ["docs"], "sources": ["docs:s8c762260991c", "docs:s5701d26de241"], "status": "REASONED"}, "password": {"text": "Identity uses PBKDF2 with default 100000 iterations; keep its password hasher.", "components": ["docs"], "sources": ["docs:s3790c49f70b9", "docs:s9d10c561bb48"], "status": "REASONED"}, "lockout": {"text": "Configure five failures, five-minute lockout and allowance for new users; PasswordSignInAsync must use lockoutOnFailure=true, while the template uses false.", "components": ["net10"], "sources": ["net10:s0d7c367ac06f"], "status": "REASONED"}, "cookies": {"text": "Configure HttpOnly, SecurePolicy.Always, SameSite=Lax and eight-hour expiry; cookie-only auth uses the same options.", "components": ["docs", "net10"], "sources": ["docs:sa3c22a38f4cb", "docs:sb6780977fa3f", "net10:s0d7c367ac06f"], "status": "REASONED"}, "samesite": {"text": "NET 10 auth cookie defaults Lax; Strict can suppress cross-site app cookies without universally breaking OAuth/OIDC callbacks.", "components": ["net10"], "sources": ["net10:sc0654e58fc4c"], "status": "REASONED"}, "remote-cookies": {"text": "Correlation and OIDC nonce cookies separately default None; preserve secure cross-site settings and avoid global rewriting.", "components": ["net10"], "sources": ["net10:sc0654e58fc4c"], "status": "REASONED"}, "authorization": {"text": "Run authentication before authorization and Map calls; use an authenticated fallback policy and explicitly AllowAnonymous on public pages.", "components": ["docs"], "sources": ["docs:sa3c22a38f4cb"], "status": "REASONED"}, "login-limit": {"text": "NET 7+ built-in rate limiting: login policy permits 20 per 15 minutes, no queue, 429 rejection; endpoint policies need UseRateLimiter after routing.", "components": ["rate-min"], "sources": ["rate-min:s6afb78d1fd7a"], "status": "REASONED"}, "oidc": {"text": "Configure OpenIdConnect code flow with cookie DefaultScheme and OIDC DefaultChallengeScheme; keep client secrets outside appsettings.json.", "components": ["docs"], "sources": ["docs:s592dc85bb71f"], "status": "REASONED"}, "mfa": {"text": "Enforce MFA at the identity provider or fronting layer; this guide recommends Cloudflare Access independent MFA for a self-hosted public app only with effective MFA enforcement (policy over application over organization) and no origin bypass. The cited pages do not establish this deployment's enforcement or origin isolation.", "components": ["docs", "cf-docs"], "sources": ["docs:s592dc85bb71f", "docs:s2d5a930aac35", "cf-docs:s6f6104362214", "cf-docs:s7e793d13cf31", "cf-docs:sfddb92e438ca", "cf-docs:s863803bd9a1f"], "status": "REASONED"}, "client-validation": {"text": "Never accept every certificate through DangerousAcceptAnyServerCertificateValidator or an always-true callback.", "components": ["docs"], "sources": ["docs:s6afc0255098f"], "status": "REASONED"}, "client-ca": {"text": "On Linux NET 10 install internal roots or use SSL_CERT_FILE for a PEM file and SSL_CERT_DIR for a certificate directory; preserve needed public roots.", "components": ["runtime", "openssl", "dotnet-rolling"], "sources": ["dotnet-rolling:s640872a17a8a", "runtime:s29ee940c94b9", "openssl:scac6dada5f6a"], "status": "REASONED"}, "developer-errors": {"text": "Development automatically enables the Developer Exception Page; production must exclude it and check the effective environment.", "components": ["net10"], "sources": ["net10:s2f7e6a8213f1", "net10:s61afb89034f8"], "status": "REASONED"}, "error-handler": {"text": "Install UseExceptionHandler early after forwarding; provide a generic /Error route supporting failed methods/anonymous requests and preserve intentional 413 responses.", "components": ["net10"], "sources": ["net10:s2f7e6a8213f1"], "status": "REASONED"}, "environment": {"text": "Production is default absent overrides; NET 7+ WebApplicationBuilder prioritizes command-line/DOTNET_ over ASPNETCORE_, while older WebHost differs.", "components": ["net10", "precedence"], "sources": ["net10:s61afb89034f8", "precedence:s10d46bc97fef"], "status": "REASONED"}, "database-diagnostics": {"text": "Register database developer exception filters before Build and migrations middleware after Build only in Development; migrations execute database changes.", "components": ["net10"], "sources": ["net10:s2f7e6a8213f1", "net10:s9d290374f4e8"], "status": "REASONED"}, "body-limit": {"text": "NET 10 Kestrel total body default is 30000000 bytes, null unlimited; example lowers it to 1048576 bytes.", "components": ["net10", "source"], "sources": ["net10:s1ce47370d948", "source:s903da00d32b5"], "status": "REASONED"}, "body-override": {"text": "RequestSizeLimit overrides before body reading; DisableRequestSizeLimit removes the protection.", "components": ["net10"], "sources": ["net10:s1ce47370d948", "net10:s55f298bf0048"], "status": "REASONED"}, "multipart": {"text": "MultipartBodyLengthLimit defaults 134217728 bytes per parsed section; example uses 1048576. Framing consumes total allowance and parsing failure need not return 413.", "components": ["source", "net10"], "sources": ["source:s67f347ba7947", "net10:s792d3e8f3e42"], "status": "REASONED"}, "iis-body": {"text": "In-process IISServerOptions body default is 30000000, separately from IIS maxAllowedContentLength default 30000000; out-of-process IIS disables Kestrel body limiting.", "components": ["net10"], "sources": ["net10:s1ce47370d948", "net10:s792d3e8f3e42", "net10:s529a3d7cce08"], "status": "REASONED"}, "keepalive": {"text": "Kestrel KeepAliveTimeout defaults to 130 seconds; it is not a total execution deadline.", "components": ["net10", "source"], "sources": ["net10:s1ce47370d948", "source:s903da00d32b5"], "status": "REASONED"}, "header-timeout": {"text": "Kestrel RequestHeadersTimeout defaults to 30 seconds; it is not a total execution deadline.", "components": ["net10", "source"], "sources": ["net10:s1ce47370d948", "source:s903da00d32b5"], "status": "REASONED"}, "connections": {"text": "MaxConcurrentConnections defaults null/unlimited; example caps ordinary connections at 100.", "components": ["source"], "sources": ["source:s903da00d32b5"], "status": "REASONED"}, "upgraded": {"text": "Upgraded connections leave the ordinary count; MaxConcurrentUpgradedConnections separately defaults null.", "components": ["net10", "source"], "sources": ["net10:s1ce47370d948", "source:s903da00d32b5"], "status": "REASONED"}, "header-count": {"text": "Kestrel MaxRequestHeaderCount defaults 100.", "components": ["source"], "sources": ["source:s903da00d32b5"], "status": "REASONED"}, "header-size": {"text": "Kestrel MaxRequestHeadersTotalSize defaults 32768 bytes.", "components": ["source"], "sources": ["source:s903da00d32b5"], "status": "REASONED"}, "request-rate": {"text": "MinRequestBodyDataRate defaults 240 bytes/second with five-second grace.", "components": ["source"], "sources": ["source:s903da00d32b5"], "status": "REASONED"}, "response-rate": {"text": "MinResponseDataRate defaults 240 bytes/second with five-second grace.", "components": ["source"], "sources": ["source:s903da00d32b5"], "status": "REASONED"}, "rate-scope": {"text": "HTTP/2 per-request rate adjustments differ from HTTP/1.x; several timeout/rate checks are disabled under a debugger.", "components": ["net10"], "sources": ["net10:s1ce47370d948"], "status": "REASONED"}, "h2-streams": {"text": "NET 10 HTTP/2 MaxStreamsPerConnection defaults 100.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-table": {"text": "NET 10 HTTP/2 HeaderTableSize defaults 4096 bytes.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-frame": {"text": "NET 10 HTTP/2 MaxFrameSize defaults 16384 bytes.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-header": {"text": "NET 10 HTTP/2 MaxRequestHeaderFieldSize defaults 32768 bytes, not the older 8192.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-connection-window": {"text": "NET 10 HTTP/2 InitialConnectionWindowSize defaults 1048576 bytes, not the older 131072.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-stream-window": {"text": "NET 10 HTTP/2 InitialStreamWindowSize defaults 786432 bytes, not the older 98304.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-ping-delay": {"text": "NET 10 HTTP/2 KeepAlivePingDelay defaults TimeSpan.MaxValue, disabling pings.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-ping-timeout": {"text": "NET 10 HTTP/2 KeepAlivePingTimeout defaults 20 seconds.", "components": ["source"], "sources": ["source:sc00d8d67e93b"], "status": "REASONED"}, "h2-scope": {"text": "Flow-control windows bound outstanding buffered data, not total bodies; Http2Limits do not configure HTTP/3.", "components": ["net10", "source"], "sources": ["net10:s1ce47370d948", "source:sc00d8d67e93b"], "status": "REASONED"}, "key-defaults": {"text": "Data Protection key persistence depends on hosting: user profiles, IIS or Azure may persist; process-only fallback and disposable container layers can lose keys.", "components": ["net10"], "sources": ["net10:s66f1ab4555d6"], "status": "REASONED"}, "key-persistence": {"text": "Persist keys durably with restricted permissions; replicas share repository, application name and decryption certificates, not merely identical directory names.", "components": ["net10"], "sources": ["net10:s6682926c8da7", "net10:sd635cefc0a9f"], "status": "REASONED"}, "key-encryption": {"text": "Choosing an explicit repository disables automatic at-rest encryption; configure ProtectKeysWithCertificate and retain needed keys/certificates.", "components": ["net10"], "sources": ["net10:s6682926c8da7", "net10:sd635cefc0a9f"], "status": "REASONED"}, "key-isolation": {"text": "Separate repositories, permissions and certificate private keys for untrusted apps; SetApplicationName is not isolation from another holder of master keys.", "components": ["net10"], "sources": ["net10:s6682926c8da7"], "status": "REASONED"}, "cookie-sharing": {"text": "Cookie continuity also needs compatible cookie names, schemes, stores and identity-validation configuration.", "components": ["net10"], "sources": ["net10:s9463626a3d0e"], "status": "REASONED"}, "server-header": {"text": "NET 10 AddServerHeader defaults true; set false and check all hosting layers since header removal does not control access.", "components": ["source"], "sources": ["source:s18c44d6c2fb7"], "status": "REASONED"}, "iis-headers": {"text": "Remove IIS X-Powered-By and set removeServerHeader; the latter requires IIS 10 with Windows Server/Windows 10 version 1709 or later.", "components": ["iis", "iis-rolling"], "sources": ["iis-rolling:s267eddd43502", "iis:sb2eac755b583"], "status": "REASONED"}, "openapi": {"text": "NET 10 built-in OpenAPI requires AddOpenApi before Build; map only in Development or RequireAuthorization with an appropriate policy.", "components": ["net10"], "sources": ["net10:s1d58219cedec"], "status": "REASONED"}, "swagger": {"text": "Built-in OpenAPI has no Swagger UI; keep separately added UseSwaggerUI in Development or protect it separately from the document.", "components": ["net10"], "sources": ["net10:s1f9d4c5e975c"], "status": "REASONED"}, "health": {"text": "Register health checks and authorize /healthz; monitoring must authenticate or use a deliberately controlled minimal liveness route.", "components": ["net10"], "sources": ["net10:sc41b77cc06c8"], "status": "REASONED"}, "health-host": {"text": "RequireHost checks a spoofable Host header and is not a network or authentication boundary.", "components": ["net10"], "sources": ["net10:sc41b77cc06c8"], "status": "REASONED"}, "host-filter": {"text": "AllowedHosts uses semicolon-separated names without ports; wildcard permits all. It neither authenticates nor binds interfaces; forwarded-host allowlisting is separate.", "components": ["net10"], "sources": ["net10:s5701d26de241", "net10:se101626c29d6"], "status": "REASONED"}, "verify-redirect": {"text": "HTTP should return 307/308 with HTTPS Location.", "components": ["docs"], "sources": ["docs:s8c762260991c"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "HTTPS must validate without -k and show HSTS.", "components": ["docs", "net10"], "sources": ["docs:s8c762260991c", "net10:s74dcd6a72d63"], "status": "REASONED", "verify": [1]}, "verify-api": {"text": "Known protected NET 10 cookie API endpoints return 401 anonymous and 403 forbidden; pages/OIDC/custom handlers may redirect. Confirm authorized success first.", "components": ["net10"], "sources": ["net10:s837df4b1657f"], "status": "REASONED", "verify": [1]}, "verify-bind": {"text": "Inspect every listener for 127.0.0.1 and ::1 behind the proxy.", "components": ["docs", "net10"], "sources": ["docs:s74dcd6a72d63", "net10:s74dcd6a72d63"], "status": "REASONED", "verify": [1]}, "verify-errors": {"text": "Controlled exception should expose marker/detail under development and generic 500 under production; inspect plain/HTML bodies and correlate handler execution.", "components": ["net10"], "sources": ["net10:s2f7e6a8213f1"], "status": "REASONED", "verify": [2]}, "verify-body": {"text": "Body-consuming test route returns 204 at 1048576 bytes, 413 at 1048577; raised limit permits both. Isolate other limits and distinguish upstream rejection.", "components": ["net10"], "sources": ["net10:s1ce47370d948"], "status": "REASONED", "verify": [3]}, "verify-aux": {"text": "Probe actual diagnostic/OpenAPI/UI/health routes anonymously, then authorized; Development-only routes should be absent and retained routes protected. Status alone does not prove authorization.", "components": ["net10"], "sources": ["net10:s1d58219cedec", "net10:s1f9d4c5e975c", "net10:sc41b77cc06c8"], "status": "REASONED", "verify": [4]}, "verify-migrations": {"text": "GET cannot prove migrations middleware absent; inspect registration and test actual behavior only in isolated database infrastructure.", "components": ["net10"], "sources": ["net10:s9d290374f4e8"], "status": "REASONED"}, "verify-headers": {"text": "Inspect public success, redirect and error responses for absent Server/X-Powered-By after hosting-layer removal where supported.", "components": ["source", "iis", "iis-rolling"], "sources": ["source:s18c44d6c2fb7", "iis-rolling:s267eddd43502", "iis:sb2eac755b583"], "status": "REASONED", "verify": [5]}, "verify-cookie": {"text": "Anonymous cookie API control returns 401; original cookie keeps the same identity with 200 before/after restart and across confirmed replicas, before expiry and without identity changes.", "components": ["net10"], "sources": ["net10:s837df4b1657f", "net10:s66f1ab4555d6", "net10:s9463626a3d0e"], "status": "REASONED", "verify": [6]}, "verify-antiforgery": {"text": "Retain an antiforgery token and cookie across restart and submit without replacement; key loss can invalidate it independently of login recovery.", "components": ["net10"], "sources": ["net10:s6682926c8da7", "net10:s66f1ab4555d6"], "status": "REASONED"} } } ```` ====================================================================== ==> java.md ====================================================================== # Spring Boot: TLS and authentication Spring Boot is a Java framework for building web apps that run on a built-in web server. This guide helps your AI assistant deploy it securely, including TLS directly or behind a reverse proxy, authentication, Actuator endpoints limited and bound to a private management port, and production error responses. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/java.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Spring Boot 4.1.2, Spring Boot starter migration 4.0, Spring Security HSTS 6.5, JDK keytool documentation 21, Tomcat HTTP connector 10.1, curl minimum diagnostics version 7.75.0, OpenJDK jdk21u javax.net.ssl sources jdk-21.0.12.1-ga, and Authelia source v4.39.28; 2 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-10-19; documentation checked September 2026. The guide records 57 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Preferred production layout: bind the embedded server to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) or behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Spring Boot can also terminate TLS itself, shown below. Certificates: [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). ## 1. HTTPS directly in Spring Boot PEM certificate and key, or a PKCS12 keystore (the PEM properties are in the current Spring Boot how-to; on an older release, confirm your version's reference lists `server.ssl.certificate` before relying on it): ```properties server.port=8443 server.ssl.certificate=file:/etc/ssl/certs/server.crt server.ssl.certificate-private-key=file:/etc/ssl/private/server.key server.ssl.enabled-protocols=TLSv1.2,TLSv1.3 # PKCS12 keystore instead of the two PEM lines: # server.ssl.key-store=file:/etc/ssl/private/server.p12 # server.ssl.key-store-password=REPLACE_WITH_LONG_RANDOM_VALUE # server.ssl.key-store-type=PKCS12 ``` Spring Boot configures one connector from properties, HTTP or HTTPS, not both; the how-to recommends HTTPS in properties and an HTTP connector added in code if a port 80 redirect is needed. With Spring Security 6.5 or later, `http.redirectToHttps(withDefaults())` in the `SecurityFilterChain` sends requests that arrive over HTTP to HTTPS, and Spring Security's default writer emits `Strict-Transport-Security` only when the request's `isSecure()` returns `true`. The HSTS defaults are one year (`max-age=31536000`), `includeSubDomains=true`, and `preload=false`. Behind TLS termination, trusted forwarded-header handling matters because it determines whether the application sees the request as secure. In the proxy layout, let the proxy do the redirect and skip the second connector. ## 2. Behind a proxy ```properties server.address=127.0.0.1 server.port=8080 server.forward-headers-strategy=NATIVE ``` `NATIVE` lets the embedded server (Tomcat by default) honour `X-Forwarded-For` and `X-Forwarded-Proto`; `FRAMEWORK` uses Spring's `ForwardedHeaderFilter` instead; the default outside supported cloud platforms is `NONE`. With Tomcat, `server.tomcat.remoteip.internal-proxies` restricts which proxy addresses those headers are accepted from, and `server.tomcat.redirect-context-root=false` keeps redirects on HTTPS when TLS ends at the proxy. The edge proxy must strip inbound `Forwarded` and `X-Forwarded-*` headers before setting trusted values. Restrict direct access to the application listener so clients cannot bypass that boundary. Spring's [forwarded-header documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/ForwardedHeaderFilter.html) explicitly requires removing externally supplied forwarded headers at the trust boundary. For code that imports server configuration, `ServerProperties` is `org.springframework.boot.autoconfigure.web.ServerProperties` in Boot 3.5 and `org.springframework.boot.web.server.autoconfigure.ServerProperties` in Boot 4. See the [3.5.16 API](https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ServerProperties.html) and [4.1.2 API](https://docs.spring.io/spring-boot/4.1/api/java/org/springframework/boot/web/server/autoconfigure/ServerProperties.html). ## 3. Authentication Follow [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). With `spring-boot-starter-security` on the classpath and Boot's default web security configuration active, application endpoints require authentication, except that Actuator health is permitted when Actuator is present; form login and HTTP Basic are on, and CSRF protection and security headers are on. Defining any custom `SecurityFilterChain` makes Boot's default web security configuration back off, including its Actuator rules. The separate default-user auto-configuration, when applicable, creates a single user `user` with a generated password normally logged at startup; a custom filter chain alone does not remove that user. That user is for development only; `spring.security.user.name` and `spring.security.user.password` replace it for local use. Production needs deliberately configured authentication: OAuth2/OIDC does not require a local `UserDetailsService`; for locally stored accounts, configure a real `UserDetailsService` with this encoder: ```java @Bean PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default, stored as {bcrypt}... } ``` `BCryptPasswordEncoder(strength)` defaults to strength 10; the docs say to tune it to about 1 second per verification. `Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()` is the alternative. Session cookie flags and lifetime: ```properties server.servlet.session.cookie.secure=true server.servlet.session.cookie.http-only=true server.servlet.session.cookie.same-site=lax server.servlet.session.timeout=30m ``` The Spring Security reference pages checked document no built-in login rate limiter: limit `/login` at the reverse proxy ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)), or count failures and lock the account in your own `UserDetailsService`. SSO: use `spring-boot-starter-oauth2-client` for Boot 3.5 or `spring-boot-starter-security-oauth2-client` for Boot 4, plus properties and `http.oauth2Login(withDefaults())`. Boot 4 deprecates the old starter name; see the [Boot 4.0 starter reference](https://docs.spring.io/spring-boot/4.0/reference/using/build-systems.html) and [4.0 migration guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide): ```properties spring.security.oauth2.client.registration.sso.client-id=REPLACE_WITH_CLIENT_ID spring.security.oauth2.client.registration.sso.client-secret=${SSO_CLIENT_SECRET} spring.security.oauth2.client.registration.sso.provider=sso spring.security.oauth2.client.registration.sso.scope=openid,profile,email spring.security.oauth2.client.provider.sso.issuer-uri=https://idp.example.com/ ``` The `issuer-uri` drives OpenID Connect discovery, and the `openid` scope is what makes the registration an OpenID Connect login that returns an ID token; without it the login is plain OAuth2. Allowlist and token checks are in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), providers in [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md). MFA: enforce it at the identity provider, or front the app per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Examples of layers that can require MFA, cited at the pins in Sources: a Microsoft Entra Conditional Access policy can grant access only with the built-in Multifactor authentication strength; and Cloudflare Access independent MFA lets Access enforce MFA requirements without relying on the identity provider. Cloudflare Access independent MFA, as cited at the pins in Sources, prompts for an additional authentication method before granting access to the application. Authelia, another example of a layer that can require MFA cited at the pins in Sources, supports configuring TOTP, WebAuthn security keys and Duo mobile push as second factors. None of those pages says a layer enforces MFA by default, and none establishes that this deployment's provider or fronting layer requires MFA; checking the effective policy and testing a login without the second factor are this guide's requirements. Do not disable CSRF protection merely because an API is "stateless". Browsers can automatically attach authentication cookies or HTTP Basic credentials, so those requests remain vulnerable to CSRF. Decide from how clients supply credentials, not from whether the application stores a session. See Spring Security's [stateless-browser qualification](https://docs.spring.io/spring-security/reference/features/exploits/csrf.html#csrf-and-stateless-browser-applications). Spring Security's default session fixation protection uses `changeSessionId` on Servlet 3.1 and newer containers: an existing session receives a new ID on authentication. Keep this protection enabled. See [session fixation protection](https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html#understanding-session-fixation-attack-protection). ## 4. Client-side TLS discipline - Never install a trust-all `TrustManager` or hostname verifier, and never copy one from an answer that "fixes" a certificate error. The pinned jdk21u sources document what such code switches off: `X509TrustManager` instances manage which X509 certificates may be used to authenticate the remote side of a secure socket, and its `checkClientTrusted` and `checkServerTrusted` methods throw `CertificateException` when a chain is not trusted. The pinned jdk21u sources also document, as part of what such code switches off, that `HostnameVerifier.verify` decides whether a connection is allowed when the URL hostname and the server's identification hostname mismatch, and that `SSLContext.init` takes trust managers as that context's sources of peer authentication trust decisions. The scope depends on where the trust-all code lands: `HttpsURLConnection.setDefaultSSLSocketFactory` and `setDefaultHostnameVerifier` set the defaults inherited by `HttpsURLConnection` instances created afterwards, a process-wide default, while the per-instance `setSSLSocketFactory` and `setHostnameVerifier` replace only that one object's factory or verifier. That a trust-all pair disables certificate and hostname validation for every client built from the affected `SSLContext` or default, and any reading as broad as "the whole JVM client", are this guide's assessment of those documented contracts rather than statements on the cited lines; the prohibition applies at every scope. - For an internal CA, either import it into the JDK truststore, `keytool -importcert -cacerts -alias internal-ca -file /path/ca.crt` (the `cacerts` password defaults to `changeit`; change it), or declare an SSL bundle and apply it to the client. The SSL bundle route is to declare the bundle, `spring.ssl.bundle.pem.internal.truststore.certificate=file:/path/ca.crt`, and apply that bundle to the client: `restClientBuilder.apply(ssl.fromBundle("internal"))` with an injected `RestClientSsl` (`WebClientSsl` for `WebClient`). See [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). The client SSL helper imports differ between [Boot 3.5.16](https://docs.spring.io/spring-boot/3.5/reference/io/rest-client.html) and [Boot 4.1.2](https://docs.spring.io/spring-boot/4.1/reference/io/rest-client.html): | Helper | Boot 3.5 | Boot 4 | | --- | --- | --- | | `RestClientSsl` | `org.springframework.boot.autoconfigure.web.client.RestClientSsl` | `org.springframework.boot.restclient.autoconfigure.RestClientSsl` | | `WebClientSsl` | `org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientSsl` | `org.springframework.boot.webclient.autoconfigure.WebClientSsl` | ## 5. Actuator: expose only what you need, bind management privately, authorize administrators Actuator can disclose application internals and provide administrative operations. In Boot 3.5.16 and 4.1.2, `management.endpoints.web.exposure.include` defaults to `health`. Keep an explicit allowlist instead of exposing `*`. Exposure, endpoint access, and HTTP authorization are separate controls. See the [3.5 endpoint reference](https://docs.spring.io/spring-boot/3.5/reference/actuator/endpoints.html) and [4.1 endpoint reference](https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html). ```properties management.endpoints.web.exposure.include=health management.server.port=8081 management.server.address=127.0.0.1 management.endpoint.shutdown.access=none management.endpoint.heapdump.access=none ``` Use a management port different from the main application's port: `management.server.address` can bind a different address only with a separate port. Keep the listener private and do not forward the public proxy to it. To disable management HTTP endpoints entirely, use `management.server.port=-1` instead of `8081`; this does not disable JMX exposure. These settings are documented for [Boot 3.5](https://docs.spring.io/spring-boot/3.5/reference/actuator/monitoring.html) and [Boot 4.1](https://docs.spring.io/spring-boot/4.1/reference/actuator/monitoring.html). Treat individual endpoints according to what they reveal or change: - `env` and `configprops` sanitize values by default; their `show-values` settings default to `never`. Sanitization does not justify exposing configuration metadata publicly. - `beans`, `mappings`, and `threaddump` disclose application components, request routes, and thread stacks. - `heapdump` can expose secrets held in process memory and consume substantial resources. Keep it unavailable outside a controlled diagnostic procedure. - `loggers` can read and change logging levels. Administrative writes can increase logging volume and expose information through logs. - `shutdown` stops the application. Its access defaults to `none`; `heapdump` also defaults to `none` in the specifically checked 3.5.16 and 4.1.2 releases. Do not assume defaults from an older patch release. The endpoint descriptions and sanitization behaviour are documented in the [Boot 3.5 Actuator reference](https://docs.spring.io/spring-boot/3.5/reference/actuator/endpoints.html#actuator.endpoints.sanitization) and [Boot 4.1 Actuator reference](https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html#actuator.endpoints.sanitization). Authorize administrators explicitly. For a servlet application, this example requires `ROLE_ENDPOINT_ADMIN` for every exposed Actuator endpoint, including health. It also supplies a fallback chain for application requests: ```java import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; import static org.springframework.security.config.Customizer.withDefaults; @Configuration(proxyBeanMethods = false) public class DeploymentSecurityConfiguration { @Bean @Order(1) SecurityFilterChain managementSecurity(HttpSecurity http) throws Exception { http.securityMatcher(EndpointRequest.toAnyEndpoint()); http.authorizeHttpRequests(requests -> requests.anyRequest().hasRole("ENDPOINT_ADMIN")); http.httpBasic(withDefaults()); return http.build(); } @Bean @Order(2) SecurityFilterChain applicationSecurity(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests.anyRequest().authenticated()); http.formLogin(withDefaults()); http.httpBasic(withDefaults()); return http.build(); } } ``` The example imports Boot 3.5's `org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest`. For Boot 4, replace that import with `org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest`. Both versions document `EndpointRequest.toAnyEndpoint()` with `securityMatcher`. See the [3.5 example](https://docs.spring.io/spring-boot/3.5/reference/actuator/endpoints.html#actuator.endpoints.security) and [4.1 example](https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html#actuator.endpoints.security). **Any custom `SecurityFilterChain` makes Boot's default web security back off. An Actuator-only chain is therefore insufficient: requests outside its matcher need another chain.** Spring Security does not protect requests that match no chain. Integrate these rules into existing chains rather than adding competing configurations, and retain the application's authentication method and route permissions. For the OIDC setup in section 3, use `http.oauth2Login(withDefaults())` in the application chain instead of its local form login. Provision the administrator authority through the configured authentication system; this example does not create an administrator. See [multiple filter chains](https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#_multiple_httpsecurity_instances). ## 6. Production error responses and development surfaces Keep exception details out of client responses. The following are the defaults checked in Boot 3.5.16 and 4.1.2; declaring them explicitly helps prevent development configuration from becoming production configuration. Boot 3.5: ```properties server.error.include-message=never server.error.include-stacktrace=never server.error.include-binding-errors=never server.error.include-exception=false ``` Boot 4: ```properties spring.web.error.include-message=never spring.web.error.include-stacktrace=never spring.web.error.include-binding-errors=never spring.web.error.include-exception=false ``` Use the property family for the deployed major version. The prefix moved from `server.error` to `spring.web.error`; see the [3.5.16 properties](https://docs.spring.io/spring-boot/3.5/appendix/application-properties/index.html) and [4.1.2 properties](https://docs.spring.io/spring-boot/4.1/appendix/application-properties/index.html). `on-param` is not a production security boundary: the requester can supply the parameter that requests the error attribute. Disabling the whitelabel page alone changes the HTML error presentation; it does not fix information disclosed through JSON responses, custom error handlers, or container error pages. Apply the same disclosure policy to application-defined error responses. See the [error-inclusion options](https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ErrorProperties.IncludeAttribute.html) and [servlet error handling](https://docs.spring.io/spring-boot/3.5/reference/web/servlet.html). Keep the H2 console off in production. This property defaults to `false` in both checked releases: ```properties spring.h2.console.enabled=false ``` Exclude `spring-boot-devtools` from production dependencies and deployed artifacts. When its development defaults are active, it sets error message, stack trace, and binding-error inclusion to `always`, using the version-appropriate prefix, and sets `spring.h2.console.enabled=true`. As this guide's own assessment, console availability still depends on the required H2 components. Verify the production artifact and launch configuration rather than relying only on the normal packaged-application behaviour. See [Boot 3.5 devtools defaults](https://docs.spring.io/spring-boot/3.5/reference/using/devtools.html#using.devtools.property-defaults) and [Boot 4.1 devtools defaults](https://docs.spring.io/spring-boot/4.1/reference/using/devtools.html#using.devtools.property-defaults). ## 7. Bound uploads, forms, and headers Set limits according to the requests the application needs to accept. These property names and defaults are the same in the checked Boot 3.5.16 and 4.1.2 releases: ```properties spring.servlet.multipart.max-file-size=1MB spring.servlet.multipart.max-request-size=10MB server.tomcat.max-http-form-post-size=2MB server.max-http-request-header-size=8KB ``` | Property | Scope | | --- | --- | | `spring.servlet.multipart.max-file-size` | Each uploaded file handled by servlet multipart support. | | `spring.servlet.multipart.max-request-size` | The complete multipart request, including its parts and multipart overhead. | | `server.tomcat.max-http-form-post-size` | Tomcat's processing of form request bodies into parameters; not arbitrary request bodies. | | `server.max-http-request-header-size` | HTTP request headers, with details dependent on the embedded server. Tomcat counts the request line and all header names and values together. | The defaults and header qualifications appear in the [3.5.16 property reference](https://docs.spring.io/spring-boot/3.5/appendix/application-properties/index.html) and [4.1.2 property reference](https://docs.spring.io/spring-boot/4.1/appendix/application-properties/index.html). Tomcat explicitly documents that its [form parsing limit](https://tomcat.apache.org/tomcat-10.1-doc/config/http.html) is not a general request-body limit. None of these properties establishes a universal JSON body limit. Bound other request bodies at the reverse proxy and, where necessary, in the application's request processing. Test the actual content type and route; an authentication failure does not demonstrate a size limit. ## 8. Verify (REASONED: TLS, authentication, listener and generated-user checks follow the Spring Sources below; no deployment outcomes are recorded. The authoring environment limitations are recorded below: no runnable Spring Boot fixture, Maven, Gradle or container runtime.) ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' # the deployment's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 3 (a command failed), which outranks 2 (ss is missing), which outranks 0 curl -q -g -sI --noproxy '*' "https://$1/" || # succeeds without -k; shows Strict-Transport-Security { echo "curl exit $?: the HTTPS request failed; inconclusive"; set -- "$1" 3; } curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' "https://$1/api" || # 401, or 302 to the login page, without credentials { echo "curl exit $?: the /api request failed; inconclusive"; set -- "$1" 3; } if command -v ss >/dev/null; then set -- "$1" "$2" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" printf '%s' "${3%$'\n'*}" # read every listener; java: behind a proxy: 127.0.0.1 only [ "${3##*$'\n'}" = 0 ] || { echo "ss -tlnp failed; inconclusive"; set -- "$1" 3; } set -- "$1" "$2" listed # the 3rd value says whether ss ran, so the last line names the listener list only when it did else echo "ss is not installed here; not listing sockets" case "$2" in 0) set -- "$1" 2 ;; esac set -- "$1" "$2" unlisted fi case "$3" in listed) echo "block exit $2; the HTTPS headers, including Strict-Transport-Security, the /api status and the listener list are not classified, so judge them yourself" ;; *) echo "block exit $2; the HTTPS headers, including Strict-Transport-Security, and the /api status are not classified, so judge them yourself" ;; esac exit "$2" ) ``` ```bash ( # a subshell, so the exits below end only the subshell [ -r app.log ] || { echo "no readable app.log in this directory: run this block where the application writes app.log; not counting"; exit 2; } set -- "$(if grep -c "Using generated security password" app.log; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%%$'\n'*}" # grep's exit status, then the count printf '%s\n' "$2" # a positive count records password generation; zero does not prove the default user is absent case "$1" in 0) echo "FINDING: app.log records $2 generated security password line(s), so the default user was created with a generated password"; set -- 1 ;; 1) echo "a zero generated-password log count is inconclusive"; set -- 3 ;; *) echo "grep exit $1: app.log could not be searched; inconclusive"; set -- 3 ;; esac echo "block exit $1; this block cannot exit 0, because a zero count is inconclusive: check the deployed authentication configuration and exercise the intended login method yourself" exit "$1" ) ``` A zero generated-password log count is inconclusive: logging may suppress the message, and setting `spring.security.user.password` avoids password generation while retaining the default account. Check the deployed authentication configuration and exercise the intended login method. A custom `SecurityFilterChain` alone does not remove default-user auto-configuration, and OAuth2/OIDC does not require introducing a local `UserDetailsService`. See [Boot's security auto-configuration rules](https://docs.spring.io/spring-boot/3.5/reference/web/spring-security.html). REASONED: the deployment checks below are not demonstrated. The authoring environment has no runnable Spring Boot fixture, no Maven or Gradle executable, and no container runtime; its filesystem is read-only and network access is restricted. Shell syntax and negative placeholder-guard cases were checked locally. Those local checks covered the guarded blocks below in an earlier form, whose guard refusals ended with status 0, and no recorded local check covered the later form that ended each block with `) || echo "block exit $?"`. The current form of every block in this section, which reports its result in its exit status, is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block, and the first block's current form is also covered by the stub runs recorded in the next paragraph. Expected deployment outcomes rest on the cited Spring Boot and vendor documentation. REASONED: paste and status guidance, following the cited curl behavior and CONTRIBUTING.md rule 6. Paste each guarded block whole and substitute inside the single quotes. Every block in this section records each classified result and each failed command as it runs and exits once: 0 for a pass or a result it leaves unclassified, 1 for a finding, 2 for a guard refusal and 3 for a failed command or an inconclusive result, a finding outranking an inconclusive result. The first block in this section refuses only its listener inventory when `ss` is missing: it prints `ss is not installed here; not listing sockets` after its requests have run, then still ends with its `block exit` line, which names the listener list only when `ss` ran, and exits 2 only when neither request failed. Stub runs (October 2026, Bash 5.3.9, scratch `ss` and `curl` stubs, no network, under an inherited `readonly` variable, `declare -i` variable and `set -e`) covered that block in an earlier form, whose `block exit` line named the listener list even when `ss` was missing. Stub runs under the same conditions then covered a later form, which lowercased the host with `tr` before refusing reserved names, and exited 0 with `ss` and the stubbed requests passing, 3 with `ss` failing, and 2 with `ss` missing; with `ss` missing, stubbed failed requests gave 3 and the last line did not name the listener list. Its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs on 2026-11-01 (Bash 5.3.9 and dash 0.5.12, the same kind of stubs, no network). In Bash, under the same conditions and with `tr` present or absent, those runs gave the same statuses and refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with status 2 before any request. Under dash they refused every host name with status 2 and the message that the block needs bash, before any request. Unless a guard ends the block early, each block ends with a `block exit` line that gives its status and names the output it leaves for you to judge, so status 0 alone does not mean the check passed. The generated-password count is a block of its own because it cannot exit 0: a zero count is inconclusive. The blocks that read `./java-verify-auth.headers`, `./below-limit.bin`, `./over-limit.bin` or `app.log` refuse with status 2, before any request or count, when a file they need is not readable in the current directory. Supply URLs without a trailing slash where the block appends a path, and without existing query parameters where it appends a query. These blocks require curl 7.75.0 or newer for `exitcode` and `errormsg`. A DNS error, TLS failure, or timeout is not proof that an endpoint is securely configured; inspect the error and confirm the intended service and network path. For authenticated requests, prepare `./java-verify-auth.headers` as a private, mode `0600` file containing the actual request headers for a test identity, one header per line. Use the application's authentication method, such as its session cookie or an accepted authorization header. For uploads, include the matching session and valid CSRF header where required. The blocks pass that file to curl through stdin, keeping credential values out of command arguments. Protect and remove the file after testing. Curl documents [header input and transfer diagnostics](https://curl.se/docs/manpage.html). ### Management reachability and authorization (REASONED: public/private Actuator access and external-port checks follow the cited endpoint and management-listener references; no runnable Spring Boot fixture or external management test deployment is available, as recorded above.) Reasoned check: run this HTTPS sweep from outside the trusted management network against the public application origin to verify that it does not serve Actuator responses to either identity. Include any configured context or management base path in the substituted URL. Use an administrator identity for the authenticated pass. ```bash ( set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_PUBLIC_HOST/actuator' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the quotes on the set -- line; not probing"; exit 2 ;; esac [ -r ./java-verify-auth.headers ] || { echo "no readable ./java-verify-auth.headers in this directory: prepare it as described above; not probing"; exit 2; } set -- "$1" 0 health env configprops beans mappings threaddump loggers # the 2nd value is the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the endpoints still to request while [ "$#" -gt 2 ]; do set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'anonymous %{url_effective}: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1/$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:4}" printf '%s' "${4%$'\n'*}" case "${4##*$'\n'}:${4%$'\n'*}" in 0:*': http=2'??' exit='*) echo "FINDING: the public origin answered $3 with a 2xx for the anonymous identity; confirm it came from the application rather than a proxy or generic frontend"; set -- "$1" 1 "${@:3}" ;; 0:*) ;; *) echo "curl exit ${4##*$'\n'}: the anonymous request for $3 failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "${@:3}" ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 20 \ --header @- -o /dev/null \ -w 'authorized %{url_effective}: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1/$3" < ./java-verify-auth.headers; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:5}" printf '%s' "${4%$'\n'*}" case "${4##*$'\n'}:${4%$'\n'*}" in 0:*': http=2'??' exit='*) echo "FINDING: the public origin answered $3 with a 2xx for the authorized identity; confirm it came from the application rather than a proxy or generic frontend"; set -- "$1" 1 "${@:3}" ;; 0:*) ;; *) echo "curl exit ${4##*$'\n'}: the authorized request for $3 failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "${@:3}" ;; esac set -- "$1" "$2" "${@:5}" # drop this endpoint and its result done echo "block exit $2; answers other than a 2xx are not classified (a 401 or 403 demonstrates access control, not absence), so judge them yourself" exit "$2" ) ``` Reasoned check: run this separate management-authorization sweep on the application host against the HTTP listener at `http://127.0.0.1:8081/actuator`. Adjust the port and base path in both URLs to match your management configuration. Prepare `./java-verify-auth.headers` on that host with an administrator identity accepted by the management security chain. ```bash ( [ -r ./java-verify-auth.headers ] || { echo "no readable ./java-verify-auth.headers in this directory: prepare it on this host as described above; not probing"; exit 2; } set -- 0 health env configprops beans mappings threaddump loggers # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the endpoints still to request while [ "$#" -gt 1 ]; do set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --proto '=http' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'anonymous %{url_effective}: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "http://127.0.0.1:8081/actuator/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:3}" printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:$2:${3%$'\n'*}" in 0:health:*': http=401 exit='*) ;; 0:*': http=2'??' exit='*) echo "FINDING: the anonymous $2 request answered with a 2xx; an anonymous 2xx from a sensitive endpoint needs investigation"; set -- 1 "${@:2}" ;; 0:health:*) echo "the anonymous health request did not get the expected 401; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; 0:*) ;; *) echo "curl exit ${3##*$'\n'}: the anonymous request for $2 failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --proto '=http' --connect-timeout 5 --max-time 20 \ --header @- -o /dev/null \ -w 'authorized %{url_effective}: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "http://127.0.0.1:8081/actuator/$2" < ./java-verify-auth.headers; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:4}" printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:$2:${3%$'\n'*}" in 0:health:*': http=200 exit='*|0:health:*': http=503 exit='*) ;; 0:health:*) echo "the administrator's health request did not get a health response (200, or 503 for an unhealthy status), so check the identity in ./java-verify-auth.headers; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; 0:*) ;; *) echo "curl exit ${3##*$'\n'}: the authorized request for $2 failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac set -- "$1" "${@:4}" # drop this endpoint and its result done echo "block exit $1; the administrator's answers from endpoints other than health are not classified (normally 404 with health-only exposure), so judge them yourself and repeat with a non-administrator identity for the 403" exit "$1" ) ``` With the private-listener layout, the public origin must not serve Actuator responses to either identity. On the reachable management listener, the example security chain should reject anonymous health requests with `401`, reject an authenticated non-administrator with `403`, and allow an administrator to receive the health response. Health itself can report an unhealthy status, commonly `503`, so successful authorization is not synonymous with HTTP `200`. With health-only exposure, the other listed endpoints should remain unavailable even to an administrator, normally returning `404` once authentication succeeds. An anonymous `2xx` from a sensitive endpoint needs investigation; `401` or `403` demonstrates access control, not absence. Confirm that responses come from the intended application rather than a proxy or generic frontend. Repeat the authenticated pass with a non-administrator to test the role boundary. These expectations follow the documented [endpoint exposure and security rules](https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html). Also check the separate port directly from an external machine. This block assumes the HTTP management listener in the proxy layout above; use the actual port and protocol if different: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PUBLIC_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the quotes on the set -- line; not probing"; exit 2 ;; esac set -- "$1" 0 "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "http://$1:8081/actuator/health"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}" in 0) echo "FINDING: an HTTP response, including 401, shows that an HTTP listener is reachable on public port 8081"; set -- "$1" 1 ;; 7) echo "connection refused: consistent with the loopback binding, but corroborate it with ss -tlnp on the server and a successful connection from the intended management path" ;; 28) echo "curl exit 28: an external timeout alone is inconclusive"; set -- "$1" 3 ;; *) echo "curl exit ${3##*$'\n'}: the request failed without an HTTP response; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; a refused connection is not classified until you corroborate it as above" exit "$2" ) ``` Any HTTP response, including `401`, demonstrates that an HTTP listener is reachable on that public port. The fixed loopback binding should prevent that connection. Corroborate external failure with the existing `ss -tlnp` check on the server and a successful connection from the intended management path; an external timeout alone is inconclusive. See [management address binding](https://docs.spring.io/spring-boot/3.5/reference/actuator/monitoring.html#actuator.monitoring.customizing-management-server-address). The sweep deliberately omits heap-dump generation and shutdown. Verify their explicit `access=none` settings in the effective deployment configuration. Demonstrating their exposed behaviour belongs in an isolated fixture: a heap-dump request can allocate substantial resources, and a shutdown request can stop the process. ### Error-body leakage (REASONED: error disclosure checks follow the cited error-inclusion references; the recorded authoring environment has no runnable Spring Boot fixture.) Reasoned check: use an isolated test deployment with a known route that triggers a controlled exception containing a recognizable, non-secret marker. Supply that route below, then repeat for a known binding-error case. Use credentials that allow the request to reach the handler. A missing URL alone does not exercise exception or binding-error disclosure. ```bash ( set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_HOST/REPLACE_WITH_TEST_ERROR_PATH' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the quotes on the set -- line; not probing"; exit 2 ;; esac [ -r ./java-verify-auth.headers ] || { echo "no readable ./java-verify-auth.headers in this directory: prepare it as described above; not probing"; exit 2; } set -- "$1" 0 "$(if curl -q -g -sS -i --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 20 \ --header @- -H 'Accept: application/json' \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1?trace=true&message=true&errors=true" < ./java-verify-auth.headers; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:${3%$'\n'*}" in 0:*$'\n''http=401 exit='*|0:*$'\n''http=403 exit='*) echo "an authentication response does not test error handling; inconclusive"; set -- "$1" 3 ;; 0:*) ;; *) echo "curl exit ${3##*$'\n'}: the request failed; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; the error status and the body are not classified, so read them for the marker, stack frames, internal paths and rejected values yourself" exit "$2" ) ``` In the exposed fixture, `always` or `on-param` inclusion can reveal the controlled message, trace, or binding details. With the production settings, Boot's standard error response should omit those details and the exception class even with these parameters. Confirm the expected error status and inspect the body for the marker, stack frames, internal paths, and rejected values. An authentication response does not test error handling. Repeat with `Accept: text/html` and check custom error responses separately. See the [error-inclusion semantics](https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ErrorProperties.IncludeAttribute.html) and [versioned production defaults](https://docs.spring.io/spring-boot/4.1/appendix/application-properties/index.html). ### H2 console absence (REASONED: console absence checks follow the cited console properties and devtools defaults; the recorded authoring environment has no runnable Spring Boot fixture.) Reasoned check: test both anonymously and with an identity allowed through application security. Substitute the configured console path if it differs from `/h2-console/`. ```bash ( set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_HOST/h2-console/' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the quotes on the set -- line; not probing"; exit 2 ;; esac [ -r ./java-verify-auth.headers ] || { echo "no readable ./java-verify-auth.headers in this directory: prepare it as described above; not probing"; exit 2; } set -- "$1" 0 "$(if curl -q -g -sS -i --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 20 \ -w '\nanonymous: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status so far: 3 (inconclusive) outranks 0 printf '%s' "${3%$'\n'*}" [ "${3##*$'\n'}" = 0 ] || { echo "curl exit ${3##*$'\n'}: the anonymous request failed; inconclusive"; set -- "$1" 3; } set -- "$1" "$2" "$(if curl -q -g -sS -i --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 20 \ --header @- -w '\nauthorized: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1" < ./java-verify-auth.headers; then printf '\n0'; else printf '\n%s' "$?"; fi)" printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:${3%$'\n'*}" in 0:*$'\n''authorized: http=404 exit='*) ;; 0:*) echo "the authorized request did not reach the 404 missing-route answer: H2 content is the finding, and a login redirect, 401 or 403 alone does not establish absence; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit ${3##*$'\n'}: the authorized request failed; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; the anonymous response, and any H2 content in either response, are not classified, so judge them yourself" exit "$2" ) ``` An enabled, reachable console can return H2 content or a redirect into it. With the console disabled, a request that passes application security should reach normal missing-route handling, typically `404`, without H2 content. A login redirect, `401`, or `403` alone does not establish absence. Confirm the effective `spring.h2.console.enabled=false` setting and absence of production devtools as well. See the [console properties](https://docs.spring.io/spring-boot/4.1/appendix/application-properties/index.html) and [devtools overrides](https://docs.spring.io/spring-boot/4.1/reference/using/devtools.html#using.devtools.property-defaults). ### Over-limit upload (REASONED: multipart discrimination follows the cited multipart limits; the recorded authoring environment has no runnable Spring Boot upload fixture.) Reasoned check: use a test upload route that accepts a multipart file field named `file`. Prepare `./below-limit.bin` with 1,024 bytes and `./over-limit.bin` with 1,048,577 bytes of non-secret test data. These sizes bracket the configured `1MB` file limit while remaining below the `10MB` request limit. Supply valid authentication and CSRF headers for both requests, and adapt the field name to the real handler. ```bash ( set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_HOST/REPLACE_WITH_UPLOAD_PATH' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the value inside the quotes on the set -- line; not probing"; exit 2 ;; esac if [ -r ./java-verify-auth.headers ] && [ -r ./below-limit.bin ] && [ -r ./over-limit.bin ]; then :; else echo "./java-verify-auth.headers, ./below-limit.bin and ./over-limit.bin must all be readable in this directory: prepare them as described above; not probing"; exit 2 fi set -- "$1" 0 "$(if curl -q -g -sS -i --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 30 \ --header @- --form 'file=@./below-limit.bin' \ -w '\nbelow limit: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1" < ./java-verify-auth.headers; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:${3%$'\n'*}" in 0:*$'\n''below limit: http=2'??' exit='*) ;; 0:*) echo "the 1,024-byte upload did not succeed, so the positive control failed; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit ${3##*$'\n'}: the below-limit request failed; inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS -i --noproxy '*' --proto '=https' --connect-timeout 5 --max-time 30 \ --header @- --form 'file=@./over-limit.bin' \ -w '\nover limit: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1" < ./java-verify-auth.headers; then printf '\n0'; else printf '\n%s' "$?"; fi)" printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:${3%$'\n'*}" in 0:*$'\n''over limit: http=2'??' exit='*) echo "FINDING: the 1,048,577-byte upload succeeded, so no 1MB file limit rejected it"; set -- "$1" 1 ;; 0:*$'\n''over limit: http=401 exit='*|0:*$'\n''over limit: http=403 exit='*|0:*$'\n''over limit: http=404 exit='*) echo "an authentication, CSRF or missing-route answer is not evidence of the multipart limit; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; 0:*) ;; *) echo "curl exit ${3##*$'\n'}: the over-limit request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the cause of an over-limit rejection is not classified, so read the response and server logs for a size cause yourself" exit "$2" ) ``` The small upload must reach and succeed at the intended handler. With a deliberately raised limit in an isolated exposed fixture, both files should succeed; with the production limit, the larger file must be rejected because of its size. Inspect the response and server logs for that specific cause. Authentication, CSRF, missing-route, content-validation, and transport failures are not evidence of the multipart limit. A proxy rejection demonstrates the proxy limit; testing Boot's limit also requires reaching the application through an authorized path. See the [multipart limits](https://docs.spring.io/spring-boot/3.5/appendix/application-properties/index.html). ## Sources (checked September 2026) - Spring Boot how-to, embedded web servers (Configure SSL, forward headers, Tomcat proxy settings): https://docs.spring.io/spring-boot/how-to/webserver.html - Spring Boot SSL bundles: https://docs.spring.io/spring-boot/reference/features/ssl.html ; REST clients (applying a bundle): https://docs.spring.io/spring-boot/reference/io/rest-client.html - Spring Boot javadoc, Ssl (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/server/Ssl.html - Spring Boot javadoc, ServerProperties (address, forwardHeadersStrategy, servlet.session) (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ServerProperties.html - Spring Boot javadoc, ServerProperties (moved package) (rolling documentation, checked October 2026): https://docs.spring.io/spring-boot/4.1/api/java/org/springframework/boot/web/server/autoconfigure/ServerProperties.html - Spring Boot javadoc, session Cookie: https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/server/Cookie.html ; Spring Boot and Spring Security defaults: https://docs.spring.io/spring-boot/reference/web/spring-security.html - Spring Boot OAuth2 client properties: https://docs.spring.io/spring-boot/reference/security/oauth2.html ; Spring Security OAuth2 login: https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html - Spring Boot 4.0 starters and deprecated OAuth2 client starter name: https://docs.spring.io/spring-boot/4.0/reference/using/build-systems.html - Spring Boot 4.0 migration guide, deprecated starters: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide/e11f389f3671d1eb7fa346065410b5b1eab99c3c - Spring Security getting started (Boot defaults): https://docs.spring.io/spring-security/reference/servlet/getting-started.html ; password storage: https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html ; redirect to HTTPS and HSTS: https://docs.spring.io/spring-security/reference/servlet/exploits/http.html - Microsoft Entra documentation `231747abc59ae3d50c74e215c1cdd592eb980723`, Conditional Access MFA authentication-strength requirement (checked October 2026): https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-all-users-mfa-strength.md#L56-L57 - Cloudflare documentation `c896795b8e8bab68d05ae78d64c9cbcda1fdb104`, independent MFA enforced in Access without relying on the IdP (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24 - Authelia source `v4.39.28`, TOTP, WebAuthn security keys and Duo push factors (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/introduction.md#L21-L31 - keytool (importcert, cacerts): https://docs.oracle.com/en/java/javase/21/docs/specs/man/keytool.html - OpenJDK jdk21u `jdk-21.0.12.1-ga`, X509TrustManager interface and check methods (checked October 2026): https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/X509TrustManager.java#L31-L85 - OpenJDK jdk21u `jdk-21.0.12.1-ga`, HostnameVerifier callback and verify method (checked October 2026): https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/HostnameVerifier.java#L29-L55 - OpenJDK jdk21u `jdk-21.0.12.1-ga`, SSLContext.init trust-manager parameter (checked October 2026): https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/SSLContext.java#L296-L315 - OpenJDK jdk21u `jdk-21.0.12.1-ga`, HttpsURLConnection default and per-instance hostname verifier (checked October 2026): https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/HttpsURLConnection.java#L212-L273 - OpenJDK jdk21u `jdk-21.0.12.1-ga`, HttpsURLConnection default and per-instance SSL socket factory (checked October 2026): https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/HttpsURLConnection.java#L295-L371 - Spring Boot application properties, Actuator access, error inclusion, H2, multipart, forms, and headers (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/appendix/application-properties/index.html - Spring Boot application properties, including spring.web.error (rolling documentation, checked October 2026): https://docs.spring.io/spring-boot/4.1/appendix/application-properties/index.html - Spring Boot Actuator endpoints, exposure, sanitization, and security (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/reference/actuator/endpoints.html - Spring Boot Actuator endpoints, sanitization, and moved EndpointRequest import (rolling documentation, checked October 2026): https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html ; sanitization: https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html#actuator.endpoints.sanitization - Spring Boot management listener configuration (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/reference/actuator/monitoring.html - Spring Boot management listener configuration (rolling documentation, checked October 2026): https://docs.spring.io/spring-boot/4.1/reference/actuator/monitoring.html - Spring Boot web security and default-user auto-configuration (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/reference/web/spring-security.html - Spring Boot devtools and development property defaults (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/reference/using/devtools.html - Spring Boot devtools and development property defaults (rolling documentation, checked October 2026): https://docs.spring.io/spring-boot/4.1/reference/using/devtools.html - Spring Boot error-inclusion options, including ON_PARAM (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ErrorProperties.IncludeAttribute.html - Spring Boot servlet error handling (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/reference/web/servlet.html - Spring Boot RestClientSsl API (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.html - Spring Boot 4.1.2 RestClientSsl source, moved package (checked October 2026): https://raw.githubusercontent.com/spring-projects/spring-boot/v4.1.2/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientSsl.java - Spring Boot REST clients and SSL helper imports (rolling documentation, checked September 2026): https://docs.spring.io/spring-boot/3.5/reference/io/rest-client.html - Spring Boot REST clients and moved SSL helper imports (rolling documentation, checked October 2026): https://docs.spring.io/spring-boot/4.1/reference/io/rest-client.html - Spring Framework ForwardedHeaderFilter, proxy trust boundary: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/ForwardedHeaderFilter.html - Spring Security 6.5 HstsHeaderWriter, secure-request condition and defaults: https://docs.spring.io/spring-security/reference/6.5/api/java/org/springframework/security/web/header/writers/HstsHeaderWriter.html - Spring Security HstsHeaderWriter, secure-request condition and defaults (rolling documentation, checked September 2026): https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/web/header/writers/HstsHeaderWriter.html - Spring Security multiple filter chains and unmatched requests: https://docs.spring.io/spring-security/reference/servlet/configuration/java.html - Spring Security CSRF and stateless browser applications: https://docs.spring.io/spring-security/reference/features/exploits/csrf.html - Spring Security session fixation protection and changeSessionId: https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html - Apache Tomcat HTTP connector, form parsing and request-header limits: https://tomcat.apache.org/tomcat-10.1-doc/config/http.html - curl manual, header input, multipart files, protocol restrictions, and transfer diagnostics (curl 7.75.0 or newer for `exitcode` and `errormsg`): https://curl.se/docs/manpage.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - boot41: Spring Boot (4.1.2) - boot40: Spring Boot starter migration (4.0) - security65: Spring Security HSTS (6.5) - boot: Spring Boot documentation (unknown) - boot-docs: Spring Boot documentation (rolling) (unknown) - security: Spring Security documentation (rolling) (unknown) - framework: Spring Framework documentation (unknown) - jdk: JDK keytool documentation (21) - tomcat: Tomcat HTTP connector (10.1) - curl: curl minimum diagnostics version (7.75.0) - jdk21u: OpenJDK jdk21u javax.net.ssl sources (jdk-21.0.12.1-ga) - entra-docs: Microsoft Entra documentation (231747abc59ae3d50c74e215c1cdd592eb980723) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - authelia-source: Authelia source (v4.39.28) Claims (full source mapping in the JSON block below): - tls (REASONED; boot, boot-docs): Direct HTTPS on 8443 uses PEM certificate/key or PKCS12 and TLSv1.2/TLSv1.3; older releases must confirm PEM property support. - connector (REASONED; boot): Properties configure one HTTP or HTTPS connector; add the port-80 redirect connector in code, or let the proxy redirect. - https-redirect (REASONED; security, security65): Spring Security 6.5+ redirectToHttps redirects insecure requests; trusted forwarded headers determine secure status behind TLS termination. - hsts (REASONED; security65, security): Default HSTS writer runs only for secure requests; defaults are one year, includeSubDomains=true and preload=false. - proxy-bind (REASONED; boot, boot-docs): Bind 127.0.0.1:8080 behind the TLS proxy. - forwarding (REASONED; boot): NATIVE delegates forwarded headers to the server, FRAMEWORK uses ForwardedHeaderFilter; outside supported cloud platforms the default is NONE. - proxy-trust (REASONED; boot, framework): Restrict Tomcat internal-proxies, strip inbound Forwarded/X-Forwarded-* at the edge and prevent direct listener bypass. - proxy-redirect (REASONED; boot): Tomcat redirect-context-root=false preserves HTTPS redirects behind TLS termination. - server-import (REASONED; boot-docs): ServerProperties moves from boot.autoconfigure.web in Boot 3.5 to boot.web.server.autoconfigure in Boot 4. - default-web-auth (REASONED; boot, security, boot-docs): With starter-security and default web configuration, require authentication except Actuator health; form login, Basic, CSRF and security headers are enabled. - custom-chain (REASONED; boot-docs, security): Any custom SecurityFilterChain replaces Boot default web and Actuator rules; unmatched requests need a fallback chain or remain unprotected. - default-user (REASONED; boot-docs): Separate default-user auto-configuration creates user with a normally logged generated password; a custom chain alone does not remove it. Production needs deliberate authentication. - password (REASONED; security): Use DelegatingPasswordEncoder, bcrypt by default with {bcrypt}; BCrypt strength defaults to 10 and is tuned to about one second; Argon2 v5_8 defaults are an alternative. - cookies (REASONED; boot, boot-docs): Set secure/http-only cookies, SameSite=lax and a 30m servlet session timeout. - login-limit (REASONED; security): Checked references document no built-in login limiter; limit at the proxy or count failures and lock local accounts. - oidc-starter (REASONED; boot40): Boot 3.5 uses starter-oauth2-client; Boot 4 uses starter-security-oauth2-client and deprecates the old name. - oidc (REASONED; boot, security): Configure client ID, environment-supplied secret, issuer discovery and openid/profile/email scopes with oauth2Login; without openid the login is OAuth2, not OIDC. - mfa (REASONED; security, entra-docs, cf-docs, authelia-source): Enforce MFA at the identity provider or a fronting layer per linked guides; for example, an Entra Conditional Access policy can require an MFA authentication strength, Cloudflare Access independent MFA can enforce MFA without relying on the IdP, and Authelia supports TOTP, WebAuthn and Duo second factors. The cited pages do not establish this deployment's enforcement. - csrf (REASONED; security): Statelessness does not remove CSRF risk for automatically attached browser cookies or Basic credentials; retain appropriate protection. - session-fixation (REASONED; security): Default fixation protection uses changeSessionId on Servlet 3.1+ containers; keep it enabled. - client-validation (REASONED; boot, jdk, jdk21u): Do not install trust-all TrustManager or hostname verifiers; pinned jdk21u sources document X509TrustManager's check methods, HostnameVerifier.verify, SSLContext.init's trust-manager input, and HttpsURLConnection default setters reaching later instances while per-instance setters affect one object. The whole-JVM-client reading is this guide's assessment. - client-ca (REASONED; boot, jdk): Import an internal CA with keytool into cacerts and change its default changeit password, or configure and apply a named SSL bundle to the client. - client-import (REASONED; boot-docs, boot41): RestClientSsl and WebClientSsl imports differ between Boot 3.5.16 and 4.1.2; apply ssl.fromBundle to the selected client. - actuator-exposure (REASONED; boot-docs): Boot 3.5.16/4.1.2 default web exposure is health; use an explicit allowlist. Exposure, access and authorization are separate. - management-bind (REASONED; boot-docs): Management binds 127.0.0.1:8081 separately; a distinct management address requires a distinct port and no public proxy forwarding. - management-disable (REASONED; boot-docs): management.server.port=-1 disables management HTTP, not JMX exposure. - sanitize (REASONED; boot-docs): env/configprops show-values default to never; sanitization does not justify exposing metadata. - metadata-endpoints (REASONED; boot-docs): beans, mappings and threaddump expose components, routes and thread stacks. - heapdump (REASONED; boot-docs): heapdump may expose secrets and consume resources; access defaults to none in specifically checked 3.5.16/4.1.2, and is explicitly disabled. - loggers (REASONED; boot-docs): loggers can read/change logging levels, increasing volume and possible information disclosure. - shutdown (REASONED; boot-docs): shutdown stops the application; access defaults to none and is explicitly disabled. - management-auth (REASONED; boot-docs, security): The first servlet chain requires ROLE_ENDPOINT_ADMIN for all exposed Actuator endpoints including health; the second authenticates application requests. Provision identities separately. - endpoint-import (REASONED; boot-docs): EndpointRequest moves from boot.actuate.autoconfigure.security.servlet in Boot 3.5 to boot.security.autoconfigure.actuate.web.servlet in Boot 4. - error-message (REASONED; boot-docs): Error message inclusion defaults to never in checked releases; use server.error in Boot 3.5 and spring.web.error in Boot 4. - error-stack (REASONED; boot-docs): Stacktrace inclusion defaults to never in checked releases, using the version-appropriate error prefix. - error-binding (REASONED; boot-docs): Binding-error inclusion defaults to never in checked releases, using the version-appropriate error prefix. - error-exception (REASONED; boot-docs): Exception-class inclusion defaults to false in checked releases, using the version-appropriate error prefix. - error-policy (REASONED; boot-docs): on-param lets requesters request details; disabling whitelabel HTML alone does not protect JSON/custom/container errors. - h2 (REASONED; boot-docs): H2 console defaults disabled in checked releases; explicitly set spring.h2.console.enabled=false. - devtools (REASONED; boot-docs): Exclude production devtools; active development defaults enable H2 when components exist and set message/trace/binding errors to always. - multipart-file (REASONED; boot-docs): Servlet multipart max-file-size defaults to 1MB per file in checked releases. - multipart-request (REASONED; boot-docs): Servlet multipart max-request-size defaults to 10MB including parts and overhead in checked releases. - form-limit (REASONED; boot-docs, tomcat): Tomcat max-http-form-post-size defaults to 2MB for form-to-parameter processing, not arbitrary request bodies. - header-limit (REASONED; boot-docs, tomcat): max-http-request-header-size defaults to 8KB; Tomcat counts request line and all header names/values together. - json-limit (REASONED; boot-docs, tomcat): These properties do not establish a universal JSON body limit; bound other bodies at the proxy/application and test the actual route and content type. - verify-tls (REASONED; boot, security65, security): HTTPS must succeed without -k and show HSTS. - verify-auth (REASONED; boot-docs): Unauthenticated /api should return 401 or a 302 login redirect. - verify-bind (REASONED; boot-docs): Inspect every listener for loopback-only Java binding behind the proxy. - verify-user (REASONED; boot-docs): Positive generated-password log count records generation; zero is inconclusive because logging or a configured password can hide the default account. - verify-public-management (REASONED; boot-docs): Public origin must serve no Actuator content to anonymous or administrator identities; health-only exposure leaves other endpoints unavailable even to administrators. - verify-management-auth (REASONED; boot-docs): On private 8081, anonymous health should get 401, non-admin 403 and admin a health response that may be 503; sensitive routes normally 404 after successful authentication. - verify-management-port (REASONED; boot-docs): Any HTTP response on external 8081 proves reachability; corroborate failure with socket inventory and successful intended-path access. - verify-dangerous (REASONED; boot-docs): Inspect heapdump/shutdown access=none without invoking them; exposed behavior needs an isolated fixture. - verify-errors (REASONED; boot-docs): Controlled exception/binding markers appear with always/on-param but should disappear with production settings, including parameter requests; check JSON, HTML and custom responses after auth. - verify-h2 (REASONED; boot-docs): Enabled console can return content or redirect; disabled console normally yields 404 after app authorization. Login/401/403 alone do not prove absence. - verify-upload (REASONED; boot-docs): 1024-byte file must succeed; 1048577-byte file must fail for size under the 1MB policy; both succeed with raised limits. Auth, CSRF, route and transport failures are inconclusive. - verify-credentials (REASONED; curl): Use private mode-0600 header files via stdin, valid session/CSRF headers and curl 7.75.0+ diagnostics; transport errors do not prove secure configuration. ````json { "schema": 1, "checked": "2026-10-19", "documentation_checked": "2026-09", "body_sha256": "48f596a614667a2f4284f778f9ce929ff800155978d0bfec39ad85686d335fcc", "components": { "boot41": { "name": "Spring Boot", "basis": "4.1.2", "sources": { "sd774fe80f56e": "https://raw.githubusercontent.com/spring-projects/spring-boot/v4.1.2/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientSsl.java" } }, "boot40": { "name": "Spring Boot starter migration", "basis": "4.0", "sources": { "s08143a9162b2": "https://docs.spring.io/spring-boot/4.0/reference/using/build-systems.html", "s0486713aa731": "https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide/e11f389f3671d1eb7fa346065410b5b1eab99c3c" } }, "security65": { "name": "Spring Security HSTS", "basis": "6.5", "sources": { "s527bf5b95139": "https://docs.spring.io/spring-security/reference/6.5/api/java/org/springframework/security/web/header/writers/HstsHeaderWriter.html" } }, "boot": { "name": "Spring Boot documentation", "basis": "unknown", "sources": { "s4f6aa9df744a": "https://docs.spring.io/spring-boot/how-to/webserver.html", "s6d0dd8e90590": "https://docs.spring.io/spring-boot/reference/features/ssl.html", "s1b86cf7d45f1": "https://docs.spring.io/spring-boot/reference/io/rest-client.html", "s81ce68041084": "https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/server/Cookie.html", "s75be5256a326": "https://docs.spring.io/spring-boot/reference/web/spring-security.html", "sc88333e257f0": "https://docs.spring.io/spring-boot/reference/security/oauth2.html" } }, "boot-docs": { "name": "Spring Boot documentation (rolling)", "basis": "unknown", "sources": { "se7aed3f6650b": "https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/server/Ssl.html", "s34f52429c110": "https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ServerProperties.html", "s6198bce672f6": "https://docs.spring.io/spring-boot/3.5/appendix/application-properties/index.html", "s973cb5a333ac": "https://docs.spring.io/spring-boot/3.5/reference/actuator/endpoints.html", "s73890facf07c": "https://docs.spring.io/spring-boot/3.5/reference/actuator/monitoring.html", "s8cf61ca65ae4": "https://docs.spring.io/spring-boot/3.5/reference/web/spring-security.html", "se8918714c2c8": "https://docs.spring.io/spring-boot/3.5/reference/using/devtools.html", "saea08b7576f4": "https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ErrorProperties.IncludeAttribute.html", "s6ce5733455c4": "https://docs.spring.io/spring-boot/3.5/reference/web/servlet.html", "s3a681db376ce": "https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/client/RestClientSsl.html", "s2845cade9c1d": "https://docs.spring.io/spring-boot/3.5/reference/io/rest-client.html", "s61bf6b20e945": "https://docs.spring.io/spring-boot/4.1/api/java/org/springframework/boot/web/server/autoconfigure/ServerProperties.html", "sc24ee7294b38": "https://docs.spring.io/spring-boot/4.1/appendix/application-properties/index.html", "s0511290ee7a9": "https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html", "s033f98a55ba1": "https://docs.spring.io/spring-boot/4.1/reference/actuator/endpoints.html#actuator.endpoints.sanitization", "s1246672710d1": "https://docs.spring.io/spring-boot/4.1/reference/actuator/monitoring.html", "s88d377c93f63": "https://docs.spring.io/spring-boot/4.1/reference/using/devtools.html", "s03b9d97c1a28": "https://docs.spring.io/spring-boot/4.1/reference/io/rest-client.html" } }, "security": { "name": "Spring Security documentation (rolling)", "basis": "unknown", "sources": { "s55e8fcd2ff11": "https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html", "sae4824b03001": "https://docs.spring.io/spring-security/reference/servlet/getting-started.html", "sd5aebfb9d1ed": "https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html", "s23cf30cad42c": "https://docs.spring.io/spring-security/reference/servlet/exploits/http.html", "s9471b4cfe0f6": "https://docs.spring.io/spring-security/reference/servlet/configuration/java.html", "s8859c309d60a": "https://docs.spring.io/spring-security/reference/features/exploits/csrf.html", "sf301a3d0645a": "https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html", "s6fb344722a7c": "https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/web/header/writers/HstsHeaderWriter.html" } }, "framework": { "name": "Spring Framework documentation", "basis": "unknown", "sources": { "sc25b4ff29bc9": "https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/ForwardedHeaderFilter.html" } }, "jdk": { "name": "JDK keytool documentation", "basis": "21", "sources": { "s8a504e61bc07": "https://docs.oracle.com/en/java/javase/21/docs/specs/man/keytool.html" } }, "tomcat": { "name": "Tomcat HTTP connector", "basis": "10.1", "sources": { "s20a557a45f7a": "https://tomcat.apache.org/tomcat-10.1-doc/config/http.html" } }, "curl": { "name": "curl minimum diagnostics version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "jdk21u": { "name": "OpenJDK jdk21u javax.net.ssl sources", "basis": "jdk-21.0.12.1-ga", "sources": { "s1379fba0b5e6": "https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/X509TrustManager.java#L31-L85", "s0249b6cf0853": "https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/HostnameVerifier.java#L29-L55", "sef5d534061ca": "https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/SSLContext.java#L296-L315", "sd6ea702f8bb2": "https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/HttpsURLConnection.java#L212-L273", "se315608f569f": "https://github.com/openjdk/jdk21u/blob/jdk-21.0.12.1-ga/src/java.base/share/classes/javax/net/ssl/HttpsURLConnection.java#L295-L371" } }, "entra-docs": { "name": "Microsoft Entra documentation", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "s2c2f323edbd0": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/policy-all-users-mfa-strength.md#L56-L57" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24" } }, "authelia-source": { "name": "Authelia source", "basis": "v4.39.28", "sources": { "sfeb54a05c30d": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/second-factor/introduction.md#L21-L31" } } }, "claims": { "tls": {"text": "Direct HTTPS on 8443 uses PEM certificate/key or PKCS12 and TLSv1.2/TLSv1.3; older releases must confirm PEM property support.", "components": ["boot", "boot-docs"], "sources": ["boot:s4f6aa9df744a", "boot-docs:se7aed3f6650b"], "status": "REASONED"}, "connector": {"text": "Properties configure one HTTP or HTTPS connector; add the port-80 redirect connector in code, or let the proxy redirect.", "components": ["boot"], "sources": ["boot:s4f6aa9df744a"], "status": "REASONED"}, "https-redirect": {"text": "Spring Security 6.5+ redirectToHttps redirects insecure requests; trusted forwarded headers determine secure status behind TLS termination.", "components": ["security", "security65"], "sources": ["security:s23cf30cad42c", "security65:s527bf5b95139"], "status": "REASONED"}, "hsts": {"text": "Default HSTS writer runs only for secure requests; defaults are one year, includeSubDomains=true and preload=false.", "components": ["security65", "security"], "sources": ["security65:s527bf5b95139", "security:s6fb344722a7c"], "status": "REASONED"}, "proxy-bind": {"text": "Bind 127.0.0.1:8080 behind the TLS proxy.", "components": ["boot", "boot-docs"], "sources": ["boot:s4f6aa9df744a", "boot-docs:s34f52429c110", "boot-docs:s61bf6b20e945"], "status": "REASONED"}, "forwarding": {"text": "NATIVE delegates forwarded headers to the server, FRAMEWORK uses ForwardedHeaderFilter; outside supported cloud platforms the default is NONE.", "components": ["boot"], "sources": ["boot:s4f6aa9df744a"], "status": "REASONED"}, "proxy-trust": {"text": "Restrict Tomcat internal-proxies, strip inbound Forwarded/X-Forwarded-* at the edge and prevent direct listener bypass.", "components": ["boot", "framework"], "sources": ["boot:s4f6aa9df744a", "framework:sc25b4ff29bc9"], "status": "REASONED"}, "proxy-redirect": {"text": "Tomcat redirect-context-root=false preserves HTTPS redirects behind TLS termination.", "components": ["boot"], "sources": ["boot:s4f6aa9df744a"], "status": "REASONED"}, "server-import": {"text": "ServerProperties moves from boot.autoconfigure.web in Boot 3.5 to boot.web.server.autoconfigure in Boot 4.", "components": ["boot-docs"], "sources": ["boot-docs:s34f52429c110", "boot-docs:s61bf6b20e945"], "status": "REASONED"}, "default-web-auth": {"text": "With starter-security and default web configuration, require authentication except Actuator health; form login, Basic, CSRF and security headers are enabled.", "components": ["boot", "security", "boot-docs"], "sources": ["boot:s75be5256a326", "security:sae4824b03001", "boot-docs:s8cf61ca65ae4"], "status": "REASONED"}, "custom-chain": {"text": "Any custom SecurityFilterChain replaces Boot default web and Actuator rules; unmatched requests need a fallback chain or remain unprotected.", "components": ["boot-docs", "security"], "sources": ["boot-docs:s8cf61ca65ae4", "security:s9471b4cfe0f6"], "status": "REASONED"}, "default-user": {"text": "Separate default-user auto-configuration creates user with a normally logged generated password; a custom chain alone does not remove it. Production needs deliberate authentication.", "components": ["boot-docs"], "sources": ["boot-docs:s8cf61ca65ae4"], "status": "REASONED"}, "password": {"text": "Use DelegatingPasswordEncoder, bcrypt by default with {bcrypt}; BCrypt strength defaults to 10 and is tuned to about one second; Argon2 v5_8 defaults are an alternative.", "components": ["security"], "sources": ["security:sd5aebfb9d1ed"], "status": "REASONED"}, "cookies": {"text": "Set secure/http-only cookies, SameSite=lax and a 30m servlet session timeout.", "components": ["boot", "boot-docs"], "sources": ["boot:s81ce68041084", "boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "login-limit": {"text": "Checked references document no built-in login limiter; limit at the proxy or count failures and lock local accounts.", "components": ["security"], "sources": ["security:sae4824b03001", "security:sd5aebfb9d1ed"], "status": "REASONED"}, "oidc-starter": {"text": "Boot 3.5 uses starter-oauth2-client; Boot 4 uses starter-security-oauth2-client and deprecates the old name.", "components": ["boot40"], "sources": ["boot40:s08143a9162b2", "boot40:s0486713aa731"], "status": "REASONED"}, "oidc": {"text": "Configure client ID, environment-supplied secret, issuer discovery and openid/profile/email scopes with oauth2Login; without openid the login is OAuth2, not OIDC.", "components": ["boot", "security"], "sources": ["boot:sc88333e257f0", "security:s55e8fcd2ff11"], "status": "REASONED"}, "mfa": {"text": "Enforce MFA at the identity provider or a fronting layer per linked guides; for example, an Entra Conditional Access policy can require an MFA authentication strength, Cloudflare Access independent MFA can enforce MFA without relying on the IdP, and Authelia supports TOTP, WebAuthn and Duo second factors. The cited pages do not establish this deployment's enforcement.", "components": ["security", "entra-docs", "cf-docs", "authelia-source"], "sources": ["security:s55e8fcd2ff11", "entra-docs:s2c2f323edbd0", "cf-docs:s7e793d13cf31", "authelia-source:sfeb54a05c30d"], "status": "REASONED"}, "csrf": {"text": "Statelessness does not remove CSRF risk for automatically attached browser cookies or Basic credentials; retain appropriate protection.", "components": ["security"], "sources": ["security:s8859c309d60a"], "status": "REASONED"}, "session-fixation": {"text": "Default fixation protection uses changeSessionId on Servlet 3.1+ containers; keep it enabled.", "components": ["security"], "sources": ["security:sf301a3d0645a"], "status": "REASONED"}, "client-validation": {"text": "Do not install trust-all TrustManager or hostname verifiers; pinned jdk21u sources document X509TrustManager's check methods, HostnameVerifier.verify, SSLContext.init's trust-manager input, and HttpsURLConnection default setters reaching later instances while per-instance setters affect one object. The whole-JVM-client reading is this guide's assessment.", "components": ["boot", "jdk", "jdk21u"], "sources": ["boot:s6d0dd8e90590", "jdk:s8a504e61bc07", "jdk21u:s1379fba0b5e6", "jdk21u:s0249b6cf0853", "jdk21u:sef5d534061ca", "jdk21u:sd6ea702f8bb2", "jdk21u:se315608f569f"], "status": "REASONED"}, "client-ca": {"text": "Import an internal CA with keytool into cacerts and change its default changeit password, or configure and apply a named SSL bundle to the client.", "components": ["boot", "jdk"], "sources": ["boot:s6d0dd8e90590", "boot:s1b86cf7d45f1", "jdk:s8a504e61bc07"], "status": "REASONED"}, "client-import": {"text": "RestClientSsl and WebClientSsl imports differ between Boot 3.5.16 and 4.1.2; apply ssl.fromBundle to the selected client.", "components": ["boot-docs", "boot41"], "sources": ["boot-docs:s3a681db376ce", "boot41:sd774fe80f56e", "boot-docs:s2845cade9c1d", "boot-docs:s03b9d97c1a28"], "status": "REASONED"}, "actuator-exposure": {"text": "Boot 3.5.16/4.1.2 default web exposure is health; use an explicit allowlist. Exposure, access and authorization are separate.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "management-bind": {"text": "Management binds 127.0.0.1:8081 separately; a distinct management address requires a distinct port and no public proxy forwarding.", "components": ["boot-docs"], "sources": ["boot-docs:s73890facf07c", "boot-docs:s1246672710d1"], "status": "REASONED"}, "management-disable": {"text": "management.server.port=-1 disables management HTTP, not JMX exposure.", "components": ["boot-docs"], "sources": ["boot-docs:s73890facf07c", "boot-docs:s1246672710d1"], "status": "REASONED"}, "sanitize": {"text": "env/configprops show-values default to never; sanitization does not justify exposing metadata.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s033f98a55ba1"], "status": "REASONED"}, "metadata-endpoints": {"text": "beans, mappings and threaddump expose components, routes and thread stacks.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "heapdump": {"text": "heapdump may expose secrets and consume resources; access defaults to none in specifically checked 3.5.16/4.1.2, and is explicitly disabled.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "loggers": {"text": "loggers can read/change logging levels, increasing volume and possible information disclosure.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "shutdown": {"text": "shutdown stops the application; access defaults to none and is explicitly disabled.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "management-auth": {"text": "The first servlet chain requires ROLE_ENDPOINT_ADMIN for all exposed Actuator endpoints including health; the second authenticates application requests. Provision identities separately.", "components": ["boot-docs", "security"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9", "security:s9471b4cfe0f6"], "status": "REASONED"}, "endpoint-import": {"text": "EndpointRequest moves from boot.actuate.autoconfigure.security.servlet in Boot 3.5 to boot.security.autoconfigure.actuate.web.servlet in Boot 4.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "error-message": {"text": "Error message inclusion defaults to never in checked releases; use server.error in Boot 3.5 and spring.web.error in Boot 4.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "error-stack": {"text": "Stacktrace inclusion defaults to never in checked releases, using the version-appropriate error prefix.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "error-binding": {"text": "Binding-error inclusion defaults to never in checked releases, using the version-appropriate error prefix.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "error-exception": {"text": "Exception-class inclusion defaults to false in checked releases, using the version-appropriate error prefix.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "error-policy": {"text": "on-param lets requesters request details; disabling whitelabel HTML alone does not protect JSON/custom/container errors.", "components": ["boot-docs"], "sources": ["boot-docs:saea08b7576f4", "boot-docs:s6ce5733455c4"], "status": "REASONED"}, "h2": {"text": "H2 console defaults disabled in checked releases; explicitly set spring.h2.console.enabled=false.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "devtools": {"text": "Exclude production devtools; active development defaults enable H2 when components exist and set message/trace/binding errors to always.", "components": ["boot-docs"], "sources": ["boot-docs:se8918714c2c8", "boot-docs:s88d377c93f63"], "status": "REASONED"}, "multipart-file": {"text": "Servlet multipart max-file-size defaults to 1MB per file in checked releases.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "multipart-request": {"text": "Servlet multipart max-request-size defaults to 10MB including parts and overhead in checked releases.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED"}, "form-limit": {"text": "Tomcat max-http-form-post-size defaults to 2MB for form-to-parameter processing, not arbitrary request bodies.", "components": ["boot-docs", "tomcat"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "tomcat:s20a557a45f7a"], "status": "REASONED"}, "header-limit": {"text": "max-http-request-header-size defaults to 8KB; Tomcat counts request line and all header names/values together.", "components": ["boot-docs", "tomcat"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "tomcat:s20a557a45f7a"], "status": "REASONED"}, "json-limit": {"text": "These properties do not establish a universal JSON body limit; bound other bodies at the proxy/application and test the actual route and content type.", "components": ["boot-docs", "tomcat"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "tomcat:s20a557a45f7a"], "status": "REASONED"}, "verify-tls": {"text": "HTTPS must succeed without -k and show HSTS.", "components": ["boot", "security65", "security"], "sources": ["boot:s4f6aa9df744a", "security65:s527bf5b95139", "security:s6fb344722a7c"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "Unauthenticated /api should return 401 or a 302 login redirect.", "components": ["boot-docs"], "sources": ["boot-docs:s8cf61ca65ae4"], "status": "REASONED", "verify": [1]}, "verify-bind": {"text": "Inspect every listener for loopback-only Java binding behind the proxy.", "components": ["boot-docs"], "sources": ["boot-docs:s34f52429c110", "boot-docs:s61bf6b20e945"], "status": "REASONED", "verify": [1]}, "verify-user": {"text": "Positive generated-password log count records generation; zero is inconclusive because logging or a configured password can hide the default account.", "components": ["boot-docs"], "sources": ["boot-docs:s8cf61ca65ae4"], "status": "REASONED", "verify": [2]}, "verify-public-management": {"text": "Public origin must serve no Actuator content to anonymous or administrator identities; health-only exposure leaves other endpoints unavailable even to administrators.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED", "verify": [3]}, "verify-management-auth": {"text": "On private 8081, anonymous health should get 401, non-admin 403 and admin a health response that may be 503; sensitive routes normally 404 after successful authentication.", "components": ["boot-docs"], "sources": ["boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED", "verify": [4]}, "verify-management-port": {"text": "Any HTTP response on external 8081 proves reachability; corroborate failure with socket inventory and successful intended-path access.", "components": ["boot-docs"], "sources": ["boot-docs:s73890facf07c", "boot-docs:s1246672710d1"], "status": "REASONED", "verify": [5]}, "verify-dangerous": {"text": "Inspect heapdump/shutdown access=none without invoking them; exposed behavior needs an isolated fixture.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "boot-docs:s973cb5a333ac", "boot-docs:s0511290ee7a9"], "status": "REASONED"}, "verify-errors": {"text": "Controlled exception/binding markers appear with always/on-param but should disappear with production settings, including parameter requests; check JSON, HTML and custom responses after auth.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "boot-docs:saea08b7576f4", "boot-docs:s6ce5733455c4"], "status": "REASONED", "verify": [6]}, "verify-h2": {"text": "Enabled console can return content or redirect; disabled console normally yields 404 after app authorization. Login/401/403 alone do not prove absence.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38", "boot-docs:se8918714c2c8", "boot-docs:s88d377c93f63"], "status": "REASONED", "verify": [7]}, "verify-upload": {"text": "1024-byte file must succeed; 1048577-byte file must fail for size under the 1MB policy; both succeed with raised limits. Auth, CSRF, route and transport failures are inconclusive.", "components": ["boot-docs"], "sources": ["boot-docs:s6198bce672f6", "boot-docs:sc24ee7294b38"], "status": "REASONED", "verify": [8]}, "verify-credentials": {"text": "Use private mode-0600 header files via stdin, valid session/CSRF headers and curl 7.75.0+ diagnostics; transport errors do not prove secure configuration.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [3, 4, 5, 6, 7, 8]} } } ```` ====================================================================== ==> php.md ====================================================================== # PHP and Laravel: TLS and authentication PHP is a scripting language for building websites and web apps, and Laravel is a framework written in it. This guide helps your AI assistant configure them securely, including a private php-fpm listener, hardened session cookies, password hashing with login rate limiting, production php.ini settings, and debug mode off. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/php.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against PHP native configuration 8.x, PHP production template 8.4 and 8.3, Laravel 12.x, fail2ban 1.1.1, and curl manual 7.75.0; 2 further sources carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 65 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. PHP normally runs behind a web server (Apache with php-fpm or mod_php, nginx or Caddy with php-fpm), so TLS terminates there: follow [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md). The PHP-specific exposures are the php-fpm FastCGI socket (the PHP manual: "An exposed FastCGI endpoint allows arbitrary code execution"), session cookies that PHP ships without `Secure`, `HttpOnly`, or `SameSite` (all three default off), `APP_DEBUG=true` left on in production, and cURL calls with certificate checks turned off. ## 1. Keep php-fpm private `listen` is mandatory per pool. Prefer a Unix socket when the web server is on the same host; with TCP, list the allowed clients, because `listen.allowed_clients` is unset by default and then accepts any address. In containers, never publish the FPM port on the host. ```ini ; /etc/php/*/fpm/pool.d/www.conf listen = /run/php/php-fpm.sock ; access controlled by listen.owner, listen.group, listen.mode (default 0660) ; listen = 127.0.0.1:9000 plus listen.allowed_clients = 127.0.0.1 for the TCP alternative, loopback only ``` ## 2. Plain PHP: sessions, passwords, rate limiting ```ini session.cookie_secure = 1 ; default 0 session.cookie_httponly = 1 ; default 0 session.cookie_samesite = Lax ; default "" (no attribute); Lax or Strict session.use_strict_mode = 1 ; default 0; the manual calls enabling it "mandatory for general session security" ``` Call `session_regenerate_id()` after login and on privilege change. Its `delete_old_session` parameter defaults to `false`; the manual advises against destroying the old session immediately (unstable networks, hijack detection), so expire it with a timestamp instead. Hash with `password_hash($password, PASSWORD_DEFAULT)` (bcrypt at the time of writing; `PASSWORD_ARGON2ID` needs PHP built with Argon2), check with `password_verify()`, and upgrade old hashes when `password_needs_rehash()` says so. `PASSWORD_DEFAULT` is designed to change over time, so store the hash in a column that can grow past 60 bytes (the manual suggests 255). Guide assessment: the PHP password and session functions cited here provide no login-attempt throttling; this is not a claim about every PHP extension or application. Guide advice: apply a proxy limiter or a fail2ban jail over a failure log written by the application or proxy, following the authentication-endpoint requirements in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Among the linked proxy guides, [nginx.md, section 4](https://github.com/jposluns/secureconfig/blob/main/nginx.md#4-bound-the-expensive-endpoints) documents `limit_req`; [caddy.md, section 4](https://github.com/jposluns/secureconfig/blob/main/caddy.md#4-bound-the-expensive-endpoints) states that stock Caddy has no rate limiter, and [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md) gives no limiter configuration. fail2ban 1.1.1 documents [`filter` and `logpath`](https://github.com/fail2ban/fail2ban/blob/1.1.1/man/jail.conf.5#L187-L200); its [`nginx-limit-req` jail](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L378-L384) reads the nginx error log and its [filter](https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/nginx-limit-req.conf#L48-L49) matches limiting or delaying entries. These sources do not establish a filter for this PHP application's login form or its log format. Guide advice: for plain-PHP MFA, use a TOTP library or an identity layer in front whose policy requires MFA; [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md#identity-layers-and-auth-proxies-open-source) covers identity-layer options and enforcement requirements, but its library list has no PHP TOTP library. No PHP TOTP library is pinned here. ## 3. Laravel ```ini APP_ENV=production # .env stays out of source control APP_DEBUG=false # true in production exposes configuration values to end users APP_URL=https://app.example.com APP_KEY= # php artisan key:generate; list old keys in APP_PREVIOUS_KEYS when rotating SESSION_SECURE_COOKIE=true # config/session.php 'secure' has no default SESSION_HTTP_ONLY=true # default true SESSION_SAME_SITE=lax # default lax; strict for admin-only apps ``` Behind a proxy, name it in `bootstrap/app.php` (Laravel 12.x docs; check the docs for your version) so `url()`, `request()->secure()`, and secure cookies see HTTPS: ```php ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: ['127.0.0.1', '10.0.0.0/8']); // '*' only when the app is unreachable except through the proxy }) ``` Force HTTPS in generated URLs with `URL::forceHttps()` (or `URL::forceScheme('https')`) in a service provider's `boot()` method. Passwords: `Hash::make()` and `Hash::check()` use bcrypt by default; `HASH_DRIVER=argon2id` switches the driver, `Hash::needsRehash()` indicates whether a hash needs rehashing, and `Hash::check()` rejects hashes made with a different algorithm unless `HASH_VERIFY=false`. Rate-limit the login route in `AppServiceProvider::boot()` and attach it with the `throttle` middleware: ```php RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)->by($request->email.$request->ip())); Route::post('/login', [AuthController::class, 'store'])->middleware('throttle:login'); // attach the limiter to your real login route and handler ``` The 12.x starter kits (React, Vue, Svelte, Livewire) authenticate through Laravel Fortify, which throttles login by username plus IP, regenerates the session ID on login, and ships TOTP two-factor authentication with recovery codes: `Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true])` in `config/fortify.php`. Fortify alone (`composer require laravel/fortify`, `php artisan fortify:install`) gives the same backend without views. Laravel Socialite handles OAuth login for Google, GitHub, GitLab, Slack, and others; OpenID Connect providers come through the community Socialite Providers packages. After the callback, apply the allowlist and token checks in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md), and keep the `redirect` in `config/services.php` on `https://`. For Laravel 12.x production deployments, never commit the plaintext `.env` file. After supplying the production configuration, build the configuration and route caches as part of deployment. Once configuration is cached, Laravel does not load `.env`; call `env()` only in configuration files and read application settings through `config()`. Rebuild the caches when their inputs change. [Configuration](https://laravel.com/framework/docs/12.x/configuration), [deployment](https://laravel.com/framework/docs/12.x/deployment). ```bash php artisan config:cache php artisan route:cache ``` Restrict accepted hostnames at the web server. Laravel accepts arbitrary `Host` headers by default; enable `TrustHosts` as an additional check. Add this statement inside the existing `withMiddleware` callback, alongside `trustProxies`, and substitute the application's hostname in the anchored regular expression. `subdomains: false` prevents automatic trust of subdomains of the application URL. [Trusted hosts](https://laravel.com/framework/docs/12.x/requests#configuring-trusted-hosts). ```php $middleware->trustHosts(at: ['^app\.example\.com$'], subdomains: false); ``` Keep Telescope and Debugbar out of the production installation. Use Telescope's documented local-only installation, including conditional provider registration, so production does not attempt to load an omitted development dependency. Debugbar's maintainer also recommends omitting it from production. [Telescope local-only installation](https://laravel.com/framework/docs/12.x/telescope#local-only-installation), [Debugbar](https://github.com/fruitcake/laravel-debugbar). ## 4. Client-side TLS discipline ```php curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // the default; never set false curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // the default; keep 2 in production curl_setopt($ch, CURLOPT_CAINFO, '/etc/ssl/certs/internal-ca.pem'); // internal CA instead of disabling checks ``` ## 5. php.ini production error and banner hardening These settings target PHP 8.x. Defaults below mean PHP's built-in defaults; distribution packages, templates, and deployment overrides can differ. Configure the `php.ini` used by the web SAPI, then reload the relevant service. This is the plain-PHP counterpart to Laravel's `APP_DEBUG=false`. ```ini display_errors = Off ; built-in default On display_startup_errors = Off ; default On since PHP 8.0.0; previously Off log_errors = On ; built-in default Off error_log = /var/log/php/app-error.log error_reporting = E_ALL ; built-in default since PHP 8.0.0 expose_php = Off ; built-in default On ``` `error_reporting` selects which diagnostics PHP reports; it does not decide whether they reach the HTTP response. Keep reporting enabled while directing errors into protected logs. Pre-create the example log destination outside the document root, make it writable by the PHP worker identity, restrict access to that identity and authorized operators, and arrange rotation. An unset `error_log` uses the SAPI's logger. Configure error display before requests execute; an `ini_set()` inside a script cannot protect against a fatal error that prevents that statement from running. [PHP error configuration](https://www.php.net/manual/en/errorfunc.configuration.php). Use the bundled `php.ini-production` as a starting point, then review its actual settings. For example, PHP 8.4's template disables both display settings and enables logging, but uses `E_ALL & ~E_DEPRECATED` and leaves `expose_php` enabled. The explicit settings above retain all diagnostic levels in logs and disable PHP's banner. [PHP 8.4 production template](https://raw.githubusercontent.com/php/php-src/d313ad6098430f4e61f0121a9e7ab392d195e4e4/php.ini-production). `expose_php=Off` suppresses PHP's own `X-Powered-By: PHP/...` header. It does not remove headers added elsewhere or make the application unidentifiable. [Hiding PHP](https://www.php.net/manual/en/security.hiding.php). ## 6. Code execution, inclusion, and upload-directory surface For PHP 8.x, consider an application-tested `disable_functions` list only where these process-launching functions are unnecessary. Its default is an empty list. It affects internal functions only; since PHP 8.0, disabling a function removes its definition and allows userland to redefine that name. This reduces available functionality but is not a security boundary. [PHP core directives](https://www.php.net/manual/en/ini.core.php#ini.disable-functions). The first two settings below are conditional on application compatibility. Retain the third setting: ```ini disable_functions = exec,system,passthru,shell_exec,proc_open,popen allow_url_fopen = Off allow_url_include = Off ``` `allow_url_fopen` defaults to On and enables URL-aware file wrappers; disable it where unnecessary. `allow_url_include` defaults to Off and has been deprecated since PHP 7.4. It governs URL-wrapper use by `include`, `include_once`, `require`, and `require_once`, and requires `allow_url_fopen` to be enabled. These switches are not a general network-egress policy. [Filesystem and streams configuration](https://www.php.net/manual/en/filesystem.configuration.php). `open_basedir` defaults to NULL, with no directory restriction. If used, allow every required application, dependency, session, and temporary directory, and test the result. It disables the realpath cache and is an additional restriction, not filesystem isolation. PHP 8.3 additionally rejects `..` components when changing this setting at runtime. Retain operating-system permissions and application isolation. [PHP core directives](https://www.php.net/manual/en/ini.core.php#ini.open-basedir). In the FPM pool configuration, restrict the main script extensions: ```ini security.limit_extensions = .php ``` The PHP 8.x FPM default is `.php .phar`. Restricting it to `.php` still permits an uploaded `.php` file if the web server sends that file to FPM. [FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php). Never execute PHP from upload or other writable data directories. Store such data outside the document root where possible, and enforce the exclusion in the actual web-server handler mappings. Any publicly served upload directory must serve permitted content without dispatching it to PHP; include aliases and symbolic links in that review. Keep executable application code separate from writable data. See [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), and [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). For Laravel 12.x, the default local disk uses `storage/app/private`; the public disk uses `storage/app/public` and can be exposed through `public/storage`. That public link does not itself prevent PHP execution: the web-server rules must cover it. [Laravel file storage](https://laravel.com/framework/docs/12.x/filesystem). ## 7. Upload and request limits Set finite limits appropriate to the application and test legitimate requests near each limit. The following retains PHP 8.x's built-in size, count, memory, and execution defaults, while explicitly choosing 60 seconds for input parsing: ```ini upload_max_filesize = 2M post_max_size = 8M max_file_uploads = 20 memory_limit = 128M max_input_vars = 1000 max_execution_time = 30 max_input_time = 60 ``` `upload_max_filesize` limits each uploaded file; `post_max_size` limits the complete POST body and must exceed it, allowing room for other fields and multipart overhead. A count limit of 20 does not mean twenty maximum-sized files fit into an 8M request. If POST data exceeds `post_max_size`, PHP leaves `$_POST` and `$_FILES` empty; handle that condition as a rejected request. `memory_limit` bounds a script's allocation and should generally exceed `post_max_size`; `-1` removes that memory limit. [PHP core directives](https://www.php.net/manual/en/ini.core.php). `max_input_vars=1000` applies separately to GET, POST, and COOKIE input; excess variables generate a warning and are truncated. It is not a general JSON-body limit. `max_execution_time` defaults to 30 seconds for web execution and 0 for CLI. It is not a portable wall-clock deadline: accounting depends on the platform and build and can exclude I/O waits. The built-in `max_input_time` default is `-1`, meaning use `max_execution_time`; 0 permits unlimited input-parsing time. The production template sets it to 60. [PHP execution and input limits](https://www.php.net/manual/en/info.configuration.php), [production template](https://raw.githubusercontent.com/php/php-src/d313ad6098430f4e61f0121a9e7ab392d195e4e4/php.ini-production). Uploads also consume input-processing time. Test slow and multiple-file uploads, and coordinate these values with the web server's request-size and timeout limits. These settings bound individual requests; they do not replace capacity limits or rate limiting. [PHP upload pitfalls](https://www.php.net/manual/en/features.file-upload.common-pitfalls.php). For FPM, choose a finite request termination limit. This example uses 60 seconds as an application policy, not a vendor default: ```ini ; FPM pool configuration, not php.ini request_terminate_timeout = 60s request_terminate_timeout_track_finished = yes ``` `request_terminate_timeout` defaults to 0, disabled, and kills the worker when the request exceeds the limit. Ordinarily it stops applying after `fastcgi_finish_request()` or during shutdown processing; `request_terminate_timeout_track_finished=yes`, default no, extends it to those phases. Test applications that perform work after sending the response. [FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php). ## 8. Session storage and lifetime beyond cookie flags These settings govern PHP 8.x native sessions. Laravel's session subsystem uses `config/session.php`; do not assume native PHP session directives configure Laravel's session storage or lifetime. ```ini session.gc_maxlifetime = 1440 session.cookie_lifetime = 0 session.use_only_cookies = 1 session.save_path = "/var/lib/php/app-sessions" ``` The first three values retain PHP's built-in defaults. `session.gc_maxlifetime` makes session data eligible for collection after 1440 seconds, 24 minutes. `session.cookie_lifetime=0` creates a browser-session cookie. `session.use_only_cookies=1` keeps session IDs out of GET and POST parameters; retain it. Disabling it is deprecated from PHP 8.4. [Session configuration](https://www.php.net/manual/en/session.configuration.php). Garbage collection is probabilistic by default and is not an authentication-expiry guarantee. Enforce idle and absolute expiry using application timestamps, and arrange reliable cleanup of expired storage. Applications sharing a save location can have their data removed according to another application's shorter collection lifetime. [Session security](https://www.php.net/manual/en/session.security.ini.php), [session configuration](https://www.php.net/manual/en/session.configuration.php). For the default `files` save handler, pre-create a private directory outside the document root for each application, writable by its PHP identity. A directory mode of 0700 is appropriate for a dedicated runtime account; session files default to 0600. Separate untrusted applications by runtime identity as well as path. Avoid a shared, world-readable session directory. [Session configuration](https://www.php.net/manual/en/session.configuration.php#ini.session.save-path). PHP 8.x's built-in `session.sid_length` and `session.sid_bits_per_character` defaults are 32 and 4. Changing either away from its default is deprecated from PHP 8.4. Do not add new tuning overrides on those versions. Older templates differ: PHP 8.3's production template sets 26 and 5, so review inherited configuration during upgrades. [Session configuration](https://www.php.net/manual/en/session.configuration.php), [PHP 8.3 production template](https://raw.githubusercontent.com/php/php-src/b94f9f68a610e0fca5f2fea5bfa7c7d6d3d5a847/php.ini-production). ## Verify **REASONED: deployment checks have not been demonstrated.** The authoring environment has no PHP or PHP-FPM runtime, no Docker or Podman, and no supplied PHP/Laravel deployment. The listener, TLS, cookie, authentication, banner, and error-disclosure checks require a real deployment. Expected outcomes are REASONED from the cited PHP, Laravel, curl manual, and fronting-server documentation. REASONED: paste and probe guidance. A live PHP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The listener, TLS and cookie comparisons also need a deployed FPM listener and HTTPS application, which were unavailable. Exposed listener: `ss -tlnp` shows a wildcard FPM TCP bind; fixed listener: `ss -xlnp` shows a Unix socket or `ss -tlnp` shows `127.0.0.1:9000`, per the guide's cited PHP FPM configuration (`listen`, `listen.allowed_clients`, `listen.owner`) (PHP 8.x). Exposed TLS: the listener block first prints no headers and `exit=60`, the cited certificate failure marker; the backend-dependent `err=` wording names the certificate failure. Fixed TLS: the first request prints response headers and `exit=0` without `-k`; that success marker is REASONED, not stated in the cited exit-code passages. The exit-code distinction follows the curl manual, exit codes 6, 7, 28 and 60 and the `exitcode` and `errormsg` write-out variables (added in curl 7.75.0) (checked October 2026) entry. The Laravel 12.x deployment (nginx example, `APP_DEBUG`) and PHP cURL constants (`CURLOPT_SSL_VERIFYPEER`, `CURLOPT_SSL_VERIFYHOST`, `CURLOPT_CAINFO`) entries back the settings in sections 3 and 4; they do not establish this probe's outcomes. A lack of headers also appears on a DNS failure (`exit=6`), a refused connection (`exit=7`), a timeout (`exit=28`) or plain HTTP on 443, so on its own it does not show a certificate failure. Exposed cookie: `Set-Cookie` omits one of Secure, HttpOnly or SameSite; fixed cookie: `Set-Cookie` includes all three, per the guide's cited PHP session runtime configuration (PHP 8.x) and Laravel 12.x `config/session.php` defaults entries. All five Verify Bash blocks passed `bash -n` and ShellCheck 0.11.0 with no output. Stubbed URL blocks printed `substitute the full HTTPS URL inside the quotes above; not probing` or `substitute the full HTTPS fixture URL inside the quotes above; not probing` for their placeholders; dummy HTTPS URLs reached `curl-stub`. With `bash -u`, empty and extra values and a missing `set --` line printed `substitute the full HTTPS URL inside the quotes above; not probing` or `substitute the full HTTPS fixture URL inside the quotes above; not probing`, `the set -- line needs exactly 1 value; not probing`, and `paste the whole block, including its set -- line; not probing`, respectively. Local marking review (October 2026, Bash 5.3.9, ShellCheck 0.11.0): the listener block passed `bash -n` and ShellCheck with empty stdout/stderr and status 0. Each Bash -u trial used scratch `ss`, `curl` and `grep` stubs; all had empty stderr and real process status 0. The bash -n, ShellCheck and stub runs recorded here covered all five Bash blocks in earlier forms, in which the listener, route, banner and body blocks left the subshell with status 0 after a guard refusal; no recorded run covered the later form that ended those four blocks with `) || echo "block exit $?"`, which kept status 0 when a block was pasted whole. The current form of all five blocks, which reports each block's result in its exit status under CONTRIBUTING.md rule 6, is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block, and the listener block's current form is also covered by the stub runs recorded later in this paragraph. Exact stdout of the earlier form follows, with `\n` denoting each printed line feed; the earlier form printed no `block exit` line. Placeholder: `ss-stub:-xlnp\nsubstitute the full HTTPS URL inside the quotes above; not probing\n` (no curl calls). Empty value: `ss-stub:-xlnp\nsubstitute the full HTTPS URL inside the quotes above; not probing\n` (no curl calls). Missing set line: `ss-stub:-xlnp\npaste the whole block, including its set -- line; not probing\n` (no curl calls). Wrong marker: `ss-stub:-xlnp\npaste the whole block, including its set -- line; not probing\n` (no curl calls). Extra value: `ss-stub:-xlnp\nthe set -- line needs exactly 1 value; not probing\n` (no curl calls). Option-like value: `ss-stub:-xlnp\nuse a URL, not an option; not probing\n` (no curl calls). Spaced URL: `ss-stub:-xlnp\nremove whitespace from the URL; not probing\n` (no curl calls). The http:// URL: `ss-stub:-xlnp\nuse an https:// URL; not probing\n` (no curl calls). Bare host: `ss-stub:-xlnp\nuse an https:// URL; not probing\n` (no curl calls). Certificate with cookie present: `ss-stub:-xlnp\nhttp=000 exit=60 err=SSL certificate problem: unable to get local issuer certificate\nSet-Cookie: session=x; Secure; HttpOnly; SameSite=Lax\n`. Certificate with cookie missing: `ss-stub:-xlnp\nhttp=000 exit=60 err=SSL certificate problem: unable to get local issuer certificate\n`. DNS with cookie present: `ss-stub:-xlnp\nhttp=000 exit=6 err=Could not resolve host\nSet-Cookie: session=x; Secure; HttpOnly; SameSite=Lax\n`. DNS with cookie missing: `ss-stub:-xlnp\nhttp=000 exit=6 err=Could not resolve host\n`. Refusal with cookie present: `ss-stub:-xlnp\nhttp=000 exit=7 err=Failed to connect\nSet-Cookie: session=x; Secure; HttpOnly; SameSite=Lax\n`. Refusal with cookie missing: `ss-stub:-xlnp\nhttp=000 exit=7 err=Failed to connect\n`. Timeout with cookie present: `ss-stub:-xlnp\nhttp=000 exit=28 err=Operation timed out\nSet-Cookie: session=x; Secure; HttpOnly; SameSite=Lax\n`. Timeout with cookie missing: `ss-stub:-xlnp\nhttp=000 exit=28 err=Operation timed out\n`. Success with cookie present: `ss-stub:-xlnp\nHTTP/2 200\nhttp=200 exit=0 err=\nSet-Cookie: session=x; Secure; HttpOnly; SameSite=Lax\n`. Success with cookie missing: `ss-stub:-xlnp\nHTTP/2 200\nhttp=200 exit=0 err=\n`. Success with a trailing slash and cookie present: `ss-stub:-xlnp\nHTTP/2 200\nhttp=200 exit=0 err=\nSet-Cookie: session=x; Secure; HttpOnly; SameSite=Lax\n`. In each curl-stub trial, the two calls were to `https://example.test/base` and `https://example.test/base/login`; the trailing-slash trial used `https://example.test/base/` then `https://example.test/base/login`. No request reached a network. No live outcome was observed. Paste each guarded block whole. Substitute a full HTTPS URL inside the single quotes on its `set --` line; use a URL without a literal apostrophe or embedded credentials. Supply the application's base URL; the cookie request appends `/login`, and no output from it is inconclusive. The `exitcode` and `errormsg` write-out variables require curl 7.75.0 or later per the curl manual, exit codes 6, 7, 28 and 60 and the `exitcode` and `errormsg` write-out variables (added in curl 7.75.0) (checked October 2026) entry. Each block records every classified result and every failed command as it runs and exits once: 0 for a pass or a result it leaves unclassified, 1 for a finding, 2 for a guard refusal and 3 for a failed command or an inconclusive result, a finding outranking an inconclusive result. In the listener block a missing `ss` refuses only the listener part: the block prints `ss is not installed here; not listing sockets`, still runs the HTTPS and cookie requests, and exits 2 only when they found nothing and were not inconclusive. Stub runs (October 2026, Bash 5.3.9, scratch `ss` and `curl` stubs, no network, under an inherited `readonly` variable, `declare -i` variable and `set -e`) covered the listener block in an earlier form. That form's `block exit` line named the listener list, the HTTPS response headers and the Set-Cookie lines even when they were not printed, and called a Set-Cookie line without all three attributes unclassified. The listener block's current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs under the same conditions, which exited 0 with `ss` and both stubbed requests passing, 3 with `ss` failing, and 2 with `ss` missing. With `ss` missing, a stubbed certificate failure (`exit=60`) gave 1, a stubbed refused connection gave 3 and the unsubstituted placeholder gave 2; with `ss` passing, a lone `Set-Cookie: x=y` gave 1, and a session cookie carrying all three attributes beside a cookie carrying none gave 0. Unless a guard ends the block early, each block ends with a `block exit` line that gives its status and names the output it leaves for the reader to judge; the listener block's `ss` check and URL guard refuse only their own part, so that block still prints the line. That line names the listener list only when `ss` ran, and the HTTPS response headers and Set-Cookie lines only when the URL part sent its requests, and it says that the block classified only the first request's curl exit and whether at least one login Set-Cookie line carries all three attributes. In the listener block `exit=60` from the first request is a finding and any other nonzero exit is inconclusive; the cookie request is inconclusive when it fails or prints no `Set-Cookie` line, and a finding when no `Set-Cookie` line carries all of Secure, HttpOnly and SameSite. REASONED: protected-route comparison. A live PHP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no protected route or authorized session was available. Exposed: the same route returns protected content anonymously; fixed: it returns that content only to an authorized session and denies or redirects an anonymous request to login. Per the guide's cited Laravel 12.x Fortify and Laravel 12.x starter kits (Fortify, two-factor, rate limiting) entries. The route block passed `bash -n` and ShellCheck 0.11.0 with no output. Its scratch `bash` curl-stub run printed the placeholder guard; a dummy HTTPS URL reached `curl-stub`; `bash -u` empty, extra and missing-`set --` trials printed the substitute, exactly-one-value and paste-whole-block messages. The bash -n, ShellCheck and stub runs recorded here covered the route block in an earlier form, whose guard refusals left the subshell with status 0. Its current form exits 0 for a 401 or 403, 3 for a failed request or a status that is neither a denial nor a redirect, and 0 for a 2xx or a redirect, which it leaves for the reader to judge; that form is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block. No route response was observed. For the protected-route check, confirm the same route returns the expected protected content to an authorized session. In an isolated exposed-state test, removing the authentication requirement must make that content accessible anonymously; restoring it must produce the documented denial or login redirect. A missing route or an unrelated error is not evidence of authentication. REASONED: following block; FPM listener, HTTPS and cookie inspection. The guide records no PHP/PHP-FPM runtime, container runtime or supplied deployment; expectations follow its cited PHP and Laravel documentation and, for the TLS status, the curl manual entry, not a recorded run. ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (ss is missing or the URL part refused its input), which outranks 0 if command -v ss >/dev/null; then ss -xlnp || # read every listener; php-fpm: Unix socket; with TCP, ss -tlnp shows 127.0.0.1:9000 only { echo "ss -xlnp failed; inconclusive"; set -- 3; } set -- "$1" listed # the 2nd value says whether ss ran, so the last line names the listener list only when it did else echo "ss is not installed here; not listing sockets"; set -- 2 unlisted fi ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the full HTTPS URL inside the quotes above; not probing"; exit 2 ;; -*) echo "use a URL, not an option; not probing"; exit 2 ;; *[[:space:]]*) echo "remove whitespace from the URL; not probing"; exit 2 ;; https://*) ;; *) echo "use an https:// URL; not probing"; exit 2 ;; esac set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sSI --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' -- "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}" in 0) ;; 60) echo "FINDING: exit=60, the cited certificate failure marker: the certificate did not verify"; set -- "$1" 1 ;; *) echo "curl exit ${3##*$'\n'} is not a certificate failure on its own (a DNS failure, refused connection, timeout or plain HTTP on 443 also prints no headers); inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g --noproxy '*' -sI --connect-timeout 5 --max-time 20 -- "${1%/}/login"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the login response headers if [ "$3" != 0 ]; then echo "curl exit $3: the login request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 elif printf '%s' "$4" | grep -i set-cookie; then # secure; httponly; samesite=lax if printf '%s' "$4" | awk '{ l = tolower($0); sub(/\r$/, "", l) } l ~ /^set-cookie:/ && l ~ /; *secure *(;|$)/ && l ~ /; *httponly *(;|$)/ && l ~ /; *samesite=/ { found = 1 } END { exit !found }'; then :; else case "$?" in 1) echo "FINDING: no Set-Cookie line above carries all of Secure, HttpOnly and SameSite"; set -- "$1" 1 ;; *) echo "awk could not read the Set-Cookie lines; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac fi else echo "no Set-Cookie line from the login page, which is inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 fi exit "$2" ) case "$?:$1" in # fold that part's status into the block's, ranked as above; the 3rd value says whether that part sent its requests 0:*) set -- "$1" "$2" sent ;; 1:*) set -- 1 "$2" sent ;; 2:0) set -- 2 "$2" refused ;; 2:*) set -- "$1" "$2" refused ;; *:1) set -- 1 "$2" sent ;; *) set -- 3 "$2" sent ;; esac case "$2:$3" in listed:sent) echo "block exit $1; the block classified only the first request's curl exit and whether at least one login Set-Cookie line carries all of Secure, HttpOnly and SameSite, so judge the listener list, the HTTPS response headers and any Set-Cookie lines above yourself, including which one is the session cookie" ;; *:sent) echo "block exit $1; the block classified only the first request's curl exit and whether at least one login Set-Cookie line carries all of Secure, HttpOnly and SameSite, so judge the HTTPS response headers and any Set-Cookie lines above yourself, including which one is the session cookie" ;; listed:*) echo "block exit $1; the URL part sent no request, so judge the listener list yourself" ;; *) echo "block exit $1; ss did not run and the URL part sent no request, so the block printed nothing for you to judge" ;; esac exit "$1" ) ``` REASONED: following block; anonymous protected-route discrimination. The guide records no PHP/PHP-FPM runtime, container runtime or supplied deployment; expectations follow its cited PHP and Laravel documentation, not a recorded run. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_PROTECTED_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the full HTTPS URL inside the quotes above; not probing"; exit 2 ;; esac set -- "$1" 0 "$(if curl -q -g --noproxy '*' -sS -i --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' -- "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status printf '%s' "${3%$'\n'*}" # This request sends no session cookie. Expect 401 or 403, or a redirect whose Location is the login # page, and no protected content in the response; an anonymous session Set-Cookie in the response is # permitted (Laravel's StartSession issues one even for a denied request). An unrelated canonical # redirect (for example to a trailing slash) is not a login redirect; a 200 carrying protected content # is a failure. TLS and cookie flags say nothing about whether a route actually refuses an # unauthenticated request. case "${3##*$'\n'}:${3%$'\n'*}" in 0:*'http=401 exit='*|0:*'http=403 exit='*) ;; 0:*'http=2'??' exit='*|0:*'http=3'??' exit='*) ;; 0:*) echo "the response is neither a denial nor a redirect, and a missing route or an unrelated error is not evidence of authentication; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit ${3##*$'\n'}: the request failed; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; a 2xx or redirect response is not classified, so read it for protected content or a login-page Location yourself" exit "$2" ) ``` **REASONED: inspect the loaded configuration locally.** No PHP executable was available in the authoring environment. On the deployment host, these commands identify CLI configuration and show the production error settings. [PHP command-line options](https://www.php.net/manual/en/features.commandline.options.php). REASONED: following block; loaded CLI configuration inspection. The guide records no PHP/PHP-FPM runtime, container runtime or supplied deployment; expectations follow its cited PHP and Laravel documentation, not a recorded run. ```bash ( # a subshell, so the exits below end only the subshell command -v php > /dev/null || { echo "no php command on this host: run this block on the PHP host; not checking"; exit 2; } set -- 0 # the block's status so far: 3 (a command failed or printed nothing) or 0 php --ini || { echo "php --ini exit $?; inconclusive"; set -- 3; } set -- "$1" "$(if php -i; then printf '\n0'; else printf '\n%s' "$?"; fi)" if [ "${2##*$'\n'}" != 0 ]; then echo "php -i exit ${2##*$'\n'}; inconclusive"; set -- 3 elif printf '%s' "${2%$'\n'*}" | grep -E '^(display_errors|display_startup_errors|log_errors|error_log|error_reporting|expose_php) =>'; then : else echo "php -i listed none of the error settings (grep exit $?); inconclusive"; set -- 3 fi php -r 'echo "E_ALL=", E_ALL, PHP_EOL;' || { echo "php -r exit $?; inconclusive"; set -- 3; } echo "block exit $1; the printed values are not classified, so compare them with the expected settings below" exit "$1" ) ``` REASONED: preceding block; loaded CLI configuration inspection and expected error settings. In the authoring environment, `php -v` exited 127 and printed `/usr/bin/bash: line 1: php: command not found`; `php` was not on the command path, and neither network package installation nor an authorized offline PHP package source was available. The block passed `bash -n` and ShellCheck 0.11.0 with no output; its scratch stubs printed `php-stub:--ini`, `grep-stub:-E ^(display_errors|display_startup_errors|log_errors|error_log|error_reporting|expose_php) =>` and `php-stub:-r echo "E_ALL=", E_ALL, PHP_EOL;`. Those runs covered this block in an earlier form, three top-level commands with no status of their own; its current form refuses with status 2 when no `php` command is found, exits 3 when a command fails or `php -i` lists none of the settings, and is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block. Exposed settings show display or startup errors On, logging Off or `expose_php` On where misconfigured; fixed settings show display and startup errors Off, logging On, a protected log, `expose_php` Off and reporting matched to the installed `E_ALL`. Per the guide's cited PHP command-line inspection (`--ini`, `-i`, `-r`), PHP 8.x error configuration (`display_errors`, `display_startup_errors`, `log_errors`, `error_log`, `error_reporting`) and PHP hiding and `expose_php` entries. An effective FPM or Apache comparison still needs that deployed SAPI; a live PHP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No actual CLI or FPM values were observed. Expect `display_errors=Off`, `display_startup_errors=Off`, `log_errors=On`, the intended protected `error_log`, `expose_php=Off`, and `error_reporting` equal to the installed version's `E_ALL`. PHP's diagnostic values can change between versions, so compare against the printed constant rather than a copied integer. CLI inspection does not establish FPM or Apache's effective configuration: inspect that SAPI's loaded files and overrides separately, including the FPM pool settings. Do not publish a `phpinfo()` endpoint. **REASONED: banner and native PHP error disclosure.** No web SAPI or deployment was available. In an isolated deployment matching production, temporarily serve this controlled fixture through the intended PHP handler and proxy. Use PHP's built-in error handler, without framework bootstrapping or application error-handler overrides. Restrict access to the test operator and remove the fixture afterwards. The fixed warning text contains no secret. [PHP `trigger_error()`](https://www.php.net/manual/en/function.trigger-error.php). REASONED: following block; controlled native PHP warning fixture. The guide records no PHP/PHP-FPM runtime, container runtime or supplied deployment; expectations follow its cited PHP and Laravel documentation, not a recorded run. ```php /dev/null; then echo "FINDING: the response carries X-Powered-By: PHP/, the header expose_php=On sends"; set -- "$1" 1 else case "$?" in 1) ;; *) echo "grep could not read the headers; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac fi fi echo "block exit $2; with the header absent, a proxy that strips it in both states is not detected, so attribute the setting through the active SAPI configuration yourself" exit "$2" ) ``` REASONED: preceding block; fixture-header curl probe and PHP banner interpretation. A live PHP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no proxy response or active SAPI inventory was available. Exposed with `expose_php=On`: successful fixture HTTP 200 with `X-Powered-By: PHP/...` unless a proxy strips it; fixed with Off: successful HTTP 200 without PHP's header. Per the guide's cited PHP hiding and `expose_php` and PHP 8.x error configuration (`display_errors`, `display_startup_errors`, `log_errors`, `error_log`, `error_reporting`) entries. The banner block passed `bash -n` and ShellCheck 0.11.0 with no output; scratch `bash` printed its placeholder guard, a dummy HTTPS URL reached `curl-stub`, and `bash -u` empty, extra and missing-`set --` trials printed the matching guard messages. The bash -n, ShellCheck and stub runs recorded here covered the banner block in an earlier form, whose guard refusals left the subshell with status 0; its current form, which exits 1 when the response carries `X-Powered-By: PHP/` and 3 for a failed transfer or a status other than 200, is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block. No response header was observed. With `expose_php=On`, the exposed-state response is expected to contain `X-Powered-By: PHP/...` unless another layer strips it. With `expose_php=Off`, it must be absent. Require a successful transfer and the fixture's expected HTTP 200 response. If the proxy strips the header in both states, this checks the externally visible response only; attribute the PHP setting through the active SAPI configuration. [PHP banner behavior](https://www.php.net/manual/en/security.hiding.php). REASONED: fixture-body inspection instruction. A live PHP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no protected worker log was available. Exposed with display errors On: the fixture HTTP 200 body includes `SECURECONFIG_PHP_ERROR_CANARY`; fixed with display errors Off and logging On: HTTP 200 body includes `SECURECONFIG_PHP_REACHED` without the warning, while the protected log receives `SECURECONFIG_PHP_ERROR_CANARY`. Per the guide's cited PHP controlled warning fixture (`trigger_error`) and PHP 8.x error configuration (`display_errors`, `display_startup_errors`, `log_errors`, `error_log`, `error_reporting`) entries. The following body block passed `bash -n` and ShellCheck 0.11.0 with no output. The following body block's scratch `bash` run printed `substitute the full HTTPS fixture URL inside the quotes above; not probing`, and a dummy HTTPS URL reached `curl-stub`. The following body block's `bash -u` empty, extra and missing-`set --` trials printed `substitute the full HTTPS fixture URL inside the quotes above; not probing`, `the set -- line needs exactly 1 value; not probing`, and `paste the whole block, including its set -- line; not probing`, respectively. The bash -n, ShellCheck and stub runs recorded here covered the following body block in an earlier form, whose guard refusals left the subshell with status 0; its current form, which reports its result in its exit status, is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block. No body or log was observed. Then inspect the body from the same fixture URL: REASONED: following block; native PHP and Laravel error-disclosure discrimination. The guide records no PHP/PHP-FPM runtime, container runtime or supplied deployment; expectations follow its cited PHP and Laravel documentation, not a recorded run. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_FIXTURE_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the full HTTPS fixture URL inside the quotes above; not probing"; exit 2 ;; esac set -- "$1" 0 "$(if curl -q -g --noproxy '*' -sS --connect-timeout 5 --max-time 20 \ -i \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' -- "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the 2nd value is the block's status printf '%s' "${3%$'\n'*}" case "${3##*$'\n'}:${3%$'\n'*}" in 0:*SECURECONFIG_PHP_ERROR_CANARY*) echo "FINDING: the response contains SECURECONFIG_PHP_ERROR_CANARY, so PHP displays the warning to the client"; set -- "$1" 1 ;; 0:*SECURECONFIG_PHP_REACHED*$'\n''http=200 exit='*) ;; 0:*) echo "the response is not HTTP 200 carrying SECURECONFIG_PHP_REACHED: a blank response, redirect, 404 or other error does not satisfy this check; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit ${3##*$'\n'}: the transfer failed; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; PHP diagnostics other than the marker, and the protected log entry for this request, are not classified, so check them yourself (a Laravel exception route does not print SECURECONFIG_PHP_REACHED, so there this block exits 3 and you read the response yourself)" exit "$2" ) ``` REASONED: preceding block; native PHP body curl probe, response and log comparison. A live PHP deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no worker log was available. Exposed with display errors On: HTTP 200 contains the warning marker; fixed with display errors Off and logging On: HTTP 200 contains the reached marker without diagnostics and the protected log contains the warning marker. Per the guide's cited PHP controlled warning fixture (`trigger_error`) and PHP 8.x error configuration (`display_errors`, `display_startup_errors`, `log_errors`, `error_log`, `error_reporting`) entries. The body block passed `bash -n` and ShellCheck 0.11.0 with no output; scratch `bash` printed its placeholder guard, a dummy HTTPS URL reached `curl-stub`, and `bash -u` empty, extra and missing-`set --` trials printed the matching guard messages. The bash -n, ShellCheck and stub runs recorded here covered the body block in an earlier form, whose guard refusals left the subshell with status 0. Its current form exits 1 when the response contains `SECURECONFIG_PHP_ERROR_CANARY` and 3 unless it is HTTP 200 carrying `SECURECONFIG_PHP_REACHED`; that form is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block. No HTTP or log outcome was observed. In the isolated exposed state, `display_errors=On` with `error_reporting=E_ALL` should disclose `SECURECONFIG_PHP_ERROR_CANARY` in the response. After applying section 5, the HTTP 200 body must contain `SECURECONFIG_PHP_REACHED` without the warning marker or PHP diagnostics, while the protected log receives the warning marker for that request. A blank response, redirect, 404, failed transfer, or missing log entry does not satisfy this check. This fixture tests a runtime warning; it does not exercise startup or parse errors. [PHP error configuration](https://www.php.net/manual/en/errorfunc.configuration.php). REASONED: Laravel exception-route comparison. A live Laravel deployment stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no controlled route, authorized session or application log was available. Exposed with `APP_DEBUG=true`: the route's response shows the distinctive exception, trace or configuration values; fixed with `APP_DEBUG=false` after rebuilding cached configuration: a generic error response omits those details and the protected application log records the exception. Per the guide's cited Laravel 12.x deployment (nginx example, `APP_DEBUG`) and Laravel 12.x configuration and environment-file security entries. The related body curl block passed `bash -n` and ShellCheck 0.11.0 with no output; scratch `bash` printed its placeholder guard, a dummy HTTPS URL reached `curl-stub`, and `bash -u` empty, extra and missing-`set --` trials printed the matching guard messages. The bash -n, ShellCheck and stub runs recorded here covered the related body curl block in an earlier form, whose guard refusals left the subshell with status 0; its current form is covered by the repository gate suite, which runs bash -n and ShellCheck 0.11.0 over every block. Against a Laravel exception route that block exits 3, because the response carries no `SECURECONFIG_PHP_REACHED`, so read the response itself for this comparison. No Laravel response or log was observed. For Laravel 12.x, repeat the body probe against a controlled route that raises an exception with a distinctive, non-secret message. In an isolated deployment, compare `APP_DEBUG=true` with `APP_DEBUG=false`, rebuilding cached configuration after each change. The protected state must show a generic error response without the exception message, trace, or configuration values; confirm the matching exception in the protected application log. The native PHP fixture alone does not establish Laravel's framework error behavior. [Laravel debug mode](https://laravel.com/framework/docs/12.x/deployment#debug-mode). ## Sources (checked September 2026) - PHP FPM configuration (`listen`, `listen.allowed_clients`, `listen.owner`) (PHP 8.x): https://www.php.net/manual/en/install.fpm.configuration.php - PHP session runtime configuration (PHP 8.x): https://www.php.net/manual/en/session.configuration.php - PHP `session_regenerate_id()`: https://www.php.net/manual/en/function.session-regenerate-id.php - fail2ban 1.1.1 jail filter and logpath options (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/man/jail.conf.5#L187-L200 - fail2ban 1.1.1 nginx-limit-req jail and nginx limiter prerequisite (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L378-L384 - fail2ban 1.1.1 nginx-limit-req filter expression (checked October 2026): https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/nginx-limit-req.conf#L48-L49 - PHP `password_hash()`: https://www.php.net/manual/en/function.password-hash.php - PHP cURL constants (`CURLOPT_SSL_VERIFYPEER`, `CURLOPT_SSL_VERIFYHOST`, `CURLOPT_CAINFO`): https://www.php.net/manual/en/curl.constants.php - curl manual, exit codes 6, 7, 28 and 60 and the `exitcode` and `errormsg` write-out variables (added in curl 7.75.0) (checked October 2026): https://curl.se/docs/manpage.html - Laravel 12.x encryption (`APP_KEY`, `key:generate`, `APP_PREVIOUS_KEYS`): https://laravel.com/framework/docs/12.x/encryption - Laravel 12.x requests (trusted proxies, trusted hosts): https://laravel.com/framework/docs/12.x/requests - Laravel 12.x `config/session.php` defaults: https://github.com/laravel/laravel/blob/f6b2e79bdbfc5bf4a37ad16466cc06ad79cc9e8f/config/session.php - Laravel 12.x `UrlGenerator::forceHttps()` and `forceScheme()`: https://api.laravel.com/docs/12.x/Illuminate/Routing/UrlGenerator.html - Laravel 12.x hashing: https://laravel.com/framework/docs/12.x/hashing - Laravel 12.x routing (rate limiting): https://laravel.com/framework/docs/12.x/routing - Laravel 12.x starter kits (Fortify, two-factor, rate limiting): https://laravel.com/framework/docs/12.x/starter-kits - Laravel 12.x Fortify: https://laravel.com/framework/docs/12.x/fortify - Laravel 12.x Socialite: https://laravel.com/framework/docs/12.x/socialite - Laravel 12.x deployment (nginx example, `APP_DEBUG`): https://laravel.com/framework/docs/12.x/deployment - PHP 8.x error configuration (`display_errors`, `display_startup_errors`, `log_errors`, `error_log`, `error_reporting`): https://www.php.net/manual/en/errorfunc.configuration.php - PHP core directives (`disable_functions`, `open_basedir`, memory and upload limits) (PHP 8.x): https://www.php.net/manual/en/ini.core.php - PHP hiding and `expose_php`: https://www.php.net/manual/en/security.hiding.php - PHP 8.4 bundled production template: https://raw.githubusercontent.com/php/php-src/d313ad6098430f4e61f0121a9e7ab392d195e4e4/php.ini-production - PHP 8.3 bundled production template (older session ID overrides): https://raw.githubusercontent.com/php/php-src/b94f9f68a610e0fca5f2fea5bfa7c7d6d3d5a847/php.ini-production - PHP filesystem and streams configuration (`allow_url_fopen`, `allow_url_include`): https://www.php.net/manual/en/filesystem.configuration.php - PHP execution and input limits (`max_execution_time`, `max_input_time`, `max_input_vars`) (PHP 8.x): https://www.php.net/manual/en/info.configuration.php - PHP upload-limit interactions and pitfalls: https://www.php.net/manual/en/features.file-upload.common-pitfalls.php - PHP session security and lifetime management: https://www.php.net/manual/en/session.security.ini.php - PHP command-line inspection (`--ini`, `-i`, `-r`): https://www.php.net/manual/en/features.commandline.options.php - PHP controlled warning fixture (`trigger_error`): https://www.php.net/manual/en/function.trigger-error.php - Laravel 12.x configuration and environment-file security: https://laravel.com/framework/docs/12.x/configuration - Laravel 12.x file storage (private local disk and public storage link): https://laravel.com/framework/docs/12.x/filesystem - Laravel 12.x Telescope local-only installation: https://laravel.com/framework/docs/12.x/telescope - Laravel Debugbar production-installation guidance: https://github.com/fruitcake/laravel-debugbar ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - php8: PHP native configuration (8.x) - template84: PHP production template (8.4) - template83: PHP production template (8.3) - laravel: Laravel (12.x) - php: PHP documentation (unknown) - debugbar: Laravel Debugbar (unknown) - fail2ban: fail2ban (1.1.1) - curl: curl manual (7.75.0) Claims (full source mapping in the JSON block below): - fpm-private (REASONED; php8): FPM requires listen per pool; exposed FastCGI permits code execution. Use a Unix socket or loopback TCP 9000; never publish container FPM. - fpm-clients (REASONED; php8): listen.allowed_clients defaults unset, accepting any address; explicitly allow loopback for TCP. - fpm-socket (REASONED; php8): Unix socket access uses listen.owner, listen.group and listen.mode, default 0660. - cookie-secure (REASONED; php8): Native session.cookie_secure defaults 0; enable it. - cookie-httponly (REASONED; php8): Native session.cookie_httponly defaults 0; enable it. - cookie-samesite (REASONED; php8): Native session.cookie_samesite defaults empty; choose Lax or Strict. - session-strict (REASONED; php8): session.use_strict_mode defaults 0; enable it. - session-regenerate (REASONED; php): Regenerate IDs after login/privilege changes; delete_old_session defaults false. Timestamp expiry avoids immediate deletion. - passwords (REASONED; php): PASSWORD_DEFAULT currently uses bcrypt; Argon2id needs an Argon2 build. Verify/rehash passwords and allow growing hashes, suggested 255 bytes. - plain-auth (REASONED; php, fail2ban): Guide assessment: cited PHP password and session functions lack login-attempt throttling (no claim about every PHP extension or application). Guide advice: a proxy limiter or fail2ban jail over app/proxy failure logs (cited sources establish no filter for this login form or log format) and for plain-PHP MFA a TOTP library or an MFA-requiring identity layer in front; no PHP TOTP library is pinned. - laravel-debug (REASONED; laravel): Set APP_ENV=production, APP_DEBUG=false and HTTPS APP_URL; debug mode exposes configuration values. - laravel-key (REASONED; laravel): Generate APP_KEY with artisan key:generate; list old keys in APP_PREVIOUS_KEYS during rotation. - laravel-secure (REASONED; laravel): Set SESSION_SECURE_COOKIE=true; the session secure configuration has no default. - laravel-httponly (REASONED; laravel): SESSION_HTTP_ONLY defaults true; retain it. - laravel-samesite (REASONED; laravel): SESSION_SAME_SITE defaults lax; strict suits admin-only apps. - laravel-proxy (REASONED; laravel): Laravel 12.x trustProxies names proxies in bootstrap/app.php; wildcard trust requires exclusive proxy reachability. - laravel-urls (REASONED; laravel): URL::forceHttps() or forceScheme(https) in a provider boot method forces generated HTTPS URLs. - laravel-hashes (REASONED; laravel): Hash::make/check default to bcrypt; HASH_DRIVER=argon2id switches it and needsRehash detects upgrades. HASH_VERIFY=false disables rejection of other algorithms. - laravel-limiter (REASONED; laravel): Define a five-per-minute login limiter keyed by email plus IP; attach throttle:login to the real route. - fortify-login (REASONED; laravel): 12.x starter kits use Fortify, which throttles username plus IP and regenerates session IDs on login; standalone Fortify omits views. - fortify-mfa (REASONED; laravel): Fortify twoFactorAuthentication with confirm and confirmPassword supplies TOTP and recovery codes. - socialite (REASONED; laravel): Socialite supplies OAuth providers; community packages supply OIDC. Use HTTPS callbacks and linked allowlist/token checks. - laravel-cache (REASONED; laravel): Keep plaintext .env untracked; build config/route caches after production inputs and rebuild on changes. Cached config does not load .env; use env only in config files. - laravel-hosts (REASONED; laravel): Laravel accepts arbitrary Host headers by default; restrict hosts at the server and add anchored TrustHosts with subdomains:false. - debug-tools (REASONED; laravel, debugbar): Omit Telescope and Debugbar from production; Telescope local-only installation includes conditional provider registration. - curl-peer (REASONED; php): CURLOPT_SSL_VERIFYPEER defaults true; retain certificate verification. - curl-host (REASONED; php): CURLOPT_SSL_VERIFYHOST defaults 2; retain hostname checks and use CURLOPT_CAINFO for an internal CA. - display-errors (REASONED; php8): PHP 8.x display_errors defaults On; set Off before execution. ini_set cannot hide a fatal error preventing it from running. - startup-errors (REASONED; php8): display_startup_errors defaults On since PHP 8.0.0, previously Off; set Off. - log-errors (REASONED; php8): log_errors defaults Off; enable it with a protected, pre-created, worker-writable log outside the document root and rotation. Unset error_log uses the SAPI logger. - error-reporting (REASONED; php8): error_reporting defaults E_ALL since PHP 8.0.0; retain reporting into logs. Package/template/deployment overrides can differ. - php-banner (REASONED; php8, php): expose_php defaults On; Off suppresses PHP own X-Powered-By header, not other layers or all application identification. - production-template (REASONED; template84): PHP 8.4 production template disables display and enables logging, but uses E_ALL & ~E_DEPRECATED and leaves expose_php enabled. - disable-functions (REASONED; php8): disable_functions defaults empty; test the listed process-function restrictions. Since PHP 8.0 definitions disappear and userland can redefine them; internal functions only, not a security boundary. - url-fopen (REASONED; php): allow_url_fopen defaults On; disable unnecessary URL wrappers after compatibility tests, not as a general egress policy. - url-include (REASONED; php): allow_url_include defaults Off, deprecated since PHP 7.4; retain Off. URL includes also require allow_url_fopen. - basedir (REASONED; php8): open_basedir defaults NULL; allow required paths, retain OS isolation and account for disabled realpath caching. PHP 8.3 rejects runtime .. components. - fpm-extensions (REASONED; php8): PHP 8.x FPM security.limit_extensions defaults .php .phar; restrict to .php, which still permits uploaded PHP dispatched by the server. - upload-execution (REASONED; php8, laravel): Separate writable uploads from code, preferably outside the document root; server mappings must forbid PHP execution, including aliases/symlinks. - laravel-storage (REASONED; laravel): Laravel 12.x local disk defaults storage/app/private; public disk uses storage/app/public via public/storage, which does not itself prevent PHP execution. - upload-size (REASONED; php8): upload_max_filesize defaults 2M per file; post_max_size must allow multipart overhead. - post-size (REASONED; php8): post_max_size defaults 8M; excess leaves POST and FILES empty and must be rejected by the app. - upload-count (REASONED; php8, php): max_file_uploads defaults 20; twenty maximum-sized files need not fit the POST limit. - memory-limit (REASONED; php8): memory_limit defaults 128M, should generally exceed post_max_size, and -1 removes the bound. - input-vars (REASONED; php8): max_input_vars defaults 1000 separately for GET, POST and COOKIE; excess is warned/truncated, not a general JSON limit. - execution-time (REASONED; php8): max_execution_time defaults 30 for web and 0 for CLI; platform/build accounting can exclude I/O, not a portable wall-clock deadline. - input-time (REASONED; php8, template84): max_input_time defaults -1, using max_execution_time; 0 is unlimited. Example and production template select 60 seconds. - upload-time (REASONED; php): Test slow/multiple uploads and coordinate server size/time limits. Per-request bounds do not replace capacity controls or rate limiting. - fpm-timeout (REASONED; php8): request_terminate_timeout defaults 0, disabled; example selects 60s as app policy and kills overlong workers. - fpm-finished (REASONED; php8): request_terminate_timeout_track_finished defaults no; yes extends the limit past fastcgi_finish_request and into shutdown work. - session-gc (REASONED; php8, php): Native gc_maxlifetime defaults 1440 seconds; probabilistic collection is not auth expiry. Use timestamp expiry/cleanup; shared paths can inherit shorter lifetimes. - session-lifetime (REASONED; php8, laravel): Native cookie_lifetime defaults 0, a browser-session cookie; Laravel uses config/session.php rather than native storage/lifetime directives. - session-only-cookies (REASONED; php8): session.use_only_cookies defaults 1; retain it to exclude GET/POST IDs. Disabling it is deprecated from PHP 8.4. - session-storage (REASONED; php8): Default files handler needs a private worker-writable directory outside the document root; dedicated-account mode 0700, default file mode 0600, separate untrusted-app identities. - session-sid-length (REASONED; php8, template83): PHP 8.x session.sid_length defaults 32; changes are deprecated from 8.4. PHP 8.3 production template sets 26. - session-sid-bits (REASONED; php8, template83): PHP 8.x session.sid_bits_per_character defaults 4; changes are deprecated from 8.4. PHP 8.3 production template sets 5. - verify-listener (REASONED; php8): Inventory all FPM listeners: Unix socket or loopback TCP 9000 only. - verify-tls (REASONED; curl): The TLS probe distinguishes cited exit=60 certificate failure from reasoned exit=0 success without -k; linked fronting-server guides supply TLS configuration. - verify-cookies (REASONED; php8, laravel): Inspect login Set-Cookie for Secure, HttpOnly and SameSite=Lax. - verify-auth (REASONED; laravel): Anonymous protected routes must deny or redirect to login without protected content; require authorized success and isolated auth-removal control. Unrelated redirects/errors and anonymous Set-Cookie do not establish authentication. - verify-config (REASONED; php, php8): Inspect CLI ini/settings against installed E_ALL; CLI does not establish web SAPI/FPM settings. Do not publish phpinfo. - verify-banner (REASONED; php): Fixture should give HTTP 200; expose_php On should show PHP header unless stripped, Off must hide it. Proxy stripping in both states needs SAPI inspection. - verify-error (REASONED; php8, php): Native warning fixture must disclose canary with display_errors On, then return reached marker without diagnostics and log the warning after hardening. Errors/missing logs/blank bodies are inconclusive; startup/parse errors untested. - verify-laravel-error (REASONED; laravel): Repeat body probe with controlled Laravel exception, rebuild config cache after APP_DEBUG changes, and require generic response plus protected log; native fixture does not test framework errors. - verify-curl-version (REASONED; curl): The exitcode and errormsg write-out variables require curl 7.75.0 or later per the curl manual. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "68ff4db5978b00e023f86d75f52ae8a48964384416a045a2150ad4d7e7d12941", "components": { "php8": { "name": "PHP native configuration", "basis": "8.x", "sources": { "s89c799077b82": "https://www.php.net/manual/en/errorfunc.configuration.php", "s5a3f7bde8338": "https://www.php.net/manual/en/info.configuration.php", "sc9e417e2ad6f": "https://www.php.net/manual/en/ini.core.php", "s2e692b7c86a6": "https://www.php.net/manual/en/install.fpm.configuration.php", "s000e155a7b25": "https://www.php.net/manual/en/session.configuration.php" } }, "template84": { "name": "PHP production template", "basis": "8.4", "sources": { "sd72e3d69551f": "https://raw.githubusercontent.com/php/php-src/d313ad6098430f4e61f0121a9e7ab392d195e4e4/php.ini-production" } }, "template83": { "name": "PHP production template", "basis": "8.3", "sources": { "sea3d3a4ea486": "https://raw.githubusercontent.com/php/php-src/b94f9f68a610e0fca5f2fea5bfa7c7d6d3d5a847/php.ini-production" } }, "laravel": { "name": "Laravel", "basis": "12.x", "sources": { "sf0448f3d4665": "https://api.laravel.com/docs/12.x/Illuminate/Routing/UrlGenerator.html", "s98db3429151c": "https://github.com/laravel/laravel/blob/f6b2e79bdbfc5bf4a37ad16466cc06ad79cc9e8f/config/session.php", "s4e846bd588a8": "https://laravel.com/framework/docs/12.x/configuration", "s6510c1aa8d96": "https://laravel.com/framework/docs/12.x/deployment", "sb3e2d08bd07d": "https://laravel.com/framework/docs/12.x/encryption", "s879d69d64852": "https://laravel.com/framework/docs/12.x/filesystem", "sc58ba221e3cd": "https://laravel.com/framework/docs/12.x/fortify", "sdf4ba04ddcda": "https://laravel.com/framework/docs/12.x/hashing", "s517839d232ec": "https://laravel.com/framework/docs/12.x/requests", "s248fef722141": "https://laravel.com/framework/docs/12.x/routing", "s003198079bdd": "https://laravel.com/framework/docs/12.x/socialite", "sb3e846095c54": "https://laravel.com/framework/docs/12.x/starter-kits", "sd7d2287b5d35": "https://laravel.com/framework/docs/12.x/telescope" } }, "php": { "name": "PHP documentation", "basis": "unknown", "sources": { "s09fdbeba0867": "https://www.php.net/manual/en/curl.constants.php", "s5840a5a0a848": "https://www.php.net/manual/en/features.commandline.options.php", "s3cbc65590d52": "https://www.php.net/manual/en/features.file-upload.common-pitfalls.php", "s6f3e55d536e7": "https://www.php.net/manual/en/filesystem.configuration.php", "s50f85ff4fbd1": "https://www.php.net/manual/en/function.password-hash.php", "s625cd1d40655": "https://www.php.net/manual/en/function.session-regenerate-id.php", "s2f1c806d037c": "https://www.php.net/manual/en/function.trigger-error.php", "s7b2c2c19835d": "https://www.php.net/manual/en/security.hiding.php", "s8664dc56a028": "https://www.php.net/manual/en/session.security.ini.php" } }, "debugbar": { "name": "Laravel Debugbar", "basis": "unknown", "sources": { "s7099781fdf4a": "https://github.com/fruitcake/laravel-debugbar" } }, "fail2ban": { "name": "fail2ban", "basis": "1.1.1", "sources": { "s0470655e109b": "https://github.com/fail2ban/fail2ban/blob/1.1.1/man/jail.conf.5#L187-L200", "s9b3b926fe020": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/jail.conf#L378-L384", "sdb5f6f355db6": "https://github.com/fail2ban/fail2ban/blob/1.1.1/config/filter.d/nginx-limit-req.conf#L48-L49" } }, "curl": { "name": "curl manual", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "fpm-private": {"text": "FPM requires listen per pool; exposed FastCGI permits code execution. Use a Unix socket or loopback TCP 9000; never publish container FPM.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED"}, "fpm-clients": {"text": "listen.allowed_clients defaults unset, accepting any address; explicitly allow loopback for TCP.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED"}, "fpm-socket": {"text": "Unix socket access uses listen.owner, listen.group and listen.mode, default 0660.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED"}, "cookie-secure": {"text": "Native session.cookie_secure defaults 0; enable it.", "components": ["php8"], "sources": ["php8:s000e155a7b25"], "status": "REASONED"}, "cookie-httponly": {"text": "Native session.cookie_httponly defaults 0; enable it.", "components": ["php8"], "sources": ["php8:s000e155a7b25"], "status": "REASONED"}, "cookie-samesite": {"text": "Native session.cookie_samesite defaults empty; choose Lax or Strict.", "components": ["php8"], "sources": ["php8:s000e155a7b25"], "status": "REASONED"}, "session-strict": {"text": "session.use_strict_mode defaults 0; enable it.", "components": ["php8"], "sources": ["php8:s000e155a7b25"], "status": "REASONED"}, "session-regenerate": {"text": "Regenerate IDs after login/privilege changes; delete_old_session defaults false. Timestamp expiry avoids immediate deletion.", "components": ["php"], "sources": ["php:s625cd1d40655"], "status": "REASONED"}, "passwords": {"text": "PASSWORD_DEFAULT currently uses bcrypt; Argon2id needs an Argon2 build. Verify/rehash passwords and allow growing hashes, suggested 255 bytes.", "components": ["php"], "sources": ["php:s50f85ff4fbd1"], "status": "REASONED"}, "plain-auth": {"text": "Guide assessment: cited PHP password and session functions lack login-attempt throttling (no claim about every PHP extension or application). Guide advice: a proxy limiter or fail2ban jail over app/proxy failure logs (cited sources establish no filter for this login form or log format) and for plain-PHP MFA a TOTP library or an MFA-requiring identity layer in front; no PHP TOTP library is pinned.", "components": ["php", "fail2ban"], "sources": ["php:s50f85ff4fbd1", "php:s8664dc56a028", "fail2ban:s0470655e109b", "fail2ban:s9b3b926fe020", "fail2ban:sdb5f6f355db6"], "status": "REASONED"}, "laravel-debug": {"text": "Set APP_ENV=production, APP_DEBUG=false and HTTPS APP_URL; debug mode exposes configuration values.", "components": ["laravel"], "sources": ["laravel:s4e846bd588a8", "laravel:s6510c1aa8d96"], "status": "REASONED"}, "laravel-key": {"text": "Generate APP_KEY with artisan key:generate; list old keys in APP_PREVIOUS_KEYS during rotation.", "components": ["laravel"], "sources": ["laravel:sb3e2d08bd07d"], "status": "REASONED"}, "laravel-secure": {"text": "Set SESSION_SECURE_COOKIE=true; the session secure configuration has no default.", "components": ["laravel"], "sources": ["laravel:s98db3429151c"], "status": "REASONED"}, "laravel-httponly": {"text": "SESSION_HTTP_ONLY defaults true; retain it.", "components": ["laravel"], "sources": ["laravel:s98db3429151c"], "status": "REASONED"}, "laravel-samesite": {"text": "SESSION_SAME_SITE defaults lax; strict suits admin-only apps.", "components": ["laravel"], "sources": ["laravel:s98db3429151c"], "status": "REASONED"}, "laravel-proxy": {"text": "Laravel 12.x trustProxies names proxies in bootstrap/app.php; wildcard trust requires exclusive proxy reachability.", "components": ["laravel"], "sources": ["laravel:s517839d232ec"], "status": "REASONED"}, "laravel-urls": {"text": "URL::forceHttps() or forceScheme(https) in a provider boot method forces generated HTTPS URLs.", "components": ["laravel"], "sources": ["laravel:sf0448f3d4665"], "status": "REASONED"}, "laravel-hashes": {"text": "Hash::make/check default to bcrypt; HASH_DRIVER=argon2id switches it and needsRehash detects upgrades. HASH_VERIFY=false disables rejection of other algorithms.", "components": ["laravel"], "sources": ["laravel:sdf4ba04ddcda"], "status": "REASONED"}, "laravel-limiter": {"text": "Define a five-per-minute login limiter keyed by email plus IP; attach throttle:login to the real route.", "components": ["laravel"], "sources": ["laravel:s248fef722141"], "status": "REASONED"}, "fortify-login": {"text": "12.x starter kits use Fortify, which throttles username plus IP and regenerates session IDs on login; standalone Fortify omits views.", "components": ["laravel"], "sources": ["laravel:sc58ba221e3cd", "laravel:sb3e846095c54"], "status": "REASONED"}, "fortify-mfa": {"text": "Fortify twoFactorAuthentication with confirm and confirmPassword supplies TOTP and recovery codes.", "components": ["laravel"], "sources": ["laravel:sc58ba221e3cd", "laravel:sb3e846095c54"], "status": "REASONED"}, "socialite": {"text": "Socialite supplies OAuth providers; community packages supply OIDC. Use HTTPS callbacks and linked allowlist/token checks.", "components": ["laravel"], "sources": ["laravel:s003198079bdd"], "status": "REASONED"}, "laravel-cache": {"text": "Keep plaintext .env untracked; build config/route caches after production inputs and rebuild on changes. Cached config does not load .env; use env only in config files.", "components": ["laravel"], "sources": ["laravel:s4e846bd588a8", "laravel:s6510c1aa8d96"], "status": "REASONED"}, "laravel-hosts": {"text": "Laravel accepts arbitrary Host headers by default; restrict hosts at the server and add anchored TrustHosts with subdomains:false.", "components": ["laravel"], "sources": ["laravel:s517839d232ec"], "status": "REASONED"}, "debug-tools": {"text": "Omit Telescope and Debugbar from production; Telescope local-only installation includes conditional provider registration.", "components": ["laravel", "debugbar"], "sources": ["laravel:sd7d2287b5d35", "debugbar:s7099781fdf4a"], "status": "REASONED"}, "curl-peer": {"text": "CURLOPT_SSL_VERIFYPEER defaults true; retain certificate verification.", "components": ["php"], "sources": ["php:s09fdbeba0867"], "status": "REASONED"}, "curl-host": {"text": "CURLOPT_SSL_VERIFYHOST defaults 2; retain hostname checks and use CURLOPT_CAINFO for an internal CA.", "components": ["php"], "sources": ["php:s09fdbeba0867"], "status": "REASONED"}, "display-errors": {"text": "PHP 8.x display_errors defaults On; set Off before execution. ini_set cannot hide a fatal error preventing it from running.", "components": ["php8"], "sources": ["php8:s89c799077b82"], "status": "REASONED"}, "startup-errors": {"text": "display_startup_errors defaults On since PHP 8.0.0, previously Off; set Off.", "components": ["php8"], "sources": ["php8:s89c799077b82"], "status": "REASONED"}, "log-errors": {"text": "log_errors defaults Off; enable it with a protected, pre-created, worker-writable log outside the document root and rotation. Unset error_log uses the SAPI logger.", "components": ["php8"], "sources": ["php8:s89c799077b82"], "status": "REASONED"}, "error-reporting": {"text": "error_reporting defaults E_ALL since PHP 8.0.0; retain reporting into logs. Package/template/deployment overrides can differ.", "components": ["php8"], "sources": ["php8:s89c799077b82"], "status": "REASONED"}, "php-banner": {"text": "expose_php defaults On; Off suppresses PHP own X-Powered-By header, not other layers or all application identification.", "components": ["php8", "php"], "sources": ["php8:sc9e417e2ad6f", "php:s7b2c2c19835d"], "status": "REASONED"}, "production-template": {"text": "PHP 8.4 production template disables display and enables logging, but uses E_ALL & ~E_DEPRECATED and leaves expose_php enabled.", "components": ["template84"], "sources": ["template84:sd72e3d69551f"], "status": "REASONED"}, "disable-functions": {"text": "disable_functions defaults empty; test the listed process-function restrictions. Since PHP 8.0 definitions disappear and userland can redefine them; internal functions only, not a security boundary.", "components": ["php8"], "sources": ["php8:sc9e417e2ad6f"], "status": "REASONED"}, "url-fopen": {"text": "allow_url_fopen defaults On; disable unnecessary URL wrappers after compatibility tests, not as a general egress policy.", "components": ["php"], "sources": ["php:s6f3e55d536e7"], "status": "REASONED"}, "url-include": {"text": "allow_url_include defaults Off, deprecated since PHP 7.4; retain Off. URL includes also require allow_url_fopen.", "components": ["php"], "sources": ["php:s6f3e55d536e7"], "status": "REASONED"}, "basedir": {"text": "open_basedir defaults NULL; allow required paths, retain OS isolation and account for disabled realpath caching. PHP 8.3 rejects runtime .. components.", "components": ["php8"], "sources": ["php8:sc9e417e2ad6f"], "status": "REASONED"}, "fpm-extensions": {"text": "PHP 8.x FPM security.limit_extensions defaults .php .phar; restrict to .php, which still permits uploaded PHP dispatched by the server.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED"}, "upload-execution": {"text": "Separate writable uploads from code, preferably outside the document root; server mappings must forbid PHP execution, including aliases/symlinks.", "components": ["php8", "laravel"], "sources": ["php8:s2e692b7c86a6", "laravel:s879d69d64852"], "status": "REASONED"}, "laravel-storage": {"text": "Laravel 12.x local disk defaults storage/app/private; public disk uses storage/app/public via public/storage, which does not itself prevent PHP execution.", "components": ["laravel"], "sources": ["laravel:s879d69d64852"], "status": "REASONED"}, "upload-size": {"text": "upload_max_filesize defaults 2M per file; post_max_size must allow multipart overhead.", "components": ["php8"], "sources": ["php8:sc9e417e2ad6f"], "status": "REASONED"}, "post-size": {"text": "post_max_size defaults 8M; excess leaves POST and FILES empty and must be rejected by the app.", "components": ["php8"], "sources": ["php8:sc9e417e2ad6f"], "status": "REASONED"}, "upload-count": {"text": "max_file_uploads defaults 20; twenty maximum-sized files need not fit the POST limit.", "components": ["php8", "php"], "sources": ["php8:sc9e417e2ad6f", "php:s3cbc65590d52"], "status": "REASONED"}, "memory-limit": {"text": "memory_limit defaults 128M, should generally exceed post_max_size, and -1 removes the bound.", "components": ["php8"], "sources": ["php8:sc9e417e2ad6f"], "status": "REASONED"}, "input-vars": {"text": "max_input_vars defaults 1000 separately for GET, POST and COOKIE; excess is warned/truncated, not a general JSON limit.", "components": ["php8"], "sources": ["php8:s5a3f7bde8338"], "status": "REASONED"}, "execution-time": {"text": "max_execution_time defaults 30 for web and 0 for CLI; platform/build accounting can exclude I/O, not a portable wall-clock deadline.", "components": ["php8"], "sources": ["php8:s5a3f7bde8338"], "status": "REASONED"}, "input-time": {"text": "max_input_time defaults -1, using max_execution_time; 0 is unlimited. Example and production template select 60 seconds.", "components": ["php8", "template84"], "sources": ["php8:s5a3f7bde8338", "template84:sd72e3d69551f"], "status": "REASONED"}, "upload-time": {"text": "Test slow/multiple uploads and coordinate server size/time limits. Per-request bounds do not replace capacity controls or rate limiting.", "components": ["php"], "sources": ["php:s3cbc65590d52"], "status": "REASONED"}, "fpm-timeout": {"text": "request_terminate_timeout defaults 0, disabled; example selects 60s as app policy and kills overlong workers.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED"}, "fpm-finished": {"text": "request_terminate_timeout_track_finished defaults no; yes extends the limit past fastcgi_finish_request and into shutdown work.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED"}, "session-gc": {"text": "Native gc_maxlifetime defaults 1440 seconds; probabilistic collection is not auth expiry. Use timestamp expiry/cleanup; shared paths can inherit shorter lifetimes.", "components": ["php8", "php"], "sources": ["php8:s000e155a7b25", "php:s8664dc56a028"], "status": "REASONED"}, "session-lifetime": {"text": "Native cookie_lifetime defaults 0, a browser-session cookie; Laravel uses config/session.php rather than native storage/lifetime directives.", "components": ["php8", "laravel"], "sources": ["php8:s000e155a7b25", "laravel:s98db3429151c"], "status": "REASONED"}, "session-only-cookies": {"text": "session.use_only_cookies defaults 1; retain it to exclude GET/POST IDs. Disabling it is deprecated from PHP 8.4.", "components": ["php8"], "sources": ["php8:s000e155a7b25"], "status": "REASONED"}, "session-storage": {"text": "Default files handler needs a private worker-writable directory outside the document root; dedicated-account mode 0700, default file mode 0600, separate untrusted-app identities.", "components": ["php8"], "sources": ["php8:s000e155a7b25"], "status": "REASONED"}, "session-sid-length": {"text": "PHP 8.x session.sid_length defaults 32; changes are deprecated from 8.4. PHP 8.3 production template sets 26.", "components": ["php8", "template83"], "sources": ["php8:s000e155a7b25", "template83:sea3d3a4ea486"], "status": "REASONED"}, "session-sid-bits": {"text": "PHP 8.x session.sid_bits_per_character defaults 4; changes are deprecated from 8.4. PHP 8.3 production template sets 5.", "components": ["php8", "template83"], "sources": ["php8:s000e155a7b25", "template83:sea3d3a4ea486"], "status": "REASONED"}, "verify-listener": {"text": "Inventory all FPM listeners: Unix socket or loopback TCP 9000 only.", "components": ["php8"], "sources": ["php8:s2e692b7c86a6"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "The TLS probe distinguishes cited exit=60 certificate failure from reasoned exit=0 success without -k; linked fronting-server guides supply TLS configuration.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-cookies": {"text": "Inspect login Set-Cookie for Secure, HttpOnly and SameSite=Lax.", "components": ["php8", "laravel"], "sources": ["php8:s000e155a7b25", "laravel:s98db3429151c"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "Anonymous protected routes must deny or redirect to login without protected content; require authorized success and isolated auth-removal control. Unrelated redirects/errors and anonymous Set-Cookie do not establish authentication.", "components": ["laravel"], "sources": ["laravel:sc58ba221e3cd"], "status": "REASONED", "verify": [2]}, "verify-config": {"text": "Inspect CLI ini/settings against installed E_ALL; CLI does not establish web SAPI/FPM settings. Do not publish phpinfo.", "components": ["php", "php8"], "sources": ["php:s5840a5a0a848", "php8:s89c799077b82", "php8:s2e692b7c86a6"], "status": "REASONED", "verify": [3]}, "verify-banner": {"text": "Fixture should give HTTP 200; expose_php On should show PHP header unless stripped, Off must hide it. Proxy stripping in both states needs SAPI inspection.", "components": ["php"], "sources": ["php:s7b2c2c19835d", "php:s2f1c806d037c"], "status": "REASONED", "verify": [4]}, "verify-error": {"text": "Native warning fixture must disclose canary with display_errors On, then return reached marker without diagnostics and log the warning after hardening. Errors/missing logs/blank bodies are inconclusive; startup/parse errors untested.", "components": ["php8", "php"], "sources": ["php8:s89c799077b82", "php:s2f1c806d037c"], "status": "REASONED", "verify": [5]}, "verify-laravel-error": {"text": "Repeat body probe with controlled Laravel exception, rebuild config cache after APP_DEBUG changes, and require generic response plus protected log; native fixture does not test framework errors.", "components": ["laravel"], "sources": ["laravel:s4e846bd588a8", "laravel:s6510c1aa8d96"], "status": "REASONED", "verify": [5]}, "verify-curl-version": {"text": "The exitcode and errormsg write-out variables require curl 7.75.0 or later per the curl manual.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [1, 2, 4, 5]} } } ```` ====================================================================== ==> ruby.md ====================================================================== # Ruby on Rails and Puma: TLS and authentication Ruby on Rails is a Ruby framework for building web apps, and Puma is a web server that runs them. This guide helps your AI assistant deploy them securely, including Puma bound privately behind a TLS reverse proxy, Rails told about that proxy, authentication, and certificate checks in outbound calls. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/ruby.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Rails documentation (no recorded version), Action Pack 7.1, and Puma defaults v8.0.2; 5 further sources are pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 26 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Puma's default bind is `tcp://[::]:9292` when the host has a non-loopback IPv6 address, otherwise `tcp://0.0.0.0:9292` (as of v8.0.2): every interface, plain HTTP. Rails encrypts its session cookie but still sends it in clear unless HTTPS is enforced. Preferred layout: bind Puma to loopback and terminate TLS in a reverse proxy ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md)) or behind [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md). Puma can also terminate TLS itself, shown below. ## 1. Bind Puma privately ```ruby # config/puma.rb bind "tcp://127.0.0.1:3000" # or: bind "unix:///var/run/puma.sock" # Puma terminating TLS itself (only when no proxy is in front): # bind "ssl://0.0.0.0:8443?key=/etc/ssl/private/server.key&cert=/etc/ssl/certs/server.crt" ``` `bind` accepts only `tcp://`, `unix://`, and `ssl://` URIs. The `ssl://` query string also takes `ca=` and `verify_mode=` for client certificates ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)). ## 2. Rails behind the proxy ```ruby # config/environments/production.rb config.force_ssl = true # ActionDispatch::SSL: HTTPS redirect, HSTS per config.ssl_options (default hsts: { subdomains: true }) config.assume_ssl = true # Rails 7.1+: the proxy terminated TLS and forwards plain HTTP config.hosts << "app.example.com" # Host header allowlist (DNS rebinding) config.session_store :cookie_store, key: "_app_session", secure: true, httponly: true, same_site: :lax, expire_after: 14.days ``` `config.action_dispatch.trusted_proxies` already covers loopback, private, and link-local ranges, so a same-host proxy needs nothing more. A proxy on a public address must be listed as an enumerable, for example `config.action_dispatch.trusted_proxies = [IPAddr.new("203.0.113.10")]`; a single value is not supported. Without any proxy, `X-Forwarded-For` is client-controlled, so do not trust `request.remote_ip` for rate limits or allowlists. `config.action_dispatch.cookies_same_site_protection` defaults to `:lax` from `load_defaults 6.1`. ## 3. Authentication Follow [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Rails specifics: - Rails 8.0 and later: `bin/rails generate authentication` "Generates a basic authentication system with users, sessions, and password reset", built on `has_secure_password`. Prefer it to hand-rolled login code. - `has_secure_password` needs `gem "bcrypt", "~> 3.1.7"` and a `password_digest` column; `user.authenticate(password)` returns the user or `false`. It validates presence and the 72-byte bcrypt limit; add your own minimum length. - Rate-limit login in the controller (needs an `ActiveSupport::Cache` store; defaults to `config.action_controller.cache_store`): ```ruby class SessionsController < ApplicationController rate_limit to: 10, within: 3.minutes, only: :create end ``` - For path-level throttles and blocklists, [Rack::Attack](https://github.com/rack/rack-attack) (`gem "rack-attack"`, configured in `config/initializers/rack_attack.rb`; its railtie adds the middleware in Rails). - Secrets live in `config/credentials.yml.enc`, edited with `bin/rails credentials:edit`; the decryption key is `config/master.key` (Rails adds it to `.gitignore`) or `ENV["RAILS_MASTER_KEY"]`, which takes precedence. Set `config.require_master_key = true` so a deployment without the key refuses to boot instead of running half-configured. - MFA: Rails has no built-in second factor. Add TOTP in the app or front it with an identity layer; options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). OIDC login follows [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md). Sidekiq's Web UI is a common blind spot. Mounted the usual way it has no authentication of its own: ```ruby # config/routes.rb -- unsafe as written require "sidekiq/web" mount Sidekiq::Web => "/sidekiq" ``` Anyone who reaches `/sidekiq` can read the arguments of the jobs it lists, which routinely carry tokens, email addresses, and record IDs, and can retry, kill, or clear queues. Gate the mount; these route snippets go inside your `Rails.application.routes.draw` block. With Devise, wrap it in an `authenticate` constraint so only a signed-in admin reaches it: ```ruby authenticate :user, ->(u) { u.admin? } do mount Sidekiq::Web => "/sidekiq" end ``` Without Devise, put HTTP Basic Auth in front of the Rack app, a separate credential kept out of source and compared in constant time (the SHA256 digests give `secure_compare` equal-length inputs, and `&` avoids a short-circuit that would leak which half matched): ```ruby # config/initializers/sidekiq.rb require "sidekiq/web" Sidekiq::Web.use(Rack::Auth::Basic) do |user, password| ActiveSupport::SecurityUtils.secure_compare( ::Digest::SHA256.hexdigest(user), ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_USER"))) & ActiveSupport::SecurityUtils.secure_compare( ::Digest::SHA256.hexdigest(password), ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_PASSWORD"))) end ``` Never mount it bare, and keep it off the public internet even behind auth. ## 4. Client-side TLS discipline ```ruby Net::HTTP.start("api.example.com", 443, use_ssl: true, # verify_mode defaults to OpenSSL::SSL::VERIFY_PEER ca_file: "/etc/ssl/certs/internal-ca.pem") do |http| # internal CA; or set SSL_CERT_FILE in the environment http.request(Net::HTTP::Get.new("/")) end ``` `Net::HTTP.start` takes `use_ssl`, `ca_file`, `verify_mode`, and the other SSL settings in its options hash and applies them when it opens the connection. Assigning `http.ca_file` inside the block comes after the handshake and does not affect it; to set attributes on an instance, use `Net::HTTP.new` and set them before calling `start`. Never set `verify_mode = OpenSSL::SSL::VERIFY_NONE`. Ruby's default SSL context loads the system store through `set_default_paths`, and OpenSSL reads `SSL_CERT_FILE` and `SSL_CERT_DIR` to locate that store, so an internal CA goes there (see [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) rather than into a disabled check. ## Verify REASONED: following block; Puma binds, Rails HTTPS/cookies/login, Sidekiq denial and master-key tracking. No application deployment or run outcome is recorded here; these checks are reasoned from the cited Puma, Rails and Sidekiq sources. The four curls take the application's host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of this block. Stub runs with the network and host commands replaced ran its guard but no deployment on an earlier form, which lowercased the host with `tr` and checked `tr`'s exit status before refusing reserved names; earlier QA reports record stub runs of forms before that one, and no exposed/fixed deployment run of any form is recorded. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and its host part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. Stub runs of the current form on 2026-11-01, with stub curl and ss and no network, also ran no deployment. In bash, the host part refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probes. Under dash, stub runs of the current form refused every host name in the host part with 2 and the bash message before its probes, and the block exited 3, as the earlier form did there for every host, which it reported inconclusive at its `tr` step. When `ss` is missing, the block prints `ss is not installed here; not listing sockets` and exits 2 only if no other part found something or was inconclusive. ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input or found ss missing), which outranks 0 ( # a nested subshell, so this part's set -- and exit leave the block's status above untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your Rails app is served on (a name in config.hosts), no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above curl -q -g -sI "http://$1/" || # 301 to https:// (force_ssl) { echo "curl exit $?: the redirect request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } set -- "$1" "$2" "$(if curl -q -g -sI "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the headers if printf '%s' "$4" | grep -iE 'strict-transport|set-cookie'; then :; else # HSTS; secure; httponly; samesite=lax case "$?" in 1) ;; *) echo "grep could not filter the HTTPS headers; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; esac fi [ "$3" = 0 ] || { echo "curl exit $3: the HTTPS request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3; } set -- "$1" "$2" "$(if curl -q -g -s -o /dev/null -w '%{http_code}\n' "https://$1/dashboard"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" # 302 to login, or 401 case "$3:$4" in 0:302|0:401) ;; 0:2??) echo "FINDING: /dashboard answered $4 with no credentials"; set -- "$1" 1 ;; 0:*) echo "HTTP $4 from /dashboard is neither a 302 to login, a 401 nor a 2xx finding; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the /dashboard request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS -i -w '\n%{http_code}' "https://$1/sidekiq"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response and its HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # this part's status, curl's exit status, the response, then the HTTP code printf '%s\n' "$4" # if you run Sidekiq Web, read the headers, not just the code: Basic Auth answers 401 with a # WWW-Authenticate: Basic header; the Devise constraint answers 302 with a Location pointing at your # login path (a bare 404 is inconclusive, it also means nothing is mounted there). A 200 that returns # the Sidekiq dashboard is the exposure. Then confirm an authorized request does reach it case "$3:$5" in 0:200) if printf '%s' "$4" | grep -i sidekiq > /dev/null; then echo "FINDING: /sidekiq answered 200 with a page that names Sidekiq, so the dashboard is exposed"; set -- "$1" 1 else echo "HTTP 200 from /sidekiq with a page that does not name Sidekiq; read it yourself; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 fi ;; 0:*) ;; *) echo "curl exit $3: the /sidekiq request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above if command -v ss >/dev/null; then set -- "$1" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far, then the listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the listener table, then ss's exit status printf '%s' "$2" # read every listener; puma/ruby: 127.0.0.1:3000 only (the check also counts any 127.x.x.x, [::1] or [::ffff:127.x.x.x] address on 3000 as loopback) [ "$3" = 0 ] || { echo "ss -tlnp failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:3000$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 3000 is bound to a non-loopback address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi if [ -f config/application.rb ]; then set -- "$1" "$(if git ls-files config/master.key; then printf '\n0'; else printf '\n%s' "$?"; fi)" # git prints nothing set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, git's output, then git's exit status if [ "$3" != 0 ]; then echo "git ls-files exit $3 (run this in the app's repository); inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" elif [ -n "$2" ]; then printf '%s' "$2"; echo "FINDING: config/master.key is tracked by git"; set -- 1 "$2" "$3" else echo "config/master.key is not tracked by git" fi else # a guard: git ls-files resolves the path against this directory echo "no config/application.rb here: run this block from the Rails app's root directory, where git ls-files looks for config/master.key; not checking" case "$1" in 0) set -- 2 ;; esac fi echo "block exit $1; the redirect and HTTPS headers, a /sidekiq answer other than 200, and listeners on ports other than 3000 (every listener, if ss is missing) are not classified, so judge them yourself" exit "$1" ) ``` ## Sources (checked September 2026) - Puma README (binding): https://github.com/puma/puma/blob/aef89221e4d729c3133c723844382331ba3bbbd9/README.md - Puma DSL (`bind`, `ssl_bind`, default bind): https://github.com/puma/puma/blob/d70de8b4e926f1f5fa0269dc46cdfadf52562628/lib/puma/dsl.rb - Puma default TCP port `9292` (`tcp_port: 9292`, L174) and default bind host (L369-L384): `::` when a non-loopback IPv6 interface exists, else `0.0.0.0` (pinned tag v8.0.2): https://github.com/puma/puma/blob/v8.0.2/lib/puma/configuration.rb#L369-L384 and https://github.com/puma/puma/blob/v8.0.2/lib/puma/configuration.rb#L174 - Puma `UNSPECIFIED_IPV4 = "0.0.0.0"` and `UNSPECIFIED_IPV6 = "::"` (pinned tag v8.0.2): https://github.com/puma/puma/blob/v8.0.2/lib/puma/const.rb#L214-L215 - Rails configuring guide (`force_ssl`, `assume_ssl`, `ssl_options`, `hosts`, `session_store`, `cookies_same_site_protection`, `require_master_key`): https://guides.rubyonrails.org/configuring.html - Action Pack 7.1 changelog (`ActionDispatch::AssumeSSL`): https://github.com/rails/rails/blob/ffcbf6f205363f8c2fb3e9834bc86690dd59f1cb/actionpack/CHANGELOG.md - `ActionDispatch::RemoteIp` (trusted proxies, spoofing warning): https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html - `ActionDispatch::Session::CookieStore` options: https://api.rubyonrails.org/classes/ActionDispatch/Session/CookieStore.html - Rails security guide (authentication generator, `has_secure_password`, `rate_limit`, credentials): https://guides.rubyonrails.org/security.html - `has_secure_password`: https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html - `ActionController::RateLimiting`: https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html - `bin/rails credentials:help` text (`master.key`, `RAILS_MASTER_KEY`): https://github.com/rails/rails/blob/e4cd6ae6f1f0a847958b3aa846c9fd8a3014922a/railties/lib/rails/commands/credentials/USAGE - Rack::Attack README: https://github.com/rack/rack-attack - Sidekiq Web UI security (the mount, the Devise `authenticate` constraint, and `Rack::Auth::Basic`): https://github.com/sidekiq/sidekiq/wiki/Monitoring - Net::HTTP source (`verify_mode`, `ca_file`, `VERIFY_PEER` default): https://github.com/ruby/net-http/blob/39cf5f648a9e5e25312909a2dc26681edaa2402c/lib/net/http.rb - Ruby OpenSSL `SSLContext` defaults (`DEFAULT_CERT_STORE.set_default_paths`, `VERIFY_PEER`): https://github.com/ruby/openssl/blob/a77ed4b9908e179cea8f018f7e245535301746a8/lib/openssl/ssl.rb - OpenSSL environment variables (`SSL_CERT_FILE`, `SSL_CERT_DIR`): https://docs.openssl.org/master/man7/openssl-env/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - rails: Rails documentation (unknown) - assume: Action Pack (7.1) - puma: Puma defaults (v8.0.2) - puma-readme: Puma README (aef89221e4d729c3133c723844382331ba3bbbd9) - puma-dsl: Puma DSL (d70de8b4e926f1f5fa0269dc46cdfadf52562628) - credentials: Rails credentials source (e4cd6ae6f1f0a847958b3aa846c9fd8a3014922a) - rack: Rack::Attack (unknown) - sidekiq: Sidekiq Web (unknown) - net-http: Net::HTTP source (39cf5f648a9e5e25312909a2dc26681edaa2402c) - openssl-ruby: Ruby OpenSSL source (a77ed4b9908e179cea8f018f7e245535301746a8) - openssl: OpenSSL documentation (unknown) Claims (full source mapping in the JSON block below): - puma-default (REASONED; puma): Puma v8.0.2 defaults plaintext 9292 on :: when non-loopback IPv6 exists, otherwise 0.0.0.0. - puma-private (REASONED; puma-readme, puma-dsl): Bind loopback TCP 3000 or Unix socket with proxy TLS; bind accepts tcp, unix and ssl URIs only. - puma-tls (REASONED; puma-dsl): Native ssl bind example uses 8443 with key/cert; ca and verify_mode configure client certificates. - force-ssl (REASONED; rails): force_ssl enables HTTPS redirect and HSTS; ssl_options defaults hsts subdomains true. Encrypted cookies still need HTTPS. - assume-ssl (REASONED; rails, assume): Rails 7.1+ assume_ssl says the proxy terminated TLS before forwarding HTTP. - hosts (REASONED; rails): Add the application hostname to config.hosts for Host allowlisting. - session (REASONED; rails): CookieStore example selects Secure, HttpOnly, SameSite=Lax and 14-day expiry. - trusted-proxies (REASONED; rails): Defaults include loopback/private/link-local; public proxies require an enumerable. Without a proxy, client-controlled X-Forwarded-For makes remote_ip unsuitable for allowlists/rate limits. - same-site-default (REASONED; rails): cookies_same_site_protection defaults lax from load_defaults 6.1. - auth-generator (REASONED; rails): Rails 8.0+ authentication generator supplies users, sessions and password reset using has_secure_password. - passwords (REASONED; rails): has_secure_password needs bcrypt ~> 3.1.7 and password_digest; authenticate returns user/false, validates presence and 72-byte limit, and needs an app minimum length. - rate-limit (REASONED; rails): Controller rate_limit allows ten attempts per three minutes for create; needs ActiveSupport::Cache, defaulting to action_controller.cache_store. - rack-attack (REASONED; rack): Rack::Attack supplies path throttles/blocklists; configure its initializer and Rails railtie installs middleware. - credentials (REASONED; credentials, rails): Edit encrypted credentials with credentials:edit; RAILS_MASTER_KEY overrides master.key, which Rails gitignores. - require-key (REASONED; rails): require_master_key=true refuses boot without the decryption key. - mfa (REASONED; rails): Rails has no built-in second factor; add TOTP or an identity layer and use linked MFA/OIDC guidance. - sidekiq-open (REASONED; sidekiq): Bare /sidekiq mount has no auth; reachable users can read job arguments and retry, kill or clear queues. - sidekiq-devise (REASONED; sidekiq): Devise authenticate constraint requires a signed-in admin; keep Sidekiq off the public internet even with auth. - sidekiq-basic (REASONED; sidekiq): Otherwise add Rack Basic auth with credentials outside source, equal-length SHA256 secure_compare inputs and non-short-circuit &. - client-tls (REASONED; net-http): Net::HTTP.start use_ssl defaults VERIFY_PEER; supply ca_file before handshake, not inside the opened block. Never use VERIFY_NONE. - client-store (REASONED; openssl-ruby, openssl): Ruby SSLContext loads system trust through set_default_paths; SSL_CERT_FILE and SSL_CERT_DIR select an internal CA store. - verify-bind (REASONED; puma-readme, puma-dsl): Inventory every listener; Puma should bind only 127.0.0.1:3000. - verify-https (REASONED; rails): HTTP should redirect 301 to HTTPS; inspect HSTS and Secure/HttpOnly/SameSite=Lax cookies. - verify-dashboard (REASONED; rails): Anonymous dashboard request should return login 302 or 401. - verify-sidekiq (REASONED; sidekiq): Basic needs 401 plus WWW-Authenticate, Devise login 302; bare 404 is inconclusive, dashboard 200 is exposure. Require authorized success. - verify-key (REASONED; credentials): git ls-files config/master.key should print nothing. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "5abe45766cdb5cb3239379744dc7afee97dda1f3115d12d146a5a59e1672ca5c", "components": { "rails": { "name": "Rails documentation", "basis": "unknown", "sources": { "s0e5c35fd756d": "https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html", "s22fb0dcbb6e1": "https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html", "sba5f72c7a627": "https://api.rubyonrails.org/classes/ActionDispatch/Session/CookieStore.html", "sae7bf5d75809": "https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html", "s574629d873e4": "https://guides.rubyonrails.org/configuring.html", "s2abb8f0ec8d2": "https://guides.rubyonrails.org/security.html" } }, "assume": { "name": "Action Pack", "basis": "7.1", "sources": { "sf720dd92b2e7": "https://github.com/rails/rails/blob/ffcbf6f205363f8c2fb3e9834bc86690dd59f1cb/actionpack/CHANGELOG.md" } }, "puma": { "name": "Puma defaults", "basis": "v8.0.2", "sources": { "s9687194339f3": "https://github.com/puma/puma/blob/v8.0.2/lib/puma/configuration.rb#L369-L384", "s8e2a34b121ba": "https://github.com/puma/puma/blob/v8.0.2/lib/puma/configuration.rb#L174", "s1d9011be835b": "https://github.com/puma/puma/blob/v8.0.2/lib/puma/const.rb#L214-L215" } }, "puma-readme": { "name": "Puma README", "basis": "aef89221e4d729c3133c723844382331ba3bbbd9", "sources": { "se5323687b58f": "https://github.com/puma/puma/blob/aef89221e4d729c3133c723844382331ba3bbbd9/README.md" } }, "puma-dsl": { "name": "Puma DSL", "basis": "d70de8b4e926f1f5fa0269dc46cdfadf52562628", "sources": { "s8d510226544c": "https://github.com/puma/puma/blob/d70de8b4e926f1f5fa0269dc46cdfadf52562628/lib/puma/dsl.rb" } }, "credentials": { "name": "Rails credentials source", "basis": "e4cd6ae6f1f0a847958b3aa846c9fd8a3014922a", "sources": { "saab0a90edc0c": "https://github.com/rails/rails/blob/e4cd6ae6f1f0a847958b3aa846c9fd8a3014922a/railties/lib/rails/commands/credentials/USAGE" } }, "rack": { "name": "Rack::Attack", "basis": "unknown", "sources": { "s1e5ca83efb79": "https://github.com/rack/rack-attack" } }, "sidekiq": { "name": "Sidekiq Web", "basis": "unknown", "sources": { "s106d0b1ced6e": "https://github.com/sidekiq/sidekiq/wiki/Monitoring" } }, "net-http": { "name": "Net::HTTP source", "basis": "39cf5f648a9e5e25312909a2dc26681edaa2402c", "sources": { "sbcafa3e06a73": "https://github.com/ruby/net-http/blob/39cf5f648a9e5e25312909a2dc26681edaa2402c/lib/net/http.rb" } }, "openssl-ruby": { "name": "Ruby OpenSSL source", "basis": "a77ed4b9908e179cea8f018f7e245535301746a8", "sources": { "sf0192683941c": "https://github.com/ruby/openssl/blob/a77ed4b9908e179cea8f018f7e245535301746a8/lib/openssl/ssl.rb" } }, "openssl": { "name": "OpenSSL documentation", "basis": "unknown", "sources": { "se3858048f9d6": "https://docs.openssl.org/master/man7/openssl-env/" } } }, "claims": { "puma-default": {"text": "Puma v8.0.2 defaults plaintext 9292 on :: when non-loopback IPv6 exists, otherwise 0.0.0.0.", "components": ["puma"], "sources": ["puma:s9687194339f3", "puma:s8e2a34b121ba", "puma:s1d9011be835b"], "status": "REASONED"}, "puma-private": {"text": "Bind loopback TCP 3000 or Unix socket with proxy TLS; bind accepts tcp, unix and ssl URIs only.", "components": ["puma-readme", "puma-dsl"], "sources": ["puma-readme:se5323687b58f", "puma-dsl:s8d510226544c"], "status": "REASONED"}, "puma-tls": {"text": "Native ssl bind example uses 8443 with key/cert; ca and verify_mode configure client certificates.", "components": ["puma-dsl"], "sources": ["puma-dsl:s8d510226544c"], "status": "REASONED"}, "force-ssl": {"text": "force_ssl enables HTTPS redirect and HSTS; ssl_options defaults hsts subdomains true. Encrypted cookies still need HTTPS.", "components": ["rails"], "sources": ["rails:s574629d873e4", "rails:sba5f72c7a627"], "status": "REASONED"}, "assume-ssl": {"text": "Rails 7.1+ assume_ssl says the proxy terminated TLS before forwarding HTTP.", "components": ["rails", "assume"], "sources": ["rails:s574629d873e4", "assume:sf720dd92b2e7"], "status": "REASONED"}, "hosts": {"text": "Add the application hostname to config.hosts for Host allowlisting.", "components": ["rails"], "sources": ["rails:s574629d873e4"], "status": "REASONED"}, "session": {"text": "CookieStore example selects Secure, HttpOnly, SameSite=Lax and 14-day expiry.", "components": ["rails"], "sources": ["rails:sba5f72c7a627", "rails:s574629d873e4"], "status": "REASONED"}, "trusted-proxies": {"text": "Defaults include loopback/private/link-local; public proxies require an enumerable. Without a proxy, client-controlled X-Forwarded-For makes remote_ip unsuitable for allowlists/rate limits.", "components": ["rails"], "sources": ["rails:s22fb0dcbb6e1"], "status": "REASONED"}, "same-site-default": {"text": "cookies_same_site_protection defaults lax from load_defaults 6.1.", "components": ["rails"], "sources": ["rails:s574629d873e4"], "status": "REASONED"}, "auth-generator": {"text": "Rails 8.0+ authentication generator supplies users, sessions and password reset using has_secure_password.", "components": ["rails"], "sources": ["rails:s2abb8f0ec8d2"], "status": "REASONED"}, "passwords": {"text": "has_secure_password needs bcrypt ~> 3.1.7 and password_digest; authenticate returns user/false, validates presence and 72-byte limit, and needs an app minimum length.", "components": ["rails"], "sources": ["rails:sae7bf5d75809"], "status": "REASONED"}, "rate-limit": {"text": "Controller rate_limit allows ten attempts per three minutes for create; needs ActiveSupport::Cache, defaulting to action_controller.cache_store.", "components": ["rails"], "sources": ["rails:s0e5c35fd756d"], "status": "REASONED"}, "rack-attack": {"text": "Rack::Attack supplies path throttles/blocklists; configure its initializer and Rails railtie installs middleware.", "components": ["rack"], "sources": ["rack:s1e5ca83efb79"], "status": "REASONED"}, "credentials": {"text": "Edit encrypted credentials with credentials:edit; RAILS_MASTER_KEY overrides master.key, which Rails gitignores.", "components": ["credentials", "rails"], "sources": ["credentials:saab0a90edc0c", "rails:s2abb8f0ec8d2"], "status": "REASONED"}, "require-key": {"text": "require_master_key=true refuses boot without the decryption key.", "components": ["rails"], "sources": ["rails:s574629d873e4"], "status": "REASONED"}, "mfa": {"text": "Rails has no built-in second factor; add TOTP or an identity layer and use linked MFA/OIDC guidance.", "components": ["rails"], "sources": ["rails:s2abb8f0ec8d2"], "status": "REASONED"}, "sidekiq-open": {"text": "Bare /sidekiq mount has no auth; reachable users can read job arguments and retry, kill or clear queues.", "components": ["sidekiq"], "sources": ["sidekiq:s106d0b1ced6e"], "status": "REASONED"}, "sidekiq-devise": {"text": "Devise authenticate constraint requires a signed-in admin; keep Sidekiq off the public internet even with auth.", "components": ["sidekiq"], "sources": ["sidekiq:s106d0b1ced6e"], "status": "REASONED"}, "sidekiq-basic": {"text": "Otherwise add Rack Basic auth with credentials outside source, equal-length SHA256 secure_compare inputs and non-short-circuit &.", "components": ["sidekiq"], "sources": ["sidekiq:s106d0b1ced6e"], "status": "REASONED"}, "client-tls": {"text": "Net::HTTP.start use_ssl defaults VERIFY_PEER; supply ca_file before handshake, not inside the opened block. Never use VERIFY_NONE.", "components": ["net-http"], "sources": ["net-http:sbcafa3e06a73"], "status": "REASONED"}, "client-store": {"text": "Ruby SSLContext loads system trust through set_default_paths; SSL_CERT_FILE and SSL_CERT_DIR select an internal CA store.", "components": ["openssl-ruby", "openssl"], "sources": ["openssl-ruby:sf0192683941c", "openssl:se3858048f9d6"], "status": "REASONED"}, "verify-bind": {"text": "Inventory every listener; Puma should bind only 127.0.0.1:3000.", "components": ["puma-readme", "puma-dsl"], "sources": ["puma-readme:se5323687b58f", "puma-dsl:s8d510226544c"], "status": "REASONED", "verify": [1]}, "verify-https": {"text": "HTTP should redirect 301 to HTTPS; inspect HSTS and Secure/HttpOnly/SameSite=Lax cookies.", "components": ["rails"], "sources": ["rails:s574629d873e4", "rails:sba5f72c7a627"], "status": "REASONED", "verify": [1]}, "verify-dashboard": {"text": "Anonymous dashboard request should return login 302 or 401.", "components": ["rails"], "sources": ["rails:s2abb8f0ec8d2"], "status": "REASONED", "verify": [1]}, "verify-sidekiq": {"text": "Basic needs 401 plus WWW-Authenticate, Devise login 302; bare 404 is inconclusive, dashboard 200 is exposure. Require authorized success.", "components": ["sidekiq"], "sources": ["sidekiq:s106d0b1ced6e"], "status": "REASONED", "verify": [1]}, "verify-key": {"text": "git ls-files config/master.key should print nothing.", "components": ["credentials"], "sources": ["credentials:saab0a90edc0c"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> host.md ====================================================================== # Host baseline: SSH, firewall, updates A host baseline is the hardening of a server: who can log in, which ports are open, and how it is patched. This guide helps your AI assistant apply it, including key-only SSH without root login, a default-deny inbound firewall, bans after repeated failed logins, and automatic security updates. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/host.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against OpenSSH documentation (no recorded version), OpenSSH 8.2 server manual V_8_2_P1, OpenSSH 8.1 server manual V_8_1_P1, OpenSSH 9.9 source V_9_9_P2, DNF 4.14.0, cloud-init 26.2 and 20.1, iproute2 ss manual v6.12.0, and BusyBox 1_36_1; 9 further sources are pinned to a commit and 9 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 22 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Every guide in this repository secures a service; this one secures the machine under them. Apply it once per host before exposing anything. ## 1. SSH: keys only, no root login Add your public key to a **non-root** admin account's `~/.ssh/authorized_keys`, and from a fresh connection confirm that key login to that account works and that it can `sudo`, **before** disabling passwords or root login. Keep the current session open while testing changes, so a mistake is recoverable. Put this in a drop-in that sorts first, `/etc/ssh/sshd_config.d/00-hardening.conf`, not only in the main `/etc/ssh/sshd_config`: ``` PasswordAuthentication no KbdInteractiveAuthentication no PermitRootLogin no PubkeyAuthentication yes ``` OpenSSH 9.9 documents that, unless noted otherwise, sshd uses the *first* value it reads for each keyword and processes `Include` wildcard matches in lexical order. The fetched Ubuntu focal OpenSSH 1:8.2p1-4 configuration and CentOS Stream 9 packaging place `Include /etc/ssh/sshd_config.d/*.conf` near the top, before main-file settings; OpenSSH 8.2 added `Include` to `sshd_config`. In cloud-init 26.2, `ssh_pwauth: true` sets `PasswordAuthentication yes` in `50-cloud-init.conf` when the main configuration includes that drop-in directory; cloud-init 20.1 instead updated the main file. Read before a later main-file line or a higher-numbered drop-in, that `yes` value wins and password login stays on. Name the hardening file `00-hardening.conf` so it is read first, and confirm with `sshd -T` (below) that the value took effect; as this guide's reasoning, an upgraded main file may lack the include or an earlier drop-in may already have set it. The fetched packaging does not establish current defaults for every Debian, Ubuntu and RHEL-family release, or how often cloud images contain this drop-in. ```bash sudo sshd -t && sudo systemctl reload ssh # sshd on RHEL-family systems sudo sshd -T | grep -iE 'passwordauthentication|permitrootlogin|kbdinteractiveauthentication' # the EFFECTIVE global values after every include; confirm # they match what you set, not a drop-in read earlier. If you # use Match blocks, also check a connection with # `sudo sshd -T -C user=admin,addr=203.0.113.10`, since Match can override these ``` Add a second factor for SSH per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md): TOTP via [google-authenticator-libpam](https://github.com/google/google-authenticator-libpam) or push approval via Duo's `pam_duo`. Both run through PAM's keyboard-interactive path, which the block above turns off. When adding PAM MFA, change the block to: ``` PasswordAuthentication no KbdInteractiveAuthentication yes UsePAM yes AuthenticationMethods publickey,keyboard-interactive PermitRootLogin no PubkeyAuthentication yes ``` `AuthenticationMethods` with a comma-separated list requires every method in it, so a key alone is no longer enough. `PasswordAuthentication no` stays: the code prompt is keyboard-interactive, not password. Older sshd_config files spell `KbdInteractiveAuthentication` as `ChallengeResponseAuthentication`; set that one to `yes` where it is the one present. ## 2. Firewall: default deny inbound ```bash # Debian/Ubuntu (ufw) sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw delete allow OpenSSH # only if an earlier run of this guide added it: a new rule does not replace an old one sudo ufw allow proto tcp from REPLACE_WITH_ADMIN_RANGE to any port 22 sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable ``` SSH stays closed to the world here for the same reason rule 3 in [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) keeps it off the cloud firewall: `ufw allow OpenSSH` opens port 22 to every address on the internet. Restrict it to the range you administer from. AWS SSM Session Manager SSH uses the SSM Agent's outbound TLS connection to Session Manager endpoints and needs no inbound port on the managed node; allow outbound HTTPS 443 to the documented endpoints. A tailnet ordinarily needs no inbound internet firewall port for Tailscale, and Tailscale SSH accepts traffic from the tailnet on port 22 ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)). GCP IAP TCP forwarding requires a cloud ingress allow from `35.235.240.0/20` (`2600:2d00:1:7::/64` for IPv6 VMs) to SSH's default port 22 or its configured port; where an NSG is applied to the target VM subnet, Azure Bastion requires an inbound SSH rule on default port 22 or a custom port with Standard or Premium SKU. Microsoft's best practice scopes that rule's source to the `AzureBastionSubnet` IP address range. As this guide's advice, check the effective host firewall rules for the broker paths; the fetched IAP, Bastion and Tailscale pages do not specify which UFW or firewalld rules, if any, are needed. See [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md). RHEL-family systems use firewalld (`firewall-cmd --permanent --add-service=https` and so on) with the same posture, and that includes SSH: `--add-service=ssh` opens port 22 to every address exactly as `ufw allow OpenSSH` does. Open only the ports the TLS-terminating layer needs; databases and app servers stay unreachable from outside per their guides. With Docker Engine's default Linux bridge firewall rules, published container traffic is diverted before ufw's rules; see [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md) before relying on the firewall. As this guide's assessment, other modes and paths, including the userland proxy, need separate review in that guide. ```bash # firewalld, where an earlier run enabled the ssh service: a rich rule does not supersede it. # Every command below names the zone: without --zone they act on the default zone, which is not # necessarily the one holding the internet-facing interface. sudo firewall-cmd --get-active-zones # find the zone your public interface is in sudo firewall-cmd --permanent --zone=REPLACE_WITH_PUBLIC_ZONE --remove-service=ssh sudo firewall-cmd --permanent --zone=REPLACE_WITH_PUBLIC_ZONE --add-rich-rule='rule family="ipv4" source address="REPLACE_WITH_ADMIN_RANGE" service name="ssh" accept' # confirm your current address is inside REPLACE_WITH_ADMIN_RANGE first sudo firewall-cmd --reload # --permanent writes the stored config only; nothing changes until this sudo firewall-cmd --zone=REPLACE_WITH_PUBLIC_ZONE --list-all # confirm nothing reaches 22 from an unauthorized source: no `ssh` (or other 22/tcp) service, no `22/tcp` or a port range covering 22, no protocol-wide or broad-source rule, the zone target is not ACCEPT, only the admin rich rule matches; check other applicable zones too ``` ## 3. Brute-force protection and updates - fail2ban ([github.com/fail2ban/fail2ban](https://github.com/fail2ban/fail2ban)) or CrowdSec ([crowdsec.net](https://www.crowdsec.net/)) bans repeated authentication failures against SSH and login panels. - Automate security patches: `unattended-upgrades` on Debian/Ubuntu, `dnf-automatic` on RHEL-family systems. Configure them to install updates: on Ubuntu noble, the unattended-upgrades post-install script writes `20auto-upgrades` when `enable_auto_updates` is true, and `apt-daily-upgrade.timer` runs `apt.systemd.daily install`, which invokes `unattended-upgrade` when its periodic interval permits. Its Ubuntu allowed origins include the release and security pockets; inspect local origins and periodic settings. On CentOS Stream 9, `dnf-automatic` is a packaged subpackage. Upstream DNF 4.14.0 ships the cited `automatic.conf` defaults (`apply_updates = no` and `upgrade_type = default`) and timers; with that configuration the generic timer does not install updates. CentOS Stream 9 applies downstream dnf-automatic patches this guide has not reviewed, so its packaged defaults remain unestablished. Enable `dnf-automatic-install.timer` to install updates, and set `upgrade_type = security` when only advisory-backed security upgrades are intended. Package defaults on other Debian and RHEL-family releases remain unestablished. ## 4. Verify REASONED: listener and firewall inventory; no exposed/fixed run is recorded in this guide. Expectations follow the cited iproute2, UFW and firewalld sources; the authoring environment has no authorized target host. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the block's `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it, as the guide's cited sudo sudoers manual, secure_path entry describes. ```bash ( # a subshell, so the exit below ends only the subshell; run it on the host you are checking set +e # errexit off here, so a command that fails is recorded below instead of ending the block set -- 0 # the block's status so far: 3 (a command failed) outranks 2 (the ss or ufw check refused), which outranks 0 if command -v ss > /dev/null; then sudo ss -4 -tulnp || { echo "ss -4 exit $?: the IPv4 listener list failed; inconclusive"; set -- 3; } # IPv4 TCP and UDP listeners: only intended ones, on intended addresses sudo ss -6 -tulnp || { echo "ss -6 exit $?: the IPv6 listener list failed; inconclusive"; set -- 3; } # IPv6 TCP and UDP listeners: only intended ones, on intended addresses set -- "$1" listed # the 2nd value says whether ss ran, so the last line names the listeners only when it did else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac set -- "$1" unlisted fi if command -v ufw > /dev/null || [ -x /usr/sbin/ufw ]; then sudo ufw status verbose || { echo "ufw status exit $?: the firewall status failed; inconclusive"; set -- 3 "$2"; } # default deny incoming; then `sudo ufw show raw` (status verbose omits rules loaded from /etc/ufw): every allowance that can reach 22 (a port-22 rule OR a broader "from to any" allow) is scoped to your admin range, never Anywhere else # with no ufw, as on a firewalld host, this block never exits 0: it exits 2, or 3 if a listener list failed echo "ufw is not installed here, so its status was not read: on a firewalld system read the zone with firewall-cmd --list-all as section 2 shows; not checking" case "$1" in 0) set -- 2 "$2" ;; esac fi case "$2" in listed) echo "block exit $1; the listeners and the firewall status are not classified, so judge them against the comments" ;; *) echo "block exit $1; the firewall status is not classified, so judge it against the comments" ;; esac exit "$1" ) ``` ```bash # REASONED: disallowed-source SSH reachability follows the cited netcat, BusyBox, UFW and firewalld sources; no external probe machine is available in the authoring environment. # Run this block from an address outside the admin range. It never exits 0: a connection is the finding (1), and # any failure to connect is inconclusive (3), because a refusal or timeout alone does not show that the host # firewall denied it; it exits 2 if it refuses its input. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; *) if nc -vz -w 3 "$1" 22; then # from an address outside the admin range: must fail to connect echo "FINDING: port 22 accepted a connection from this address; block exit 1"; exit 1 else echo "nc exit $?: no connection; inconclusive, so confirm SSH works from an allowed source and read the effective firewall rules; block exit 3"; exit 3 fi ;; # as this guide's assessment, a refusal or timeout only means THIS connection failed, not that the host firewall denied it (a host with # no sshd listening refuses too): confirm SSH works from an allowed source and read `ufw status`/`--list-all` # for the effective rule. A local error, an unsupported option (BusyBox small nc lacks -v; its 1.10-compatible applet lists it), or exit 1 with no # output at all is inconclusive as this guide's assessment; it does not establish a remote firewall result esac ) ``` ```bash # Run this block from a second terminal before closing your working session. Both checks are read-and-judge, and # neither ssh exit status is a result: the keys-only check passes by failing, and the MFA check is judged by # its prompts, so the block always exits 0. ( # a subshell, so the exit below ends only the subshell set +e # errexit off here, so the keys-only check's expected failure does not end the block before the MFA check # guard-conventions: allow probe of an illustrative user@host; no reader-substituted placeholder in this probe's argv ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no user@host # keys-only: require immediate failure with NO password prompt AND a Permission denied (...) method list naming neither password nor keyboard-interactive. As this guide's reasoning, the client prints the server's remaining methods in that list; "Permission denied (publickey)" is expected when only publickey remains; a password prompt or a method list naming either method means that path is still on # guard-conventions: allow probe of an illustrative user@host; no reader-substituted placeholder in this probe's argv ssh user@host # with PAM MFA: the key is accepted, then a code is required before a shell. Also confirm a wrong or omitted code is REJECTED, that unenrolled users are denied (drop `nullok`), and that Duo fails closed (`failmode=secure`), per mfa.md; otherwise the factor is optional, not mandatory echo "block exit 0; both ssh results are not classified, so judge them against the comments" exit 0 ) ``` REASONED: preceding block; key-only login and PAM MFA comparisons follow the cited OpenSSH, TOTP and Duo sources; no authorized target host is available in the authoring environment, and no exposed/fixed result is recorded. Run the SSH test from a second terminal before closing your working session. The inventory and the disallowed-source SSH reachability probe in the first two blocks carry their own declarations. The second block's `nc -vz -w 3` form is sourced for Ubuntu noble netcat-openbsd; CentOS Stream 9 packages Ncat 7.92 as an `nc` alternative, but the fetched Ncat source is a later master commit, so this guide has not established the packaged version's option behaviour. Check the installed `nc` variant before treating that probe as evidence. ## Sources (checked September 2026) - OpenSSH sshd_config manual: https://man.openbsd.org/sshd_config - CentOS Stream 9 OpenSSH patch identifier, at 0b69a3828606595828fc3b878a7c70652303ad67 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/openssh/-/blob/0b69a3828606595828fc3b878a7c70652303ad67/openssh.spec#L135-L135 - cloud-init 20.1 default sshd_config path, at 20.1 (checked October 2026): https://github.com/canonical/cloud-init/blob/20.1/cloudinit/ssh_util.py#L17-L18 - OpenSSH 8.1 adjacent server keyword entries, at V_8_1_P1 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_8_1_P1/sshd_config.5#L793-L804 - OpenSSH server Include entry, at V_8_2_P1 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_8_2_P1/sshd_config.5#L805-L817 - OpenSSH first-obtained-value rule, at V_9_9_P2 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L51-L51 - OpenSSH lexical Include order, at V_9_9_P2 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L912-L918 - Ubuntu focal stock sshd_config include, at 1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc (checked October 2026): https://git.launchpad.net/ubuntu/+source/openssh/tree/sshd_config?id=1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc#n13 - Ubuntu focal source version, at 1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc (checked October 2026): https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/changelog?id=1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc#n1 - Ubuntu focal source changelog for drop-in packaging, at 1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc (checked October 2026): https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/changelog?id=1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc#n4 - CentOS Stream 9 sshd_config include patch, at 0b69a3828606595828fc3b878a7c70652303ad67 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/openssh/-/blob/0b69a3828606595828fc3b878a7c70652303ad67/openssh-7.7p1-redhat.patch#L69-L71 - CentOS Stream 9 OpenSSH patch application, at 0b69a3828606595828fc3b878a7c70652303ad67 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/openssh/-/blob/0b69a3828606595828fc3b878a7c70652303ad67/openssh.spec#L444-L444 - cloud-init drop-in selection and writing, at 26.2 (checked October 2026): https://github.com/canonical/cloud-init/blob/26.2/cloudinit/ssh_util.py#L548-L586 - cloud-init ssh_pwauth value handling, at 26.2 (checked October 2026): https://github.com/canonical/cloud-init/blob/26.2/cloudinit/config/cc_set_passwords.py#L60-L90 - cloud-init ssh_pwauth call site, at 26.2 (checked October 2026): https://github.com/canonical/cloud-init/blob/26.2/cloudinit/config/cc_set_passwords.py#L255-L255 - cloud-init 20.1 main-file update, at 20.1 (checked October 2026): https://github.com/canonical/cloud-init/blob/20.1/cloudinit/ssh_util.py#L338-L348 - cloud-init 20.1 ssh_pwauth value handling, at 20.1 (checked October 2026): https://github.com/canonical/cloud-init/blob/20.1/cloudinit/config/cc_set_passwords.py#L97-L122 - cloud-init 20.1 ssh_pwauth call site, at 20.1 (checked October 2026): https://github.com/canonical/cloud-init/blob/20.1/cloudinit/config/cc_set_passwords.py#L230-L232 - Docker default Linux firewall integration, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L11-L32 - Docker and ufw published-port behaviour, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L90-L102 - AWS Session Manager SSH tunnel (rolling) (checked October 2026): https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html#session-manager-getting-started-enable-ssh-connections - AWS Session Manager managed-node outbound HTTPS prerequisites (rolling) (checked October 2026): https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-prerequisites.html#session-manager-prerequisites - Google Cloud IAP TCP forwarding firewall source and SSH port (rolling) (checked October 2026): https://docs.cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule - Azure Bastion target VM subnet NSG rule, at 8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f (checked October 2026): https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/bastion/bastion-nsg.md#L77-L84 - Tailscale firewall ports (rolling) (checked October 2026): https://tailscale.com/docs/reference/faq/firewall-ports - Tailscale SSH tailnet port 22 (rolling) (checked October 2026): https://tailscale.com/docs/features/tailscale-ssh#why-it-matters - ufw(8), for `default deny incoming`, `allow` and `status verbose`: https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html - firewall-cmd(1), for `--permanent --add-service` and `--add-rich-rule`: https://firewalld.org/documentation/man-pages/firewall-cmd.html - firewalld.richlanguage(5), for the `source address` / `service name` / `accept` rule form: https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html - fail2ban: https://github.com/fail2ban/fail2ban - CrowdSec: https://www.crowdsec.net/ - google-authenticator-libpam: https://github.com/google/google-authenticator-libpam - Duo Unix (`UsePAM`, `KbdInteractiveAuthentication`, `AuthenticationMethods publickey,keyboard-interactive`): https://duo.com/docs/duounix - Ubuntu noble conditional automatic-update configuration, lines 47 to 58, at 0570be65234e2392d420308470d26920fa4cd21a (checked October 2026): https://git.launchpad.net/ubuntu/+source/unattended-upgrades/tree/debian/postinst?id=0570be65234e2392d420308470d26920fa4cd21a#n47 - Ubuntu noble allowed origins, lines 6 to 18, at 0570be65234e2392d420308470d26920fa4cd21a (checked October 2026): https://git.launchpad.net/ubuntu/+source/unattended-upgrades/tree/data/50unattended-upgrades.Ubuntu?id=0570be65234e2392d420308470d26920fa4cd21a#n6 - Ubuntu noble apt upgrade timer, lines 5 to 11, at 835f914ef5657b923fbb8b357f134b00de24c6fd (checked October 2026): https://git.launchpad.net/ubuntu/+source/apt/tree/debian/apt-daily-upgrade.timer?id=835f914ef5657b923fbb8b357f134b00de24c6fd#n5 - Ubuntu noble apt upgrade service, lines 7 to 12, at 835f914ef5657b923fbb8b357f134b00de24c6fd (checked October 2026): https://git.launchpad.net/ubuntu/+source/apt/tree/debian/apt-daily-upgrade.service?id=835f914ef5657b923fbb8b357f134b00de24c6fd#n7 - Ubuntu noble periodic unattended-upgrade invocation, lines 491 to 503, at 835f914ef5657b923fbb8b357f134b00de24c6fd (checked October 2026): https://git.launchpad.net/ubuntu/+source/apt/tree/debian/apt.systemd.daily?id=835f914ef5657b923fbb8b357f134b00de24c6fd#n491 - CentOS Stream 9 dnf-automatic subpackage, at 3d5ca2af8635e0ef77219625f734d503b9900208 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L260-L267 - CentOS Stream 9 DNF package version, line 75, at 3d5ca2af8635e0ef77219625f734d503b9900208 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L75-L75 - CentOS Stream 9 downstream DNF patch list, lines 82 to 158, at 3d5ca2af8635e0ef77219625f734d503b9900208 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L82-L158 - CentOS Stream 9 downstream DNF patch application, lines 281 to 282, at 3d5ca2af8635e0ef77219625f734d503b9900208 (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L281-L282 - DNF 4.14.0 automatic configuration defaults, at 4.14.0 (checked October 2026): https://github.com/rpm-software-management/dnf/blob/4.14.0/etc/dnf/automatic.conf#L1-L22 - DNF 4.14.0 automatic timer behaviour, at 4.14.0 (checked October 2026): https://github.com/rpm-software-management/dnf/blob/4.14.0/doc/automatic.rst#L38-L50 - DNF 4.14.0 security upgrade type, at 4.14.0 (checked October 2026): https://github.com/rpm-software-management/dnf/blob/4.14.0/doc/automatic.rst#L86-L91 - DNF 4.14.0 automatic install timer, at 4.14.0 (checked October 2026): https://github.com/rpm-software-management/dnf/blob/4.14.0/etc/systemd/dnf-automatic-install.timer#L7-L13 - DNF 4.14.0 automatic install service, at 4.14.0 (checked October 2026): https://github.com/rpm-software-management/dnf/blob/4.14.0/etc/systemd/dnf-automatic-install.service#L7-L13 - iproute2 ss numeric output, at v6.12.0 (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L33-L34 - iproute2 ss listening sockets, at v6.12.0 (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L43-L44 - iproute2 ss process display, at v6.12.0 (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L162-L163 - iproute2 ss TCP sockets, at v6.12.0 (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L374-L375 - iproute2 ss UDP sockets, at v6.12.0 (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L377-L378 - iproute2 ss explicit IPv4 and IPv6 selection, at v6.12.0 (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L365-L369 - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 - Ubuntu noble nc verbose flag, lines 257 to 258, at c047f1a4572e796c8643fb7e5a719a9d960a677b (checked October 2026): https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n257 - Ubuntu noble nc timeout flag, lines 263 to 266, at c047f1a4572e796c8643fb7e5a719a9d960a677b (checked October 2026): https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n263 - Ubuntu noble nc zero-I/O scan, lines 308 to 311, at c047f1a4572e796c8643fb7e5a719a9d960a677b (checked October 2026): https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n308 - Ubuntu noble nc connection and exit path, lines 899 to 953, at c047f1a4572e796c8643fb7e5a719a9d960a677b (checked October 2026): https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/netcat.c?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n899 - CentOS Stream 9 ncat nc alternative, at 17c2b7795d821cd7fbe311cf3f885be3ea1f122f (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/nmap/-/blob/17c2b7795d821cd7fbe311cf3f885be3ea1f122f/nmap.spec#L133-L137 - OpenSSH 9.9 client PreferredAuthentications, at V_9_9_P2 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/ssh_config.5#L1536-L1546 - OpenSSH 9.9 client PubkeyAuthentication, at V_9_9_P2 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/ssh_config.5#L1661-L1674 - OpenSSH 9.9 client authentication failure message and method list, at V_9_9_P2 (checked October 2026): https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshconnect2.c#L540-L543 - BusyBox 1.36.1 small nc option parser, at 1_36_1 (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L136-L170 - BusyBox 1.36.1 compatible nc options, at 1_36_1 (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L61-L93 - BusyBox 1.36.1 nc compatibility build selection, at 1_36_1 (checked October 2026): https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L30-L59 - CentOS Stream 9 nmap package version, at 17c2b7795d821cd7fbe311cf3f885be3ea1f122f (checked October 2026): https://gitlab.com/redhat/centos-stream/rpms/nmap/-/blob/17c2b7795d821cd7fbe311cf3f885be3ea1f122f/nmap.spec#L6-L8 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - ssh: OpenSSH documentation (unknown) - openssh82: OpenSSH 8.2 server manual (V_8_2_P1) - openssh81: OpenSSH 8.1 server manual (V_8_1_P1) - openssh99: OpenSSH 9.9 source (V_9_9_P2) - ubuntu-focal: Ubuntu focal OpenSSH source (1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc) - rhel-c9s: CentOS Stream 9 OpenSSH packaging (0b69a3828606595828fc3b878a7c70652303ad67) - ufw: UFW documentation (unknown) - firewalld: firewalld documentation (unknown) - ubuntu-uu: Ubuntu noble unattended-upgrades source (0570be65234e2392d420308470d26920fa4cd21a) - ubuntu-apt: Ubuntu noble-updates apt source (835f914ef5657b923fbb8b357f134b00de24c6fd) - c9s-dnf: CentOS Stream 9 dnf packaging (3d5ca2af8635e0ef77219625f734d503b9900208) - dnf4: DNF (4.14.0) - aws-ssm: AWS Session Manager (unknown) - gcp-iap: Google Cloud IAP (unknown) - azure-docs: Azure documentation snapshot (8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f) - tailscale-docs: Tailscale documentation (unknown) - fail2ban: fail2ban (unknown) - crowdsec: CrowdSec (unknown) - totp: google-authenticator-libpam (unknown) - duo: Duo Unix (unknown) - cloudinit26: cloud-init (26.2) - cloudinit20: cloud-init (20.1) - docker: Docker Engine networking (4e9a5751518ed8223a8dcde53693badddd72604f) - iproute2: iproute2 ss manual (v6.12.0) - ubuntu-nc: Ubuntu noble netcat-openbsd source (c047f1a4572e796c8643fb7e5a719a9d960a677b) - c9s-nmap: CentOS Stream 9 nmap packaging (17c2b7795d821cd7fbe311cf3f885be3ea1f122f) - busybox: BusyBox (1_36_1) Claims (full source mapping in the JSON block below): - safe-transition (REASONED; ssh): Confirm non-root key login and sudo in a fresh session before disabling password/root access; retain the working session while testing. - keys-only (REASONED; ssh): Disable PasswordAuthentication and KbdInteractiveAuthentication; enable PubkeyAuthentication and prohibit root login. - include-order (REASONED; ssh, openssh81, openssh82, openssh99, ubuntu-focal, rhel-c9s, cloudinit26, cloudinit20): OpenSSH 8.2 added server Include; OpenSSH 9.9 documents that, unless noted otherwise, each keyword uses its first obtained value, and documents lexical wildcard order supporting 00-hardening.conf. The fetched Ubuntu focal OpenSSH 1:8.2p1-4 configuration and CentOS Stream 9 packaging place the drop-in Include near the top, before main-file settings. With ssh_pwauth: true, cloud-init 26.2 writes PasswordAuthentication yes to 50-cloud-init.conf when the main configuration includes that directory; cloud-init 20.1 instead updated the main file. Current distribution defaults and cloud-image prevalence remain unestablished. - effective-config (REASONED; ssh): Validate with sshd -t before reload; inspect sshd -T global values and -T -C for Match-specific settings. Service names differ by distribution. - totp (REASONED; ssh, totp): SSH TOTP uses google-authenticator-libpam through PAM keyboard-interactive; keys-only configuration must change before adding this factor. - duo (REASONED; ssh, duo): Duo pam_duo provides the alternative push factor through PAM keyboard-interactive. - mfa-methods (REASONED; ssh, duo): UsePAM yes and AuthenticationMethods publickey,keyboard-interactive require both methods; keep password/root login disabled and public keys enabled. - legacy-name (REASONED; ssh, duo): Older configurations use ChallengeResponseAuthentication for KbdInteractiveAuthentication; enable the spelling present when configuring PAM MFA. - ufw-default (REASONED; ufw): Set deny incoming and allow outgoing, then enable UFW; permit TCP 80 and 443 for the TLS layer. - ufw-ssh (REASONED; ufw): Remove any broad OpenSSH allow before adding an admin-source TCP 22 rule; adding a narrower rule does not replace the old allowance. - brokered-ssh (REASONED; ufw, aws-ssm, gcp-iap, azure-docs, tailscale-docs): SSM SSH uses the agent's outbound TLS connection and needs no inbound port on the managed node; allow outbound HTTPS 443 to the documented Systems Manager endpoints. A tailnet ordinarily needs no inbound internet firewall port, and Tailscale SSH accepts tailnet traffic on port 22. IAP requires cloud ingress from 35.235.240.0/20, or 2600:2d00:1:7::/64 for IPv6 VMs, to SSH's default port 22 or its configured port; where an NSG is applied to the target VM subnet, Bastion requires an inbound SSH rule on default port 22 or a custom port with Standard or Premium SKU. Microsoft's best practice scopes that rule's source to the AzureBastionSubnet IP range. As this guide's advice, check the host firewall for these broker paths; the fetched IAP, Bastion and Tailscale pages do not specify which UFW or firewalld rules, if any, are needed. - firewalld-zone (REASONED; firewalld): Find the public interface zone explicitly; remove its broad ssh service, add the IPv4 admin rich rule permanently, then reload to activate it. - firewalld-audit (REASONED; firewalld): Inspect applicable zones for other services, port ranges, protocol/source allowances or ACCEPT targets that reopen 22; the narrow rich rule does not supersede them. - docker-bypass (REASONED; ufw, docker): With Docker Engine default Linux bridge firewall rules, published container traffic is diverted before UFW settings; consult docker.md before relying on the host firewall. As this guide's assessment, other modes and paths, including the userland proxy, need separate review in that guide. - fail2ban (REASONED; fail2ban): Use fail2ban to ban repeated authentication failures against SSH/login services. - crowdsec (REASONED; crowdsec): CrowdSec is the alternative protection for repeated authentication failures. - updates (REASONED; ssh, ubuntu-uu, ubuntu-apt, c9s-dnf, dnf4): Automate security patches with unattended-upgrades on Debian/Ubuntu or dnf-automatic on RHEL-family systems, configured to install them. Ubuntu noble's postinst conditionally writes 20auto-upgrades, and apt-daily-upgrade invokes unattended-upgrade when its periodic interval permits; its allowed origins include the release and security pockets. CentOS Stream 9 packages dnf-automatic. Upstream DNF 4.14.0 ships the cited automatic.conf defaults apply_updates=no and upgrade_type=default and the cited timers; enable dnf-automatic-install.timer and select upgrade_type=security for security-only installation. CentOS Stream 9 applies downstream dnf-automatic patches this guide has not reviewed, so its packaged defaults remain unestablished. Other Debian and RHEL-family package defaults remain unestablished. - verify-listeners (REASONED; ufw, iproute2, ubuntu-nc, c9s-nmap, busybox, openssh99): Inventory intended TCP/UDP listeners and addresses in both IP families with explicit ss -4 and -6 runs. Ubuntu noble netcat-openbsd supports -v, -z and -w; CentOS Stream 9 packages Ncat as an nc alternative, but behaviour of packaged 7.92 remains unestablished. BusyBox 1.36.1 small nc lacks -v while its 1.10-compatible applet lists -v. As this guide's assessment, refusal or timeout proves only that this connection failed; local errors, unsupported options and exit 1 without output are inconclusive. The OpenSSH 9.9 client options select password and disable public key; the literal Permission denied (publickey) is this guide's reasoned expectation dependent on server-advertised methods, while immediate failure with NO password prompt and a Permission denied (...) method list naming neither password nor keyboard-interactive are required. As this guide's reasoning, the client prints the server's remaining methods in that list. - verify-firewall (REASONED; ufw): UFW status should show default-deny ingress and admin-scoped SSH allowances; also inspect show raw because status omits /etc/ufw rules. - verify-external (REASONED; ufw, firewalld): Disallowed-source TCP 22 must fail while allowed SSH works; correlate effective rules. As this guide's assessment, refusal alone, local errors and unsupported netcat options are inconclusive. - verify-password (REASONED; ssh, openssh99): A password-only SSH attempt must fail immediately without a password prompt and show a Permission denied (...) method list naming neither password nor keyboard-interactive. As this guide's reasoning, the client prints the server's remaining methods in that list; a prompt or a list naming either method means that path is still on. - verify-mfa (REASONED; ssh, totp, duo): After key acceptance require a code, reject wrong/omitted codes and unenrolled users without nullok, and use Duo failmode=secure; test before closing the working session. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "685fc3a1da70487fa46b8d9f74f054cbf82eb02e1e050872a172c10d022c1cbc", "components": { "ssh": { "name": "OpenSSH documentation", "basis": "unknown", "sources": { "s44f73ffcb41e": "https://man.openbsd.org/sshd_config" } }, "openssh82": { "name": "OpenSSH 8.2 server manual", "basis": "V_8_2_P1", "sources": { "s74206a9a30dd": "https://github.com/openssh/openssh-portable/blob/V_8_2_P1/sshd_config.5#L805-L817" } }, "openssh81": { "name": "OpenSSH 8.1 server manual", "basis": "V_8_1_P1", "sources": { "s76bfb1ff5934": "https://github.com/openssh/openssh-portable/blob/V_8_1_P1/sshd_config.5#L793-L804" } }, "openssh99": { "name": "OpenSSH 9.9 source", "basis": "V_9_9_P2", "sources": { "sb409a9bcefc5": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L51-L51", "s447d8f1b88bd": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshd_config.5#L912-L918", "saa71f4024a44": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/sshconnect2.c#L540-L543", "sd768a096f236": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/ssh_config.5#L1536-L1546", "s711f7f9ad562": "https://github.com/openssh/openssh-portable/blob/V_9_9_P2/ssh_config.5#L1661-L1674" } }, "ubuntu-focal": { "name": "Ubuntu focal OpenSSH source", "basis": "1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc", "sources": { "sbc9404fbd678": "https://git.launchpad.net/ubuntu/+source/openssh/tree/sshd_config?id=1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc#n13", "s8681449a40db": "https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/changelog?id=1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc#n1", "sc389a883f3ae": "https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/changelog?id=1b842ea8f368b6c7a913ae9e3f253107fb0aa1cc#n4" } }, "rhel-c9s": { "name": "CentOS Stream 9 OpenSSH packaging", "basis": "0b69a3828606595828fc3b878a7c70652303ad67", "sources": { "sd34862bbf569": "https://gitlab.com/redhat/centos-stream/rpms/openssh/-/blob/0b69a3828606595828fc3b878a7c70652303ad67/openssh-7.7p1-redhat.patch#L69-L71", "s87a3827a045e": "https://gitlab.com/redhat/centos-stream/rpms/openssh/-/blob/0b69a3828606595828fc3b878a7c70652303ad67/openssh.spec#L444-L444", "s0a2ed08d7a83": "https://gitlab.com/redhat/centos-stream/rpms/openssh/-/blob/0b69a3828606595828fc3b878a7c70652303ad67/openssh.spec#L135-L135" } }, "ufw": { "name": "UFW documentation", "basis": "unknown", "sources": { "scff3a017f30b": "https://manpages.ubuntu.com/manpages/noble/man8/ufw.8.html" } }, "firewalld": { "name": "firewalld documentation", "basis": "unknown", "sources": { "sa1a1385129f1": "https://firewalld.org/documentation/man-pages/firewall-cmd.html", "sc1cb4d135f3b": "https://firewalld.org/documentation/man-pages/firewalld.richlanguage.html" } }, "ubuntu-uu": { "name": "Ubuntu noble unattended-upgrades source", "basis": "0570be65234e2392d420308470d26920fa4cd21a", "sources": { "sf087d7bd33d0": "https://git.launchpad.net/ubuntu/+source/unattended-upgrades/tree/debian/postinst?id=0570be65234e2392d420308470d26920fa4cd21a#n47", "s665816188e55": "https://git.launchpad.net/ubuntu/+source/unattended-upgrades/tree/data/50unattended-upgrades.Ubuntu?id=0570be65234e2392d420308470d26920fa4cd21a#n6" } }, "ubuntu-apt": { "name": "Ubuntu noble-updates apt source", "basis": "835f914ef5657b923fbb8b357f134b00de24c6fd", "sources": { "sca779f690beb": "https://git.launchpad.net/ubuntu/+source/apt/tree/debian/apt-daily-upgrade.timer?id=835f914ef5657b923fbb8b357f134b00de24c6fd#n5", "sfd64eba4bd67": "https://git.launchpad.net/ubuntu/+source/apt/tree/debian/apt-daily-upgrade.service?id=835f914ef5657b923fbb8b357f134b00de24c6fd#n7", "s11bc9e19894d": "https://git.launchpad.net/ubuntu/+source/apt/tree/debian/apt.systemd.daily?id=835f914ef5657b923fbb8b357f134b00de24c6fd#n491" } }, "c9s-dnf": { "name": "CentOS Stream 9 dnf packaging", "basis": "3d5ca2af8635e0ef77219625f734d503b9900208", "sources": { "s9b090e2053b2": "https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L260-L267", "s32a269377ebb": "https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L75-L75", "s006862fe84d1": "https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L82-L158", "s50216e5989bf": "https://gitlab.com/redhat/centos-stream/rpms/dnf/-/blob/3d5ca2af8635e0ef77219625f734d503b9900208/dnf.spec#L281-L282" } }, "dnf4": { "name": "DNF", "basis": "4.14.0", "sources": { "s97934a4740a2": "https://github.com/rpm-software-management/dnf/blob/4.14.0/etc/dnf/automatic.conf#L1-L22", "sbd65ffa4b83c": "https://github.com/rpm-software-management/dnf/blob/4.14.0/doc/automatic.rst#L38-L50", "s5619c2b76401": "https://github.com/rpm-software-management/dnf/blob/4.14.0/doc/automatic.rst#L86-L91", "sb4ab6d87f528": "https://github.com/rpm-software-management/dnf/blob/4.14.0/etc/systemd/dnf-automatic-install.timer#L7-L13", "s189a68fdfaad": "https://github.com/rpm-software-management/dnf/blob/4.14.0/etc/systemd/dnf-automatic-install.service#L7-L13" } }, "aws-ssm": { "name": "AWS Session Manager", "basis": "unknown", "sources": { "s06a2a8e07062": "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html#session-manager-getting-started-enable-ssh-connections", "s0f77b30546ee": "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-prerequisites.html#session-manager-prerequisites" } }, "gcp-iap": { "name": "Google Cloud IAP", "basis": "unknown", "sources": { "s7e6eda97f950": "https://docs.cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule" } }, "azure-docs": { "name": "Azure documentation snapshot", "basis": "8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f", "sources": { "s6d46120d991b": "https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/bastion/bastion-nsg.md#L77-L84" } }, "tailscale-docs": { "name": "Tailscale documentation", "basis": "unknown", "sources": { "scf48029376f6": "https://tailscale.com/docs/reference/faq/firewall-ports", "s44f0b2a0a8ba": "https://tailscale.com/docs/features/tailscale-ssh#why-it-matters" } }, "fail2ban": { "name": "fail2ban", "basis": "unknown", "sources": { "s7a37ae416e85": "https://github.com/fail2ban/fail2ban" } }, "crowdsec": { "name": "CrowdSec", "basis": "unknown", "sources": { "s4efe4c22c69b": "https://www.crowdsec.net/" } }, "totp": { "name": "google-authenticator-libpam", "basis": "unknown", "sources": { "s6f787f7b4f18": "https://github.com/google/google-authenticator-libpam" } }, "duo": { "name": "Duo Unix", "basis": "unknown", "sources": { "s18b76ce18df1": "https://duo.com/docs/duounix" } }, "cloudinit26": { "name": "cloud-init", "basis": "26.2", "sources": { "sad4ec0d2acc5": "https://github.com/canonical/cloud-init/blob/26.2/cloudinit/ssh_util.py#L548-L586", "s8de0318d9aa5": "https://github.com/canonical/cloud-init/blob/26.2/cloudinit/config/cc_set_passwords.py#L60-L90", "s368c8162762a": "https://github.com/canonical/cloud-init/blob/26.2/cloudinit/config/cc_set_passwords.py#L255-L255" } }, "cloudinit20": { "name": "cloud-init", "basis": "20.1", "sources": { "s471abb6d2123": "https://github.com/canonical/cloud-init/blob/20.1/cloudinit/ssh_util.py#L338-L348", "sa8d2f85a5876": "https://github.com/canonical/cloud-init/blob/20.1/cloudinit/ssh_util.py#L17-L18", "s872ab5dff723": "https://github.com/canonical/cloud-init/blob/20.1/cloudinit/config/cc_set_passwords.py#L97-L122", "s7e40e982cb9f": "https://github.com/canonical/cloud-init/blob/20.1/cloudinit/config/cc_set_passwords.py#L230-L232" } }, "docker": { "name": "Docker Engine networking", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sc920c6946fcf": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L11-L32", "sb5ca9f87b1f3": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L90-L102" } }, "iproute2": { "name": "iproute2 ss manual", "basis": "v6.12.0", "sources": { "s2afc4764d5bb": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L33-L34", "sfaa932c2ca13": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L43-L44", "sd295f82d643f": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L162-L163", "s65278ee45850": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L374-L375", "s8ecdaf9e9ea7": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L377-L378", "s4e1c28d5adad": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L365-L369" } }, "ubuntu-nc": { "name": "Ubuntu noble netcat-openbsd source", "basis": "c047f1a4572e796c8643fb7e5a719a9d960a677b", "sources": { "s478aaf9a8975": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n257", "sd7b31d3bc579": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n263", "scc48da80246b": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n308", "s5e260ceab82c": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/netcat.c?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n899" } }, "c9s-nmap": { "name": "CentOS Stream 9 nmap packaging", "basis": "17c2b7795d821cd7fbe311cf3f885be3ea1f122f", "sources": { "sd06e83963e39": "https://gitlab.com/redhat/centos-stream/rpms/nmap/-/blob/17c2b7795d821cd7fbe311cf3f885be3ea1f122f/nmap.spec#L133-L137", "s3ab25d24e37a": "https://gitlab.com/redhat/centos-stream/rpms/nmap/-/blob/17c2b7795d821cd7fbe311cf3f885be3ea1f122f/nmap.spec#L6-L8" } }, "busybox": { "name": "BusyBox", "basis": "1_36_1", "sources": { "s34bdee292f79": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L61-L93", "sd707e18625cc": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L136-L170", "s14b89daa008d": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L30-L59" } } }, "claims": { "safe-transition": {"text": "Confirm non-root key login and sudo in a fresh session before disabling password/root access; retain the working session while testing.", "components": ["ssh"], "sources": ["ssh:s44f73ffcb41e"], "status": "REASONED"}, "keys-only": {"text": "Disable PasswordAuthentication and KbdInteractiveAuthentication; enable PubkeyAuthentication and prohibit root login.", "components": ["ssh"], "sources": ["ssh:s44f73ffcb41e"], "status": "REASONED"}, "include-order": {"text": "OpenSSH 8.2 added server Include; OpenSSH 9.9 documents that, unless noted otherwise, each keyword uses its first obtained value, and documents lexical wildcard order supporting 00-hardening.conf. The fetched Ubuntu focal OpenSSH 1:8.2p1-4 configuration and CentOS Stream 9 packaging place the drop-in Include near the top, before main-file settings. With ssh_pwauth: true, cloud-init 26.2 writes PasswordAuthentication yes to 50-cloud-init.conf when the main configuration includes that directory; cloud-init 20.1 instead updated the main file. Current distribution defaults and cloud-image prevalence remain unestablished.", "components": ["ssh", "openssh81", "openssh82", "openssh99", "ubuntu-focal", "rhel-c9s", "cloudinit26", "cloudinit20"], "sources": ["ssh:s44f73ffcb41e", "openssh82:s74206a9a30dd", "openssh99:sb409a9bcefc5", "openssh99:s447d8f1b88bd", "ubuntu-focal:sbc9404fbd678", "ubuntu-focal:s8681449a40db", "rhel-c9s:sd34862bbf569", "rhel-c9s:s87a3827a045e", "cloudinit26:sad4ec0d2acc5", "cloudinit26:s8de0318d9aa5", "cloudinit20:s471abb6d2123", "cloudinit20:sa8d2f85a5876", "rhel-c9s:s0a2ed08d7a83", "openssh81:s76bfb1ff5934", "ubuntu-focal:sc389a883f3ae", "cloudinit26:s368c8162762a", "cloudinit20:s872ab5dff723", "cloudinit20:s7e40e982cb9f"], "status": "REASONED"}, "effective-config": {"text": "Validate with sshd -t before reload; inspect sshd -T global values and -T -C for Match-specific settings. Service names differ by distribution.", "components": ["ssh"], "sources": ["ssh:s44f73ffcb41e"], "status": "REASONED"}, "totp": {"text": "SSH TOTP uses google-authenticator-libpam through PAM keyboard-interactive; keys-only configuration must change before adding this factor.", "components": ["ssh", "totp"], "sources": ["ssh:s44f73ffcb41e", "totp:s6f787f7b4f18"], "status": "REASONED"}, "duo": {"text": "Duo pam_duo provides the alternative push factor through PAM keyboard-interactive.", "components": ["ssh", "duo"], "sources": ["ssh:s44f73ffcb41e", "duo:s18b76ce18df1"], "status": "REASONED"}, "mfa-methods": {"text": "UsePAM yes and AuthenticationMethods publickey,keyboard-interactive require both methods; keep password/root login disabled and public keys enabled.", "components": ["ssh", "duo"], "sources": ["ssh:s44f73ffcb41e", "duo:s18b76ce18df1"], "status": "REASONED"}, "legacy-name": {"text": "Older configurations use ChallengeResponseAuthentication for KbdInteractiveAuthentication; enable the spelling present when configuring PAM MFA.", "components": ["ssh", "duo"], "sources": ["ssh:s44f73ffcb41e", "duo:s18b76ce18df1"], "status": "REASONED"}, "ufw-default": {"text": "Set deny incoming and allow outgoing, then enable UFW; permit TCP 80 and 443 for the TLS layer.", "components": ["ufw"], "sources": ["ufw:scff3a017f30b"], "status": "REASONED"}, "ufw-ssh": {"text": "Remove any broad OpenSSH allow before adding an admin-source TCP 22 rule; adding a narrower rule does not replace the old allowance.", "components": ["ufw"], "sources": ["ufw:scff3a017f30b"], "status": "REASONED"}, "brokered-ssh": {"text": "SSM SSH uses the agent's outbound TLS connection and needs no inbound port on the managed node; allow outbound HTTPS 443 to the documented Systems Manager endpoints. A tailnet ordinarily needs no inbound internet firewall port, and Tailscale SSH accepts tailnet traffic on port 22. IAP requires cloud ingress from 35.235.240.0/20, or 2600:2d00:1:7::/64 for IPv6 VMs, to SSH's default port 22 or its configured port; where an NSG is applied to the target VM subnet, Bastion requires an inbound SSH rule on default port 22 or a custom port with Standard or Premium SKU. Microsoft's best practice scopes that rule's source to the AzureBastionSubnet IP range. As this guide's advice, check the host firewall for these broker paths; the fetched IAP, Bastion and Tailscale pages do not specify which UFW or firewalld rules, if any, are needed.", "components": ["ufw", "aws-ssm", "gcp-iap", "azure-docs", "tailscale-docs"], "sources": ["ufw:scff3a017f30b", "aws-ssm:s06a2a8e07062", "aws-ssm:s0f77b30546ee", "gcp-iap:s7e6eda97f950", "azure-docs:s6d46120d991b", "tailscale-docs:scf48029376f6", "tailscale-docs:s44f0b2a0a8ba"], "status": "REASONED"}, "firewalld-zone": {"text": "Find the public interface zone explicitly; remove its broad ssh service, add the IPv4 admin rich rule permanently, then reload to activate it.", "components": ["firewalld"], "sources": ["firewalld:sa1a1385129f1", "firewalld:sc1cb4d135f3b"], "status": "REASONED"}, "firewalld-audit": {"text": "Inspect applicable zones for other services, port ranges, protocol/source allowances or ACCEPT targets that reopen 22; the narrow rich rule does not supersede them.", "components": ["firewalld"], "sources": ["firewalld:sa1a1385129f1", "firewalld:sc1cb4d135f3b"], "status": "REASONED"}, "docker-bypass": {"text": "With Docker Engine default Linux bridge firewall rules, published container traffic is diverted before UFW settings; consult docker.md before relying on the host firewall. As this guide's assessment, other modes and paths, including the userland proxy, need separate review in that guide.", "components": ["ufw", "docker"], "sources": ["ufw:scff3a017f30b", "docker:sc920c6946fcf", "docker:sb5ca9f87b1f3"], "status": "REASONED"}, "fail2ban": {"text": "Use fail2ban to ban repeated authentication failures against SSH/login services.", "components": ["fail2ban"], "sources": ["fail2ban:s7a37ae416e85"], "status": "REASONED"}, "crowdsec": {"text": "CrowdSec is the alternative protection for repeated authentication failures.", "components": ["crowdsec"], "sources": ["crowdsec:s4efe4c22c69b"], "status": "REASONED"}, "updates": {"text": "Automate security patches with unattended-upgrades on Debian/Ubuntu or dnf-automatic on RHEL-family systems, configured to install them. Ubuntu noble's postinst conditionally writes 20auto-upgrades, and apt-daily-upgrade invokes unattended-upgrade when its periodic interval permits; its allowed origins include the release and security pockets. CentOS Stream 9 packages dnf-automatic. Upstream DNF 4.14.0 ships the cited automatic.conf defaults apply_updates=no and upgrade_type=default and the cited timers; enable dnf-automatic-install.timer and select upgrade_type=security for security-only installation. CentOS Stream 9 applies downstream dnf-automatic patches this guide has not reviewed, so its packaged defaults remain unestablished. Other Debian and RHEL-family package defaults remain unestablished.", "components": ["ssh", "ubuntu-uu", "ubuntu-apt", "c9s-dnf", "dnf4"], "sources": ["ssh:s44f73ffcb41e", "ubuntu-uu:sf087d7bd33d0", "ubuntu-uu:s665816188e55", "ubuntu-apt:sca779f690beb", "ubuntu-apt:sfd64eba4bd67", "ubuntu-apt:s11bc9e19894d", "c9s-dnf:s9b090e2053b2", "c9s-dnf:s32a269377ebb", "c9s-dnf:s006862fe84d1", "c9s-dnf:s50216e5989bf", "dnf4:s97934a4740a2", "dnf4:sbd65ffa4b83c", "dnf4:s5619c2b76401", "dnf4:sb4ab6d87f528", "dnf4:s189a68fdfaad"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory intended TCP/UDP listeners and addresses in both IP families with explicit ss -4 and -6 runs. Ubuntu noble netcat-openbsd supports -v, -z and -w; CentOS Stream 9 packages Ncat as an nc alternative, but behaviour of packaged 7.92 remains unestablished. BusyBox 1.36.1 small nc lacks -v while its 1.10-compatible applet lists -v. As this guide's assessment, refusal or timeout proves only that this connection failed; local errors, unsupported options and exit 1 without output are inconclusive. The OpenSSH 9.9 client options select password and disable public key; the literal Permission denied (publickey) is this guide's reasoned expectation dependent on server-advertised methods, while immediate failure with NO password prompt and a Permission denied (...) method list naming neither password nor keyboard-interactive are required. As this guide's reasoning, the client prints the server's remaining methods in that list.", "components": ["ufw", "iproute2", "ubuntu-nc", "c9s-nmap", "busybox", "openssh99"], "sources": ["ufw:scff3a017f30b", "iproute2:s2afc4764d5bb", "iproute2:sfaa932c2ca13", "iproute2:sd295f82d643f", "iproute2:s65278ee45850", "iproute2:s8ecdaf9e9ea7", "iproute2:s4e1c28d5adad", "ubuntu-nc:s478aaf9a8975", "ubuntu-nc:sd7b31d3bc579", "ubuntu-nc:scc48da80246b", "c9s-nmap:sd06e83963e39", "busybox:sd707e18625cc", "busybox:s34bdee292f79", "busybox:s14b89daa008d", "c9s-nmap:s3ab25d24e37a", "ubuntu-nc:s5e260ceab82c", "openssh99:sd768a096f236", "openssh99:s711f7f9ad562", "openssh99:saa71f4024a44"], "status": "REASONED", "verify": [1, 2, 3]}, "verify-firewall": {"text": "UFW status should show default-deny ingress and admin-scoped SSH allowances; also inspect show raw because status omits /etc/ufw rules.", "components": ["ufw"], "sources": ["ufw:scff3a017f30b"], "status": "REASONED", "verify": [1]}, "verify-external": {"text": "Disallowed-source TCP 22 must fail while allowed SSH works; correlate effective rules. As this guide's assessment, refusal alone, local errors and unsupported netcat options are inconclusive.", "components": ["ufw", "firewalld"], "sources": ["ufw:scff3a017f30b", "firewalld:sa1a1385129f1"], "status": "REASONED", "verify": [2]}, "verify-password": {"text": "A password-only SSH attempt must fail immediately without a password prompt and show a Permission denied (...) method list naming neither password nor keyboard-interactive. As this guide's reasoning, the client prints the server's remaining methods in that list; a prompt or a list naming either method means that path is still on.", "components": ["ssh", "openssh99"], "sources": ["ssh:s44f73ffcb41e", "openssh99:saa71f4024a44", "openssh99:sd768a096f236", "openssh99:s711f7f9ad562"], "status": "REASONED", "verify": [3]}, "verify-mfa": {"text": "After key acceptance require a code, reject wrong/omitted codes and unenrolled users without nullok, and use Duo failmode=secure; test before closing the working session.", "components": ["ssh", "totp", "duo"], "sources": ["ssh:s44f73ffcb41e", "totp:s6f787f7b4f18", "duo:s18b76ce18df1"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> cloud-firewalls.md ====================================================================== # Cloud firewalls: security groups and network rules Cloud firewalls are the network rules AWS, Google Cloud and Azure apply before traffic reaches your servers. This guide helps your AI assistant configure them securely, including only 80 and 443 public, databases reachable from private sources only, SSH limited to your addresses or brokered access, and default deny. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/cloud-firewalls.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against AWS security groups and CLI (no recorded version); 2 further sources are pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 14 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. On AWS (security groups), Google Cloud (VPC firewall rules), and Azure (network security groups), the recurring hole is one rule wide open to the world: `0.0.0.0/0` (or `::/0`) on a database, admin, or SSH port, added once to unblock a remote connection and never removed. ## Rules 1. **Public means 80/443 on the TLS layer, nothing else.** Only the load balancer, reverse proxy, or tunnel endpoint accepts traffic from `0.0.0.0/0`, and only on 80 (redirect) and 443. 2. **Databases and internal services accept traffic from private sources only**: the application's security group, subnet, or VPC, never the internet. The per-database guides' TLS and auth still apply on top; the firewall is a layer, not the control. 3. **SSH is not public.** Restrict port 22 to your addresses, or remove the public rule and use brokered access. AWS SSM Session Manager SSH uses the SSM Agent's outbound TLS connection to Session Manager endpoints and needs no inbound port on the managed node; allow outbound HTTPS 443 to the documented endpoints. GCP IAP TCP forwarding requires a cloud ingress allow from `35.235.240.0/20` (`2600:2d00:1:7::/64` for IPv6 VMs) to SSH's default port 22 or its configured port; where an NSG is applied to the target VM subnet, Azure Bastion requires an inbound SSH rule on default port 22 or a custom port with Standard or Premium SKU. Microsoft's best practice scopes that rule's source to the `AzureBastionSubnet` IP address range. Or use a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)): Tailscale ordinarily needs no inbound internet firewall port, and Tailscale SSH accepts traffic from the tailnet on port 22. As this guide's reasoning, these broker paths avoid a public SSH rule. As this guide's advice, check the host firewall separately; the fetched IAP, Bastion and Tailscale pages do not specify which UFW or firewalld rules, if any, are needed. Then harden the host per [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md). 4. **Default deny, explicit allow.** Start from no inbound rules and add the minimum; review rules whenever a service is retired. Reference security-group IDs rather than IP ranges where the provider supports it, so app-to-database access survives IP changes without widening. 5. **Both layers matter on VMs running Docker**: as this guide's advice, use both the cloud firewall and the host's rules, remembering that Docker documents published container traffic bypassing host UFW settings ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). ## Verify REASONED: rule-enumeration scope and the one allowed public pattern, per the cited AWS security group rules, Google Cloud VPC firewall rules, Google firewall policy, evaluation-order and gcloud references, Azure network security group rule properties, the fetched Session Manager, IAP, Bastion and Tailscale pages, and the Azure security-admin rule and applied-rules pages. No authorized AWS, Google Cloud or Azure account is available in the authoring environment, so no rule inventory was read here, while the fenced commands below passed bash -n and ShellCheck 0.11.0 at marking time with no diagnostics, in an earlier form; for the port-probe block, that form's guard refusals left the subshell with status 0. The rule inventory is now one fence per cloud, and the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of those fences and of the port-probe block, as do stub runs with no network reached. The Google Cloud and Azure fences exit 2 when their guard refuses an unsubstituted value, and each inventory fence exits 3 if a listing command fails and otherwise 0, because you judge every rule it lists. For a project with no organization, the Google Cloud fence takes `none` in place of the organization ID: it then lists the VPC firewall rules and network firewall policies, skips the hierarchical policy listing and prints an unclassified line saying that hierarchical policies were not listed. Enumerate every inbound rule, then, for each one whose source reaches the internet, confirm that the destination port is 80 or 443 on the front layer, nothing else. Rule 3's administrative SSH access is the one other source that may reach 22, and it is a named address or a documented broker source (an IAP `35.235.240.0/20` rule, its IPv6 range for IPv6 VMs, or an Azure Bastion inbound SSH rule on the target VM subnet where an NSG is applied, is expected; Microsoft recommends scoping the Bastion source to `AzureBastionSubnet`), never `0.0.0.0/0`. These commands list rather than select, because no filter catches the exposure class. REASONED: cloud-rule inventory and external reachability checks; no exposed/fixed run is recorded in this guide. Expectations follow the cited provider rule documentation, Google firewall policy and evaluation-order references, and the Azure security-admin rule and applied-rules pages; the authoring environment has no authorized cloud accounts or allowed/disallowed probe hosts. ```bash # AWS, once per region. Every rule, not a selection: no filter catches the exposure class, because # 0.0.0.0/1 together with 128.0.0.0/1 admits every IPv4 address while matching neither literal, and # a source can be a prefix list or another security group instead of a CIDR. ( # a subshell, so the exits below end only the subshell aws ec2 describe-security-group-rules \ --query "SecurityGroupRules[].{Group:GroupId,Egress:IsEgress,Proto:IpProtocol,From:FromPort,To:ToPort,V4:CidrIpv4,V6:CidrIpv6,Prefix:PrefixListId,SG:ReferencedGroupInfo.GroupId}" \ --output table || { echo "aws exit $?: the security group rule listing failed; inconclusive; block exit 3"; exit 3; } # read every row whose Egress column reads False. Its source must be a private CIDR, a security # group, a prefix list you have resolved and trust, or, where the destination port is 80 or 443, # the internet. A public source on any other port is a finding, except the single administrative # address rule 3 allows on 22. Rows reading True # are outbound, a separate question. echo "block exit 0; unclassified observation: judge every rule listed above yourself, as the comments in this block describe" exit 0 ) ``` ```bash # REASONED: Google Cloud rule and policy inventory follows the cited VPC firewall rule, firewall policy, # evaluation-order and gcloud references; no authorized Google Cloud account is available here. # Google Cloud, once per project. Whole records, because a table projection drops the enforcement # state: an enforced world-open rule and the same rule with "disabled": true project identically. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORG_ID' # your organization ID, or none for a project with no organization, which skips the hierarchical policy listing: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not listing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not listing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[[:space:][:cntrl:]]*) echo "substitute your organization ID inside the quotes on the set -- line above; not listing"; exit 2 ;; esac set -- "$1" 0 # your organization ID or none, then the block's status so far: 3 (a listing failed, inconclusive) outranks 0 gcloud compute firewall-rules list --format=json || { echo "gcloud exit $?: the VPC firewall rule listing failed; inconclusive"; set -- "$1" 3; } # for each entry with "direction": "INGRESS", "disabled": false and an "allowed" list, work out what # its "sourceRanges" actually reach, by the same reasoning as the AWS command above: 0.0.0.0/0 and # ::/0 are the obvious cases, and so is any set of ranges that together cover the internet. An entry # with "denied" restricts rather than exposes. # firewall-rules list displays Compute Engine firewall rules in this project; also enumerate the policy resources: if [ "$1" = none ]; then echo "unclassified observation: none was given for the organization ID, so hierarchical firewall policies were not listed" else gcloud compute firewall-policies list --organization="$1" || # repeat with --folder=REPLACE_WITH_FOLDER_ID for each relevant folder { echo "gcloud exit $?: the hierarchical firewall policy listing failed; inconclusive"; set -- "$1" 3; } fi gcloud compute network-firewall-policies list || # no scope flag lists global and regional policies in this project { echo "gcloud exit $?: the network firewall policy listing failed; inconclusive"; set -- "$1" 3; } # inspect each hierarchical policy with `gcloud compute firewall-policies describe POLICY_ID --format=json`. # inspect each global network policy with `gcloud compute network-firewall-policies describe POLICY_NAME --global --format=json`; # for each regional policy, use `gcloud compute network-firewall-policies describe POLICY_NAME --region=REGION --format=json`. # Hierarchical policy rules can override or delegate to global network and VPC rules. Read the effective order and matching # rule: as this guide's reasoning, a policy allow with an internet source can expose the port even when the VPC list # above is clean, unless an earlier rule denies it. Google documents the VPC network's networkFirewallPolicyEnforcementOrder: # AFTER_CLASSIC_FIREWALL (default) evaluates hierarchical and regional system policies, VPC rules, then global and # regional network policies; BEFORE_CLASSIC_FIREWALL puts global and regional network policies after hierarchical and # regional system policies but before VPC rules. # A matching allow or deny stops evaluation; goto_next continues it. See # https://docs.cloud.google.com/firewall/docs/firewall-policies-rule-eval-order#determine-rule-evaluation # and https://docs.cloud.google.com/firewall/docs/firewall-policies-rule-eval-order#default-rule-evaluation echo "block exit $2; unclassified observation: judge every rule and policy listed above yourself, as the comments in this block describe" exit "$2" ) ``` ```bash # REASONED: Azure rule inventory follows the cited network security group rule properties and the Azure # security-admin rule and applied-rules pages; no authorized Azure account is available here. # Azure, once per network security group. JSON, not a table: the table formatter omits array-valued # columns, so a rule carrying sourceAddressPrefixes rather than sourceAddressPrefix would print an # empty cell in the exposed state and the safe state alike. --include-default because the platform's # own rules are inbound rules too, and this step claims to enumerate every one. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_RESOURCE_GROUP' 'REPLACE_WITH_NSG_NAME' # the resource group, then the network security group's name: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not listing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not listing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[[:space:][:cntrl:]]*) echo "substitute your resource group inside the quotes on the set -- line above; not listing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|*[[:space:][:cntrl:]]*) echo "substitute your network security group's name inside the quotes on the set -- line above; not listing"; exit 2 ;; esac az network nsg rule list \ --resource-group "$1" --nsg-name "$2" \ --include-default --output json || { echo "az exit $?: the network security group rule listing failed; inconclusive; block exit 3"; exit 3; } # for each rule with "direction": "Inbound" and "access": "Allow", work out what its # "sourceAddressPrefix" and "sourceAddressPrefixes" actually reach: "*", "Internet", "0.0.0.0/0" and # "::/0" are the obvious cases, and so is any set of ranges that together cover the internet. # NSG rules are not the whole story: Azure Virtual Network Manager security admin rules run before NSG rules. # Allow continues to NSG evaluation; Always Allow bypasses it; Deny stops traffic. For a target VM # whose virtual network has a Virtual Network Manager configuration applied, use the Azure portal: # Networking > network interface > Support + troubleshooting > Effective security rules, then select # the Azure Virtual Network Manager to inspect its applied security admin rules. # As this guide's advice, when none is applied, the NSG review above alone covers that VM. # As this guide's reasoning, this check passes when no applied Always Allow from an unauthorized or # internet source reaches a restricted port, including SSH's default port 22 or its configured port. # Any such rule is a finding even if the NSG list above looks safe; inspect its source, destination, # action and port. # The fetched pages give a portal path for applied rules; this guide has no sourced CLI command to list them. echo "block exit 0; unclassified observation: judge every rule listed above, and any applied security admin rules in the portal, yourself" exit 0 ) ``` - REASONED: disallowed-source port probes and their failure interpretation, per the guide's cited AWS security group rules and Azure network security group rule properties. No authorized cloud endpoints or allowed/disallowed probe hosts are available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; original, missing-assignment, short, empty and embedded-placeholder cases printed refusal messages without reaching nc, while a substituted address reached five nc stubs. The bash -n, ShellCheck and stub runs recorded here covered the port-probe block in an earlier form, whose guard refusals left the subshell with status 0. The current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, in which each guard refusal exited 2 without reaching nc, a substituted address reached five nc stubs, an accepted connection exited 1, and an nc failure with no output or with usage text exited 3. Live exposed connections and fixed refusals/timeouts remain unobserved; local errors are inconclusive. From an address outside the range you administer from: REASONED: cloud-rule inventory and external reachability checks; no exposed/fixed run is recorded in this guide. Expectations follow the cited provider rule documentation and Google firewall policy and evaluation-order references; the authoring environment has no authorized cloud accounts or allowed/disallowed probe hosts. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 22 3306 5432 6379 27017 # your address, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports while [ "$#" -gt 2 ]; do # one pass per port; each pass drops the port it probed set -- "$(if nc -vz -w 3 "$1" "$3" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" printf '%s' "${1%$'\n'*}" # what nc printed; the values above now follow nc's output and exit status case "${1##*$'\n'}:${1%$'\n'*}" in 0:*) echo "FINDING: port $4 accepted a connection from here"; set -- "$2" 1 "${@:5}" ;; *:) echo "nc exit ${1##*$'\n'} with no output for port $4: nothing reached the network; inconclusive" case "$3" in 1) set -- "$2" 1 "${@:5}" ;; *) set -- "$2" 3 "${@:5}" ;; esac ;; *[Uu]sage:*|*"invalid option"*|*"unrecognized option"*|*"illegal option"*) echo "nc exit ${1##*$'\n'} with a usage error for port $4: nothing reached the network; inconclusive" case "$3" in 1) set -- "$2" 1 "${@:5}" ;; *) set -- "$2" 3 "${@:5}" ;; esac ;; *) set -- "$2" "$3" "${@:5}" ;; # refused, timed out or another error: read the line yourself esac done echo "block exit $2; a failed nc line passes only if it reports a refused or timed-out connection, and a usage error is inconclusive, so read each one yourself" exit "$2" ) ``` Each port must report a refused or timed-out connection; a usage error from `nc` (some netcat variants take one port or a range per invocation) is not a passing result, and neither is exit 1 with no output at all, which is what a denied local socket looks like: in both cases nothing reached the network, so the check is inconclusive rather than passed. - REASONED: complete address/port coverage and allowed/disallowed controls, per the guide's cited AWS security group rules, Google Cloud VPC firewall rules and Azure network security group rule properties. No authorized cloud endpoints or separate source networks are available. The related shell blocks passed bash -n and ShellCheck 0.11.0 without diagnostics; rejected placeholder cases reached no nc stub and a substituted address reached five. For the port-probe block, the bash -n, ShellCheck and stub runs recorded here used an earlier form, whose guard refusals left the subshell with status 0; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the stub runs recorded in the item above. No real scan ran. Exposed services answer disallowed sources; fixed services answer only allowed sources, corroborated by effective rules, with unused or failed services remaining inconclusive. The `nc` list above is a non-exhaustive spot-check (it omits RDP 3389 and application ports such as 8080 or 9200), and a refused or timed-out port only means nothing answered from here, not that the cloud firewall denies it (an allowed port with no listener refuses too). The strongest reachability test is a full-range external scan of every public IPv4 and IPv6 address (for example with nmap, against your own infrastructure only) from a disallowed source: distinguish open, closed, and filtered, since a closed or refused port is not proof the firewall denies it (a port with no listener also reads closed). For each restricted service, confirm it is reachable from its allowed source and unreachable from a disallowed one, and correlate that with the effective firewall rules before crediting the block to the cloud firewall. ## Sources (checked September 2026) - AWS Session Manager SSH tunnel (rolling) (checked October 2026): https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html#session-manager-getting-started-enable-ssh-connections - AWS Session Manager managed-node outbound HTTPS prerequisites (rolling) (checked October 2026): https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-prerequisites.html#session-manager-prerequisites - Google Cloud IAP TCP forwarding firewall source and SSH port (rolling) (checked October 2026): https://docs.cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule - Azure Bastion target VM subnet NSG rule, at 8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f (checked October 2026): https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/bastion/bastion-nsg.md#L77-L84 - Azure security-admin actions and NSG evaluation order, at 8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f (checked October 2026): https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/virtual-network-manager/concept-security-admins.md#L50-L67 - Azure portal view of applied VM security-admin rules, at 8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f (checked October 2026): https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/virtual-network-manager/how-to-view-applied-configurations.md#L70-L100 - Tailscale firewall ports (rolling) (checked October 2026): https://tailscale.com/docs/reference/faq/firewall-ports - Tailscale SSH tailnet port 22 (rolling) (checked October 2026): https://tailscale.com/docs/features/tailscale-ssh#why-it-matters - AWS CLI `describe-security-group-rules` (the `IsEgress`, `CidrIpv4`, `CidrIpv6`, `FromPort` and `ToPort` fields): https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-security-group-rules.html - AWS security group rules (`0.0.0.0/0` as every IPv4 address): https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html - Google Cloud VPC firewall rules, including the `gcloud compute firewall-rules list --format` projection used above: https://docs.cloud.google.com/firewall/docs/using-firewalls - Azure CLI `az network nsg rule list`: https://learn.microsoft.com/en-us/cli/azure/network/nsg/rule?view=azure-cli-latest - Azure network security group rule properties (`direction`, `access`, `sourceAddressPrefix`, `sourceAddressPrefixes`): https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get?view=rest-virtualnetwork-2026-01-01 - AWS managed prefix lists (a rule source that is neither a CIDR nor a security group): https://docs.aws.amazon.com/vpc/latest/userguide/managed-prefix-lists.html - gcloud output formats, for the `json` format value used above: https://docs.cloud.google.com/sdk/gcloud/reference/topic/formats - Docker and UFW published-port traffic, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L90-L102 - Google Cloud regional network firewall policies (rolling) (checked October 2026): https://docs.cloud.google.com/firewall/docs/firewall-policies-overview#regional_network_firewall_policies - Google Cloud hierarchical policy resource order and delegation to lower-level rules (rolling) (checked October 2026): https://docs.cloud.google.com/firewall/docs/firewall-policies#resource_hierarchy - gcloud hierarchical policy list syntax (rolling) (checked October 2026): https://docs.cloud.google.com/sdk/gcloud/reference/compute/firewall-policies/list - gcloud hierarchical policy describe syntax (rolling) (checked October 2026): https://docs.cloud.google.com/sdk/gcloud/reference/compute/firewall-policies/describe - gcloud network policy list scope, including unflagged global and regional enumeration (rolling) (checked October 2026): https://docs.cloud.google.com/sdk/gcloud/reference/compute/network-firewall-policies/list - gcloud network policy describe global/region syntax (rolling) (checked October 2026): https://docs.cloud.google.com/sdk/gcloud/reference/compute/network-firewall-policies/describe - gcloud Compute Engine firewall-rule list project scope (rolling) (checked October 2026): https://docs.cloud.google.com/sdk/gcloud/reference/compute/firewall-rules/list - Google Cloud firewall policy evaluation order and enforcement setting (rolling) (checked October 2026): https://docs.cloud.google.com/firewall/docs/firewall-policies-rule-eval-order ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - aws: AWS security groups and CLI (unknown) - gcp: Google Cloud firewall and CLI (unknown) - azure: Azure NSG and CLI (unknown) - azure-docs: Azure documentation snapshot (8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f) - docker-docs: Docker Engine networking documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - tailscale-docs: Tailscale documentation (unknown) Claims (full source mapping in the JSON block below): - public-front (REASONED; aws, gcp, azure): Allow internet sources only to the front TLS layer on 80 for redirects and 443; private sources alone reach databases/internal services, with TLS/authentication still required. - ssh (REASONED; aws, gcp, azure): Restrict 22 to named administrative addresses or documented broker sources, never unrestricted internet ranges. - ssm (REASONED; aws): SSM SSH uses the agent's outbound TLS connection and needs no inbound port on the managed node; allow outbound HTTPS 443 to the documented Systems Manager endpoints. - iap (REASONED; gcp): IAP requires cloud ingress from 35.235.240.0/20, or 2600:2d00:1:7::/64 for IPv6 VMs, to SSH's default port 22 or its configured port. - bastion (REASONED; azure, azure-docs, tailscale-docs, gcp): Where an NSG is applied to the target VM subnet, Bastion requires an inbound SSH rule on default port 22 or a custom port with Standard or Premium SKU. Microsoft's best practice scopes that rule's source to the AzureBastionSubnet IP range. A tailnet ordinarily needs no inbound internet firewall port, and Tailscale SSH accepts tailnet traffic on port 22. As this guide's advice, check the host firewall for these broker paths; the fetched IAP, Bastion and Tailscale pages do not specify which UFW or firewalld rules, if any, are needed. - least-access (REASONED; aws, gcp, azure): Start default-deny, add minimum allowances and retire obsolete rules; use security-group references where supported to survive app IP changes. - docker (REASONED; docker-docs): On VMs running Docker, use both cloud and host firewall layers as this guide's advice; Docker documents that published container traffic bypasses UFW settings, as the linked Docker guide explains. - verify-aws (REASONED; aws): Per region inspect all inbound rules, protocol/port ranges, IPv4/IPv6 CIDRs, resolved prefix lists and group references; combinations of narrower ranges can still expose the internet. - verify-gcp (REASONED; gcp): Per project read JSON classic VPC rules, including direction, disabled state, allowed/denied and source ranges; a table can hide whether enforcement is disabled. - gcp-policies (REASONED; gcp): Also list/describe hierarchical and global/regional network policies and read their effective order. Hierarchical policy rules can override or delegate to global network and VPC rules. The VPC network's networkFirewallPolicyEnforcementOrder defaults to AFTER_CLASSIC_FIREWALL: hierarchical and regional system policies, VPC rules, then global and regional network policies; BEFORE_CLASSIC_FIREWALL puts global and regional network policies after hierarchical and regional system policies but before VPC rules. A matching allow or deny stops evaluation; goto_next continues it. As this guide's reasoning, a policy allow with an internet source can expose a port even when the VPC list is clean, unless an earlier rule denies it. - verify-azure (REASONED; azure): Per NSG include default rules and read JSON direction/access plus singular/plural source prefixes; tables omit arrays and can conceal exposure. - azure-admin (REASONED; azure, azure-docs): Azure Virtual Network Manager security admin rules run before NSG rules: Allow continues to NSG evaluation, Always Allow bypasses NSG evaluation, and Deny stops traffic. For a target VM whose virtual network has a Virtual Network Manager configuration applied, review its applied rules in the Azure portal; as this guide's advice, when none is applied, the NSG review alone covers that VM. As this guide's reasoning, this check passes when no applied Always Allow from an unauthorized or internet source reaches a restricted port, including SSH's default port 22 or its configured port; any such rule fails even if the NSG list looks safe. This guide has no sourced CLI command to list applied rules. - verify-ports (REASONED; aws, gcp, azure): Outside the admin range, spot-check 22, 3306, 5432, 6379 and 27017; refusal/timeout is expected, while netcat usage errors or silent local failure are inconclusive. - verify-complete (REASONED; aws, gcp, azure): The spot-check omits RDP 3389 and apps such as 8080/9200; scan all public IPv4/IPv6 ports, distinguish open/closed/filtered, and correlate allowed/disallowed controls with effective rules. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "a3fb65779a75d02a5a0af8bc077cce1cc0bf9d5dce42fc92e7530518eba7e395", "components": { "aws": { "name": "AWS security groups and CLI", "basis": "unknown", "sources": { "s0c459e0aa7a1": "https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-security-group-rules.html", "s5594f61a25d6": "https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html", "sf8813f7c4057": "https://docs.aws.amazon.com/vpc/latest/userguide/managed-prefix-lists.html", "s06a2a8e07062": "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html#session-manager-getting-started-enable-ssh-connections", "s0f77b30546ee": "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-prerequisites.html#session-manager-prerequisites" } }, "gcp": { "name": "Google Cloud firewall and CLI", "basis": "unknown", "sources": { "s1c06c98a99c9": "https://docs.cloud.google.com/firewall/docs/using-firewalls", "s3a90bf10d4eb": "https://docs.cloud.google.com/sdk/gcloud/reference/topic/formats", "s68e82eab648c": "https://docs.cloud.google.com/firewall/docs/firewall-policies-overview#regional_network_firewall_policies", "s633352524069": "https://docs.cloud.google.com/firewall/docs/firewall-policies#resource_hierarchy", "sbfb2e25c9059": "https://docs.cloud.google.com/sdk/gcloud/reference/compute/firewall-policies/list", "s12d2856bf552": "https://docs.cloud.google.com/sdk/gcloud/reference/compute/firewall-policies/describe", "sef4965908f7b": "https://docs.cloud.google.com/sdk/gcloud/reference/compute/network-firewall-policies/list", "s652edf39de2a": "https://docs.cloud.google.com/sdk/gcloud/reference/compute/network-firewall-policies/describe", "s57189bff8b8f": "https://docs.cloud.google.com/sdk/gcloud/reference/compute/firewall-rules/list", "sd9ee642a0bff": "https://docs.cloud.google.com/firewall/docs/firewall-policies-rule-eval-order", "s7e6eda97f950": "https://docs.cloud.google.com/iap/docs/using-tcp-forwarding#create-firewall-rule" } }, "azure": { "name": "Azure NSG and CLI", "basis": "unknown", "sources": { "sfdd568ca0dea": "https://learn.microsoft.com/en-us/cli/azure/network/nsg/rule?view=azure-cli-latest", "s3d55deda04f8": "https://learn.microsoft.com/en-us/rest/api/virtualnetwork/network-security-groups/get?view=rest-virtualnetwork-2026-01-01" } }, "azure-docs": { "name": "Azure documentation snapshot", "basis": "8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f", "sources": { "s6d46120d991b": "https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/bastion/bastion-nsg.md#L77-L84", "s03ca867c4276": "https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/virtual-network-manager/concept-security-admins.md#L50-L67", "s382379ec555c": "https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/virtual-network-manager/how-to-view-applied-configurations.md#L70-L100" } }, "docker-docs": { "name": "Docker Engine networking documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sb5ca9f87b1f3": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L90-L102" } }, "tailscale-docs": { "name": "Tailscale documentation", "basis": "unknown", "sources": { "scf48029376f6": "https://tailscale.com/docs/reference/faq/firewall-ports", "s44f0b2a0a8ba": "https://tailscale.com/docs/features/tailscale-ssh#why-it-matters" } } }, "claims": { "public-front": {"text": "Allow internet sources only to the front TLS layer on 80 for redirects and 443; private sources alone reach databases/internal services, with TLS/authentication still required.", "components": ["aws", "gcp", "azure"], "sources": ["aws:s5594f61a25d6", "gcp:s1c06c98a99c9", "azure:s3d55deda04f8"], "status": "REASONED"}, "ssh": {"text": "Restrict 22 to named administrative addresses or documented broker sources, never unrestricted internet ranges.", "components": ["aws", "gcp", "azure"], "sources": ["aws:s5594f61a25d6", "gcp:s1c06c98a99c9", "azure:s3d55deda04f8"], "status": "REASONED"}, "ssm": {"text": "SSM SSH uses the agent's outbound TLS connection and needs no inbound port on the managed node; allow outbound HTTPS 443 to the documented Systems Manager endpoints.", "components": ["aws"], "sources": ["aws:s5594f61a25d6", "aws:s06a2a8e07062", "aws:s0f77b30546ee"], "status": "REASONED"}, "iap": {"text": "IAP requires cloud ingress from 35.235.240.0/20, or 2600:2d00:1:7::/64 for IPv6 VMs, to SSH's default port 22 or its configured port.", "components": ["gcp"], "sources": ["gcp:s1c06c98a99c9", "gcp:s7e6eda97f950"], "status": "REASONED"}, "bastion": {"text": "Where an NSG is applied to the target VM subnet, Bastion requires an inbound SSH rule on default port 22 or a custom port with Standard or Premium SKU. Microsoft's best practice scopes that rule's source to the AzureBastionSubnet IP range. A tailnet ordinarily needs no inbound internet firewall port, and Tailscale SSH accepts tailnet traffic on port 22. As this guide's advice, check the host firewall for these broker paths; the fetched IAP, Bastion and Tailscale pages do not specify which UFW or firewalld rules, if any, are needed.", "components": ["azure", "azure-docs", "tailscale-docs", "gcp"], "sources": ["azure:s3d55deda04f8", "azure-docs:s6d46120d991b", "tailscale-docs:scf48029376f6", "tailscale-docs:s44f0b2a0a8ba", "gcp:s7e6eda97f950"], "status": "REASONED"}, "least-access": {"text": "Start default-deny, add minimum allowances and retire obsolete rules; use security-group references where supported to survive app IP changes.", "components": ["aws", "gcp", "azure"], "sources": ["aws:s5594f61a25d6", "gcp:s1c06c98a99c9", "azure:s3d55deda04f8"], "status": "REASONED"}, "docker": {"text": "On VMs running Docker, use both cloud and host firewall layers as this guide's advice; Docker documents that published container traffic bypasses UFW settings, as the linked Docker guide explains.", "components": ["docker-docs"], "sources": ["docker-docs:sb5ca9f87b1f3"], "status": "REASONED"}, "verify-aws": {"text": "Per region inspect all inbound rules, protocol/port ranges, IPv4/IPv6 CIDRs, resolved prefix lists and group references; combinations of narrower ranges can still expose the internet.", "components": ["aws"], "sources": ["aws:s0c459e0aa7a1", "aws:s5594f61a25d6", "aws:sf8813f7c4057"], "status": "REASONED", "verify": [1]}, "verify-gcp": {"text": "Per project read JSON classic VPC rules, including direction, disabled state, allowed/denied and source ranges; a table can hide whether enforcement is disabled.", "components": ["gcp"], "sources": ["gcp:s1c06c98a99c9", "gcp:s3a90bf10d4eb"], "status": "REASONED", "verify": [2]}, "gcp-policies": {"text": "Also list/describe hierarchical and global/regional network policies and read their effective order. Hierarchical policy rules can override or delegate to global network and VPC rules. The VPC network's networkFirewallPolicyEnforcementOrder defaults to AFTER_CLASSIC_FIREWALL: hierarchical and regional system policies, VPC rules, then global and regional network policies; BEFORE_CLASSIC_FIREWALL puts global and regional network policies after hierarchical and regional system policies but before VPC rules. A matching allow or deny stops evaluation; goto_next continues it. As this guide's reasoning, a policy allow with an internet source can expose a port even when the VPC list is clean, unless an earlier rule denies it.", "components": ["gcp"], "sources": ["gcp:s1c06c98a99c9", "gcp:s68e82eab648c", "gcp:s633352524069", "gcp:sbfb2e25c9059", "gcp:s12d2856bf552", "gcp:sef4965908f7b", "gcp:s652edf39de2a", "gcp:s57189bff8b8f", "gcp:sd9ee642a0bff"], "status": "REASONED", "verify": [2]}, "verify-azure": {"text": "Per NSG include default rules and read JSON direction/access plus singular/plural source prefixes; tables omit arrays and can conceal exposure.", "components": ["azure"], "sources": ["azure:sfdd568ca0dea", "azure:s3d55deda04f8"], "status": "REASONED", "verify": [3]}, "azure-admin": {"text": "Azure Virtual Network Manager security admin rules run before NSG rules: Allow continues to NSG evaluation, Always Allow bypasses NSG evaluation, and Deny stops traffic. For a target VM whose virtual network has a Virtual Network Manager configuration applied, review its applied rules in the Azure portal; as this guide's advice, when none is applied, the NSG review alone covers that VM. As this guide's reasoning, this check passes when no applied Always Allow from an unauthorized or internet source reaches a restricted port, including SSH's default port 22 or its configured port; any such rule fails even if the NSG list looks safe. This guide has no sourced CLI command to list applied rules.", "components": ["azure", "azure-docs"], "sources": ["azure:s3d55deda04f8", "azure-docs:s03ca867c4276", "azure-docs:s382379ec555c"], "status": "REASONED", "verify": [3]}, "verify-ports": {"text": "Outside the admin range, spot-check 22, 3306, 5432, 6379 and 27017; refusal/timeout is expected, while netcat usage errors or silent local failure are inconclusive.", "components": ["aws", "gcp", "azure"], "sources": ["aws:s5594f61a25d6", "gcp:s1c06c98a99c9", "azure:s3d55deda04f8"], "status": "REASONED", "verify": [4]}, "verify-complete": {"text": "The spot-check omits RDP 3389 and apps such as 8080/9200; scan all public IPv4/IPv6 ports, distinguish open/closed/filtered, and correlate allowed/disallowed controls with effective rules.", "components": ["aws", "gcp", "azure"], "sources": ["aws:s5594f61a25d6", "gcp:s1c06c98a99c9", "azure:s3d55deda04f8"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> paas.md ====================================================================== # PaaS platforms: what the platform does, what stays yours Render, Fly.io, Railway, Vercel, Heroku and Hugging Face Spaces host your app and handle parts of running it, such as HTTPS. This guide helps your AI assistant deploy securely, including what each platform's TLS covers, authentication that stays yours, and backend secrets kept server-side, never in a Static Space. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/paas.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Render documentation (no recorded version), Next.js pinned documentation v16.4.2, Next.js environment variables 15, Express request protocol 5.x, Django proxy security setting 6.1, MySQL client connection options 8.4, and curl curl-8_14_1; 3 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-10-22; documentation checked September 2026. The guide records 24 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. TLS responsibilities depend on the platform and deployment mode: - Render: the load balancer terminates HTTPS for web services and forwards HTTP to the app. Render automatically issues and renews certificates for added custom domains, including wildcards. Add the domain, configure DNS, and verify it in the dashboard. Wildcards require the root domain to point to Render and the prescribed wildcard, `_acme-challenge`, and `_cf-custom-hostname` CNAME records; existing CAA records must allow Let's Encrypt and Google Trust Services, with `issuewild` records for wildcards. At the time of writing, included custom-domain quotas depend on the workspace plan and additional domains cost extra. - Fly.io: Fly Proxy's `tls` handler terminates TLS and forwards plaintext; without handlers, TCP passes through and the app can terminate TLS itself. Add the custom domain and configure DNS for automatic certificate issuance. Issuance and renewal require domain verification through an AAAA record pointing to the app, an `_acme-challenge` CNAME, or a `_fly-ownership` TXT record; wildcard certificates require DNS-01 with the `_acme-challenge` CNAME. This guide's assessment: these passages describe managed certificates and renewal prerequisites but do not explicitly establish automatic renewal. Fly's current custom-domain page says Fly.io needs that verification "To issue or renew a certificate", automatically validates ownership and issues certificates once DNS is configured, and notes that renewals do not count against Let's Encrypt's Certificates per Registered Domain limit. This guide's assessment: that page also does not explicitly state automatic renewal, renewal timing or renewal-failure handling. Fly's pinned Cloudflare page, under custom certificate issues, says: "Unlike Let's Encrypt certificates, which renew automatically, custom certificates must be manually renewed and re-imported before they expire." This guide's reading: the pinned custom-domain page calls Fly-managed certificates Let's Encrypt certificates, so this documents their automatic renewal; it states no renewal timing or renewal-failure handling. Fly's pinned Certificates resource page says Fly.io "can automatically issue Let's Encrypt certificates (ACME)" and, when a custom certificate also exists for a hostname, that "the ACME certificate acts as an automatic fallback". This guide's assessment: that file, read whole, states no renewal timing or renewal-failure handling either. Imported certificates require ownership verification and take priority over a coexisting Fly-managed fallback. - Railway: correctly configured custom domains receive automatically issued Let's Encrypt certificates, renewed with 30 days of their 90-day validity remaining; external certificates are unsupported. Both the supplied CNAME and ownership TXT records are required; wildcards also require the `_acme-challenge` CNAME, unproxied on Cloudflare. Root domains require CNAME flattening or dynamic ALIAS records. At the time of writing, custom-domain limits are one on Trial, two per service on Hobby and twenty per service by default on Pro, with increases available on request; Freenom domains are unsupported. For Cloudflare-proxied domains, issuance can fail and Railway can instead serve its default `*.up.railway.app` certificate; the docs require Full mode and warn that Full (Strict) will not work as intended. Cloudflare proxying below first-level subdomains requires Advanced Certificate Manager or switching those records to DNS Only. Railway's edge proxy (tcp-proxy) terminates TLS; traffic is then forwarded over Railway's internal network to the deployment ([request flow](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43)). - Vercel: its CDN terminates TLS. Certificates for deployment URLs are generated and kept updated automatically. Custom-domain issuance requires successful validation after DNS propagation; non-wildcards use HTTP-01 reachable at Vercel, and wildcards require Vercel nameservers for DNS-01. Vercel automatically attempts renewal 14 to 30 days before expiry; correct DNS, proxy, and CAA configuration must still permit validation. At the time of writing, only Enterprise teams can configure custom SSL; uploaded certificates cannot be renewed automatically by Vercel. The `/.well-known` path is reserved and cannot be redirected or rewritten. - Heroku: the HTTP routing page describes load-balancer SSL termination, with separate Private Spaces routers. At the time of writing, ACM is included free for all applications; enable it for automatic issuance and renewal one month before expiry, using the correct Heroku DNS target. Adding custom domains requires a verified Heroku account. Cedar Private Spaces require a running web dyno for validation and renewal, cannot use ACM with Trusted IPs blocking access, and cannot use ACM for wildcard domains. On wildcards, the page says "You must also upload your own wildcard certificate for Private Space apps." and points to add-ons "If you want to automatically renew a wildcard certificate on a Private Space app"; neither statement names a generation. Its limitations say "Cedar generation Private Space apps using ACM can’t have wildcard domains. For Generation Private Space apps can use ACM with wildcard domains." This guide's assessment: the three statements agree for Cedar Private Spaces; for the other generation, which the page calls "For Generation" and which probably means Fir, the last sentence conflicts with the two unqualified statements, so Fir wildcard eligibility remains unresolved. When checked in October 2026, the Generations page's Feature Parity table, with columns Fir and Cedar, marked "Heroku ACM wildcard domains" with an X in both. This guide's assessment: since that page says "Fir is now generally available with Fir Private Spaces", the Fir mark supports the ACM page's other-generation sentence, but it withdraws neither unqualified statement, so the conflict remains. The same page says "Both the Common Runtime and Private Spaces run on the Cedar generation of the platform." Its Feature Parity table's Cedar column also marks the "Common Runtime" row, and the Internal routing, VPC peering, VPN support and Private Spaces DNS Service Discovery rows, where Fir has To Be Added; the Private Spaces page lists those four features under "Additional Features for Cedar Private Spaces". No cited page says whether the Cedar wildcard mark covers Cedar Private Spaces. This guide's assessment: the Common Runtime row's mark cannot describe a Private Space, so a Cedar mark records a feature of the Cedar Common Runtime, of Cedar Private Spaces or of both, and the wildcard mark establishes no conflict with the ACM page's Cedar Private Spaces limitation, which this guide keeps as the governing statement. When checked on 2026-11-04, the ACM page, last updated July 07, 2026, still carried that limitations sentence, and the Generations page, last updated September 11, 2026, still marked Fir for "Heroku ACM wildcard domains". Heroku's Understanding TLS on Heroku page, last updated September 30, 2024, lists "Private Space apps using wildcard domains" under what ACM does not support and says "Note that ACM doesn’t support wildcard domains for Private Spaces", naming no generation. Heroku's Acquiring an SSL Certificate page, also last updated September 30, 2024, says "If you want to use a different certificate authority for your application or a wildcard certificate on a Private Space app, follow these steps to acquire a certificate manually." and names no generation. This guide's assessment: the Understanding TLS page's note conflicts with the ACM page's other-generation sentence and the Generations page's Fir mark, and so does the Acquiring page's conditional advice, read without a generation exception, so Fir Private Space wildcard eligibility for ACM remains conflicting across the pages named. Uploading a certificate disables ACM. The ACM page's "Known limitations" list also says "ACM does not support IPv6 AAAA DNS records." and "ACM is not compatible with internal routing." CAA must permit Let's Encrypt, and DNS/CDN configuration must allow validation; ACM must be disabled if the app requires the platform-handled ACME challenge paths. This guide's advice: where the platform terminates TLS and manages the required certificate, do not bolt certbot or an extra TLS reverse proxy onto the app, and do not apply this repository's server-TLS guides to that managed ingress. What stays yours: ## 1. Authentication: entirely yours The platform does not authenticate your application's users. Some platforms protect the deployment itself: Vercel Deployment Protection, for example, can require a Vercel login to open preview and deployment URLs on every plan, and production domains too once the All Deployments scope is selected, which Vercel's changelog of 9 September 2026 made free on every plan including Hobby. Vercel's Password Protection is not available on Hobby, is a paid add-on per protected project on Pro, and is included on Enterprise (at the time of writing). Vercel Deployment Protection keeps the public out of a preview; it is not your app's login. Every non-public endpoint still needs login or keys per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md), MFA where viable per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), and a hosted provider makes both easy ([identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). "It is on Vercel" changes nothing about an open `/api/admin`. ## 2. Secrets: use the platform's store Each platform provides environment/secret configuration. Set secrets there; never commit `.env` files ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Rotate anything that ever appeared in the repository, build logs, or client bundles. Public frontend frameworks compile some env vars into the client (for example `NEXT_PUBLIC_`-prefixed values); only put genuinely public values in those. ## 3. Enforce HTTPS and correct proxy awareness - Redirect HTTP to HTTPS. This guide's advice: use the documented automatic redirect, enable the documented platform setting, or implement the redirect in the app with verified proxy trust: - Render's TLS page says all HTTP requests are automatically redirected to HTTPS, and its custom-domains page says the same for custom-domain traffic; neither states the HTTP-to-HTTPS status code. Its static-site redirect rules document 301 for moving a request to a rule's destination. This guide's assessment: that is a separate feature and does not establish the HTTP-to-HTTPS status. - Fly.io's pinned configuration reference documents `force_https = true` under `[http_service]` in `fly.toml` for apps managed by Fly Launch. The pinned services page says enabling `force_https` returns 301 for all HTTP requests; the option is only valid on HTTP handlers, and deployment fails if it is set on other handlers. - Railway's pinned public-networking specifications describe automatic HTTPS redirects for plain HTTP GET requests with 301, and HTTP POST redirects that convert the request to GET ([GET status and POST conversion](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L37-L38)). This guide's assessment: these GET/POST statements establish no configurable redirect toggle, POST status code or HEAD response used below. Railway's pinned 405 troubleshooting page explains that a POST request can reach the application as a GET because, when the request is made using HTTP, "Railway will attempt to redirect your insecure request with a 301 Moved Permanently status code", and clients typically change POST to GET when they follow it ([POST redirect status](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/405-method-not-allowed.md#L8-L18)). This guide's reading: that page documents 301 for an HTTP POST and attributes the change to GET to the client; the cited passage establishes no HEAD response or redirect toggle. The pinned specifications page instead says HTTP POST requests to port 80 "are redirected to HTTPS as GET requests" ([request handling](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L16-L17)) and that plain HTTP POST requests "will be converted to GET requests" ([POST conversion](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L37-L38)). This guide's assessment: unlike the 405 page, that page does not attribute the change to GET to the client, and this guide does not establish which description is right. - Vercel's encryption page says its CDN automatically redirects HTTP requests to HTTPS with 308 and that this redirection cannot be disabled. - Heroku's ACM page says that, when ACM is enabled, the platform handles `/.well-known/acme-challenge/*` URLs, redirected away from the app, and requires disabling ACM if the app needs those paths. This guide's assessment: that path handling and the cited HTTP routing page establish none of the following: a general HTTP-to-HTTPS redirect policy, a redirect toggle, an application-only redirect contract, or a status code. Heroku's help article "Can Heroku force an application to use SSL/TLS?" says: "Redirects need to be performed at the application level as the Heroku router does not provide this functionality." The article names no Heroku generation or runtime; this guide's assessment: applying it to every Heroku generation and runtime is an inference. Heroku's ASP.NET Core configuration page says "Heroku doesn’t automatically redirect HTTP requests to HTTPS.", and its example configuration sets `options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect;`. This guide's assessment: 308 is that example's choice, not a status the Heroku platform sends. This guide's advice: implement application-side redirection unless the deployed ingress is known to enforce HTTPS, and verify the result. For application-side handling, this guide's advice is to check the platform's forwarded-protocol header only after verifying the framework's proxy trust requirements. Fly's HTTP handler documents `X-Forwarded-Proto` as the original client protocol, `http` or `https`; Railway's public-networking specifications say it always indicates `https`. Heroku's routing page describes it as the originating protocol but explicitly warns that forwarded headers, including `X-Forwarded-Proto`, are not trusted because the order of pre-existing fields is unknown. Fly's pinned request-headers page says request headers "are added by the HTTP handler service" and describes `X-Forwarded-Proto` as "The protocol which the client used to make the request."; this guide's assessment: it does not say whether Fly overwrites, removes or passes through a value the client sent. These descriptions do not establish header sanitization; the cited Render and Vercel TLS pages do not establish a forwarded-protocol header contract. Vercel's request-headers page describes `x-forwarded-proto` as the protocol of the forwarded server, typically `https` in production and `http` in development. It also says that, when Vercel is used behind a proxy, Vercel currently overwrites `X-Forwarded-For` and does not forward external IPs, to prevent IP spoofing; this guide's assessment: it states no such overwrite for `x-forwarded-proto`, so it does not establish that header's sanitization. The page adds that Enterprise customers can purchase and enable a trusted proxy to allow a custom `X-Forwarded-For` IP. Heroku's help article, which names no generation or runtime, says: "Under the hood, Heroku router (over)writes the `X-Forwarded-Proto` and the `X-Forwarded-Port` request headers." Heroku's HTTP routing page still says the `X-Forwarded-For`, `X-Forwarded-By`, `X-Forwarded-Proto` and `X-Forwarded-Host` headers "are not trusted for security reasons". This guide's assessment: the two Heroku pages are not reconciled, so this guide's advice stands: trust `X-Forwarded-Proto` only after verifying the framework's proxy trust requirements. Render's TLS, Web Services and Custom Domains pages, web-services tutorial and authentication and FastAPI production articles, and Vercel's request-headers, encryption and reverse-proxy pages, searched on 2026-11-04 and listed in Sources, do not say whether either platform overwrites, removes or passes through an `X-Forwarded-Proto` value the client sent. This guide's assessment: not finding it in those pages does not establish that either platform documents no such handling. Keep the Express 5.x `trust proxy` and Django 6.1 `SECURE_PROXY_SSL_HEADER` trust requirements in view when implementing this guide's advice. - Behind the platform proxy, configure the framework accordingly (`trust proxy` in Express per [nodejs.md](https://github.com/jposluns/secureconfig/blob/main/nodejs.md), `SECURE_PROXY_SSL_HEADER` in Django per [python.md](https://github.com/jposluns/secureconfig/blob/main/python.md)) so secure cookies and redirects behave. - Bind the listener required by the deployment model. Render web services must bind `0.0.0.0`; Render recommends `PORT` (default port 10000, configurable), can usually detect another bound port, and requires the public HTTP server to use `PORT` when multiple ports are bound. Fly Proxy HTTP services must be reachable over IPv4: use `0.0.0.0` and match the service's configured `internal_port` to the app listener; IPv4-mapped IPv6 wildcard behaviour depends on the VM and runtime. Railway domains route to a detected or selected internal target port; its troubleshooting page says web servers should bind `0.0.0.0` and listen on the automatically injected `PORT` ([listener and port](https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51)). Heroku assigns web processes a `PORT` environment variable: its runtime-principles page says each web process binds to that port and listens for requests on it, and its Node.js deployment page says the web process command must bind to that port or the dyno does not start. For Fir, the Generations page says Private Spaces networking uses IPv6 by default, that IPv4-only assumptions do not bind properly, and recommends an IPv6 or dual-stack listener (typically `::`); the cited pages do not establish Cedar's listener address. Vercel runtimes transform code into Functions served by its CDN, including Node.js function entrypoints and Python ASGI/WSGI apps; that page establishes neither a universal injected port nor a required listener address. Vercel's Functions overview shows functions as exported `fetch` or HTTP-method handlers and says each incoming request is a new invocation with no servers to manage. This guide's assessment: that overview states no listener address or port either. When checked in October 2026, Vercel's Express page said the Express entry file "must also export the application as a default export of the module or use a port listener", showing the `app.listen` pattern with an example port of 3000, and its Python runtime page described the entrypoint as "the file and top-level variable that Vercel loads as the Vercel Function handler". This guide's assessment: neither page states a required listener address or a port Vercel uses, and the example port is not a requirement. When checked on 2026-11-04, the Express page, last updated August 10, 2026, said "You may also run your application using the `app.listen` pattern that exposes the server on a port." That page does not say which address or port the deployed function listens on, or whether Vercel connects to that socket. Vercel's Express, Python runtime, Functions runtimes, Functions and Backends pages, searched on 2026-11-04 and listed in Sources, state neither. This guide's assessment: not finding it in those pages does not establish that Vercel documents neither. This guide's advice: bind nothing else and do not open extra listeners. ## 4. Databases attached to PaaS apps Managed databases from these platforms come with TLS endpoints, but check what the specific endpoint supports: require certificate and hostname verification with the client's own setting (`sslmode=verify-full` for PostgreSQL, `--ssl-mode=VERIFY_IDENTITY` for MySQL) per the database guides ([postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md)) where the endpoint allows it. The endpoint check is also to note that some managed internal endpoints support encryption but not `verify-full` (Render's internal PostgreSQL connections, where TLS is optional, do not support `verify-ca`/`verify-full`). The step for those endpoints that support encryption but not `verify-full` is to set `sslmode=require` to force encryption and prevent a plaintext fallback, and not to treat that encryption-only connection as identity-verified. Keep the credentials in the platform's secret store, and restrict the network too: many managed databases are reachable from any IP by default (Render's Postgres is), so prefer private connectivity and disable or source-restrict external access with the provider's database network controls, not TLS alone. Databases you run yourself elsewhere follow their own guides plus [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md). ## 5. Hugging Face Spaces This guide's advice: explicitly select and verify visibility when you create a Space. The pinned docs describe public, protected, and private visibility; public visibility exposes both the source and the running app, and duplicating a Space defaults to private. At the time of writing, the pinned Advanced Security docs attach repository visibility controls to Team & Enterprise organizations: Public by default creates new repositories with public visibility; Private by default creates new repositories with private visibility; Private only enforces private visibility for all new repositories. The same pinned Advanced Security docs, at the time of writing, also say that changing the Private by default setting does not affect existing repositories and that, under Private only, organization admins are the only ones able to change visibility settings. Those lines do not establish a general creation default or a retroactive conversion of existing repositories by Private only. Visibility can be changed to "protected" (the source stays private to the owner and collaborators, but the running app is still public through its embed URL or custom domain) or "private" (both the source and the running app are restricted to the owner and collaborators). Protected visibility requires a paid plan, PRO for personal accounts or Team/Enterprise for organizations, at the time of writing. Space secrets belong in the Settings tab's secrets store, never in the repository or its README metadata; anything set as a "variable" instead of a "secret" is still publicly readable. That secrets store is not a safe place for backend or provider credentials on a Static Space: Hugging Face exposes both variables and secrets to client-side JavaScript there, through `window.huggingface.variables`, so anything placed in a Static Space's secrets still reaches the browser. Keep server-side credentials in a server-side service, such as a Docker or Gradio Space or a separate backend, instead. At the time of writing, the pinned Dev Mode docs require PRO or Team & Enterprise. The Dev Mode Docker image starts the application as a subprocess and starts an SSH server for local editors and terminals and a VS Code server for in-product VS Code Web. Dev Mode exposes the running Space as a standard SSH host; clients use the Space subdomain shown in the Dev Mode controls as the username at ssh.hf.space, and local VS Code uses Remote - SSH to connect to the same host. SSH requires adding the machine's SSH public key to the user's Hugging Face account; these docs do not establish which users or roles may connect, or the access control for in-product VS Code Web. This guide's assessment: access into the running container is materially more access than the app itself, so treat an unattended Dev Mode session the same as any other exposed admin path. For serverless GPU platforms, see [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md). ## 6. Verify REASONED: HTTPS redirect, application-authentication and deployed-secret checks; no PaaS application, tenant credentials or deployed client bundle was supplied for this metadata review, and no live run is recorded. The redirect and unauthenticated-request checks take the app's host name on guarded `set --` lines that refuse example.com, example.net, example.org and their subdomains, and the unauthenticated-request check takes its protected path on the same line. No run of the guards on the form before this change, which lowercased the host and the redirect's headers with `tr` and deleted the headers' spaces and tabs with a second `tr`, was recorded before 2026-11-01; curl-stub runs of it that day refused the reserved spellings with `tr` present, and with `tr` absent or failing refused none, probed the reserved name and reported a finding. The current form refuses a reserved name, and matches the Location header, under bash's `nocasematch` option, and deletes the spaces and tabs with bash expansions, instead of through `tr`; each part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs on 2026-11-01 with no network. In bash, each part refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached both probes. In those runs a 301 whose Location header read `LOCATION:` and a tab before `HTTPS://`, or `location :` and spaces and a tab before `https://`, passed, also with `tr` absent or failing, while a 301 with an http:// Location or with no Location line was a finding. The redirect part unsets LC_ALL and sets LC_ALL=C inside its subshell just before the Location match, because `nocasematch` folds case through the locale and a Turkish or Azerbaijani locale does not fold `I` to `i`; the C locale folds A to Z only, so `location:` matches in any letter case in every locale, and a non-ASCII letter must match exactly. If LC_ALL is read-only, that part says so and exits 2 instead of classifying the redirect; both arms of the match end the part, so no later command runs in that locale. In stub runs on 2026-11-01 without the LC_ALL lines, LC_ALL=tr_TR.UTF-8 or LANG=tr_TR.UTF-8 turned a 301 whose header read `LOCATION: https://` into a finding. With them, that 301 passed under C, C.UTF-8 and both Turkish settings, also under an inherited `nocasematch` or `set -e`, while an http:// Location stayed a finding and a read-only LC_ALL gave 2 and its message. Under dash, each part refused every host name with 2 and the bash message before its probe, and the block exited 2. The expected readings below follow the cited platform documentation and the guide's application-security reasoning; exact application behaviour remains deployment-specific. ```bash # 1. This guide checks HTTP->HTTPS redirection. Spot-check on / only: it does not prove HTTPS on every route/method, nor that # the app's proxy-awareness (section 3) is set for secure cookies. --noproxy so a client proxy cannot answer. # Documented: Vercel 308; Fly HTTP handlers with force_https enabled 301. Render's HTTPS code is unstated; its static-rule 301 differs. # Railway's specifications page documents GET 301 and its 405 troubleshooting page a 301 for HTTP POST that # clients typically follow as GET; neither page's cited passages document this HEAD response. This guide's assessment: # Heroku's ACM challenge-path handling establishes no general HTTPS redirect policy, setting, application-only responsibility or status. # Heroku's SSL/TLS help article, which names no generation or runtime, says redirects need to be performed at the # application level, as its router does not provide them; its ASP.NET Core page says Heroku does not redirect HTTP to # HTTPS automatically, and that page's 308 is its example's choice. # This guide's check: require 301/302/307/308 with an https:// Location; HTTP content without that redirect fails. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the public host name your platform serves the app on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" "$(if curl -q -g -sSI --noproxy '*' -w 'http=%{http_code}\n' "http://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # this guide's redirect check above; -S makes curl show an error message if it fails despite -s (cited curl show-error). This guide's assessment: http=000 beside a "curl: (N) ..." error line means no HTTP response arrived: inconclusive, not a pass or a fail set -- "$1" "${2##*$'\n'}" "${2%$'\n\n'*}" # curl's exit status, then the headers and the http= line set -- "$1" "$2" "$3" "${3##*$'\n'}" # the same values, then the http= line alone printf '%s\n' "$3" case "$2:$4" in 0:http=301|0:http=302|0:http=307|0:http=308) set -- "$1" "$2" "${3// /}" "$4" # the headers with their spaces deleted by a bash expansion, not an outside command { unset -v LC_ALL; } 2>/dev/null || { echo "LC_ALL is read-only in this shell, so the Location header cannot be matched; not classifying the redirect"; exit 2; } LC_ALL=C # nocasematch folds case by locale, and the C locale folds A to Z only, as header names need shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # header names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail; both of its arms end this part's subshell, and the option with it case "${3//$'\t'/}" in # the headers with their tabs deleted too, so any spaces and tabs may follow location: *$'\n''location:https://'*) exit 0 ;; *) echo "FINDING: a ${4#http=} redirect without an https:// Location, which this guide's check requires"; exit 1 ;; esac ;; 0:http=000) echo "http=000: no HTTP response arrived; inconclusive"; exit 3 ;; 0:*) echo "FINDING: HTTP ${4#http=} instead of a 301, 302, 307 or 308 with an https:// Location, so HTTP content is served without that redirect"; exit 1 ;; *) echo "curl exit $2: the redirect request failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # 2. Your APP's own auth must reject an unauthenticated request, NOT the platform deployment-protection gate. # Vercel documents that Deployment Protection requires authentication for all requests, and that a valid # Protection Bypass for Automation token lets the request access the deployment without authentication, # sent as the x-vercel-protection-bypass HTTP header or query parameter carrying the project's generated # secret. Disable deployment protection (or use that bypass) so only application auth is in play. That # bypass is Vercel's; no Render or Fly equivalent is cited here, so do not assume one exists. If you use # the bypass secret, send it as the header, supplied from a file with -H @file per this guide's # conventions so it stays out of shell history; the query-parameter form writes it into history and logs. # The status codes below are this guide's expected reading; the cited pages state none, including for the # platform gate's own challenge (its SSO 401 below): ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' 'REPLACE_WITH_PROTECTED_PATH' # the same app host name as the redirect check above (no scheme, user name, port, path, query, fragment, percent sign or trailing dot), then a protected route without its leading slash: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|/*|*[[:space:][:cntrl:]]*) echo "substitute a protected path, without its leading slash, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w 'http=%{http_code}\n' "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # no creds: want the APP's own 401/403, not a platform SSO 401; -S makes curl show an error message if it fails despite -s (cited curl show-error). This guide's assessment: http=000 beside a "curl: (N) ..." error line means no HTTP response arrived: inconclusive, not a rejection set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the http= line printf '%s\n' "$4" case "$3:$4" in 0:http=2??) echo "FINDING: $2 answered ${4#http=} with no credentials, so nothing rejected the unauthenticated request"; exit 1 ;; 0:http=000) echo "http=000: no HTTP response arrived; inconclusive, not a rejection"; exit 3 ;; 0:*) echo "unclassified observation: ${4#http=} with no credentials; a 401 or 403 is the pass only if your app sent it, not a platform SSO gate, so judge which did"; exit 0 ;; *) echo "curl exit $3: the unauthenticated request failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # Then repeat with a valid application credential (your app's session cookie or API token, supplied from a file with # -b or -H @file so it stays out of shell history) and require a 2xx: that positive control proves the endpoint is # reachable and that the app's own auth, not the platform gate, is what gates it. # 3. Secrets: run secrets.md's history and working-tree scans AND grep the DEPLOYED client bundle (JS, source maps, # generated config, runtime-injected values) for every credential type, not just private keys. No matches is evidence, not proof. # Why the deployed bundle: as the Next.js case, the pinned v16.4.2 docs say NEXT_PUBLIC_ values are inlined into # the JavaScript bundle delivered to the client at build time, and production browser source maps are disabled # unless productionBrowserSourceMaps opts in. What to grep and which credential types are this guide's procedure, # delegated to secrets.md, not taken from the cited pages. echo "block exit $1; a protected-path answer other than a 2xx, the credentialed positive control and the step 3 secret scans are not classified, so judge them yourself" exit "$1" ) ``` ## Sources (checked September 2026) - Render: https://render.com/docs ; Fly.io: https://docs.fly.io/ ; Vercel: https://vercel.com/docs (each documents managed TLS and environment configuration; consult your platform's pages for the exact toggles) - Vercel Deployment Protection: https://vercel.com/docs/deployment-protection - Vercel changelog, protect production deployments for free on every plan (9 September 2026): https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan - Vercel deployment protection bypass methods (checked October 2026): https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection - Vercel Protection Bypass for Automation header and query parameter (checked October 2026): https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation - Hugging Face Spaces: https://huggingface.co/docs/hub/spaces-overview and https://huggingface.co/docs/hub/spaces-config-reference - Hugging Face Hub Space visibility choices and source/app access (checked October 2026; commit 6fb324369143cb76a723c88a592010514ac1095a): https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-overview.md#L29-L45 - Hugging Face Hub Space duplication visibility default (checked October 2026; commit 6fb324369143cb76a723c88a592010514ac1095a): https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-overview.md#L114-L118 - Hugging Face Hub organization visibility controls and plan scope (checked October 2026; commit 6fb324369143cb76a723c88a592010514ac1095a): https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/enterprise-advanced-security.md#L3-L27 - Hugging Face Hub Dev Mode plan requirement and servers (checked October 2026; commit 6fb324369143cb76a723c88a592010514ac1095a): https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-dev-mode.md#L3-L14 - Hugging Face Hub Dev Mode SSH, editor connection and SSH key requirement (checked October 2026; commit 6fb324369143cb76a723c88a592010514ac1095a): https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-dev-mode.md#L41-L78 - Render managed PostgreSQL (external access is open by default and can be restricted; internal connections do not support `sslmode=verify-ca`/`verify-full`): https://render.com/docs/postgresql-creating-connecting - Next.js 15 environment variables (checked October 2026; `NEXT_PUBLIC_` values in browser JavaScript): https://nextjs.org/docs/15/app/guides/environment-variables - Next.js v16.4.2 `NEXT_PUBLIC_` build-time inlining into client JavaScript (checked October 2026): https://github.com/vercel/next.js/blob/v16.4.2/docs/01-app/02-guides/environment-variables.mdx#L158-L164 - Next.js v16.4.2 production browser source maps default and opt-in serving (checked October 2026): https://github.com/vercel/next.js/blob/v16.4.2/docs/01-app/03-api-reference/05-config/01-next-config-js/productionBrowserSourceMaps.mdx#L8-L18 - Express 5.x request protocol (checked October 2026; `trust proxy` and `X-Forwarded-Proto`): https://expressjs.com/en/5x/api/request/#req.protocol - Django 6.1 settings (checked October 2026; `SECURE_PROXY_SSL_HEADER` and trusted proxy requirements): https://docs.djangoproject.com/en/6.1/ref/settings/#secure-proxy-ssl-header - MySQL 8.4 client connection options (checked October 2026; `--ssl-mode=VERIFY_IDENTITY` certificate and hostname verification): https://dev.mysql.com/doc/refman/8.4/en/connection-options.html#option_general_ssl-mode - Render web-service listener and TLS termination (checked October 2026): https://render.com/docs/web-services - Render managed TLS (checked October 2026): https://render.com/docs/tls. The page states: "Finally, Render automatically redirects all HTTP requests to HTTPS so your users' security is never compromised." This guide's assessment: no HTTP-to-HTTPS status code is stated. - Render custom-domain prerequisites (checked October 2026): https://render.com/docs/custom-domains. The page states: "All HTTP traffic to a custom domain is automatically redirected to HTTPS." This guide's assessment: no HTTP-to-HTTPS status code is stated. - Render static-site redirect rules (checked October 2026): https://render.com/docs/redirects-rewrites. The Redirect action "Instructs the browser (or any other client) to switch URLs to the rule's destination via a 301 Moved Permanently response code." This guide's assessment: this status belongs to static-site redirect rules and does not establish the HTTP-to-HTTPS status. - Render web-services tutorial (checked November 2026): https://render.com/tutorials/web-service-vs-static-site/web-services. The page was searched on 2026-11-04 for Render's handling of a client-sent `X-Forwarded-Proto` value and states none. - Render authentication and authorization article (checked November 2026): https://render.com/articles/how-to-implement-authentication-and-authorization. The article was searched on 2026-11-04 for Render's handling of a client-sent `X-Forwarded-Proto` value and states none. - Render FastAPI production practices article (checked November 2026): https://render.com/articles/fastapi-production-best-practices. The article was searched on 2026-11-04 for Render's handling of a client-sent `X-Forwarded-Proto` value and states none. - Vercel TLS termination (checked November 2026): https://vercel.com/docs/cdn-security/encryption - Vercel request headers, `x-forwarded-proto` description, `X-Forwarded-For` overwrite and Enterprise trusted proxy (checked November 2026): https://vercel.com/docs/headers/request-headers. The page describes `x-forwarded-proto` as the protocol of the forwarded server, typically `https` in production and `http` in development. Under `x-forwarded-for`, it states: "If you are trying to use Vercel behind a proxy, we currently overwrite the X-Forwarded-For header and do not forward external IPs. This restriction is in place to prevent IP spoofing." Under "Custom X-Forwarded-For IP", it states: "Enterprise customers can purchase and enable a trusted proxy to allow your custom X-Forwarded-For IP." This guide's assessment: the page states no overwrite of a client-sent `x-forwarded-proto`, so it does not establish that header's sanitization. - Vercel reverse proxy servers (checked November 2026): https://vercel.com/docs/security/reverse-proxy. The page was searched on 2026-11-04 for Vercel's handling of a client-sent `X-Forwarded-Proto` value and states none. - Vercel certificate validation and renewal (checked October 2026): https://vercel.com/docs/domains/working-with-ssl - Vercel Functions runtimes (checked October 2026): https://vercel.com/docs/functions/runtimes. The page, last updated August 12, 2026, was searched on 2026-11-04 for the address or port a deployed Express function listens on and for whether Vercel connects to an `app.listen` socket, and states neither. - Vercel Functions request handlers and invocations (checked October 2026): https://vercel.com/docs/functions. No last-updated marker was found on the page, which was searched on 2026-11-04 for the address or port a deployed Express function listens on and for whether Vercel connects to an `app.listen` socket, and states neither. - Vercel Express entry-file export or port listener (checked October 2026): https://vercel.com/docs/frameworks/backend/express. The page states: "The file must also export the application as a default export of the module or use a port listener." This guide's assessment: its port 3000 is an example; the page states no required listener address or port. On 2026-11-04 the page, last updated August 10, 2026, states: "You may also run your application using the `app.listen` pattern that exposes the server on a port." It was searched that day for the address or port the deployed function listens on and for whether Vercel connects to that socket, and states neither. - Vercel Python runtime entrypoint (checked October 2026): https://vercel.com/docs/functions/runtimes/python. The page states that a Python entrypoint is "the file and top-level variable that Vercel loads as the Vercel Function handler". This guide's assessment: it states no listener address or port. The page, last updated August 12, 2026, was searched on 2026-11-04 for the address or port a deployed Express function listens on and for whether Vercel connects to an `app.listen` socket, and states neither. - Vercel Backends (checked November 2026): https://vercel.com/docs/frameworks/backend. The page, last updated August 20, 2026, was searched on 2026-11-04 for the address or port a deployed Express function listens on and for whether Vercel connects to an `app.listen` socket, and states neither. - Heroku certificate automation and limitations (checked October 2026): https://devcenter.heroku.com/articles/automated-certificate-management. The page states: "When ACM is enabled the Let’s Encrypt HTTP challenge URLs (/.well-known/acme-challenge/*) are handled by the Heroku Platform, redirected away from your app. If you require use of these paths on your app you must disable ACM." This guide's assessment: this path handling establishes none of the following for general HTTP-to-HTTPS redirection: a policy, a toggle, application-only responsibility or a status code. The page's "Known limitations" list also states: "ACM does not support IPv6 AAAA DNS records." and "ACM is not compatible with internal routing." On 2026-11-04 the page, last updated July 07, 2026, still states: "Cedar generation Private Space apps using ACM can’t have wildcard domains. For Generation Private Space apps can use ACM with wildcard domains." This guide's assessment: Heroku's Understanding TLS on Heroku page, and its Acquiring an SSL Certificate page read without a generation exception, both last updated September 30, 2024, conflict with its other-generation sentence. - Heroku HTTP routing (checked November 2026): https://devcenter.heroku.com/articles/http-routing - Heroku SSL/TLS redirect responsibility and `X-Forwarded-Proto` overwrite (checked November 2026): https://help.heroku.com/J2R1S4T8/can-heroku-force-an-application-to-use-ssl-tls. The article states: "Redirects need to be performed at the application level as the Heroku router does not provide this functionality." It also states: "Under the hood, Heroku router (over)writes the `X-Forwarded-Proto` and the `X-Forwarded-Port` request headers." The article names no Heroku generation or runtime. This guide's assessment: the HTTP routing page still lists `X-Forwarded-Proto` among headers that "are not trusted for security reasons", and neither page reconciles the two statements. - Heroku ASP.NET Core HTTPS redirection (checked November 2026): https://devcenter.heroku.com/articles/aspnetcore-app-configuration. The page states: "Heroku doesn’t automatically redirect HTTP requests to HTTPS." Its example configuration sets `options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect;`. This guide's assessment: 308 is that example's choice, not a status the Heroku platform sends. - Heroku custom-domain prerequisites (checked October 2026): https://devcenter.heroku.com/articles/custom-domains. The article states: "For security purposes, you must verify your Heroku account to add domains to apps." This guide's assessment: this sentence establishes the account prerequisite, not the verification procedure or certificate eligibility. - Heroku web-process port assignment (checked October 2026): https://devcenter.heroku.com/articles/runtime-principles - Heroku Node.js web-process `PORT` binding requirement (checked October 2026): https://devcenter.heroku.com/articles/deploying-nodejs - Heroku Fir Private Spaces networking, listener recommendation and ACM wildcard feature parity (checked October 2026): https://devcenter.heroku.com/articles/generations. The article states: "Fir utilizes IPv6 as the default and recommended connection protocol for networking within Heroku Private Spaces." It warns: "If you designed your application, its dependencies, or connected services within a space assuming only IPv4 connectivity, they don’t bind properly on Fir." It recommends: "Ensure IPv6 compatibility by correctly binding to an IPv6 listener or the dual-stack address listener in your app’s language (typically ::) to leverage Fir’s networking configuration." This guide's assessment: these sentences concern networking within Heroku Private Spaces; they do not establish Cedar's listener address or a deployed application's listener configuration. Its Feature Parity table, with columns Fir and Cedar, marks "Heroku ACM wildcard domains" with an X in both. This guide's assessment: the Fir mark supports Fir Private Space ACM wildcard eligibility without withdrawing the ACM page's two unqualified statements. The article also states: "Both the Common Runtime and Private Spaces run on the Cedar generation of the platform." The table's Cedar column also marks the "Common Runtime" row and the Internal routing, VPC peering, VPN support and Private Spaces DNS Service Discovery rows, whose linked features the Private Spaces page lists for Cedar Private Spaces, and the article does not say whether the Cedar wildcard mark covers Cedar Private Spaces. This guide's assessment: a Cedar mark therefore need not hold in both Cedar runtimes, so the wildcard mark establishes no conflict with the ACM page's Cedar Private Spaces limitation. On 2026-11-04 the page, last updated September 11, 2026, still marks "Heroku ACM wildcard domains" with an X in the Fir column. - Heroku Cedar Private Spaces features (checked October 2026): https://devcenter.heroku.com/articles/private-spaces. Under the heading "Additional Features for Cedar Private Spaces", the article says: "In addition to the features listed for the spaces in the Fir generation of the Heroku platform, the spaces in the Cedar generation have the following features." It then lists Internal Routing, DNS Service Discovery, VPN Connection and VPC Peering, linking the articles that the Generations page's Internal routing, Private Spaces DNS Service Discovery, VPN support and VPC peering rows link. This guide's assessment: those rows' Cedar marks record features this page describes for Cedar Private Spaces, while the "Common Runtime" row's Cedar mark records the other Cedar runtime. - Heroku Understanding TLS on Heroku, ACM and Private Spaces wildcard domains (checked November 2026): https://devcenter.heroku.com/articles/understanding-tls-on-heroku. The page, last updated September 30, 2024, lists "Private Space apps using wildcard domains" under "ACM doesn’t support" and states: "Note that ACM doesn’t support wildcard domains for Private Spaces". It names no generation. This guide's assessment: it conflicts with the ACM page's other-generation sentence and the Generations page's Fir mark. - Heroku Acquiring an SSL Certificate, Private Space wildcard certificates (checked November 2026): https://devcenter.heroku.com/articles/acquiring-an-ssl-certificate. The page, last updated September 30, 2024, states: "If you want to use a different certificate authority for your application or a wildcard certificate on a Private Space app, follow these steps to acquire a certificate manually." It names no generation exception. This guide's assessment: read without a generation exception, it conflicts with the ACM page's other-generation sentence and the Generations page's Fir mark. - Fly TCP passthrough (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97 - Fly TLS termination (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176 - Fly domain verification for issuance and renewal (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/custom-domain.mdx#L39-L44 - Fly wildcard validation and automatic issuance (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/custom-domain.mdx#L64-L87 - Fly imported certificates and fallback (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/custom-domain.mdx#L91-L102 - Fly Let's Encrypt automatic renewal and manual custom-certificate renewal (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/understanding-cloudflare.mdx#L105-L105. The line states: "Unlike Let's Encrypt certificates, which renew automatically, custom certificates must be manually renewed and re-imported before they expire." This guide's assessment: it states no renewal timing or renewal-failure handling. - Fly Certificates resource, automatic ACME issuance and fallback (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/machines/api/certificates-resource.mdx#L6-L6. The line states: "Fly.io can automatically issue Let's Encrypt certificates (ACME), or you can import your own custom certificates. When both exist for a hostname, the custom certificate is served as primary and the ACME certificate acts as an automatic fallback." This guide's assessment: the file, read whole on 2026-10-25, states no renewal timing or renewal-failure handling. - Fly custom-domain issuance and renewal verification, automatic validation and renewal rate-limit note (checked October 2026): https://docs.fly.io/networking/custom-domain - Fly public service binding (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42 - Fly IPv4 and IPv6 wildcard qualification (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92 - Railway custom-domain DNS and plan conditions (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L39-L122 - Railway certificate renewal and Cloudflare limitations (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L220-L247 - Railway root-domain DNS (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L150-L150 - Railway target ports (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146 - Railway web-server listener and injected port (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51 - Railway edge-proxy TLS termination and internal forwarding (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43 - Fly app configuration file (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/reference/configuration.mdx#L7-L7 - Fly HTTP service redirect setting (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/reference/configuration.mdx#L329-L350 - Fly redirect status and HTTP-handler restriction (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L229-L229 - Fly HTTP handler forwarded-protocol header (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L117-L123 - Fly request headers, `X-Forwarded-Proto` description (checked October 2026; commit 2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9): https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/request-headers.mdx#L9-L35. Line 9 states: "Request headers are added by the HTTP handler service." Under `X-Forwarded-Proto`, line 35 states: "The protocol which the client used to make the request. Either `http` or `https`." This guide's assessment: the page does not say whether a client-sent `X-Forwarded-Proto` is overwritten, removed or passed through. - Railway public-networking headers and request handling (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L16-L17 - Railway GET redirect status and POST conversion (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L37-L38. The two complete entries state: "Plain HTTP GET requests will be redirected to HTTPS with a `301` response." and "Plain HTTP POST requests will be converted to GET requests." This guide's assessment: these entries state none of the following: HEAD behaviour, a POST status code or a configurable redirect toggle. - Railway HTTP POST redirect status and client method change (checked October 2026; commit 72354493198302677fb6be6c692dd75c64cae2c6): https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/405-method-not-allowed.md#L8-L18. The page states: "This occurs because your request was made using HTTP. Railway will attempt to redirect your insecure request with a 301 Moved Permanently status code." It continues: "When an HTTP client encounters a 301 Moved Permanently redirect, the client will follow the redirect." It then says that, according to the HTTP/1.1 specifications, "the client will typically change the request method from POST to GET when it follows the redirect to the new URL". The page's line 8 places this in "a POST request to your application" that "is redirected to a GET request". This guide's assessment: these lines state no HEAD behaviour or configurable redirect toggle. - curl curl-8_14_1 (checked October 2026): [-S restores error messages with -s (4-19)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/show-error.md#L4-L19). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - render: Render documentation (unknown) - fly: Fly.io documentation (unknown) - fly-pinned: Fly.io pinned documentation (2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9) - vercel: Vercel documentation (unknown) - spaces: Hugging Face Spaces documentation (unknown) - spaces-pinned: Hugging Face Hub documentation (6fb324369143cb76a723c88a592010514ac1095a) - heroku: Heroku documentation (unknown) - railway: Railway documentation (72354493198302677fb6be6c692dd75c64cae2c6) - nextjs-pinned: Next.js pinned documentation (v16.4.2) - nextjs: Next.js environment variables (15) - express: Express request protocol (5.x) - django: Django proxy security setting (6.1) - mysql-client: MySQL client connection options (8.4) - curl: curl (curl-8_14_1) Claims (full source mapping in the JSON block below): - managed-tls (REASONED; render, fly, vercel, heroku, fly-pinned, railway): TLS and certificates vary by platform and deployment; Railway's edge proxy terminates TLS before its internal network; Fly passes TCP through without handlers; Heroku ACM supports no AAAA records or internal routing; Heroku pages conflict on Fir Private Space wildcard ACM. Guide advice: no certbot or extra TLS proxy where the platform terminates TLS and manages the required certificate. - fly-renewal (REASONED; fly-pinned): Fly's pinned Cloudflare page says Let's Encrypt certificates renew automatically, unlike custom certificates, which must be manually renewed and re-imported; this guide's reading: the pinned custom-domain page calls Fly-managed certificates Let's Encrypt certificates, so they renew automatically. Unestablished: Fly renewal timing and renewal-failure handling. - application-auth (REASONED; vercel): Deployment protection does not replace app login, endpoint keys or application MFA; non-public endpoints still need their own access controls. - vercel-login (REASONED; vercel): Vercel login can gate preview/deployment URLs on every plan and production domains with All Deployments; the 9 September 2026 change made that scope free including Hobby. - vercel-password (REASONED; vercel): At the recorded documentation date, Password Protection is unavailable on Hobby, a paid per-project add-on on Pro and included on Enterprise. - secret-store (REASONED; render, fly, vercel): Use platform environment/secret configuration, not committed .env files; rotate credentials exposed in repositories, build logs or client bundles. - public-env (REASONED; vercel, nextjs): Only public values belong in browser-compiled environment variables such as NEXT_PUBLIC_. - https-redirect (REASONED; render, fly, vercel, heroku, fly-pinned, railway): Enforce HTTPS: Render automatic (status unstated), Vercel mandatory 308, Fly HTTP handlers 301 with force_https, Railway GET 301 and, per its 405 troubleshooting page, POST 301 that clients typically follow as GET (HEAD unknown). Heroku's help article (no generation or runtime named) puts redirects in the app, not its router; its ASP.NET Core example's 308 is not a platform status. - https-redirect-advice (REASONED; render, fly, vercel, heroku, express, django, fly-pinned): Guide advice: implement HTTP-to-HTTPS redirects in the app unless deployed ingress is known to enforce HTTPS, with verified proxy trust and no assumed forwarded-header sanitization. Heroku's help article says its router (over)writes X-Forwarded-Proto; its routing page calls it untrusted. Render and Vercel pages searched 2026-11-04 do not say how a client-sent value is handled. - proxy-awareness (REASONED; render, fly, vercel, express, django): Configure Express trust proxy or Django SECURE_PROXY_SSL_HEADER so secure cookies and redirects work behind the platform. - listener-port (REASONED; render, fly, vercel, heroku, fly-pinned, railway): Railway advises 0.0.0.0 on injected PORT and Heroku web processes bind PORT; listener rules vary by deployment, with IPv6/dual-stack recommended for Heroku Fir Private Spaces. Vercel's Express page allows a port listener; cited pages establish no Heroku Cedar listener address, or Vercel's deployed address, port or connection; this guide's advice: bind nothing else and do not open extra listeners. - database-identity (REASONED; render, mysql-client): Where supported, require TLS certificate/hostname verification with PostgreSQL sslmode=verify-full or MySQL --ssl-mode=VERIFY_IDENTITY. - render-internal-tls (REASONED; render): Render internal PostgreSQL TLS is optional and supports neither verify-ca nor verify-full; sslmode=require prevents plaintext fallback but is not identity verification. - database-network (REASONED; render): Render Postgres external access is open to any IP by default; prefer private connectivity and disable or source-restrict external access. Keep database credentials in the secret store. - spaces-creation (REASONED; spaces, spaces-pinned): This guide's advice: explicitly select and verify visibility when you create a Space. The pinned docs describe public, protected, and private visibility, public exposing source and running app and duplicates defaulting to private; the pinned Advanced Security lines (Team & Enterprise controls at the time of writing) establish no general creation default or retroactive Private only conversion. - spaces-public (REASONED; spaces): Public Spaces expose both source and running app. - spaces-protected (REASONED; spaces): Protected Spaces keep source private to owner/collaborators but expose the app via embed URL or custom domain; the recorded requirement is PRO for personal accounts or Team/Enterprise for organizations. - spaces-private (REASONED; spaces): Private Spaces restrict both source and running app to the owner and collaborators. - spaces-secrets (REASONED; spaces): Use Settings secrets for sensitive values, never repository/README metadata; variables remain publicly readable. - spaces-static (REASONED; spaces): Static Spaces expose both variables and secrets to client JavaScript through window.huggingface.variables; keep backend/provider credentials in Docker/Gradio Spaces or another server-side service. - spaces-dev (REASONED; spaces, spaces-pinned): At the time of writing, the pinned Dev Mode docs require PRO or Team & Enterprise. The Dev Mode Docker image starts the application as a subprocess and starts an SSH server for local editors and terminals and a VS Code server for in-product VS Code Web. Dev Mode exposes the running Space as a standard SSH host; clients use the Space subdomain shown in the Dev Mode controls as the username at ssh.hf.space, and local VS Code uses Remote - SSH to connect to the same host. SSH requires adding the machine's SSH public key to the user's Hugging Face account; these docs do not establish which users or roles may connect, or the access control for in-product VS Code Web. This guide's assessment: access into the running container is materially more access than the app itself, so treat an unattended Dev Mode session the same as any other exposed admin path. - verify-redirect (REASONED; vercel, render, fly-pinned, railway, heroku, curl): This guide's HTTP HEAD / check requires 301/302/307/308 with an https:// Location; Vercel documents 308 and Fly HTTP handlers 301 with force_https. Spot-check proves neither every route/method nor secure-cookie proxy awareness; Render's HTTPS status (not its static-rule 301) and Railway HEAD status remain unestablished. Heroku's ASP.NET Core 308 is the example's choice, not a platform status. - verify-app-auth (REASONED; vercel, curl): Disable Vercel deployment protection or use its documented Protection Bypass for Automation, the x-vercel-protection-bypass header or query parameter carrying the project's generated secret, to isolate app auth; no Render or Fly equivalent is cited. This guide's expected reading: anonymous protected requests get the app's 401/403, valid app credentials a 2xx, and a platform SSO 401 is not app enforcement. - verify-secrets (REASONED; render, fly, vercel, nextjs-pinned): Scan history, working tree and deployed client JS, source maps, generated config and runtime values for every credential type; no matches is evidence, not proof. The scan procedure is this guide's, delegated to secrets.md; the pinned Next.js v16.4.2 lines document only the NEXT_PUBLIC_ build-time inlining and off-by-default production browser source maps that motivate the bundle scan. ````json { "schema": 1, "checked": "2026-10-22", "documentation_checked": "2026-09", "body_sha256": "59a1f9107da1ac7bb86982f00ceb93add040c3349f356cb1707e55b0e5f3d0b0", "components": { "render": { "name": "Render documentation", "basis": "unknown", "sources": { "s213783ba6209": "https://render.com/docs", "se240afec8d2d": "https://render.com/docs/postgresql-creating-connecting", "s1820ac471b6c": "https://render.com/docs/web-services", "s5b8400c2a2ba": "https://render.com/docs/tls", "s22963948fc90": "https://render.com/docs/custom-domains", "s4935cea7a2ea": "https://render.com/docs/redirects-rewrites", "s86db60c0804c": "https://render.com/tutorials/web-service-vs-static-site/web-services", "s392f75ca4bcf": "https://render.com/articles/how-to-implement-authentication-and-authorization", "sec77c58ba095": "https://render.com/articles/fastapi-production-best-practices" } }, "fly": { "name": "Fly.io documentation", "basis": "unknown", "sources": { "s29502d5e8321": "https://docs.fly.io/", "s74ed2822178a": "https://docs.fly.io/networking/custom-domain" } }, "fly-pinned": { "name": "Fly.io pinned documentation", "basis": "2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9", "sources": { "s254c8c8a5edd": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L93-L97", "s32999aa4c58c": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L174-L176", "sf4105f79b22b": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/custom-domain.mdx#L39-L44", "sea5560c14d13": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/custom-domain.mdx#L64-L87", "sb935e8478a30": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/custom-domain.mdx#L91-L102", "s62fec91543a1": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L33-L42", "s61c1e50d2c11": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/app-services.mdx#L92-L92", "s6e9a7292860d": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/reference/configuration.mdx#L7-L7", "sa63b828c93df": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/reference/configuration.mdx#L329-L350", "s4324a02dbc42": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L229-L229", "scf2e6f3bce02": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/services.mdx#L117-L123", "sdf84e7f7ba34": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/understanding-cloudflare.mdx#L105-L105", "sdcd6b9620532": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/machines/api/certificates-resource.mdx#L6-L6", "sdb820fc62ed4": "https://github.com/superfly/docs/blob/2f68a40e09bb6ef4c4ac6f90c15c1cf5a176d7e9/networking/request-headers.mdx#L9-L35" } }, "vercel": { "name": "Vercel documentation", "basis": "unknown", "sources": { "s2b470a6bc853": "https://vercel.com/docs", "se6089d5c4e96": "https://vercel.com/docs/deployment-protection", "s7836e043434a": "https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan", "s3e7832fd3031": "https://vercel.com/docs/cdn-security/encryption", "se314aa4b7f1e": "https://vercel.com/docs/domains/working-with-ssl", "s22498401f5d0": "https://vercel.com/docs/functions/runtimes", "s845513031876": "https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection", "sc1588029e14e": "https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation", "s2f00e51f9e6b": "https://vercel.com/docs/functions", "s0ede3eac8f7e": "https://vercel.com/docs/frameworks/backend/express", "sacb72fc28bb6": "https://vercel.com/docs/functions/runtimes/python", "sc2d0fb3d1d6d": "https://vercel.com/docs/headers/request-headers", "s43a030ff5da7": "https://vercel.com/docs/security/reverse-proxy", "s1b69b9469345": "https://vercel.com/docs/frameworks/backend" } }, "spaces": { "name": "Hugging Face Spaces documentation", "basis": "unknown", "sources": { "sda0c810c053d": "https://huggingface.co/docs/hub/spaces-overview", "sd5ec2b2772e0": "https://huggingface.co/docs/hub/spaces-config-reference" } }, "spaces-pinned": { "name": "Hugging Face Hub documentation", "basis": "6fb324369143cb76a723c88a592010514ac1095a", "sources": { "se2fb4e2a5c02": "https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-overview.md#L29-L45", "s9d332f0940e2": "https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-overview.md#L114-L118", "s969a28a4c44e": "https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/enterprise-advanced-security.md#L3-L27", "s5d393b019bad": "https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-dev-mode.md#L3-L14", "sd1ae2b703a70": "https://github.com/huggingface/hub-docs/blob/6fb324369143cb76a723c88a592010514ac1095a/docs/hub/spaces-dev-mode.md#L41-L78" } }, "heroku": { "name": "Heroku documentation", "basis": "unknown", "sources": { "sa86eb06bbd4a": "https://devcenter.heroku.com/articles/automated-certificate-management", "s0c5bf300cef4": "https://devcenter.heroku.com/articles/http-routing", "sfbcf716168be": "https://devcenter.heroku.com/articles/custom-domains", "s370d4beec29a": "https://devcenter.heroku.com/articles/runtime-principles", "s211f5fe76042": "https://devcenter.heroku.com/articles/generations", "s110a083f7129": "https://devcenter.heroku.com/articles/deploying-nodejs", "sa8e24c10ccd5": "https://devcenter.heroku.com/articles/private-spaces", "s401f4756733b": "https://help.heroku.com/J2R1S4T8/can-heroku-force-an-application-to-use-ssl-tls", "s478a51b74185": "https://devcenter.heroku.com/articles/aspnetcore-app-configuration", "scb3d72451e99": "https://devcenter.heroku.com/articles/understanding-tls-on-heroku", "sccd98f23baca": "https://devcenter.heroku.com/articles/acquiring-an-ssl-certificate" } }, "railway": { "name": "Railway documentation", "basis": "72354493198302677fb6be6c692dd75c64cae2c6", "sources": { "s93da6ccbfe58": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L39-L122", "s6d4dc8fb7784": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L220-L247", "s2bc94f13536b": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L150-L150", "sac1383056b65": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/domains/working-with-domains.md#L126-L146", "sbf08758c106a": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L16-L17", "s844d338bd121": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/public-networking/specs-and-limits.md#L37-L38", "s0462eef65b1b": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/application-failed-to-respond.md#L46-L51", "sa9d326046a7f": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/edge-networking.md#L40-L43", "sdd5e5ed1be54": "https://github.com/railwayapp/docs/blob/72354493198302677fb6be6c692dd75c64cae2c6/content/docs/networking/troubleshooting/405-method-not-allowed.md#L8-L18" } }, "nextjs-pinned": { "name": "Next.js pinned documentation", "basis": "v16.4.2", "sources": { "s0e3d2cd2248e": "https://github.com/vercel/next.js/blob/v16.4.2/docs/01-app/02-guides/environment-variables.mdx#L158-L164", "sf7cfdc553311": "https://github.com/vercel/next.js/blob/v16.4.2/docs/01-app/03-api-reference/05-config/01-next-config-js/productionBrowserSourceMaps.mdx#L8-L18" } }, "nextjs": { "name": "Next.js environment variables", "basis": "15", "sources": { "s22e4b70dab8b": "https://nextjs.org/docs/15/app/guides/environment-variables" } }, "express": { "name": "Express request protocol", "basis": "5.x", "sources": { "s5bc62e66824b": "https://expressjs.com/en/5x/api/request/#req.protocol" } }, "django": { "name": "Django proxy security setting", "basis": "6.1", "sources": { "sb5e26f65fd91": "https://docs.djangoproject.com/en/6.1/ref/settings/#secure-proxy-ssl-header" } }, "mysql-client": { "name": "MySQL client connection options", "basis": "8.4", "sources": { "s823c0bfaaafa": "https://dev.mysql.com/doc/refman/8.4/en/connection-options.html#option_general_ssl-mode" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s07916104bf60": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/show-error.md#L4-L19" } } }, "claims": { "managed-tls": {"text": "TLS and certificates vary by platform and deployment; Railway's edge proxy terminates TLS before its internal network; Fly passes TCP through without handlers; Heroku ACM supports no AAAA records or internal routing; Heroku pages conflict on Fir Private Space wildcard ACM. Guide advice: no certbot or extra TLS proxy where the platform terminates TLS and manages the required certificate.", "components": ["render", "fly", "vercel", "heroku", "fly-pinned", "railway"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853", "render:s1820ac471b6c", "render:s5b8400c2a2ba", "render:s22963948fc90", "vercel:s3e7832fd3031", "vercel:se314aa4b7f1e", "heroku:sa86eb06bbd4a", "heroku:s0c5bf300cef4", "heroku:sfbcf716168be", "fly-pinned:s254c8c8a5edd", "fly-pinned:s32999aa4c58c", "fly-pinned:sf4105f79b22b", "fly-pinned:sea5560c14d13", "fly-pinned:sb935e8478a30", "railway:s93da6ccbfe58", "railway:s6d4dc8fb7784", "railway:s2bc94f13536b", "railway:sa9d326046a7f", "fly:s74ed2822178a", "fly-pinned:sdf84e7f7ba34", "heroku:s211f5fe76042", "heroku:sa8e24c10ccd5", "heroku:scb3d72451e99", "heroku:sccd98f23baca"], "status": "REASONED"}, "fly-renewal": {"text": "Fly's pinned Cloudflare page says Let's Encrypt certificates renew automatically, unlike custom certificates, which must be manually renewed and re-imported; this guide's reading: the pinned custom-domain page calls Fly-managed certificates Let's Encrypt certificates, so they renew automatically. Unestablished: Fly renewal timing and renewal-failure handling.", "components": ["fly-pinned"], "sources": ["fly-pinned:sdf84e7f7ba34", "fly-pinned:sb935e8478a30", "fly-pinned:sf4105f79b22b", "fly-pinned:sdcd6b9620532"], "status": "REASONED"}, "application-auth": {"text": "Deployment protection does not replace app login, endpoint keys or application MFA; non-public endpoints still need their own access controls.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96"], "status": "REASONED"}, "vercel-login": {"text": "Vercel login can gate preview/deployment URLs on every plan and production domains with All Deployments; the 9 September 2026 change made that scope free including Hobby.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96", "vercel:s7836e043434a"], "status": "REASONED"}, "vercel-password": {"text": "At the recorded documentation date, Password Protection is unavailable on Hobby, a paid per-project add-on on Pro and included on Enterprise.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96"], "status": "REASONED"}, "secret-store": {"text": "Use platform environment/secret configuration, not committed .env files; rotate credentials exposed in repositories, build logs or client bundles.", "components": ["render", "fly", "vercel"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853"], "status": "REASONED"}, "public-env": {"text": "Only public values belong in browser-compiled environment variables such as NEXT_PUBLIC_.", "components": ["vercel", "nextjs"], "sources": ["vercel:s2b470a6bc853", "nextjs:s22e4b70dab8b"], "status": "REASONED"}, "https-redirect": {"text": "Enforce HTTPS: Render automatic (status unstated), Vercel mandatory 308, Fly HTTP handlers 301 with force_https, Railway GET 301 and, per its 405 troubleshooting page, POST 301 that clients typically follow as GET (HEAD unknown). Heroku's help article (no generation or runtime named) puts redirects in the app, not its router; its ASP.NET Core example's 308 is not a platform status.", "components": ["render", "fly", "vercel", "heroku", "fly-pinned", "railway"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853", "render:s5b8400c2a2ba", "vercel:s3e7832fd3031", "heroku:s0c5bf300cef4", "fly-pinned:s6e9a7292860d", "fly-pinned:sa63b828c93df", "fly-pinned:s4324a02dbc42", "fly-pinned:scf2e6f3bce02", "railway:sbf08758c106a", "railway:s844d338bd121", "render:s22963948fc90", "render:s4935cea7a2ea", "heroku:sa86eb06bbd4a", "railway:sdd5e5ed1be54", "heroku:s401f4756733b", "heroku:s478a51b74185"], "status": "REASONED"}, "https-redirect-advice": {"text": "Guide advice: implement HTTP-to-HTTPS redirects in the app unless deployed ingress is known to enforce HTTPS, with verified proxy trust and no assumed forwarded-header sanitization. Heroku's help article says its router (over)writes X-Forwarded-Proto; its routing page calls it untrusted. Render and Vercel pages searched 2026-11-04 do not say how a client-sent value is handled.", "components": ["render", "fly", "vercel", "heroku", "express", "django", "fly-pinned"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853", "heroku:s0c5bf300cef4", "express:s5bc62e66824b", "django:sb5e26f65fd91", "vercel:sc2d0fb3d1d6d", "fly-pinned:sdb820fc62ed4", "heroku:s401f4756733b", "render:s5b8400c2a2ba", "render:s22963948fc90", "render:s1820ac471b6c", "vercel:s3e7832fd3031", "render:s86db60c0804c", "render:s392f75ca4bcf", "render:sec77c58ba095", "vercel:s43a030ff5da7"], "status": "REASONED"}, "proxy-awareness": {"text": "Configure Express trust proxy or Django SECURE_PROXY_SSL_HEADER so secure cookies and redirects work behind the platform.", "components": ["render", "fly", "vercel", "express", "django"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853", "express:s5bc62e66824b", "django:sb5e26f65fd91"], "status": "REASONED"}, "listener-port": {"text": "Railway advises 0.0.0.0 on injected PORT and Heroku web processes bind PORT; listener rules vary by deployment, with IPv6/dual-stack recommended for Heroku Fir Private Spaces. Vercel's Express page allows a port listener; cited pages establish no Heroku Cedar listener address, or Vercel's deployed address, port or connection; this guide's advice: bind nothing else and do not open extra listeners.", "components": ["render", "fly", "vercel", "heroku", "fly-pinned", "railway"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853", "render:s1820ac471b6c", "vercel:s22498401f5d0", "heroku:s370d4beec29a", "heroku:s211f5fe76042", "fly-pinned:s62fec91543a1", "fly-pinned:s61c1e50d2c11", "railway:sac1383056b65", "railway:s0462eef65b1b", "heroku:s110a083f7129", "vercel:s2f00e51f9e6b", "vercel:s0ede3eac8f7e", "vercel:sacb72fc28bb6", "vercel:s1b69b9469345"], "status": "REASONED"}, "database-identity": {"text": "Where supported, require TLS certificate/hostname verification with PostgreSQL sslmode=verify-full or MySQL --ssl-mode=VERIFY_IDENTITY.", "components": ["render", "mysql-client"], "sources": ["render:se240afec8d2d", "mysql-client:s823c0bfaaafa"], "status": "REASONED"}, "render-internal-tls": {"text": "Render internal PostgreSQL TLS is optional and supports neither verify-ca nor verify-full; sslmode=require prevents plaintext fallback but is not identity verification.", "components": ["render"], "sources": ["render:se240afec8d2d"], "status": "REASONED"}, "database-network": {"text": "Render Postgres external access is open to any IP by default; prefer private connectivity and disable or source-restrict external access. Keep database credentials in the secret store.", "components": ["render"], "sources": ["render:se240afec8d2d"], "status": "REASONED"}, "spaces-creation": {"text": "This guide's advice: explicitly select and verify visibility when you create a Space. The pinned docs describe public, protected, and private visibility, public exposing source and running app and duplicates defaulting to private; the pinned Advanced Security lines (Team & Enterprise controls at the time of writing) establish no general creation default or retroactive Private only conversion.", "components": ["spaces", "spaces-pinned"], "sources": ["spaces:sda0c810c053d", "spaces-pinned:se2fb4e2a5c02", "spaces-pinned:s9d332f0940e2", "spaces-pinned:s969a28a4c44e"], "status": "REASONED"}, "spaces-public": {"text": "Public Spaces expose both source and running app.", "components": ["spaces"], "sources": ["spaces:sda0c810c053d"], "status": "REASONED"}, "spaces-protected": {"text": "Protected Spaces keep source private to owner/collaborators but expose the app via embed URL or custom domain; the recorded requirement is PRO for personal accounts or Team/Enterprise for organizations.", "components": ["spaces"], "sources": ["spaces:sda0c810c053d"], "status": "REASONED"}, "spaces-private": {"text": "Private Spaces restrict both source and running app to the owner and collaborators.", "components": ["spaces"], "sources": ["spaces:sda0c810c053d"], "status": "REASONED"}, "spaces-secrets": {"text": "Use Settings secrets for sensitive values, never repository/README metadata; variables remain publicly readable.", "components": ["spaces"], "sources": ["spaces:sda0c810c053d", "spaces:sd5ec2b2772e0"], "status": "REASONED"}, "spaces-static": {"text": "Static Spaces expose both variables and secrets to client JavaScript through window.huggingface.variables; keep backend/provider credentials in Docker/Gradio Spaces or another server-side service.", "components": ["spaces"], "sources": ["spaces:sda0c810c053d"], "status": "REASONED"}, "spaces-dev": {"text": "At the time of writing, the pinned Dev Mode docs require PRO or Team & Enterprise. The Dev Mode Docker image starts the application as a subprocess and starts an SSH server for local editors and terminals and a VS Code server for in-product VS Code Web. Dev Mode exposes the running Space as a standard SSH host; clients use the Space subdomain shown in the Dev Mode controls as the username at ssh.hf.space, and local VS Code uses Remote - SSH to connect to the same host. SSH requires adding the machine's SSH public key to the user's Hugging Face account; these docs do not establish which users or roles may connect, or the access control for in-product VS Code Web. This guide's assessment: access into the running container is materially more access than the app itself, so treat an unattended Dev Mode session the same as any other exposed admin path.", "components": ["spaces", "spaces-pinned"], "sources": ["spaces:sda0c810c053d", "spaces-pinned:s5d393b019bad", "spaces-pinned:sd1ae2b703a70"], "status": "REASONED"}, "verify-redirect": {"text": "This guide's HTTP HEAD / check requires 301/302/307/308 with an https:// Location; Vercel documents 308 and Fly HTTP handlers 301 with force_https. Spot-check proves neither every route/method nor secure-cookie proxy awareness; Render's HTTPS status (not its static-rule 301) and Railway HEAD status remain unestablished. Heroku's ASP.NET Core 308 is the example's choice, not a platform status.", "components": ["vercel", "render", "fly-pinned", "railway", "heroku", "curl"], "sources": ["vercel:s2b470a6bc853", "vercel:s3e7832fd3031", "render:s5b8400c2a2ba", "fly-pinned:s4324a02dbc42", "railway:sbf08758c106a", "railway:s844d338bd121", "heroku:s0c5bf300cef4", "render:s22963948fc90", "render:s4935cea7a2ea", "heroku:sa86eb06bbd4a", "railway:sdd5e5ed1be54", "curl:s07916104bf60", "heroku:s401f4756733b", "heroku:s478a51b74185"], "status": "REASONED", "verify": [1]}, "verify-app-auth": {"text": "Disable Vercel deployment protection or use its documented Protection Bypass for Automation, the x-vercel-protection-bypass header or query parameter carrying the project's generated secret, to isolate app auth; no Render or Fly equivalent is cited. This guide's expected reading: anonymous protected requests get the app's 401/403, valid app credentials a 2xx, and a platform SSO 401 is not app enforcement.", "components": ["vercel", "curl"], "sources": ["vercel:se6089d5c4e96", "vercel:s845513031876", "vercel:sc1588029e14e", "curl:s07916104bf60"], "status": "REASONED", "verify": [1]}, "verify-secrets": {"text": "Scan history, working tree and deployed client JS, source maps, generated config and runtime values for every credential type; no matches is evidence, not proof. The scan procedure is this guide's, delegated to secrets.md; the pinned Next.js v16.4.2 lines document only the NEXT_PUBLIC_ build-time inlining and off-by-default production browser source maps that motivate the bundle scan.", "components": ["render", "fly", "vercel", "nextjs-pinned"], "sources": ["render:s213783ba6209", "fly:s29502d5e8321", "vercel:s2b470a6bc853", "nextjs-pinned:s0e3d2cd2248e", "nextjs-pinned:sf7cfdc553311"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> egress-metadata.md ====================================================================== # Egress control and cloud metadata: keeping an agent from exfiltrating credentials Cloud metadata services are built-in addresses where a cloud server reads its settings and temporary credentials. This guide helps your AI assistant make misdirected requests to them fail, including IMDSv2 with a hop limit of 1 on AWS, the addresses blocked inside workloads not needing them, and default-deny egress. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/egress-metadata.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against AWS metadata and networking (no recorded version), Azure IMDS API 2025-04-07, iptables owner match v1.8.13, and nftables packet filtering v1.1.7; 1 further source is pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 27 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An AI agent, RAG fetcher, or webhook handler that retrieves URLs can be steered by a prompt injection into requesting the cloud metadata endpoint or an internal service instead of the URL it was meant to fetch. Making the fetcher refuse that request is application security; this guide covers the deployment-side backstop, metadata hardening so the endpoint rejects an unqualified request, plus default-deny egress so the request never leaves the workload at all. ## AWS: require IMDSv2 and cap the hop limit The instance metadata service listens on `169.254.169.254`, and on Nitro instances in an IPv6-enabled subnet also on `[fd00:ec2::254]` (IMDSv2-compatible); block or verify-disabled BOTH, since an IPv4-only control misses the IPv6 path. IMDSv2 requires a session token obtained with a `PUT` before any `GET` succeeds. The response to that `PUT`, the token itself, has a hop limit at the IP protocol level (1 on many launches, but launch parameters, account/Region settings, and the IMDSv2-only `ImdsSupport: v2.0` AMI setting can make it 2, so set it explicitly below rather than assuming). The token cannot travel more than one network hop back to the requester at hop limit 1 (per the AWS instance metadata service documentation). A containerized application sitting one hop from the host, for example behind the container network's own routing, will not receive the token and so cannot complete an IMDSv2 request; this is a limit on the token response reaching that far, not a guarantee that no proxy anywhere can reach the metadata service itself. Enforce IMDSv2 and keep the hop limit at 1: ```bash aws ec2 modify-instance-metadata-options \ --instance-id i-0123456789abcdef0 \ --http-tokens required \ --http-put-response-hop-limit 1 \ --http-endpoint enabled ``` This command does not touch `--http-protocol-ipv6`, so it stays at its current value; AWS keeps IPv6 IMDS disabled unless you enable it explicitly, so the safest stance is to leave it off (one fewer endpoint to defend). If a workload needs IPv6 IMDS, enable it deliberately and then block and probe `[fd00:ec2::254]` alongside the IPv4 address; `describe-instances` reports the state as `HttpProtocolIpv6`. With `--http-tokens required`, a request without a valid token receives a 401 from the service itself. ## GCP and Azure: a required header, but block the address anyway GCP's metadata server answers at `metadata.google.internal` or `169.254.169.254` and requires a `Metadata-Flavor: Google` header on every request; Google states the request and response never leave the physical host. Azure's Instance Metadata Service listens at the same non-routable address, `169.254.169.254`, reachable only from within the VM, and requires a `Metadata: true` header, rejecting any request that also carries an `X-Forwarded-For` header. Both header checks stop a naive `curl`, but neither stops a fetch that has been steered into adding the header, so block `169.254.169.254` (and the IPv6 metadata addresses where enabled: AWS `[fd00:ec2::254]`, GCP `[fd20:ce::254]` on IPv6-only instances) from workloads that have no legitimate reason to reach it, the same as any other internal address. Note this must be a workload/guest control (host firewall, CNI, or an egress proxy): like AWS security groups, GCP VPC and hierarchical firewall rules do not filter traffic to the metadata server, so a cloud-firewall rule alone will not block it. Scope any guest block carefully, since this address can also serve DNS. ## Default-deny egress - **Cloud firewall / security group egress rules** (the inbound side of the same tools is in [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md)): AWS security groups and Google Cloud VPC firewalls start with broad outbound allowances; Azure NSGs include outbound allow rules. At the time of writing, the fetched Azure page says subnets in new virtual networks created with API versions released after March 31, 2026, and through the portal default to private; existing virtual networks are unchanged. Those private subnets need explicit outbound connectivity to public endpoints. As this guide recommends, permit only DNS and the specific provider APIs the application calls. On AWS a security group is allow-only (it has no deny rules), so you must REMOVE its default allow-all egress rule and add narrow allows, and account for every other security group attached to the instance; a network ACL, which does support deny rules, is the subnet-level complement; its rules are stateless. AWS security groups do not filter traffic to or from the instance metadata address `169.254.169.254` (per the security groups documentation); they are not a backstop for metadata access. Keep egress rules for every other destination, and rely on IMDSv2, the hop limit, and disabling the metadata endpoint where it is unused to control reachability of the metadata service itself. - **Kubernetes NetworkPolicy egress**: a pod is unrestricted for egress until a `NetworkPolicy` with `Egress` in its `policyTypes` selects it, after which only listed destinations are reachable. Policies are ADDITIVE, so a second policy selecting the same pod can re-permit the metadata address; audit every policy that selects the workload, not just this one. Behaviour for `hostNetwork` pods is undefined, and it has no effect at all unless the cluster's network plugin (CNI) implements `NetworkPolicy`; confirm enforcement before relying on it. ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: agent-egress spec: podSelector: {matchLabels: {app: agent}} policyTypes: [Egress] egress: # DNS, or every hostname-based egress in the pod fails once this policy selects it: - to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}}] ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] # the API destinations the app actually needs (203.0.113.0/24 is a placeholder - replace it): - to: [{ipBlock: {cidr: 203.0.113.0/24}}] ports: [{protocol: TCP, port: 443}] ``` - **Docker network isolation**: an internal network has no default route out, and Docker's own firewall rules drop traffic leaving it, while containers on the network still reach each other: `docker network create --internal agent-net`. - **Host-process restriction**: IMDSv2 with hop limit 1 still lets a process ON the host obtain its own token, so on a host that runs the agent directly (not in a container one hop away) restrict the metadata address by user, as this guide recommends, with an `iptables` `OUTPUT` owner match, e.g. `iptables -A OUTPUT -d 169.254.169.254 -m owner ! --uid-owner aws -j DROP` (adapt the allowed uid). The `iptables` `OUTPUT` owner match does not cover traffic forwarded from a container, an nftables equivalent must be in a base chain hooked at the output path, or in a regular chain reached from that base chain by a jump, and you need the matching `ip6tables` rule for `[fd00:ec2::254]` where IPv6 IMDS is enabled. A forward proxy with a destination allowlist only enforces on traffic that actually passes through it, so it is not a substitute for a network block: pair it with a rule that stops the workload reaching metadata directly, or the workload just bypasses the proxy, and test the proxy path separately. ## Scope boundary Steering a fetcher into requesting the metadata address or an internal host is server-side request forgery, an application-security defect belonging to the code doing the fetching, not to this guide. IMDSv2, the header requirements above, and egress rules are deployment-side controls: they do not prevent the request from being attempted, they make the attempt fail. ## Verify REASONED: exit statuses and run locations, from the blocks below as written; no exposed/fixed run is recorded in this guide. Each block below is pasted whole and runs in one subshell, so one exit status covers every check in it: 0 for a pass, or for an observation the block cannot classify and says so; 1 for a finding; 2 when a guard refuses the block's input; and 3 for a failed or inconclusive check, a finding outranking an inconclusive result. Run the metadata blocks for your own cloud on the workload, the egress controls on the same workload, and the metadata-options check wherever your AWS CLI has credentials. REASONED: metadata headers, token flow, network blocks, egress controls and applied options; no exposed/fixed run is recorded in this guide. Expectations follow the cited cloud, NetworkPolicy and curl sources; the authoring environment has no authorized cloud VM or workload enforcement point. ```bash # --noproxy so an ambient http(s)_proxy/ALL_PROXY cannot answer in place of the metadata service (Azure # documents that IMDS queries must bypass proxies); timeouts so a blocked address does not hang. # AWS, on the workload. This tests the header requirement, which is not the control this guide # recommends; the next block tests the network block itself. ( # a subshell, so the exits below end only this check set +e # errexit off here, so a request that fails is classified below instead of ending the block # AWS, no token supplied: with --http-tokens required this returns 401 set -- "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 -w '%{http_code}\n' http://169.254.169.254/latest/meta-data/; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$2" case "$1:$2" in 0:401) echo "block exit 0; the tokenless request got 401, so tokens are required"; exit 0 ;; 0:2??) echo "FINDING: the tokenless request got $2, so the instance answers without an IMDSv2 token"; exit 1 ;; 0:*) echo "HTTP $2 is neither the 401 pass nor a 2xx finding; inconclusive"; exit 3 ;; 7:*|28:*) echo "block exit 0; curl exit $1, no answer, so the header requirement was not tested from here: the next block classifies reachability"; exit 0 ;; *) echo "curl exit $1: the tokenless request failed; inconclusive"; exit 3 ;; esac ) ``` REASONED: following block; metadata reachability and what each provider's probed path returns, per the cited AWS instance metadata, GCP metadata and Azure Instance Metadata Service documentation; no exposed/fixed run is recorded. In the network blocks below, a 200, or any HTTP response including a 401, means the metadata endpoint is reachable from the workload, whatever the header checks returned. Reachability is the finding. GCP's probed path returns a live access token (credential issuance). AWS's `/latest/meta-data/iam/security-credentials/` lists the attached role NAMES (append a role name to fetch the actual credentials), and the token PUT issues an IMDS session token, not IAM credentials, so treat the AWS checks as metadata reachability, not credential issuance. Azure's `/metadata/instance` likewise proves reachability only, its managed-identity token being a separate `/metadata/identity/oauth2/token` request. REASONED: following block; reading a failed probe and running the positive control, per the cited curl manual and AWS VPC Flow Logs limitations page; no exposed/fixed run is recorded. `http=000` on its own is not proof of a block: an endpoint that accepts the connection and then stalls produces it too. Read `time_connect`, which stays 0.000000 only when no connection completed, and corroborate with an enforcement-point deny record for THIS flow: a CNI NetworkPolicy drop counter or log, a host iptables or nftables DROP counter, or a packet capture. AWS VPC Flow Logs EXCLUDE traffic to and from 169.254.169.254, so they cannot corroborate a metadata block (they are fine for the other egress destinations in the negative control below). Run the network blocks BEFORE applying the block too (the positive control): they should reach metadata then, so a later refusal is the block working, not the endpoint being down. ```bash # REASONED: the AWS IMDSv2 token flow and metadata reachability, per the cited AWS instance metadata # documentation and curl manual; no exposed/fixed run is recorded. # AWS, on a workload that has no legitimate reason to reach metadata. A tokenless 401 proves the # header requirement, not a network block, so this does the real IMDSv2 two-step: PUT a token, then # GET WITH it. # Assumes a clean Bash shell with trusted startup files. # Tokens stay in positional parameters and curl's stdin, not named variables, environment or argv. # The same account and root can still read process memory; this does not erase earlier exports. # Stdin closes the argv channel only. No token is typed, and each subshell turns set -x tracing off # before it fetches a token, so shell history and set -x tracing do not record it. # This block cannot exit 0. A token issued over either address family is the finding (1). A failed # PUT, which is what a working block produces, is inconclusive (3) until an enforcement-point deny # record for this flow corroborates it. An HTTP error answer to the PUT (curl exit 22) also records # 3, but something answered it, so no network block is on that path. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 ( trap - DEBUG RETURN ERR set +x +a +e # Append curl's status after a newline, then split it from the response without a named variable. set -- "$(if curl -q -g -sf --noproxy '*' --connect-timeout 5 --max-time 5 -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" printf 'PUT exit: %s\n' "$2" case "$2" in 0) ;; 22) echo 'the token PUT got an HTTP error answer (curl exit 22), so the endpoint answered and no network block is on this path; inconclusive'; exit 3 ;; *) echo 'no usable token: PUT failed; inconclusive without an enforcement-point deny record'; exit 3 ;; esac case "$1" in ''|*[[:cntrl:]]*) echo 'empty or malformed token; inconclusive'; exit 3 ;; esac printf 'X-aws-ec2-metadata-token: %s\n' "$1" | curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 -w 'aws=%{http_code} time_connect=%{time_connect}\n' -H @- http://169.254.169.254/latest/meta-data/iam/security-credentials/ echo "FINDING: the metadata service issued a session token to this workload over IPv4, so it is reachable from here" exit 1 ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; *:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # Where IPv6 IMDS is enabled, the WHOLE probe is repeated over IPv6 (do not reuse the IPv4 token: # acquire one over IPv6 too, since IPv4 may be blocked while IPv6 is not). Where it is disabled, # this PUT fails and reports inconclusive. The bracketed literal is quoted, -q first then -g. ( trap - DEBUG RETURN ERR set +x +a +e set -- "$(if curl -q -g -sf --noproxy '*' --connect-timeout 5 --max-time 5 -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' 'http://[fd00:ec2::254]/latest/api/token'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" printf 'PUT6 exit: %s\n' "$2" case "$2" in 0) ;; 22) echo 'the IPv6 token PUT got an HTTP error answer (curl exit 22), so the endpoint answered and no network block is on this path; inconclusive'; exit 3 ;; *) echo 'no usable IPv6 token: PUT failed; inconclusive without an enforcement-point deny record'; exit 3 ;; esac case "$1" in ''|*[[:cntrl:]]*) echo 'empty or malformed IPv6 token; inconclusive'; exit 3 ;; esac printf 'X-aws-ec2-metadata-token: %s\n' "$1" | curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 -w 'aws6=%{http_code} time_connect=%{time_connect}\n' -H @- 'http://[fd00:ec2::254]/latest/meta-data/' echo "FINDING: the metadata service issued a session token to this workload over IPv6, so it is reachable from here" exit 1 ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; *:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the GET lines above are printed for reading, and a 3 needs the deny record before you read it as a block" exit "$1" ) ``` ```bash # REASONED: the GCP header requirement and metadata reachability, per the cited GCP metadata # documentation and curl manual; no exposed/fixed run is recorded. # GCP, on a workload that has no legitimate reason to reach metadata. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a request that fails is classified below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # GCP, no Metadata-Flavor header: must fail, must not return metadata. This tests the header # requirement, which is not the control this guide recommends. set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 -w '%{http_code}\n' http://169.254.169.254/computeMetadata/v1/instance/; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # status, curl's exit status, then the HTTP code printf '%s\n' "$3" case "$2:$3" in 0:2??) echo "FINDING: the request without the header got $3, so it returned metadata"; set -- 1 ;; 0:*|7:*|28:*) ;; # refused or unanswered: the request failed, as it must *) echo "curl exit $2: the request without the header failed for another reason; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac # The network block itself, WITH the header the service requires (send the required header). set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w 'gcp=%{http_code} time_connect=%{time_connect}\n' -H 'Metadata-Flavor: Google' \ http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # status, curl's exit status, then the gcp= line printf '%s\n' "$3" case "$2:$3" in 0:*) echo "FINDING: the metadata endpoint answered the request with the header, so it is reachable from this workload"; set -- 1 ;; 7:*time_connect=0.000000|28:*time_connect=0.000000) echo "curl exit $2 and no connection completed: consistent with a block; confirm it with an enforcement-point deny record for this flow" ;; *) echo "curl exit $2 is not proof of a block, because a connection completed or the request failed for another reason; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac # IPv6-only GCP instances also answer at [fd20:ce::254]; elsewhere this request finds no route, # which reads the same as a block. The bracketed literal is quoted, -q first then -g. set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 -w 'gcp6=%{http_code} time_connect=%{time_connect}\n' -H 'Metadata-Flavor: Google' 'http://[fd20:ce::254]/computeMetadata/v1/instance/'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # status, curl's exit status, then the gcp6= line printf '%s\n' "$3" case "$2:$3" in 0:*) echo "FINDING: the IPv6 metadata endpoint answered the request with the header, so it is reachable from this workload"; set -- 1 ;; 7:*time_connect=0.000000|28:*time_connect=0.000000) echo "curl exit $2 and no connection completed over IPv6: consistent with a block or with no IPv6 metadata address; confirm which" ;; *) echo "curl exit $2 over IPv6 is not proof of a block, because a connection completed or the request failed for another reason; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac echo "block exit $1; a 0 still needs the enforcement-point deny record described above before you read it as a block" exit "$1" ) ``` ```bash # REASONED: the Azure header requirement and metadata reachability, per the cited Azure Instance # Metadata Service documentation and curl manual; no exposed/fixed run is recorded. # Azure, on a workload that has no legitimate reason to reach metadata. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a request that fails is classified below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Azure, no Metadata: true header: must fail, must not return metadata. This tests the header # requirement, which is not the control this guide recommends. set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 -w '%{http_code}\n' 'http://169.254.169.254/metadata/instance?api-version=2025-04-07'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # status, curl's exit status, then the HTTP code printf '%s\n' "$3" case "$2:$3" in 0:2??) echo "FINDING: the request without the header got $3, so it returned metadata"; set -- 1 ;; 0:*|7:*|28:*) ;; # refused or unanswered: the request failed, as it must *) echo "curl exit $2: the request without the header failed for another reason; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac # The network block itself, WITH the header the service requires (send Metadata: true). set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w 'azure=%{http_code} time_connect=%{time_connect}\n' -H 'Metadata: true' \ 'http://169.254.169.254/metadata/instance?api-version=2025-04-07'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # status, curl's exit status, then the azure= line printf '%s\n' "$3" case "$2:$3" in 0:*) echo "FINDING: the metadata endpoint answered the request with the header, so it is reachable from this workload"; set -- 1 ;; 7:*time_connect=0.000000|28:*time_connect=0.000000) echo "curl exit $2 and no connection completed: consistent with a block; confirm it with an enforcement-point deny record for this flow" ;; *) echo "curl exit $2 is not proof of a block, because a connection completed or the request failed for another reason; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac echo "block exit $1; a 0 still needs the enforcement-point deny record described above before you read it as a block" exit "$1" ) ``` ```bash # REASONED: the egress allow and deny controls, per the cited curl manual and AWS STS pages; no # exposed/fixed run is recorded. # positive control: use any host on this workload's egress allow list; the AWS STS global endpoint # is one example when allowed. If it is not allowed, substitute an allowed URL, such as a Regional # STS endpoint, inside the single quotes on the set -- line. # The fetched STS pages document the global endpoint and authentication errors, but do not state # the response to this bare unauthenticated GET. Receiving an HTTP response is this guide's REASONED # expectation; the exact status and body of the bare unauthenticated GET are unknown. A 000 response # fails this control. --noproxy '*' on these two controls tests DIRECT egress (a network rule), # not a forward proxy's allow list; where a proxy is the egress control, test through it separately. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( set -- SECURECONFIG_EGRESS_ALLOW_URL 'https://sts.amazonaws.com/' [ "${1-}" = SECURECONFIG_EGRESS_ALLOW_URL ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 URL; not probing"; exit 2; } [ -n "${1-}" ] || { echo "substitute a URL on this workload's egress allow list; not probing"; exit 2; } set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' -w '%{http_code}\n' --max-time 5 "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # the URL, curl's exit status, then the HTTP code printf '%s\n' "$3" case "$2:$3" in 0:000) echo "no HTTP response from the allowed URL, so this positive control failed and the negative control below proves nothing; inconclusive"; exit 3 ;; 0:*) exit 0 ;; # an HTTP response, whatever its code: the control passed *) echo "curl exit $2: no HTTP response from the allowed URL, so this positive control failed and the negative control below proves nothing; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # negative control: a known-live host outside the egress allow list, judged by curl's exit status, # not by matching text in its output, since a successful connection also lacks the string # "Could not resolve host" and so would otherwise be misreported as blocked. # Status: 1 when the connection succeeded, 0 for exit 7 or 28 with time_connect 0.000000, which # still needs the deny-record evidence it prints, and 3 for exit 7 or 28 after a connection # completed, for curl exit 6 or for an unexpected code ( # a subshell, so the exit below ends only this check # guard-conventions: allow C4-EXAMPLE-HOST egress negative control: example.com, served by IANA, is a known-live host outside the egress allow list, and this probe must fail when egress is enforced set -- "$(if curl -q -g -s --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null -w 'time_connect=%{time_connect}\n' https://example.com/; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%%$'\n'*}" # curl's exit status, then the time_connect= line printf '%s\n' "$2" case "$1:$2" in 0:*) echo "FAIL: connected to a host outside the allow list, egress is not enforced" exit 1 ;; 6:*) echo "inconclusive: DNS resolution failed (curl exit 6), confirm this host still resolves before retrying" exit 3 ;; 7:time_connect=0.000000|28:time_connect=0.000000) echo "request failed or timed out (curl exit $1): inconclusive on its own. curl cannot say why it" echo "failed, and --max-time can expire after the connection already succeeded, in which case the" echo "time_connect printed above is non-zero. Treat this as blocked only when time_connect stayed" echo "0.000000 AND the enforcement point recorded the deny: a VPC Flow Logs REJECT for this flow," echo "or the CNI's NetworkPolicy drop log or counter" exit 0 ;; 7:*|28:*) echo "curl exit $1 after a connection completed (time_connect above is not 0.000000), so this is not evidence of a block; inconclusive" exit 3 ;; *) echo "unexpected curl exit code $1, investigate before treating this as a pass" exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the positive control's HTTP code is not classified further, and a negative-control 0 for curl exit 7 or 28 with time_connect 0.000000 still needs the enforcement-point deny record, so read them as above" exit "$1" ) ``` ```bash # REASONED: the applied metadata options, per the cited AWS instance metadata options and # modify-instance-metadata-options pages; no exposed/fixed run is recorded. # confirm the metadata options actually took effect: run this wherever your AWS CLI has credentials # for the account, not on the workload. It prints HttpTokens, HttpPutResponseHopLimit, # HttpProtocolIpv6 and State, in that order, separated by tabs. ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a command that fails is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_INSTANCE_ID' # the instance ID, such as i-0123456789abcdef0: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|-*|*[[:space:][:cntrl:]]*) echo "substitute your instance ID inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$(if aws ec2 describe-instances --instance-ids "$1" \ --query 'Reservations[].Instances[].MetadataOptions.[HttpTokens,HttpPutResponseHopLimit,HttpProtocolIpv6,State]' \ --output text; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%$'\n'*}" # the CLI's exit status, then its output set -- "$1" "${2%$'\n'}" # the exit status, then the four values without the output's final newline printf '%s\n' "$2" # expect HttpTokens: required, HttpPutResponseHopLimit: 1, HttpProtocolIpv6: disabled (the safe stance; # if you deliberately enabled IPv6 IMDS it reads enabled, and you must then block and probe # [fd00:ec2::254] as well), and State: applied (State: pending means the change is not yet in effect); # then re-run the workload-side probes above to confirm the live behaviour. case "$1:$2" in 0:optional$'\t'*) echo "FINDING: HttpTokens is optional, so the instance answers metadata requests without an IMDSv2 token"; set -- 1 ;; 0:required$'\t'1$'\t'*$'\t'applied) set -- 0 ;; 0:required$'\t'1$'\t'*) echo "State is not applied (pending means the change is not yet in effect); inconclusive"; set -- 3 ;; 0:required$'\t'[0123456789]*) echo "FINDING: HttpPutResponseHopLimit is above 1, so a token response can reach a container an extra hop away"; set -- 1 ;; 0:*) echo "the metadata options above are not one instance's four values in the expected form; inconclusive"; set -- 3 ;; *) echo "aws ec2 describe-instances exit $1: could not read the metadata options; inconclusive"; set -- 3 ;; esac echo "block exit $1; HttpProtocolIpv6 is not classified, so read it as above" exit "$1" ) ``` ## Sources (checked September 2026) - AWS EC2 instance metadata service configuration (IMDSv2, hop limit, the `[fd00:ec2::254]` IPv6 endpoint): https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html - AWS instance metadata options (hop-limit defaults incl. `ImdsSupport: v2.0`, IPv6): https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html - AWS VPC Flow Logs limitations (metadata `169.254.169.254` traffic is not logged): https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs-limitations.html - GCP querying metadata (required header, IPv6 `[fd20:ce::254]`): https://docs.cloud.google.com/compute/docs/metadata/querying-metadata - AWS VPC security groups (traffic security groups do not filter, including instance metadata): https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html - AWS CLI `modify-instance-metadata-options`: https://docs.aws.amazon.com/cli/latest/reference/ec2/modify-instance-metadata-options.html - GCP metadata server overview: https://docs.cloud.google.com/compute/docs/metadata/overview - GCP VPC firewall (a VM reaches metadata regardless of firewall rules): https://docs.cloud.google.com/firewall/docs/firewalls#metadata-server - Azure Instance Metadata Service (API version 2025-04-07): https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service - Kubernetes NetworkPolicy: https://kubernetes.io/docs/concepts/services-networking/network-policies/ - Docker network create (`--internal`): https://docs.docker.com/reference/cli/docker/network/create/ - curl manual (exit 7 "Failed to connect to host", exit 28 "Operation timeout", `--connect-timeout`, and the `time_connect` write-out variable): https://curl.se/docs/manpage.html - AWS security-group rules (allow-only, default egress, combined groups; checked October 2026): https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html#security-group-rule-characteristics - AWS network ACL basics (subnet deny, stateless; checked October 2026): https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html#nacl-basics - GCP egress cases (implied allow egress to any destination; checked October 2026): https://docs.cloud.google.com/firewall/docs/firewalls#egress_cases - Azure NSG default outbound rules (8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f, lines 67-85; checked October 2026): https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/virtual-network/network-security-groups-overview.md#L67-L85 - Azure private subnet outbound access (checked October 2026): https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/default-outbound-access#retirement-of-default-outbound-access-scope-and-impact - iptables owner match (v1.8.13, lines 1-10; checked October 2026): https://git.netfilter.org/iptables/tree/extensions/libxt_owner.man?h=v1.8.13#n1 - iptables and ip6tables commands (v1.8.13, lines 28-32, 76-77, 99-102, 152-155, 293-304, 306-315; checked October 2026): https://git.netfilter.org/iptables/tree/iptables/iptables.8.in?h=v1.8.13#n28 - nftables output hook (v1.1.7, lines 230-234; checked October 2026): https://git.netfilter.org/nftables/tree/doc/nft.txt?h=v1.1.7#n230 - nftables chains and hooks (v1.1.7, lines 387-419; checked October 2026): https://git.netfilter.org/nftables/tree/doc/nft.txt?h=v1.1.7#n387 - nftables socket UID (v1.1.7, lines 92-94; checked October 2026): https://git.netfilter.org/nftables/tree/doc/primary-expression.txt?h=v1.1.7#n92 - AWS STS MissingAuthenticationToken (checked October 2026): https://docs.aws.amazon.com/STS/latest/APIReference/CommonErrors.html#CommonErrors-MissingAuthenticationToken - AWS STS global endpoint (checked October 2026): https://docs.aws.amazon.com/general/latest/gr/sts.html#sts_region ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - aws: AWS metadata and networking (unknown) - gcp: Google Cloud metadata and firewall (unknown) - azure: Azure IMDS API (2025-04-07) - azure-nsg: Azure network security groups (8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f) - azure-outbound: Azure outbound access (unknown) - kubernetes: Kubernetes NetworkPolicy (unknown) - docker: Docker network create (unknown) - iptables: iptables owner match (v1.8.13) - nftables: nftables packet filtering (v1.1.7) - curl: curl documentation (unknown) Claims (full source mapping in the JSON block below): - aws-endpoints (REASONED; aws): IMDS uses 169.254.169.254 and, on Nitro in IPv6-enabled subnets, [fd00:ec2::254]; block or verify-disabled both paths. - aws-token (REASONED; aws): IMDSv2 requires a PUT-issued session token on subsequent GETs; required tokens make missing/invalid-token requests return 401. - hop-default (REASONED; aws): PUT response hop limit is often 1, but launch/account/Region options and ImdsSupport: v2.0 AMIs can make it 2; set it explicitly. - hop-control (REASONED; aws): Set required tokens, hop limit 1 and endpoint enabled; a container an extra routing hop away cannot receive the token, but this does not exclude every proxy path. - aws-ipv6 (REASONED; aws): The modify command leaves HttpProtocolIpv6 unchanged; IPv6 IMDS defaults disabled. If deliberately enabled, block and probe it too. - gcp-header (REASONED; gcp): GCP metadata uses metadata.google.internal or 169.254.169.254, requires Metadata-Flavor: Google and stays on the physical host. - gcp-ipv6 (REASONED; gcp): IPv6-only GCP instances also expose metadata at [fd20:ce::254]; include it in workload restrictions. - azure-header (REASONED; azure): Azure IMDS is VM-local at 169.254.169.254, requires Metadata: true and rejects X-Forwarded-For; the example instance API uses 2025-04-07. - guest-block (REASONED; gcp, azure, aws): Headers do not stop a fetcher that can add them; use guest/workload metadata restrictions, accounting for DNS use of the address and all enabled IPv6 endpoints. - gcp-firewall (REASONED; gcp): GCP VPC and hierarchical firewall rules do not block metadata-server traffic; use a host firewall, CNI or enforced egress proxy path. - cloud-egress (REASONED; aws, gcp, azure-nsg, azure-outbound): AWS security groups and Google Cloud VPC firewalls start with broad outbound allowances; Azure NSGs include outbound allow rules. At the time of writing, the fetched Azure page says subnets in new virtual networks created with API versions released after March 31, 2026, and through the portal default to private; existing virtual networks are unchanged. Those private subnets need explicit outbound connectivity to public endpoints. This guide recommends permitting only DNS and required provider APIs. - aws-groups (REASONED; aws): Security groups are allow-only and additive across attached groups; remove default allow-all egress and add narrow rules. A subnet network ACL supplies deny rules but is stateless. - aws-metadata-bypass (REASONED; aws): AWS security groups do not filter 169.254.169.254; use IMDSv2/hop limit or disable unused metadata, keeping egress rules for other destinations. - policy-default (REASONED; kubernetes): Pod egress is unrestricted until a selecting policy includes Egress; policies add allowances, so audit every selecting policy. - policy-limits (REASONED; kubernetes): NetworkPolicy needs an enforcing CNI and has undefined hostNetwork behaviour; confirm enforcement before relying on it. - policy-dns (REASONED; kubernetes): agent-egress selects app: agent and allows UDP/TCP 53 to kube-system pods labeled k8s-app: kube-dns. - policy-api (REASONED; kubernetes): The API allowance permits TCP 443 to 203.0.113.0/24 as a replaceable destination placeholder. - docker-internal (REASONED; docker): An internal Docker network has no default route out and firewall rules drop external traffic while peer containers communicate. - host-owner (REASONED; aws, iptables, nftables): Hop limit 1 still permits host processes; this guide recommends OUTPUT owner filtering and matching IPv6 rules. The owner match applies to locally generated packets, not forwarded containers; an nftables equivalent must be in a base chain hooked at the output path, or in a regular chain reached from that base chain by a jump. - proxy-bypass (REASONED; curl): A destination-allowlist proxy controls only traversing traffic; pair it with a direct metadata block and test its path separately. - verify-headers (REASONED; aws, gcp, azure): Tokenless AWS must return 401; headerless GCP/Azure must not return metadata. These header checks alone do not demonstrate network isolation. - verify-token (REASONED; aws, curl): AWS network checks acquire a token then GET with it, separately over IPv4/IPv6; failed PUT needs enforcement evidence. Token/session reachability is not IAM credential issuance. - verify-provider-paths (REASONED; gcp, azure): Required-header GCP token and Azure instance requests test reachability; any HTTP response means reachable. GCP returns credentials; Azure instance data is separate from managed-identity tokens. - verify-deny (REASONED; curl, aws): Run positive metadata controls before blocking; http=000 alone is inconclusive. Require no completed connection plus a matching enforcement deny record; metadata is excluded from AWS VPC Flow Logs. - verify-allow (REASONED; curl, aws): A positive-control bare unauthenticated GET can target any host on this workload's egress allow list; the fetched STS pages document the global endpoint and authentication errors, and that endpoint is one example when allowed. Receiving an HTTP response is this guide's REASONED expectation; the exact status and body of the bare unauthenticated GET are unknown. --noproxy tests direct egress, so proxy allowlists require separate probes. - verify-negative (REASONED; curl): Known-live disallowed egress must fail: curl success is a finding (block status 1), exit 6 is DNS-inconclusive (block status 3), and 7/28 need zero time_connect plus correlated deny evidence. - verify-options (REASONED; aws): Read MetadataOptions for required tokens, hop limit 1, intended IPv6 state and State applied rather than pending, then repeat workload probes. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "a57fad00a152a13dc92f3ae4b35ab36434878f88ea76a082bf8dcd3426d10cf7", "components": { "aws": { "name": "AWS metadata and networking", "basis": "unknown", "sources": { "s82aca706445c": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html", "s8e0b69b2747a": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html", "s5de42a587ed0": "https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs-limitations.html", "s6761ddae28b8": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html", "sb843930c4944": "https://docs.aws.amazon.com/cli/latest/reference/ec2/modify-instance-metadata-options.html", "sba085af3d477": "https://docs.aws.amazon.com/vpc/latest/userguide/security-group-rules.html#security-group-rule-characteristics", "s0604de50506f": "https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html#nacl-basics", "s9e5244cd7271": "https://docs.aws.amazon.com/STS/latest/APIReference/CommonErrors.html#CommonErrors-MissingAuthenticationToken", "sb525b61fd496": "https://docs.aws.amazon.com/general/latest/gr/sts.html#sts_region" } }, "gcp": { "name": "Google Cloud metadata and firewall", "basis": "unknown", "sources": { "sdb98c49a5970": "https://docs.cloud.google.com/compute/docs/metadata/querying-metadata", "se75aedd46ef0": "https://docs.cloud.google.com/compute/docs/metadata/overview", "see9b515c5ffd": "https://docs.cloud.google.com/firewall/docs/firewalls#metadata-server", "s0d0c82f83f1d": "https://docs.cloud.google.com/firewall/docs/firewalls#egress_cases" } }, "azure": { "name": "Azure IMDS API", "basis": "2025-04-07", "sources": { "s5301638a3c81": "https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service" } }, "azure-nsg": { "name": "Azure network security groups", "basis": "8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f", "sources": { "scd8e9e55f3b7": "https://github.com/MicrosoftDocs/azure-docs/blob/8ef1ae8a3759ddb2ed2e437bf38083aa2268c39f/articles/virtual-network/network-security-groups-overview.md#L67-L85" } }, "azure-outbound": { "name": "Azure outbound access", "basis": "unknown", "sources": { "sdf1b3f364763": "https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/default-outbound-access#retirement-of-default-outbound-access-scope-and-impact" } }, "kubernetes": { "name": "Kubernetes NetworkPolicy", "basis": "unknown", "sources": { "s953450b4076f": "https://kubernetes.io/docs/concepts/services-networking/network-policies/" } }, "docker": { "name": "Docker network create", "basis": "unknown", "sources": { "sc308b8f1039c": "https://docs.docker.com/reference/cli/docker/network/create/" } }, "iptables": { "name": "iptables owner match", "basis": "v1.8.13", "sources": { "s35430e42cc22": "https://git.netfilter.org/iptables/tree/extensions/libxt_owner.man?h=v1.8.13#n1", "s8033be9cc6c1": "https://git.netfilter.org/iptables/tree/iptables/iptables.8.in?h=v1.8.13#n28" } }, "nftables": { "name": "nftables packet filtering", "basis": "v1.1.7", "sources": { "se1acebec90fb": "https://git.netfilter.org/nftables/tree/doc/nft.txt?h=v1.1.7#n230", "s85ee6e57ed0c": "https://git.netfilter.org/nftables/tree/doc/nft.txt?h=v1.1.7#n387", "s60417f1df3e4": "https://git.netfilter.org/nftables/tree/doc/primary-expression.txt?h=v1.1.7#n92" } }, "curl": { "name": "curl documentation", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "aws-endpoints": {"text": "IMDS uses 169.254.169.254 and, on Nitro in IPv6-enabled subnets, [fd00:ec2::254]; block or verify-disabled both paths.", "components": ["aws"], "sources": ["aws:s82aca706445c", "aws:s8e0b69b2747a"], "status": "REASONED"}, "aws-token": {"text": "IMDSv2 requires a PUT-issued session token on subsequent GETs; required tokens make missing/invalid-token requests return 401.", "components": ["aws"], "sources": ["aws:s82aca706445c"], "status": "REASONED"}, "hop-default": {"text": "PUT response hop limit is often 1, but launch/account/Region options and ImdsSupport: v2.0 AMIs can make it 2; set it explicitly.", "components": ["aws"], "sources": ["aws:s82aca706445c", "aws:s8e0b69b2747a"], "status": "REASONED"}, "hop-control": {"text": "Set required tokens, hop limit 1 and endpoint enabled; a container an extra routing hop away cannot receive the token, but this does not exclude every proxy path.", "components": ["aws"], "sources": ["aws:s82aca706445c", "aws:sb843930c4944"], "status": "REASONED"}, "aws-ipv6": {"text": "The modify command leaves HttpProtocolIpv6 unchanged; IPv6 IMDS defaults disabled. If deliberately enabled, block and probe it too.", "components": ["aws"], "sources": ["aws:s8e0b69b2747a", "aws:sb843930c4944"], "status": "REASONED"}, "gcp-header": {"text": "GCP metadata uses metadata.google.internal or 169.254.169.254, requires Metadata-Flavor: Google and stays on the physical host.", "components": ["gcp"], "sources": ["gcp:sdb98c49a5970", "gcp:se75aedd46ef0"], "status": "REASONED"}, "gcp-ipv6": {"text": "IPv6-only GCP instances also expose metadata at [fd20:ce::254]; include it in workload restrictions.", "components": ["gcp"], "sources": ["gcp:sdb98c49a5970"], "status": "REASONED"}, "azure-header": {"text": "Azure IMDS is VM-local at 169.254.169.254, requires Metadata: true and rejects X-Forwarded-For; the example instance API uses 2025-04-07.", "components": ["azure"], "sources": ["azure:s5301638a3c81"], "status": "REASONED"}, "guest-block": {"text": "Headers do not stop a fetcher that can add them; use guest/workload metadata restrictions, accounting for DNS use of the address and all enabled IPv6 endpoints.", "components": ["gcp", "azure", "aws"], "sources": ["gcp:sdb98c49a5970", "gcp:see9b515c5ffd", "azure:s5301638a3c81", "aws:s82aca706445c"], "status": "REASONED"}, "gcp-firewall": {"text": "GCP VPC and hierarchical firewall rules do not block metadata-server traffic; use a host firewall, CNI or enforced egress proxy path.", "components": ["gcp"], "sources": ["gcp:see9b515c5ffd"], "status": "REASONED"}, "cloud-egress": {"text": "AWS security groups and Google Cloud VPC firewalls start with broad outbound allowances; Azure NSGs include outbound allow rules. At the time of writing, the fetched Azure page says subnets in new virtual networks created with API versions released after March 31, 2026, and through the portal default to private; existing virtual networks are unchanged. Those private subnets need explicit outbound connectivity to public endpoints. This guide recommends permitting only DNS and required provider APIs.", "components": ["aws", "gcp", "azure-nsg", "azure-outbound"], "sources": ["aws:s6761ddae28b8", "aws:sba085af3d477", "gcp:s0d0c82f83f1d", "azure-nsg:scd8e9e55f3b7", "azure-outbound:sdf1b3f364763"], "status": "REASONED"}, "aws-groups": {"text": "Security groups are allow-only and additive across attached groups; remove default allow-all egress and add narrow rules. A subnet network ACL supplies deny rules but is stateless.", "components": ["aws"], "sources": ["aws:s6761ddae28b8", "aws:sba085af3d477", "aws:s0604de50506f"], "status": "REASONED"}, "aws-metadata-bypass": {"text": "AWS security groups do not filter 169.254.169.254; use IMDSv2/hop limit or disable unused metadata, keeping egress rules for other destinations.", "components": ["aws"], "sources": ["aws:s6761ddae28b8", "aws:s8e0b69b2747a"], "status": "REASONED"}, "policy-default": {"text": "Pod egress is unrestricted until a selecting policy includes Egress; policies add allowances, so audit every selecting policy.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "policy-limits": {"text": "NetworkPolicy needs an enforcing CNI and has undefined hostNetwork behaviour; confirm enforcement before relying on it.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "policy-dns": {"text": "agent-egress selects app: agent and allows UDP/TCP 53 to kube-system pods labeled k8s-app: kube-dns.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "policy-api": {"text": "The API allowance permits TCP 443 to 203.0.113.0/24 as a replaceable destination placeholder.", "components": ["kubernetes"], "sources": ["kubernetes:s953450b4076f"], "status": "REASONED"}, "docker-internal": {"text": "An internal Docker network has no default route out and firewall rules drop external traffic while peer containers communicate.", "components": ["docker"], "sources": ["docker:sc308b8f1039c"], "status": "REASONED"}, "host-owner": {"text": "Hop limit 1 still permits host processes; this guide recommends OUTPUT owner filtering and matching IPv6 rules. The owner match applies to locally generated packets, not forwarded containers; an nftables equivalent must be in a base chain hooked at the output path, or in a regular chain reached from that base chain by a jump.", "components": ["aws", "iptables", "nftables"], "sources": ["aws:s82aca706445c", "iptables:s35430e42cc22", "iptables:s8033be9cc6c1", "nftables:se1acebec90fb", "nftables:s85ee6e57ed0c", "nftables:s60417f1df3e4"], "status": "REASONED"}, "proxy-bypass": {"text": "A destination-allowlist proxy controls only traversing traffic; pair it with a direct metadata block and test its path separately.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED"}, "verify-headers": {"text": "Tokenless AWS must return 401; headerless GCP/Azure must not return metadata. These header checks alone do not demonstrate network isolation.", "components": ["aws", "gcp", "azure"], "sources": ["aws:s82aca706445c", "gcp:sdb98c49a5970", "azure:s5301638a3c81"], "status": "REASONED", "verify": [1, 3, 4]}, "verify-token": {"text": "AWS network checks acquire a token then GET with it, separately over IPv4/IPv6; failed PUT needs enforcement evidence. Token/session reachability is not IAM credential issuance.", "components": ["aws", "curl"], "sources": ["aws:s82aca706445c", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2]}, "verify-provider-paths": {"text": "Required-header GCP token and Azure instance requests test reachability; any HTTP response means reachable. GCP returns credentials; Azure instance data is separate from managed-identity tokens.", "components": ["gcp", "azure"], "sources": ["gcp:sdb98c49a5970", "azure:s5301638a3c81"], "status": "REASONED", "verify": [3, 4]}, "verify-deny": {"text": "Run positive metadata controls before blocking; http=000 alone is inconclusive. Require no completed connection plus a matching enforcement deny record; metadata is excluded from AWS VPC Flow Logs.", "components": ["curl", "aws"], "sources": ["curl:s2b2686afaf41", "aws:s5de42a587ed0"], "status": "REASONED", "verify": [2, 3, 4]}, "verify-allow": {"text": "A positive-control bare unauthenticated GET can target any host on this workload's egress allow list; the fetched STS pages document the global endpoint and authentication errors, and that endpoint is one example when allowed. Receiving an HTTP response is this guide's REASONED expectation; the exact status and body of the bare unauthenticated GET are unknown. --noproxy tests direct egress, so proxy allowlists require separate probes.", "components": ["curl", "aws"], "sources": ["curl:s2b2686afaf41", "aws:s9e5244cd7271", "aws:sb525b61fd496"], "status": "REASONED", "verify": [5]}, "verify-negative": {"text": "Known-live disallowed egress must fail: curl success is a finding (block status 1), exit 6 is DNS-inconclusive (block status 3), and 7/28 need zero time_connect plus correlated deny evidence.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [5]}, "verify-options": {"text": "Read MetadataOptions for required tokens, hop limit 1, intended IPv6 state and State applied rather than pending, then repeat workload probes.", "components": ["aws"], "sources": ["aws:s8e0b69b2747a", "aws:sb843930c4944"], "status": "REASONED", "verify": [6]} } } ```` ====================================================================== ==> gpu-clouds.md ====================================================================== # Rented GPUs: RunPod, Vast.ai, Lambda, Modal RunPod, Vast.ai, Lambda and Modal rent GPU computing, from whole machines to serverless functions. This guide helps your AI assistant expose only what you intend, including how each platform makes a port public, and every listener bound privately or protected by its own login or an authenticating gateway. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/gpu-clouds.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against RunPod documentation (no recorded version), Jupyter Server contents authentication v2.18.0, Ollama v0.34.2, iproute2 ss manual v6.12.0, and Linux network namespaces manual man-pages-5.13; 3 further sources are pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-10-21; documentation checked September 2026. The guide records 44 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Unlike the major clouds, most rented-GPU platforms put no default-deny firewall in front of the box. How a bound port becomes public differs by platform: on RunPod you expose a port (HTTP or TCP), on Vast.ai a launch mode or an added port maps it to a public port, on Lambda you open a firewall rule (its inbound firewall is deny-by-default), and on Modal it depends on the serverless construct. Raw port publication and a firewall allowance add NO authentication of their own, so a listener exposed that way is reachable with only whatever auth it has itself. An authenticating platform proxy is the exception (Modal's Endpoints and Servers require proxy auth by default and Shared Endpoints always do, only a public Web endpoint does not, and Vast.ai's Instance Portal gates the routes it proxies), but do not assume one is in front of a given port. Treat every listener the same way you would on your own hardware: bind it privately or authenticate it, per [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) and [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md). ## RunPod Exposed HTTP ports (the "Expose HTTP Ports" pod setting) go through RunPod's proxy at `https://[POD_ID]-[INTERNAL_PORT].proxy.runpod.net`. The proxy terminates HTTPS automatically, "All connections are secured with HTTPS, even if your internal service uses HTTP," but the resulting URL is publicly reachable by anyone who has it, so a bare Jupyter server or model API on an exposed HTTP port still needs its own authentication. Exposed TCP ports get "direct TCP forwarding with a public IP address" instead, and TLS is not automatic there: RunPod's own docs say to "implement TLS in your application when handling sensitive data over TCP." Secure every template listener, including notebooks, before exposing its port; a notebook exposed as raw TCP with no application-level password is fully open. A "symmetrical port mapping" option lets a template ask for matching internal and external port numbers by specifying a value above 70000 in its TCP configuration. That value is not a port: RunPod's documentation says such numbers are not valid ports and serve only to signal the request, and the real assignment arrives in the pod's environment (for example `$RUNPOD_TCP_PORT_70000`). It is a convenience for the pod's own scripts, not a security boundary, and the port is still public once mapped. ## Vast.ai Rented instances open ports per launch mode (port 22 for SSH mode, port 22 plus 8080 for Jupyter mode by default); each internal port maps to a random external port on a shared public IP, and once a port is mapped it is reachable from the internet with no firewall step described in Vast.ai's docs. The Instance Portal fronts web apps on the instance with a reverse proxy (Caddy) when the external and internal ports differ, and secures access with a token rather than a login: "a secure token is appended to the link to prevent unauthorised access to your applications." At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the README documents OPEN_BUTTON_TOKEN for Basic/Bearer access, WEB_PASSWORD as a custom password, ENABLE_AUTH=false to disable authentication (default true), and AUTH_EXCLUDE as external ports excluded from authentication. PORTAL_CONFIG defines portal entries and port routing; equal external/internal ports bypass proxying. The auth test code uses both tokens as Bearer credentials and WEB_PASSWORD for Basic auth; these are test expectations, not observed results. The Caddy launcher delegates configuration to portal-aio; the delegated configurator is now cited at the same commit, and the next two paragraphs trace variable precedence and route enforcement from it. In this guide's assessment, routing configuration is not a credential. A token-bearing link IS the credential: anyone who has that URL is authenticated, so treat it as a secret and share it only with intended recipients (stripping the token is useful for testing that anonymous access is refused). Do not assume an app is private just because it sits behind the portal's port remapping, and confirm the route is one the portal actually authenticates. What the cited configurator shows, at the same commit: it reads `PORTAL_CONFIG` only when `/etc/portal.yaml` does not exist, writes the parsed entries back to that file (so the file, once written, takes precedence over the variable), and emits a Caddy listener only for an entry whose external and internal ports differ and whose external port has a `VAST_TCP_PORT` mapping in the environment. An entry with equal ports, or with an unmapped external port, gets no Caddy listener from the cited configurator at the same commit, so nothing in the generated configuration authenticates it. A boot script rewrites `PORTAL_CONFIG` for Jupyter: it drops Jupyter entries when `VAST_TCP_PORT_8080` is absent or empty, or when `/.launch` exists without a Jupyter match and `JUPYTER_OVERRIDE` is not `true` (case-insensitively). Re-addition requires no `/.launch`, a nonempty mapping, no existing Jupyter entry and `FORCE_JUPYTER` not equal to `false` (case-insensitively). When `/.launch` contains Jupyter and `JUPYTER_OVERRIDE` is not `true` (case-insensitively), it rewrites `localhost:8080:18080` to `localhost:8080:8080`; these equal-port entries get no generated Caddy listener or portal authentication only if the rewritten environment is used, since an existing `/etc/portal.yaml` takes precedence. Authentication stays on unless `ENABLE_AUTH` equals `false` case-insensitively; any other value, including unset, keeps it on. `WEB_PASSWORD` and `OPEN_BUTTON_TOKEN` have no precedence over each other: when neither is set, one generated value is used for both; when only one is set, the other mirrors it; when both are set, every matcher accepts either, so each is a full credential on every port the portal protects. A protected port accepts a `token` query parameter, an auth cookie named after the container label, or an `Authorization: Bearer` header, each matching either token, and falls back to HTTP Basic with `WEB_USERNAME` (default `vastai`) and a hash of `WEB_PASSWORD`; a valid query token sets the auth cookie. The generated listeners serve plain HTTP unless `ENABLE_HTTPS` is `true` and a valid instance certificate and key are present. The exemptions are the part to check. `AUTH_EXCLUDE` is a comma-separated list matched on the EXTERNAL port, and an excluded port is proxied with no authentication at all, as is every port when `ENABLE_AUTH=false`: both are exposures. Even on an authenticated port, a fixed set of paths bypasses every credential check: `/portal-resolver` is answered directly with 200, and `/.well-known/acme-challenge/*`, `/.well-known/change-password`, `/manifest.json`, `/manifest.webmanifest`, `/site.webmanifest`, `/.well-known/security.txt` and `/security.txt` are reverse-proxied to the backend with no credential. An app that serves private content on one of those paths is therefore anonymously reachable through the portal, even on an authenticated port. The configurator prints the effective username and credentials to its console output. The `OPEN_BUTTON_TOKEN` value is reused beyond the web routes: the portal launcher and the Syncthing supervisor script use it as the Syncthing GUI API key (the supervisor script falls back to a random value only when the variable is unset or empty), and the desktop template's VNC password fallback under Common mistakes reuses it as well. A token-bearing link therefore shares those reused credentials too, in this guide's reasoning. The portal's capabilities manifest reports the same variables but is not the enforcement point: both reporting and enforcement lowercase `ENABLE_AUTH` and default an unset value to `"true"`; reporting tests equality to `"true"`, while enforcement tests inequality to `"false"`, so other values (including empty) are enforced but reported as disabled. ## Lambda Lambda's Public Cloud firewall is deny-by-default for inbound traffic with two exceptions: "By default, Lambda allows only incoming ICMP traffic or TCP traffic on port 22 (SSH)." Everything else, a Jupyter notebook, a model server port, needs an explicit firewall rule (global, workspace-wide rules, or a per-instance ruleset attached at launch); Lambda's own guidance is blunt about the tradeoff: "Each port you open increases the attack surface of your instances." Opening a rule makes the port reachable from whatever source range you allow, with no authentication of its own, so pair the rule with the listener's native auth or a proxy in front, not the firewall rule alone. ## Modal Modal's serverless constructs default differently by shape, and the control differs too. Web endpoints (the current decorator is `modal.fastapi_endpoint`, which replaced `@web_endpoint`) are "publicly available by default" and stay public until the function sets `requires_proxy_auth=True`. A Dedicated Endpoint is made public with the `--unauthenticated` flag; a Server sets `unauthenticated=True` on `@app.server(...)` instead of that flag; and a Shared Endpoint always requires a Proxy Token. Modal's Shared Endpoints page says "Shared Endpoints always require a proxy token," and its Endpoints page's Proxy tokens section says Shared Endpoints always require one while "Dedicated Endpoints can also be created with `--unauthenticated`." The cited pages document the unauthenticated option for Dedicated Endpoints only, so, in this guide's assessment, a Shared Endpoint cannot be made public. Name the construct you are deploying and check its actual control rather than assuming one flag covers all of them. Where proxy auth is enabled, callers authenticate with a Token ID and Token Secret pair, either as separate `Modal-Key` and `Modal-Secret` headers or combined as `Authorization: Bearer .` (Modal notes this mirrors "the same scheme the OpenAI API uses"). A protected endpoint called without credentials returns 401 with "missing credentials for proxy authorization." Check which default your construct uses before deploying; do not assume a Web Function is private. ## The pattern, whatever the platform The platform's own controls (RunPod's proxy TLS, Vast.ai's portal token, Lambda's firewall rules, Modal's proxy auth) are necessary but not sufficient on their own. How to bind the model server depends on which kind of proxy is in front of it. RunPod's HTTP proxy reaches the pod over its exposed network interface rather than through a local backend, so the service must bind `0.0.0.0` inside the pod; RunPod's own troubleshooting guidance is explicit that binding to `localhost` only will keep the proxy from reaching it. Vast.ai's Instance Portal is the opposite case: it is a local reverse proxy (Caddy) running on the instance itself, so the app it forwards to can stay on loopback behind it, the same pattern as an authenticating proxy on your own hardware. Whichever binding the platform's proxy needs, add the listener's OWN authentication where it supports one; where it does not, keep the unauthenticated backend private and put an authenticating gateway in front, so the gateway and not the bare listener is what the platform exposes, exactly as [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) and [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) describe. At Ollama v0.34.2, the cited local API route setup installs CORS and host filtering without an inbound credential check. Cloud sign-in and OLLAMA_AUTH client outbound signing are separate from local inbound authentication. A platform-level control that changes later, a firewall rule widened to a broader source range, a template rebuilt without a flag, should not be the only thing standing between the listener and the internet (deleting a Lambda allow rule, by contrast, closes the port rather than opening it). MFA: this guide cites no platform feature that adds a second factor to the workload itself, so workload authentication must be verified separately. RunPod's quickstart recommends setting up two-factor authentication; its account-management page documents Basic, Billing, Dev and Admin team roles, but these passages do not show factor methods, setup steps or enforcement. RunPod's product updates, at the same documentation commit, list "Passkey auth" under November 2024 as "Passwordless WebAuthn sign-in for phishing-resistant account access"; in this guide's assessment, they do not say whether a passkey serves as a second factor, or how it is set up or enforced. In this guide's search of the RunPod documentation at that commit, no page names 2FA factor types, a 2FA setup path, or a team or organization 2FA requirement; a search that finds nothing does not establish that RunPod lacks them. Vast.ai's two-factor authentication page requires configuring at least one authenticator-app (TOTP) or SMS method to use 2FA; email is a backup, used automatically when setting up the first method, and remains an explicit option at the verification prompt rather than being triggered automatically at login. At the same documentation commit, Vast.ai's 2FA API reference lists a `team_user_forbidden` error (HTTP 403) for an "Endpoint called from a Team Context (override)", stating "2FA actions must be performed in the personal context.", and its team-roles page lists a `user_write` scope that "Allows the ability to change account settings such as email, password, 2FA, etc." The permissions reference's `user_write` row, by contrast, reads "Creating/modifying API keys, SSH keys, environment variables, templates, teams", its `user_write` endpoint list names no 2FA endpoint, and which page reflects the scope's behaviour has not been established. In this guide's reading of the teams overview, managing-teams and team-roles guides, the API and CLI permissions references, the security FAQ, and the 2FA guide, CLI page and API reference at that commit, none documents an owner setting, role permission or policy that requires team members to enable 2FA. The team-roles page lists default Owner, Manager and Member roles, the managing-teams guide names Manager and Member as every team's two default roles, and the CLI permissions page says team roles use the same permission model as API keys. That reading, like this guide's earlier search of the Vast.ai documentation at that commit, does not establish that Vast.ai lacks such a control. Pages at that commit that were not read include the account-settings guide (the 2FA guide sends setup to Account Settings), the teams quickstart, legacy-teams and SDK permissions pages, the API, CLI and SDK authentication pages, the CLI and SDK team and member reference pages, the CLI `tfa` reference pages, and the team OpenAPI files. Lambda's console page says MFA can be set up in User settings and account admins can manage users, roles and workspaces; it does not specify factor methods, setup steps or enforcement. Lambda's single sign-on (SSO) page documents letting users authenticate to Lambda Cloud through the organization's existing identity provider (IdP), chosen from a list of supported providers or set up as Custom SAML or Custom OIDC. At the time of writing, its prerequisites include a 1-Click Cluster (1CC) and the Admin role in the Lambda Cloud account, and setup starts from an invitation email requested from the Lambda account manager. Once the connection is enabled, the page says users can sign in through the IdP; it does not say whether other sign-in methods remain available, and it shows no Lambda-side MFA enforcement. In this guide's inference, an IdP that enforces MFA can enforce it for Lambda Cloud sign-ins through that SSO connection, but not for any sign-in that bypasses it. Modal's Workspaces page notes that accounts can be provisioned through SAML SSO, points to separate SAML SSO documentation for configuring SSO for a Workspace, and directs users to contact Modal for SSO through Google or other providers. It also documents Owner, Manager and Member roles, with RBAC for more granular permissions. At the time of writing, Modal's Custom SAML SSO, Okta SSO and Microsoft Entra SSO pages each say that SSO option is available on the Enterprise plan, and each lists as prerequisites a Workspace on an Enterprise plan, admin access to that Workspace and admin privileges in the identity provider. Setup adds a Modal SAML application in the identity provider and links the Workspace to it by supplying the identity provider's SAML metadata on the Identity and Provisioning tab of Workspace Management. Each of the three pages says that enabling the "Require SSO" setting will force all users to sign in via SSO (the Okta and Microsoft Entra pages name their own provider), and that admins should confirm their admin access to the Workspace through the identity provider before enabling it. None of the three documents an owner, recovery or alternative-login exception to that setting, which does not establish that none exists. In this guide's inference, where a Workspace enables Require SSO, an IdP that enforces MFA can enforce it for members' sign-ins to that Workspace, but not for Workspace API tokens, which authenticate programmatic access separately; that enforcement has not been observed. Modal's security page requires phishing-resistant MFA for Modal's own employee accounts; that statement does not establish a customer-account second factor, and none of the cited Modal pages shows such a control or Modal-side MFA enforcement for customer accounts. This guide's advice is to enable MFA on each RunPod, Vast.ai and Lambda platform account, and on Modal wherever a customer control is documented ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)); wherever you use Lambda or Modal SSO, use an identity provider that enforces MFA and verify that enforcement for every member. No cited Modal page documents a customer-account second factor, so on Modal this guide's advice is to use SSO with Require SSO enabled where the plan allows, after confirming admin access to the Workspace through the identity provider, as Modal's SSO pages direct. This guide's advice is to confirm the available factor methods and setup steps with RunPod and Lambda, and verify account MFA enforcement on RunPod, Vast.ai and Lambda, and on Modal wherever a customer control is documented. These citations do not establish RunPod factor methods, setup steps or enforcement, Vast.ai team-wide enforcement, Lambda's own factor methods, setup steps or enforcement, whether Lambda SSO can be made the only sign-in path, any exception to Modal's Require SSO, or any Modal customer-account second-factor control or Modal-side MFA enforcement; these remain open in TODO.md under 1.264. In this guide's reasoning, an API key or proxy-auth token secures a machine client and is a separate control from the platform login. SSH: authenticate with your own key, not a password. Lambda requires an SSH key at launch; Vast.ai disables password authentication and uses the key you register; RunPod recommends key auth and offers an optional password you should skip. Keep the private key off the instance, and treat SSH access as a control separate from the platform login above. ## Verify REASONED: listener inventory and direct/proxied authentication pairs; no GPU-platform deployment, workload credentials or outside probe host was supplied for this metadata review, and no service run is recorded. Expected readings below follow the cited platform documentation, the Vast.ai base-image portal configurator at commit 00064421641881c1f83ff58c55f13cbc10cbea4d, Jupyter Server v2.18.0 implementation, REST specification and security documentation, iproute2 v6.12.0 ss manual and implementation, and Linux man-pages-5.13 network namespace isolation. This guide infers from the ss namespace switch and Linux namespace isolation that ss -tlnp inventories TCP listeners only in the current network namespace, assuming normal live kernel/proc inputs without input-file environment overrides. This is not a UDP, firewall-state or platform-publication check; this guide's test design requires cross-checking platform mappings and probing each mapped port from outside. REASONED: following block; this guide's test design compares anonymous and valid-token requests directly to loopback `:8888/api/contents`. For Jupyter Server v2.18.0 with token authentication enabled, no session cookie, the route at that URL and an existing readable contents path, anonymous GET returns 403 without a login redirect; with default authorization, valid-token GET completes the JSON model. The REST specification documents HTTP 200 for this successful GET. Any anonymous data response is a finding, in this guide's assessment. Tokens are accepted in `Authorization: token` or the `token` query parameter; password-only and cookie-authenticated requests need separate controls. The Sources entries below pin the contents route, authenticated GET, API refusal, token handling and authentication/authorization defaults; the REST specification documents the successful GET response as HTTP 200. These are source-based expectations, not observed deployment results; the missing deployment and credentials prevent the live comparison here. The block exits 1 when the anonymous request gets a 2xx data response, 3 when `ss -tlnp` or a request fails, the anonymous request gets anything but 403 or a 2xx, or the valid-token positive control does not return 200, 2 when `ss` is not installed or its token guard refuses, and otherwise 0. It gives 2 only while no other part gave a finding or an inconclusive result, and it leaves any listener table it printed for you to judge. ```bash ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (ss is not installed, or a part refused its input), which outranks 0, so 2 stands only while nothing else was recorded # Inventory assumes normal live kernel/proc inputs, without ss input-file environment overrides. if command -v ss >/dev/null; then ss -tlnp || # TCP listening sockets in THIS network namespace only - not UDP, not a firewall, and not # the platform's NAT/proxy publication. A port here may be private OR public (once exposed/ # mapped), and a port published by the platform need not appear here at all, so cross-check # against the platform's port mappings and probe each MAPPED port from outside (next block). { echo "ss -tlnp exit $?; inconclusive"; set -- 3; } else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi # Does the LISTENER itself demand auth? Test it DIRECTLY on loopback, with no platform proxy in the path, # as a matched pair: this guide's test design. Jupyter Server v2.18.0 accepts # 'Authorization: token ' or ?token=. With token auth enabled and no session cookie, # /api/contents returns 403 without credentials, not a login redirect. For the route at this URL, # default authorization and an existing readable path, valid-token GET completes the JSON model; # the REST specification documents HTTP 200 for this successful GET. # Any anonymous data response is a finding. Substitute the real token. ( # Feed the token to curl on stdin (curl --header @-), never in argv: # -H "Authorization: token TOKEN" is readable in ps / /proc//cmdline. trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The Jupyter token you substitute on the set -- line enters shell history. # Clear that history line afterward. # Stdin closes the argv channel only: it does not hide the token from set -x # tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_JUPYTER_TOKEN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the Jupyter token on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above set -- "$1" "$2" "$(if curl -q -g -sS -o /dev/null -w 'jupyter no-token=%{http_code} exit=%{exitcode}\n' --noproxy '*' \ --connect-timeout 5 --max-time 10 http://127.0.0.1:8888/api/contents; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its no-token line printf '%s\n' "$4" case "$3:$4" in 0:'jupyter no-token=403 '*) ;; 0:'jupyter no-token=2'??' '*) echo "FINDING: an anonymous request to /api/contents got a data response"; set -- "$1" 1 ;; 0:*) echo "the no-token request got neither the 403 refusal nor a data response; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the no-token request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if printf 'Authorization: token %s\n' "$1" | curl -q -g -sS -o /dev/null -w 'jupyter with-token=%{http_code} exit=%{exitcode}\n' --noproxy '*' \ --connect-timeout 5 --max-time 10 -H @- http://127.0.0.1:8888/api/contents; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its with-token line printf '%s\n' "$4" case "$3:$4" in 0:'jupyter with-token=200 '*) ;; 0:*) echo "the valid-token request did not return 200, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the valid-token request failed, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the listener table, if one was printed, is not classified, so judge it yourself, then probe each mapped port from outside with the next block" exit "$1" ) ``` ```bash # REASONED: outside probe of each exposed mapping, per the cited RunPod, Vast.ai, Modal and Jupyter sources; no platform deployment, outside probe host or workload credentials are available, and no run is recorded. # Then confirm the SAME listener is authenticated FROM OUTSIDE, once PER exposed port/mapping. A probe of # the platform proxy hostname shows reachability and that something gates the URL, but a 401/200 there can # be the PLATFORM proxy rather than the listener, so read it with the direct-loopback pair above: a no- # credential 200 returning app data is a finding; a 401 is only conclusive alongside a direct 200. The # guard refuses while a placeholder remains; repeat for each mapped port. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The token you substitute on the set -- line enters shell history. # Use a short-lived token or clear that history line afterward. # Stdin closes the argv channel only: it does not hide the token from set -x # tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_POD_ID-REPLACE_WITH_PORT.proxy.runpod.net/REPLACE_WITH_PROTECTED_PATH' 'REPLACE_WITH_TOKEN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the URL on the set -- line above; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the token on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status: 3 once a request fails, otherwise 0 curl -q -g -sS -o /dev/null -w 'no-cred=%{http_code} exit=%{exitcode}\n' --noproxy '*' --connect-timeout 5 --max-time 10 "$1" || { echo "curl exit $?: the no-credential request failed; inconclusive"; set -- "$1" "$2" 3; } printf 'Authorization: Bearer %s\n' "$2" | curl -q -g -sS -o /dev/null -w 'with-cred=%{http_code} exit=%{exitcode}\n' --noproxy '*' --connect-timeout 5 --max-time 10 -H @- "$1" || { echo "curl exit $?: the with-credential request failed; inconclusive"; set -- "$1" "$2" 3; } echo "block exit $3; neither code is classified, because this block cannot see whether a 200 returns app data or whose 401 it is: read them with the direct-loopback pair in the block above" exit "$3" ) ``` REASONED: exposure interpretation from the cited platform publication and Jupyter authentication documentation; no platform deployment or outside probe host is available. Every port `ss` shows listening should be either closed (not exposed at the platform layer) or authenticated (the listener itself demands a key, token, or login). In this guide's assessment, any anonymous Jupyter data response is a finding, whichever of these platforms it runs on; a valid session cookie is authentication, and a 403 error response is not data access. The outside block above exits 2 when its guard refuses, 3 when a request fails and otherwise 0, because it cannot see whether a 200 returns app data or which layer sent a 401, so read its codes with the direct-loopback pair. REASONED: following block; this guide's test design for the Vast.ai portal's path exemptions and port exclusions has no live run: no Vast.ai instance or outside probe host is available. For an authenticated external mapping at base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the cited configurator lines 175-186 and 323-387 generate resolver 200, exempt-path backend responses and a Basic-auth challenge elsewhere. Before probing, establish harmless known private content at an exempt path and a non-exempt path, and obtain the portal's actual `WWW-Authenticate` header independently; confirm that the backend's own challenge differs. Substitute inside the quotes: mapped host:port, both paths starting with `/`, a distinctive non-secret body marker shared by those private resources, and that exact portal header line. If these prerequisites cannot be established, the result is INCONCLUSIVE. A public manifest is not private content merely because it returns 200. Anonymous matching content is a finding when that path is private; on the non-exempt private path, a 401 with the independently distinguished portal header matches the generated challenge, while a backend 401 or any unexplained response is INCONCLUSIVE. On the exempt path, which the configurator proxies to the backend with no credential, a complete response without the private marker is an unclassified observation for you to judge. Disabled authentication and excluded ports can expose the same content, but these probes cannot diagnose which setting caused access. The resolver's 200 tests reachability only. Headers and bodies remain visible for inspection; transport failures are INCONCLUSIVE. The block exits once, after all three probes: with status 1 if any probe printed FINDING, with status 0 only if the resolver probe printed REACHABLE, the exempt-path probe printed UNCLASSIFIED OBSERVATION and the private-path probe printed PORTAL CHALLENGE, and otherwise with status 3. A resolver 200 without the private marker prints REACHABLE, because that 200 tests reachability only, and any other resolver answer prints INCONCLUSIVE, so read each probe's line as well as the status. The block uses plain HTTP, the image default; with `ENABLE_HTTPS=true` and valid instance certificates, adjust the scheme. Repeat for each mapped port the portal proxies. ```bash # Anonymous probes only; no credential enters this block. ( set +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VAST_MAPPED_HOST_AND_PORT' '/REPLACE_WITH_EXEMPT_PATH' '/REPLACE_WITH_PRIVATE_PATH' 'REPLACE_WITH_PRIVATE_BODY_MARKER' 'REPLACE_WITH_PORTAL_CHALLENGE_HEADER' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "the set -- line needs exactly 5 values; not probing"; exit 2; } [ -n "$1" ] && [ -n "$2" ] && [ -n "$3" ] && [ -n "$4" ] && [ -n "$5" ] || { echo "all five values are required; not probing"; exit 2; } case "$1$2$3$4$5" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2:$3" in /*:/*) ;; *) echo "both paths must start with /; not probing"; exit 2 ;; esac portal_probe() ( # Positional values: host:port, path, private body marker, distinct portal header, then which probe this is (resolver, exempt or private). set -- "$@" "$(curl -q -g -sS -i -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --noproxy '*' \ --connect-timeout 5 --max-time 10 "http://$1$2"; printf '\nprobe-curl-rc=%s\n' "$?")" printf '%s\n%s\n' "Probe $2" "$6" case "$6" in *$'\nprobe-curl-rc=0') ;; *) echo 'INCONCLUSIVE: transport failure'; exit 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "${6//$'\r'/}" case "$6" in *$'\n\n'*) ;; *) echo 'INCONCLUSIVE: no complete response headers'; exit 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "${6%%$'\n\n'*}" "${6#*$'\n\n'}" case "${7%$'\nhttp='*}" in *"$3"*) echo 'FINDING: known private content returned anonymously'; exit 1 ;; *) case "$5:$7" in resolver:*$'\nhttp=200 exit=0 err=\n'*) echo 'REACHABLE: the resolver answered 200, which tests reachability only' ;; resolver:*) echo 'INCONCLUSIVE: the resolver did not answer 200'; exit 3 ;; exempt:*) echo 'UNCLASSIFIED OBSERVATION: no known private content; the cited configurator proxies this exempt path to the backend with no credential, so judge the response above yourself' ;; *$'\nhttp=401 exit=0 err=\n'*) case "$6"$'\n' in *$'\n'"$4"$'\n'*) echo 'PORTAL CHALLENGE: matches the independently distinguished header' ;; *) echo 'INCONCLUSIVE: 401 without the distinct portal challenge'; exit 3 ;; esac ;; *) echo 'INCONCLUSIVE: no known private content or distinct portal challenge'; exit 3 ;; esac ;; esac ) # Each probe's status is appended to the positional values: 1 finding, 3 inconclusive, 0 for a resolver 200, an unclassified exempt-path response or a private-path portal challenge. portal_probe "$1" /portal-resolver "$4" "$5" resolver set -- "$@" "$?" portal_probe "$1" "$2" "$4" "$5" exempt set -- "$@" "$?" portal_probe "$1" "$3" "$4" "$5" private set -- "$@" "$?" # The block exits once: 1 if any probe found private content, else 0 only if the resolver answered 200, the exempt path's response was left unclassified and the private path saw the portal challenge, else 3. case "$6:$7:$8" in 1:*|*:1:*|*:1) exit 1 ;; 0:0:0) exit 0 ;; *) exit 3 ;; esac ) ``` ## Common mistakes - Assuming a rented box has a default-deny firewall like a major cloud's security group; most rented-GPU platforms do not. - Treating a template's HTTPS proxy URL as proof the underlying service is authenticated; the proxy secures transport, not access. - Overlooking a template's remote desktop: at Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the linux-desktop launcher configures x11vnc on port 5900 with a password from VNC_PASSWORD, falling back to OPEN_BUTTON_TOKEN when VNC_PASSWORD is unset or empty. The template README describes OPEN_BUTTON_TOKEN as randomly generated on first boot and visible in instance logs, and offers Selkies WebRTC and Guacamole browser VNC alongside direct VNC. These citations do not establish other templates' ports or password defaults, or the strength of the effective VNC password. This guide's advice: check both browser and direct VNC listeners and their public mappings for missing or weak authentication. In this guide's reasoning, the browser page's HTTPS does not protect a separately reachable VNC connection, so a direct VNC mapping needs its own strong password and an encrypted tunnel, or should be kept off the public mapping. This guide's advice: test a direct VNC mapping with a VNC client, since the browser access controls and the HTTP status checks above may not cover it. - Leaving a Modal Web Function without `requires_proxy_auth=True` because Endpoints and Servers are private by default and it is easy to assume Web Functions are too. - Reusing a rented box's platform login (RunPod/Vast.ai/Lambda/Modal account) as if it were the same thing as the workload's own authentication; they are separate controls. ## Sources (checked September 2026) - RunPod expose ports (proxy HTTPS, TCP forwarding): https://docs.runpod.io/pods/configuration/expose-ports - Vast.ai networking and ports (default ports, port mapping): https://docs.vast.ai/guides/instances/connect/networking - Vast.ai Instance Portal (PORTAL_CONFIG, Caddy reverse proxy, secure-token links): https://docs.vast.ai/guides/instances/connect/instance-portal - Lambda Cloud firewalls (default-deny inbound, SSH/ICMP exception, rule types): https://docs.lambda.ai/public-cloud/firewalls/ - Modal proxy auth for web endpoints (Endpoints/Servers vs Web Functions defaults, headers): https://modal.com/docs/guide/webhook-proxy-auth - Modal Shared Endpoints (Shared Endpoints always require a proxy token) (checked October 2026): https://modal.com/docs/guide/shared-endpoints - Modal Endpoints, Proxy tokens section (Shared Endpoints always require a Proxy Token; Dedicated Endpoints can also be created with `--unauthenticated`) (checked October 2026): https://modal.com/docs/guide/endpoints - Vast.ai base image (portal auth: `OPEN_BUTTON_TOKEN`, `WEB_PASSWORD`, `ENABLE_AUTH`/`AUTH_EXCLUDE`): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md - Jupyter Server security (token authentication; the pinned API handler below establishes the missing-credentials 403): https://jupyter-server.readthedocs.io/en/latest/operators/security.html - Jupyter Server v2.18.0 contents handler inheritance, authenticated GET and JSON completion (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/services/contents/handlers.py#L83-L186 - Jupyter Server v2.18.0 contents route registration (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/services/contents/handlers.py#L436-L445 - Jupyter Server v2.18.0 API authentication failure without a login redirect (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/base/handlers.py#L782-L789 - Jupyter Server v2.18.0 optional contents path (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/base/handlers.py#L1201 - Jupyter Server v2.18.0 query and header token extraction and validation (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/auth/identity.py#L465-L516 - Jupyter Server v2.18.0 token and cookie identity and disabled authentication (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/auth/identity.py#L255-L298 - Jupyter Server v2.18.0 default token authentication and credential forms (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/docs/source/operators/security.rst#L6-L26 - Jupyter Server v2.18.0 authorization decisions and permissive default (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/docs/source/operators/security.rst#L315-L322 - Jupyter Server v2.18.0 REST specification for contents GET, including its HTTP 200 response (checked October 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/services/api/api.yaml#L82-L137 - Lambda SSH (an SSH key is required at launch): https://docs.lambda.ai/public-cloud/on-demand/connecting-instance/ - Vast.ai SSH (password authentication is disabled; register a key): https://docs.vast.ai/guides/instances/connect/ssh - RunPod SSH (key authentication recommended, optional password): https://docs.runpod.io/pods/configuration/use-ssh - iproute2 v6.12.0 `ss` manual (checked October 2026): [numeric output](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L33-L34), [listening sockets](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L43-L44), [process display](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L162-L163), and [TCP sockets](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L374-L375). - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d README authentication methods and variables (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md#L175-L187 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d README portal entry fields (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md#L135-L147 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d README proxy bypass and portal configuration (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md#L154-L158 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d auth test credential expectations, not execution evidence (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/opt/instance-tools/tests/base/26-caddy-auth.sh#L97-L110 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d Caddy configurator delegation; does not establish the delegated implementation (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/opt/supervisor-scripts/caddy.sh#L9-L14 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator load order: `/etc/portal.yaml` preferred, `PORTAL_CONFIG` parsing and writeback (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L14-L40 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator `AUTH_EXCLUDE` parsing against the external port (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L68-L71 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator HTTPS off by default, authentication on unless `ENABLE_AUTH` is `false`, default username and dual-token defaults (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L75-L93 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator query, cookie and Bearer matchers accepting either token (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L125-L132 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator unauthenticated path matcher list (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L175-L186 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator per-port listener condition and per-port authentication selection (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L212-L240 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator unauthenticated port route generation (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L293-L308 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator basic-auth hashing of the web password (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L311-L321 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator authenticated route order: exempt-path proxying, cookie-setting token routes and basic-auth fallback (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L323-L387 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d configurator credential console output (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L400-L413 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d launcher reuse of `OPEN_BUTTON_TOKEN` as the Syncthing GUI API key (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/launch.sh#L77-L92 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d Syncthing supervisor API key from `OPEN_BUTTON_TOKEN` with a random fallback only when unset or empty (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/opt/supervisor-scripts/syncthing.sh#L14-L16 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d boot-time `PORTAL_CONFIG` Jupyter rewrites (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/etc/vast_boot.d/10-prep-env.sh#L12-L27 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d capabilities manifest auth descriptor; reporting, not enforcement (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/capabilities/manifest.py#L674-L694 - iproute2 v6.12.0 explicit network namespace selection (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L358-L359 - iproute2 v6.12.0 namespace switch option handling (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/misc/ss.c#L6114-L6117 - iproute2 v6.12.0 TCP input overrides and normal netlink path (checked October 2026): https://github.com/iproute2/iproute2/blob/v6.12.0/misc/ss.c#L4235-L4240 - Linux man-pages-5.13 network namespace isolation of protocol stacks, firewall rules, /proc/net and port numbers (sockets) (checked October 2026): https://github.com/mkerrisk/man-pages/blob/man-pages-5.13/man7/network_namespaces.7#L30-L40 - Ollama v0.34.2 local API route setup, CORS and host filtering; does not establish inbound caller authentication (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/server/routes.go - Ollama v0.34.2 client outbound signing, separate from inbound authentication (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/api/client.go - Ollama v0.34.2 cloud sign-in command, separate from local inbound authentication (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/cmd/cmd.go - Ollama v0.34.2 OLLAMA_AUTH setting definition (client consumption shown by the api/client.go citation) (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/envconfig/config.go - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d linux-desktop x11vnc port and password fallback; does not establish password strength (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/derivatives/linux-desktop/ROOT/opt/supervisor-scripts/x11vnc.sh#L22-L24 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d linux-desktop browser and direct connection choices (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/derivatives/linux-desktop/README.template.md#L47-L50 - Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d linux-desktop documented VNC password and override (checked October 2026): https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/derivatives/linux-desktop/README.template.md#L76-L80 - RunPod account documentation at commit 85a9f105d28e37758a43a44b1e75dca08fc1c33c (quickstart recommendation to set up 2FA; no factor methods, setup steps or enforcement shown) (checked October 2026): https://github.com/runpod/docs/blob/85a9f105d28e37758a43a44b1e75dca08fc1c33c/get-started.mdx#L13-L17 - RunPod account documentation at commit 85a9f105d28e37758a43a44b1e75dca08fc1c33c (team roles and permission table; not a second-factor control) (checked October 2026): https://github.com/runpod/docs/blob/85a9f105d28e37758a43a44b1e75dca08fc1c33c/accounts-billing/manage-accounts.mdx#L57-L74 - RunPod account documentation at commit 85a9f105d28e37758a43a44b1e75dca08fc1c33c (product updates, November 2024: Passkey auth as passwordless WebAuthn sign-in; not stated to be a second factor; no setup steps or enforcement shown) (checked October 2026): https://github.com/runpod/docs/blob/85a9f105d28e37758a43a44b1e75dca08fc1c33c/release-notes.mdx#L387-L393 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (TOTP/SMS prerequisite and email backup/selection behaviour) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/reference/two-factor-authentication.mdx#L14-L18 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (2FA API reference: team-context error; 2FA actions must be performed in the personal context; no team-wide 2FA requirement shown) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/api-reference/two-factor-authentication-endpoints.mdx#L544 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (team-roles page's description of the `user_write` scope as allowing changes to account settings including 2FA; not a team-wide 2FA requirement) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/teams-roles.mdx#L48-L52 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (permissions reference: permission categories, with `user_write` described without 2FA) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/api-reference/permissions.mdx#L14-L26 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (permissions reference: `user_write` endpoint list, with no 2FA endpoint) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/api-reference/permissions.mdx#L165-L181 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (CLI permissions page: team roles use the same permission model as API keys) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/cli/permissions.mdx#L102-L104 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (team-roles page: default Owner, Manager and Member roles and custom roles) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/teams-roles.mdx#L16-L20 - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (teams overview, read whole: team owners set custom access controls; no team-wide 2FA requirement shown) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/teams-overview.mdx - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (managing-teams guide, read whole: default roles, invitations, team name and ownership transfer; no team-wide 2FA requirement shown) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/managing-teams.mdx - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (security FAQ, read whole: account-security practices, which do not mention 2FA) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/reference/faq/security.mdx - Vast.ai account authentication at commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d (CLI 2FA page, read whole: per-account `tfa` commands; no team-wide 2FA requirement shown) (checked October 2026): https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/cli/two-factor-authentication.mdx - Lambda Public Cloud console (User settings MFA and account-admin management; no factor methods, setup steps or enforcement specified) (checked October 2026): https://docs.lambda.ai/public-cloud/console/ - Lambda Public Cloud single sign-on (sign-in through the organization's IdP with Custom SAML or Custom OIDC options; prerequisites include a 1-Click Cluster and the Admin role; invitation from the Lambda account manager; no MFA enforcement or SSO-only sign-in shown) (checked October 2026): https://docs.lambda.ai/public-cloud/enabling-sso/ - Modal account security (accounts provisioned through SAML SSO; separate SAML SSO docs linked; contact Modal for Google or other SSO providers; workspace roles and RBAC; no plan availability, customer second-factor control or MFA enforcement shown) (checked October 2026): https://modal.com/docs/guide/workspaces - Modal account security (employee MFA and customer access controls; employee MFA does not establish customer MFA) (checked October 2026): https://modal.com/docs/guide/security - Modal account security (Custom SAML SSO page: available on the Enterprise plan; prerequisites; IdP SAML application and Workspace link on the Identity and Provisioning tab; enabling "Require SSO" forces all users to sign in via SSO; no owner, recovery or alternative-login exception, customer second factor or MFA enforcement shown) (checked October 2026): https://modal.com/docs/guide/saml-sso - Modal account security (Okta SSO page: available on the Enterprise plan; prerequisites; enabling "Require SSO" forces all users to sign in via Okta; no exception, customer second factor or MFA enforcement shown) (checked October 2026): https://modal.com/docs/guide/okta-sso - Modal account security (Microsoft Entra SSO page: available on the Enterprise plan; prerequisites; enabling "Require SSO" forces all users to sign in via Microsoft Entra; no exception, customer second factor or MFA enforcement shown) (checked October 2026): https://modal.com/docs/guide/entra-sso ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - runpod: RunPod documentation (unknown) - vast: Vast.ai documentation (unknown) - vast-image: Vast.ai base image (00064421641881c1f83ff58c55f13cbc10cbea4d) - lambda: Lambda Public Cloud documentation (unknown) - modal: Modal proxy authentication (unknown) - jupyter: Jupyter Server security (unknown) - jupyter-contents: Jupyter Server contents authentication (v2.18.0) - ollama: Ollama (v0.34.2) - iproute2: iproute2 ss manual (v6.12.0) - network-namespaces: Linux network namespaces manual (man-pages-5.13) - runpod-account: RunPod account documentation (85a9f105d28e37758a43a44b1e75dca08fc1c33c) - vast-account: Vast.ai account authentication (8629af9a05e884dd603b6a24bbb7c39826c1a10d) - modal-account: Modal account security (unknown) Claims (full source mapping in the JSON block below): - publication (REASONED; runpod, vast, lambda, modal): Raw port publication or a firewall allowance adds no listener authentication; platform proxies authenticate only the routes they actually cover. - runpod-http (REASONED; runpod): Expose HTTP Ports publishes https://[POD_ID]-[INTERNAL_PORT].proxy.runpod.net with automatic HTTPS; anyone with the URL can reach it, so the application still needs authentication. - runpod-tcp (REASONED; runpod): Exposed TCP ports forward directly on a public IP without automatic TLS; secure template listeners before exposure and implement application TLS for sensitive TCP data. - runpod-symmetry (REASONED; runpod): TCP configuration values above 70000 request symmetrical mapping rather than naming valid ports; read the assigned mapping from the pod environment, such as RUNPOD_TCP_PORT_70000. Mapping remains public. - vast-ssh-port (REASONED; vast): Vast.ai SSH launch mode opens internal port 22 by default. - vast-jupyter-port (REASONED; vast): Vast.ai Jupyter launch mode opens internal 8080 as well as SSH port 22 by default. - vast-mapping (REASONED; vast): Each open Vast.ai internal port maps to a random external port on a usually shared public IP; the networking docs describe no additional firewall step. - vast-proxy (REASONED; vast): Instance Portal uses local Caddy proxying when external and internal ports differ; forwarded apps can stay on loopback and PORTAL_CONFIG describes routing and presentation. - vast-token (REASONED; vast): Portal links carry a credential token; protect the URL, test access without it and confirm the selected route actually authenticates. - vast-auth-controls (REASONED; vast-image, vast): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the README documents OPEN_BUTTON_TOKEN for Basic/Bearer access, WEB_PASSWORD as a custom password, ENABLE_AUTH=false to disable authentication (default true), and AUTH_EXCLUDE as external ports excluded from authentication. The cited portal-aio configurator implements these; auth test code records expectations, not observed results. - vast-portal-routing (REASONED; vast-image): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, portal-aio reads PORTAL_CONFIG only if /etc/portal.yaml is absent, writes it there, and emits a Caddy listener only where external and internal ports differ and the external port has a VAST_TCP_PORT mapping; other entries bypass proxying and portal authentication. In this guide's assessment, routing configuration is not a credential. - vast-portal-jupyter (REASONED; vast-image): Boot edits PORTAL_CONFIG: absent/empty 8080 mapping drops Jupyter; non-Jupyter /.launch drops it and Jupyter /.launch rewrites 8080:18080 to equal ports unless JUPYTER_OVERRIDE=true (case-insensitive). Re-add needs no /.launch, a nonempty mapping, no Jupyter entry and FORCE_JUPYTER not false (case-insensitive); /etc/portal.yaml wins, so only effective equal-port entries lose portal authentication. - vast-auth-defaults (REASONED; vast-image): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the configurator keeps auth on unless ENABLE_AUTH is false case-insensitively, serves plain HTTP unless ENABLE_HTTPS is true with valid certs, and defaults WEB_USERNAME to vastai. At that commit it gives WEB_PASSWORD and OPEN_BUTTON_TOKEN no precedence: an unset one mirrors the other; if neither is set, one generated value serves both. - vast-auth-routes (REASONED; vast-image): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, a protected port accepts either token as a token query parameter, a container-label auth cookie or an Authorization: Bearer header, sets the cookie on a valid query token, and falls back to HTTP Basic whose hashed password is WEB_PASSWORD. - vast-auth-exemptions (REASONED; vast-image): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, AUTH_EXCLUDE ports, matched externally, and every port under ENABLE_AUTH=false are proxied unauthenticated, an exposure. Even on authenticated ports, /portal-resolver answers anonymously and ACME challenge, change-password, manifest and security.txt paths proxy to the backend with no credential, an exposure for private content there. - vast-token-reuse (REASONED; vast-image): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the configurator prints the effective credentials to its console output, and OPEN_BUTTON_TOKEN is also the Syncthing GUI API key in the launcher and supervisor scripts (the supervisor's random fallback only when unset or empty). In this guide's reasoning, sharing a token-bearing link shares these reused credentials too. - vast-manifest-report (REASONED; vast-image): At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, manifest reporting and configurator enforcement lowercase ENABLE_AUTH and default unset to "true": reporting tests equality to "true", enforcement inequality to "false". Other values can be reported disabled yet enforced; the manifest reports, it does not enforce. - lambda-default (REASONED; lambda): Lambda inbound firewall denies by default except ICMP and TCP 22; other listeners require explicit global/workspace rules or a per-instance ruleset attached at launch. - lambda-rule (REASONED; lambda): An allow rule admits the specified source range without authenticating callers; pair it with listener or proxy authentication. Removing an allow rule closes access. - modal-web (REASONED; modal): Web endpoints using modal.fastapi_endpoint, the replacement for @web_endpoint, are public by default; requires_proxy_auth=True enables proxy authentication. - modal-dedicated (REASONED; modal): Dedicated Endpoints require proxy authentication by default; --unauthenticated makes them public. - modal-server (REASONED; modal): Servers require proxy authentication by default; @app.server(unauthenticated=True) makes them public. - modal-shared (REASONED; modal): Shared Endpoints always require a Proxy Token; the cited pages document the --unauthenticated option for Dedicated Endpoints only, so, in this guide's assessment, a Shared Endpoint cannot be made public. - modal-credentials (REASONED; modal): Proxy authentication accepts Modal-Key and Modal-Secret or Authorization: Bearer <token_id>.<token_secret>; missing credentials on a protected endpoint return 401. - runpod-bind (REASONED; runpod): RunPod HTTP proxy needs the exposed pod interface: bind the exposed service to 0.0.0.0 inside the pod, not localhost. - listener-auth (REASONED; runpod, vast, jupyter): Whichever binding the platform's proxy needs, add the listener's OWN authentication where it supports one; otherwise keep the unauthenticated backend private and put an authenticating gateway in front, so the platform exposes the gateway rather than the bare listener. - listener-auth-ollama (REASONED; ollama): At Ollama v0.34.2, the cited local API route setup installs CORS and host filtering without an inbound credential check. Cloud sign-in and OLLAMA_AUTH client outbound signing are separate from local inbound authentication. - mfa (REASONED; modal, jupyter, runpod-account, vast-account, lambda, modal-account): Advice: enable account MFA on RunPod, Vast.ai and Lambda, and on Modal where a customer control is documented; verify enforcement. Use an MFA-enforcing IdP for SSO and, where Modal's plan allows, Require SSO after confirming Workspace admin access through the IdP; this guide cites no workload second factor and treats API keys and proxy tokens as separate machine-client controls. - lambda-sso (REASONED; lambda): At the time of writing, Lambda's SSO page documents sign-in through an organization's IdP, with Custom SAML or Custom OIDC options; prerequisites include a 1-Click Cluster and the Admin role, and setup is by invitation from the Lambda account manager. This guide infers that an MFA-enforcing IdP can enforce MFA for SSO sign-ins; the page shows no Lambda-side MFA enforcement or SSO-only sign-in. - modal-sso (REASONED; modal-account): At the time of writing, Modal's SAML, Okta and Entra SSO pages place SSO on the Enterprise plan and say enabling Require SSO forces all users to sign in via SSO, documenting no exception; the security page requires employee MFA. This guide infers an MFA-enforcing IdP can enforce MFA for SSO sign-ins but not API tokens; no cited page shows a customer second factor or customer MFA enforcement. - account-mfa-gaps (REASONED; runpod-account, vast-account, lambda, modal-account): This guide's source assessment: RunPod's quickstart recommends 2FA, Vast.ai documents TOTP or SMS methods with an email backup, and Lambda's console page places MFA setup in User settings. These sources do not establish RunPod or Lambda factor methods, setup steps or enforcement, Vast.ai team-wide enforcement, whether Lambda SSO can be the only sign-in path, or exceptions to Modal's Require SSO. - runpod-passkey (REASONED; runpod-account): RunPod's product updates at docs commit 85a9f105d28e37758a43a44b1e75dca08fc1c33c document Passkey auth (November 2024) as passwordless WebAuthn sign-in for phishing-resistant account access. In this guide's assessment, they do not say whether a passkey is a second factor; its search of that commit found no 2FA factor list, setup path or team 2FA requirement, which does not establish absence. - vast-team-2fa (REASONED; vast-account): At docs commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d, Vast.ai's 2FA API reference requires 2FA actions in the personal context, and the team-roles page's user_write scope covers 2FA settings. In this guide's reading, none of nine team, permission, security and 2FA pages documents a team-wide 2FA requirement, which does not establish absence. - lambda-ssh (REASONED; lambda): Lambda requires an SSH key at launch; keep the private key off the instance. - vast-ssh (REASONED; vast): Vast.ai disables SSH password authentication and uses the registered public key; keep the private key off the instance. - runpod-ssh (REASONED; runpod): RunPod recommends SSH keys and offers an optional password; use the key and skip the password. - verify-inventory (REASONED; runpod, vast, iproute2, network-namespaces): This guide infers from the ss namespace switch and Linux namespace isolation that ss -tlnp inventories TCP listeners only in the current network namespace, assuming normal live kernel/proc inputs without input-file environment overrides. It is not a UDP, firewall-state or platform-publication check; this guide's test design requires cross-checking platform mappings and probing each from outside. - verify-jupyter (REASONED; jupyter, jupyter-contents): This guide's test design compares anonymous and valid-token requests directly to loopback :8888/api/contents; any anonymous data response is a finding, in this guide's assessment. For Jupyter Server v2.18.0 with token authentication enabled, no session cookie, the route at that URL and an existing readable contents path, anonymous GET returns 403 without a login redirect. - verify-jupyter-token (REASONED; jupyter, jupyter-contents): For Jupyter Server v2.18.0 with token authentication enabled, no session cookie, default authorization, the route at :8888/api/contents and an existing readable path, valid-token GET completes the JSON model (the REST specification documents HTTP 200). Tokens are accepted in Authorization: token or the token query parameter; password-only and cookie-authenticated requests need separate controls. - verify-mapping (REASONED; runpod, vast, modal, jupyter): Repeat credential-free and authenticated requests per exposed mapping; anonymous app data fails, while a proxy 401 alone does not prove native listener auth and needs the direct positive control. - verify-portal-exempt (REASONED; vast-image): For an authenticated portal mapping at the cited commit, this guide expects resolver 200, exempt-path backend replies and a Basic challenge elsewhere, told apart only by a portal header known to differ from the backend. Anonymous known private content is a finding; transport failures and private-path backend 401s or unexplained replies are INCONCLUSIVE; other exempt-path replies are unclassified. - remote-desktop (REASONED; vast-image): At Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the linux-desktop launcher configures x11vnc on port 5900 with a password from VNC_PASSWORD, falling back to OPEN_BUTTON_TOKEN when VNC_PASSWORD is unset or empty. These citations do not establish other templates' ports or password defaults, or the strength of the effective VNC password. - remote-desktop-browser (REASONED; runpod, vast, vast-image): At Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the linux-desktop template README describes OPEN_BUTTON_TOKEN as randomly generated on first boot and visible in instance logs, and offers Selkies WebRTC and Guacamole browser VNC alongside direct VNC. This guide's advice: check both browser and direct VNC listeners and their public mappings for missing or weak authentication. - remote-desktop-direct (REASONED; runpod, vast, vast-image): In this guide's reasoning, a browser VNC page's HTTPS does not protect a separately reachable VNC connection, so a direct VNC mapping needs its own strong password and an encrypted tunnel, or should be kept off the public mapping. This guide's advice: test a direct VNC mapping with a VNC client, since browser VNC access controls and this guide's HTTP status checks may not cover it. ````json { "schema": 1, "checked": "2026-10-21", "documentation_checked": "2026-09", "body_sha256": "987e395d78900fcb8f247ee334e70cf832f1d272bbd202d0f2200133369a4335", "components": { "runpod": { "name": "RunPod documentation", "basis": "unknown", "sources": { "s817198f039ee": "https://docs.runpod.io/pods/configuration/expose-ports", "s13c43b856d4f": "https://docs.runpod.io/pods/configuration/use-ssh" } }, "vast": { "name": "Vast.ai documentation", "basis": "unknown", "sources": { "sc25989a1613e": "https://docs.vast.ai/guides/instances/connect/networking", "s0bdbda534723": "https://docs.vast.ai/guides/instances/connect/instance-portal", "s6e0303ef19fe": "https://docs.vast.ai/guides/instances/connect/ssh" } }, "vast-image": { "name": "Vast.ai base image", "basis": "00064421641881c1f83ff58c55f13cbc10cbea4d", "sources": { "sf75c3c07819e": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md", "s5d2b91002bac": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md#L175-L187", "s229c9334547e": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md#L135-L147", "s3b735c4b3632": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/README.md#L154-L158", "s037a7cbd778c": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/opt/instance-tools/tests/base/26-caddy-auth.sh#L97-L110", "sdc669faa429d": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/opt/supervisor-scripts/caddy.sh#L9-L14", "s0428d55e9145": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/derivatives/linux-desktop/ROOT/opt/supervisor-scripts/x11vnc.sh#L22-L24", "s1b8c6c46f358": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/derivatives/linux-desktop/README.template.md#L47-L50", "s44f3439f13de": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/derivatives/linux-desktop/README.template.md#L76-L80", "sd0ba21945c9a": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L14-L40", "sc77a7717bbc4": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L68-L71", "s3c43b58f9e41": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L75-L93", "sf1b910a375c6": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L125-L132", "sae1085635ff9": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L175-L186", "sd9c32e411f08": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L212-L240", "s5a43e0cf8348": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L293-L308", "sea35d951abe5": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L311-L321", "s4a05112af859": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L323-L387", "s5c18e60bb9e7": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/caddy_manager/caddy_config_manager.py#L400-L413", "s92be3bd5cdef": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/launch.sh#L77-L92", "s7ac7955e604d": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/opt/supervisor-scripts/syncthing.sh#L14-L16", "s10acc068e127": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/ROOT/etc/vast_boot.d/10-prep-env.sh#L12-L27", "s6d0bc9f2a0da": "https://github.com/vast-ai/base-image/blob/00064421641881c1f83ff58c55f13cbc10cbea4d/portal-aio/capabilities/manifest.py#L674-L694" } }, "lambda": { "name": "Lambda Public Cloud documentation", "basis": "unknown", "sources": { "se22064748974": "https://docs.lambda.ai/public-cloud/firewalls/", "sb12b4a953833": "https://docs.lambda.ai/public-cloud/on-demand/connecting-instance/", "s4d76884333e7": "https://docs.lambda.ai/public-cloud/console/", "s3b3e03a672e9": "https://docs.lambda.ai/public-cloud/enabling-sso/" } }, "modal": { "name": "Modal proxy authentication", "basis": "unknown", "sources": { "s7d083f819a21": "https://modal.com/docs/guide/webhook-proxy-auth", "s12a4f5e04dbe": "https://modal.com/docs/guide/shared-endpoints", "s33e1c2c2faf3": "https://modal.com/docs/guide/endpoints" } }, "jupyter": { "name": "Jupyter Server security", "basis": "unknown", "sources": { "sc703a8030c8f": "https://jupyter-server.readthedocs.io/en/latest/operators/security.html" } }, "jupyter-contents": { "name": "Jupyter Server contents authentication", "basis": "v2.18.0", "sources": { "sdc3ad9152505": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/services/contents/handlers.py#L83-L186", "sf0a3ea8ee42e": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/services/contents/handlers.py#L436-L445", "s4bdc7b9183f3": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/base/handlers.py#L782-L789", "s4e7504ed1ac8": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/base/handlers.py#L1201", "s52316685e1e4": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/auth/identity.py#L465-L516", "s83f1081e863e": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/auth/identity.py#L255-L298", "s6c884bdf8fb4": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/docs/source/operators/security.rst#L6-L26", "sb7fab0e85c94": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/docs/source/operators/security.rst#L315-L322", "s58a8a77857b5": "https://github.com/jupyter-server/jupyter_server/blob/v2.18.0/jupyter_server/services/api/api.yaml#L82-L137" } }, "ollama": { "name": "Ollama", "basis": "v0.34.2", "sources": { "s96f77aee8bdd": "https://github.com/ollama/ollama/blob/v0.34.2/server/routes.go", "sd146dc5d917d": "https://github.com/ollama/ollama/blob/v0.34.2/api/client.go", "s7478dad099ab": "https://github.com/ollama/ollama/blob/v0.34.2/cmd/cmd.go", "s2abe15b73bce": "https://github.com/ollama/ollama/blob/v0.34.2/envconfig/config.go" } }, "iproute2": { "name": "iproute2 ss manual", "basis": "v6.12.0", "sources": { "s2afc4764d5bb": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L33-L34", "sfaa932c2ca13": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L43-L44", "sd295f82d643f": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L162-L163", "s65278ee45850": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L374-L375", "s6c87acff18cf": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L358-L359", "sf8fdfa16eb3f": "https://github.com/iproute2/iproute2/blob/v6.12.0/misc/ss.c#L6114-L6117", "s1e96518d2ede": "https://github.com/iproute2/iproute2/blob/v6.12.0/misc/ss.c#L4235-L4240" } }, "network-namespaces": { "name": "Linux network namespaces manual", "basis": "man-pages-5.13", "sources": { "s427c923e346f": "https://github.com/mkerrisk/man-pages/blob/man-pages-5.13/man7/network_namespaces.7#L30-L40" } }, "runpod-account": { "name": "RunPod account documentation", "basis": "85a9f105d28e37758a43a44b1e75dca08fc1c33c", "sources": { "sd631388ab114": "https://github.com/runpod/docs/blob/85a9f105d28e37758a43a44b1e75dca08fc1c33c/get-started.mdx#L13-L17", "se14f2c54dadd": "https://github.com/runpod/docs/blob/85a9f105d28e37758a43a44b1e75dca08fc1c33c/accounts-billing/manage-accounts.mdx#L57-L74", "s33231187aa09": "https://github.com/runpod/docs/blob/85a9f105d28e37758a43a44b1e75dca08fc1c33c/release-notes.mdx#L387-L393" } }, "vast-account": { "name": "Vast.ai account authentication", "basis": "8629af9a05e884dd603b6a24bbb7c39826c1a10d", "sources": { "s34d7af02cefb": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/reference/two-factor-authentication.mdx#L14-L18", "s7d9050148a23": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/api-reference/two-factor-authentication-endpoints.mdx#L544", "s739e6a60fa5a": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/teams-roles.mdx#L48-L52", "sc07eac4162b4": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/api-reference/permissions.mdx#L14-L26", "s955a47268112": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/api-reference/permissions.mdx#L165-L181", "s80c87ae72e03": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/cli/permissions.mdx#L102-L104", "s79c4266fb354": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/teams-roles.mdx#L16-L20", "sca7b7577b245": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/teams-overview.mdx", "s3da159748d09": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/teams/managing-teams.mdx", "s1d50ed14f409": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/guides/reference/faq/security.mdx", "s49f3fc581793": "https://github.com/vast-ai/docs/blob/8629af9a05e884dd603b6a24bbb7c39826c1a10d/cli/two-factor-authentication.mdx" } }, "modal-account": { "name": "Modal account security", "basis": "unknown", "sources": { "sc231ebe09068": "https://modal.com/docs/guide/workspaces", "sbca5146e672b": "https://modal.com/docs/guide/security", "sda25bf7bdc76": "https://modal.com/docs/guide/saml-sso", "s50823c7faa20": "https://modal.com/docs/guide/okta-sso", "s3f8d08a8bfaf": "https://modal.com/docs/guide/entra-sso" } } }, "claims": { "publication": {"text": "Raw port publication or a firewall allowance adds no listener authentication; platform proxies authenticate only the routes they actually cover.", "components": ["runpod", "vast", "lambda", "modal"], "sources": ["runpod:s817198f039ee", "vast:sc25989a1613e", "vast:s0bdbda534723", "lambda:se22064748974", "modal:s7d083f819a21"], "status": "REASONED"}, "runpod-http": {"text": "Expose HTTP Ports publishes https://[POD_ID]-[INTERNAL_PORT].proxy.runpod.net with automatic HTTPS; anyone with the URL can reach it, so the application still needs authentication.", "components": ["runpod"], "sources": ["runpod:s817198f039ee"], "status": "REASONED"}, "runpod-tcp": {"text": "Exposed TCP ports forward directly on a public IP without automatic TLS; secure template listeners before exposure and implement application TLS for sensitive TCP data.", "components": ["runpod"], "sources": ["runpod:s817198f039ee"], "status": "REASONED"}, "runpod-symmetry": {"text": "TCP configuration values above 70000 request symmetrical mapping rather than naming valid ports; read the assigned mapping from the pod environment, such as RUNPOD_TCP_PORT_70000. Mapping remains public.", "components": ["runpod"], "sources": ["runpod:s817198f039ee"], "status": "REASONED"}, "vast-ssh-port": {"text": "Vast.ai SSH launch mode opens internal port 22 by default.", "components": ["vast"], "sources": ["vast:sc25989a1613e"], "status": "REASONED"}, "vast-jupyter-port": {"text": "Vast.ai Jupyter launch mode opens internal 8080 as well as SSH port 22 by default.", "components": ["vast"], "sources": ["vast:sc25989a1613e"], "status": "REASONED"}, "vast-mapping": {"text": "Each open Vast.ai internal port maps to a random external port on a usually shared public IP; the networking docs describe no additional firewall step.", "components": ["vast"], "sources": ["vast:sc25989a1613e"], "status": "REASONED"}, "vast-proxy": {"text": "Instance Portal uses local Caddy proxying when external and internal ports differ; forwarded apps can stay on loopback and PORTAL_CONFIG describes routing and presentation.", "components": ["vast"], "sources": ["vast:s0bdbda534723"], "status": "REASONED"}, "vast-token": {"text": "Portal links carry a credential token; protect the URL, test access without it and confirm the selected route actually authenticates.", "components": ["vast"], "sources": ["vast:s0bdbda534723"], "status": "REASONED"}, "vast-auth-controls": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the README documents OPEN_BUTTON_TOKEN for Basic/Bearer access, WEB_PASSWORD as a custom password, ENABLE_AUTH=false to disable authentication (default true), and AUTH_EXCLUDE as external ports excluded from authentication. The cited portal-aio configurator implements these; auth test code records expectations, not observed results.", "components": ["vast-image", "vast"], "sources": ["vast-image:sf75c3c07819e", "vast:s0bdbda534723", "vast-image:s5d2b91002bac", "vast-image:s229c9334547e", "vast-image:s3b735c4b3632", "vast-image:s037a7cbd778c", "vast-image:sdc669faa429d", "vast-image:sc77a7717bbc4", "vast-image:s3c43b58f9e41", "vast-image:sf1b910a375c6", "vast-image:sd9c32e411f08", "vast-image:sea35d951abe5", "vast-image:s4a05112af859"], "status": "REASONED"}, "vast-portal-routing": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, portal-aio reads PORTAL_CONFIG only if /etc/portal.yaml is absent, writes it there, and emits a Caddy listener only where external and internal ports differ and the external port has a VAST_TCP_PORT mapping; other entries bypass proxying and portal authentication. In this guide's assessment, routing configuration is not a credential.", "components": ["vast-image"], "sources": ["vast-image:sd0ba21945c9a", "vast-image:sd9c32e411f08"], "status": "REASONED"}, "vast-portal-jupyter": {"text": "Boot edits PORTAL_CONFIG: absent/empty 8080 mapping drops Jupyter; non-Jupyter /.launch drops it and Jupyter /.launch rewrites 8080:18080 to equal ports unless JUPYTER_OVERRIDE=true (case-insensitive). Re-add needs no /.launch, a nonempty mapping, no Jupyter entry and FORCE_JUPYTER not false (case-insensitive); /etc/portal.yaml wins, so only effective equal-port entries lose portal authentication.", "components": ["vast-image"], "sources": ["vast-image:s10acc068e127", "vast-image:sd9c32e411f08", "vast-image:sd0ba21945c9a"], "status": "REASONED"}, "vast-auth-defaults": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the configurator keeps auth on unless ENABLE_AUTH is false case-insensitively, serves plain HTTP unless ENABLE_HTTPS is true with valid certs, and defaults WEB_USERNAME to vastai. At that commit it gives WEB_PASSWORD and OPEN_BUTTON_TOKEN no precedence: an unset one mirrors the other; if neither is set, one generated value serves both.", "components": ["vast-image"], "sources": ["vast-image:s3c43b58f9e41"], "status": "REASONED"}, "vast-auth-routes": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, a protected port accepts either token as a token query parameter, a container-label auth cookie or an Authorization: Bearer header, sets the cookie on a valid query token, and falls back to HTTP Basic whose hashed password is WEB_PASSWORD.", "components": ["vast-image"], "sources": ["vast-image:sf1b910a375c6", "vast-image:s4a05112af859", "vast-image:sea35d951abe5"], "status": "REASONED"}, "vast-auth-exemptions": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, AUTH_EXCLUDE ports, matched externally, and every port under ENABLE_AUTH=false are proxied unauthenticated, an exposure. Even on authenticated ports, /portal-resolver answers anonymously and ACME challenge, change-password, manifest and security.txt paths proxy to the backend with no credential, an exposure for private content there.", "components": ["vast-image"], "sources": ["vast-image:sc77a7717bbc4", "vast-image:sd9c32e411f08", "vast-image:s5a43e0cf8348", "vast-image:sae1085635ff9", "vast-image:s4a05112af859"], "status": "REASONED"}, "vast-token-reuse": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the configurator prints the effective credentials to its console output, and OPEN_BUTTON_TOKEN is also the Syncthing GUI API key in the launcher and supervisor scripts (the supervisor's random fallback only when unset or empty). In this guide's reasoning, sharing a token-bearing link shares these reused credentials too.", "components": ["vast-image"], "sources": ["vast-image:s5c18e60bb9e7", "vast-image:s92be3bd5cdef", "vast-image:s7ac7955e604d"], "status": "REASONED"}, "vast-manifest-report": {"text": "At base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, manifest reporting and configurator enforcement lowercase ENABLE_AUTH and default unset to \"true\": reporting tests equality to \"true\", enforcement inequality to \"false\". Other values can be reported disabled yet enforced; the manifest reports, it does not enforce.", "components": ["vast-image"], "sources": ["vast-image:s6d0bc9f2a0da", "vast-image:s3c43b58f9e41"], "status": "REASONED"}, "lambda-default": {"text": "Lambda inbound firewall denies by default except ICMP and TCP 22; other listeners require explicit global/workspace rules or a per-instance ruleset attached at launch.", "components": ["lambda"], "sources": ["lambda:se22064748974"], "status": "REASONED"}, "lambda-rule": {"text": "An allow rule admits the specified source range without authenticating callers; pair it with listener or proxy authentication. Removing an allow rule closes access.", "components": ["lambda"], "sources": ["lambda:se22064748974"], "status": "REASONED"}, "modal-web": {"text": "Web endpoints using modal.fastapi_endpoint, the replacement for @web_endpoint, are public by default; requires_proxy_auth=True enables proxy authentication.", "components": ["modal"], "sources": ["modal:s7d083f819a21"], "status": "REASONED"}, "modal-dedicated": {"text": "Dedicated Endpoints require proxy authentication by default; --unauthenticated makes them public.", "components": ["modal"], "sources": ["modal:s7d083f819a21"], "status": "REASONED"}, "modal-server": {"text": "Servers require proxy authentication by default; @app.server(unauthenticated=True) makes them public.", "components": ["modal"], "sources": ["modal:s7d083f819a21"], "status": "REASONED"}, "modal-shared": {"text": "Shared Endpoints always require a Proxy Token; the cited pages document the --unauthenticated option for Dedicated Endpoints only, so, in this guide's assessment, a Shared Endpoint cannot be made public.", "components": ["modal"], "sources": ["modal:s7d083f819a21", "modal:s12a4f5e04dbe", "modal:s33e1c2c2faf3"], "status": "REASONED"}, "modal-credentials": {"text": "Proxy authentication accepts Modal-Key and Modal-Secret or Authorization: Bearer .; missing credentials on a protected endpoint return 401.", "components": ["modal"], "sources": ["modal:s7d083f819a21"], "status": "REASONED"}, "runpod-bind": {"text": "RunPod HTTP proxy needs the exposed pod interface: bind the exposed service to 0.0.0.0 inside the pod, not localhost.", "components": ["runpod"], "sources": ["runpod:s817198f039ee"], "status": "REASONED"}, "listener-auth": {"text": "Whichever binding the platform's proxy needs, add the listener's OWN authentication where it supports one; otherwise keep the unauthenticated backend private and put an authenticating gateway in front, so the platform exposes the gateway rather than the bare listener.", "components": ["runpod", "vast", "jupyter"], "sources": ["runpod:s817198f039ee", "vast:s0bdbda534723", "jupyter:sc703a8030c8f"], "status": "REASONED"}, "listener-auth-ollama": {"text": "At Ollama v0.34.2, the cited local API route setup installs CORS and host filtering without an inbound credential check. Cloud sign-in and OLLAMA_AUTH client outbound signing are separate from local inbound authentication.", "components": ["ollama"], "sources": ["ollama:s96f77aee8bdd", "ollama:sd146dc5d917d", "ollama:s7478dad099ab", "ollama:s2abe15b73bce"], "status": "REASONED"}, "mfa": {"text": "Advice: enable account MFA on RunPod, Vast.ai and Lambda, and on Modal where a customer control is documented; verify enforcement. Use an MFA-enforcing IdP for SSO and, where Modal's plan allows, Require SSO after confirming Workspace admin access through the IdP; this guide cites no workload second factor and treats API keys and proxy tokens as separate machine-client controls.", "components": ["modal", "jupyter", "runpod-account", "vast-account", "lambda", "modal-account"], "sources": ["modal:s7d083f819a21", "jupyter:sc703a8030c8f", "runpod-account:sd631388ab114", "runpod-account:se14f2c54dadd", "vast-account:s34d7af02cefb", "lambda:s4d76884333e7", "lambda:s3b3e03a672e9", "modal-account:sc231ebe09068", "modal-account:sbca5146e672b", "modal-account:sda25bf7bdc76", "modal-account:s50823c7faa20", "modal-account:s3f8d08a8bfaf"], "status": "REASONED"}, "lambda-sso": {"text": "At the time of writing, Lambda's SSO page documents sign-in through an organization's IdP, with Custom SAML or Custom OIDC options; prerequisites include a 1-Click Cluster and the Admin role, and setup is by invitation from the Lambda account manager. This guide infers that an MFA-enforcing IdP can enforce MFA for SSO sign-ins; the page shows no Lambda-side MFA enforcement or SSO-only sign-in.", "components": ["lambda"], "sources": ["lambda:s3b3e03a672e9"], "status": "REASONED"}, "modal-sso": {"text": "At the time of writing, Modal's SAML, Okta and Entra SSO pages place SSO on the Enterprise plan and say enabling Require SSO forces all users to sign in via SSO, documenting no exception; the security page requires employee MFA. This guide infers an MFA-enforcing IdP can enforce MFA for SSO sign-ins but not API tokens; no cited page shows a customer second factor or customer MFA enforcement.", "components": ["modal-account"], "sources": ["modal-account:sc231ebe09068", "modal-account:sbca5146e672b", "modal-account:sda25bf7bdc76", "modal-account:s50823c7faa20", "modal-account:s3f8d08a8bfaf"], "status": "REASONED"}, "account-mfa-gaps": {"text": "This guide's source assessment: RunPod's quickstart recommends 2FA, Vast.ai documents TOTP or SMS methods with an email backup, and Lambda's console page places MFA setup in User settings. These sources do not establish RunPod or Lambda factor methods, setup steps or enforcement, Vast.ai team-wide enforcement, whether Lambda SSO can be the only sign-in path, or exceptions to Modal's Require SSO.", "components": ["runpod-account", "vast-account", "lambda", "modal-account"], "sources": ["runpod-account:sd631388ab114", "runpod-account:se14f2c54dadd", "vast-account:s34d7af02cefb", "lambda:s4d76884333e7", "lambda:s3b3e03a672e9", "modal-account:sda25bf7bdc76", "modal-account:s50823c7faa20", "modal-account:s3f8d08a8bfaf"], "status": "REASONED"}, "runpod-passkey": {"text": "RunPod's product updates at docs commit 85a9f105d28e37758a43a44b1e75dca08fc1c33c document Passkey auth (November 2024) as passwordless WebAuthn sign-in for phishing-resistant account access. In this guide's assessment, they do not say whether a passkey is a second factor; its search of that commit found no 2FA factor list, setup path or team 2FA requirement, which does not establish absence.", "components": ["runpod-account"], "sources": ["runpod-account:s33231187aa09"], "status": "REASONED"}, "vast-team-2fa": {"text": "At docs commit 8629af9a05e884dd603b6a24bbb7c39826c1a10d, Vast.ai's 2FA API reference requires 2FA actions in the personal context, and the team-roles page's user_write scope covers 2FA settings. In this guide's reading, none of nine team, permission, security and 2FA pages documents a team-wide 2FA requirement, which does not establish absence.", "components": ["vast-account"], "sources": ["vast-account:s7d9050148a23", "vast-account:s739e6a60fa5a", "vast-account:s34d7af02cefb", "vast-account:sc07eac4162b4", "vast-account:s955a47268112", "vast-account:s80c87ae72e03", "vast-account:s79c4266fb354", "vast-account:sca7b7577b245", "vast-account:s3da159748d09", "vast-account:s1d50ed14f409", "vast-account:s49f3fc581793"], "status": "REASONED"}, "lambda-ssh": {"text": "Lambda requires an SSH key at launch; keep the private key off the instance.", "components": ["lambda"], "sources": ["lambda:sb12b4a953833"], "status": "REASONED"}, "vast-ssh": {"text": "Vast.ai disables SSH password authentication and uses the registered public key; keep the private key off the instance.", "components": ["vast"], "sources": ["vast:s6e0303ef19fe"], "status": "REASONED"}, "runpod-ssh": {"text": "RunPod recommends SSH keys and offers an optional password; use the key and skip the password.", "components": ["runpod"], "sources": ["runpod:s13c43b856d4f"], "status": "REASONED"}, "verify-inventory": {"text": "This guide infers from the ss namespace switch and Linux namespace isolation that ss -tlnp inventories TCP listeners only in the current network namespace, assuming normal live kernel/proc inputs without input-file environment overrides. It is not a UDP, firewall-state or platform-publication check; this guide's test design requires cross-checking platform mappings and probing each from outside.", "components": ["runpod", "vast", "iproute2", "network-namespaces"], "sources": ["runpod:s817198f039ee", "vast:sc25989a1613e", "iproute2:s2afc4764d5bb", "iproute2:sfaa932c2ca13", "iproute2:sd295f82d643f", "iproute2:s65278ee45850", "iproute2:s6c87acff18cf", "iproute2:sf8fdfa16eb3f", "iproute2:s1e96518d2ede", "network-namespaces:s427c923e346f"], "status": "REASONED", "verify": [1]}, "verify-jupyter": {"text": "This guide's test design compares anonymous and valid-token requests directly to loopback :8888/api/contents; any anonymous data response is a finding, in this guide's assessment. For Jupyter Server v2.18.0 with token authentication enabled, no session cookie, the route at that URL and an existing readable contents path, anonymous GET returns 403 without a login redirect.", "components": ["jupyter", "jupyter-contents"], "sources": ["jupyter:sc703a8030c8f", "jupyter-contents:sdc3ad9152505", "jupyter-contents:sf0a3ea8ee42e", "jupyter-contents:s4bdc7b9183f3", "jupyter-contents:s4e7504ed1ac8", "jupyter-contents:s83f1081e863e", "jupyter-contents:s6c884bdf8fb4"], "status": "REASONED", "verify": [1]}, "verify-jupyter-token": {"text": "For Jupyter Server v2.18.0 with token authentication enabled, no session cookie, default authorization, the route at :8888/api/contents and an existing readable path, valid-token GET completes the JSON model (the REST specification documents HTTP 200). Tokens are accepted in Authorization: token or the token query parameter; password-only and cookie-authenticated requests need separate controls.", "components": ["jupyter", "jupyter-contents"], "sources": ["jupyter:sc703a8030c8f", "jupyter-contents:sdc3ad9152505", "jupyter-contents:sf0a3ea8ee42e", "jupyter-contents:s4e7504ed1ac8", "jupyter-contents:s52316685e1e4", "jupyter-contents:s83f1081e863e", "jupyter-contents:s6c884bdf8fb4", "jupyter-contents:sb7fab0e85c94", "jupyter-contents:s58a8a77857b5"], "status": "REASONED", "verify": [1]}, "verify-mapping": {"text": "Repeat credential-free and authenticated requests per exposed mapping; anonymous app data fails, while a proxy 401 alone does not prove native listener auth and needs the direct positive control.", "components": ["runpod", "vast", "modal", "jupyter"], "sources": ["runpod:s817198f039ee", "vast:sc25989a1613e", "vast:s0bdbda534723", "modal:s7d083f819a21", "jupyter:sc703a8030c8f"], "status": "REASONED", "verify": [2]}, "verify-portal-exempt": {"text": "For an authenticated portal mapping at the cited commit, this guide expects resolver 200, exempt-path backend replies and a Basic challenge elsewhere, told apart only by a portal header known to differ from the backend. Anonymous known private content is a finding; transport failures and private-path backend 401s or unexplained replies are INCONCLUSIVE; other exempt-path replies are unclassified.", "components": ["vast-image"], "sources": ["vast-image:sae1085635ff9", "vast-image:s4a05112af859", "vast-image:sd9c32e411f08", "vast-image:s3c43b58f9e41"], "status": "REASONED", "verify": [3]}, "remote-desktop": {"text": "At Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the linux-desktop launcher configures x11vnc on port 5900 with a password from VNC_PASSWORD, falling back to OPEN_BUTTON_TOKEN when VNC_PASSWORD is unset or empty. These citations do not establish other templates' ports or password defaults, or the strength of the effective VNC password.", "components": ["vast-image"], "sources": ["vast-image:s0428d55e9145", "vast-image:s44f3439f13de"], "status": "REASONED"}, "remote-desktop-browser": {"text": "At Vast.ai base-image commit 00064421641881c1f83ff58c55f13cbc10cbea4d, the linux-desktop template README describes OPEN_BUTTON_TOKEN as randomly generated on first boot and visible in instance logs, and offers Selkies WebRTC and Guacamole browser VNC alongside direct VNC. This guide's advice: check both browser and direct VNC listeners and their public mappings for missing or weak authentication.", "components": ["runpod", "vast", "vast-image"], "sources": ["runpod:s817198f039ee", "vast:sc25989a1613e", "vast-image:s1b8c6c46f358", "vast-image:s44f3439f13de"], "status": "REASONED"}, "remote-desktop-direct": {"text": "In this guide's reasoning, a browser VNC page's HTTPS does not protect a separately reachable VNC connection, so a direct VNC mapping needs its own strong password and an encrypted tunnel, or should be kept off the public mapping. This guide's advice: test a direct VNC mapping with a VNC client, since browser VNC access controls and this guide's HTTP status checks may not cover it.", "components": ["runpod", "vast", "vast-image"], "sources": ["runpod:s817198f039ee", "vast:sc25989a1613e", "vast-image:s1b8c6c46f358"], "status": "REASONED"} } } ```` ====================================================================== ==> deployment-lifecycle.md ====================================================================== # Deploying safely over time: verify from outside, safe order, previews, teardown A deployment's lifecycle runs from a service's first launch to its retirement, through previews, restores and upgrades. This guide helps your AI assistant keep the service secure throughout, including checks from a second network, a safe first-deployment order, production protections on previews, DNS removed first at teardown, and offboarding. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/deployment-lifecycle.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Nmap documentation (no recorded version), BIND dig documentation 9.18.39, OpenSSL command documentation 3.0, OpenBSD nc manual 7.6, OWASP Web Security Testing Guide v4.2, OAuth2 Proxy v7.15.6, Authelia v4.39.28, and NIST digital identity guidelines SP 800-63B-4, plus 2 more recorded bases; 9 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 22 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. This repository's per-service guides check TLS, binding, and authentication from inside the host and at the moment you set them up. Real exposure is judged from outside the host, and it drifts: a preview URL, a restored backup, or an upgrade can reopen something already closed. This guide is the lifecycle layer around the per-service checks. ## 1. Verify from outside, not just from the host A `curl` or `ss -tlnp` run on the host itself cannot see a firewall the host does not know about, or a platform-level bypass like Docker's iptables rules ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). Verify from a second network: a phone hotspot, a cloud shell, or a second VM. - Port-by-port with one `nc -vz -w 3` probe for each of the ports the commands below list, or a full sweep with `nmap -Pn -p-` (per the nmap reference, which documents `-p-` and `-p 1-65535` as equivalent port-range syntax). The full sweep, per the same nmap reference, covers TCP only, so inventory and separately probe any UDP service with `nmap -sU` on its ports. The scanning rule, per the same nmap reference, is to only scan hosts you own or are authorized to test. Run either form only through the [guarded Verify block](#verify), which refuses an unsubstituted address before probing, rather than with the address typed into a bare command. The IPv4 block runs the full sweep as `nmap -Pn -p- "$1"` in the condition of an `if`; for the port-by-port form, replace only that `nmap -Pn -p- "$1"` with `nc -vz -w 3 "$1" 22; nc -vz -w 3 "$1" 80; nc -vz -w 3 "$1" 443; nc -vz -w 3 "$1" 3000; nc -vz -w 3 "$1" 5432; nc -vz -w 3 "$1" 6379; nc -vz -w 3 "$1" 27017`, keeping the whole guard and substituting inside the quotes on `set --`. With that swap the block exits 3 whatever the ports do, and its nmap messages do not apply, so read nc's own line for each port instead of the block's status. As this guide's assessment, that is because netcat-openbsd prints those lines on standard error, which the block does not capture, so the block never sees an nmap report. Ubuntu noble netcat-openbsd documents `-v` as verbose output, `-z` as scanning without sending data and `-w` as a connection/idle timeout. BusyBox 1.36.1 small `nc` lacks `-v` and `-z`, and has `-w` only with `NC_EXTRA`; its `NC_110_COMPAT` applet lists `-v` and `-w`, with `-z` conditional on `NC_EXTRA`. CentOS Stream 9 packages Ncat 7.92 as an `nc` alternative; Ncat 7.92's option behaviour is not established here, nor is traditional or GNU netcat behaviour. Check the installed variant. As this guide's assessment, refusal or timeout shows only that this connection failed; an unsupported-option error, local error or silent exit is inconclusive. - Check every address the service actually has, not just the one you remember configuring: the public IPv4 address, the public IPv6 address if the host has one, and any platform-assigned URL alongside your custom domain (a PaaS default subdomain, per [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md), often stays reachable even when the custom domain is fronted). A scan of one address that misses the others is not a clean result, it is an incomplete one. - Cross-reference what is already indexed about your IP with a passive internet-wide scanner such as Shodan or Censys; both build a continuously updated index of internet-connected hosts and services, so a stale exposure can show up there before you find it yourself ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) covers the firewall rules this is checking). ## 2. Safe initialization order This guide advises the following first-deployment order, keeping application access closed until initialization and the fronting gate are ready: 1. Create the owner or admin account's credentials and any signing secrets (session, JWT, or cookie-signing keys) per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md); the ordering is this guide's advice, and signing secrets follow that guide's recommendation. 2. Set the registration policy (disable open self-registration, or restrict it to an allowlisted domain) before opening application access, as this guide advises; [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) covers allowlisting after federated login. 3. Put the fronting gate in place, TLS and any proxy-level authentication, per [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) and the fronting-layer guides. For ACME, this guide advises DNS-01 issuance before opening inbound access, or opening only the challenge port during issuance while keeping application access blocked: HTTP-01 requires inbound port 80 and TLS-ALPN-01 requires inbound port 443, whereas DNS-01 needs no inbound port, per the [prerequisite list](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md#prerequisites). These challenge requirements do not establish this deployment order. Cloudflare recommends creating the Access application before the tunnel route and warns that without it the published application is available to anyone on the Internet; an already publicly routable origin needs its IP protected by other methods, per the pinned self-hosted application instructions. 4. Only then open application network access, as this guide advises. For applications that give admin to whoever completes first-run setup, an unclaimed setup wizard is a race to become admin, open to whoever completes it first. [Open WebUI](https://github.com/jposluns/secureconfig/blob/main/open-webui.md) at commit 0a7c15832fb30b1903753e83f81dc7d27e5b0944 is one example: its signup handler makes the first account admin; with authentication enabled, ENABLE_SIGNUP and ENABLE_LOGIN_FORM default to true, and an empty database bypasses ENABLE_SIGNUP when the login form is enabled. Once accounts exist, disabled signup refuses registration. These Open WebUI paths do not establish other applications' bootstrap, signing-secret, registration or domain-enforcement behaviour. After initialization, confirm the setup route itself is closed: restart the service and try hitting the initial-setup URL again; it must refuse to mint a second admin, not silently succeed (this guide's test expectation). The Open WebUI signup handler illustrates bootstrap and signup refusal, not every application's setup route or a deployed restart result. ## 3. Previews and clones get production posture A preview deployment or a database clone is not lower stakes just because it is temporary. Vercel's Deployment Protection illustrates the gap: Standard Protection, available on every plan, gates preview and generated deployment URLs but leaves the production domain open by default; the All Deployments scope closes that gap too, and Vercel's September 9, 2026 change made pairing it with Vercel Authentication free on every plan rather than Pro and Enterprise only. The previous sentence is per [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md) and per Vercel's Deployment Protection changelog of 9 September 2026 and its current Deployment Protection reference, which states that Vercel Authentication for All Deployments does not require a paid add-on. Where the platform's own gate does not cover a hostname, front it the same way as production, for example a Cloudflare Access policy scoped to that preview hostname, per the pinned self-hosted application instructions. Those instructions establish the Access gate, not the platform's coverage of preview URLs. Either way, as this guide's advice, treat a clone's data the same as the original: rotate any credential a clone inherited if the clone is less trusted than the source. That a clone can inherit credentials is this guide's assessment; clone credential inheritance and rotation are not established by the Access citation, which supplies fronting context only. ## 4. Teardown: DNS before the app, then revoke the rest Retiring a deployment in the wrong order can leave a dangling DNS record pointing at a resource someone else can claim (a subdomain takeover); OWASP's Subdomain Takeover Prevention Cheat Sheet describes this mechanism, and WSTG v4.2 identifies an inactive target and inadequate ownership verification as preconditions. Delete the DNS record (the `CNAME` or `A`/`AAAA`) before you delete or release the underlying app, load balancer, or IP, and wait at least the record's old TTL before decommissioning the resource, following OWASP's order; WSTG supports removing vulnerable records but does not specify that wait. RFC 1034 section 3.6 and RFC 1035 section 3.2.1 describe resolver caching and TTL, with TTL bounds clarified by RFC 2181 section 8 and updated by RFC 8767 section 4. **TTL expiry is not a hard bound: RFC 8767 permits serving stale records when they cannot be authoritatively refreshed.** This guide infers that deleting a record does not flush cached answers, so immediately releasing a reclaimable IP or hostname can still send cached clients to its next owner. OWASP describes claimable CNAME targets and released IPs that an attacker obtains after reassignment; the cited files do not establish a particular platform's current reclaimability. This guide advises keeping the resource reserved through the old TTL and until removal is confirmed at every authoritative nameserver, and accounting for serve-stale before release; authoritative removal and elapsed TTL do not prove that every resolver has stopped returning the old mapping. Then revoke what pointed at it: access policies (Cloudflare Access or equivalent), API tokens and service credentials scoped to that deployment ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)), and database users created only for it. This remains the guide's retirement requirement: OWASP Secrets Management requires secure revocation of secrets that are no longer required or potentially compromised. The Access-policy citation covers fronting context only, and the cited files do not establish removal of an Access application or policy or dropping database users. ## 5. Re-verify after anything changes Rerun the outside-in checks in section 1 and the negative auth tests from [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) after an upgrade, a backup restore (which, as this guide's assessment, can reintroduce a default account or reset a feature flag), or any change to network policy or auth configuration. Docker's pinned 28.x release notes give concrete change-related cases: 28.0.0 sets IPv6 `FORWARD` policy to `DROP` only when Docker enables forwarding itself, warning that hosts with forwarding already enabled that relied on Docker setting `DROP` may need configuration changes. The same pinned 28.x release notes give another concrete change-related case: 28.3.3 fixes direct local-network access to published container ports after a firewalld reload, even when intended for loopback-only access. Open WebUI's pinned configuration supplies an environment-derived signup default and enables persistence by default. As this guide's conditional inference for restores, if database values override the environment after first launch as described in [open-webui.md](https://github.com/jposluns/secureconfig/blob/main/open-webui.md), restoring earlier database values could reinstate earlier signup settings. The configuration this guide cites delegates to `models.config.Config`, whose implementation this guide does not cite, so these files do not establish that precedence or general restore behaviour. Nmap supports scanning, not application restore behaviour. Monitor certificate expiry from outside the host too: as this guide's assessment, a renewal cron can silently fail while the host's own view still looks fine, so this guide's advice is an external check (a scheduled `openssl s_client` from another machine, or a third-party uptime/certificate monitor). Certbot v2.3.0's `certbot renew --dry-run` uses the staging test server unless a custom server is set, obtains test certificates without saving them and does not run deploy hooks unless `--run-deploy-hooks` is supplied; applicable deploy hooks then run only after a successful dry run, using the current active certificate. The option documentation also warns that dry runs can temporarily change webserver configuration, reload webservers and run pre/post hooks; the user guide's dry-run example distinguishes it from a subsequent live renewal. From these documented limits, this guide infers that a dry run cannot show whether the last real renewal succeeded; Nmap is outside-check context only. ## 6. Offboarding When a person leaves, revoke access at every layer they touched, not only their password: identity-provider membership (the SSO or directory account), proxy-level sessions (Cloudflare Access or equivalent), application-level sessions on each service, and any personal access tokens or API keys issued to them (this guide's recommendation, as in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). Microsoft Entra documents blocking new sign-ins and revoking refresh tokens, but the application must revoke its own session; access-token lifetimes vary, and CAE critical-event enforcement can take up to 15 minutes for supported services. Cloudflare Access documents per-user session revocation, but an active IdP profile can start a new session; after user logout, previously issued tokens stop being accepted in 20-30 seconds. OAuth2 Proxy v7.15.6 documents cookie expiry and refresh, and Authelia v4.39.28 documents cookie expiry and session destruction, overridden by remember-me; these passages do not establish immediate per-user revocation for either proxy. RFC 7009 section 2.1 specifies immediate token invalidation with possible propagation delay and conditional access-token revocation support. OWASP requires secure revocation of unneeded or potentially compromised secrets. NIST SP 800-63B-4 section 4.5 requires CSPs to promptly invalidate authenticators when the CSP determines that the subscriber no longer meets its eligibility requirements. Where a system cannot revoke immediately, such as a session cached until its own expiry, measure that system's maximum time-to-revoke and work to shrink it (this guide's recommendation, as in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). The cited files do not establish a provider-membership removal mechanism, a session-revocation mechanism for every application, or this deployment's measured maximum time-to-revoke. For delayed invalidation checks, follow the REASONED offboarding replay check in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md#quick-checks): matched requests must succeed before revocation, then each old credential must be refused and return no protected data within its configured maximum time-to-revoke; these are test expectations, not observed results. [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) covers revoking the second factor alongside the account. ## Common mistakes - Verifying only the custom domain and forgetting the platform's own generated URL, which is often still reachable and unauthenticated. - Deleting the app first and the DNS record later, leaving the dangling-CNAME window OWASP warns about when the target can be claimed; waiting only for TTL expiry also misses the serve-stale exception in RFC 8767. - Trusting a local `certbot renew --dry-run` as proof that production certificates are actually renewing; Certbot v2.3.0 documents test certificates without saving them, staging unless a custom server is set, and deploy hooks only with `--run-deploy-hooks` after a successful dry run, using the active certificate. This guide infers that this test does not establish whether the last real renewal succeeded. ## Verify REASONED: outside-network scans, DNS retirement and certificate inspection; no deployment, authoritative DNS zone or second-network probe host was supplied for this metadata review, and no live run is recorded. The commands and expected readings below follow the cited Nmap, dig and OpenSSL 3.0 documentation and the guide's lifecycle reasoning; Sources has no curl reference. DNS response interpretations follow RFC 2308 section 2, and cache timing follows the cited DNS RFCs, including RFC 8767's serve-stale exception; the authoritative-removal checks are this guide's advice and do not establish that all recursive caches have refreshed. The DNS-retirement and certificate checks take the retired host name and the live app's host name on guarded `set --` lines that refuse example.com, example.net, example.org and their subdomains. The IPv4 and IPv6 scan checks take the public address on guarded `set --` lines, and before nmap runs each refuses with status 2 a shell other than bash and then any value that is not a literal address of its own family, including every host name, such as example.com. The IPv6 check accepts only hexadecimal groups and colons, so it also refuses the literal IPv6 forms with a dotted IPv4 part, such as ::ffff:203.0.113.10 and 64:ff9b::192.0.2.1, and zoned addresses. Stub runs on 2026-11-01 covered all four blocks, the scan blocks in their earlier form without the bash and address refusals, under Bash 5.3.9, with curl, dig, openssl and nmap replaced by local stubs and no network, but no run reached the retired-name DNS block's status 1, the certificate block's x509 step or status 0, or a scan block's reading of an empty report or a host that is down. In each block an unsubstituted placeholder, a second value on the `set --` line and a paste without the `set --` line, with or without caller arguments, were each refused with status 2, the block's own message and no stub call. The retired-name DNS block and the certificate block each refused example.com, EXAMPLE.COM, Example.Org, example.NET, app.example.net, APP.EXAMPLE.NET, Api.Example.Com and deep.sub.EXAMPLE.org with status 2, the reserved-name message and no stub call, and a host with a scheme with status 2 and the host-format message. In each of those two blocks EXAMPLE.COM and App.Example.ORG were refused the same way with `tr` absent from PATH and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and an inherited `nocasematch` was still set after the block. The host names status.acme-widgets.io, also with `tr` absent and under each of those preludes, and Status.Acme-Widgets.IO were not refused: the DNS block made one curl and three dig calls and exited 3, and the certificate block made one openssl call and exited 3 when the stub returned 1. In that earlier form the IPv4 and IPv6 scan blocks had no reserved-name refusal: an address, also with `tr` absent and under each of those preludes, and the value example.com each reached one nmap call, and the block exited 0 against the stub's host-up report. Under dash 0.5.12 and BusyBox 1.37.0 sh, the DNS and certificate blocks refused the eight reserved names and status.acme-widgets.io alike with status 2 and the message that the block needs bash, with no stub call; the placeholder and the partial paste got their own messages instead. Under those two shells the earlier scan form refused the placeholder and the partial paste with status 2 and their own messages; an address reached the nmap call, and the block then exited 0 under BusyBox sh and 3 under dash, which lacks bash's `$'\n'` quoting and printed nmap's report where its exit status belongs. In the current form each scan block's guards run in this order: the `set --` marker, the value count, the placeholder, the bash check and then the address check, so a value is refused by the first guard it fails. Stub runs on 2026-11-02 covered the current scan blocks under Bash 5.3.9, dash 0.5.12 and BusyBox 1.37.0 sh, each under the C and tr_TR.UTF-8 locales, with nmap replaced by a local stub and no network. In those runs each scan block refused the unsubstituted placeholder, a second value and a paste without the `set --` line, with or without caller arguments, with status 2, its own message and no nmap call, under all three shells. Under Bash both blocks refused example.com, EXAMPLE.COM, Example.Org, example.NET, app.example.net, APP.EXAMPLE.NET, Api.Example.Com, deep.sub.EXAMPLE.org, status.acme-widgets.io, Status.Acme-Widgets.IO and localhost with status 2, the block's address message and no nmap call. The same message refused, in the IPv4 block, 203.0.113.256, 256.1.1.1, 203.0.113.010, 203.0.113, 203.0.113.10.5, 1.2.3.4/24, 203.0.113.10:22, 203.0.113.1-10, a value with a space or a newline and 2001:db8::10, each with no nmap call. In the IPv6 block it refused 2001:db8::10%eth0, [2001:db8::10], 2001:db8::/64, ::ffff:203.0.113.10, 1::2:3:4:5:6:7:8, 1::2::3, 12345::1 and 203.0.113.10, each with no nmap call. Under Bash, 203.0.113.10, 0.0.0.0 and 255.255.255.255 in the IPv4 block, and 2001:db8::10, 2001:DB8::10, ::1 and 1:2:3:4:5:6:7:8 in the IPv6 block, each made one nmap call and exited 0 against the stub's host-up report. Under Bash, after one nmap call, 203.0.113.10 and 2001:db8::10 exited 3 against a host-down report, an empty report and an nmap exit status of 1. Under Bash with an inherited `set -e`, readonly and `declare -i` variables, or `nocasematch`, both blocks still refused example.com, EXAMPLE.COM, App.Example.ORG and status.acme-widgets.io with status 2 and no nmap call, an address still exited 0, and an inherited `nocasematch` was still set after the block. Under dash and BusyBox sh both current scan blocks refused every value that passed the placeholder guard, addresses and host names alike, with status 2, the message that the block needs bash and no nmap call. Each of these results was the same under the C and tr_TR.UTF-8 locales, and, run on its own over about 2.3 million generated IPv4 and 1.6 million generated IPv6 candidates, none with a dot or a zone in the IPv6 set, each block's address check accepted exactly the values that Python 3.14.4's ipaddress module accepts. No other shell, including Bash 3.2, zsh or ksh, was run, and no live scan, DNS or certificate result is recorded. The four checks are separate blocks, each with its own exit status: the IPv4 scan, the IPv6 scan, the retired-name DNS check and the certificate check. The retired-name block cannot exit 0 by design, because it reads your configured resolver only: it exits 1 for any answer and 3 otherwise, and only the authoritative check in its comment can establish removal. ```bash # From a second network, not the host itself: ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # the reading below uses bash quoting, so any other shell stops here, before nmap runs case "$1" in *.*.*.*.*|*[!0123456789.]*|.*|*.|*..*|*[0123456789][0123456789][0123456789][0123456789]*|0[0123456789]*|*.0[0123456789]*|[3456789][0123456789][0123456789]*|*.[3456789][0123456789][0123456789]*|2[6789][0123456789]*|*.2[6789][0123456789]*|25[6789]*|*.25[6789]*) echo "substitute your own IPv4 address (four dot-separated decimal numbers from 0 to 255 with no leading zero, and no host name, port or prefix length) inside the quotes on the set -- line above; not probing"; exit 2 ;; *.*.*.*) ;; # four decimal numbers from 0 to 255: nmap's target is an address, never a host name *) echo "substitute your own IPv4 address (four dot-separated decimal numbers from 0 to 255 with no leading zero, and no host name, port or prefix length) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$(if nmap -Pn -p- "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # TCP only: only the intended ports answer here; probe any UDP service separately (nmap -sU) set -- "${1%$'\n'*}" "${1##*$'\n'}" # nmap's report, then its exit status printf '%s' "$1" # and the scan actually ran: nmap reporting a host down, a permission error, or no output at all is # inconclusive, not a clean result case "$2:$1" in 0:|0:*'(0 hosts up)'*) echo "nmap printed no report or reported the host down; inconclusive, not a clean result"; exit 3 ;; 0:*) echo "block exit 0; the open ports nmap lists above are not classified, so judge them yourself"; exit 0 ;; *) echo "nmap exit $2: the scan did not complete; inconclusive, not a clean result"; exit 3 ;; esac ) ``` REASONED: following block; outside-network IPv6 scan, per the cited Nmap documentation; no deployment or second-network probe host was supplied, and no live run is recorded. Run it only when the host has a public IPv6 address, and skip it otherwise, because an unsubstituted placeholder is refused with status 2. Before nmap runs it also refuses with status 2 a shell other than bash and then any value that is not a literal IPv6 address written only in hexadecimal groups and colons, so it refuses every host name, a bracketed or prefixed address, and the literal forms with a dotted IPv4 part, such as ::ffff:203.0.113.10 and 64:ff9b::192.0.2.1, or a zone, such as 2001:db8::10%eth0. Like the IPv4 block, it exits 0 when nmap ran and reported the host up, leaving the open ports it lists for you to judge, and 3 when the scan failed, printed nothing or reported the host down. ```bash # From a second network, not the host itself: ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IPV6' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # the reading below uses bash quoting, so any other shell stops here, before nmap runs case "$1" in *[!0123456789abcdefABCDEF:]*|:|:[!:]*|*[!:]:|*:::*|*::*::*|*[0123456789abcdefABCDEF][0123456789abcdefABCDEF][0123456789abcdefABCDEF][0123456789abcdefABCDEF][0123456789abcdefABCDEF]*|::*:*:*:*:*:*:*:*|*:*:*:*:*:*:*:*::|[!:]*:*:*:*:*:*:*:*:*[!:]) echo "substitute your own IPv6 address (groups of one to four hexadecimal digits separated by colons, with at most one ::, and no host name, brackets, zone, prefix length or dotted IPv4 part) inside the quotes on the set -- line above; not probing"; exit 2 ;; *::*|*:*:*:*:*:*:*:*) ;; # eight groups, or fewer with one ::, so nmap's target is an address, never a host name *) echo "substitute your own IPv6 address (groups of one to four hexadecimal digits separated by colons, with at most one ::, and no host name, brackets, zone, prefix length or dotted IPv4 part) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$(if nmap -Pn -6 -p- "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # same, over the public IPv6 address (TCP only) set -- "${1%$'\n'*}" "${1##*$'\n'}" # nmap's report, then its exit status printf '%s' "$1" # same reading: a scan that did not run is not a clean scan case "$2:$1" in 0:|0:*'(0 hosts up)'*) echo "nmap printed no report or reported the host down; inconclusive, not a clean result"; exit 3 ;; 0:*) echo "block exit 0; the open ports nmap lists above are not classified, so judge them yourself"; exit 0 ;; *) echo "nmap exit $2: the scan did not complete; inconclusive, not a clean result"; exit 3 ;; esac ) ``` REASONED: following block; retired-name DNS check, per the cited dig documentation and DNS RFCs, including RFC 2308 section 2; no retired host name or authoritative DNS zone was supplied, and no live run is recorded. This block cannot exit 0 by design: it reads your configured resolver only, so it exits 1 when that resolver returns any A, AAAA or CNAME answer and 3 otherwise, including empty answers and a failed lookup or request. Removal is decided by the authoritative check in its comment, `dig @AUTH_NS name TYPE +norecurse` against each authoritative nameserver, which must give NXDOMAIN or NOERROR/NODATA with no CNAME. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_RETIRED_HOST' # the retired preview or service host name whose DNS record you removed, no scheme, user name, port, path, query, fragment, percent sign, leading hyphen, leading plus sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|-*|+*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the retired host name (no scheme, user name, port, path, query, fragment, percent sign, leading hyphen, leading plus sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), and no lookup returns it to 0 curl -q -g -sI "https://$1/" || # a connection/DNS/TLS failure here is NOT proof the record is gone (a dangling record to a released target fails the same way); use the dig check below, and confirm at the authoritative nameservers, to establish removal { echo "curl exit $?: the request failed, which is not proof the record is gone; inconclusive"; set -- "$1" 3; } # The three dig lookups below check your configured resolver only, so they are not authoritative: any A/AAAA/CNAME answer means that resolver still returns a mapping (investigate before release; an answer alone does not prove the target is dangling), and empty output is inconclusive. Confirm removal against each authoritative nameserver directly (dig @AUTH_NS name TYPE +norecurse), requiring NXDOMAIN or NOERROR/NODATA with no CNAME; treat SERVFAIL/referral/timeout as inconclusive echo "A:" set -- "$1" "$2" "$(if dig +short "$1" A; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the host, the block's status, dig's answer, then dig's exit status printf '%s' "$3" case "$4:$3" in 0:) [ "$2" = 1 ] || set -- "$1" 3 ;; # no answer: inconclusive on its own 0:*) set -- "$1" 1 ;; # an answer: that resolver still returns a mapping *) echo "dig exit $4: the A lookup failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "AAAA:" set -- "$1" "$2" "$(if dig +short "$1" AAAA; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" printf '%s' "$3" case "$4:$3" in 0:) [ "$2" = 1 ] || set -- "$1" 3 ;; 0:*) set -- "$1" 1 ;; *) echo "dig exit $4: the AAAA lookup failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "CNAME:" set -- "$1" "$2" "$(if dig +short "$1" CNAME; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" printf '%s' "$3" case "$4:$3" in 0:) [ "$2" = 1 ] || set -- "$1" 3 ;; 0:*) set -- "$1" 1 ;; *) echo "dig exit $4: the CNAME lookup failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac case "$2" in 1) echo "FINDING: your resolver still returns a mapping for $1; investigate before you release the target" ;; *) echo "no answer from your resolver, or a failed lookup or request: inconclusive, so confirm removal at each authoritative nameserver as the comment above says" ;; esac echo "block exit $2; the HTTPS headers above are not classified, and this block cannot exit 0 by design: your resolver is not authoritative, so only the authoritative check in the comment above can establish removal" exit "$2" # 1 or 3, never 0, by design: the authoritative check decides removal ) ``` REASONED: following block; certificate expiry check, per the cited OpenSSL 3.0 documentation; no live app host name was supplied, and no live run is recorded. Run it from outside on a schedule: it exits 0 when s_client verified the chain and the name and x509 printed the notAfter date, which it leaves for you to judge, and 3 when s_client or x509 failed, so an expired, untrusted or misnamed certificate gives 3. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_APP_HOST' # the live app's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" "$(if openssl s_client -connect "$1:443" -servername "$1" \ -verify_hostname "$1" -verify_return_error - Certbot dry-run test-server dispatch, at v2.3.0 (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/cli/helpful.py#L210-L211 - Certbot staging-server selection and custom-server exception, at v2.3.0 (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/cli/helpful.py#L237-L264 - Certbot user-guide dry-run and subsequent live-renewal example, at v2.3.0 (checked October 2026): https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L786-L798 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - nmap: Nmap documentation (unknown) - shodan: Shodan (unknown) - censys: Censys (unknown) - vercel: Vercel Deployment Protection (unknown) - access: Cloudflare Access policies (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - dns: BIND dig documentation (9.18.39) - openssl: OpenSSL command documentation (3.0) - openbsd-nc: OpenBSD nc manual (7.6) - owasp-takeover: OWASP subdomain takeover prevention (668ba7db3d0da5868b8a0305c259f7f6914a6ecd) - wstg: OWASP Web Security Testing Guide (v4.2) - dns-rfcs: DNS caching RFCs (unknown) - owasp-docs: OWASP security guidance (668ba7db3d0da5868b8a0305c259f7f6914a6ecd) - entra-docs: Microsoft Entra revocation model (231747abc59ae3d50c74e215c1cdd592eb980723) - cloudflare-access: Cloudflare Access and origin configuration (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - oauth2-proxy: OAuth2 Proxy (v7.15.6) - authelia: Authelia (v4.39.28) - oauth-revocation: OAuth 2.0 Token Revocation RFC (unknown) - nist: NIST digital identity guidelines (SP 800-63B-4) - ubuntu-nc: Ubuntu noble netcat-openbsd source (c047f1a4572e796c8643fb7e5a719a9d960a677b) - c9s-nmap: CentOS Stream 9 nmap packaging (17c2b7795d821cd7fbe311cf3f885be3ea1f122f) - busybox: BusyBox (1_36_1) - docker-notes: Docker Engine release notes (4e9a5751518ed8223a8dcde53693badddd72604f) - webui: Open WebUI (0a7c15832fb30b1903753e83f81dc7d27e5b0944) - certbot-v230: Certbot user guide and source (v2.3.0) - le: Let's Encrypt documentation (unknown) Claims (full source mapping in the JSON block below): - outside (REASONED; nmap): Run exposure checks from a second network; host-local curl and ss do not establish outside reachability or platform firewall behaviour. - tcp-scan (REASONED; nmap, openbsd-nc, ubuntu-nc, c9s-nmap, busybox): Sweep authorized hosts with nmap -Pn -p-; the guide also gives nc port probes. Use the house placeholder guard for both. Ubuntu noble netcat-openbsd documents -v verbosity, -z scanning and -w timeout; BusyBox 1.36.1 small nc lacks -v/-z and needs NC_EXTRA for -w, while its NC_110_COMPAT applet lists -v/-w and needs NC_EXTRA for -z. CentOS Stream 9 packages Ncat 7.92 as nc; its option behaviour and traditional/GNU netcat remain unestablished. As this guide's assessment, refusal or timeout shows only connection failure; unsupported-option errors, local errors and silent exits are inconclusive. - udp-scan (REASONED; nmap): The full port sweep is TCP only; inventory UDP services and probe their ports separately with nmap -sU. - all-addresses (REASONED; nmap, vercel): Check public IPv4, public IPv6 and platform URLs as well as the custom domain; one address cannot establish coverage of the others. - passive-index (REASONED; shodan, censys): Cross-reference Shodan and Censys indexes for previously indexed exposure; indexed observations are not a fresh direct scan. - initialize-secrets (REASONED; access, webui): As this guide's advice, create owner/admin credentials and any session, JWT or cookie-signing secrets before opening application access, following authentication.md's signing-secret recommendation. For applications that give admin to whoever completes first-run setup, an unclaimed setup wizard is a race to become admin; Open WebUI at the recorded pin is one example, with its first account becoming admin. Access is fronting context only; other applications' bootstrap and signing-secret behaviour are not established here. - registration (REASONED; access, webui): As this guide's advice, disable open self-registration or restrict it to an allowlisted domain before opening application access; authentication.md covers allowlisting after federated login. Open WebUI at the recorded pin defaults ENABLE_SIGNUP and ENABLE_LOGIN_FORM to true with authentication enabled; an empty database bypasses ENABLE_SIGNUP when the login form is enabled, and after accounts exist disabled signup refuses registration. Access is fronting context only; other applications' registration controls and domain enforcement are not established here. - fronting-order (REASONED; access, le): As this guide's advice, install TLS and any proxy authentication before opening application access: use DNS-01 before opening inbound access, or open only the challenge port during issuance while keeping application access blocked. Per free-certificates.md, HTTP-01 requires inbound port 80 and TLS-ALPN-01 requires inbound port 443; DNS-01 needs no inbound port. These challenge requirements do not establish this deployment order. Cloudflare recommends creating Access before the tunnel route and warns that without it the published app is public; publicly routable origins need separate IP protection. The general order remains this guide's advice. - setup-route (REASONED; access, webui): After initialization, restart and retry the initial-setup URL; it must refuse to mint a second admin, not silently succeed (this guide's test expectation). Open WebUI's signup handler illustrates first-account bootstrap and refusal when accounts exist and signup is disabled; it does not establish every application's setup route or a deployed restart result. Access is fronting context only. - preview-standard (REASONED; vercel): Vercel Standard Protection is available on every plan and gates previews and generated deployment URLs while leaving the production domain open. - preview-all (REASONED; vercel): All Deployments also protects production; pairing it with Vercel Authentication became free on every plan on 9 September 2026, with no paid add-on. - preview-gate (REASONED; access, vercel): Apply a fronting gate such as a Cloudflare Access policy to preview hostnames the platform gate does not cover. The pinned Cloudflare instructions establish the Access gate, not the platform's coverage of preview URLs. - clone-secrets (REASONED; access): As this guide's advice, treat a clone's data the same as the original and rotate any inherited credential if the clone is less trusted than the source. That a clone can inherit credentials is this guide's assessment; clone credential inheritance and rotation are not established by the Access citation, which supplies fronting context only. - dns-retirement (REASONED; dns, owasp-takeover, wstg, dns-rfcs): Remove CNAME or A/AAAA records before releasing the app, load balancer or IP, and wait at least the old TTL per OWASP; WSTG supports record removal, not that wait. TTL expiry is not a hard bound: RFC 8767 permits serve-stale when authoritative refresh fails. This guide advises retaining the resource through the old TTL and confirmation of removal at every authoritative nameserver, and accounting for serve-stale before release; these checks do not prove all caches have refreshed. By this guide's inference, deletion does not flush cached answers and cached clients can reach the next owner of a reclaimable target. Reclaimability is OWASP's account of claimable CNAME targets and reassigned released IPs, not established for a particular platform. dig supports DNS inspection. - retire-credentials (REASONED; access, owasp-docs): After DNS retirement revoke deployment access policies, API/service credentials and dedicated database users (guide requirement). OWASP requires secure revocation of secrets that are no longer required or potentially compromised. The Access-policy citation covers fronting context only; the cited files do not establish removal of an Access application or policy or dropping database users. - reverify (REASONED; nmap, docker-notes, webui): Repeat outside scans and negative auth tests after upgrades, backup restores or network/auth changes; a restore can reintroduce a default account or reset a feature flag as this guide's assessment, not a general vendor fact. Docker 28.0.0 warns that hosts with IPv6 forwarding already enabled that relied on Docker setting FORWARD to DROP may need configuration changes; 28.3.3 fixes local-network access to loopback-intended published ports after firewalld reload. Open WebUI enables persistence by default; as this guide's conditional inference, restoring database values that override the environment after first launch could reinstate earlier signup settings. The files this guide cites do not establish database precedence or general restore behaviour; Nmap supports scanning only. - certificate-monitor (REASONED; nmap, openssl, certbot-v230): Monitor certificate expiry externally. As this guide's assessment, a renewal cron can silently fail while the host's view looks fine; its advice is a scheduled external openssl s_client or third-party monitor. Certbot v2.3.0 dry-run uses staging unless a custom server is set, saves no test certificates and runs applicable deploy hooks only with --run-deploy-hooks after success, using the active certificate. The user guide separates dry-run from live renewal; this guide infers that dry-run cannot establish the last real renewal's success. Nmap is outside-check context only. - offboarding (REASONED; access, owasp-docs, entra-docs, cloudflare-access, oauth2-proxy, authelia, oauth-revocation, nist): When a person leaves, revoke every touched layer, not only the password: identity-provider membership, proxy/app sessions on each service, personal tokens and API keys (this guide's recommendation), and revoke the second factor alongside the account. Entra documents blocking sign-ins and revoking refresh tokens; application sessions require application revocation, access-token lifetimes vary, and supported-service CAE critical-event enforcement can take up to 15 minutes. Cloudflare documents per-user revocation, but an active IdP profile can start a new session; user logout stops acceptance of issued tokens in 20-30 seconds. OAuth2 Proxy documents cookie expiry/refresh; Authelia documents cookie expiry and session destruction, overridden by remember-me; neither passage establishes immediate per-user revocation. RFC 7009 specifies immediate invalidation with possible propagation delay and conditional access-token revocation support. OWASP requires secure revocation of unneeded or potentially compromised secrets. NIST requires CSPs to promptly invalidate authenticators when the CSP determines that the subscriber no longer meets its eligibility requirements. Where immediate revocation is unavailable, measure and shrink maximum time-to-revoke (this guide's recommendation). Provider-membership removal, every application's session-revocation mechanism and this deployment's measured maximum time-to-revoke remain unestablished. Follow authentication.md's REASONED replay check: matched requests must succeed before revocation, then old credentials must be refused without protected data within each configured maximum; these are test expectations, not observations. - verify-ipv4 (REASONED; nmap): From another network, nmap -Pn -p- should find only intended TCP ports; unintended open ports fail, and errors or an unexecuted scan are inconclusive. - verify-ipv6 (REASONED; nmap): Repeat the full TCP sweep over the public IPv6 address with -6; a scan that did not run is inconclusive. - verify-dns (REASONED; dns, dns-rfcs): curl failure does not prove DNS removal; resolver A/AAAA/CNAME answers need investigation and empty output is inconclusive. Check every authoritative server with +norecurse for NXDOMAIN or NOERROR/NODATA without CNAME; SERVFAIL, referral and timeout are inconclusive. - verify-certificate (REASONED; nmap, openssl): The scheduled external s_client probe checks hostname and verification errors and pipes the certificate to x509 -enddate. No observed outcome is recorded; Nmap is outside-check context only. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "27faf94b933259be394e81a22e1312540d77f5f9ac2c5c6af961c812b70eaa51", "components": { "nmap": { "name": "Nmap documentation", "basis": "unknown", "sources": { "sf8404f695ccd": "https://nmap.org/book/man-briefoptions.html", "sb7d0e7eb5141": "https://nmap.org/book/man-host-discovery.html" } }, "shodan": { "name": "Shodan", "basis": "unknown", "sources": { "scf863db67f1e": "https://www.shodan.io/" } }, "censys": { "name": "Censys", "basis": "unknown", "sources": { "s1e64a007a91f": "https://censys.com/" } }, "vercel": { "name": "Vercel Deployment Protection", "basis": "unknown", "sources": { "se6089d5c4e96": "https://vercel.com/docs/deployment-protection", "s7836e043434a": "https://vercel.com/changelog/protect-production-deployments-for-free-on-every-plan" } }, "access": { "name": "Cloudflare Access policies", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sfddb92e438ca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L14-L16", "s7cafe1b17afd": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app.mdx#L40-L54" } }, "dns": { "name": "BIND dig documentation", "basis": "9.18.39", "sources": { "s54cff06bb882": "https://bind9.readthedocs.io/en/v9.18.39/manpages.html" } }, "openssl": { "name": "OpenSSL command documentation", "basis": "3.0", "sources": { "s00aaf106164f": "https://docs.openssl.org/3.0/man1/openssl-s_client/", "s0856f4a58574": "https://docs.openssl.org/3.0/man1/openssl-x509/" } }, "openbsd-nc": { "name": "OpenBSD nc manual", "basis": "7.6", "sources": { "s0c8b1a738e7b": "https://man.openbsd.org/OpenBSD-7.6/nc.1" } }, "owasp-takeover": { "name": "OWASP subdomain takeover prevention", "basis": "668ba7db3d0da5868b8a0305c259f7f6914a6ecd", "sources": { "s03e38fd5f89d": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Subdomain_Takeover_Prevention_Cheat_Sheet.md#L5", "sf5a4befe8112": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Subdomain_Takeover_Prevention_Cheat_Sheet.md#L43-L44", "s33c539162561": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Subdomain_Takeover_Prevention_Cheat_Sheet.md#L98-L105" } }, "wstg": { "name": "OWASP Web Security Testing Guide", "basis": "v4.2", "sources": { "s62c309021e80": "https://github.com/OWASP/wstg/blob/v4.2/document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md#L9-L14", "s39a0e1d42b51": "https://github.com/OWASP/wstg/blob/v4.2/document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md#L109" } }, "dns-rfcs": { "name": "DNS caching RFCs", "basis": "unknown", "sources": { "s0d5c3bb5aad8": "https://www.rfc-editor.org/rfc/rfc1034#section-3.6", "s8bc007a2299d": "https://www.rfc-editor.org/rfc/rfc1035#section-3.2.1", "s3b645146ca75": "https://www.rfc-editor.org/rfc/rfc2181#section-8", "s78f445a994d7": "https://www.rfc-editor.org/rfc/rfc8767#section-4", "s1d32db254435": "https://www.rfc-editor.org/rfc/rfc2308#section-2" } }, "owasp-docs": { "name": "OWASP security guidance", "basis": "668ba7db3d0da5868b8a0305c259f7f6914a6ecd", "sources": { "seb866a1f94e9": "https://github.com/OWASP/CheatSheetSeries/blob/668ba7db3d0da5868b8a0305c259f7f6914a6ecd/cheatsheets/Secrets_Management_Cheat_Sheet.md#L214-L218" } }, "entra-docs": { "name": "Microsoft Entra revocation model", "basis": "231747abc59ae3d50c74e215c1cdd592eb980723", "sources": { "s983f45259faa": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L62", "scb38e683ae3f": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/users/users-revoke-access.md#L96-L106", "s9ff4d6ed4cfe": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity-platform/access-tokens.md#L54-L60", "sca6feea4b601": "https://github.com/MicrosoftDocs/entra-docs/blob/231747abc59ae3d50c74e215c1cdd592eb980723/docs/identity/conditional-access/concept-continuous-access-evaluation.md#L21-L25" } }, "cloudflare-access": { "name": "Cloudflare Access and origin configuration", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "sf2dbab0fc574": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L159-L186", "sa0e3eb928f4a": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/session-management.mdx#L190-L195" } }, "oauth2-proxy": { "name": "OAuth2 Proxy", "basis": "v7.15.6", "sources": { "sa4c6097e2686": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L126-L130" } }, "authelia": { "name": "Authelia", "basis": "v4.39.28", "sources": { "sc844fe82794f": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/session/introduction.md#L220-L221" } }, "oauth-revocation": { "name": "OAuth 2.0 Token Revocation RFC", "basis": "unknown", "sources": { "s3383c9f41cc2": "https://www.rfc-editor.org/rfc/rfc7009.html#section-2.1" } }, "nist": { "name": "NIST digital identity guidelines", "basis": "SP 800-63B-4", "sources": { "s2a689905c161": "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b-4.pdf" } }, "ubuntu-nc": { "name": "Ubuntu noble netcat-openbsd source", "basis": "c047f1a4572e796c8643fb7e5a719a9d960a677b", "sources": { "s478aaf9a8975": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n257", "sd7b31d3bc579": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n263", "scc48da80246b": "https://git.launchpad.net/ubuntu/+source/netcat-openbsd/tree/nc.1?id=c047f1a4572e796c8643fb7e5a719a9d960a677b#n308" } }, "c9s-nmap": { "name": "CentOS Stream 9 nmap packaging", "basis": "17c2b7795d821cd7fbe311cf3f885be3ea1f122f", "sources": { "sd06e83963e39": "https://gitlab.com/redhat/centos-stream/rpms/nmap/-/blob/17c2b7795d821cd7fbe311cf3f885be3ea1f122f/nmap.spec#L133-L137", "s3ab25d24e37a": "https://gitlab.com/redhat/centos-stream/rpms/nmap/-/blob/17c2b7795d821cd7fbe311cf3f885be3ea1f122f/nmap.spec#L6-L8" } }, "busybox": { "name": "BusyBox", "basis": "1_36_1", "sources": { "s34bdee292f79": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc_bloaty.c#L61-L93", "sd707e18625cc": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L136-L170", "s14b89daa008d": "https://github.com/mirror/busybox/blob/1_36_1/networking/nc.c#L30-L59" } }, "docker-notes": { "name": "Docker Engine release notes", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "s182da2bdd442": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/release-notes/28.md#L771-L772", "sfbe11311e8a4": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/release-notes/28.md#L263-L263" } }, "webui": { "name": "Open WebUI", "basis": "0a7c15832fb30b1903753e83f81dc7d27e5b0944", "sources": { "s94d055784840": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py", "s9d6f5fb157de": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py" } }, "certbot-v230": { "name": "Certbot user guide and source", "basis": "v2.3.0", "sources": { "s5def4225771f": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/cli/__init__.py#L146-L170", "se96df86ba351": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/cli/helpful.py#L210-L211", "sc37d1ffd6192": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/certbot/_internal/cli/helpful.py#L237-L264", "s77de4441a9e5": "https://github.com/certbot/certbot/blob/v2.3.0/certbot/docs/using.rst#L786-L798" } }, "le": { "name": "Let's Encrypt documentation", "basis": "unknown", "sources": { "s9ff2d787c618": "https://letsencrypt.org/docs/" } } }, "claims": { "outside": {"text": "Run exposure checks from a second network; host-local curl and ss do not establish outside reachability or platform firewall behaviour.", "components": ["nmap"], "sources": ["nmap:sf8404f695ccd", "nmap:sb7d0e7eb5141"], "status": "REASONED"}, "tcp-scan": {"text": "Sweep authorized hosts with nmap -Pn -p-; the guide also gives nc port probes. Use the house placeholder guard for both. Ubuntu noble netcat-openbsd documents -v verbosity, -z scanning and -w timeout; BusyBox 1.36.1 small nc lacks -v/-z and needs NC_EXTRA for -w, while its NC_110_COMPAT applet lists -v/-w and needs NC_EXTRA for -z. CentOS Stream 9 packages Ncat 7.92 as nc; its option behaviour and traditional/GNU netcat remain unestablished. As this guide's assessment, refusal or timeout shows only connection failure; unsupported-option errors, local errors and silent exits are inconclusive.", "components": ["nmap", "openbsd-nc", "ubuntu-nc", "c9s-nmap", "busybox"], "sources": ["nmap:sf8404f695ccd", "nmap:sb7d0e7eb5141", "openbsd-nc:s0c8b1a738e7b", "ubuntu-nc:s478aaf9a8975", "ubuntu-nc:sd7b31d3bc579", "ubuntu-nc:scc48da80246b", "c9s-nmap:sd06e83963e39", "c9s-nmap:s3ab25d24e37a", "busybox:s34bdee292f79", "busybox:sd707e18625cc", "busybox:s14b89daa008d"], "status": "REASONED"}, "udp-scan": {"text": "The full port sweep is TCP only; inventory UDP services and probe their ports separately with nmap -sU.", "components": ["nmap"], "sources": ["nmap:sf8404f695ccd"], "status": "REASONED"}, "all-addresses": {"text": "Check public IPv4, public IPv6 and platform URLs as well as the custom domain; one address cannot establish coverage of the others.", "components": ["nmap", "vercel"], "sources": ["nmap:sf8404f695ccd", "vercel:se6089d5c4e96"], "status": "REASONED"}, "passive-index": {"text": "Cross-reference Shodan and Censys indexes for previously indexed exposure; indexed observations are not a fresh direct scan.", "components": ["shodan", "censys"], "sources": ["shodan:scf863db67f1e", "censys:s1e64a007a91f"], "status": "REASONED"}, "initialize-secrets": {"text": "As this guide's advice, create owner/admin credentials and any session, JWT or cookie-signing secrets before opening application access, following authentication.md's signing-secret recommendation. For applications that give admin to whoever completes first-run setup, an unclaimed setup wizard is a race to become admin; Open WebUI at the recorded pin is one example, with its first account becoming admin. Access is fronting context only; other applications' bootstrap and signing-secret behaviour are not established here.", "components": ["access", "webui"], "sources": ["access:s7cafe1b17afd", "webui:s94d055784840", "webui:s9d6f5fb157de"], "status": "REASONED"}, "registration": {"text": "As this guide's advice, disable open self-registration or restrict it to an allowlisted domain before opening application access; authentication.md covers allowlisting after federated login. Open WebUI at the recorded pin defaults ENABLE_SIGNUP and ENABLE_LOGIN_FORM to true with authentication enabled; an empty database bypasses ENABLE_SIGNUP when the login form is enabled, and after accounts exist disabled signup refuses registration. Access is fronting context only; other applications' registration controls and domain enforcement are not established here.", "components": ["access", "webui"], "sources": ["access:s7cafe1b17afd", "webui:s94d055784840", "webui:s9d6f5fb157de"], "status": "REASONED"}, "fronting-order": {"text": "As this guide's advice, install TLS and any proxy authentication before opening application access: use DNS-01 before opening inbound access, or open only the challenge port during issuance while keeping application access blocked. Per free-certificates.md, HTTP-01 requires inbound port 80 and TLS-ALPN-01 requires inbound port 443; DNS-01 needs no inbound port. These challenge requirements do not establish this deployment order. Cloudflare recommends creating Access before the tunnel route and warns that without it the published app is public; publicly routable origins need separate IP protection. The general order remains this guide's advice.", "components": ["access", "le"], "sources": ["access:s7cafe1b17afd", "access:sfddb92e438ca", "le:s9ff2d787c618"], "status": "REASONED"}, "setup-route": {"text": "After initialization, restart and retry the initial-setup URL; it must refuse to mint a second admin, not silently succeed (this guide's test expectation). Open WebUI's signup handler illustrates first-account bootstrap and refusal when accounts exist and signup is disabled; it does not establish every application's setup route or a deployed restart result. Access is fronting context only.", "components": ["access", "webui"], "sources": ["access:s7cafe1b17afd", "webui:s9d6f5fb157de"], "status": "REASONED"}, "preview-standard": {"text": "Vercel Standard Protection is available on every plan and gates previews and generated deployment URLs while leaving the production domain open.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96"], "status": "REASONED"}, "preview-all": {"text": "All Deployments also protects production; pairing it with Vercel Authentication became free on every plan on 9 September 2026, with no paid add-on.", "components": ["vercel"], "sources": ["vercel:se6089d5c4e96", "vercel:s7836e043434a"], "status": "REASONED"}, "preview-gate": {"text": "Apply a fronting gate such as a Cloudflare Access policy to preview hostnames the platform gate does not cover. The pinned Cloudflare instructions establish the Access gate, not the platform's coverage of preview URLs.", "components": ["access", "vercel"], "sources": ["access:s7cafe1b17afd", "vercel:se6089d5c4e96"], "status": "REASONED"}, "clone-secrets": {"text": "As this guide's advice, treat a clone's data the same as the original and rotate any inherited credential if the clone is less trusted than the source. That a clone can inherit credentials is this guide's assessment; clone credential inheritance and rotation are not established by the Access citation, which supplies fronting context only.", "components": ["access"], "sources": ["access:s7cafe1b17afd"], "status": "REASONED"}, "dns-retirement": {"text": "Remove CNAME or A/AAAA records before releasing the app, load balancer or IP, and wait at least the old TTL per OWASP; WSTG supports record removal, not that wait. TTL expiry is not a hard bound: RFC 8767 permits serve-stale when authoritative refresh fails. This guide advises retaining the resource through the old TTL and confirmation of removal at every authoritative nameserver, and accounting for serve-stale before release; these checks do not prove all caches have refreshed. By this guide's inference, deletion does not flush cached answers and cached clients can reach the next owner of a reclaimable target. Reclaimability is OWASP's account of claimable CNAME targets and reassigned released IPs, not established for a particular platform. dig supports DNS inspection.", "components": ["dns", "owasp-takeover", "wstg", "dns-rfcs"], "sources": ["dns:s54cff06bb882", "owasp-takeover:s03e38fd5f89d", "owasp-takeover:sf5a4befe8112", "owasp-takeover:s33c539162561", "wstg:s62c309021e80", "wstg:s39a0e1d42b51", "dns-rfcs:s0d5c3bb5aad8", "dns-rfcs:s8bc007a2299d", "dns-rfcs:s3b645146ca75", "dns-rfcs:s78f445a994d7"], "status": "REASONED"}, "retire-credentials": {"text": "After DNS retirement revoke deployment access policies, API/service credentials and dedicated database users (guide requirement). OWASP requires secure revocation of secrets that are no longer required or potentially compromised. The Access-policy citation covers fronting context only; the cited files do not establish removal of an Access application or policy or dropping database users.", "components": ["access", "owasp-docs"], "sources": ["access:s7cafe1b17afd", "owasp-docs:seb866a1f94e9"], "status": "REASONED"}, "reverify": {"text": "Repeat outside scans and negative auth tests after upgrades, backup restores or network/auth changes; a restore can reintroduce a default account or reset a feature flag as this guide's assessment, not a general vendor fact. Docker 28.0.0 warns that hosts with IPv6 forwarding already enabled that relied on Docker setting FORWARD to DROP may need configuration changes; 28.3.3 fixes local-network access to loopback-intended published ports after firewalld reload. Open WebUI enables persistence by default; as this guide's conditional inference, restoring database values that override the environment after first launch could reinstate earlier signup settings. The files this guide cites do not establish database precedence or general restore behaviour; Nmap supports scanning only.", "components": ["nmap", "docker-notes", "webui"], "sources": ["nmap:sf8404f695ccd", "nmap:sb7d0e7eb5141", "docker-notes:s182da2bdd442", "docker-notes:sfbe11311e8a4", "webui:s94d055784840"], "status": "REASONED"}, "certificate-monitor": {"text": "Monitor certificate expiry externally. As this guide's assessment, a renewal cron can silently fail while the host's view looks fine; its advice is a scheduled external openssl s_client or third-party monitor. Certbot v2.3.0 dry-run uses staging unless a custom server is set, saves no test certificates and runs applicable deploy hooks only with --run-deploy-hooks after success, using the active certificate. The user guide separates dry-run from live renewal; this guide infers that dry-run cannot establish the last real renewal's success. Nmap is outside-check context only.", "components": ["nmap", "openssl", "certbot-v230"], "sources": ["nmap:sf8404f695ccd", "openssl:s00aaf106164f", "openssl:s0856f4a58574", "certbot-v230:s5def4225771f", "certbot-v230:se96df86ba351", "certbot-v230:sc37d1ffd6192", "certbot-v230:s77de4441a9e5"], "status": "REASONED"}, "offboarding": {"text": "When a person leaves, revoke every touched layer, not only the password: identity-provider membership, proxy/app sessions on each service, personal tokens and API keys (this guide's recommendation), and revoke the second factor alongside the account. Entra documents blocking sign-ins and revoking refresh tokens; application sessions require application revocation, access-token lifetimes vary, and supported-service CAE critical-event enforcement can take up to 15 minutes. Cloudflare documents per-user revocation, but an active IdP profile can start a new session; user logout stops acceptance of issued tokens in 20-30 seconds. OAuth2 Proxy documents cookie expiry/refresh; Authelia documents cookie expiry and session destruction, overridden by remember-me; neither passage establishes immediate per-user revocation. RFC 7009 specifies immediate invalidation with possible propagation delay and conditional access-token revocation support. OWASP requires secure revocation of unneeded or potentially compromised secrets. NIST requires CSPs to promptly invalidate authenticators when the CSP determines that the subscriber no longer meets its eligibility requirements. Where immediate revocation is unavailable, measure and shrink maximum time-to-revoke (this guide's recommendation). Provider-membership removal, every application's session-revocation mechanism and this deployment's measured maximum time-to-revoke remain unestablished. Follow authentication.md's REASONED replay check: matched requests must succeed before revocation, then old credentials must be refused without protected data within each configured maximum; these are test expectations, not observations.", "components": ["access", "owasp-docs", "entra-docs", "cloudflare-access", "oauth2-proxy", "authelia", "oauth-revocation", "nist"], "sources": ["access:s7cafe1b17afd", "owasp-docs:seb866a1f94e9", "entra-docs:s983f45259faa", "entra-docs:scb38e683ae3f", "entra-docs:s9ff4d6ed4cfe", "entra-docs:sca6feea4b601", "cloudflare-access:sf2dbab0fc574", "cloudflare-access:sa0e3eb928f4a", "oauth2-proxy:sa4c6097e2686", "authelia:sc844fe82794f", "oauth-revocation:s3383c9f41cc2", "nist:s2a689905c161"], "status": "REASONED"}, "verify-ipv4": {"text": "From another network, nmap -Pn -p- should find only intended TCP ports; unintended open ports fail, and errors or an unexecuted scan are inconclusive.", "components": ["nmap"], "sources": ["nmap:sf8404f695ccd", "nmap:sb7d0e7eb5141"], "status": "REASONED", "verify": [1]}, "verify-ipv6": {"text": "Repeat the full TCP sweep over the public IPv6 address with -6; a scan that did not run is inconclusive.", "components": ["nmap"], "sources": ["nmap:sf8404f695ccd", "nmap:sb7d0e7eb5141"], "status": "REASONED", "verify": [2]}, "verify-dns": {"text": "curl failure does not prove DNS removal; resolver A/AAAA/CNAME answers need investigation and empty output is inconclusive. Check every authoritative server with +norecurse for NXDOMAIN or NOERROR/NODATA without CNAME; SERVFAIL, referral and timeout are inconclusive.", "components": ["dns", "dns-rfcs"], "sources": ["dns:s54cff06bb882", "dns-rfcs:s1d32db254435"], "status": "REASONED", "verify": [3]}, "verify-certificate": {"text": "The scheduled external s_client probe checks hostname and verification errors and pipes the certificate to x509 -enddate. No observed outcome is recorded; Nmap is outside-check context only.", "components": ["nmap", "openssl"], "sources": ["nmap:sf8404f695ccd", "openssl:s00aaf106164f", "openssl:s0856f4a58574"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> postgresql.md ====================================================================== # PostgreSQL: TLS and authentication PostgreSQL is a relational database that stores application data in tables queried with SQL. This guide helps your AI assistant make sure it is configured securely, including a private listener, TLS and SCRAM passwords for remote connections, an application role that does not own its tables, and connection logging. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/postgresql.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against PostgreSQL initdb source REL_17_11, PostgreSQL TLS minimum 12, PostgreSQL SCRAM default 14, PostgreSQL schema defaults 15, PostgreSQL membership and require_auth 16, and PostgreSQL MD5 and logging changes 18; 2 further sources are pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 46 claims: 2 demonstrated by a check that ran and 44 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Default posture: PostgreSQL should not listen on public interfaces at all. Widen `listen_addresses` only for genuine remote clients, and then require both TLS and SCRAM authentication as below. ## 1. Server TLS Get a certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)), give the key to the `postgres` user with mode `600`, then in `postgresql.conf`: ```ini listen_addresses = 'localhost' # widen deliberately, e.g. 'localhost,10.0.0.5' ssl = on ssl_cert_file = '/etc/ssl/certs/server.crt' ssl_key_file = '/etc/ssl/private/server.key' ssl_min_protocol_version = 'TLSv1.2' # PostgreSQL 12 and later password_encryption = 'scram-sha-256' # default from PostgreSQL 14 ``` For the TLS settings shown and `password_encryption`, reload the configuration from an administrative connection: ```sql SELECT pg_reload_conf(); ``` A true result means the reload signal was sent; it does not establish that replacement certificate files were accepted. Invalid TLS files during reload can leave the previous TLS configuration active. Inspect the server logs and reconnect. Passphrase-protected keys have additional reload requirements involving `ssl_passphrase_command_supports_reload`; do not assume every `ssl*` setting behaves identically. See [server TLS file handling](https://www.postgresql.org/docs/current/ssl-tcp.html) and [TLS configuration](https://www.postgresql.org/docs/current/runtime-config-connection.html). Changing `listen_addresses` requires a server restart through the deployment's service manager. Confirm the resulting listeners with the guarded `ss` check under Verify. The historical version boundaries above are documented in the [PostgreSQL 12](https://www.postgresql.org/docs/12/release-12.html) and [PostgreSQL 14](https://www.postgresql.org/docs/14/release-14.html) release notes. The official Docker images (PostgreSQL 14 through 19, Debian and Alpine, at the pinned commit) start wider. Their build sets `listen_addresses = '*'` in the sample configuration that a new data directory is initialized from. When the entrypoint initializes a new data directory, it runs initdb with no authentication method beyond what `POSTGRES_INITDB_ARGS` supplies. The initdb default, `trust` in every release checked (14.24, 15.19, 16.15, 17.11, 18.6 and 19 beta 4), therefore applies to the local-socket rule unless those arguments pass `--auth-local` or `--auth` (`-A`), and to the loopback (`127.0.0.1/32`, `::1/128`) rules unless they pass `--auth-host` or `--auth` (`-A`). The entrypoint then appends `host all all all` with `POSTGRES_HOST_AUTH_METHOD`, which defaults to the server's `password_encryption` setting. `pg_hba.conf` uses the first rule that matches, so with initdb's default, loopback connections inside the container's network namespace need no password (with `--network host`, or in a Kubernetes pod, that namespace is shared), and other addresses get the `POSTGRES_HOST_AUTH_METHOD` method, a password method by default. The entrypoint refuses to initialize without `POSTGRES_PASSWORD` (or `POSTGRES_PASSWORD_FILE`) unless `POSTGRES_HOST_AUTH_METHOD=trust`, and `trust` lets every address that reaches the port connect without a password. Publish the port only to host loopback or a private network, give `POSTGRES_INITDB_ARGS` an `--auth-host` and `--auth-local` method if loopback should need a password too, and never set `POSTGRES_HOST_AUTH_METHOD=trust`. ## 2. Require TLS per connection in pg_hba.conf `host` matches both TLS and non-TLS TCP connections; it does not require encryption. `hostssl` matches only TLS. Use `hostssl` with `scram-sha-256` for the intended remote application connections: ```conf # TYPE DATABASE USER ADDRESS METHOD local all all peer hostssl app app 10.0.0.0/24 scram-sha-256 # Do not leave broader matching host, trust or password rules elsewhere. ``` Inspect the complete rule order, including included files. PostgreSQL uses the first matching record, with no fall-through after authentication failure. On this guide's Unix-style deployment, reload after editing `pg_hba.conf`; Windows applies HBA edits to subsequent new connections without the same reload requirement. See the [HBA reference](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html). Changing `password_encryption` does not convert existing password verifiers. In an administrative psql session using `scram-sha-256`, reset the application's password with `\password app`, which prompts without embedding the password in SQL or command-line arguments. PostgreSQL 18 deprecates MD5 password support. An HBA entry named `md5` can still negotiate SCRAM when the stored verifier is SCRAM; checking the negotiated method does not prove that every `md5` entry was removed. See [password authentication](https://www.postgresql.org/docs/current/auth-password.html), [psql password changes](https://www.postgresql.org/docs/current/app-psql.html) and the [PostgreSQL 18 release notes](https://www.postgresql.org/docs/18/release-18.html). For machine-to-machine links, add client certificate verification on top of SCRAM: configure `ssl_ca_file` with the CA that signs the client certificates, then append `clientcert=verify-full` to the applicable `hostssl` line. This option is available from PostgreSQL 12. It checks the certificate identity against the requested database username or a configured mapping. By default, that identity is the certificate Common Name, not the client's DNS hostname. Provision certificates accordingly. See [client certificate HBA options](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html). MFA: a client certificate adds a possession factor held by the connecting machine, but does not by itself establish MFA for a person. `radius` is an alternative HBA authentication method, not a second method appended to SCRAM. PostgreSQL documents username/password validation with Access Accept or Access Reject, not a general interactive MFA conversation. Use an external MFA integration only with documentation and a test for its compatible flow. Put human access to SSH and administrative UIs behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). See [RADIUS authentication](https://www.postgresql.org/docs/current/auth-radius.html). ## 3. Separate runtime privileges from object ownership A compromised application account should not be able to administer other roles, create databases, bypass row policies, or alter the objects it uses. Keep ownership in a separate `NOLOGIN` role and grant runtime privileges through another group role. Run the following as an administrator authorized for these operations. This example assumes database `app` and login `app` already exist; `app_owner`, `app_runtime`, and schema `app_data` are new. ```sql CREATE ROLE app_owner NOLOGIN; CREATE ROLE app_runtime NOLOGIN; ALTER ROLE app LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; GRANT app_runtime TO app; ALTER DATABASE app OWNER TO app_owner; ``` `NOLOGIN` prevents direct login, not access through membership. Do not grant `app_owner` or another privileged role to `app`, directly or indirectly. Changing role attributes does not remove existing memberships or unrelated grants; review those separately. See [CREATE ROLE](https://www.postgresql.org/docs/current/sql-createrole.html), [ALTER ROLE](https://www.postgresql.org/docs/current/sql-alterrole.html), [role membership](https://www.postgresql.org/docs/current/role-membership.html) and [ALTER DATABASE](https://www.postgresql.org/docs/current/sql-alterdatabase.html). While connected administratively to database `app`: ```sql CREATE SCHEMA app_data AUTHORIZATION app_owner; GRANT USAGE ON SCHEMA app_data TO app_runtime; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app_data TO app_runtime; GRANT USAGE ON ALL SEQUENCES IN SCHEMA app_data TO app_runtime; ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app_data GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_runtime; ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app_data GRANT USAGE ON SEQUENCES TO app_runtime; ``` Use schema-wide grants only when every affected object belongs to the application's access boundary. Otherwise, grant privileges on named objects. Default privileges affect future objects created by `app_owner` in this database, not existing objects or objects created under another role. See [CREATE SCHEMA](https://www.postgresql.org/docs/current/sql-createschema.html), [GRANT](https://www.postgresql.org/docs/current/sql-grant.html) and [ALTER DEFAULT PRIVILEGES](https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html). Run migrations from a separate authorized administrative connection that can select the owner role: ```sql SET ROLE app_owner; -- Run migrations here, using schema-qualified object names. RESET ROLE; ``` For an existing application table, transfer ownership explicitly, substituting its actual qualified name: ```sql ALTER TABLE app_data.records OWNER TO app_owner; ``` Changing database ownership does not transfer ownership of its tables. Inventory and transfer existing objects individually. Ownership includes powers that ordinary privilege revocation cannot remove. See [SET ROLE](https://www.postgresql.org/docs/current/sql-set-role.html), [ALTER TABLE](https://www.postgresql.org/docs/current/sql-altertable.html) and [object ownership](https://www.postgresql.org/docs/current/ddl-priv.html). These are core controls. PostgreSQL 16+ has per-membership `INHERIT` and `SET` options. The role-level `INHERIT` attribute supplies the default for the new membership granted above; it does not repair an existing membership's options. Role-level `NOINHERIT` must not be treated as preventing `SET ROLE`. See [PostgreSQL 16 role membership](https://www.postgresql.org/docs/16/role-membership.html). ## 4. Restrict database admission and schema creation PostgreSQL grants database `CONNECT` and `TEMPORARY` to `PUBLIC` by default. Restrict admission explicitly. Before committing, also preserve access for the administrative, monitoring, backup, and migration roles the deployment requires. ```sql BEGIN; REVOKE CONNECT ON DATABASE app FROM PUBLIC; GRANT CONNECT ON DATABASE app TO app_runtime; COMMIT; ``` Repeat for each database requiring restricted admission. `CONNECT` is checked when a connection starts; revocation does not disconnect existing sessions. Both HBA authentication and database privileges must permit a new connection. See [database privileges](https://www.postgresql.org/docs/current/ddl-priv.html) and [HBA database access](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html). While connected administratively to `app`, remove public schema creation rights: ```sql REVOKE CREATE ON SCHEMA public FROM PUBLIC; ``` If the application does not need temporary tables: ```sql REVOKE TEMPORARY ON DATABASE app FROM PUBLIC; ``` Revocation from `PUBLIC` does not remove privileges granted directly to a role or obtained through another membership. Do not revoke `USAGE` on `public` indiscriminately if applications need objects installed there. See [schema privileges](https://www.postgresql.org/docs/current/ddl-schemas.html) and [REVOKE](https://www.postgresql.org/docs/current/sql-revoke.html). PostgreSQL 15 changed the defaults for new databases: `PUBLIC` no longer receives `CREATE` on schema `public`, and `pg_database_owner` owns that schema. Upgrades and restores can preserve earlier permissions and ownership. This change did not remove default database `CONNECT`. See the [PostgreSQL 15 migration notes](https://www.postgresql.org/docs/15/release-15.html). ## 5. Secure schema lookup and privileged functions A schema on `search_path` is trusted code territory: users who can create objects there can redirect unqualified lookups. Keep application schemas writable only by trusted owners and qualify object names in migrations. ```sql ALTER ROLE app IN DATABASE app SET search_path = pg_catalog, app_data, pg_temp; ``` Reconnect to use the new default. `SET ROLE` does not apply the target role's login settings. Explicitly placing `pg_temp` last prevents temporary relations from taking precedence over trusted relations. This is a session default, not a restriction preventing the application from changing its own path. See [schema search paths](https://www.postgresql.org/docs/current/ddl-schemas.html), the [search_path reference](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SEARCH-PATH) and [ALTER ROLE defaults](https://www.postgresql.org/docs/current/sql-alterrole.html). A `SECURITY DEFINER` function needs its own safe path and restricted execution privileges. While connected to `app`, run the following as its owner or an authorized administrator. It assumes an existing application function `app_data.lookup_record(bigint)`; substitute its actual name and input argument types. ```sql BEGIN; ALTER FUNCTION app_data.lookup_record(bigint) SET search_path = pg_catalog, app_data, pg_temp; REVOKE EXECUTE ON FUNCTION app_data.lookup_record(bigint) FROM PUBLIC; GRANT EXECUTE ON FUNCTION app_data.lookup_record(bigint) TO app_runtime; COMMIT; ALTER DEFAULT PRIVILEGES FOR ROLE app_owner REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; ``` For new functions, establish execution privileges in the same transaction as creation. The default-privilege revocation deliberately omits `IN SCHEMA`: a schema-specific revocation cannot subtract the global default `PUBLIC EXECUTE` grant. It applies to future functions created by `app_owner` in the current database; review existing functions separately. These changes harden name resolution and execution access. They do not establish that the function's implementation is safe, or justify changing an ordinary function into a `SECURITY DEFINER` function. See [safe SECURITY DEFINER functions](https://www.postgresql.org/docs/current/sql-createfunction.html), [ALTER FUNCTION](https://www.postgresql.org/docs/current/sql-alterfunction.html) and [default privilege scope](https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html). These are core controls. PostgreSQL 15's public-schema change does not make arbitrary user-writable schemas safe. ## 6. Client side Require server identity verification as well as encryption. For libpq clients, use `sslmode=verify-full`, `sslrootcert=REPLACE_WITH_CA_FILE`, and `gssencmode=disable`. The guarded psql block under Verify supplies these settings and prompts for the password. `sslmode=require` does not verify the server hostname. When a root CA file exists, libpq's compatibility behaviour makes `require` validate the certificate chain like `verify-ca`; applications should still request `verify-full` explicitly. See [libpq TLS verification](https://www.postgresql.org/docs/current/libpq-ssl.html). Keep `gssencmode=disable` when testing or requiring TLS certificate verification: otherwise, working GSSAPI encryption can take precedence over TLS regardless of `sslmode`. Libpq-based clients accept these connection parameters; other drivers have their own interfaces and must be configured accordingly. See [libpq connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html). ## 7. Record connection activity and administrative SQL Configure a collected log destination and record connections, disconnections, and DDL. For a Unix-style deployment without an existing managed log destination: ```ini log_destination = 'stderr' logging_collector = on log_file_mode = 0600 log_line_prefix = '%m [%p] %u@%d %r ' log_connections = on log_disconnections = on log_statement = 'ddl' ``` Enabling `logging_collector` requires a restart. Retest with fresh connections. If the deployment already collects PostgreSQL logs, use that destination and configure equivalent access restrictions instead of replacing it. `log_file_mode` controls collector-created files on Unix; Windows ignores it. `ddl` excludes ordinary reads and writes. `mod` adds data-changing statements; `all` adds reads, increasing volume and the risk of recording sensitive values. Restrict log access and establish retention. Core statement logging is not a complete audit trail. These are core settings. PostgreSQL 18 accepts a list for `log_connections`; `on` remains supported and enables receipt, authentication, and authorization logging. It does not enable every new connection-log option. See the [logging reference](https://www.postgresql.org/docs/current/runtime-config-logging.html). ### Optional: pgAudit for detailed audit records pgAudit is an external, open-source extension. Install the release matching the server major version. Add it to the existing preload list, preserving other entries: ```ini # This value assumes the existing list is empty. shared_preload_libraries = 'pgaudit' ``` Restart, then connect as a superuser to each database requiring the extension. For database `app`: ```sql CREATE EXTENSION pgaudit; ALTER DATABASE app SET pgaudit.log = 'read,write,ddl,role'; ``` Create the extension before setting `pgaudit.log`. Reconnect to apply the database default. Choose audit classes for the actual requirement and measure volume. Installing the extension alone does not demonstrate auditing. At the time of writing, pgAudit 18.x targets PostgreSQL 18. Package and managed-service availability need separate checks. Its parameters are extension parameters, not PostgreSQL core settings. See the [official pgAudit 18 documentation](https://github.com/pgaudit/pgaudit/blob/dedd42ec3fe880bf581dd578c0df87ed4ad246cd/README.md), [CREATE EXTENSION](https://www.postgresql.org/docs/current/sql-createextension.html) and [shared_preload_libraries](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SHARED-PRELOAD-LIBRARIES). ## 8. Verify REASONED: service-check scope from the authoring limitations recorded in this paragraph and the PostgreSQL and pgAudit documentation cited at each check below. This revision's service checks are **REASONED, not demonstrated**. The authoring environment has psql 18.6 and PostgreSQL server binaries, but is read-only and has no authorized writable test deployment. Neither Docker nor Podman was found on `PATH`. Missing binaries are not the reason for the service-test limitation. Earlier execution evidence is not recorded in this file; this does not establish whether a previous author ran the earlier probes. REASONED: the local checks recorded in this paragraph, partially re-run at marking time: both blocks passed bash -n (Bash 5.3.9) and ShellCheck 0.11.0, and 19 guard and argument cases ran with local command spies (13 rejected with their not-probing messages, and the valid values and every documented mode dispatched to the spy). The PostgreSQL configuration parse was re-run at marking time with the PostgreSQL 18.6 server binary installed outside PATH at /usr/lib/postgresql/18/bin: postgres -C returned all 14 values from the combined TLS, logging and preload fragments with exit 0, and an ssl=not_a_boolean control was rejected with exit 1 (parameter "ssl" requires a Boolean value); no server was started. Local checks performed: both shell blocks passed `bash -n` and ShellCheck. Unsubstituted blocks exited locally, and 22 guard/argument cases passed using explicit local command spies without contacting a service. The bash -n, ShellCheck, guard and argument runs recorded in this paragraph covered an earlier form of both blocks, whose refusals exited with status 1; on the current form, whose refusals exit with status 2 and whose classified results exit 0, 1 or 3, the repository gate suite's bash -n and ShellCheck 0.11.0 checks pass, and the guard and argument cases recorded above were not repeated. The current form's refusals include the listener block's refusal when `ss` is missing, and stub runs of that block, with a stand-in `ss` present and passing, present and failing, and absent, exited 0, 3 and 2 under an inherited `readonly`, `declare -i` and `set -e`; no PostgreSQL server or real `ss` was involved. PostgreSQL 18.6 parsed and returned the 14 configuration values shown in the TLS, logging, and preload blocks using anonymous memory; an invalid Boolean control was rejected. This did not load certificates or pgAudit, parse HBA through a running server, or execute SQL. REASONED: deployment-demonstration procedure per the cited PostgreSQL documentation; no isolated disposable deployment is available in the authoring environment, so this remains the required procedure rather than a recorded run. Demonstrate exposed configurations only in an isolated disposable deployment. Record the server/client versions, fixture, commands, output, and relevant server logs for both states. A DNS error, timeout, missing CA file, or unrelated authentication failure does not prove enforcement. ### 8.1. TLS and authentication (REASONED: TLS/authentication comparisons from cited PostgreSQL documentation; no authorized writable deployment or certificate fixtures.) **REASONED: no authorized writable PostgreSQL deployment with TLS, credentials, and controllable HBA rules.** Use the same actual host, port, database, and role for each matched pair. This example uses port `5432`; change every occurrence if the deployment uses another port. Substitute inside the single quotes and paste the whole block. The compact connection strings require a hostname and CA path without whitespace, literal quotes, or backslashes. The fourth value selects `tls`, `wrong`, `scram`, `plaintext`, or an interactive `sql` session. `wrong` uses the same connection settings as `tls`; enter a deliberately incorrect password at its prompt. Every password is entered at a prompt, never in argv. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DB_HOST' 'REPLACE_WITH_CA_FILE' 'app' 'tls' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "supply host, CA file, role and mode; not probing"; exit 2; } case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder; not probing"; exit 2 ;; esac case "$2" in *"'"*) echo "use a CA path without whitespace, quotes or backslashes; not probing"; exit 2 ;; *[[:space:]]*|*\\*) echo "use a CA path without whitespace, quotes or backslashes; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]*) echo "supply a simple role name; not probing"; exit 2 ;; esac case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder; not probing"; exit 2 ;; *) case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.:-]*) echo "use a single hostname or IP address; not probing"; exit 2 ;; esac [ -f "$2" ] || { echo "CA file is missing; not probing"; exit 2; } [ -r "$2" ] || { echo "CA file is unreadable; not probing"; exit 2; } case "$4" in # one psql per run; except in sql mode, the 5th value holds what it printed, then its exit status tls|wrong) set -- "$1" "$2" "$3" "$4" "$(if psql -X -W -v ON_ERROR_STOP=1 \ "host=$1 port=5432 dbname=app user=$3 sslmode=verify-full sslrootcert=$2 gssencmode=disable connect_timeout=5" \ -c 'SELECT version();' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; scram) set -- "$1" "$2" "$3" "$4" "$(if psql -X -W -v ON_ERROR_STOP=1 \ "host=$1 port=5432 dbname=app user=$3 sslmode=verify-full sslrootcert=$2 gssencmode=disable connect_timeout=5 require_auth=scram-sha-256" \ -c 'SELECT 1;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; plaintext) set -- "$1" "$2" "$3" "$4" "$(if psql -X -W -v ON_ERROR_STOP=1 \ "host=$1 port=5432 dbname=app user=$3 sslmode=disable gssencmode=disable connect_timeout=5" \ -c 'SELECT 1;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; sql) if psql -X -W -v ON_ERROR_STOP=1 \ "host=$1 port=5432 dbname=app user=$3 sslmode=verify-full sslrootcert=$2 gssencmode=disable connect_timeout=5"; then echo "block exit 0; the sql session is not classified, so judge what it showed yourself"; exit 0 else echo "psql exit $?: the sql session did not connect or ended with an error; block exit 3, inconclusive"; exit 3 fi ;; *) echo "choose tls, wrong, scram, plaintext or sql; not probing"; exit 2 ;; esac set -- "$4" "${5##*$'\n'}" "${5%$'\n'*}" # the mode, psql's exit status, then what psql printed printf '%s' "$3" case "$1:$2" in tls:0|scram:0) echo "pass: the $1 login connected and ran its query, the positive control"; set -- 0 "$1" ;; wrong:0) echo "FINDING: the deliberately wrong password connected, as an earlier matching trust rule allows"; set -- 1 "$1" ;; plaintext:0) echo "FINDING: the plaintext login connected without TLS, as a plaintext-permitting host rule allows"; set -- 1 "$1" ;; wrong:*) case "$3" in *'password authentication failed'*) echo "pass: the wrong password failed with password authentication failure, the fixed result while the tls login succeeds"; set -- 0 "$1" ;; *) echo "psql exit $2 without a password authentication failure; inconclusive"; set -- 3 "$1" ;; esac ;; plaintext:*) case "$3" in *'no pg_hba.conf entry'*'no encryption'*) echo "pass: the plaintext login received an HBA refusal naming no matching entry and no encryption, the fixed result while the tls login succeeds"; set -- 0 "$1" ;; *) echo "psql exit $2 without an HBA refusal naming no matching entry and no encryption; inconclusive"; set -- 3 "$1" ;; esac ;; tls:*) echo "psql exit $2: the tls login did not connect; inconclusive (the hostname-verification and client-certificate comparisons expect a rejection here, which this block cannot tell from any other failure)"; set -- 3 "$1" ;; *) echo "psql exit $2: the scram login did not connect; inconclusive (a rejected non-SCRAM exchange is the exposed state, which this block cannot tell from any other failure)"; set -- 3 "$1" ;; esac echo "block exit $1; only this $2 run is classified, so pairing it with its matched run in the table below the block, and reading any error above, is left to you" exit "$1" ;; esac ) ``` Run each mode separately so an expected failure cannot skip the next positive control. | Check | Exposed fixture and expected result | Fixed result and matched positive control | | --- | --- | --- | | Password demand | An earlier matching `trust` rule lets `wrong` connect despite the incorrect prompted password. | `wrong` fails with password authentication failure; `tls` with the correct password still succeeds. | | Negotiated SCRAM | A matching `password` rule allows the ordinary `tls` login, but `scram` rejects the non-SCRAM exchange. | `scram` succeeds with the correct password. This requires libpq 16+ and proves the negotiated method, not removal of every HBA entry named `md5`. | | TLS enforcement | A matching plaintext-permitting `host` rule lets `plaintext` connect using disposable valid credentials. | `plaintext` receives an HBA refusal indicating no matching entry and no encryption; `tls` still succeeds. | | Server hostname verification | On the disposable endpoint, install an otherwise trusted certificate for the wrong hostname. In the exposed client comparison, change only the `tls` branch's `sslmode` to `require`; it can connect. | Restore `verify-full`; the wrong-name certificate causes a hostname-verification failure. A trusted certificate for the actual hostname makes the same `tls` command succeed. | The first-match rule, SCRAM negotiation, and TLS-mode distinctions are documented in the [HBA reference](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html), [password authentication](https://www.postgresql.org/docs/current/auth-password.html), [libpq parameters](https://www.postgresql.org/docs/current/libpq-connect.html) and [libpq TLS verification](https://www.postgresql.org/docs/current/libpq-ssl.html). The client version boundary is documented in the [PostgreSQL 16 release notes](https://www.postgresql.org/docs/16/release-16.html). If using `clientcert=verify-full`, also test with two trusted, otherwise valid client certificates: one matching the requested database identity and one not matching it or any configured mapping. Run `tls` with each, supplying the certificate through the test client's normal libpq certificate configuration. With exposed `clientcert=verify-ca`, both identities can pass certificate validation; fixed `verify-full` rejects the mismatched identity while the matching certificate and correct password still connect. **REASONED: the writable deployment and client-certificate fixture are unavailable.** See [client certificate options](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html) and [libpq client certificates](https://www.postgresql.org/docs/current/libpq-ssl.html). ### 8.2. Listener binding (REASONED: listener comparison from cited PostgreSQL and ss documentation; no authorized writable deployment.) **REASONED: no authorized writable deployment in which to change and inspect PostgreSQL listeners.** Run on the database host, substituting the intended bind address. Inspect every returned listener, including IPv6. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_EXPECTED_BIND_ADDRESS' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply the expected bind address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder; not probing"; exit 2 ;; *) command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } printf 'Expected listener address: %s; inspect every returned row.\n' "$1" set -- "$(if ss -tlnp 'sport = :5432'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" # the listener table, then ss's exit status printf '%s' "$1" if [ "$2" != 0 ]; then echo "ss exit $2: the listener inventory failed; inconclusive unless a row above is a wildcard" fi if printf '%s' "$1" | awk 'NR > 1 && ($4 == "0.0.0.0:5432" || $4 == "[::]:5432" || $4 == "*:5432") { found = 1 } END { exit !found }'; then echo "FINDING: a wildcard listener on port 5432, which listen_addresses = '*' produces"; exit 1 else case "$?" in 1) ;; *) echo "awk could not read the listener table; inconclusive"; exit 3 ;; esac fi if [ "$2" != 0 ]; then exit 3 fi if printf '%s' "$1" | awk 'NR > 1 { found = 1 } END { exit !found }'; then echo "no wildcard listener on port 5432; unclassified observation: compare every row above with your intended addresses, and pair it with a successful tls connection through an intended interface"; exit 0 else case "$?" in 1) echo "no listener on port 5432, which is not a passing result; inconclusive"; exit 3 ;; *) echo "awk could not read the listener table; inconclusive"; exit 3 ;; esac fi ;; esac ) ``` In the exposed disposable fixture, `listen_addresses = '*'` produces wildcard listeners on available IP interfaces. After setting the intended loopback/private addresses and restarting, only the intended addresses should remain. Pair this observation with a successful `tls` connection through an intended interface; no listener at all is not a passing result. See [listen_addresses](https://www.postgresql.org/docs/current/runtime-config-connection.html) and [ss](https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html). ### 8.3. Runtime access and ownership (REASONED: ownership and grant comparisons from cited PostgreSQL documentation; no authorized writable cluster.) **REASONED: no authorized writable cluster for ownership, membership, and default-grant fixtures.** Use actual `app` logins through the wrapper's `sql` mode for application checks. A superuser session followed by `SET ROLE app` does not test login-time settings or connection admission. In the disposable database, create and seed an existing-object probe from the separate migration connection: ```sql SET ROLE app_owner; CREATE TABLE app_data.security_probe (id integer PRIMARY KEY); INSERT INTO app_data.security_probe VALUES (1); GRANT SELECT, INSERT, UPDATE, DELETE ON app_data.security_probe TO app_runtime; RESET ROLE; ``` As the real `app` login, this positive control must return the seeded row in both states: ```sql SELECT * FROM app_data.security_probe; ``` Run each negative probe separately. If a statement fails inside a transaction, issue `ROLLBACK;` before continuing, or close that session. ```sql BEGIN; ALTER TABLE app_data.security_probe ADD COLUMN probe integer; ROLLBACK; ``` In a separate `app` session: ```sql SET ROLE app_owner; ``` With exposed table ownership or owner-role membership, alteration can succeed; membership permitting role selection also allows `SET ROLE app_owner`. Fixed runtime access must deny both while preserving the read. Pair the denied alteration with the same transactional alteration run successfully from the authorized migration connection as `app_owner`. To check future grants, create another table as `app_owner` after configuring default privileges, without a manual grant: ```sql SET ROLE app_owner; CREATE TABLE app_data.security_probe_future (id integer PRIMARY KEY); INSERT INTO app_data.security_probe_future VALUES (2); RESET ROLE; ``` As `app`: ```sql SELECT * FROM app_data.security_probe_future; ``` The fixed state returns the seeded row. In the comparison fixture without the default table grant, this future-table read is denied while the explicitly granted existing-table read still succeeds. Recreate the fixture for each state; changing defaults does not repair a table already created. These expected distinctions follow [ownership privileges](https://www.postgresql.org/docs/current/ddl-priv.html), [SET ROLE](https://www.postgresql.org/docs/current/sql-set-role.html) and [default grants](https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html). ### 8.4. Database admission and schema creation (REASONED: admission and creation comparisons from cited PostgreSQL documentation; no disposable cluster or nonmember login.) **REASONED: no authorized writable cluster with a disposable nonmember login and controllable HBA/ACLs.** Administratively create a disposable login: ```sql CREATE ROLE app_probe_nonmember LOGIN; ``` Set its disposable password with psql's interactive `\password app_probe_nonmember`. Give it matching SCRAM HBA access to `app` from the test client, but no runtime membership or separate `CONNECT` grant. Run the wrapper with mode `tls` and role `app_probe_nonmember`, then with role `app`. With exposed `PUBLIC CONNECT`, both connect. After the admission changes, the nonmember must receive a database permission denial while `app` still connects. A password failure or missing HBA entry is not the admission discriminator. See [CONNECT privileges](https://www.postgresql.org/docs/current/ddl-priv.html). As `app`, separately test public schema creation: ```sql BEGIN; CREATE TABLE public.security_creation_probe (id integer); ROLLBACK; ``` With exposed `PUBLIC CREATE`, creation succeeds. After revocation, it must fail for insufficient schema privileges. Run the same transactional creation successfully as an authorized administrator to show that the schema and operation are usable. If `app` still succeeds, inspect ownership, direct grants, and memberships. See [schema privileges](https://www.postgresql.org/docs/current/ddl-schemas.html). If temporary-table creation was revoked, also run as `app`: ```sql BEGIN; CREATE TEMP TABLE security_temp_probe (id integer); ROLLBACK; ``` The exposed default permits creation; the fixed state denies it. Pair the denial with a successful `SELECT * FROM app_data.security_probe;` as `app` and successful temporary-table creation from an authorized administrative connection. See [TEMPORARY privileges](https://www.postgresql.org/docs/current/ddl-priv.html) and [CREATE TABLE](https://www.postgresql.org/docs/current/sql-createtable.html). ### 8.5. Schema lookup and function execution (REASONED: lookup and execution comparisons from cited PostgreSQL documentation; no writable cluster or function fixtures.) **REASONED: no authorized writable cluster for trusted/shadow tables and privileged-function fixtures.** Create this test-only fixture administratively in the disposable database: ```sql BEGIN; SET ROLE app_owner; CREATE TABLE app_data.security_lookup_probe ( id integer PRIMARY KEY, marker text ); INSERT INTO app_data.security_lookup_probe VALUES (1, 'trusted'); CREATE FUNCTION app_data.security_lookup_probe_fn() RETURNS text LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, app_data, pg_temp AS $$ SELECT marker FROM security_lookup_probe WHERE id = 1; $$; REVOKE EXECUTE ON FUNCTION app_data.security_lookup_probe_fn() FROM PUBLIC; GRANT EXECUTE ON FUNCTION app_data.security_lookup_probe_fn() TO app_runtime; RESET ROLE; COMMIT; ``` For this isolated lookup test only, an administrator must grant `TEMPORARY` to `app` if the optional revocation in step 4 removed it: ```sql GRANT TEMPORARY ON DATABASE app TO app; ``` Open a fresh real `app` session with mode `sql`. Inspect the login default, then create a distinguishable temporary relation. Grant its read privilege to the function owner so a permission error cannot conceal an unsafe lookup. ```sql SHOW search_path; CREATE TEMP TABLE security_lookup_probe ( id integer PRIMARY KEY, marker text ); INSERT INTO pg_temp.security_lookup_probe VALUES (1, 'shadow'); GRANT SELECT ON pg_temp.security_lookup_probe TO app_owner; SELECT marker FROM security_lookup_probe WHERE id = 1; SELECT app_data.security_lookup_probe_fn(); ``` With the fixed login path and function-local path, both queries must return `trusted`. For the exposed login-path comparison, set the disposable session's path without an explicit `pg_temp` entry: ```sql SET search_path = pg_catalog, app_data; SELECT marker FROM security_lookup_probe WHERE id = 1; SELECT app_data.security_lookup_probe_fn(); ``` The ordinary unqualified query now returns `shadow`; the fixed function must still return `trusted`. For the independent exposed function-path comparison, change only the test function administratively: ```sql ALTER FUNCTION app_data.security_lookup_probe_fn() SET search_path = pg_catalog, app_data; ``` Reconnect as `app`, recreate the temporary fixture and its grant, and call the function again. It now returns `shadow`. Restore the function-local path: ```sql ALTER FUNCTION app_data.security_lookup_probe_fn() SET search_path = pg_catalog, app_data, pg_temp; ``` Repeat through a fresh `app` session with the same temporary fixture; the result must return to `trusted`. The documented reason is that omitting `pg_temp` searches temporary relations first. See [search_path](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SEARCH-PATH) and [safe SECURITY DEFINER functions](https://www.postgresql.org/docs/current/sql-createfunction.html). For the execution-privilege test, retain the fixed function path. Administratively admit the disposable nonmember and give it schema lookup access, isolating function execution from admission and schema failures: ```sql GRANT CONNECT ON DATABASE app TO app_probe_nonmember; GRANT USAGE ON SCHEMA app_data TO app_probe_nonmember; GRANT EXECUTE ON FUNCTION app_data.security_lookup_probe_fn() TO PUBLIC; ``` Through separate real `app` and `app_probe_nonmember` connections, run: ```sql SELECT app_data.security_lookup_probe_fn(); ``` Both succeed in this exposed fixture. Administratively apply: ```sql REVOKE EXECUTE ON FUNCTION app_data.security_lookup_probe_fn() FROM PUBLIC; ``` The same call must then fail for the nonmember with a function permission denial, while `app` still returns `trusted`. This tests the [EXECUTE privilege](https://www.postgresql.org/docs/current/sql-grant.html). Discard the disposable fixture after testing. The temporary-table and nonmember admission grants above are test prerequisites, not production grants. ### 8.6. Core logging (REASONED: log-delivery comparison from cited PostgreSQL documentation; no writable cluster or collected logs.) **REASONED: no authorized writable cluster and accessible collected log destination.** Through a fresh real `app` connection, run and then disconnect: ```sql SELECT 1; ``` From the separate migration connection: ```sql SET ROLE app_owner; BEGIN; CREATE TABLE app_data.audit_probe (id integer); ROLLBACK; RESET ROLE; ``` Compare a disposable fixture with connection/disconnection logging disabled and `log_statement = 'none'` against step 7. Both operations must succeed in both states. The fixed state must produce the connection, disconnection, and DDL records in the actual collected destination, correlated by time, process, role, and database. The corresponding records are absent in the exposed fixture. `ddl` need not record the successful `SELECT 1` statement. Inspect actual records; `SHOW` output alone does not demonstrate delivery. See the [logging reference](https://www.postgresql.org/docs/current/runtime-config-logging.html). ### 8.7. Optional pgAudit (REASONED: audit-event comparison from cited pgAudit documentation; no writable cluster with pgAudit and logs.) **REASONED: no authorized writable cluster with pgAudit and access to its server-side logs.** As the real `app` login: ```sql SELECT * FROM app_data.security_probe; ``` Compare fresh sessions with the effective `pgaudit.log` setting disabled (`none`) and with the selected audit classes enabled. The seeded query must succeed in both states. An appropriate server-side `READ` audit event, correlated to that query and session, must appear in the enabled state and be absent from the disabled state. Confirm effective settings for this login; role-specific defaults can override database defaults. Extension installation alone is not a passing result. See the [pgAudit session audit documentation](https://github.com/pgaudit/pgaudit/blob/dedd42ec3fe880bf581dd578c0df87ed4ad246cd/README.md). ### 8.8. Supplemental diagnostics (REASONED: diagnostics from cited PostgreSQL documentation; no authorized target service.) **REASONED: no authorized target service on which to execute these administrative queries.** Run through an authorized administrative connection. These provide context for the paired tests; they are not independent enforcement proofs. ```sql SELECT ssl, count(*) FROM pg_stat_ssl JOIN pg_stat_activity USING (pid) GROUP BY ssl; SELECT rolname, rolpassword LIKE 'SCRAM-SHA-256%' AS scram FROM pg_authid WHERE rolname = 'app'; SELECT line_number, error FROM pg_hba_file_rules WHERE error IS NOT NULL; SELECT name, setting, applied, error FROM pg_file_settings WHERE error IS NOT NULL OR NOT applied; ``` `pg_stat_ssl` describes live connections, not connections the server would permit. The `pg_authid` query reports only a verifier-format Boolean, not the password verifier itself; require a row for `app` with `scram` true, then separately test negotiated SCRAM. `pg_hba_file_rules` describes current file contents, not necessarily the last successfully loaded rules. `pg_file_settings` also describes current files; an unapplied entry can be superseded by a later entry without being an error. Neither view proves which HBA rule authenticated a session or that replacement TLS files were accepted. See [connection statistics](https://www.postgresql.org/docs/current/monitoring-stats.html), [pg_authid](https://www.postgresql.org/docs/current/catalog-pg-authid.html), [pg_hba_file_rules](https://www.postgresql.org/docs/current/view-pg-hba-file-rules.html) and [pg_file_settings](https://www.postgresql.org/docs/current/view-pg-file-settings.html). ### Verification scope | Check scope | Status and missing prerequisite | Deployment comparison | | --- | --- | --- | | REASONED: whole row, per the cited PostgreSQL and pgAudit documentation, with the missing prerequisites and the required exposed and fixed comparisons stated in this row; no authorized writable deployment is available. Service checks 8.1-8.8 | REASONED from the cited PostgreSQL and pgAudit documentation; not demonstrated. An authorized writable disposable PostgreSQL deployment, TLS/client-certificate fixtures, nonmember credentials, collected logs, and pgAudit for the optional check are unavailable. | Run each applicable exposed/fixed comparison and matched positive control above through real authenticated logins. Record versions, configurations, commands, results, and correlated server logs. Include the earlier `trust` rule, plaintext-permitting `host` rule, and non-SCRAM password exchange fixtures. Record diagnostic output without treating it as enforcement proof. | ## Common mistakes - `listen_addresses = '*'` plus a permissive `host all all 0.0.0.0/0 md5` line pasted from a tutorial. - `trust` authentication left enabled for remote addresses. - The superuser (`postgres`) used as the application account; create a least-privilege role instead ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). - Giving the application membership in its owner role, which defeats the separation between runtime access and migrations. - Assuming PostgreSQL 15 repaired public-schema permissions in an upgraded or restored database. - Treating a successful reload signal, a stored SCRAM verifier, or an installed audit extension as proof of enforcement. ## Sources (checked September 2026) - Official PostgreSQL Docker images (14 through 19, Debian and Alpine; shown for 19/bookworm; pinned commit d588a44673ea9d123c1acb1a6924de10a27fc315). The cited Docker image files cover the build's `listen_addresses = '*'` edit to postgresql.conf.sample, `CMD ["postgres"]`, the entrypoint's initdb call with no authentication method beyond `POSTGRES_INITDB_ARGS`, the init-only password check (`POSTGRES_PASSWORD` read with `file_env`), and the appended `host all all all` line defaulting to `password_encryption`. Official PostgreSQL Docker image sources: https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/Dockerfile#L183-L184 and https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/Dockerfile#L223-L224. More official PostgreSQL Docker image sources: https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L92 and https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L105-L138. More official PostgreSQL Docker image sources: https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L235 and https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L268-L286. One more official PostgreSQL Docker image source: https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L347-L355 - initdb's default authentication method `trust` when none is given, and the sample `pg_hba.conf` loopback rules that take it (pinned tag REL_17_11; the same default in REL_14_24, REL_15_19, REL_16_15, REL_18_6 and REL_19_BETA4). PostgreSQL initdb and `pg_hba.conf` sources: https://github.com/postgres/postgres/blob/REL_17_11/src/bin/initdb/initdb.c#L2550-L2557, https://github.com/postgres/postgres/blob/REL_17_11/src/bin/initdb/initdb.c#L3434-L3435 and https://github.com/postgres/postgres/blob/REL_17_11/src/backend/libpq/pg_hba.conf.sample#L113-L117 - Secure TCP/IP connections with SSL and TLS file reload behaviour: https://www.postgresql.org/docs/current/ssl-tcp.html - Connections and authentication settings: https://www.postgresql.org/docs/current/runtime-config-connection.html - HBA matching, reload behaviour, and client certificate options: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html - Password authentication and SCRAM negotiation through md5 HBA entries: https://www.postgresql.org/docs/current/auth-password.html - RADIUS authentication: https://www.postgresql.org/docs/current/auth-radius.html - Libpq TLS verification and client certificates: https://www.postgresql.org/docs/current/libpq-ssl.html - Libpq connection parameters: https://www.postgresql.org/docs/current/libpq-connect.html - CREATE ROLE: https://www.postgresql.org/docs/current/sql-createrole.html - ALTER ROLE and login defaults: https://www.postgresql.org/docs/current/sql-alterrole.html - Role membership: https://www.postgresql.org/docs/current/role-membership.html - ALTER DATABASE: https://www.postgresql.org/docs/current/sql-alterdatabase.html - CREATE SCHEMA: https://www.postgresql.org/docs/current/sql-createschema.html - GRANT: https://www.postgresql.org/docs/current/sql-grant.html - REVOKE: https://www.postgresql.org/docs/current/sql-revoke.html - ALTER DEFAULT PRIVILEGES: https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html - SET ROLE and RESET ROLE: https://www.postgresql.org/docs/current/sql-set-role.html - Object ownership and default privileges: https://www.postgresql.org/docs/current/ddl-priv.html - Schemas, privileges, and search paths: https://www.postgresql.org/docs/current/ddl-schemas.html - Client defaults, search_path, and shared_preload_libraries: https://www.postgresql.org/docs/current/runtime-config-client.html - CREATE FUNCTION and safe SECURITY DEFINER functions: https://www.postgresql.org/docs/current/sql-createfunction.html - ALTER FUNCTION: https://www.postgresql.org/docs/current/sql-alterfunction.html - Logging configuration: https://www.postgresql.org/docs/current/runtime-config-logging.html - Official pgAudit 18 documentation: https://github.com/pgaudit/pgaudit/blob/dedd42ec3fe880bf581dd578c0df87ed4ad246cd/README.md - CREATE EXTENSION: https://www.postgresql.org/docs/current/sql-createextension.html - CREATE TABLE and temporary tables: https://www.postgresql.org/docs/current/sql-createtable.html - ALTER TABLE and ownership transfer: https://www.postgresql.org/docs/current/sql-altertable.html - INSERT: https://www.postgresql.org/docs/current/sql-insert.html - SELECT: https://www.postgresql.org/docs/current/sql-select.html - BEGIN: https://www.postgresql.org/docs/current/sql-begin.html - COMMIT: https://www.postgresql.org/docs/current/sql-commit.html - ROLLBACK: https://www.postgresql.org/docs/current/sql-rollback.html - SHOW: https://www.postgresql.org/docs/current/sql-show.html - psql invocation, password prompts, and password changes: https://www.postgresql.org/docs/current/app-psql.html - PostgreSQL server configuration inspection: https://www.postgresql.org/docs/current/app-postgres.html - System administration functions including pg_reload_conf: https://www.postgresql.org/docs/current/functions-admin.html - System information functions including version: https://www.postgresql.org/docs/current/functions-info.html - Aggregate functions including count: https://www.postgresql.org/docs/current/functions-aggregate.html - Pattern matching with LIKE: https://www.postgresql.org/docs/current/functions-matching.html - Connection statistics including pg_stat_ssl and pg_stat_activity: https://www.postgresql.org/docs/current/monitoring-stats.html - Password verifier catalog fields: https://www.postgresql.org/docs/current/catalog-pg-authid.html - HBA file diagnostics: https://www.postgresql.org/docs/current/view-pg-hba-file-rules.html - Configuration file diagnostics: https://www.postgresql.org/docs/current/view-pg-file-settings.html - PostgreSQL 12 TLS parameter and client certificate changes: https://www.postgresql.org/docs/12/release-12.html - PostgreSQL 14 password_encryption default change: https://www.postgresql.org/docs/14/release-14.html - PostgreSQL 15 public-schema permission and ownership changes: https://www.postgresql.org/docs/15/release-15.html - PostgreSQL 16 membership options: https://www.postgresql.org/docs/16/role-membership.html - PostgreSQL 16 require_auth introduction: https://www.postgresql.org/docs/16/release-16.html - PostgreSQL 18 MD5 deprecation and connection logging changes: https://www.postgresql.org/docs/18/release-18.html - ss listener options and sport filter: https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - init: PostgreSQL initdb source (REL_17_11) - pg12: PostgreSQL TLS minimum (12) - pg14: PostgreSQL SCRAM default (14) - pg15: PostgreSQL schema defaults (15) - pg16: PostgreSQL membership and require_auth (16) - pg18: PostgreSQL MD5 and logging changes (18) - docs: PostgreSQL documentation (unknown) - docker: PostgreSQL image source (d588a44673ea9d123c1acb1a6924de10a27fc315) - audit: pgAudit source (dedd42ec3fe880bf581dd578c0df87ed4ad246cd) Claims (full source mapping in the JSON block below): - bind (REASONED; docs): Keep listen_addresses local; widening requires a restart and listener inspection. - server-tls (REASONED; docs): Enable TLS with certificate/key files and a postgres-owned key at mode 600. - tls-minimum (REASONED; pg12, docs): ssl_min_protocol_version sets TLSv1.2; this setting requires PostgreSQL 12+. - scram-default (REASONED; pg14, docs): password_encryption is scram-sha-256 by default from PostgreSQL 14. - reload (REASONED; docs): pg_reload_conf signals reload, not certificate acceptance; invalid TLS replacements can retain old settings; passphrase reload has extra requirements. - image-bind (REASONED; docker): Pinned Debian/Alpine images for 14 through 19 initialize from listen_addresses='*'; restrict published ports. - image-init-auth (REASONED; init, docker): Pinned initdb defaults to trust for sockets and loopback unless auth arguments override it; recorded tags cover 14.24 through 19 beta 4. - image-host-auth (REASONED; docker, docs): The entrypoint appends host all all all using POSTGRES_HOST_AUTH_METHOD, defaulting to password_encryption; earlier matching rules win. - image-password (REASONED; docker): New-directory initialization requires POSTGRES_PASSWORD or its file unless host auth is trust; never select trust for reachable clients. - hba (REASONED; docs): host permits plaintext and TLS; hostssl plus SCRAM restricts remote access. Local peer is separate; first-match HBA has no fallback after authentication failure. - hba-reload (REASONED; docs): Unix HBA edits require reload; Windows applies edits to subsequent connections without that same requirement. - password-migration (REASONED; docs): Changing encryption does not convert stored verifiers; use prompted psql password reset. An md5 rule can negotiate SCRAM. - md5-deprecation (REASONED; pg18): PostgreSQL 18 deprecates MD5 password support. - clientcert (REASONED; pg12, docs): PostgreSQL 12+ clientcert=verify-full adds CA-validated client identity to SCRAM, matching username/mapping, normally by Common Name. - mfa (REASONED; docs): Machine certificates do not establish human MFA; RADIUS is an alternative HBA method, not an added SCRAM factor or general interactive MFA flow. - runtime (REASONED; docs): Separate NOLOGIN owner/runtime roles; remove superuser, database/role creation, replication and RLS bypass from app; review inherited grants. - ownership (REASONED; docs): NOLOGIN does not prevent membership access; keep app outside owner roles and transfer existing objects separately from database ownership. - grants (REASONED; docs): Grant runtime schema, table and sequence access only inside its boundary; owner-specific default grants affect future objects, not existing ones. - membership (REASONED; pg16): PostgreSQL 16+ membership INHERIT/SET options are distinct; role INHERIT defaults new memberships, and NOINHERIT does not prohibit SET ROLE. - admission (REASONED; docs): PUBLIC receives CONNECT and TEMPORARY by default; restrict each database, preserving required operators. CONNECT revocation affects new sessions only. - schema-create (REASONED; docs): Revoke unwanted PUBLIC CREATE and optional TEMPORARY; direct grants, ownership and memberships can still confer access; preserve required USAGE. - schema-default (REASONED; pg15): PostgreSQL 15 new databases remove PUBLIC CREATE on public and use pg_database_owner; upgrades/restores can preserve old rights; CONNECT is unchanged. - search-path (REASONED; docs): Trust only owner-writable schemas; set app's login path with pg_temp last. Reconnect; SET ROLE does not apply login defaults and app can change its path. - definer (REASONED; docs): SECURITY DEFINER functions need their own safe path and restricted EXECUTE; revoke global default PUBLIC EXECUTE for future owner-created functions. - client-tls (REASONED; docs): libpq verify-full checks hostname and CA; require does not check hostname, even when a root CA makes it validate the chain. - gss (REASONED; docs): Set gssencmode=disable when requiring TLS verification; working GSSAPI encryption otherwise can take precedence; other drivers need equivalent settings. - logging (REASONED; docs): Collect connection/disconnection and DDL logs; logging_collector requires restart, log_file_mode is Unix-only, and broader statement logging risks sensitive values. - logging-version (REASONED; pg18, docs): PostgreSQL 18 log_connections accepts a list; on retains receipt, authentication and authorization logging, not every new option. - pgaudit (REASONED; audit, docs): pgAudit 18.x targets PostgreSQL 18; match server major, preserve preloads, restart, create extension before pgaudit.log and reconnect; installation is not audit delivery. - verify-password (REASONED; docs): Wrong prompted password succeeds under trust but must fail under password authentication while the correct-password TLS control succeeds. - verify-scram (REASONED; pg16, docs): libpq 16+ require_auth=scram-sha-256 rejects a non-SCRAM exchange; success proves negotiation, not removal of all md5 rules. - verify-plaintext (REASONED; docs): Plaintext succeeds under a permitting host rule; fixed HBA refuses no-encryption access while the same TLS login succeeds. - verify-identity (REASONED; docs): A trusted wrong-host certificate can pass require but must fail verify-full; restore the matching certificate as the positive control. - verify-clientcert (REASONED; docs): Compare matching and mismatched trusted client identities: verify-ca accepts both, verify-full rejects the mismatch while correct identity/password succeeds. - verify-listeners (REASONED; docs): Inventory all 5432 listeners including IPv6; wildcard becomes intended addresses after restart, with a successful intended-interface TLS control. - verify-ownership (REASONED; docs): Real app login reads existing fixtures but cannot ALTER or SET ROLE owner; owner alteration succeeds as the matched control. - verify-admission (REASONED; docs): PUBLIC CONNECT permits a nonmember; revocation must produce database permission denial while app connects, not an HBA/password error. - verify-creation (REASONED; docs): PUBLIC CREATE and TEMPORARY probes must change from success to privilege denial, retaining administrator and application positive controls. - verify-path (REASONED; docs): Trusted/shadow fixtures independently test login and function-local paths; omitted pg_temp permits shadow relations and restoring it returns trusted. - verify-execute (REASONED; docs): With admission and schema lookup held constant, PUBLIC EXECUTE removal denies the nonmember while the application function call succeeds. - verify-logs (REASONED; docs): Successful connection, disconnection and DDL produce correlated records only when enabled; SHOW and extension installation do not prove delivery. - verify-audit (REASONED; audit): Compare none versus enabled audit classes in fresh app sessions; the same seeded read succeeds and only the enabled state produces its READ event. - diagnostics (REASONED; docs): pg_stat_ssl describes live sessions; verifier format, HBA/current-file views and superseded settings do not prove negotiated auth or accepted TLS reload. - local-guards (DEMONSTRATED; docs): Historical shell and argument checks exercised local guards only, without service contact. They covered an earlier form of both blocks, whose refusals exited with status 1, and their guard and argument cases were not repeated; the current form is covered by gate-suite bash -n and ShellCheck 0.11.0 passes and by listener-block stub runs with ss passing, failing and missing. - local-parsing (DEMONSTRATED; docs): Historical PostgreSQL 18.6 parsed 14 configuration values and rejected an invalid Boolean; no certificates, pgAudit, running HBA or SQL were exercised. - verify-default-grants (REASONED; docs): Owner-created future tables must be readable with default grants and denied without them while the existing-table control succeeds; recreate fixtures per state. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "8dfdaa5890bde912d02bc09c4b2deb85d5a5f6b4cd58956274ad0ea39a47a7a9", "components": { "init": { "name": "PostgreSQL initdb source", "basis": "REL_17_11", "sources": { "sd22c3409ddcd": "https://github.com/postgres/postgres/blob/REL_17_11/src/backend/libpq/pg_hba.conf.sample#L113-L117", "s311f8c9d4751": "https://github.com/postgres/postgres/blob/REL_17_11/src/bin/initdb/initdb.c#L2550-L2557", "s7d7180e2b81f": "https://github.com/postgres/postgres/blob/REL_17_11/src/bin/initdb/initdb.c#L3434-L3435" } }, "pg12": { "name": "PostgreSQL TLS minimum", "basis": "12", "sources": { "s260cb0b13223": "https://www.postgresql.org/docs/12/release-12.html" } }, "pg14": { "name": "PostgreSQL SCRAM default", "basis": "14", "sources": { "s624abdbb3a62": "https://www.postgresql.org/docs/14/release-14.html" } }, "pg15": { "name": "PostgreSQL schema defaults", "basis": "15", "sources": { "s674004a0d3a8": "https://www.postgresql.org/docs/15/release-15.html" } }, "pg16": { "name": "PostgreSQL membership and require_auth", "basis": "16", "sources": { "s03525343bc49": "https://www.postgresql.org/docs/16/role-membership.html", "s2bdad423cced": "https://www.postgresql.org/docs/16/release-16.html" } }, "pg18": { "name": "PostgreSQL MD5 and logging changes", "basis": "18", "sources": { "se36ec635e2a4": "https://www.postgresql.org/docs/18/release-18.html" } }, "docs": { "name": "PostgreSQL documentation", "basis": "unknown", "sources": { "s4feea1443725": "https://www.postgresql.org/docs/current/runtime-config-connection.html", "s8215c4f0b29d": "https://www.postgresql.org/docs/current/ssl-tcp.html", "s7a7f6fb3b9ef": "https://www.postgresql.org/docs/current/auth-password.html", "s6d49df9638da": "https://www.postgresql.org/docs/current/functions-admin.html", "sbb484e8233fe": "https://www.postgresql.org/docs/current/auth-pg-hba-conf.html", "sf8b93623c032": "https://www.postgresql.org/docs/current/app-psql.html", "s213c900cae6f": "https://www.postgresql.org/docs/current/auth-radius.html", "s74d80ffaaf96": "https://www.postgresql.org/docs/current/sql-createrole.html", "sc9e1e81c4eb9": "https://www.postgresql.org/docs/current/sql-alterrole.html", "sf897d9524ed3": "https://www.postgresql.org/docs/current/role-membership.html", "s6c7856c30ba2": "https://www.postgresql.org/docs/current/sql-alterdatabase.html", "s4ded00efc4e2": "https://www.postgresql.org/docs/current/sql-set-role.html", "s20f27b82b893": "https://www.postgresql.org/docs/current/ddl-priv.html", "s230b60e145cf": "https://www.postgresql.org/docs/current/sql-altertable.html", "sb2d96220e6f0": "https://www.postgresql.org/docs/current/sql-createschema.html", "s65aff299a5a6": "https://www.postgresql.org/docs/current/sql-grant.html", "s04e81888353f": "https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html", "sb6224f4ac866": "https://www.postgresql.org/docs/current/sql-revoke.html", "sdd8b8cfb0e56": "https://www.postgresql.org/docs/current/ddl-schemas.html", "s1844713022bf": "https://www.postgresql.org/docs/current/runtime-config-client.html", "sec39c317b06f": "https://www.postgresql.org/docs/current/sql-createfunction.html", "s0fe66d71aa10": "https://www.postgresql.org/docs/current/sql-alterfunction.html", "sb30fe96b6195": "https://www.postgresql.org/docs/current/libpq-ssl.html", "sd389a3478bc7": "https://www.postgresql.org/docs/current/libpq-connect.html", "sf1b3da55ebd5": "https://www.postgresql.org/docs/current/runtime-config-logging.html", "s8df2d48dbc2d": "https://www.postgresql.org/docs/current/sql-createextension.html", "s1b4e67e38127": "https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html", "s7bb6f68ef52e": "https://www.postgresql.org/docs/current/sql-createtable.html", "seed379369be7": "https://www.postgresql.org/docs/current/monitoring-stats.html", "s8754b96a6114": "https://www.postgresql.org/docs/current/catalog-pg-authid.html", "s3b6607ed7b1a": "https://www.postgresql.org/docs/current/view-pg-hba-file-rules.html", "s2ab6c4aebc59": "https://www.postgresql.org/docs/current/view-pg-file-settings.html", "s55cbaf1aa910": "https://www.postgresql.org/docs/current/app-postgres.html" } }, "docker": { "name": "PostgreSQL image source", "basis": "d588a44673ea9d123c1acb1a6924de10a27fc315", "sources": { "sd5180dc544b5": "https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/Dockerfile#L183-L184", "s2e6f9a2e2499": "https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L105-L138", "s595428f208ab": "https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L268-L286", "sb9987ddb368d": "https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L235", "sea8869c4fd61": "https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L347-L355", "s651e63340b47": "https://github.com/docker-library/postgres/blob/d588a44673ea9d123c1acb1a6924de10a27fc315/19/bookworm/docker-entrypoint.sh#L92" } }, "audit": { "name": "pgAudit source", "basis": "dedd42ec3fe880bf581dd578c0df87ed4ad246cd", "sources": { "s1825bbe4f88c": "https://github.com/pgaudit/pgaudit/blob/dedd42ec3fe880bf581dd578c0df87ed4ad246cd/README.md" } } }, "claims": { "bind": {"text": "Keep listen_addresses local; widening requires a restart and listener inspection.", "components": ["docs"], "sources": ["docs:s4feea1443725"], "status": "REASONED"}, "server-tls": {"text": "Enable TLS with certificate/key files and a postgres-owned key at mode 600.", "components": ["docs"], "sources": ["docs:s8215c4f0b29d", "docs:s4feea1443725"], "status": "REASONED"}, "tls-minimum": {"text": "ssl_min_protocol_version sets TLSv1.2; this setting requires PostgreSQL 12+.", "components": ["pg12", "docs"], "sources": ["pg12:s260cb0b13223", "docs:s4feea1443725"], "status": "REASONED"}, "scram-default": {"text": "password_encryption is scram-sha-256 by default from PostgreSQL 14.", "components": ["pg14", "docs"], "sources": ["pg14:s624abdbb3a62", "docs:s7a7f6fb3b9ef"], "status": "REASONED"}, "reload": {"text": "pg_reload_conf signals reload, not certificate acceptance; invalid TLS replacements can retain old settings; passphrase reload has extra requirements.", "components": ["docs"], "sources": ["docs:s8215c4f0b29d", "docs:s4feea1443725", "docs:s6d49df9638da"], "status": "REASONED"}, "image-bind": {"text": "Pinned Debian/Alpine images for 14 through 19 initialize from listen_addresses='*'; restrict published ports.", "components": ["docker"], "sources": ["docker:sd5180dc544b5"], "status": "REASONED"}, "image-init-auth": {"text": "Pinned initdb defaults to trust for sockets and loopback unless auth arguments override it; recorded tags cover 14.24 through 19 beta 4.", "components": ["init", "docker"], "sources": ["init:sd22c3409ddcd", "init:s311f8c9d4751", "init:s7d7180e2b81f", "docker:s2e6f9a2e2499"], "status": "REASONED"}, "image-host-auth": {"text": "The entrypoint appends host all all all using POSTGRES_HOST_AUTH_METHOD, defaulting to password_encryption; earlier matching rules win.", "components": ["docker", "docs"], "sources": ["docker:s595428f208ab", "docs:sbb484e8233fe"], "status": "REASONED"}, "image-password": {"text": "New-directory initialization requires POSTGRES_PASSWORD or its file unless host auth is trust; never select trust for reachable clients.", "components": ["docker"], "sources": ["docker:sb9987ddb368d", "docker:sea8869c4fd61", "docker:s651e63340b47"], "status": "REASONED"}, "hba": {"text": "host permits plaintext and TLS; hostssl plus SCRAM restricts remote access. Local peer is separate; first-match HBA has no fallback after authentication failure.", "components": ["docs"], "sources": ["docs:sbb484e8233fe"], "status": "REASONED"}, "hba-reload": {"text": "Unix HBA edits require reload; Windows applies edits to subsequent connections without that same requirement.", "components": ["docs"], "sources": ["docs:sbb484e8233fe"], "status": "REASONED"}, "password-migration": {"text": "Changing encryption does not convert stored verifiers; use prompted psql password reset. An md5 rule can negotiate SCRAM.", "components": ["docs"], "sources": ["docs:s7a7f6fb3b9ef", "docs:sf8b93623c032"], "status": "REASONED"}, "md5-deprecation": {"text": "PostgreSQL 18 deprecates MD5 password support.", "components": ["pg18"], "sources": ["pg18:se36ec635e2a4"], "status": "REASONED"}, "clientcert": {"text": "PostgreSQL 12+ clientcert=verify-full adds CA-validated client identity to SCRAM, matching username/mapping, normally by Common Name.", "components": ["pg12", "docs"], "sources": ["pg12:s260cb0b13223", "docs:sbb484e8233fe"], "status": "REASONED"}, "mfa": {"text": "Machine certificates do not establish human MFA; RADIUS is an alternative HBA method, not an added SCRAM factor or general interactive MFA flow.", "components": ["docs"], "sources": ["docs:s213c900cae6f", "docs:sbb484e8233fe"], "status": "REASONED"}, "runtime": {"text": "Separate NOLOGIN owner/runtime roles; remove superuser, database/role creation, replication and RLS bypass from app; review inherited grants.", "components": ["docs"], "sources": ["docs:s74d80ffaaf96", "docs:sc9e1e81c4eb9", "docs:sf897d9524ed3"], "status": "REASONED"}, "ownership": {"text": "NOLOGIN does not prevent membership access; keep app outside owner roles and transfer existing objects separately from database ownership.", "components": ["docs"], "sources": ["docs:s6c7856c30ba2", "docs:s4ded00efc4e2", "docs:s20f27b82b893", "docs:s230b60e145cf"], "status": "REASONED"}, "grants": {"text": "Grant runtime schema, table and sequence access only inside its boundary; owner-specific default grants affect future objects, not existing ones.", "components": ["docs"], "sources": ["docs:sb2d96220e6f0", "docs:s65aff299a5a6", "docs:s04e81888353f"], "status": "REASONED"}, "membership": {"text": "PostgreSQL 16+ membership INHERIT/SET options are distinct; role INHERIT defaults new memberships, and NOINHERIT does not prohibit SET ROLE.", "components": ["pg16"], "sources": ["pg16:s03525343bc49"], "status": "REASONED"}, "admission": {"text": "PUBLIC receives CONNECT and TEMPORARY by default; restrict each database, preserving required operators. CONNECT revocation affects new sessions only.", "components": ["docs"], "sources": ["docs:s20f27b82b893", "docs:sbb484e8233fe"], "status": "REASONED"}, "schema-create": {"text": "Revoke unwanted PUBLIC CREATE and optional TEMPORARY; direct grants, ownership and memberships can still confer access; preserve required USAGE.", "components": ["docs"], "sources": ["docs:sb6224f4ac866", "docs:sdd8b8cfb0e56"], "status": "REASONED"}, "schema-default": {"text": "PostgreSQL 15 new databases remove PUBLIC CREATE on public and use pg_database_owner; upgrades/restores can preserve old rights; CONNECT is unchanged.", "components": ["pg15"], "sources": ["pg15:s674004a0d3a8"], "status": "REASONED"}, "search-path": {"text": "Trust only owner-writable schemas; set app's login path with pg_temp last. Reconnect; SET ROLE does not apply login defaults and app can change its path.", "components": ["docs"], "sources": ["docs:sdd8b8cfb0e56", "docs:s1844713022bf", "docs:sc9e1e81c4eb9"], "status": "REASONED"}, "definer": {"text": "SECURITY DEFINER functions need their own safe path and restricted EXECUTE; revoke global default PUBLIC EXECUTE for future owner-created functions.", "components": ["docs"], "sources": ["docs:sec39c317b06f", "docs:s0fe66d71aa10", "docs:s04e81888353f"], "status": "REASONED"}, "client-tls": {"text": "libpq verify-full checks hostname and CA; require does not check hostname, even when a root CA makes it validate the chain.", "components": ["docs"], "sources": ["docs:sb30fe96b6195"], "status": "REASONED"}, "gss": {"text": "Set gssencmode=disable when requiring TLS verification; working GSSAPI encryption otherwise can take precedence; other drivers need equivalent settings.", "components": ["docs"], "sources": ["docs:sd389a3478bc7"], "status": "REASONED"}, "logging": {"text": "Collect connection/disconnection and DDL logs; logging_collector requires restart, log_file_mode is Unix-only, and broader statement logging risks sensitive values.", "components": ["docs"], "sources": ["docs:sf1b3da55ebd5"], "status": "REASONED"}, "logging-version": {"text": "PostgreSQL 18 log_connections accepts a list; on retains receipt, authentication and authorization logging, not every new option.", "components": ["pg18", "docs"], "sources": ["pg18:se36ec635e2a4", "docs:sf1b3da55ebd5"], "status": "REASONED"}, "pgaudit": {"text": "pgAudit 18.x targets PostgreSQL 18; match server major, preserve preloads, restart, create extension before pgaudit.log and reconnect; installation is not audit delivery.", "components": ["audit", "docs"], "sources": ["audit:s1825bbe4f88c", "docs:s8df2d48dbc2d", "docs:s1844713022bf"], "status": "REASONED"}, "verify-password": {"text": "Wrong prompted password succeeds under trust but must fail under password authentication while the correct-password TLS control succeeds.", "components": ["docs"], "sources": ["docs:sbb484e8233fe", "docs:s7a7f6fb3b9ef", "docs:sf8b93623c032"], "status": "REASONED", "verify": [1]}, "verify-scram": {"text": "libpq 16+ require_auth=scram-sha-256 rejects a non-SCRAM exchange; success proves negotiation, not removal of all md5 rules.", "components": ["pg16", "docs"], "sources": ["pg16:s2bdad423cced", "docs:sd389a3478bc7"], "status": "REASONED", "verify": [1]}, "verify-plaintext": {"text": "Plaintext succeeds under a permitting host rule; fixed HBA refuses no-encryption access while the same TLS login succeeds.", "components": ["docs"], "sources": ["docs:sbb484e8233fe", "docs:sd389a3478bc7"], "status": "REASONED", "verify": [1]}, "verify-identity": {"text": "A trusted wrong-host certificate can pass require but must fail verify-full; restore the matching certificate as the positive control.", "components": ["docs"], "sources": ["docs:sb30fe96b6195"], "status": "REASONED", "verify": [1]}, "verify-clientcert": {"text": "Compare matching and mismatched trusted client identities: verify-ca accepts both, verify-full rejects the mismatch while correct identity/password succeeds.", "components": ["docs"], "sources": ["docs:sbb484e8233fe", "docs:sb30fe96b6195"], "status": "REASONED", "verify": [1]}, "verify-listeners": {"text": "Inventory all 5432 listeners including IPv6; wildcard becomes intended addresses after restart, with a successful intended-interface TLS control.", "components": ["docs"], "sources": ["docs:s4feea1443725", "docs:s1b4e67e38127"], "status": "REASONED", "verify": [2]}, "verify-ownership": {"text": "Real app login reads existing fixtures but cannot ALTER or SET ROLE owner; owner alteration succeeds as the matched control.", "components": ["docs"], "sources": ["docs:s20f27b82b893", "docs:s4ded00efc4e2", "docs:s04e81888353f"], "status": "REASONED"}, "verify-admission": {"text": "PUBLIC CONNECT permits a nonmember; revocation must produce database permission denial while app connects, not an HBA/password error.", "components": ["docs"], "sources": ["docs:s20f27b82b893"], "status": "REASONED"}, "verify-creation": {"text": "PUBLIC CREATE and TEMPORARY probes must change from success to privilege denial, retaining administrator and application positive controls.", "components": ["docs"], "sources": ["docs:sdd8b8cfb0e56", "docs:s20f27b82b893", "docs:s7bb6f68ef52e"], "status": "REASONED"}, "verify-path": {"text": "Trusted/shadow fixtures independently test login and function-local paths; omitted pg_temp permits shadow relations and restoring it returns trusted.", "components": ["docs"], "sources": ["docs:s1844713022bf", "docs:sec39c317b06f"], "status": "REASONED"}, "verify-execute": {"text": "With admission and schema lookup held constant, PUBLIC EXECUTE removal denies the nonmember while the application function call succeeds.", "components": ["docs"], "sources": ["docs:s65aff299a5a6"], "status": "REASONED"}, "verify-logs": {"text": "Successful connection, disconnection and DDL produce correlated records only when enabled; SHOW and extension installation do not prove delivery.", "components": ["docs"], "sources": ["docs:sf1b3da55ebd5"], "status": "REASONED"}, "verify-audit": {"text": "Compare none versus enabled audit classes in fresh app sessions; the same seeded read succeeds and only the enabled state produces its READ event.", "components": ["audit"], "sources": ["audit:s1825bbe4f88c"], "status": "REASONED"}, "diagnostics": {"text": "pg_stat_ssl describes live sessions; verifier format, HBA/current-file views and superseded settings do not prove negotiated auth or accepted TLS reload.", "components": ["docs"], "sources": ["docs:seed379369be7", "docs:s8754b96a6114", "docs:s3b6607ed7b1a", "docs:s2ab6c4aebc59"], "status": "REASONED"}, "local-guards": {"text": "Historical shell and argument checks exercised local guards only, without service contact. They covered an earlier form of both blocks, whose refusals exited with status 1, and their guard and argument cases were not repeated; the current form is covered by gate-suite bash -n and ShellCheck 0.11.0 passes and by listener-block stub runs with ss passing, failing and missing.", "components": ["docs"], "sources": ["docs:sf8b93623c032", "docs:s1b4e67e38127"], "status": "DEMONSTRATED", "evidence": "Unsubstituted blocks exited locally, and 22 guard/argument cases passed using explicit local command spies without contacting a service."}, "local-parsing": {"text": "Historical PostgreSQL 18.6 parsed 14 configuration values and rejected an invalid Boolean; no certificates, pgAudit, running HBA or SQL were exercised.", "components": ["docs"], "sources": ["docs:s55cbaf1aa910"], "status": "DEMONSTRATED", "evidence": "PostgreSQL 18.6 parsed and returned the 14 configuration values shown in the TLS, logging, and preload blocks using anonymous memory; an invalid Boolean control was rejected."}, "verify-default-grants": {"text": "Owner-created future tables must be readable with default grants and denied without them while the existing-table control succeeds; recreate fixtures per state.", "components": ["docs"], "sources": ["docs:s04e81888353f"], "status": "REASONED"} } } ```` ====================================================================== ==> connection-poolers.md ====================================================================== # Connection poolers: PgBouncer and pgpool-II PgBouncer and pgpool-II are connection poolers: they hold a few PostgreSQL connections and share them among many clients. This guide helps your AI assistant configure them securely, including a narrow bind, client TLS, a verified database certificate with PgBouncer (pgpool-II needs a separately secured hop), and a restricted admin console. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/connection-poolers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against PgBouncer listener source pgbouncer_1_26_0, PgBouncer source and fixes 1.25.2, PgBouncer LDAP minimum 1.25.0, PgBouncer password-expiry fix 1.24.1, and Pgpool-II backend handshake 4.7.2; 3 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 53 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A connection pooler sits in front of PostgreSQL and becomes the thing clients actually connect to. The pooler opens its own connections to the database and reuses them, so every control you configured on the database server ([postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md)) now governs the pooler's connection rather than the client's, and the pooler's own defaults decide what happens on both hops. Two of those defaults fail open: PgBouncer disables client TLS entirely, and its server-side `prefer` mode drops to plain TCP without an error when TLS is refused. Default posture: bind the pooler to the interface the application uses and nothing wider, require TLS on the client hop, require a verified server certificate on the database hop, and leave the client's PostgreSQL role intact through the pooler. Run a current PgBouncer. 1.25.2 fixes four advisories, and they need different things of an attacker. CVE-2026-6664 is the one an unauthenticated remote client can reach on its own: an integer overflow in packet parsing that crashes the process from a malformed SCRAM packet. CVE-2026-6665 needs a malicious PostgreSQL server, and CVE-2026-6666 a server that returns an error response with no SQLSTATE, so both are about what the pooler trusts on the database side rather than the client side. CVE-2026-6667 needs console access, which the vendor notes "itself requires authorization", and it let any console user run `KILL_CLIENT`. That last one matters to section 6 below, which treats `stats_users` as read-only; on an older build it is not. ## 1. What the pooler binds, and on which port A comment in `pgbouncer.ini` must start its own line. The vendor is explicit: "The characters “;” and “#” are not recognized as special when they appear later in the line." A trailing comment therefore becomes part of the value and the setting is not what it looks like. ```ini ; /etc/pgbouncer/pgbouncer.ini [pgbouncer] ; The application-facing address. Never * unless this is genuinely public. ; Unset is the default (as of 1.26.0), and it means Unix socket connections ; only, unless a build with systemd support is started by socket activation: ; then the .socket unit's ListenStream= lines decide, and listen_addr is ignored. listen_addr = 10.0.0.5 listen_port = 6432 unix_socket_dir = /var/run/postgresql ``` Pgpool-II binds `localhost` by default on `port` 9999, and runs a separate control listener whose address is its own setting. Widening `listen_addresses` does not move PCP, and narrowing `listen_addresses` does not confine it either: `pcp_listen_addresses` is independent and also defaults to `localhost`. Both are set at server start only. ```ini # /etc/pgpool-II/pgpool.conf listen_addresses = '10.0.0.5' # default is 'localhost' port = 9999 pcp_listen_addresses = 'localhost' # independent of the line above; keep it here pcp_port = 9898 ``` PCP is an administration channel with its own credential file, `pcp.conf`, so treat 9898 the way you would treat an admin UI rather than a database port. Create a dedicated PCP administrator in it as `username:MD5-password-digest` (generate the digest with `pg_md5 -p`, which prompts rather than taking the password as an argument); use a unique password, restrict the file to the service account, and keep it out of source control and backups. PCP credentials are separate from database credentials, and `pcp.conf` is not interchangeable with `pool_passwd`. ## 2. Client TLS is off by default, and the database server's TLS does not cover it PgBouncer's documentation is explicit: for `client_tls_sslmode`, "TLS connections are disabled by default." A database that requires `hostssl` for every remote client gains nothing from that requirement once a pooler is in front of it, because the only remote connection PostgreSQL sees is the pooler's. The clients are on a different, plaintext hop. ```ini [pgbouncer] ; The default is disable. Over TCP, `require` makes PgBouncer refuse a client ; that will not negotiate TLS; connections over the Unix socket are exempt. client_tls_sslmode = require client_tls_key_file = /etc/pgbouncer/tls/pooler.key client_tls_cert_file = /etc/pgbouncer/tls/pooler.crt ; client_tls_protocols defaults to `secure`, which is TLSv1.2 and TLSv1.3. Leave it. ``` To require client certificates as well, set `client_tls_sslmode = verify-full` and point `client_tls_ca_file` at the CA that issued them. Give the key file mode `600` and the pooler user's ownership, as with any private key ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md), [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md)). Pgpool-II uses one switch for both hops: `ssl` is "off" by default, and setting it on "enables the SSL for both the frontend and backend communications". Its documentation notes that `ssl_key` and `ssl_cert` must also be configured for frontend connections to work. Turning it on makes TLS available and does not require it. A client that declines TLS still connects, because a `pool_hba.conf` `host` record "match[es] either SSL or non-SSL connection attempts". Requiring it means `hostssl` records, and those have their own trap in the other direction: "SSL must be enabled by setting the `ssl` configuration parameter. Otherwise, the `hostssl` record is ignored." A rule you wrote to require TLS is then simply not the rule being applied, and whichever other record matches decides instead. Section 5 has the file. ```ini ssl = on # default is off; covers frontend and backend ssl_key = '/etc/pgpool-II/tls/pooler.key' ssl_cert = '/etc/pgpool-II/tls/pooler.crt' ``` ## 3. The database hop, where `prefer` fails open `server_tls_sslmode` decides what the pooler does on its way to PostgreSQL, and its default mode is `prefer`, which the documentation describes as: "TLS connection is always requested first from PostgreSQL. If refused, the connection will be established over plain TCP. Server certificate is not validated." Two separate failures sit in that sentence. A server that stops offering TLS gets plain TCP instead of an error, and the certificate is not checked even when TLS succeeds. Raising the mode only fixes the first: `require` still says "Server certificate is not validated", and `verify-ca` still says "Server host name is not checked against certificate". Only `verify-full` requires both a valid certificate and a matching host name, which is what `sslmode=verify-full` bought you when the client spoke to PostgreSQL directly. ```ini [pgbouncer] ; The default is prefer, which falls back to plain TCP and validates nothing. ; require and verify-ca each leave one half of that open. server_tls_sslmode = verify-full server_tls_ca_file = /etc/ssl/certs/postgres-ca.crt ``` Pgpool-II's `ssl_ca_cert` and `ssl_ca_cert_dir` are documented as CA files "which can be used to verify the backend server certificates", and with neither set there is nothing to verify against, so set one: ```ini ssl_ca_cert = '/etc/ssl/certs/postgres-ca.crt' ``` The documentation describes CA verification and does not document a host name check on the backend connection, so do not assume the two products' strictest modes are equivalent: where a host name match matters, PgBouncer's `verify-full` is the one that states it. One more thing is not established for pgpool-II, and this guide will not claim it. A CA file decides what happens when TLS is negotiated; it does not decide what happens when the backend declines TLS altogether. The pgpool-II project documents no setting equivalent to PgBouncer's `server_tls_sslmode = require`, and in 4.7.2 `pool_ssl_negotiate_clientserver()` continues without TLS when the backend answers that it will not, even with `ssl_ca_cert` set (the CA governs verification when TLS is negotiated, not whether TLS is negotiated). So this configuration does not meet this guide's requirement for mandatory, host-name-verified upstream TLS, and a backend `pg_hba.conf` rejection of plaintext does not prove pgpool-II refused the downgrade, because a hostile or misconfigured backend need not enforce PostgreSQL's intended policy. Use a separately authenticated transport that enforces TLS on that hop, or the PgBouncer configuration above; if you keep pgpool-II here, test the hop rather than assume it, and record plaintext fallback as a limitation rather than a pass. ## 4. A forced `user=` collapses every client into one PostgreSQL role In the `[databases]` section, the `user` key changes who the pooler is on the database server: "If `user=` is set, all connections to the destination database will be done with the specified user, meaning that there will be only one pool for this database. Otherwise, PgBouncer logs into the destination database with the client user name, meaning that there will be one pool per user." That single key undoes per-role access control. Every `GRANT` you wrote, every row-level policy keyed on `current_user`, and every per-user line in `pg_hba.conf` stops discriminating between clients, because there is only one role left. A forced user is a legitimate choice for a single-tenant service with one application account; it is a silent privilege merge anywhere else. ```ini [databases] ; One pool per user, each client keeping its own role. Note the absent user=. app = host=db.internal port=5432 dbname=app ; Compare. Every client of this entry becomes svc on the server, whatever they ; authenticated as at the pooler: ; reports = host=db.internal port=5432 dbname=app user=svc ``` `SHOW DATABASES` reports this directly in its `force_user` column, which is what section 7 checks rather than counting pools. ## 5. `pg_hba.conf` now matches the pooler, so the client rules move into the pooler PgBouncer opens its own server connections and reuses them across clients, so a server connection is not the client's connection and outlives it. PostgreSQL matches an address-based `pg_hba.conf` record against "the client machine address(es) that this record matches", and the address it sees is the source address of the pooler's backend connection, after any NAT on the way. That need not equal the pooler's listening address, so read it from the database rather than assuming it: ```sql SELECT inet_client_addr(); -- run through the pooler, on the database server ``` Over a Unix socket the function returns NULL and the `local` rules apply instead. A line such as `hostssl app app 10.0.0.0/24 scram-sha-256`, which used to restrict a range of clients, now matches whatever the pooler's backend connections come from. Narrowing it to that address is right, and it is worth being exact about what it buys: an address is not an identity. A `/24` still admits its whole range, and even a `/32` can be several workloads sharing one NAT egress, so the rule says where a connection came from and never which process opened it. Installing a pooler also does not stop anything else in the old range connecting directly. Narrow the database server's rules to the address you actually read, put the client-facing restriction where the clients now arrive, and treat the two as separate jobs rather than one moved. On the database server: ``` # TYPE DATABASE USER ADDRESS METHOD hostssl app all 10.0.0.5/32 scram-sha-256 # the address the pooler's backend connections come from ``` At the pooler, `auth_type = hba` reads a `pg_hba`-style file so the same per-path distinctions apply to client connections. The documentation gives exactly this use: "This allows different authentication methods for different access paths, for example: connections over Unix socket use the peer authentication method, connections over TCP must use TLS." ```ini [pgbouncer] auth_type = hba auth_hba_file = /etc/pgbouncer/pg_hba.conf auth_file = /etc/pgbouncer/userlist.txt ``` ``` # /etc/pgbouncer/pg_hba.conf local all all peer hostssl app app 10.0.0.0/24 scram-sha-256 ``` Pgpool-II has the same file and does not read it by default: `enable_pool_hba` is documented as "Default is false", and with it off "the client authentication method is completely managed by PostgreSQL". That does not merge your clients into one role the way a forced `user=` does, because pgpool-II pools per user and database and the backend still authenticates each one. What it means is narrower and still worth fixing: pgpool-II applies no client-facing policy of its own, so it will not refuse a plaintext connection or a client from an address you never meant to serve. ```ini enable_pool_hba = on # default is false pool_passwd = 'pool_passwd' # a path, relative to the directory holding this file ``` Setting that path does not create the entries. For `scram-sha-256` the vendor's steps are to "Create pool_passwd file entry for database user and password in plain text or AES encrypted format", where `pg_enc` writes the AES form, and it warns that "User name and password must be identical to those registered in the PostgreSQL server". An `md5`-format entry cannot serve SCRAM. AES also needs an OpenSSL-enabled build and a decryption key, `.pgpoolkey`, which pgpool-II requires at mode `600` and owned by the service user; generate the encrypted entries with `pg_enc -m -f /etc/pgpool-II/pgpool.conf -u app -p` run as the service identity, which prompts for the password rather than taking it as an argument. Restrict `pool_passwd` to the service account and keep both files and any backups out of the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Reload after changing either side. ``` # /etc/pgpool-II/pool_hba.conf # `host` matches SSL and non-SSL alike, so requiring TLS means hostssl rather than host. # The two reject lines are not redundant with the default: an unmatched connection is denied # anyway, but a later permissive rule would match first, and these stop that silently. local all all scram-sha-256 hostssl all app 10.0.0.0/24 scram-sha-256 hostnossl all all 0.0.0.0/0 reject hostnossl all all ::/0 reject ``` Three things about that file are easy to get wrong. `local ... trust` is the tempting first line and it is a hole: the vendor says `trust` admits a client under "whatever database user name they specify", and the Unix socket lives in `unix_socket_directories`, which defaults to `/tmp`, so any local account can claim any database identity. Authenticate the local path too, or move the socket somewhere only the application user can reach. The setting is `unix_socket_directories`, which takes a comma-separated list, so restricting it means covering every directory in that list rather than one. The `0.0.0.0/0` rejection covers IPv4 only, which is why the second one is there. And records are read in order, so a permissive rule above these wins; the rejections protect against what comes after them, not before. A `hostssl` record is ignored entirely while `ssl` is off. That does not by itself open the plaintext path, because "if no record matches, access is denied"; what it means is that the rule you wrote to require TLS is not the rule being applied, so whichever other record does match is deciding instead. Set `ssl = on` first and confirm it took effect. Because the server no longer sees the client, its logs no longer identify one either. `application_name_add_host` adds "the client host address and port to the application name setting set on connection start", and its default is `0`. It is diagnostic metadata rather than an audit trail: the same page says the value applies "only at the start of a connection" and that after a later `SET application_name` "PgBouncer does not change it again", so a client can overwrite it. PostgreSQL's `log_line_prefix` defaults to `'%m [%p] '`, which carries no application name at all, so turning the setting on changes nothing in the log until `%a` is in the prefix. ```ini ; The default is 0. application_name_add_host = 1 ``` ``` # postgresql.conf, on the database server log_line_prefix = '%m [%p] %a ' ``` ## 6. The admin console, and the authentication methods that are not authentication PgBouncer reserves one database name for its control channel: "The database name 'pgbouncer' is reserved for the admin console and cannot be used as a key here." Access is governed by `admin_users` for full commands and `stats_users` for read-only `SHOW` commands, both of which default to empty. `auth_type = any` removes that gate, and the vendor's two pages disagree about how far. The configuration page says `admin_users` is "Ignored when `auth_type` is `any`, in which case any user name is allowed in as admin". The usage page says "Only users listed in the configuration parameters `admin_users` or `stats_users` are allowed to log in to the console. (Except when `auth_type=any`, then any user is allowed in as a `stats_user`.)" The released 1.25.2 source agrees with the usage page: admin status is granted to names matching `admin_users`, and an `any` connection that matches neither list is admitted without it. Either way `any` is unsafe, because an unauthenticated stranger gets console read access and can also simply claim a listed administrator's name, and this guide states the disagreement rather than picking the reading that suits it. The practical consequence matters for section 7: under `any`, a command with no administrator gate of its own, such as `SHOW VERSION`, SUCCEEDS for a user on neither list. A probe that expects a rejection has to tell one at login from one at the command, because only the first means the console is closed. The other weak values are described plainly by the vendor: `trust` is "No authentication is done", `any` is "Like the trust method, but the user name given is ignored", and `plain` sends "The clear-text password ... over the wire" and is marked deprecated. The default is `md5`. Keep `auth_type = hba` from section 5. Setting `auth_type = scram-sha-256` here instead would be a downgrade rather than an upgrade, because `auth_hba_file` is only consulted under `hba`, so the client-range restriction you wrote in section 5 would stop applying. Put the method in the HBA file: ``` # /etc/pgbouncer/pg_hba.conf hostssl pgbouncer pgbadmin 10.0.0.0/24 scram-sha-256 hostssl pgbouncer pgbmetrics 10.0.0.0/24 scram-sha-256 hostssl app app 10.0.0.0/24 scram-sha-256 local all all peer ``` ```ini [pgbouncer] admin_users = pgbadmin stats_users = pgbmetrics ``` Provision `pgbmetrics` with a SCRAM-compatible credential in `auth_file`, or through the configured `auth_user` lookup; its TCP connection must pass both the HBA rule above and the console allowlist. One account bypasses all of this by design, and it is worth knowing about: the documentation states that "the user name `pgbouncer` is allowed to log in without password, if the login comes via the Unix socket and the client has same Unix user UID as the running process". Anyone who can run a process as the pooler's Unix user already has the console. The `local ... peer` line above has the same shape and the same requirement. `peer` takes the identity from the operating system, so `user=pgbadmin` in a connection string does not supply it: the command has to run as an OS user that maps to `pgbadmin`, or it is rejected whatever password it carries. `auth_file` "may contain both MD5-encrypted and plain-text passwords", so it is a secret file regardless of what you put in it: mode `600`, owned by the pooler's user, and out of the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Choosing `scram-sha-256` in the HBA file constrains what has to be in there: the documented format is `"username" "password"` where the second field is "either a plain-text, a MD5-hashed password, or a SCRAM secret", and a SCRAM secret is `SCRAM-SHA-256$:$:`. Copying a stored secret across means copying it exactly, salt and iteration count included, or the two sides do not agree. Stored-SCRAM backend login has further prerequisites: SCRAM on the client connection, no forced `[databases] user=`, and identical secrets on both sides; local `peer` authentication does not supply the SCRAM exchange this forwarding needs, and an MD5 entry cannot satisfy a client speaking SCRAM. `auth_query` avoids the file for user passwords by reading them from the database, and the documentation warns that "Direct access to `pg_authid` requires admin rights. It's preferable to use a non-superuser that calls a SECURITY DEFINER function instead." Set `auth_user` to activate the lookup for users absent from `auth_file` (existing `auth_file` entries take precedence). Give the definer function a trusted `search_path`, revoke its execution from `PUBLIC` and grant it only to the lookup role, and install it in each queried database or set `auth_dbname`. Keep the password-expiry check: CVE-2025-2291, fixed in 1.24.1, allowed expired passwords because PgBouncer's `auth_query` did not honour a role's `VALID UNTIL`. The default query was corrected; a custom query must also check `rolvaliduntil`. ## 7. Verify (REASONED: expected pooler outcomes follow the cited documentation and pinned sources; no running pooler/PostgreSQL deployment is available in the authoring environment) ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null 2>&1 || { echo 'ss is not installed here; not listing sockets'; exit 2; } set -- "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the listener table, then ss's exit status set -- "${1%$'\n'*}" "${1##*$'\n'}" printf '%s' "$1" # read every listener; 6432/9999/9898 [ "$2" = 0 ] || { echo "ss -tlnp failed; inconclusive"; exit 3; } if printf '%s' "$1" | awk 'NR > 1 && $4 ~ /:9898$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 9898, the PCP control port, is bound to a non-loopback address"; exit 1 else case "$?" in 1) echo "no port 9898 listener outside loopback; unclassified observation: read the 6432 and 9999 listeners and every other listener above"; exit 0 ;; *) echo "awk could not read the listener list; inconclusive"; exit 3 ;; esac fi ) ``` Read that as an inventory rather than a pass. The application ports (`6432`, `9999`) must each be on `127.0.0.1` or a private address; the PCP control port (`9898`) must be loopback-only, IPv6 included, because section 1 keeps `pcp_listen_addresses` on `localhost`. An entry showing `0.0.0.0:9898`, `[::]:9898`, or `9898` on a private address is section 1 not applied, whatever `listen_addresses` says, and any listener you cannot account for is its own finding. The inventory runs on the pooler host, and with no running pooler in the authoring environment its expected listeners rest on section 1's settings and the cited PgBouncer configuration and Pgpool-II connection settings entries. The block refuses with status 2, printing `ss is not installed here; not listing sockets`, when `ss` is missing, exits 3 when `ss` or the awk read fails, 1 for a port 9898 listener outside loopback and otherwise 0 with an unclassified-observation line. It is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs with `ss` replaced by a stub, in which a missing `ss` exited 2, a failing `ss` 3, a `0.0.0.0:9898` listener 1 and loopback-only listeners 0. The client hop takes three commands, not one. A single failing connection proves nothing, because a wrong password fails the same way as a refused plaintext connection; and a passing pair proves less than it looks, because a `trust` method in the HBA file lets any password through. Put the real password in `~/.pgpass` at mode `600` rather than the environment, which PostgreSQL says is "not recommended for security reasons" because some systems expose a process's environment to other users. ```bash # ~/.pgpass, mode 600: hostname:port:database:username:password # pooler.internal:6432:app:app:REPLACE_WITH_THE_APP_PASSWORD # # Self-contained: the TLS requirement is written literally in each connection string (no shared variable a # later block could inherit empty), and the target host is a guarded positional parameter. Paste the block. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_POOLER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the pooler host; not probing'; exit 2 ;; esac { unset -n PGPASSWORD && unset -v PGPASSWORD; } 2>/dev/null || { echo 'cannot clear PGPASSWORD in this shell; not probing'; exit 2; } set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # 1. Must FAIL, and the error must name TLS rather than authentication. set -- "$1" "$2" "$(if psql -X "host=$1 port=6432 dbname=app user=app sslmode=disable" -c 'SELECT 1;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the host, the status so far, psql's output and errors, then its exit status printf '%s' "$3" case "$4:$3" in 0:*) echo "FINDING: the plaintext connection succeeded"; set -- "$1" 1 ;; *SSL*|*TLS*|*encryption*) echo "psql exit $4: the plaintext connection failed; read its error above, which must name TLS rather than authentication" ;; *) echo "psql exit $4: the plaintext connection failed without naming SSL, TLS or encryption, so it is not the expected refusal; inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" # the host and the status so far # 2. Must SUCCEED. The control: the credential is good, so the failure above was the TLS requirement, # not a bad password. if psql -X "host=$1 port=6432 dbname=app user=app sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c 'SELECT 1;'; then :; else echo "psql exit $?: the verified TLS control failed, so the failures in this block prove nothing; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 fi # 3. Must FAIL on authentication. Without this, a `trust` method in auth_hba_file passes both above while # checking no password at all. The wrong password is a literal here, not a real secret. set -- "$1" "$2" "$(if PGPASSWORD=definitely-not-the-password psql -X "host=$1 port=6432 dbname=app user=app sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c 'SELECT 1;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the host, the status so far, psql's output and errors, then its exit status printf '%s' "$3" case "$4:$(printf '%s' "$3" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')" in # letter case ignored 0:*) echo "FINDING: the wrong password was accepted"; set -- "$1" 1 ;; *authentication*) echo "psql exit $4: the wrong password was refused; read its error above, which must name authentication" ;; *) echo "psql exit $4: the wrong-password connection failed without naming authentication, so it is not the expected refusal; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the error texts of the first and third connections are checked only for a TLS or authentication word, so judge them yourself" exit "$2" ) ``` Run the same three against pgpool-II on port 9999 where that is the pooler in front, because nothing above tests its listener. pgpool-II also needs its own effective-policy and admin-channel checks, because the client probes exercise one path and the console inspection below is PgBouncer-specific: ```bash # The pgpool-II policy check follows the cited pgpool-II documentation; # no running pgpool-II deployment is available in the authoring environment. # Effective pgpool-II policy (through the pooler on 9999): ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_POOLER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the pooler host; not probing'; exit 2 ;; esac set -- 0 "$(if psql -X -A -t "host=$1 port=9999 dbname=app user=app sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" \ -c 'PGPOOL SHOW enable_pool_hba;' -c 'PGPOOL SHOW ssl;'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far, then the two values set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, enable_pool_hba then ssl, then psql's exit status printf '%s' "$2" case "$3:$2" in 0:"on"$'\n'"on"$'\n') echo "enable_pool_hba and ssl both read on" ;; 0:*off*) echo "FINDING: enable_pool_hba or ssl reads off, so the rules in pool_hba.conf are not the policy being applied"; set -- 1 "$2" "$3" ;; 0:*) echo "psql did not print on or off for both settings; inconclusive"; set -- 3 "$2" "$3" ;; *) echo "psql exit $3: PGPOOL SHOW failed; inconclusive"; set -- 3 "$2" "$3" ;; esac if grep -vE '^\s*(#|$)' /etc/pgpool-II/pool_hba.conf; then :; else # active rules, in order; first match wins case "$?" in 1) echo "no active rules in /etc/pgpool-II/pool_hba.conf" ;; *) echo "grep could not read /etc/pgpool-II/pool_hba.conf; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi echo "block exit $1; the pool_hba.conf rules are not classified, so judge them yourself" exit "$1" ) ``` `enable_pool_hba` must read `on` and `ssl` must read `on`, or the rules in `pool_hba.conf` are not the policy being applied; inspect the reload logs for parse or file-access errors, confirm remote sources cannot reach 9898 at all, and test the Unix-socket route with valid and invalid credentials. Restart pgpool-II after changing any setting documented as startup-only. ```bash # The PCP admin-channel checks follow the cited pgpool-II documentation; # no running pgpool-II/PCP deployment is available in the authoring environment. # Correct credentials must succeed; a wrong password must be refused on # authentication, and any other error leaves verification incomplete. # PCP admin channel on loopback: an authenticated read must succeed, a wrong password must be refused. ( # a subshell, so the exit below ends only the subshell set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 if pcp_node_count -h 127.0.0.1 -p 9898 -U pcpadmin -W; then :; else # enter the correct PCP password: succeeds echo "pcp_node_count exit $?: the correct-password control failed; inconclusive"; set -- 3 fi set -- "$1" "$(if pcp_node_count -h 127.0.0.1 -p 9898 -U pcpadmin -W 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # enter a wrong password: must be refused on authentication set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status so far, the command's output and errors, then its exit status printf '%s' "$2" case "$3:$(printf '%s' "$2" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')" in # letter case ignored 0:*) echo "FINDING: the wrong PCP password was accepted"; set -- 1 ;; *authentication*) echo "pcp_node_count exit $3: the wrong password was refused; read its error above, which must name authentication" ;; *) echo "pcp_node_count exit $3: the wrong-password command failed without naming authentication, so it is not the expected refusal; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac echo "block exit $1; the second command's error text is checked only for the word authentication, so judge it yourself" exit "$1" ) ``` Three commands from one address prove one path. They say nothing about a second `hostssl` line further down the file that ends in `trust`, and nothing at all if `auth_type` is not `hba`, because `auth_hba_file` is then never read and the client-range restriction you wrote is inert while still sitting in the repository looking applied. So read the effective configuration as well as probing it: ```bash # A failed RELOAD must stop the block, not be masked by a following grep's exit status. ( set -- 0 "$(if psql -X -w -v ON_ERROR_STOP=1 \ "host=/var/run/postgresql port=6432 dbname=pgbouncer user=pgbadmin" \ -c 'RELOAD;' -c 'SHOW CONFIG;'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far, then psql's output set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, psql's output, then its exit status printf '%s' "$2" [ "$3" = 0 ] || { echo "psql exit $3: RELOAD or SHOW CONFIG failed, so the HBA file is not read; inconclusive"; exit 3; } # Inspect auth_type, auth_hba_file, client_tls_sslmode and server_tls_sslmode above. set -- "$1" "$2" "$(if printf '%s' "$2" | awk -F'|' '{ k = $1; v = $2; gsub(/^ +| +$/, "", k); gsub(/^ +| +$/, "", v) } k == "auth_type" { print v; n++ } END { exit n != 1 }'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%%$'\n'*}" "${3##*$'\n'}" # status, psql's output, the auth_type value, then awk's exit status case "$4:$3" in 0:hba) echo "auth_type reads hba" ;; 0:*) echo "FINDING: auth_type reads $3, so auth_hba_file is not consulted"; set -- 1 "$2" "$3" "$4" ;; *) echo "SHOW CONFIG printed no single auth_type row; inconclusive"; set -- 3 "$2" "$3" "$4" ;; esac # Then read the effective HBA file (only reached if the reload succeeded): if grep -vE '^\s*(#|$)' /etc/pgbouncer/pg_hba.conf; then :; else case "$?" in 1) echo "no active rules in /etc/pgbouncer/pg_hba.conf" ;; *) echo "grep could not read /etc/pgbouncer/pg_hba.conf; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; esac fi echo "block exit $1; auth_hba_file, the two TLS modes and the HBA rules are not classified, so judge them yourself" exit "$1" ) ``` `auth_type` must read `hba` for the file below it to matter. Read the rules in order: the first match wins, so a `trust` line decides for every path that reaches it before something stricter does, while one sitting below a rejection that already covers the same path is unreachable. Judge each line by what reaches it, not by its presence. The `RELOAD` is not decoration. `SHOW CONFIG` reports the running settings and the file read reports the disk, and those are two different things: PgBouncer evaluates the HBA rules it parsed at load, so an edited file that has not been reloaded leaves the old rules deciding while the clean file sits on disk looking correct. A probe run against that state passes and certifies nothing. Reload first, or treat the file read as evidence about the next restart rather than about now. A successful reload is necessary but not sufficient: PgBouncer logs and skips a malformed HBA line and does not fail the reload for it, so an intended restrictive rule can vanish while a later permissive one still applies. Inspect the reload logs for HBA parsing and file-access errors, confirm the effective HBA pathname, and re-run the positive and negative connection tests after reloading; any parser warning leaves verification incomplete. For the database hop, know what the probe can and cannot tell you: ```bash ( # a subshell, so the exits below end only the subshell set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_POOLER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the pooler host; not probing'; exit 2 ;; esac if psql -X "host=$1 port=6432 dbname=app user=app sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c "SELECT current_user, inet_client_addr(), ssl FROM pg_stat_ssl JOIN pg_stat_activity USING (pid) WHERE pid = pg_backend_pid();"; then echo "unclassified observation: read current_user, inet_client_addr and ssl above; ssl does not show whether the pooler validated the certificate" else echo "psql exit $?: the query failed; inconclusive"; exit 3 fi ) ``` `ssl` reports whether the pooler-to-PostgreSQL connection uses SSL. It does not report whether the pooler validated the certificate, so a `t` here is consistent with `server_tls_sslmode = require`, which validates nothing. Certificate validation cannot be observed from the client at all: for PgBouncer configured with `server_tls_sslmode = verify-full`, test enforcement by presenting the pooler with a certificate that should fail, one signed by an untrusted CA and one valid but issued for a different host name, and confirm that it refuses both. It has to be a fresh BACKEND connection rather than a fresh client one: a new client is routinely handed a server connection that was opened earlier, under the old certificate. `inet_client_addr()` is the address section 5 tells you to write into `pg_hba.conf`. These direct-database checks are REASONED from the cited PostgreSQL HBA documentation, not demonstrated: the authoring environment lacks a running pooler/PostgreSQL deployment with distinct application and pooler source addresses. The outcomes of these direct-database checks are likewise REASONED from that documentation, not demonstrated: an exposed database admits the direct application connection while the intended HBA policy rejects it and the permitted controls succeed, and a password, certificate, DNS, or connection failure is inconclusive. Test the bypass the pooler cannot prevent. From an application source that is supposed to go through the pooler, connect to the DATABASE directly with the same credentials and require a rejection, then confirm the permitted pooler egress still connects and the application still connects through the pooler. Give the real destination as `hostaddr` while keeping the certificate host name in `host`, and guard the substituted values: ```bash # Direct-database isolation follows the cited PostgreSQL HBA documentation; # no running pooler/PostgreSQL deployment with distinct application and pooler source addresses is available. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DB_HOSTNAME' 'REPLACE_WITH_DB_IP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'exactly two values are required; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute a nonempty hostname; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*) echo 'substitute a nonempty address; not probing'; exit 2 ;; esac # From a client that must NOT bypass the pooler: a direct database connection must be REFUSED by pg_hba. # gssencmode=disable so libpq cannot prefer GSS and take an HBA rejection on that path for proof while a # hostssl path stays open. set -- "$1" "$2" "$(if psql -X "host=$1 hostaddr=$2 port=5432 dbname=app user=app gssencmode=disable sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c 'SELECT 1;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # host name, address, psql's output and errors, then its exit status printf '%s' "$3" case "$4:$3" in 0:*) echo "FINDING: the direct database connection was admitted"; exit 1 ;; *pg_hba.conf*) echo "the database refused the direct connection by its HBA rules; confirm yourself that the permitted pooler egress and the application path through the pooler still connect"; exit 0 ;; *) echo "psql exit $4 without an HBA refusal: a password, certificate, DNS or connection failure is inconclusive"; exit 3 ;; esac ) ``` These backend tests are REASONED from the cited PgBouncer and Pgpool-II TLS documentation, not demonstrated: the authoring environment lacks an isolated running pooler/PostgreSQL deployment. Force and identify a fresh backend connection before each attempt; establish a trusted-certificate control, change one condition, repeat the query above through the relevant pooler port, and restore the control after each case. PgBouncer with `server_tls_sslmode = verify-full` must reject an untrusted CA, a valid certificate for the wrong host name, and a backend that declines TLS. Pgpool-II 4.7.2 with `ssl_ca_cert` must reject an untrusted CA, but does not enforce backend host-name matching or mandatory TLS; record those two as limitations. Ask the pooler what it negotiated, and what identity it forces: ```bash ( # a subshell, so the exit below ends only the subshell if psql -X "host=/var/run/postgresql port=6432 dbname=pgbouncer user=pgbadmin" -c 'SHOW SERVERS;' -c 'SHOW DATABASES;'; then echo "unclassified observation: read the tls column of each server row and the force_user column above" else echo "psql exit $?: SHOW SERVERS or SHOW DATABASES failed; inconclusive"; exit 3 fi ) ``` The `tls` column is "A string with TLS connection information, or empty if not using TLS". An empty `tls` on a server row is a plaintext database hop happening right now, which is section 3 caught in the act; it does not by itself distinguish the `prefer` fallback from TLS being disabled outright or from a Unix-socket backend. A non-empty one is weaker still: it says this connection negotiated TLS, not that a plaintext one would have been refused. These are snapshots of established connections, and enforcement is what the configuration file says. `SHOW DATABASES` answers section 4 directly through its `force_user` column, which names the forced identity where one is set. That is a better test than counting pools, because a pool count only reflects which users have happened to connect. Finally, the console must reject a user on neither list, and it must reject it at login: ```bash ( # a subshell, so the exits below end only the subshell set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_POOLER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the pooler host; not probing'; exit 2 ;; esac set -- "$1" "$(if psql -X "host=$1 port=6432 dbname=pgbouncer user=app sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c 'SHOW VERSION;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the host, psql's output and errors, then its exit status printf '%s' "$2" case "$3:$2" in 0:*) echo "FINDING: the console admitted a user on neither list and printed a version; check auth_type first"; exit 1 ;; *'not allowed'*) echo "the console refused the unlisted user at login with not allowed; nothing here is left to judge"; exit 0 ;; *) echo "psql exit $3 without not allowed: a DNS, TLS or connection failure is inconclusive"; exit 3 ;; esac ) ``` That has to fail while connecting, with `not allowed`. `SHOW VERSION` is chosen because it has no administrator gate of its own, so it cannot fail for the wrong reason: if the connection is accepted, the command succeeds and prints a version, which is exactly what `auth_type = any` produces. Read the outcome in three ways rather than two. A refusal at login is the pass. A version printed is a failure, and `auth_type` is the first thing to check. Anything else, a DNS failure, a TLS failure, a refused connection, is inconclusive and has to be resolved before the probe means anything. None of this tests the privileged name, and testing an unlisted one only proves the list is consulted. `admin_users` is an allowlist of names and says nothing about whether those names have to prove anything, so run the pair against the console itself rather than reusing the application query: ```bash ( # a subshell, so the exit below ends only the subshell set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_POOLER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the pooler host; not probing'; exit 2 ;; esac { unset -n PGPASSWORD && unset -v PGPASSWORD; } 2>/dev/null || { echo 'cannot clear PGPASSWORD in this shell; not probing'; exit 2; } set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 if psql -X "host=$1 port=6432 dbname=pgbouncer user=pgbadmin sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c 'SHOW VERSION;'; then :; else echo "psql exit $?: the pgbadmin login with the correct password failed; inconclusive"; set -- "$1" 3 fi set -- "$1" "$2" "$(if PGPASSWORD=definitely-not-the-password psql -X "host=$1 port=6432 dbname=pgbouncer user=pgbadmin sslmode=verify-full sslrootcert=/etc/ssl/certs/ca.crt" -c 'SHOW VERSION;' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the host, the status so far, psql's output and errors, then its exit status printf '%s' "$3" case "$4:$(printf '%s' "$3" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')" in # letter case ignored 0:*) echo "FINDING: the console admitted pgbadmin with a wrong password"; set -- "$1" 1 ;; *authentication*) echo "psql exit $4: the wrong password was refused; read its error above, which must be a refusal at login" ;; *) echo "psql exit $4: the wrong-password login failed without naming authentication, so it is not the expected refusal; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the second command's error text is checked only for the word authentication, so judge it yourself" exit "$2" ) ``` The first must succeed and the second must be refused at login. Both have to name `dbname=pgbouncer`: changing only the user name on the application probe leaves it asking the `app` database for `SELECT 1`, which tests nothing about the console. The console commands over the Unix socket are a different path with a different requirement, and `peer` there takes the identity from the operating system. MFA: neither pooler adds a factor of its own, and the PostgreSQL wire protocol has no TOTP dialogue, so there is nothing here to turn on and the honest answer is that this is not where a second factor goes. The hooks that exist come with conditions worth knowing before you reach for them. PgBouncer's `auth_type` accepts `pam` and `ldap`, and `ldap` arrived in 1.25.0. Only `ldap` can be named inside `auth_hba_file`; selecting `pam` means setting it globally, which stops the HBA file being consulted and takes the client-range restriction of section 5 with it. PAM is also not an interactive one-time-password conversation here: the released implementation answers a hidden prompt with the same password the client already supplied, so it forwards a credential rather than conducting a challenge. For a possession factor, `client_tls_sslmode = verify-full` with `client_tls_ca_file` requires a client certificate while leaving `auth_type = hba` and SCRAM in place, which is usually what you want. On that client-facing setting `verify-full` and `verify-ca` are the same thing; taking the user name from the certificate is what `auth_type = cert` does, and that, like `pam`, replaces HBA selection rather than adding to it. Put the human paths to the host behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## Common mistakes - Writing a trailing `;` or `#` comment on a `pgbouncer.ini` line. The vendor does not treat either as special after the start of a line, so the comment becomes part of the value and the setting silently is not what it reads as. - Configuring TLS carefully on PostgreSQL, then putting a pooler in front of it with `client_tls_sslmode` left at its default, so every client connection is plaintext and the database's `hostssl` rules only ever match the pooler. - Leaving `server_tls_sslmode` at `prefer` and reading a successful connection as an encrypted one. It falls back to plain TCP without an error, and it validates no certificate in any case. - Treating `require` or `verify-ca` as equivalent to the client-side `sslmode=verify-full` they replaced. Neither checks the host name. - Widening the database server's `pg_hba.conf` because the old client ranges stopped matching, or assuming that narrowing it to the pooler stops anything else in the old range connecting directly. - Setting `user=` on a `[databases]` entry for convenience, which merges every client into one role and disables per-role privileges on the server. - Switching `auth_type` from `hba` to `scram-sha-256` to "tighten" it, which stops `auth_hba_file` being read and drops the client-range restriction with it. - Writing a `hostssl` rule in `pool_hba.conf` while `ssl` is still off. The record is ignored, so the rule you wrote to require TLS is not the rule being applied; whichever other record matches decides instead, and an unmatched connection is denied. - Putting a database password in `PGPASSWORD` in a shell you are pasting commands into. Use `~/.pgpass` at mode `600`. - Setting `listen_addresses` in `pgpool.conf` and expecting PCP to follow. `pcp_listen_addresses` is a separate setting, and `enable_pool_hba` is off by default, so pgpool-II authenticates nobody of its own until you turn it on. ## Sources (checked September 2026) - PgBouncer configuration, including the ini comment rule, `listen_addr`, `listen_port`, `client_tls_sslmode`, `server_tls_sslmode`, `auth_type`, `auth_file`, `auth_query`, `admin_users`, `stats_users`, `application_name_add_host`, and the `[databases]` `user` key (rolling documentation, checked September 2026): https://www.pgbouncer.org/config.html - PgBouncer `listen_addr` default `""`, and the socket-activation path that ignores it (pinned tag pgbouncer_1_26_0). PgBouncer `listen_addr` default and socket-activation path sources: https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_26_0/src/main.c#L292-L294 and https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_26_0/src/pooler.c#L495-L498. PgBouncer builds without systemd support define `sd_listen_fds()` as `(0)`. PgBouncer `sd_listen_fds()` definition source: https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_26_0/include/bouncer.h#L54-L62 - PgBouncer usage, the admin console and its `SHOW` commands, including who `auth_type=any` admits and the passwordless Unix-socket login: https://www.pgbouncer.org/usage.html - PgBouncer changelog, for CVE-2026-6664, CVE-2026-6665, CVE-2026-6666 and CVE-2026-6667, all fixed in 1.25.2; LDAP since 1.25.0; auth_query fix 1.24.1: https://www.pgbouncer.org/changelog.html - PgBouncer features and pooling modes: https://www.pgbouncer.org/features.html - Pgpool-II connection settings (`listen_addresses`, `port`, `pcp_listen_addresses`, `pcp_port`, `enable_pool_hba`): https://www.pgpool.net/docs/latest/en/html/runtime-config-connection.html - Pgpool-II SSL settings (`ssl`, `ssl_cert`, `ssl_key`, `ssl_ca_cert`, `ssl_ca_cert_dir`): https://www.pgpool.net/docs/latest/en/html/runtime-ssl.html - Pgpool-II pool_hba.conf: https://www.pgpool.net/docs/latest/en/html/auth-pool-hba-conf.html - Pgpool-II pcp.conf: https://www.pgpool.net/docs/latest/en/html/configuring-pcp-conf.html - PostgreSQL pg_hba.conf, for what an address record matches: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html - PostgreSQL connection information functions (`inet_client_addr`): https://www.postgresql.org/docs/current/functions-info.html - PostgreSQL statistics views, for what `pg_stat_ssl.ssl` does and does not report: https://www.postgresql.org/docs/current/monitoring-stats.html - PostgreSQL logging configuration (`log_line_prefix`): https://www.postgresql.org/docs/current/runtime-config-logging.html - PostgreSQL peer authentication, for why a console user name is not an OS identity: https://www.postgresql.org/docs/current/auth-peer.html - PostgreSQL environment variables, on `PGPASSWORD` being "not recommended for security reasons": https://www.postgresql.org/docs/current/libpq-envars.html - PostgreSQL password file, for the `~/.pgpass` format and its permission requirement: https://www.postgresql.org/docs/current/libpq-pgpass.html - psql, for what `-c` accepts and what `-X` suppresses: https://www.postgresql.org/docs/current/app-psql.html - PostgreSQL libpq connection parameters (`host`, `hostaddr`, `sslmode`, and GSS encryption precedence): https://www.postgresql.org/docs/current/libpq-connect.html - PgBouncer 1.25.2 console source, which is what settles the `auth_type = any` disagreement above: https://raw.githubusercontent.com/pgbouncer/pgbouncer/pgbouncer_1_25_2/src/admin.c - PgBouncer 1.25.2 PAM source, for what `pam` does with the supplied password: https://raw.githubusercontent.com/pgbouncer/pgbouncer/pgbouncer_1_25_2/src/pam.c - Pgpool-II parameter syntax, which unlike pgbouncer.ini does treat a trailing `#` as a comment: https://www.pgpool.net/docs/latest/en/html/config-setting.html - Pgpool-II authentication methods: https://www.pgpool.net/docs/latest/en/html/auth-methods.html - Pgpool-II 4.7.2 backend SSL handshake (`pool_ssl_negotiate_clientserver` continues without TLS when the backend declines): https://raw.githubusercontent.com/pgpool/pgpool2/V4_7_2/src/utils/pool_ssl.c - Pgpool-II AES-encrypted passwords and `.pgpoolkey` (`pg_enc`, required key-file permissions): https://www.pgpool.net/docs/latest/en/html/auth-aes-encrypted-password.html - Pgpool-II PCP common options (`-U`, the `-W` password prompt) used by `pcp_node_count`: https://www.pgpool.net/docs/latest/en/html/pcp-common-options.html - PgBouncer 1.25.2 HBA parser (logs and skips a malformed line without failing the reload): https://raw.githubusercontent.com/pgbouncer/pgbouncer/pgbouncer_1_25_2/src/hba.c ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - pgb-bind: PgBouncer listener source (pgbouncer_1_26_0) - pgb-source: PgBouncer source and fixes (1.25.2) - ldap-min: PgBouncer LDAP minimum (1.25.0) - expiry-fix: PgBouncer password-expiry fix (1.24.1) - pgpool-source: Pgpool-II backend handshake (4.7.2) - pgb: PgBouncer documentation (unknown) - pgpool: Pgpool-II documentation (unknown) - postgres: PostgreSQL documentation (unknown) Claims (full source mapping in the JSON block below): - packet-fix (REASONED; pgb-source): 1.25.2 fixes CVE-2026-6664: an unauthenticated malformed SCRAM packet can crash older releases. - backend-fixes (REASONED; pgb-source): 1.25.2 fixes CVE-2026-6665 from a malicious backend and CVE-2026-6666 from a backend error lacking SQLSTATE. - console-fix (REASONED; pgb-source): 1.25.2 fixes CVE-2026-6667, which allowed any authorized console user to run KILL_CLIENT. - ini-comments (REASONED; pgb): PgBouncer ini comments must start their own line; trailing # or ; becomes part of the value. - pgb-listener (REASONED; pgb, pgb-bind): Use private listen_addr and port 6432; the 1.26.0 unset default is Unix sockets only except systemd socket activation, whose ListenStream settings override listen_addr. - pgpool-listener (REASONED; pgpool): Pgpool-II defaults to localhost:9999; listen_addresses is startup-only. - pcp-listener (REASONED; pgpool): PCP independently defaults to localhost:9898 through startup-only pcp_listen_addresses and pcp_port. - pcp-credentials (REASONED; pgpool): PCP uses separate username:MD5-digest entries in protected pcp.conf; pg_md5 -p prompts, and pool_passwd is not interchangeable. - client-tls (REASONED; pgb): PgBouncer client TLS defaults disabled; require with a key and certificate rejects non-TLS TCP clients while Unix sockets are exempt. - client-protocols (REASONED; pgb): PgBouncer client_tls_protocols defaults to secure, meaning TLSv1.2 and TLSv1.3. - client-cert (REASONED; pgb): client_tls_sslmode=verify-full and client_tls_ca_file require client certificates; protect the private key at mode 600. - pgpool-tls (REASONED; pgpool): Pgpool-II ssl defaults off; enabling it covers both hops but frontend TLS also needs ssl_key and ssl_cert. - pgpool-hostssl (REASONED; pgpool): Pgpool-II host matches TLS and plaintext; hostssl requires ssl=on or is ignored, and unmatched connections are denied. - backend-tls (REASONED; pgb): PgBouncer server_tls_sslmode defaults prefer, permitting plaintext fallback without certificate checks; require omits certificate validation and verify-ca omits hostname checks. - backend-verify-full (REASONED; pgb): Set PgBouncer server_tls_sslmode=verify-full with server_tls_ca_file to require TLS, a trusted certificate and matching hostname. - pgpool-ca (REASONED; pgpool): Pgpool-II ssl_ca_cert or ssl_ca_cert_dir enables backend CA verification; the documentation establishes no hostname check. - pgpool-downgrade (REASONED; pgpool, pgpool-source): Pgpool-II 4.7.2 continues plaintext if the backend declines TLS even with ssl_ca_cert; use a separately authenticated enforcing transport or PgBouncer verify-full. - forced-user (REASONED; pgb): A databases user= forces every client into one backend role; omitting it preserves client usernames and separate pools, with force_user visible in SHOW DATABASES. - backend-source (REASONED; pgb, postgres): PostgreSQL HBA sees the pooler backend source after NAT; inet_client_addr returns that source or NULL on Unix sockets. A narrow address rule is not a process identity. - client-hba (REASONED; pgb): PgBouncer auth_type=hba activates auth_hba_file for per-path peer/SCRAM/TLS rules; choosing a global method instead disables that policy. - pgpool-hba (REASONED; pgpool): Pgpool-II enable_pool_hba defaults false, leaving authentication to PostgreSQL; enabling it adds client policy without changing per-user/database pooling. - pgpool-passwords (REASONED; pgpool): SCRAM needs matching plaintext or AES pool_passwd credentials, not MD5; pg_enc prompts, and AES requires OpenSSL plus service-owned mode-600 .pgpoolkey. - pgpool-local (REASONED; pgpool): Local trust accepts any claimed database identity; unix_socket_directories defaults /tmp and every listed socket directory needs protection. - hba-order (REASONED; pgpool, postgres): HBA first-match order governs access; explicit IPv4/IPv6 plaintext rejections stop later permissive rules, not earlier ones. - client-metadata (REASONED; pgb): application_name_add_host defaults 0; enabling it adds client address only at connection start and clients can later overwrite application_name. - log-prefix (REASONED; postgres): PostgreSQL log_line_prefix defaults to %m [%p] without application name; include %a to log the diagnostic metadata. - console-lists (REASONED; pgb): The reserved pgbouncer database uses admin_users for full commands and stats_users for read-only SHOW; both default empty. - console-any (REASONED; pgb, pgb-source): Configuration and usage docs disagree on any; 1.25.2 grants unlisted users console read access and lets clients claim an administrator name without authentication. - weak-auth (REASONED; pgb): trust and any do not authenticate, plain transmits a cleartext password and is deprecated, and auth_type defaults md5. - console-scram (REASONED; pgb): Keep hba with SCRAM rules for pgbadmin and pgbmetrics; metrics credentials must satisfy both HBA and the console allowlist. - socket-bypass (REASONED; pgb): The pgbouncer username can access the console passwordlessly over a Unix socket when the client UID equals the pooler process UID. - peer (REASONED; pgb, postgres): Peer authentication uses the OS identity; a connection-string user name or password cannot supply that identity. - auth-file (REASONED; pgb): Protect auth_file at mode 600; it can contain plaintext, MD5 or SCRAM secrets, and MD5 cannot satisfy client SCRAM. - scram-forward (REASONED; pgb): Stored-SCRAM backend login needs client SCRAM, no forced user and identical secrets including salt and iteration count; peer does not provide the required exchange. - auth-query (REASONED; pgb): auth_user enables lookup for users absent from auth_file; use a restricted SECURITY DEFINER function with trusted search_path, limited EXECUTE and database placement or auth_dbname. - password-expiry (REASONED; pgb, expiry-fix): CVE-2025-2291 was fixed in 1.24.1; custom auth_query must still check rolvaliduntil. - verify-listeners (REASONED; pgb, pgpool): Inventory 6432/9999 on private addresses and PCP 9898 on loopback including IPv6; unexpected listeners are findings. - verify-client (REASONED; pgb, pgpool, postgres): Plaintext must fail for TLS, verified TLS with the correct password must succeed, and a wrong password must fail authentication; repeat for pgpool-II 9999. - password-delivery (REASONED; postgres): Use mode-600 .pgpass for real passwords; PGPASSWORD can expose credentials through process environments. - verify-pgpool (REASONED; pgpool): PGPOOL SHOW enable_pool_hba and ssl must both report on; inspect ordered HBA, reload errors and Unix-socket positive/negative credentials. - verify-pcp (REASONED; pgpool): PCP read with correct prompted credentials must succeed and a wrong password must fail authentication; remote 9898 must be unreachable. - verify-pgb-policy (REASONED; pgb, postgres): RELOAD must succeed before SHOW CONFIG and disk-HBA inspection; confirm hba, TLS modes and pathname, then rerun connection tests. - hba-parser (REASONED; pgb-source): PgBouncer 1.25.2 logs and skips malformed HBA lines without failing RELOAD; parser or access warnings leave verification incomplete. - verify-backend-observation (REASONED; postgres): pg_stat_ssl reports negotiated encryption, not certificate enforcement; current_user and inet_client_addr identify backend role and source. - verify-direct (REASONED; postgres): Direct application-to-database access must fail HBA while permitted pooler egress and application-through-pooler controls succeed; disable GSS precedence and guard host/hostaddr. - verify-backend-enforcement (REASONED; pgb, pgpool, pgpool-source): Use fresh backend connections and restored positive controls: PgBouncer verify-full rejects untrusted CA, wrong hostname and TLS refusal; Pgpool-II only establishes CA rejection. - verify-server-tls (REASONED; pgb): SHOW SERVERS tls reports existing TLS or an empty plaintext connection, not whether plaintext would have been refused. - verify-force-user (REASONED; pgb): SHOW DATABASES force_user reveals forced identity; pool counts do not establish role preservation. - verify-console-unlisted (REASONED; pgb, pgb-source): An unlisted console user must fail at login with not allowed; SHOW VERSION success is exposure and transport/DNS/TLS failures are inconclusive. - verify-console-password (REASONED; pgb): The pgbadmin SHOW VERSION pair must succeed with the correct password and fail at login with the wrong one, both against dbname=pgbouncer. - mfa (REASONED; pgb, pgpool): Neither pooler provides native MFA or a PostgreSQL-wire TOTP dialogue; protect human host access separately. - ldap-pam (REASONED; pgb, ldap-min, pgb-source): LDAP arrived in 1.25.0 and can appear in HBA; PAM is global and disables HBA selection, forwarding the supplied password instead of conducting an OTP challenge. - mtls-hba (REASONED; pgb): Client verify-full and verify-ca are equivalent for certificates and can retain HBA/SCRAM; auth_type=cert takes the certificate username and replaces HBA selection. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "d8331d53ffa0efedcdc7f3e28b77816137fcb59d5c4a6257408e19213547adb7", "components": { "pgb-bind": { "name": "PgBouncer listener source", "basis": "pgbouncer_1_26_0", "sources": { "s3bac1c857328": "https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_26_0/src/main.c#L292-L294", "sb926a3d2cba4": "https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_26_0/src/pooler.c#L495-L498", "s6910c5b38697": "https://github.com/pgbouncer/pgbouncer/blob/pgbouncer_1_26_0/include/bouncer.h#L54-L62" } }, "pgb-source": { "name": "PgBouncer source and fixes", "basis": "1.25.2", "sources": { "s2a1788c4296b": "https://www.pgbouncer.org/changelog.html", "sd8ac14670f91": "https://raw.githubusercontent.com/pgbouncer/pgbouncer/pgbouncer_1_25_2/src/admin.c", "sb9455e4f6c90": "https://raw.githubusercontent.com/pgbouncer/pgbouncer/pgbouncer_1_25_2/src/pam.c", "sd1ad8c2587a4": "https://raw.githubusercontent.com/pgbouncer/pgbouncer/pgbouncer_1_25_2/src/hba.c" } }, "ldap-min": { "name": "PgBouncer LDAP minimum", "basis": "1.25.0", "sources": { "s2a1788c4296b": "https://www.pgbouncer.org/changelog.html" } }, "expiry-fix": { "name": "PgBouncer password-expiry fix", "basis": "1.24.1", "sources": { "s2a1788c4296b": "https://www.pgbouncer.org/changelog.html" } }, "pgpool-source": { "name": "Pgpool-II backend handshake", "basis": "4.7.2", "sources": { "saf1078d5bb5c": "https://raw.githubusercontent.com/pgpool/pgpool2/V4_7_2/src/utils/pool_ssl.c" } }, "pgb": { "name": "PgBouncer documentation", "basis": "unknown", "sources": { "sa5cdd62a9242": "https://www.pgbouncer.org/config.html", "s91d48cfb22ae": "https://www.pgbouncer.org/usage.html", "scd8f294039fa": "https://www.pgbouncer.org/features.html" } }, "pgpool": { "name": "Pgpool-II documentation", "basis": "unknown", "sources": { "s7b1cbfaacbb8": "https://www.pgpool.net/docs/latest/en/html/runtime-config-connection.html", "s76141317a1b5": "https://www.pgpool.net/docs/latest/en/html/runtime-ssl.html", "sa7204f187c7e": "https://www.pgpool.net/docs/latest/en/html/auth-pool-hba-conf.html", "sdc3d487af0ae": "https://www.pgpool.net/docs/latest/en/html/configuring-pcp-conf.html", "sd1c787b7784e": "https://www.pgpool.net/docs/latest/en/html/config-setting.html", "s7847f7feb03b": "https://www.pgpool.net/docs/latest/en/html/auth-methods.html", "s5e8db4ad8b4e": "https://www.pgpool.net/docs/latest/en/html/auth-aes-encrypted-password.html", "s13f19539cfbf": "https://www.pgpool.net/docs/latest/en/html/pcp-common-options.html" } }, "postgres": { "name": "PostgreSQL documentation", "basis": "unknown", "sources": { "sbb484e8233fe": "https://www.postgresql.org/docs/current/auth-pg-hba-conf.html", "sc39eceeac711": "https://www.postgresql.org/docs/current/functions-info.html", "seed379369be7": "https://www.postgresql.org/docs/current/monitoring-stats.html", "sf1b3da55ebd5": "https://www.postgresql.org/docs/current/runtime-config-logging.html", "s8a1c90f4aa93": "https://www.postgresql.org/docs/current/auth-peer.html", "s97264da004c0": "https://www.postgresql.org/docs/current/libpq-envars.html", "s198d4780d9a4": "https://www.postgresql.org/docs/current/libpq-pgpass.html", "sf8b93623c032": "https://www.postgresql.org/docs/current/app-psql.html", "sd389a3478bc7": "https://www.postgresql.org/docs/current/libpq-connect.html" } } }, "claims": { "packet-fix": {"text": "1.25.2 fixes CVE-2026-6664: an unauthenticated malformed SCRAM packet can crash older releases.", "components": ["pgb-source"], "sources": ["pgb-source:s2a1788c4296b"], "status": "REASONED"}, "backend-fixes": {"text": "1.25.2 fixes CVE-2026-6665 from a malicious backend and CVE-2026-6666 from a backend error lacking SQLSTATE.", "components": ["pgb-source"], "sources": ["pgb-source:s2a1788c4296b"], "status": "REASONED"}, "console-fix": {"text": "1.25.2 fixes CVE-2026-6667, which allowed any authorized console user to run KILL_CLIENT.", "components": ["pgb-source"], "sources": ["pgb-source:s2a1788c4296b"], "status": "REASONED"}, "ini-comments": {"text": "PgBouncer ini comments must start their own line; trailing # or ; becomes part of the value.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "pgb-listener": {"text": "Use private listen_addr and port 6432; the 1.26.0 unset default is Unix sockets only except systemd socket activation, whose ListenStream settings override listen_addr.", "components": ["pgb", "pgb-bind"], "sources": ["pgb:sa5cdd62a9242", "pgb-bind:s3bac1c857328", "pgb-bind:sb926a3d2cba4", "pgb-bind:s6910c5b38697"], "status": "REASONED"}, "pgpool-listener": {"text": "Pgpool-II defaults to localhost:9999; listen_addresses is startup-only.", "components": ["pgpool"], "sources": ["pgpool:s7b1cbfaacbb8"], "status": "REASONED"}, "pcp-listener": {"text": "PCP independently defaults to localhost:9898 through startup-only pcp_listen_addresses and pcp_port.", "components": ["pgpool"], "sources": ["pgpool:s7b1cbfaacbb8"], "status": "REASONED"}, "pcp-credentials": {"text": "PCP uses separate username:MD5-digest entries in protected pcp.conf; pg_md5 -p prompts, and pool_passwd is not interchangeable.", "components": ["pgpool"], "sources": ["pgpool:sdc3d487af0ae"], "status": "REASONED"}, "client-tls": {"text": "PgBouncer client TLS defaults disabled; require with a key and certificate rejects non-TLS TCP clients while Unix sockets are exempt.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "client-protocols": {"text": "PgBouncer client_tls_protocols defaults to secure, meaning TLSv1.2 and TLSv1.3.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "client-cert": {"text": "client_tls_sslmode=verify-full and client_tls_ca_file require client certificates; protect the private key at mode 600.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "pgpool-tls": {"text": "Pgpool-II ssl defaults off; enabling it covers both hops but frontend TLS also needs ssl_key and ssl_cert.", "components": ["pgpool"], "sources": ["pgpool:s76141317a1b5"], "status": "REASONED"}, "pgpool-hostssl": {"text": "Pgpool-II host matches TLS and plaintext; hostssl requires ssl=on or is ignored, and unmatched connections are denied.", "components": ["pgpool"], "sources": ["pgpool:s76141317a1b5", "pgpool:sa7204f187c7e"], "status": "REASONED"}, "backend-tls": {"text": "PgBouncer server_tls_sslmode defaults prefer, permitting plaintext fallback without certificate checks; require omits certificate validation and verify-ca omits hostname checks.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "backend-verify-full": {"text": "Set PgBouncer server_tls_sslmode=verify-full with server_tls_ca_file to require TLS, a trusted certificate and matching hostname.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "pgpool-ca": {"text": "Pgpool-II ssl_ca_cert or ssl_ca_cert_dir enables backend CA verification; the documentation establishes no hostname check.", "components": ["pgpool"], "sources": ["pgpool:s76141317a1b5"], "status": "REASONED"}, "pgpool-downgrade": {"text": "Pgpool-II 4.7.2 continues plaintext if the backend declines TLS even with ssl_ca_cert; use a separately authenticated enforcing transport or PgBouncer verify-full.", "components": ["pgpool", "pgpool-source"], "sources": ["pgpool:s76141317a1b5", "pgpool-source:saf1078d5bb5c"], "status": "REASONED"}, "forced-user": {"text": "A databases user= forces every client into one backend role; omitting it preserves client usernames and separate pools, with force_user visible in SHOW DATABASES.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242", "pgb:s91d48cfb22ae"], "status": "REASONED"}, "backend-source": {"text": "PostgreSQL HBA sees the pooler backend source after NAT; inet_client_addr returns that source or NULL on Unix sockets. A narrow address rule is not a process identity.", "components": ["pgb", "postgres"], "sources": ["pgb:scd8f294039fa", "postgres:sbb484e8233fe", "postgres:sc39eceeac711"], "status": "REASONED"}, "client-hba": {"text": "PgBouncer auth_type=hba activates auth_hba_file for per-path peer/SCRAM/TLS rules; choosing a global method instead disables that policy.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "pgpool-hba": {"text": "Pgpool-II enable_pool_hba defaults false, leaving authentication to PostgreSQL; enabling it adds client policy without changing per-user/database pooling.", "components": ["pgpool"], "sources": ["pgpool:s7b1cbfaacbb8", "pgpool:sa7204f187c7e", "pgpool:s7847f7feb03b"], "status": "REASONED"}, "pgpool-passwords": {"text": "SCRAM needs matching plaintext or AES pool_passwd credentials, not MD5; pg_enc prompts, and AES requires OpenSSL plus service-owned mode-600 .pgpoolkey.", "components": ["pgpool"], "sources": ["pgpool:s7847f7feb03b", "pgpool:s5e8db4ad8b4e"], "status": "REASONED"}, "pgpool-local": {"text": "Local trust accepts any claimed database identity; unix_socket_directories defaults /tmp and every listed socket directory needs protection.", "components": ["pgpool"], "sources": ["pgpool:s7b1cbfaacbb8", "pgpool:sa7204f187c7e"], "status": "REASONED"}, "hba-order": {"text": "HBA first-match order governs access; explicit IPv4/IPv6 plaintext rejections stop later permissive rules, not earlier ones.", "components": ["pgpool", "postgres"], "sources": ["pgpool:sa7204f187c7e", "postgres:sbb484e8233fe"], "status": "REASONED"}, "client-metadata": {"text": "application_name_add_host defaults 0; enabling it adds client address only at connection start and clients can later overwrite application_name.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "log-prefix": {"text": "PostgreSQL log_line_prefix defaults to %m [%p] without application name; include %a to log the diagnostic metadata.", "components": ["postgres"], "sources": ["postgres:sf1b3da55ebd5"], "status": "REASONED"}, "console-lists": {"text": "The reserved pgbouncer database uses admin_users for full commands and stats_users for read-only SHOW; both default empty.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242", "pgb:s91d48cfb22ae"], "status": "REASONED"}, "console-any": {"text": "Configuration and usage docs disagree on any; 1.25.2 grants unlisted users console read access and lets clients claim an administrator name without authentication.", "components": ["pgb", "pgb-source"], "sources": ["pgb:sa5cdd62a9242", "pgb:s91d48cfb22ae", "pgb-source:sd8ac14670f91"], "status": "REASONED"}, "weak-auth": {"text": "trust and any do not authenticate, plain transmits a cleartext password and is deprecated, and auth_type defaults md5.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "console-scram": {"text": "Keep hba with SCRAM rules for pgbadmin and pgbmetrics; metrics credentials must satisfy both HBA and the console allowlist.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "socket-bypass": {"text": "The pgbouncer username can access the console passwordlessly over a Unix socket when the client UID equals the pooler process UID.", "components": ["pgb"], "sources": ["pgb:s91d48cfb22ae"], "status": "REASONED"}, "peer": {"text": "Peer authentication uses the OS identity; a connection-string user name or password cannot supply that identity.", "components": ["pgb", "postgres"], "sources": ["pgb:sa5cdd62a9242", "postgres:s8a1c90f4aa93"], "status": "REASONED"}, "auth-file": {"text": "Protect auth_file at mode 600; it can contain plaintext, MD5 or SCRAM secrets, and MD5 cannot satisfy client SCRAM.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "scram-forward": {"text": "Stored-SCRAM backend login needs client SCRAM, no forced user and identical secrets including salt and iteration count; peer does not provide the required exchange.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "auth-query": {"text": "auth_user enables lookup for users absent from auth_file; use a restricted SECURITY DEFINER function with trusted search_path, limited EXECUTE and database placement or auth_dbname.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"}, "password-expiry": {"text": "CVE-2025-2291 was fixed in 1.24.1; custom auth_query must still check rolvaliduntil.", "components": ["pgb", "expiry-fix"], "sources": ["pgb:sa5cdd62a9242", "expiry-fix:s2a1788c4296b"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory 6432/9999 on private addresses and PCP 9898 on loopback including IPv6; unexpected listeners are findings.", "components": ["pgb", "pgpool"], "sources": ["pgb:sa5cdd62a9242", "pgpool:s7b1cbfaacbb8"], "status": "REASONED", "verify": [1]}, "verify-client": {"text": "Plaintext must fail for TLS, verified TLS with the correct password must succeed, and a wrong password must fail authentication; repeat for pgpool-II 9999.", "components": ["pgb", "pgpool", "postgres"], "sources": ["pgb:sa5cdd62a9242", "pgpool:s76141317a1b5", "pgpool:sa7204f187c7e", "postgres:s198d4780d9a4", "postgres:sd389a3478bc7"], "status": "REASONED", "verify": [2]}, "password-delivery": {"text": "Use mode-600 .pgpass for real passwords; PGPASSWORD can expose credentials through process environments.", "components": ["postgres"], "sources": ["postgres:s97264da004c0", "postgres:s198d4780d9a4"], "status": "REASONED"}, "verify-pgpool": {"text": "PGPOOL SHOW enable_pool_hba and ssl must both report on; inspect ordered HBA, reload errors and Unix-socket positive/negative credentials.", "components": ["pgpool"], "sources": ["pgpool:s7b1cbfaacbb8", "pgpool:s76141317a1b5", "pgpool:sa7204f187c7e", "pgpool:sd1c787b7784e"], "status": "REASONED", "verify": [3]}, "verify-pcp": {"text": "PCP read with correct prompted credentials must succeed and a wrong password must fail authentication; remote 9898 must be unreachable.", "components": ["pgpool"], "sources": ["pgpool:s7b1cbfaacbb8", "pgpool:sdc3d487af0ae", "pgpool:s13f19539cfbf"], "status": "REASONED", "verify": [4]}, "verify-pgb-policy": {"text": "RELOAD must succeed before SHOW CONFIG and disk-HBA inspection; confirm hba, TLS modes and pathname, then rerun connection tests.", "components": ["pgb", "postgres"], "sources": ["pgb:sa5cdd62a9242", "pgb:s91d48cfb22ae", "postgres:sf8b93623c032"], "status": "REASONED", "verify": [5]}, "hba-parser": {"text": "PgBouncer 1.25.2 logs and skips malformed HBA lines without failing RELOAD; parser or access warnings leave verification incomplete.", "components": ["pgb-source"], "sources": ["pgb-source:sd1ad8c2587a4"], "status": "REASONED", "verify": [5]}, "verify-backend-observation": {"text": "pg_stat_ssl reports negotiated encryption, not certificate enforcement; current_user and inet_client_addr identify backend role and source.", "components": ["postgres"], "sources": ["postgres:sc39eceeac711", "postgres:seed379369be7"], "status": "REASONED", "verify": [6]}, "verify-direct": {"text": "Direct application-to-database access must fail HBA while permitted pooler egress and application-through-pooler controls succeed; disable GSS precedence and guard host/hostaddr.", "components": ["postgres"], "sources": ["postgres:sbb484e8233fe", "postgres:sd389a3478bc7"], "status": "REASONED", "verify": [7]}, "verify-backend-enforcement": {"text": "Use fresh backend connections and restored positive controls: PgBouncer verify-full rejects untrusted CA, wrong hostname and TLS refusal; Pgpool-II only establishes CA rejection.", "components": ["pgb", "pgpool", "pgpool-source"], "sources": ["pgb:sa5cdd62a9242", "pgpool:s76141317a1b5", "pgpool-source:saf1078d5bb5c"], "status": "REASONED", "verify": [6]}, "verify-server-tls": {"text": "SHOW SERVERS tls reports existing TLS or an empty plaintext connection, not whether plaintext would have been refused.", "components": ["pgb"], "sources": ["pgb:s91d48cfb22ae"], "status": "REASONED", "verify": [8]}, "verify-force-user": {"text": "SHOW DATABASES force_user reveals forced identity; pool counts do not establish role preservation.", "components": ["pgb"], "sources": ["pgb:s91d48cfb22ae"], "status": "REASONED", "verify": [8]}, "verify-console-unlisted": {"text": "An unlisted console user must fail at login with not allowed; SHOW VERSION success is exposure and transport/DNS/TLS failures are inconclusive.", "components": ["pgb", "pgb-source"], "sources": ["pgb:s91d48cfb22ae", "pgb-source:sd8ac14670f91"], "status": "REASONED", "verify": [9]}, "verify-console-password": {"text": "The pgbadmin SHOW VERSION pair must succeed with the correct password and fail at login with the wrong one, both against dbname=pgbouncer.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242", "pgb:s91d48cfb22ae"], "status": "REASONED", "verify": [10]}, "mfa": {"text": "Neither pooler provides native MFA or a PostgreSQL-wire TOTP dialogue; protect human host access separately.", "components": ["pgb", "pgpool"], "sources": ["pgb:sa5cdd62a9242", "pgpool:s7847f7feb03b"], "status": "REASONED"}, "ldap-pam": {"text": "LDAP arrived in 1.25.0 and can appear in HBA; PAM is global and disables HBA selection, forwarding the supplied password instead of conducting an OTP challenge.", "components": ["pgb", "ldap-min", "pgb-source"], "sources": ["pgb:sa5cdd62a9242", "ldap-min:s2a1788c4296b", "pgb-source:sb9455e4f6c90"], "status": "REASONED"}, "mtls-hba": {"text": "Client verify-full and verify-ca are equivalent for certificates and can retain HBA/SCRAM; auth_type=cert takes the certificate username and replaces HBA selection.", "components": ["pgb"], "sources": ["pgb:sa5cdd62a9242"], "status": "REASONED"} } } ```` ====================================================================== ==> mysql.md ====================================================================== # MySQL and MariaDB: TLS and authentication MySQL and MariaDB are relational databases that applications use to store their data. This guide helps your AI assistant make sure a server is configured securely: a private bind address, TLS required, scoped accounts with anonymous access removed, password validation enabled, file loading restricted, and clients connecting with verification. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mysql.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MySQL reference 8.4, 8.0, 9.7, and 26.7, MySQL Docker package mysql-8.4.9, and MariaDB package source mariadb-11.8.9; 2 further sources are pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 55 claims: 2 demonstrated by a check that ran and 53 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Default posture: keep the server on `127.0.0.1` and open it to remote clients only deliberately, with TLS required. Oracle documents MySQL's `bind_address` default as `*`; do not assume a package has already restricted it. [MySQL server variables](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_bind_address). ## 1. Server TLS MySQL can generate missing certificate/key files at startup under its documented auto-generation conditions. Inspect the effective settings: ```sql SHOW GLOBAL VARIABLES LIKE '%ssl%'; ``` The auto-generated server certificate does not carry your deployment hostname, so the identity-verifying client below (`--ssl-mode=VERIFY_IDENTITY`) rejects it. Install a certificate covering the hostname clients connect to, such as `db.example.com`. [MySQL certificate generation](https://dev.mysql.com/doc/refman/8.4/en/creating-ssl-rsa-files-using-mysql.html). To use your own certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) and refuse cleartext TCP connections, set these options in `/etc/mysql/mysql.conf.d/mysqld.cnf`, or the equivalent for your packaging: ```ini [mysqld] bind_address = 127.0.0.1 mysqlx_bind_address = 127.0.0.1 require_secure_transport = ON tls_version = TLSv1.2,TLSv1.3 ssl_ca = /etc/mysql/certs/ca.pem ssl_cert = /etc/mysql/certs/server-cert.pem ssl_key = /etc/mysql/certs/server-key.pem ``` Widen the bind addresses only deliberately. Omit `mysqlx_bind_address` on MariaDB. The TLS version list requires a compatible TLS library; MySQL supports TLSv1.3 from 8.0.16 with OpenSSL 1.1.1 or later. MariaDB's TLSv1.3 support also depends on its TLS library. Restart after applying this startup configuration. [MySQL secure connections](https://dev.mysql.com/doc/mysql-secure-deployment-guide/8.0/en/secure-deployment-secure-connections.html), [MariaDB TLS variables](https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/ssltls-system-variables). Global `require_secure_transport` permits Unix sockets, but MySQL account-level `REQUIRE SSL` takes precedence and prevents that account from connecting through a Unix socket. [MySQL transport requirements](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_require_secure_transport). MariaDB introduced `require_secure_transport` in 10.5.2. MariaDB 11.4 changed automatic TLS setup and client verification; its zero-configuration behavior requires compatible server and client versions and authentication plugins. Do not assume older clients receive the same protection. Explicit CA-based configuration remains useful across mixed deployments. [MariaDB transport requirements](https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#require_secure_transport), [MariaDB automatic TLS](https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/zero-configuration-ssl). MySQL 8.0, 8.4, 9.7 and 26.7 enable the X Plugin by default. It has a separate listener: `mysqlx_port` defaults to `33060`, and `mysqlx_bind_address` defaults to `*`, every interface. The classic protocol's `bind_address` does not restrict it. Set both bind addresses, as above, or disable an unused X Plugin with `mysqlx = OFF`. These X Plugin options require a restart. [MySQL 8.0 X Plugin options](https://dev.mysql.com/doc/refman/8.0/en/x-plugin-options-system-variables.html), [MySQL 8.4 X Plugin options](https://dev.mysql.com/doc/refman/8.4/en/x-plugin-options-system-variables.html), [MySQL 9.7 X Plugin options](https://dev.mysql.com/doc/refman/9.7/en/x-plugin-options-system-variables.html), [MySQL 26.7 X Plugin options](https://dev.mysql.com/doc/refman/26.7/en/x-plugin-options-system-variables.html). MariaDB does not implement MySQL's X Protocol. Omit MySQL-specific X Plugin options: an unknown option can prevent MariaDB from starting. [MariaDB protocol differences](https://mariadb.com/docs/server/reference/clientserver-protocol/mariadb-protocol-differences-with-mysql), [MariaDB startup troubleshooting](https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/what-to-do-if-mariadb-doesnt-start). The official Docker images start wider. The MySQL images (8.4, 9.7 and innovation at the pinned commit) install Oracle's Docker server package, whose `my.cnf` (checked in the 8.4.9, 9.7.2 and 26.7.0 sources) sets no bind address. The MySQL images' `bind_address` and `mysqlx_bind_address` therefore keep their `*` defaults (in 8.4, 9.7 and 26.7 alike) and the classic and X Protocol ports (3306 and 33060 by default) listen on every address, unless an argument or an option file (such as one mounted in `/etc/mysql/conf.d/`) changes that: the matching bind address, `skip_networking`, or `mysqlx=OFF` for the X Protocol port. The MariaDB images (10.6 through 13.1) comment out every `bind-address` line in the Ubuntu variants' configuration, including the package's `127.0.0.1`, and the UBI variants' packaged configuration leaves it commented, so unless an argument or an option file sets a bind address or `skip_networking`, the server listens on the IPv4 wildcard address, and on the IPv6 one where the container has IPv6. When either entrypoint initializes a new data directory, the root host (`MYSQL_ROOT_HOST`; on MariaDB, `MARIADB_ROOT_HOST` or `MYSQL_ROOT_HOST`; `_FILE` forms accepted) is `%` when neither the variable nor its `_FILE` form supplies a value. A `_FILE` naming an empty file leaves the root host empty at that initialization, and then no remote root account is created, unless on MariaDB the `MARIADB_` form, which takes precedence, supplies a value. Either entrypoint, when it initializes a new data directory with any non-empty root host value other than `localhost`, creates a root account for that host (`root@'%'` by default) with `GRANT ALL ON *.* ... WITH GRANT OPTION` (MariaDB also grants `PROXY`) and the root password. Initialization requires a root password (on MariaDB, or its hash), a random one, or the empty-password variable (`MYSQL_ALLOW_EMPTY_PASSWORD`; MariaDB also accepts `MARIADB_ALLOW_EMPTY_ROOT_PASSWORD`). With none of the others set, any non-empty value of that variable, even `no`, lets initialization proceed, and the remote root account is created with an empty password. Set a root password or a random one, set the root host to `localhost` unless remote root is needed, never set the empty-password variable, and publish the ports only to host loopback or a private network. ## 2. Per-account requirements Require TLS, or a client certificate, at the account level as a second control. For existing accounts: ```sql ALTER USER 'app'@'10.0.0.10' REQUIRE SSL; -- Alternatively, for an existing batch account using mutual TLS: ALTER USER 'batch'@'10.0.0.11' REQUIRE X509; ``` Use the source address the database actually sees. MySQL deprecates `%` and `_` wildcards in account host values; prefer explicit addresses and use a deliberate network range only when required. [MySQL account names](https://dev.mysql.com/doc/refman/8.4/en/account-names.html), [MySQL account requirements](https://dev.mysql.com/doc/refman/8.4/en/alter-user.html). ### Create a scoped runtime account A compromised application should reach only the tables and operations it needs. Create a separate runtime account for the application's source host; keep migrations and account administration in separate accounts. The SQL below is an administrative template. Replace secret placeholders with generated secrets satisfying the configured policy, and enter password-bearing SQL through an authenticated client's input. Never put it in a shell command or a client `-e` argument. Client login passwords use the interactive `-p` prompt with no value. MySQL 8.0/8.4: ```sql CREATE USER 'app'@'10.0.0.10' IDENTIFIED WITH caching_sha2_password BY 'REPLACE_WITH_RANDOM_SECRET' REQUIRE SSL; GRANT SELECT, INSERT, UPDATE, DELETE ON app.orders TO 'app'@'10.0.0.10'; ``` Remove operations the application does not need. Repeat grants for individual required tables. Do not substitute `*.*`, `ALL PRIVILEGES`, or `WITH GRANT OPTION`. Creating this account does not remove an existing `'app'@'%'` account or its privileges; review and retire the old account after cutover. [MySQL CREATE USER](https://dev.mysql.com/doc/refman/8.4/en/create-user.html), [MySQL GRANT](https://dev.mysql.com/doc/refman/8.4/en/grant.html). For MariaDB 10.4+ deployments whose clients support `ed25519`, install the authentication plugin once if absent, then create the account: ```sql INSTALL SONAME 'auth_ed25519'; CREATE USER 'app'@'10.0.0.10' IDENTIFIED VIA ed25519 USING PASSWORD('REPLACE_WITH_RANDOM_SECRET') REQUIRE SSL; GRANT SELECT, INSERT, UPDATE, DELETE ON app.orders TO 'app'@'10.0.0.10'; ``` Check connector compatibility before migration. MariaDB added the `PASSWORD()` form for `ed25519` in 10.4. Its ordinary `IDENTIFIED BY` behavior is not MySQL's `caching_sha2_password` default. [MariaDB ed25519](https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-ed25519), [MariaDB CREATE USER](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/create-user), [MariaDB GRANT](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant). ### Remove anonymous access and installation leftovers Anonymous accounts and leftover test-schema grants can provide access outside the application's intended account boundary. On a new installation, use the vendor's interactive hardening utility. MySQL: ```bash mysql_secure_installation ``` MariaDB: ```bash mariadb-secure-installation ``` Select removal of anonymous users, unused remote root accounts, and the disposable `test` database and associated access grants. On an existing deployment, first confirm that the database is disposable and preserve a working administrative access path. The utilities prompt for credentials and choices; do not add a password argument. [MySQL hardening utility](https://dev.mysql.com/doc/refman/8.4/en/mysql-secure-installation.html), [MariaDB hardening utility](https://mariadb.com/docs/server/clients-and-utilities/deployment-tools/mariadb-secure-installation). Inventory before and after, through an administrative session: ```sql SELECT User, Host FROM mysql.user WHERE User = '' OR User = 'root'; SELECT User, Host, Db FROM mysql.db WHERE User = '' OR Db LIKE 'test%'; ``` Inspect the rows individually. The second query intentionally includes similarly named application databases; it is an inventory, not a deletion list. Removing `test` alone does not establish that grants permitting future `test_...` databases have disappeared. Do not edit grant tables directly. [MySQL grant tables](https://dev.mysql.com/doc/refman/8.4/en/grant-tables.html), [MySQL cleanup behavior](https://dev.mysql.com/doc/refman/8.4/en/mysql-secure-installation.html). Initial accounts depend on installation method. MariaDB 10.4+ normally supports local root administration through Unix-socket authentication; do not replace it with password authentication merely to follow an older checklist. In MariaDB 10.4+, `mysql.user` is a compatibility view, suitable for this inventory. [MariaDB installation guidance](https://mariadb.com/docs/server/clients-and-utilities/deployment-tools/mariadb-secure-installation), [MariaDB user view](https://mariadb.com/docs/server/reference/system-tables/the-mysql-database-tables/mysql-user-table). ### Roles and inherited privileges Where several accounts share privileges, use a role instead of the direct table grant. MySQL 8.0/8.4: ```sql CREATE ROLE app_runtime; GRANT SELECT, INSERT, UPDATE, DELETE ON app.orders TO app_runtime; GRANT app_runtime TO 'app'@'10.0.0.10'; SET DEFAULT ROLE app_runtime TO 'app'@'10.0.0.10'; ``` MariaDB: ```sql CREATE ROLE app_runtime; GRANT SELECT, INSERT, UPDATE, DELETE ON app.orders TO app_runtime; GRANT app_runtime TO 'app'@'10.0.0.10'; SET DEFAULT ROLE app_runtime FOR 'app'@'10.0.0.10'; ``` Assignment and activation are separate. MariaDB uses `FOR` and permits one current role, including inherited privileges; MySQL supports multiple active roles. Do not give the application role administration rights. [MySQL roles](https://dev.mysql.com/doc/refman/8.4/en/roles.html), [MariaDB default roles](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-default-role), [MariaDB role activation](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-role). Withhold `FILE`, `SUPER`, account-management privileges, and unnecessary global privileges. On MySQL, withhold administrative dynamic privileges such as `SYSTEM_VARIABLES_ADMIN` and `ROLE_ADMIN`; on 8.4, this also includes `SET_ANY_DEFINER` and `ALLOW_NONEXISTENT_DEFINER`. The older `SET_USER_ID` privilege was removed in MySQL 8.4. MariaDB's corresponding definer privilege is `SET USER`, available from 10.5. [MySQL privileges](https://dev.mysql.com/doc/refman/8.4/en/privileges-provided.html), [MySQL 8.4 changes](https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html), [MariaDB privileges](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant#set-user). On MariaDB 10.11+, also inspect privileges inherited by every account through `PUBLIC`: ```sql SHOW GRANTS FOR PUBLIC; ``` Revoke an unintended grant at the scope where it was granted. For example, if this specific table grant exists: ```sql REVOKE SELECT ON app.orders FROM PUBLIC; ``` This affects all users. First preserve intentional access with explicit grants. A table-level revoke does not remove a broader database-level grant. [MariaDB PUBLIC privileges](https://mariadb.com/docs/server/security/user-account-management/roles/system-users-roles-and-privileges), [MariaDB GRANT and version availability](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant). ### Authentication versions Account hygiene also follows [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). MySQL 8.0 and 8.4 default to `caching_sha2_password` for new accounts, but existing accounts retain their configured authentication plugin. `mysql_native_password` was deprecated in 8.0.34, disabled by default in 8.4, and removed in 9.0. [MySQL 8.0 authentication](https://dev.mysql.com/doc/refman/8.0/en/caching-sha2-pluggable-authentication.html), [MySQL 8.4 authentication](https://dev.mysql.com/doc/refman/8.4/en/caching-sha2-pluggable-authentication.html), [native authentication lifecycle](https://dev.mysql.com/doc/refman/8.4/en/native-pluggable-authentication.html). For an existing MySQL account, coordinate the password and client cutover: ```sql ALTER USER 'app'@'10.0.0.10' IDENTIFIED WITH caching_sha2_password BY 'REPLACE_WITH_NEW_RANDOM_SECRET'; ``` Use an administrative session for this migration. [MySQL ALTER USER](https://dev.mysql.com/doc/refman/8.4/en/alter-user.html). MariaDB now documents a server-side `caching_sha2_password` plugin in Community Server 12.1 and Enterprise Server 11.8. This does not make MySQL authentication syntax, defaults, or older MariaDB deployments interchangeable. [MariaDB SHA-256 authentication](https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-sha-256). ### Validate passwords and prevent reuse Reject weak replacement passwords and prevent recent passwords from being reused. These controls complement the account's authentication plugin. On MySQL 8.4, inspect the existing validation implementation first: ```sql SHOW GLOBAL VARIABLES LIKE 'validate_password%'; ``` Some Oracle packages enable the component automatically. If an older validation plugin is installed, follow the documented transition before using component settings. The component uses dotted names such as `validate_password.length`; the older plugin uses different names. MySQL 8.0 also provides the component. [Component installation](https://dev.mysql.com/doc/refman/8.4/en/validate-password-installation.html), [component migration](https://dev.mysql.com/doc/refman/8.4/en/validate-password-transitioning.html), [MySQL 8.0 component](https://dev.mysql.com/doc/refman/8.0/en/validate-password.html). Install the component once if absent, then set the policy: ```sql INSTALL COMPONENT 'file://component_validate_password'; SET PERSIST validate_password.length = 16; SET PERSIST validate_password.policy = 'MEDIUM'; SET PERSIST password_history = 6; SET PERSIST password_reuse_interval = 365; ``` The length and reuse values are example policy choices, not vendor defaults. `MEDIUM` checks length and configured character-class requirements. `SET PERSIST` applies values now and retains them across restarts. [Validation variables](https://dev.mysql.com/doc/refman/8.4/en/validate-password-options-variables.html), [persistent settings](https://dev.mysql.com/doc/refman/8.4/en/set-variable.html). Account-specific history settings override global policy. For an existing named administrative account: ```sql ALTER USER 'operator'@'10.0.0.20' PASSWORD HISTORY DEFAULT PASSWORD REUSE INTERVAL DEFAULT PASSWORD REQUIRE CURRENT; ``` `PASSWORD REQUIRE CURRENT` protects ordinary self-service password changes; privileged administrators can reset passwords without supplying the old password. Where organizational policy requires expiration: ```sql ALTER USER 'operator'@'10.0.0.20' PASSWORD EXPIRE INTERVAL 180 DAY; ``` Do not apply unattended expiration to application accounts without a working rotation process. For externally authenticated MySQL accounts, password management belongs in the external identity system. [MySQL password management](https://dev.mysql.com/doc/refman/8.4/en/password-management.html). MariaDB uses different plugins. On MariaDB 10.11/11.4, install each absent plugin once: ```sql INSTALL SONAME 'simple_password_check'; INSTALL SONAME 'password_reuse_check'; SET GLOBAL simple_password_check_minimal_length = 16; SET GLOBAL password_reuse_check_interval = 365; SET GLOBAL strict_password_validation = ON; ``` Persist their settings in the server option file: ```ini [mariadb] simple_password_check_minimal_length = 16 password_reuse_check_interval = 365 strict_password_validation = ON secure_timestamp = replication ``` MariaDB's reuse plugin provides a time-based policy, not MySQL's `PASSWORD HISTORY 6` syntax. Its interval defaults to zero, meaning unlimited retention, not disabled checking. Keep strict validation enabled so supplying password hashes cannot bypass the installed validators. [Simple Password Check](https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/simple-password-check-plugin), [reuse interval](https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password_reuse_check_interval). Reuse checking appeared in MariaDB 10.7. Use a maintained release with plugin version 2.0; upgrading from its older storage format invalidated previously recorded reuse protection. MariaDB also supports the per-account expiration statement above when policy requires it. [Password Reuse Check](https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password-reuse-check-plugin), [MariaDB password expiry](https://mariadb.com/docs/server/security/user-account-management/user-password-expiry). For MariaDB password expiration and temporal controls to resist session-clock tampering, set `secure_timestamp` to `YES` or `replication`, as in the option file above. The default `NO` lets any user use `SET TIMESTAMP` to advance the session clock before changing a password, recording a future last-change time and evading expiration. `YES` pins timestamps to the system clock; `replication` permits only replication threads to adjust them. Use `replication` on replicas, since `YES` can cause discrepancies with statement-based replication. This is a startup-only setting: restart and check the effective value before relying on expiration. Review existing password-change timestamps for prior tampering. [MariaDB password expiry bypass](https://mariadb.com/docs/server/security/user-account-management/user-password-expiry), [MariaDB secure_timestamp](https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#secure_timestamp). The account, role, and password controls above are Community-available. Plugin examples require the relevant installed libraries and compatible clients. MFA: MySQL 8.0.27 and later support up to three authentication factors per account; internal credential-storage plugins cannot supply factors 2 and 3. The device plugin is FIDO from 8.0.27, deprecated as of 8.0.35 and removed in 8.4. WebAuthn replaces it from 8.2 onward, including 8.4 LTS. Both server-side device plugins are Enterprise-only. On Community builds, `REQUIRE X509` adds possession of a machine-held certificate to password authentication, but does not establish MFA for a person; put human access paths behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). [MySQL MFA](https://dev.mysql.com/doc/refman/8.0/en/multifactor-authentication.html), [FIDO](https://dev.mysql.com/doc/refman/8.0/en/fido-pluggable-authentication.html), [WebAuthn](https://dev.mysql.com/doc/refman/8.4/en/webauthn-pluggable-authentication.html), [8.4 removals](https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html). ## 3. File-loading restrictions File-loading features can expose files readable by the client or database service. Disable `LOCAL` loading unless an identified import workflow requires it: ```ini [mysqld] local_infile = OFF [mysql] local-infile = 0 ``` The server and client settings protect different ends of the connection. Configure application connectors separately; the command-line client's option group does not configure application libraries. MySQL 8.4 already disables server-side `local_infile` by default, but an explicit setting documents the policy. [MySQL LOCAL security](https://dev.mysql.com/doc/refman/8.4/en/load-data-local-security.html). For MySQL 8.0/8.4, disable the server-file operations governed by `secure_file_priv` when unnecessary: ```ini [mysqld] secure_file_priv = NULL ``` `NULL` disables these operations; an empty value removes the restriction. If imports or exports are required, use an existing restricted directory instead. Restart after changing this startup-only setting. [MySQL file restrictions](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_secure_file_priv). For MariaDB, use a dedicated existing directory, readable and writable only as needed by the service and authorized operators: ```ini [mysqld] local_infile = OFF secure_file_priv = /var/lib/mysql-files ``` MariaDB documents a pathname restriction, not MySQL's `NULL` disable value. Do not copy that value across products. Its `secure_file_priv` setting also requires a restart, and its server-side `local_infile` default is `ON`. [MariaDB server variables](https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#secure_file_priv). Continue withholding `FILE` from runtime accounts. `LOAD DATA LOCAL` does not require MySQL's `FILE` privilege, so withholding it does not replace disabling local loading. These are core/free controls; test import, restore, and MySQL Shell loading workflows before rollout because some require `LOCAL`. [MySQL LOAD DATA](https://dev.mysql.com/doc/refman/8.4/en/load-data.html), [MariaDB LOAD DATA](https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/inserting-loading-data/load-data-into-tables-or-index/load-data-infile). ## 4. Client side Require server identity verification in addition to encryption. Paste each shell block whole. Substitute inside the single quotes on its `set --` line; values containing a literal apostrophe need correct shell escaping. Use absolute paths for certificate files. The guards assume ordinary shell builtins and do not protect a fragment pasted from below the guards. For the MySQL client: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DATABASE_HOST' 'REPLACE_WITH_CA_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 2 ] || { echo "supply exactly 2 values; not connecting"; exit 2; } case "$1$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace every placeholder; not connecting"; exit 2 ;; *) [ -n "$1" ] || { echo "argument 1 is empty; not connecting"; exit 2; } [ -n "$2" ] || { echo "argument 2 is empty; not connecting"; exit 2; } [ -f "$2" ] || { echo "CA must be a regular file"; exit 2; } [ -r "$2" ] || { echo "CA must be readable"; exit 2; } mysql --protocol=TCP --host="$1" --port=3306 --user=app -p \ --ssl-mode=VERIFY_IDENTITY --ssl-ca="$2" ;; esac ) ``` `--ssl-mode=REQUIRED` encrypts without certificate verification. `VERIFY_CA` verifies the certificate chain; `VERIFY_IDENTITY` also checks the hostname. Connector options in application code follow the same distinction. [MySQL encrypted connections](https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html). For MariaDB clients, use `mariadb` and replace `--ssl-mode=VERIFY_IDENTITY` with `--ssl --ssl-verify-server-cert`, retaining the CA option and guards. Do not assume MySQL-specific flags are accepted. [MariaDB client options](https://mariadb.com/docs/server/clients-and-utilities/mariadb-client/mariadb-command-line-client). The existing `batch` account's `REQUIRE X509` also needs a client certificate and key. With the MySQL client: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DATABASE_HOST' 'REPLACE_WITH_CA_FILE' 'REPLACE_WITH_CLIENT_CERT_FILE' 'REPLACE_WITH_CLIENT_KEY_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 4 ] || { echo "supply exactly 4 values; not connecting"; exit 2; } case "$1$2$3$4" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace every placeholder; not connecting"; exit 2 ;; *) [ -n "$1" ] || { echo "argument 1 is empty; not connecting"; exit 2; } [ -n "$2" ] || { echo "argument 2 is empty; not connecting"; exit 2; } [ -n "$3" ] || { echo "argument 3 is empty; not connecting"; exit 2; } [ -n "$4" ] || { echo "argument 4 is empty; not connecting"; exit 2; } [ -f "$2" ] || { echo "CA must be a regular file"; exit 2; } [ -r "$2" ] || { echo "CA must be readable"; exit 2; } [ -f "$3" ] || { echo "certificate must be a regular file"; exit 2; } [ -r "$3" ] || { echo "certificate must be readable"; exit 2; } [ -f "$4" ] || { echo "key must be a regular file"; exit 2; } [ -r "$4" ] || { echo "key must be readable"; exit 2; } mysql --protocol=TCP --host="$1" --port=3306 --user=batch -p \ --ssl-mode=VERIFY_IDENTITY --ssl-ca="$2" \ --ssl-cert="$3" --ssl-key="$4" ;; esac ) ``` Apply the same MariaDB client substitution when appropriate. Restrict access to the private key. A CA file alone does not satisfy `REQUIRE X509`. [MySQL client certificates](https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html), [MariaDB certificate options](https://mariadb.com/docs/server/clients-and-utilities/mariadb-client/mariadb-command-line-client). ## 5. Verify REASONED: service scope for every check in this section, from the authoring limitations recorded in this paragraph and the vendor documentation cited at each check below. Service behavior below is **REASONED, not demonstrated**. The authoring environment has a read-only filesystem, no MySQL/MariaDB client or server binaries, no Docker/Podman runtime, and no supplied writable deployment or database credentials. No available authorized environment could reproduce the exposed and fixed service states. DEMONSTRATED: the local shell and guard checks recorded in this paragraph, re-run at marking time: all seven blocks passed bash -n (Bash 5.3.9) and ShellCheck 0.11.0, guard-only runs under bash -u rejected 25 invalid cases across the five guarded blocks with their not-connecting messages, and each guard dispatched one valid case to a local stub command; no database client or server ran. The bash -n, ShellCheck and guard-only runs recorded in this paragraph, at marking time and originally, covered the five guarded blocks in an earlier form, whose guard refusals exited with status 1 where those of the current form exit with status 2, and the listener block also before its `ss` presence check and its status 3 for a failed `ss`. The current form of all seven blocks is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and that of the listener block also by local bash -n and stub runs with `ss` present, failing and missing. That earlier form of the two section 5 blocks with a positive control also exited with status 1 after a failed positive control, where the current form exits with status 3. At marking time, all seven shell blocks passed `bash -n` and ShellCheck. Guard-only execution under `bash -u` rejected 119 tested invalid-input cases across the five guarded blocks, including an empty value in every argument position, embedded placeholders, angle brackets, example hostnames, missing/wrong markers, and incorrect argument counts. Each guard also accepted one valid dispatch case. These tests exercised guards only; no database behavior was simulated. DEMONSTRATED: the in-memory INI parse recorded in this paragraph, re-run at marking time against both states: all five INI fragments parsed with Python 3.14.4 configparser in strict mode (`ConfigParser(strict=True)`, other arguments at their defaults). The control fragments in that marking-time re-run against both states used the same parser and options: a control fragment repeating `require_secure_transport` under `[mysqld]` was rejected with DuplicateOptionError, and a control fragment placing that option before any section header was rejected with MissingSectionHeaderError. Vendor option acceptance stays with the reasoned native checks, as stated. All five INI fragments parsed with an in-memory INI parser. This checks basic structure, not vendor option acceptance. Native configuration parsing and restart behavior remain REASONED because the server binaries and writable deployment are unavailable. DEMONSTRATED: the scanner-classification evidence recorded in this paragraph, re-run at marking time against both states: the shipped roster was confirmed to omit mysql, mariadb and ss; an in-memory scan with those names added accepted this guide as shipped (seven fences, eleven probes, five guard fences, no finding). The same marking-time re-run against both states re-observed the deliberately unguarded probe control, with an injected unguarded mysql probe line before the first guard reported as C2-PROBE-OUTSIDE-GUARD at the injected line. The scanner self-test passed (1190 cases) in that marking-time re-run against both states. The snapshot's C2 scanner does not classify `mysql`, `mariadb`, or `ss` as probes. A supplemental in-memory check added those command names, accepted these guarded blocks, and detected a deliberately unguarded probe before the guard. No gate or repository file was changed. ### TLS and account requirements (REASONED: TLS/account comparisons from cited vendor documentation; no writable deployment, clients, credentials or certificates.) **REASONED: missing a writable deployment, compatible clients, known-good credentials, and deployment certificates.** Through an administrative session, inspect both global and account requirements: ```sql SHOW GLOBAL VARIABLES LIKE 'require_secure_transport'; SELECT User, Host, ssl_type FROM mysql.user; ``` The fixed configuration must report secure transport enabled and the intended accounts' TLS requirements. In an exposed test deployment with both controls removed, these observations differ. Account requirements and global requirements must be assessed separately; one does not demonstrate the other. [MySQL transport enforcement](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_require_secure_transport), [MySQL grant-table fields](https://dev.mysql.com/doc/refman/8.4/en/grant-tables.html). At the interactive client prompt, `\s` displays this session's SSL cipher. It does not establish server-wide enforcement or hostname verification. [MySQL client commands](https://dev.mysql.com/doc/refman/8.4/en/mysql-commands.html). Run the following from the application's permitted source host. Replace the product with `mysql` or `mariadb`. Both commands use the same host, port, account, and known-good password. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PRODUCT' 'REPLACE_WITH_DATABASE_HOST' 'REPLACE_WITH_CA_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 3 ] || { echo "supply exactly 3 values; not connecting"; exit 2; } case "$1$2$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace every placeholder; not connecting"; exit 2 ;; *) [ -n "$1" ] || { echo "argument 1 is empty; not connecting"; exit 2; } [ -n "$2" ] || { echo "argument 2 is empty; not connecting"; exit 2; } [ -n "$3" ] || { echo "argument 3 is empty; not connecting"; exit 2; } [ -f "$3" ] || { echo "CA must be a regular file"; exit 2; } [ -r "$3" ] || { echo "CA must be readable"; exit 2; } case "$1" in mysql) mysql --protocol=TCP --host="$2" --port=3306 --user=app -p \ --connect-timeout=5 --ssl-mode=VERIFY_IDENTITY --ssl-ca="$3" \ -e "SELECT 1; SELECT CURRENT_USER(); SHOW SESSION STATUS LIKE 'Ssl_cipher';" \ || { echo "positive control failed; stop"; exit 3; } if mysql --protocol=TCP --host="$2" --port=3306 --user=app -p \ --connect-timeout=5 --ssl-mode=DISABLED -e 'SELECT 1;' then echo "FAIL: cleartext connection succeeded"; exit 1 else echo "inspect the refusal and requirements; failure alone is not a pass" fi ;; mariadb) mariadb --protocol=TCP --host="$2" --port=3306 --user=app -p \ --connect-timeout=5 --ssl --ssl-verify-server-cert --ssl-ca="$3" \ -e "SELECT 1; SELECT CURRENT_USER(); SHOW SESSION STATUS LIKE 'Ssl_cipher';" \ || { echo "positive control failed; stop"; exit 3; } if mariadb --protocol=TCP --host="$2" --port=3306 --user=app -p \ --connect-timeout=5 --disable-ssl -e 'SELECT 1;' then echo "FAIL: cleartext connection succeeded"; exit 1 else echo "inspect the refusal and requirements; failure alone is not a pass" fi ;; *) echo "product must be mysql or mariadb"; exit 2 ;; esac ;; esac ) ``` The positive control must succeed, identify the intended account through `CURRENT_USER()`, and show a nonempty session cipher. Enter the same known-good password when prompted; then inspect the server's refusal. MySQL error 1045 is generic access denial, not standalone proof of TLS enforcement. Error 3159 specifically identifies global secure-transport enforcement. A timeout, wrong password, unknown option, certificate-loading error, or authentication-plugin failure is inconclusive. The block deliberately does not print a pass merely because the negative command failed. [Password prompting](https://dev.mysql.com/doc/refman/8.4/en/connection-options.html), [1045 definition](https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html#error_er_access_denied_error), [3159 definition](https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html#error_er_secure_transport_required). In an isolated exposed-state test, both TLS restrictions are absent and the same cleartext request must succeed. If `caching_sha2_password` instead needs an unavailable RSA exchange after its cache is cleared, that failure does not demonstrate TLS policy. Repeat the positive control and resolve authentication prerequisites before drawing a conclusion. [MySQL authentication exchange](https://dev.mysql.com/doc/refman/8.4/en/caching-sha2-pluggable-authentication.html). For server identity, repeat the positive connection against the same reachable endpoint using a valid but unrelated CA file, or a test hostname resolving to that endpoint but absent from its certificate. Expect certificate verification failure; restore the correct CA and hostname and require success. [MySQL certificate verification](https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html). For the existing `batch` account, compare a connection with its valid client certificate to an otherwise matched connection without one. Run from its permitted source host, using the same known-good password at both prompts. This MySQL client block disables option-file and login-path loading so the negative cannot inherit a certificate. [MySQL option-file controls](https://dev.mysql.com/doc/refman/8.4/en/option-file-options.html). ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DATABASE_HOST' 'REPLACE_WITH_CA_FILE' 'REPLACE_WITH_CLIENT_CERT_FILE' 'REPLACE_WITH_CLIENT_KEY_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 4 ] || { echo "supply exactly 4 values; not connecting"; exit 2; } case "$1$2$3$4" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace every placeholder; not connecting"; exit 2 ;; *) [ -n "$1" ] || { echo "argument 1 is empty; not connecting"; exit 2; } [ -n "$2" ] || { echo "argument 2 is empty; not connecting"; exit 2; } [ -n "$3" ] || { echo "argument 3 is empty; not connecting"; exit 2; } [ -n "$4" ] || { echo "argument 4 is empty; not connecting"; exit 2; } [ -f "$2" ] || { echo "CA must be a regular file"; exit 2; } [ -r "$2" ] || { echo "CA must be readable"; exit 2; } [ -f "$3" ] || { echo "certificate must be a regular file"; exit 2; } [ -r "$3" ] || { echo "certificate must be readable"; exit 2; } [ -f "$4" ] || { echo "key must be a regular file"; exit 2; } [ -r "$4" ] || { echo "key must be readable"; exit 2; } mysql --no-defaults --no-login-paths --protocol=TCP --host="$1" --port=3306 --user=batch -p \ --ssl-mode=VERIFY_IDENTITY --ssl-ca="$2" \ --ssl-cert="$3" --ssl-key="$4" --connect-timeout=5 \ -e "SELECT 1; SELECT CURRENT_USER(); SHOW SESSION STATUS LIKE 'Ssl_cipher';" \ || { echo "certificate positive control failed; stop"; exit 3; } if mysql --no-defaults --no-login-paths --protocol=TCP \ --host="$1" --port=3306 --user=batch -p --connect-timeout=5 \ --ssl-mode=VERIFY_IDENTITY --ssl-ca="$2" -e 'SELECT 1;' then echo "FAIL: connection without a client certificate succeeded"; exit 1 else echo "inspect the certificate requirement and refusal; failure alone is not a pass" fi ;; esac ) ``` The certificate-bearing positive must succeed as the intended `batch` account with a nonempty cipher. In an isolated baseline where that same account has only `REQUIRE SSL`, the certificate-free command must also succeed. After enforcing `REQUIRE X509` (or `SUBJECT`/`ISSUER`, which imply X509), that same command must be rejected for lacking the required client certificate while the positive still succeeds. Inspect the effective account requirement and refusal; generic error 1045 alone does not establish the cause. Wrong credentials, connectivity failures, or failed server-certificate verification are inconclusive. A missing-certificate test distinguishes certificate-required policy from SSL-only policy; to test a specific subject or issuer restriction, also use a valid certificate with a non-matching subject or issuer and require rejection. [MySQL account certificate requirements](https://dev.mysql.com/doc/refman/8.4/en/create-user.html#create-user-tls). For MariaDB, adapt the whole block using `mariadb --no-defaults`, omit MySQL's `--no-login-paths`, and replace `--ssl-mode=VERIFY_IDENTITY` with `--ssl --ssl-verify-server-cert` in both commands. Retain the CA, certificate/key positive, certificate-free negative, password prompts, and guards. [MariaDB client options](https://mariadb.com/docs/server/clients-and-utilities/mariadb-client/mariadb-command-line-client). ### Listeners (REASONED: listener comparison from cited MySQL documentation; no server namespace or writable configuration.) **REASONED: missing access to the database server's network namespace and its writable listener configuration.** REASONED: the listener block's statuses, from its own commands. The block exits 2 for a refusal, including when `ss` is not installed, 3 if `ss` runs and fails, and otherwise 0, and its last line says that it leaves every listener for you to judge. Run on the database server, using its actual `hostname` output: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DATABASE_SERVER_HOSTNAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly 1 value; not connecting"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace every placeholder; not connecting"; exit 2 ;; *) [ -n "$1" ] || { echo "argument 1 is empty; not connecting"; exit 2; } [ "$(hostname)" = "$1" ] || { echo "run on the named database server"; exit 2; } command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp || { echo "ss -tlnp exit $?: the listener list did not print; inconclusive"; exit 3; } echo "block exit 0; no listener above is classified, so judge every listener yourself" exit 0 ;; esac ) ``` Read every listener, including configured nondefault ports. In the exposed state, classic protocol or X Protocol listens on an unintended interface. In the fixed loopback deployment, classic protocol remains available on loopback, and X Protocol is either loopback-only or disabled. For deliberate remote access, require only the intended private interfaces. Pair this observation with a successful connection from the permitted location; no listener at all can mean a stopped service. [MySQL listener configuration](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_bind_address), [X Plugin listener configuration](https://dev.mysql.com/doc/refman/8.4/en/x-plugin-options-system-variables.html). ### Scoped grants and cleanup (REASONED: grant/cleanup comparisons from cited vendor documentation; no writable deployment, credentials or fixtures.) **REASONED: missing a writable deployment, administrative credentials, and disposable tables for exposed/fixed comparisons.** As an administrator, inspect the runtime account: ```sql SHOW GRANTS FOR 'app'@'10.0.0.10'; ``` On MySQL, always check the server-wide role settings, even if the account uses direct grants: ```sql SELECT @@GLOBAL.mandatory_roles, @@GLOBAL.activate_all_roles_on_login; ``` `SHOW GRANTS FOR 'app'@'10.0.0.10'` omits mandatory roles and does not expand role privileges. Combine its explicitly granted roles with every existing role in `@@GLOBAL.mandatory_roles`. Mandatory roles are treated as granted to every account; they still require activation. With `activate_all_roles_on_login=ON`, all granted roles, including mandatory roles, activate at login. Otherwise, only default roles activate at login; the session can activate other granted roles with `SET ROLE`. [MySQL roles and activation](https://dev.mysql.com/doc/refman/8.4/en/roles.html). Expand the full combined list with `SHOW GRANTS ... USING`, preserving each role's host part. If `app_runtime` is the only granted role and there are no mandatory roles: ```sql SHOW GRANTS FOR 'app'@'10.0.0.10' USING 'app_runtime'@'%'; ``` When other explicit or mandatory roles exist, append all of them as comma-separated role names in that same `USING` clause. Inspect inherited roles too. The resulting privileges describe access available with those roles activated, not necessarily the roles active in a particular session. Plain `SHOW GRANTS` in the application's own session includes mandatory role assignments, but still needs `USING` to expand role privileges. [MySQL SHOW GRANTS](https://dev.mysql.com/doc/refman/8.4/en/show-grants.html). In MariaDB, inspect each granted role and recursively inspect roles it inherits: ```sql SHOW GRANTS FOR app_runtime; ``` MariaDB permits one current role plus its inherited privileges; do not use MySQL's multiple-role activation or `SHOW GRANTS ... USING` audit there. Also repeat `SHOW GRANTS FOR PUBLIC` on MariaDB 10.11+, since its grants apply to every account independently of the selected role. A `USAGE ON *.*` line denotes no privileges; it is not equivalent to a global data-access grant. [MariaDB SHOW GRANTS](https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-grants), [MariaDB roles](https://mariadb.com/docs/server/security/user-account-management/roles/roles_overview), [MariaDB role activation](https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-role), [MariaDB PUBLIC privileges](https://mariadb.com/docs/server/security/user-account-management/roles/system-users-roles-and-privileges). For a behavioral comparison, use an isolated fixture with existing, populated `app.orders` and `app.private_probe` tables. From a fresh application connection: ```sql SELECT CURRENT_USER(); SELECT CURRENT_ROLE(); SELECT 1 FROM app.orders LIMIT 1; SELECT 1 FROM app.private_probe LIMIT 1; ``` The positive read of `orders` must succeed. With an exposed broad read grant, the second table read succeeds too; with the scoped grant, it must receive a privilege denial. A nonexistent table or failed login is not the expected denial. Repeat the reads in the same disposable MySQL session after `SET ROLE ALL;`, and inspect `CURRENT_ROLE()` again, so an inactive mandatory or granted role cannot hide broader access. On MariaDB, repeat with each granted role activated individually using `SET ROLE role_name;`, substituting its actual name. Repeat required application operations on disposable fixtures, and inspect grants for unnecessary operations and inherited privileges. [MySQL table privileges](https://dev.mysql.com/doc/refman/8.4/en/grant.html). Repeat the two installation inventories from section 2 before and after cleanup. The fixed inventory has no anonymous accounts, no unused remote root accounts, and no unintended anonymous or test-schema grants. Deliberately retained administrative accounts and similarly named application databases must remain identifiable. Preserve a successful administrative login and the runtime positive control across cleanup. [MySQL cleanup behavior](https://dev.mysql.com/doc/refman/8.4/en/mysql-secure-installation.html), [MariaDB cleanup behavior](https://mariadb.com/docs/server/clients-and-utilities/deployment-tools/mariadb-secure-installation). ### File-loading restrictions (REASONED: loading comparisons from cited vendor documentation; no writable deployment, files or import table.) **REASONED: missing a writable deployment, client/server fixture files, and a disposable import table.** Inspect effective server settings after restart: ```sql SHOW GLOBAL VARIABLES LIKE 'local_infile'; SHOW GLOBAL VARIABLES LIKE 'secure_file_priv'; ``` Expect `local_infile` to be `OFF`. Expect MySQL's `secure_file_priv` to be SQL `NULL` under the disable policy, or the deliberately selected directory under an import policy. MariaDB must report the configured restricted directory. For behavioral tests, prepare a disposable one-column integer table named `app.import_probe` and files containing one integer per line. Use a dedicated test account with the table privileges needed for these tests. First prove that ordinary insertion works: ```sql INSERT INTO app.import_probe VALUES (1); ``` With a known-readable client fixture, replace the path and run: ```sql LOAD DATA LOCAL INFILE '/REPLACE_WITH_CLIENT_FIXTURE_PATH' INTO TABLE app.import_probe; ``` In the isolated exposed state, enable `LOCAL` on both client and server and require this exact load to succeed. To test the server control, keep that client enabled and disable only server `local_infile`; require a local-loading-disabled error. To test the client control, keep the isolated server enabled and reconnect with the client setting disabled. Do not configure a permitted local-load directory exception for that test. The ordinary insertion must still succeed. Restore both disabled settings afterward. [MySQL LOCAL controls and errors](https://dev.mysql.com/doc/refman/8.4/en/load-data-local-security.html), [MariaDB LOCAL loading](https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/inserting-loading-data/load-data-into-tables-or-index/load-data-infile). For server-file restrictions, use a dedicated test account with `FILE` and the necessary table privilege; never grant `FILE` to the runtime account: ```sql LOAD DATA INFILE '/var/lib/mysql-files/probe.tsv' INTO TABLE app.import_probe; ``` For MySQL, first require this known-readable file to load under a policy permitting that directory; after restarting with `secure_file_priv = NULL`, the same statement must be refused while ordinary insertion still works. For MariaDB, retain successful loading from the permitted directory and attempt the same statement with a known-readable fixture outside it. That outside-directory load must succeed in the isolated unrestricted baseline and fail under the directory restriction. Missing files, operating-system permission failures, and missing SQL privileges do not demonstrate `secure_file_priv`. [MySQL server-file policy](https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_secure_file_priv), [MariaDB server-file policy](https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#secure_file_priv). ### Password validation and reuse (REASONED: password comparisons from cited vendor documentation; no writable deployment, validation libraries or disposable account.) **REASONED: missing a writable deployment, installed validation libraries, and a disposable password-managed account.** On MySQL, inspect policy and account overrides: ```sql SHOW GLOBAL VARIABLES LIKE 'validate_password.%'; SHOW GLOBAL VARIABLES WHERE Variable_name IN ('password_history', 'password_reuse_interval'); SELECT User, Host, Password_reuse_history, Password_reuse_time, Password_require_current, password_lifetime FROM mysql.user WHERE User = 'operator' AND Host = '10.0.0.20'; ``` Require the selected global values and account inheritance settings. MySQL's nullable account history fields distinguish inherited policy from explicit overrides. Repeat after restart to check persistence. [MySQL password management](https://dev.mysql.com/doc/refman/8.4/en/password-management.html), [MySQL grant-table fields](https://dev.mysql.com/doc/refman/8.4/en/grant-tables.html). On MariaDB: ```sql SHOW PLUGINS; SHOW GLOBAL VARIABLES WHERE Variable_name IN ( 'simple_password_check_minimal_length', 'password_reuse_check_interval', 'strict_password_validation', 'secure_timestamp' ); ``` Require both validation plugins to be active and the selected values to survive restart. Require `secure_timestamp` to report `REPLICATION` for the example configuration, or `YES` for a deliberate non-replica policy; `NO` leaves the expiration bypass open. Confirm the installed reuse plugin is version 2.0 through the deployment's plugin metadata. [MariaDB validation plugin](https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/simple-password-check-plugin), [MariaDB reuse plugin](https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password-reuse-check-plugin). For behavioral checks, provision a disposable `policy_probe` account with the same authentication plugin and inherited policy as the account being tested. Through an administrative session, use the applicable statement repeatedly. MySQL: ```sql ALTER USER 'policy_probe'@'10.0.0.20' IDENTIFIED WITH caching_sha2_password BY 'REPLACE_WITH_TEST_PASSWORD'; ``` MariaDB: ```sql ALTER USER 'policy_probe'@'10.0.0.20' IDENTIFIED VIA ed25519 USING PASSWORD('REPLACE_WITH_TEST_PASSWORD'); ``` In the isolated exposed state without validation, a deliberately weak test value such as `x` is accepted. With validation enabled, it must be rejected while a fresh generated password satisfying the policy succeeds. For reuse, set two distinct policy-compliant test passwords in sequence, then attempt the first again. Without reuse protection the third change succeeds; with protection enabled and history recorded under that policy, it must fail. A third fresh password must succeed. Keep the authentication plugin unchanged throughout, and do not use production credentials. These tests distinguish validation and history enforcement from a general inability to change passwords. [MySQL validation behavior](https://dev.mysql.com/doc/refman/8.4/en/validate-password.html), [MySQL password reuse](https://dev.mysql.com/doc/refman/8.4/en/password-management.html), [MariaDB password changes](https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-ed25519), [MariaDB reuse behavior](https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password-reuse-check-plugin). Verification scope: | Check scope | Status | Deployment comparison and prerequisites | | --- | --- | --- | | Service checks | REASONED from the cited MySQL and MariaDB documentation; not demonstrated | Provide isolated writable MySQL 8.4 and MariaDB 10.11/11.4 deployments, compatible clients, administrative access, certificates, files, and disposable accounts/tables. Demonstrate exposed and fixed outcomes for TLS and account requirements, listeners, scoped grants, cleanup, file loading, and password policies; run restart tests. Record versions, requests, positive controls, refusals, and observed outcomes. | | Native configuration checks | Not run; retained local work | Run native configuration checks with the applicable MySQL 8.4 and MariaDB 10.11/11.4 binaries and fixtures. | ## Common mistakes - Creating `'app'@'%'` with a weak password to fix a connection error, then never tightening access. - Creating a narrower account but leaving an older broad account usable. - Skipping `require_secure_transport` because "the network is internal"; internal networks are where lateral movement happens. - Shipping a client with `--ssl-mode=DISABLED` to silence certificate errors instead of installing the CA ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)). - Treating error 1045, a timeout, or a password prompt as proof of TLS enforcement. - Binding `bind_address` to loopback but leaving the separately enabled MySQL X Plugin exposed. Set `mysqlx_bind_address` too, or disable an unused X Plugin. - On MariaDB deployments using systemd socket activation, assuming `bind_address` controls sockets created by `mariadb.socket`. Configure the socket unit's listening addresses, or disable socket activation, and inspect effective listeners. [MariaDB systemd socket activation](https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/systemd/configuring). - Copying MySQL's `secure_file_priv = NULL`, dotted validation settings, or role activation syntax into MariaDB. - Assuming global password policy overrides account-specific settings. - Treating schema-name hiding as the access boundary. MySQL normally limits `SHOW DATABASES` according to privileges, and global privileges can broaden visibility. Reduce grants and remove unintended test access. [MySQL SHOW DATABASES](https://dev.mysql.com/doc/refman/8.4/en/show-databases.html). ## Sources (checked September 2026) - Official MySQL Docker images (8.4, 9.7 and innovation, with identical entrypoints; shown for 8.4; pinned commit 2f988f198f35d25b1454fa2504a0e4c348100549): the `mysql-community-server-minimal` install and `my.cnf` edits (socket and `!includedir /etc/mysql/conf.d/`, no bind), `EXPOSE 3306 33060` and `CMD ["mysqld"]`. The entrypoint topics of the same images (shown for 8.4; pinned commit 2f988f198f35d25b1454fa2504a0e4c348100549) are the entrypoint's `file_env` helper, which reads `_FILE`, its password requirement, `file_env 'MYSQL_ROOT_HOST' '%'`, and the `root@'${MYSQL_ROOT_HOST}'` account with `GRANT ALL ... WITH GRANT OPTION`, created only when a new data directory is initialized. Item sources (1 of 4; shown for 8.4; pinned commit 2f988f198f35d25b1454fa2504a0e4c348100549): https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/Dockerfile.oracle#L61-L89 and https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/Dockerfile.oracle#L120-L123. Item sources (2 of 4; shown for 8.4; pinned commit 2f988f198f35d25b1454fa2504a0e4c348100549): https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L28-L43 and https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L138-L147. Item sources (3 of 4; shown for 8.4; pinned commit 2f988f198f35d25b1454fa2504a0e4c348100549): https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L223-L240 and https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L276-L285. Item sources (4 of 4; shown for 8.4; pinned commit 2f988f198f35d25b1454fa2504a0e4c348100549): https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L380-L390 - MySQL's Docker server package `my.cnf`, which sets no bind address (pinned tag mysql-8.4.9; the same in mysql-9.7.2 and mysql-26.7.0): https://github.com/mysql/mysql-server/blob/mysql-8.4.9/packaging/rpm-docker/my.cnf.in and https://github.com/mysql/mysql-server/blob/mysql-8.4.9/packaging/rpm-docker/mysql.spec.in#L298 - Official MariaDB Docker images (10.6 through 13.1, Ubuntu and UBI; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): the Ubuntu build comments out every `bind-address` line and runs `CMD ["mariadbd"]`, and the UBI build installs the `MariaDB-server` package and copies in `docker.cnf`. The entrypoint topics of the same images (shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf) are the entrypoint's `file_env` helper, password requirement, `MARIADB_`/`MYSQL_` variable merging, root host default `%` and `root@'${MARIADB_ROOT_HOST}'` account. Item sources (1 of 6; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/Dockerfile#L125-L127 and https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/Dockerfile#L141-L144. Item sources (2 of 6; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8-ubi/Dockerfile#L37 and https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8-ubi/Dockerfile#L96. Item sources (3 of 6; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/docker.cnf and https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L25-L40. Item sources (4 of 6; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L44-L52 and https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L168-L170. Item sources (5 of 6; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L269 and https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L287-L288. Item sources (6 of 6; shown for 11.8; pinned commit 1a4c8e99f7816c8d97df11eee52098522dc770cf): https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L394-L409 - MariaDB packaged `bind-address` (Debian `127.0.0.1`, RPM commented out) and the unset bind that listens on the IPv4 wildcard and, where available, the IPv6 one (pinned tag mariadb-11.8.9; the packaged settings, the `*` handling and the bind loop are the same at every shipped version, 10.6.28 through 13.1.1). Item sources (1 of 2; pinned tag mariadb-11.8.9): https://github.com/MariaDB/server/blob/mariadb-11.8.9/debian/additions/mariadb.conf.d/50-server.cnf#L27, https://github.com/MariaDB/server/blob/mariadb-11.8.9/support-files/rpm/server.cnf#L40 and https://github.com/MariaDB/server/blob/mariadb-11.8.9/sql/mysqld.cc#L2436-L2439. Item sources (2 of 2; pinned tag mariadb-11.8.9): https://github.com/MariaDB/server/blob/mariadb-11.8.9/sql/mysqld.cc#L2482-L2493 and https://github.com/MariaDB/server/blob/mariadb-11.8.9/sql/mysqld.cc#L2540-L2552 - MySQL 9.7 and 26.7 `bind_address` and `mysqlx_bind_address` defaults (`*`), with the X Plugin enabled by default. Item sources: https://dev.mysql.com/doc/refman/9.7/en/server-system-variables.html#sysvar_bind_address, https://dev.mysql.com/doc/refman/26.7/en/server-system-variables.html#sysvar_bind_address, https://dev.mysql.com/doc/refman/9.7/en/x-plugin-options-system-variables.html and https://dev.mysql.com/doc/refman/26.7/en/x-plugin-options-system-variables.html - `skip_networking` stops TCP listening: the MySQL manual, the X Plugin creating no TCP listener when it is `ON` (pinned tag mysql-8.4.9; the same in mysql-9.7.2 and mysql-26.7.0), and the MariaDB manual. Item sources: https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_skip_networking, https://github.com/mysql/mysql-server/blob/mysql-8.4.9/plugin/x/src/ngs/socket_acceptors_task.cc#L145-L163 and https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#skip_networking - MySQL encrypted connections: https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html - MySQL secure deployment and TLS protocol support: https://dev.mysql.com/doc/mysql-secure-deployment-guide/8.0/en/secure-deployment-secure-connections.html - MySQL automatic certificate generation: https://dev.mysql.com/doc/refman/8.4/en/creating-ssl-rsa-files-using-mysql.html - MySQL server variables, including bind_address, require_secure_transport, and secure_file_priv: https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html - MySQL 8.0 X Plugin options and defaults: https://dev.mysql.com/doc/refman/8.0/en/x-plugin-options-system-variables.html - MySQL 8.4 X Plugin options and defaults: https://dev.mysql.com/doc/refman/8.4/en/x-plugin-options-system-variables.html - MySQL CREATE USER: https://dev.mysql.com/doc/refman/8.4/en/create-user.html - MySQL ALTER USER: https://dev.mysql.com/doc/refman/8.4/en/alter-user.html - MySQL GRANT: https://dev.mysql.com/doc/refman/8.4/en/grant.html - MySQL account names and host matching: https://dev.mysql.com/doc/refman/8.4/en/account-names.html - MySQL roles: https://dev.mysql.com/doc/refman/8.4/en/roles.html - MySQL privileges: https://dev.mysql.com/doc/refman/8.4/en/privileges-provided.html - MySQL 8.4 changes and removals: https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html - MySQL installation hardening: https://dev.mysql.com/doc/refman/8.4/en/mysql-secure-installation.html - MySQL grant tables: https://dev.mysql.com/doc/refman/8.4/en/grant-tables.html - MySQL SHOW GRANTS: https://dev.mysql.com/doc/refman/8.4/en/show-grants.html - MySQL 8.0 caching SHA-2 authentication: https://dev.mysql.com/doc/refman/8.0/en/caching-sha2-pluggable-authentication.html - MySQL 8.4 caching SHA-2 authentication: https://dev.mysql.com/doc/refman/8.4/en/caching-sha2-pluggable-authentication.html - MySQL native authentication lifecycle: https://dev.mysql.com/doc/refman/8.4/en/native-pluggable-authentication.html - MySQL 8.0 password validation component: https://dev.mysql.com/doc/refman/8.0/en/validate-password.html - MySQL password validation behavior: https://dev.mysql.com/doc/refman/8.4/en/validate-password.html - MySQL validation component installation: https://dev.mysql.com/doc/refman/8.4/en/validate-password-installation.html - MySQL validation component migration: https://dev.mysql.com/doc/refman/8.4/en/validate-password-transitioning.html - MySQL validation variables: https://dev.mysql.com/doc/refman/8.4/en/validate-password-options-variables.html - MySQL variable assignment and persistence: https://dev.mysql.com/doc/refman/8.4/en/set-variable.html - MySQL password management: https://dev.mysql.com/doc/refman/8.4/en/password-management.html - MySQL LOCAL loading security: https://dev.mysql.com/doc/refman/8.4/en/load-data-local-security.html - MySQL LOAD DATA syntax and privileges: https://dev.mysql.com/doc/refman/8.4/en/load-data.html - MySQL option-file syntax and groups: https://dev.mysql.com/doc/refman/8.4/en/option-files.html - MySQL option-file command-line controls: https://dev.mysql.com/doc/refman/8.4/en/option-file-options.html - MySQL connection options and password prompting: https://dev.mysql.com/doc/refman/8.4/en/connection-options.html - MySQL client options: https://dev.mysql.com/doc/refman/8.4/en/mysql-command-options.html - MySQL interactive client commands: https://dev.mysql.com/doc/refman/8.4/en/mysql-commands.html - MySQL server errors: https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html - MySQL session cipher status: https://dev.mysql.com/doc/refman/8.4/en/server-status-variables.html#statvar_Ssl_cipher - MySQL SHOW VARIABLES: https://dev.mysql.com/doc/refman/8.4/en/show-variables.html - MySQL SELECT syntax: https://dev.mysql.com/doc/refman/8.4/en/select.html - MySQL INSERT syntax: https://dev.mysql.com/doc/refman/8.4/en/insert.html - MySQL SHOW DATABASES semantics: https://dev.mysql.com/doc/refman/8.4/en/show-databases.html - MySQL multifactor authentication: https://dev.mysql.com/doc/refman/8.0/en/multifactor-authentication.html - MySQL FIDO authentication: https://dev.mysql.com/doc/refman/8.0/en/fido-pluggable-authentication.html - MySQL WebAuthn authentication: https://dev.mysql.com/doc/refman/8.4/en/webauthn-pluggable-authentication.html - MariaDB TLS overview: https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/secure-connections-overview - MariaDB TLS configuration: https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/securing-connections-for-client-and-server - MariaDB automatic TLS requirements: https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/zero-configuration-ssl - MariaDB TLS variables: https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/ssltls-system-variables - MariaDB server variables: https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables - MariaDB ed25519 authentication: https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-ed25519 - MariaDB SHA-256 authentication availability: https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-sha-256 - MariaDB CREATE USER: https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/create-user - MariaDB ALTER USER: https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/alter-user - MariaDB GRANT and privilege availability: https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant - MariaDB roles overview: https://mariadb.com/docs/server/security/user-account-management/roles/roles_overview - MariaDB SHOW GRANTS: https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-grants - MariaDB SET DEFAULT ROLE: https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-default-role - MariaDB SET ROLE: https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-role - MariaDB PUBLIC role: https://mariadb.com/docs/server/security/user-account-management/roles/system-users-roles-and-privileges - MariaDB installation hardening: https://mariadb.com/docs/server/clients-and-utilities/deployment-tools/mariadb-secure-installation - MariaDB mysql.user compatibility view: https://mariadb.com/docs/server/reference/system-tables/the-mysql-database-tables/mysql-user-table - MariaDB mysql.db grant table: https://mariadb.com/docs/server/reference/system-tables/the-mysql-database-tables/mysql-db-table - MariaDB Simple Password Check: https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/simple-password-check-plugin - MariaDB Password Reuse Check and version notes: https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password-reuse-check-plugin - MariaDB password reuse interval: https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password_reuse_check_interval - MariaDB secure_timestamp and session-clock restrictions: https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#secure_timestamp - MariaDB password expiry: https://mariadb.com/docs/server/security/user-account-management/user-password-expiry - MariaDB LOAD DATA: https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/inserting-loading-data/load-data-into-tables-or-index/load-data-infile - MariaDB client options: https://mariadb.com/docs/server/clients-and-utilities/mariadb-client/mariadb-command-line-client - MariaDB protocol differences: https://mariadb.com/docs/server/reference/clientserver-protocol/mariadb-protocol-differences-with-mysql - MariaDB startup troubleshooting: https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/what-to-do-if-mariadb-doesnt-start - MariaDB systemd socket activation: https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/systemd/configuring ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - mysql: MySQL reference (8.4) - mysql8: MySQL reference (8.0) - mysql9: MySQL reference (9.7) - mysql26: MySQL reference (26.7) - package: MySQL Docker package (mysql-8.4.9) - mpackage: MariaDB package source (mariadb-11.8.9) - image: MySQL image source (2f988f198f35d25b1454fa2504a0e4c348100549) - maria: MariaDB documentation (unknown) - mimage: MariaDB image source (1a4c8e99f7816c8d97df11eee52098522dc770cf) Claims (full source mapping in the JSON block below): - classic-bind (REASONED; mysql): MySQL bind_address defaults to *; restrict classic TCP 3306 to loopback or deliberate private interfaces. - server-tls (REASONED; mysql8, maria): Configure CA/certificate/key, require_secure_transport and TLSv1.2/TLSv1.3, then restart; TLSv1.3 needs compatible libraries, MySQL 8.0.16+/OpenSSL 1.1.1+. - auto-cert (REASONED; mysql): MySQL can generate missing TLS files under documented conditions; generated server certificates lack the deployment hostname and fail VERIFY_IDENTITY. - socket (REASONED; mysql): Global require_secure_transport permits Unix sockets; MySQL account REQUIRE SSL takes precedence and disallows that account's socket connection. - maria-tls (REASONED; maria): MariaDB require_secure_transport dates from 10.5.2; 11.4 automatic TLS needs compatible clients and plugins, so mixed deployments may still need explicit CA settings. - x-listener (REASONED; mysql8, mysql, mysql9, mysql26): MySQL 8.0/8.4/9.7/26.7 enable X Plugin with separate wildcard 33060; bind it separately or set mysqlx=OFF and restart. - maria-protocol (REASONED; maria): MariaDB has no MySQL X Protocol; omit X Plugin options because unknown options can prevent startup. - image-listeners (REASONED; image, package, mysql, mysql9, mysql26): Pinned MySQL images install Docker packages with no bind setting, retaining wildcard classic/X listeners unless arguments or option files restrict them. - mimage-listeners (REASONED; mimage, mpackage): Pinned MariaDB 10.6-13.1 Ubuntu images comment out bind-address; UBI leaves it commented, enabling IPv4 wildcard and IPv6 where available. - skip-networking (REASONED; package, mysql, maria): skip_networking stops TCP listeners, including MySQL X; mounted options and mysqlx=OFF can restrict image exposure. - image-root (REASONED; image, mimage): New-directory initialization defaults root host to %, accepting _FILE; empty files suppress remote root unless MariaDB's preferred variable supplies a value. - image-root-grants (REASONED; image, mimage): A nonempty non-localhost root host gets ALL on `*.*` WITH GRANT OPTION; MariaDB also grants PROXY. Prefer localhost and private publication. - image-empty-password (REASONED; image, mimage): Initialization needs a password/hash, random password or empty-password opt-in; any nonempty opt-in, even no, can create passwordless remote root. - account-tls (REASONED; mysql, maria): REQUIRE SSL or X509 independently constrains existing accounts; match the source address seen by the server and avoid deprecated MySQL host wildcards. - runtime (REASONED; mysql, maria): Use separate table-scoped runtime accounts and migration administrators; MySQL caching_sha2_password and MariaDB ed25519 examples require compatible clients. - password-input (REASONED; mysql, maria): Enter password-bearing SQL through authenticated client input, never shell or -e arguments; use an empty -p prompt for login. - maria-ed25519 (REASONED; maria): MariaDB 10.4+ supports ed25519 PASSWORD() form; install the plugin and check connectors; ordinary IDENTIFIED BY is not MySQL's default. - cleanup (REASONED; mysql, maria): Interactive hardening removes anonymous users, unused remote root and disposable test access; inventory grants and preserve administrative access. - maria-root (REASONED; maria): MariaDB 10.4+ normally supports socket-authenticated local root; mysql.user is a compatibility view, not a table to edit directly. - roles (REASONED; mysql, maria): Role assignment and activation differ; MariaDB default-role syntax uses FOR and one current role; MySQL supports multiple active roles. - admin-privileges (REASONED; mysql, maria): Withhold FILE, SUPER and account/global administration; MySQL 8.4 replaces SET_USER_ID with newer definer privileges; MariaDB SET USER dates from 10.5. - maria-public (REASONED; maria): MariaDB 10.11+ PUBLIC grants apply to all users; inspect and revoke at their granted scope while preserving intended access. - mysql-auth (REASONED; mysql8, mysql): MySQL 8.0/8.4 default new accounts to caching_sha2_password; existing plugins persist. Native passwords deprecated in 8.0.34, disabled in 8.4, removed in 9.0. - maria-sha2 (REASONED; maria): MariaDB documents server caching_sha2_password in Community 12.1 and Enterprise 11.8; syntax/defaults are not interchangeable with MySQL. - validation (REASONED; mysql8, mysql): MySQL 8.0/8.4 validation component uses dotted settings; inspect installation and migrate old plugins before applying MEDIUM and example length values. - mysql-reuse (REASONED; mysql): SET PERSIST retains example password history/reuse policy across restart; account-specific settings override globals. - password-current (REASONED; mysql): PASSWORD REQUIRE CURRENT governs ordinary self-service changes, not privileged resets; external accounts use their identity system's policy. - expiry (REASONED; mysql, maria): Use account expiration only with a working rotation process; MariaDB supports expiration too. - maria-validation (REASONED; maria): Install MariaDB Simple Password Check and reuse plugins; persist their distinct settings and strict validation so supplied hashes cannot bypass validators. - maria-reuse (REASONED; maria): MariaDB reuse checking dates from 10.7; use plugin 2.0. Interval zero retains history indefinitely; the format upgrade invalidated earlier history. - timestamp (REASONED; maria): MariaDB secure_timestamp defaults NO, permitting session-clock expiration bypass; restart with YES or replication, choosing replication on replicas. - mfa (REASONED; mysql8, mysql): MySQL 8.0.27+ supports up to three factors, with restrictions on later factors; Enterprise FIDO is deprecated from 8.0.35 and removed in 8.4; WebAuthn replaces it from 8.2. - machine-cert (REASONED; mysql): Community REQUIRE X509 adds a machine-held certificate to password authentication, not human MFA; protect human entrypoints separately. - local-loading (REASONED; mysql, maria): Disable LOCAL on server and clients separately; MySQL 8.4 server default is OFF, MariaDB's is ON; CLI option groups do not configure application libraries. - server-files (REASONED; mysql, maria): MySQL secure_file_priv=NULL disables governed operations; empty removes restriction. MariaDB uses a directory, not NULL; both require restart. - file-privilege (REASONED; mysql, maria): Withhold FILE from runtime users, but LOAD DATA LOCAL does not require MySQL FILE; test import/restore workflows before disabling LOCAL. - client-identity (REASONED; mysql, maria): MySQL REQUIRED encrypts, VERIFY_CA checks chain, VERIFY_IDENTITY adds hostname; MariaDB uses --ssl --ssl-verify-server-cert with CA trust. - client-cert (REASONED; mysql, maria): X509 accounts also need the client certificate and protected key; CA trust alone is insufficient. - verify-transport (REASONED; mysql, maria): Matched TLS success identifies CURRENT_USER and a cipher; plaintext must succeed without restrictions and fail after enforcement. Error 3159 is specific; 1045 alone is not. - verify-server-cert (REASONED; mysql): Wrong trusted identity or unrelated CA must fail verification while the correct CA/hostname succeeds against the same endpoint. - verify-client-cert (REASONED; mysql, maria): With option-file inheritance disabled, SSL-only permits certificate-free login; X509 rejects it while the certificate-bearing control succeeds; test subject/issuer separately. - verify-listeners (REASONED; mysql): Inspect all classic/X listeners and nondefault ports; fixed interfaces retain permitted-client success. A stopped service is not proof of restriction. - socket-activation (REASONED; maria): MariaDB systemd socket activation can bypass bind_address selection; configure the socket unit or disable activation and inspect listeners. - verify-roles (REASONED; mysql): MySQL SHOW GRANTS omits mandatory roles and unexpanded role privileges; combine mandatory/explicit roles with USING and test SET ROLE ALL. - verify-maria-roles (REASONED; maria): MariaDB requires recursive role inspection and each role activated separately; PUBLIC applies independently, and USAGE ON `*.*` grants no data privileges. - verify-grants (REASONED; mysql, maria): Existing orders read stays allowed while private_probe changes from success under broad grants to privilege denial; test each available role. - verify-cleanup (REASONED; mysql, maria): Before/after inventories must remove unintended anonymous, remote-root and test grants while preserving administrative and runtime positive controls. - verify-local (REASONED; mysql, maria): Disable LOCAL separately at client and server, keeping the other side enabled; a readable fixture loads in the exposed state, is refused fixed, and ordinary insertion succeeds. - verify-server-files (REASONED; mysql, maria): MySQL NULL must refuse a previously successful readable-file load; MariaDB must deny an outside-directory fixture while permitting the allowed directory. - verify-policy (REASONED; mysql, maria): Read global policy and account overrides, then repeat after restart; MariaDB plugins must be active, reuse version 2.0, and secure_timestamp REPLICATION or deliberate YES. - verify-weak (REASONED; mysql, maria): Weak password accepted without validation must be rejected with validation while a fresh compliant password succeeds under the same auth plugin. - verify-reuse (REASONED; mysql, maria): After two compliant passwords, reuse of the first must fail only with recorded protection; a third fresh password must succeed. - visibility (REASONED; mysql): SHOW DATABASES visibility follows privileges and can broaden through global grants; hiding names is not an access boundary. - local-guards (DEMONSTRATED; mysql, maria): Historical invalid-input and valid-dispatch checks tested guards only, in an earlier form whose guard refusals exited with status 1; the current form was not re-run, and no database behavior was simulated. - local-ini (DEMONSTRATED; mysql, maria): Historical in-memory INI parsing checked structure only; native configuration acceptance and restart behavior remain unobserved. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "f2d816e86442c44d0bb64fc147557151ea41ddccb731d3992fb1f870244bc5ba", "components": { "mysql": { "name": "MySQL reference", "basis": "8.4", "sources": { "s5735e0088e90": "https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html", "s02467be75571": "https://dev.mysql.com/doc/refman/8.4/en/creating-ssl-rsa-files-using-mysql.html", "sf426889734dd": "https://dev.mysql.com/doc/refman/8.4/en/x-plugin-options-system-variables.html", "sa8e974b8e0d7": "https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html#sysvar_skip_networking", "sac4976c6bd73": "https://dev.mysql.com/doc/refman/8.4/en/alter-user.html", "scb682cfeeeca": "https://dev.mysql.com/doc/refman/8.4/en/account-names.html", "s4009a8e12cba": "https://dev.mysql.com/doc/refman/8.4/en/create-user.html", "sd25a34fe4ed0": "https://dev.mysql.com/doc/refman/8.4/en/grant.html", "s156681123604": "https://dev.mysql.com/doc/refman/8.4/en/connection-options.html", "sdde2cdb741bf": "https://dev.mysql.com/doc/refman/8.4/en/mysql-secure-installation.html", "sa14ee7281ad5": "https://dev.mysql.com/doc/refman/8.4/en/grant-tables.html", "s952465620b96": "https://dev.mysql.com/doc/refman/8.4/en/roles.html", "s43872576f07a": "https://dev.mysql.com/doc/refman/8.4/en/privileges-provided.html", "s06deabf43b11": "https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html", "s2155b7025770": "https://dev.mysql.com/doc/refman/8.4/en/caching-sha2-pluggable-authentication.html", "sb70c5dad340c": "https://dev.mysql.com/doc/refman/8.4/en/native-pluggable-authentication.html", "sd2bc0b9cb818": "https://dev.mysql.com/doc/refman/8.4/en/validate-password-installation.html", "saeede1fe7252": "https://dev.mysql.com/doc/refman/8.4/en/validate-password-transitioning.html", "s84a5e3838b24": "https://dev.mysql.com/doc/refman/8.4/en/validate-password-options-variables.html", "s79d05dce135c": "https://dev.mysql.com/doc/refman/8.4/en/set-variable.html", "sf95e18b15977": "https://dev.mysql.com/doc/refman/8.4/en/password-management.html", "s807a77fa8872": "https://dev.mysql.com/doc/refman/8.4/en/webauthn-pluggable-authentication.html", "s09db2b328d11": "https://dev.mysql.com/doc/refman/8.4/en/load-data-local-security.html", "s58e2db31fec2": "https://dev.mysql.com/doc/refman/8.4/en/option-files.html", "s37e2128d2672": "https://dev.mysql.com/doc/refman/8.4/en/load-data.html", "s2170f2fa1a36": "https://dev.mysql.com/doc/refman/8.4/en/using-encrypted-connections.html", "s3005ba5afda3": "https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html", "sf55a7efb90a1": "https://dev.mysql.com/doc/refman/8.4/en/server-status-variables.html#statvar_Ssl_cipher", "seadf3241ca81": "https://dev.mysql.com/doc/refman/8.4/en/option-file-options.html", "se2551ca67504": "https://dev.mysql.com/doc/refman/8.4/en/show-grants.html", "sd3caefce406e": "https://dev.mysql.com/doc/refman/8.4/en/validate-password.html", "s72c17cae9130": "https://dev.mysql.com/doc/refman/8.4/en/show-databases.html" } }, "mysql8": { "name": "MySQL reference", "basis": "8.0", "sources": { "se7e8af620a73": "https://dev.mysql.com/doc/mysql-secure-deployment-guide/8.0/en/secure-deployment-secure-connections.html", "s5db5340a55f1": "https://dev.mysql.com/doc/refman/8.0/en/x-plugin-options-system-variables.html", "se2dd61ae3974": "https://dev.mysql.com/doc/refman/8.0/en/caching-sha2-pluggable-authentication.html", "scc31c12f1f93": "https://dev.mysql.com/doc/refman/8.0/en/validate-password.html", "sa937d1361fe1": "https://dev.mysql.com/doc/refman/8.0/en/multifactor-authentication.html", "s909bd51d0626": "https://dev.mysql.com/doc/refman/8.0/en/fido-pluggable-authentication.html" } }, "mysql9": { "name": "MySQL reference", "basis": "9.7", "sources": { "sf91257729cc8": "https://dev.mysql.com/doc/refman/9.7/en/x-plugin-options-system-variables.html", "s64801c5527fc": "https://dev.mysql.com/doc/refman/9.7/en/server-system-variables.html#sysvar_bind_address" } }, "mysql26": { "name": "MySQL reference", "basis": "26.7", "sources": { "seedc84a3066a": "https://dev.mysql.com/doc/refman/26.7/en/x-plugin-options-system-variables.html", "sf51fbc6f0ff1": "https://dev.mysql.com/doc/refman/26.7/en/server-system-variables.html#sysvar_bind_address" } }, "package": { "name": "MySQL Docker package", "basis": "mysql-8.4.9", "sources": { "sc4b86a122d43": "https://github.com/mysql/mysql-server/blob/mysql-8.4.9/packaging/rpm-docker/my.cnf.in", "scf03124d3cd4": "https://github.com/mysql/mysql-server/blob/mysql-8.4.9/plugin/x/src/ngs/socket_acceptors_task.cc#L145-L163" } }, "mpackage": { "name": "MariaDB package source", "basis": "mariadb-11.8.9", "sources": { "s6443a34ade90": "https://github.com/MariaDB/server/blob/mariadb-11.8.9/debian/additions/mariadb.conf.d/50-server.cnf#L27", "s4fda00559270": "https://github.com/MariaDB/server/blob/mariadb-11.8.9/sql/mysqld.cc#L2436-L2439", "sbd05d481e581": "https://github.com/MariaDB/server/blob/mariadb-11.8.9/sql/mysqld.cc#L2482-L2493", "s4afb7d28204b": "https://github.com/MariaDB/server/blob/mariadb-11.8.9/sql/mysqld.cc#L2540-L2552", "sb92c4a37622f": "https://github.com/MariaDB/server/blob/mariadb-11.8.9/support-files/rpm/server.cnf#L40" } }, "image": { "name": "MySQL image source", "basis": "2f988f198f35d25b1454fa2504a0e4c348100549", "sources": { "s7554ea4715fb": "https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/Dockerfile.oracle#L61-L89", "s0a0474927ded": "https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L138-L147", "s01efe3052035": "https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L276-L285", "s20a475be2b96": "https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L223-L240", "s2b7b9e5f4c61": "https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L380-L390", "se676b35ed40f": "https://github.com/docker-library/mysql/blob/2f988f198f35d25b1454fa2504a0e4c348100549/8.4/docker-entrypoint.sh#L28-L43" } }, "maria": { "name": "MariaDB documentation", "basis": "unknown", "sources": { "s3b01f389dd01": "https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/ssltls-system-variables", "saedca564ea11": "https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables", "s3c643ec089e3": "https://mariadb.com/docs/server/security/encryption/data-in-transit-encryption/zero-configuration-ssl", "sbfabc23dbaf0": "https://mariadb.com/docs/server/reference/clientserver-protocol/mariadb-protocol-differences-with-mysql", "sa80a8ed71629": "https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/what-to-do-if-mariadb-doesnt-start", "s528037737579": "https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#skip_networking", "s3243686162ad": "https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/alter-user", "s1e467414669c": "https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-ed25519", "s7c876d9fdf39": "https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/create-user", "scff3a7165e15": "https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/grant", "s368aa92bc449": "https://mariadb.com/docs/server/clients-and-utilities/mariadb-client/mariadb-command-line-client", "sad2488929b7b": "https://mariadb.com/docs/server/clients-and-utilities/deployment-tools/mariadb-secure-installation", "s390f967a4b54": "https://mariadb.com/docs/server/reference/system-tables/the-mysql-database-tables/mysql-user-table", "s565ef2474582": "https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-default-role", "s4790add18739": "https://mariadb.com/docs/server/reference/sql-statements/account-management-sql-statements/set-role", "s475fb165ca34": "https://mariadb.com/docs/server/security/user-account-management/roles/system-users-roles-and-privileges", "s441eb805b2fc": "https://mariadb.com/docs/server/reference/plugins/authentication-plugins/authentication-plugin-sha-256", "s80ac7714220c": "https://mariadb.com/docs/server/security/user-account-management/user-password-expiry", "s9e17c3adbc4f": "https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/simple-password-check-plugin", "sf9fb4aa4058e": "https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password-reuse-check-plugin", "sc20911721ab4": "https://mariadb.com/docs/server/reference/plugins/password-validation-plugins/password_reuse_check_interval", "sf1dfed44f8b4": "https://mariadb.com/docs/server/server-management/variables-and-modes/server-system-variables#secure_timestamp", "se84c2903d176": "https://mariadb.com/docs/server/reference/sql-statements/data-manipulation/inserting-loading-data/load-data-into-tables-or-index/load-data-infile", "s141c472ec645": "https://mariadb.com/docs/server/server-management/starting-and-stopping-mariadb/systemd/configuring", "sf8dd7e9be0a6": "https://mariadb.com/docs/server/security/user-account-management/roles/roles_overview", "sc0f88b0f035f": "https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-grants", "s38f56f9ab3a3": "https://mariadb.com/docs/server/reference/system-tables/the-mysql-database-tables/mysql-db-table" } }, "mimage": { "name": "MariaDB image source", "basis": "1a4c8e99f7816c8d97df11eee52098522dc770cf", "sources": { "sccf5a83a982a": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/Dockerfile#L125-L127", "sda3814070b7b": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/docker.cnf", "s737f66e148d0": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L44-L52", "s88c31f952d76": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L269", "sad402b095652": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L287-L288", "s192c8bb1613e": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L394-L409", "s7594bc3fbc3b": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L168-L170", "s2bc107ec67a2": "https://github.com/MariaDB/mariadb-docker/blob/1a4c8e99f7816c8d97df11eee52098522dc770cf/11.8/docker-entrypoint.sh#L25-L40" } } }, "claims": { "classic-bind": {"text": "MySQL bind_address defaults to *; restrict classic TCP 3306 to loopback or deliberate private interfaces.", "components": ["mysql"], "sources": ["mysql:s5735e0088e90"], "status": "REASONED"}, "server-tls": {"text": "Configure CA/certificate/key, require_secure_transport and TLSv1.2/TLSv1.3, then restart; TLSv1.3 needs compatible libraries, MySQL 8.0.16+/OpenSSL 1.1.1+.", "components": ["mysql8", "maria"], "sources": ["mysql8:se7e8af620a73", "maria:s3b01f389dd01"], "status": "REASONED"}, "auto-cert": {"text": "MySQL can generate missing TLS files under documented conditions; generated server certificates lack the deployment hostname and fail VERIFY_IDENTITY.", "components": ["mysql"], "sources": ["mysql:s02467be75571"], "status": "REASONED"}, "socket": {"text": "Global require_secure_transport permits Unix sockets; MySQL account REQUIRE SSL takes precedence and disallows that account's socket connection.", "components": ["mysql"], "sources": ["mysql:s5735e0088e90"], "status": "REASONED"}, "maria-tls": {"text": "MariaDB require_secure_transport dates from 10.5.2; 11.4 automatic TLS needs compatible clients and plugins, so mixed deployments may still need explicit CA settings.", "components": ["maria"], "sources": ["maria:saedca564ea11", "maria:s3c643ec089e3"], "status": "REASONED"}, "x-listener": {"text": "MySQL 8.0/8.4/9.7/26.7 enable X Plugin with separate wildcard 33060; bind it separately or set mysqlx=OFF and restart.", "components": ["mysql8", "mysql", "mysql9", "mysql26"], "sources": ["mysql8:s5db5340a55f1", "mysql:sf426889734dd", "mysql9:sf91257729cc8", "mysql26:seedc84a3066a"], "status": "REASONED"}, "maria-protocol": {"text": "MariaDB has no MySQL X Protocol; omit X Plugin options because unknown options can prevent startup.", "components": ["maria"], "sources": ["maria:sbfabc23dbaf0", "maria:sa80a8ed71629"], "status": "REASONED"}, "image-listeners": {"text": "Pinned MySQL images install Docker packages with no bind setting, retaining wildcard classic/X listeners unless arguments or option files restrict them.", "components": ["image", "package", "mysql", "mysql9", "mysql26"], "sources": ["image:s7554ea4715fb", "package:sc4b86a122d43", "mysql:s5735e0088e90", "mysql:sf426889734dd", "mysql9:s64801c5527fc", "mysql26:sf51fbc6f0ff1"], "status": "REASONED"}, "mimage-listeners": {"text": "Pinned MariaDB 10.6-13.1 Ubuntu images comment out bind-address; UBI leaves it commented, enabling IPv4 wildcard and IPv6 where available.", "components": ["mimage", "mpackage"], "sources": ["mimage:sccf5a83a982a", "mimage:sda3814070b7b", "mpackage:s6443a34ade90", "mpackage:s4fda00559270", "mpackage:sbd05d481e581", "mpackage:s4afb7d28204b", "mpackage:sb92c4a37622f"], "status": "REASONED"}, "skip-networking": {"text": "skip_networking stops TCP listeners, including MySQL X; mounted options and mysqlx=OFF can restrict image exposure.", "components": ["package", "mysql", "maria"], "sources": ["package:scf03124d3cd4", "mysql:sa8e974b8e0d7", "maria:s528037737579"], "status": "REASONED"}, "image-root": {"text": "New-directory initialization defaults root host to %, accepting _FILE; empty files suppress remote root unless MariaDB's preferred variable supplies a value.", "components": ["image", "mimage"], "sources": ["image:s01efe3052035", "mimage:s737f66e148d0", "mimage:s88c31f952d76", "image:s20a475be2b96", "image:se676b35ed40f", "mimage:s192c8bb1613e", "mimage:s2bc107ec67a2"], "status": "REASONED"}, "image-root-grants": {"text": "A nonempty non-localhost root host gets ALL on `*.*` WITH GRANT OPTION; MariaDB also grants PROXY. Prefer localhost and private publication.", "components": ["image", "mimage"], "sources": ["mimage:s192c8bb1613e", "image:s01efe3052035"], "status": "REASONED"}, "image-empty-password": {"text": "Initialization needs a password/hash, random password or empty-password opt-in; any nonempty opt-in, even no, can create passwordless remote root.", "components": ["image", "mimage"], "sources": ["image:s2b7b9e5f4c61", "mimage:s7594bc3fbc3b", "mimage:s192c8bb1613e", "image:s0a0474927ded", "image:s01efe3052035", "mimage:sad402b095652"], "status": "REASONED"}, "account-tls": {"text": "REQUIRE SSL or X509 independently constrains existing accounts; match the source address seen by the server and avoid deprecated MySQL host wildcards.", "components": ["mysql", "maria"], "sources": ["mysql:sac4976c6bd73", "mysql:scb682cfeeeca", "maria:s3243686162ad"], "status": "REASONED"}, "runtime": {"text": "Use separate table-scoped runtime accounts and migration administrators; MySQL caching_sha2_password and MariaDB ed25519 examples require compatible clients.", "components": ["mysql", "maria"], "sources": ["mysql:s4009a8e12cba", "mysql:sd25a34fe4ed0", "maria:s1e467414669c", "maria:s7c876d9fdf39", "maria:scff3a7165e15"], "status": "REASONED"}, "password-input": {"text": "Enter password-bearing SQL through authenticated client input, never shell or -e arguments; use an empty -p prompt for login.", "components": ["mysql", "maria"], "sources": ["mysql:s156681123604", "maria:s368aa92bc449"], "status": "REASONED"}, "maria-ed25519": {"text": "MariaDB 10.4+ supports ed25519 PASSWORD() form; install the plugin and check connectors; ordinary IDENTIFIED BY is not MySQL's default.", "components": ["maria"], "sources": ["maria:s1e467414669c"], "status": "REASONED"}, "cleanup": {"text": "Interactive hardening removes anonymous users, unused remote root and disposable test access; inventory grants and preserve administrative access.", "components": ["mysql", "maria"], "sources": ["mysql:sdde2cdb741bf", "mysql:sa14ee7281ad5", "maria:sad2488929b7b"], "status": "REASONED"}, "maria-root": {"text": "MariaDB 10.4+ normally supports socket-authenticated local root; mysql.user is a compatibility view, not a table to edit directly.", "components": ["maria"], "sources": ["maria:sad2488929b7b", "maria:s390f967a4b54"], "status": "REASONED"}, "roles": {"text": "Role assignment and activation differ; MariaDB default-role syntax uses FOR and one current role; MySQL supports multiple active roles.", "components": ["mysql", "maria"], "sources": ["mysql:s952465620b96", "maria:s565ef2474582", "maria:s4790add18739"], "status": "REASONED"}, "admin-privileges": {"text": "Withhold FILE, SUPER and account/global administration; MySQL 8.4 replaces SET_USER_ID with newer definer privileges; MariaDB SET USER dates from 10.5.", "components": ["mysql", "maria"], "sources": ["mysql:s43872576f07a", "mysql:s06deabf43b11", "maria:scff3a7165e15"], "status": "REASONED"}, "maria-public": {"text": "MariaDB 10.11+ PUBLIC grants apply to all users; inspect and revoke at their granted scope while preserving intended access.", "components": ["maria"], "sources": ["maria:s475fb165ca34", "maria:scff3a7165e15"], "status": "REASONED"}, "mysql-auth": {"text": "MySQL 8.0/8.4 default new accounts to caching_sha2_password; existing plugins persist. Native passwords deprecated in 8.0.34, disabled in 8.4, removed in 9.0.", "components": ["mysql8", "mysql"], "sources": ["mysql8:se2dd61ae3974", "mysql:s2155b7025770", "mysql:sb70c5dad340c"], "status": "REASONED"}, "maria-sha2": {"text": "MariaDB documents server caching_sha2_password in Community 12.1 and Enterprise 11.8; syntax/defaults are not interchangeable with MySQL.", "components": ["maria"], "sources": ["maria:s441eb805b2fc"], "status": "REASONED"}, "validation": {"text": "MySQL 8.0/8.4 validation component uses dotted settings; inspect installation and migrate old plugins before applying MEDIUM and example length values.", "components": ["mysql8", "mysql"], "sources": ["mysql8:scc31c12f1f93", "mysql:sd2bc0b9cb818", "mysql:saeede1fe7252", "mysql:s84a5e3838b24"], "status": "REASONED"}, "mysql-reuse": {"text": "SET PERSIST retains example password history/reuse policy across restart; account-specific settings override globals.", "components": ["mysql"], "sources": ["mysql:s79d05dce135c", "mysql:sf95e18b15977"], "status": "REASONED"}, "password-current": {"text": "PASSWORD REQUIRE CURRENT governs ordinary self-service changes, not privileged resets; external accounts use their identity system's policy.", "components": ["mysql"], "sources": ["mysql:sf95e18b15977"], "status": "REASONED"}, "expiry": {"text": "Use account expiration only with a working rotation process; MariaDB supports expiration too.", "components": ["mysql", "maria"], "sources": ["mysql:sf95e18b15977", "maria:s80ac7714220c"], "status": "REASONED"}, "maria-validation": {"text": "Install MariaDB Simple Password Check and reuse plugins; persist their distinct settings and strict validation so supplied hashes cannot bypass validators.", "components": ["maria"], "sources": ["maria:s9e17c3adbc4f", "maria:sf9fb4aa4058e", "maria:saedca564ea11"], "status": "REASONED"}, "maria-reuse": {"text": "MariaDB reuse checking dates from 10.7; use plugin 2.0. Interval zero retains history indefinitely; the format upgrade invalidated earlier history.", "components": ["maria"], "sources": ["maria:sf9fb4aa4058e", "maria:sc20911721ab4"], "status": "REASONED"}, "timestamp": {"text": "MariaDB secure_timestamp defaults NO, permitting session-clock expiration bypass; restart with YES or replication, choosing replication on replicas.", "components": ["maria"], "sources": ["maria:sf1dfed44f8b4", "maria:s80ac7714220c"], "status": "REASONED"}, "mfa": {"text": "MySQL 8.0.27+ supports up to three factors, with restrictions on later factors; Enterprise FIDO is deprecated from 8.0.35 and removed in 8.4; WebAuthn replaces it from 8.2.", "components": ["mysql8", "mysql"], "sources": ["mysql8:sa937d1361fe1", "mysql8:s909bd51d0626", "mysql:s807a77fa8872", "mysql:s06deabf43b11"], "status": "REASONED"}, "machine-cert": {"text": "Community REQUIRE X509 adds a machine-held certificate to password authentication, not human MFA; protect human entrypoints separately.", "components": ["mysql"], "sources": ["mysql:s4009a8e12cba"], "status": "REASONED"}, "local-loading": {"text": "Disable LOCAL on server and clients separately; MySQL 8.4 server default is OFF, MariaDB's is ON; CLI option groups do not configure application libraries.", "components": ["mysql", "maria"], "sources": ["mysql:s09db2b328d11", "maria:saedca564ea11", "mysql:s58e2db31fec2"], "status": "REASONED"}, "server-files": {"text": "MySQL secure_file_priv=NULL disables governed operations; empty removes restriction. MariaDB uses a directory, not NULL; both require restart.", "components": ["mysql", "maria"], "sources": ["mysql:s5735e0088e90", "maria:saedca564ea11"], "status": "REASONED"}, "file-privilege": {"text": "Withhold FILE from runtime users, but LOAD DATA LOCAL does not require MySQL FILE; test import/restore workflows before disabling LOCAL.", "components": ["mysql", "maria"], "sources": ["mysql:s37e2128d2672", "maria:se84c2903d176"], "status": "REASONED"}, "client-identity": {"text": "MySQL REQUIRED encrypts, VERIFY_CA checks chain, VERIFY_IDENTITY adds hostname; MariaDB uses --ssl --ssl-verify-server-cert with CA trust.", "components": ["mysql", "maria"], "sources": ["mysql:s2170f2fa1a36", "maria:s368aa92bc449"], "status": "REASONED"}, "client-cert": {"text": "X509 accounts also need the client certificate and protected key; CA trust alone is insufficient.", "components": ["mysql", "maria"], "sources": ["mysql:s2170f2fa1a36", "maria:s368aa92bc449"], "status": "REASONED"}, "verify-transport": {"text": "Matched TLS success identifies CURRENT_USER and a cipher; plaintext must succeed without restrictions and fail after enforcement. Error 3159 is specific; 1045 alone is not.", "components": ["mysql", "maria"], "sources": ["mysql:s5735e0088e90", "mysql:s3005ba5afda3", "mysql:sf55a7efb90a1", "maria:s368aa92bc449"], "status": "REASONED", "verify": [1]}, "verify-server-cert": {"text": "Wrong trusted identity or unrelated CA must fail verification while the correct CA/hostname succeeds against the same endpoint.", "components": ["mysql"], "sources": ["mysql:s2170f2fa1a36"], "status": "REASONED", "verify": [1]}, "verify-client-cert": {"text": "With option-file inheritance disabled, SSL-only permits certificate-free login; X509 rejects it while the certificate-bearing control succeeds; test subject/issuer separately.", "components": ["mysql", "maria"], "sources": ["mysql:s4009a8e12cba", "mysql:seadf3241ca81", "maria:s368aa92bc449"], "status": "REASONED", "verify": [2]}, "verify-listeners": {"text": "Inspect all classic/X listeners and nondefault ports; fixed interfaces retain permitted-client success. A stopped service is not proof of restriction.", "components": ["mysql"], "sources": ["mysql:s5735e0088e90", "mysql:sf426889734dd"], "status": "REASONED", "verify": [3]}, "socket-activation": {"text": "MariaDB systemd socket activation can bypass bind_address selection; configure the socket unit or disable activation and inspect listeners.", "components": ["maria"], "sources": ["maria:s141c472ec645"], "status": "REASONED"}, "verify-roles": {"text": "MySQL SHOW GRANTS omits mandatory roles and unexpanded role privileges; combine mandatory/explicit roles with USING and test SET ROLE ALL.", "components": ["mysql"], "sources": ["mysql:s952465620b96", "mysql:se2551ca67504"], "status": "REASONED"}, "verify-maria-roles": {"text": "MariaDB requires recursive role inspection and each role activated separately; PUBLIC applies independently, and USAGE ON `*.*` grants no data privileges.", "components": ["maria"], "sources": ["maria:sf8dd7e9be0a6", "maria:sc0f88b0f035f", "maria:s4790add18739", "maria:s475fb165ca34"], "status": "REASONED"}, "verify-grants": {"text": "Existing orders read stays allowed while private_probe changes from success under broad grants to privilege denial; test each available role.", "components": ["mysql", "maria"], "sources": ["mysql:sd25a34fe4ed0", "maria:scff3a7165e15"], "status": "REASONED"}, "verify-cleanup": {"text": "Before/after inventories must remove unintended anonymous, remote-root and test grants while preserving administrative and runtime positive controls.", "components": ["mysql", "maria"], "sources": ["mysql:sdde2cdb741bf", "mysql:sa14ee7281ad5", "maria:sad2488929b7b", "maria:s38f56f9ab3a3"], "status": "REASONED"}, "verify-local": {"text": "Disable LOCAL separately at client and server, keeping the other side enabled; a readable fixture loads in the exposed state, is refused fixed, and ordinary insertion succeeds.", "components": ["mysql", "maria"], "sources": ["mysql:s09db2b328d11", "mysql:s37e2128d2672", "maria:se84c2903d176"], "status": "REASONED"}, "verify-server-files": {"text": "MySQL NULL must refuse a previously successful readable-file load; MariaDB must deny an outside-directory fixture while permitting the allowed directory.", "components": ["mysql", "maria"], "sources": ["mysql:s5735e0088e90", "maria:saedca564ea11"], "status": "REASONED"}, "verify-policy": {"text": "Read global policy and account overrides, then repeat after restart; MariaDB plugins must be active, reuse version 2.0, and secure_timestamp REPLICATION or deliberate YES.", "components": ["mysql", "maria"], "sources": ["mysql:sf95e18b15977", "mysql:sa14ee7281ad5", "maria:s9e17c3adbc4f", "maria:sf9fb4aa4058e", "maria:sf1dfed44f8b4"], "status": "REASONED"}, "verify-weak": {"text": "Weak password accepted without validation must be rejected with validation while a fresh compliant password succeeds under the same auth plugin.", "components": ["mysql", "maria"], "sources": ["mysql:sd3caefce406e", "maria:s9e17c3adbc4f", "maria:s1e467414669c"], "status": "REASONED"}, "verify-reuse": {"text": "After two compliant passwords, reuse of the first must fail only with recorded protection; a third fresh password must succeed.", "components": ["mysql", "maria"], "sources": ["mysql:sf95e18b15977", "maria:sf9fb4aa4058e"], "status": "REASONED"}, "visibility": {"text": "SHOW DATABASES visibility follows privileges and can broaden through global grants; hiding names is not an access boundary.", "components": ["mysql"], "sources": ["mysql:s72c17cae9130"], "status": "REASONED"}, "local-guards": {"text": "Historical invalid-input and valid-dispatch checks tested guards only, in an earlier form whose guard refusals exited with status 1; the current form was not re-run, and no database behavior was simulated.", "components": ["mysql", "maria"], "sources": ["mysql:s156681123604", "maria:s368aa92bc449"], "status": "DEMONSTRATED", "evidence": "Each guard also accepted one valid dispatch case. These tests exercised guards only; no database behavior was simulated."}, "local-ini": {"text": "Historical in-memory INI parsing checked structure only; native configuration acceptance and restart behavior remain unobserved.", "components": ["mysql", "maria"], "sources": ["mysql:s58e2db31fec2", "maria:sa80a8ed71629"], "status": "DEMONSTRATED", "evidence": "All five INI fragments parsed with an in-memory INI parser. This checks basic structure, not vendor option acceptance."} } } ```` ====================================================================== ==> mongodb.md ====================================================================== # MongoDB: TLS and authorization MongoDB is a database that stores records as flexible, JSON-like documents. This guide helps your AI assistant configure it securely, including authorization with collection-scoped application roles, a private bind, TLS for clients and cluster members, and, on MongoDB Enterprise only, auditing, diagnostic-log redaction and native storage encryption. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mongodb.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MongoDB bind source r8.0.32 and MongoDB documentation 8.0; 1 further source is pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 81 claims: 1 demonstrated by a check that ran and 80 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. MongoDB's history of mass data leaks comes from 2 settings: binding to all interfaces and running with authorization off. Fix both before anything else, then add TLS. Use a supported MongoDB release and its current security patches. MongoDB 4.2 reached end of life on April 30, 2023; it is not a deployment recommendation. Compatibility note: these examples use `tls` options; older configurations used `ssl` names. Check the [vendor lifecycle schedule](https://www.mongodb.com/legal/support-policy/lifecycles) before deploying. This guide covers self-managed Community and Enterprise deployments. Tier labels below describe deployment priorities, not MongoDB subscription tiers. ## 1. Enable authorization and create the admin user In `/etc/mongod.conf`, merge these settings into the existing mappings: ```yaml net: port: 27017 bindIp: 127.0.0.1 # widen deliberately, e.g. 127.0.0.1,10.0.0.5 security: authorization: enabled ``` The official Docker images (7.0, 8.0 and 8.3 at the pinned commit) do not start from those settings. On Linux, for the `mongod` command, their default, the entrypoint adds `--bind_ip_all` whenever neither the arguments nor a configuration file passed with `--config` sets a bind address (`bindIp`, or `bindIpAll: true`; the entrypoint reads only a file named by `--config`, not by the short form `-f`, so a bind set only in a `-f` file does not stop it). The same Linux entrypoint, for the `mongod` command, adds `--auth` only when both `MONGO_INITDB_ROOT_USERNAME` and `MONGO_INITDB_ROOT_PASSWORD` (or their `_FILE` forms) are set; setting only one makes the entrypoint exit. A Linux container started with neither credential, and with no bind, authorization or internal-authentication (`keyFile`, `clusterAuthMode`) setting in its arguments or in a configuration file passed with `--config` or `-f`, therefore listens on every IPv4 address (and on IPv6 as well only if `net.ipv6` is true, by `--ipv6` or in a configuration file) with authorization off. The Windows images have no such entrypoint: their command is `mongod --bind_ip_all`, and the credential variables do not turn authorization on. `mongod` reads no configuration file unless one is passed with `--config` or `-f`, so mounting this file is not enough: supply both credentials on Linux, or pass the configuration with `--config` (its loopback `bindIp` then refuses connections through a published port, so add the container's own address if clients connect that way), and publish only to host loopback or a private network. Restart the package-managed service, then use the localhost exception to create the first administrator. Connect from the server itself with `mongosh`, keeping the listener on loopback until TLS is active. The exception applies to an installation without existing users or roles; it is not an administrative recovery mechanism. See [enable access control](https://www.mongodb.com/docs/manual/tutorial/enable-authentication/). ```javascript db.getSiblingDB("admin").createUser({ user: "admin", pwd: passwordPrompt(), mechanisms: ["SCRAM-SHA-256"], roles: [{ role: "userAdminAnyDatabase", db: "admin" }] }); ``` Creating that first user ends the localhost exception but does not authenticate the current shell. Authenticate before creating anything else: `db.getSiblingDB("admin").auth("admin", passwordPrompt())`. Complete section 2 before running the application-user commands below. Reconnect as the administrator over TLS, using section 3's SCRAM connection form with username `admin` and authentication database `admin`. While binding remains local, use a certificate hostname that resolves to loopback on the server. `passwordPrompt()` keeps the password out of the command text but does not encrypt the data sent by `createUser()`; TLS protects that transport. See [user creation and its transport warning](https://www.mongodb.com/docs/manual/reference/method/db.createuser/). ### Collection-scoped application roles Tier 1. Community and Enterprise. A compromised application can read or modify unrelated data when its account has broader privileges than its workload requires. Create a separate identity per application, per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). The database containing a user is its authentication database; its assigned roles determine access. Use `read` for database-wide readers. Use `readWrite` only when its collection and index management permissions are appropriate. The [vendor's role capability table](https://www.mongodb.com/docs/compass/connect/required-access/) documents those broader permissions. For narrower access, run this in an authenticated administrative `mongosh` session over TLS: ```javascript db.getSiblingDB("REPLACE_WITH_APP_DB").createRole({ role: "REPLACE_WITH_APP_ROLE", privileges: [{ resource: { db: "REPLACE_WITH_APP_DB", collection: "REPLACE_WITH_COLLECTION" }, actions: ["find", "insert", "update"] }], roles: [] }); db.getSiblingDB("REPLACE_WITH_APP_DB").createUser({ user: "REPLACE_WITH_APP_USER", pwd: passwordPrompt(), mechanisms: ["SCRAM-SHA-256"], roles: [{ role: "REPLACE_WITH_APP_ROLE", db: "REPLACE_WITH_APP_DB" }] }); ``` This role grants no document deletion, collection dropping, or index management. It does permit creating the named non-capped collection: MongoDB accepts `insert` privilege on that collection for this operation. Provision collections and indexes through a separate deployment identity, and add privileges only for operations the application needs. See [privilege actions](https://www.mongodb.com/docs/manual/reference/privilege-actions/) and [collection creation privileges](https://www.mongodb.com/docs/manual/reference/command/create/). Both `privileges` and `roles` are required in `createRole()`. A role outside `admin` can grant privileges only within its own database and inherit only roles from that database. See [createRole](https://www.mongodb.com/docs/manual/reference/method/db.createrole/). At the time of writing, omitting a user's `mechanisms` normally creates both SCRAM-SHA-1 and SCRAM-SHA-256 credentials. Omitting the connection's `authMechanism` permits negotiation to fall back from SCRAM-SHA-256 to SCRAM-SHA-1. The explicit user and client settings here restrict this application to SCRAM-SHA-256; confirm driver compatibility before restricting existing users. See [credential creation](https://www.mongodb.com/docs/manual/reference/method/db.createuser/) and [connection negotiation](https://www.mongodb.com/docs/manual/reference/connection-string-options/). ### Restrict application authentication sources Tier 1 where remote connections are required. Community and Enterprise. A stolen credential remains usable from any reachable source unless authentication also checks the connection's origin. After creating the application user, apply source restrictions from the administrative TLS session. Substitute the application's actual source network and every database listener address through which it must authenticate: ```javascript db.getSiblingDB("REPLACE_WITH_APP_DB").updateUser( "REPLACE_WITH_APP_USER", { authenticationRestrictions: [{ clientSource: ["10.20.10.0/24"], serverAddress: ["10.20.0.11", "10.20.0.12", "10.20.0.13"] }] } ); ``` `clientSource` matches the client address MongoDB sees. `serverAddress` matches the local address that accepted the connection. Account for proxies, NAT, routers, failover, and alternate paths. These restrictions govern authentication; they do not prevent TCP connections from reaching the listener. Keep an administrative recovery path while changing restrictions. Incompatible restrictions inherited through multiple roles can prevent a user from authenticating. See [updateUser authentication restrictions](https://www.mongodb.com/docs/manual/reference/method/db.updateuser/). MFA: the wire protocol has no TOTP dialogue in Community edition; x.509 client-certificate authentication adds a possession factor held by the connecting machine for direct connections, stronger than a password alone but not MFA for a person. Put human paths to the host, including SSH and admin UIs, behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Community supports [SCRAM and x.509 authentication](https://www.mongodb.com/docs/manual/administration/security-checklist/). ## 2. Enable TLS Get a certificate using [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). Its SAN must cover the hostname clients use. For initial installation, run the following as root with an unused destination in an existing, protected directory. Substitute the operating system account and group running `mongod`: ```bash ( set -eC umask 077 cat server.crt server.key > /etc/ssl/mongodb/server.pem chown REPLACE_WITH_MONGOD_OS_USER:REPLACE_WITH_MONGOD_OS_GROUP /etc/ssl/mongodb/server.pem chmod 600 /etc/ssl/mongodb/server.pem ) ``` The creation mask applies before the private key is written. `set -C` refuses to overwrite an existing regular file. This is an initial-install block, not a certificate-rotation procedure. If the service cannot read the PEM, fix ownership and directory access; do not make the private key world-readable. See the Bash documentation for [umask](https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Builtins.html) and [redirection](https://www.gnu.org/software/bash/manual/bash.html). Merge `tls` into the existing `net` mapping, preserving `port`, private binding, and other settings. Do not append a second top-level `net` key: ```yaml net: tls: mode: requireTLS certificateKeyFile: /etc/ssl/mongodb/server.pem CAFile: /etc/ssl/mongodb/ca.crt # SCRAM-only clients work; see section 3 before requiring certificates. allowConnectionsWithoutCertificates: true ``` With this CA configuration, clients must present certificates unless `allowConnectionsWithoutCertificates` is `true`. Any certificate presented is still validated. This allows SCRAM-only clients while encrypting their connections. See [TLS certificate validation](https://www.mongodb.com/docs/manual/tutorial/configure-ssl/). `allowTLS` and `preferTLS` accept both plaintext and TLS connections. Use mixed modes only during a controlled transition; finish with `requireTLS`. See [TLS modes](https://www.mongodb.com/docs/manual/reference/configuration-options/#net.tls.mode). Restart the package-managed service after changing its configuration, confirm that startup succeeded, and reconnect over TLS before testing or creating application credentials. Widen binding only deliberately, to required private interfaces, with firewall rules limiting access. Configuration-file changes must be applied to the service actually running MongoDB; see [configuration-file startup](https://www.mongodb.com/docs/manual/reference/configuration-options/). ### Authenticate replica-set and sharded-cluster members Tier 1 for distributed deployments. Community and Enterprise. Skip this subsection for a standalone server. Application authentication alone does not establish which processes may act as cluster members. Configure internal authentication on every replica-set member and every participating `mongod` and `mongos`, including config servers and routers. Membership credentials do not replace TLS. MongoDB recommends x.509 membership authentication for production. Merge these settings into the existing `security` and `net.tls` mappings: ```yaml security: clusterAuthMode: x509 transitionToAuth: false net: tls: clusterFile: /etc/ssl/mongodb/member.pem ``` Retain `requireTLS`, `certificateKeyFile`, and `CAFile`. `clusterFile` contains this member's certificate and private key for outgoing internal authentication. Keep its key readable only by the service account. Under the default membership rules, member certificates need matching `O`, `OU`, and `DC` attributes, with at least one populated, a common issuing CA, and SANs matching the hostnames peers use. If EKU is present, the server certificate needs `serverAuth` and the separate membership certificate needs `clientAuth`. If one certificate serves both purposes, its EKU must include both. Application certificates must use a different membership attribute combination. See [internal authentication](https://www.mongodb.com/docs/manual/core/security-internal-authentication/) and [x.509 membership configuration](https://www.mongodb.com/docs/manual/tutorial/configure-x509-member-authentication/). Where keyfile authentication is used, generate the shared key once. Run this as root with an unused destination: ```bash ( set -eC umask 077 openssl rand -base64 756 > /etc/mongodb-keyfile chown REPLACE_WITH_MONGOD_OS_USER:REPLACE_WITH_MONGOD_OS_GROUP /etc/mongodb-keyfile chmod 400 /etc/mongodb-keyfile ) ``` Distribute that same file securely to every member and router, setting ownership for each service account. Do not generate independent keys on different members. Use this configuration instead of the x.509 membership fragment, merging it into the existing `security` mapping: ```yaml security: keyFile: /etc/mongodb-keyfile clusterAuthMode: keyFile transitionToAuth: false ``` Retain TLS. Unix keyfiles must have no group or world permissions and must be readable by the service account. Keyfiles can contain multiple keys for rotation, but all members must share a common key. See [keyfile requirements and setup](https://www.mongodb.com/docs/manual/tutorial/enforce-keyfile-access-control-in-existing-replica-set/). These are final configurations, not rolling migration procedures. Follow the vendor's transition procedure for an existing cluster. `transitionToAuth: true` permits unauthenticated operations and does not enforce user access controls. Keep it `false` in the final state. See [transitionToAuth](https://www.mongodb.com/docs/manual/reference/configuration-options/#security.transitionToAuth). Keep `security.authorization: enabled` on `mongod`. That particular setting is not available on `mongos`; internal authentication enables client access control there. See the [mongod](https://www.mongodb.com/docs/manual/reference/program/mongod/) and [mongos](https://www.mongodb.com/docs/manual/reference/program/mongos/) references. ### Restrict member authentication sources Tier 1 where remote connections are required. Community and Enterprise. For this example's three-member cluster, merge the following into `security` on the participating processes: ```yaml security: clusterIpSourceAllowlist: - 10.20.0.11/32 - 10.20.0.12/32 - 10.20.0.13/32 ``` Substitute actual source addresses and include every required member and router, including addresses seen after NAT. The setting was introduced in MongoDB 5.0 and has no effect without authentication. It restricts internal authentication, not application accounts or TCP reachability. See [clusterIpSourceAllowlist](https://www.mongodb.com/docs/manual/reference/configuration-options/#security.clusterIpSourceAllowlist). ## 3. Client side Connect as the application identity, using the database in which that user was created: ```bash mongosh --host db.example.com --port 27017 --tls \ --tlsCAFile /path/ca.crt \ --username REPLACE_WITH_APP_USER \ --authenticationDatabase REPLACE_WITH_APP_DB \ --authenticationMechanism SCRAM-SHA-256 --password ``` The final `--password` prompts without putting the password in argv. See [mongosh authentication options](https://www.mongodb.com/docs/mongodb-shell/reference/options/). Enable TLS in your driver and configure its CA trust. Many drivers accept `tls=true` and `tlsCAFile` in the connection string, but `tlsCAFile` is not supported by every driver; some require their own TLS/CA option. Keep certificate and hostname validation enabled. Do not ship `tlsAllowInvalidCertificates`; install the CA instead, per [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). See [connection-string TLS options](https://www.mongodb.com/docs/manual/reference/connection-string-options/). ### Alternative: authenticate the application with x.509 Tier 1 for deployments adopting certificate authentication; otherwise Tier 2. Community and Enterprise. Requiring a trusted certificate without defining its database identity and privileges can leave authentication incomplete or accidentally confer cluster-member privileges. TLS certificate validation and MongoDB x.509 authentication are separate steps. Register the certificate subject in `$external`, then authenticate with `MONGODB-X509`. Obtain the RFC2253 subject: ```bash openssl x509 -in /path/client.pem -inform PEM -subject -nameopt RFC2253 ``` Copy only the subject value, excluding the `subject=` prefix and the certificate output. Run this as the user administrator over TLS: ```javascript db.getSiblingDB("$external").createUser({ user: "REPLACE_WITH_EXACT_RFC2253_SUBJECT", roles: [{ role: "REPLACE_WITH_APP_ROLE", db: "REPLACE_WITH_APP_DB" }] }); ``` Connect using that certificate and its private key: ```bash mongosh --host db.example.com --port 27017 --tls \ --tlsCAFile /path/ca.crt \ --tlsCertificateKeyFile /path/client.pem \ --authenticationDatabase "\$external" \ --authenticationMechanism MONGODB-X509 ``` The escaped dollar sign passes the literal database name `$external`. Without an explicit username, `mongosh` uses the certificate subject. See the [client x.509 procedure](https://www.mongodb.com/docs/manual/tutorial/configure-x509-client-authentication/). Client certificates must be current, meet the documented CA requirements, and contain `digitalSignature` key usage and `clientAuth` EKU. Keep their subjects and membership attributes separate from both server and member certificates. Matching the cluster's membership identity can confer internal privileges. See [client certificate requirements and membership separation](https://www.mongodb.com/docs/manual/core/security-x.509/). For remote application access, also apply section 1's source restrictions to this `$external` user: use `$external` as the database and the exact RFC2253 subject as the username in `updateUser()`. Restrictions on the SCRAM identity do not transfer automatically to this separate identity. Once all connecting tools have suitable certificates, merge this change into the existing `net.tls` mapping and restart the service: ```yaml net: tls: allowConnectionsWithoutCertificates: false ``` Keep authorization enabled. This switch requires a certificate during TLS establishment; it does not select `MONGODB-X509` or assign database roles. SCRAM clients must also present a valid client certificate after this change. At the time of writing, MongoDB's TLS documentation requires `allowConnectionsWithoutCertificates: true` for `mongot`. Check that topology before enforcing the certificate requirement. See [TLS certificate validation behavior](https://www.mongodb.com/docs/manual/tutorial/configure-ssl/). Keep certificate issuance and human MFA guidance in [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md), and [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 4. Verify REASONED: service-comparison provenance, per the guide's cited MongoDB security checklist and Community authentication support; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local syntax and stub checks do not establish the exposed and fixed service outcomes described in the subsections. All existing tool-availability and earlier local-test statements in this Verify section are the original author's historical account: This guide's service behavior is **REASONED, not demonstrated**. The authoring environment has no `mongod`, `mongosh`, Docker, or Podman, and no available authorized deployment for exposed-versus-fixed tests. The expected outcomes are REASONED from the cited MongoDB documentation. Local checks did run: Bash parsing, ShellCheck, JavaScript syntax parsing, duplicate-key YAML parsing, and merges for both membership alternatives. Shell-only argument tracing checked probe dispatch; it did not simulate or verify MongoDB behavior. Placeholder tests rejected unchanged tokens, embedded `REPLACE_WITH_` values, angle brackets, empty values, example hosts, shortened assignments, an omitted assignment with ordinary inherited arguments, and a wrong argument count under a modified `IFS`. The JavaScript probe guards also rejected unchanged placeholders before database access. ### Listener exposure (REASONED: listener inventory and interpretation; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Bash parsing and ShellCheck 0.11.0 exited 0 without diagnostics; a stubbed ss returned fixture listeners. Those runs and the attempt recorded below covered an earlier form of the block, a bare ss -tlnp whose exit status was its own, and the repository gate suite's bash -n and ShellCheck 0.11.0 checks covered a later form, which exited 3 when ss failed and 0 otherwise. The current form, which exits 2 when ss is not installed, 3 when ss fails and 0 otherwise, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with ss passing, failing and missing, which exited 0, 3 and 2. Expected comparison: unintended wildcard or public binds are exposed; selected private or loopback binds are the fixed expectation. These deployment outcomes remain unobserved in this run, per the guide's cited MongoDB security checklist and Community authentication support.) **REASONED:** no MongoDB service is available. The local command was attempted, but the sandbox refused its netlink socket with `Operation not permitted`; that output establishes nothing about listeners. Run on every database and router host: ```bash # Listener inventory; no MongoDB service or permitted netlink socket. Expected binds and network-hardening source follow. ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tlnp; then echo "unclassified observation: read every listener above; an unintended wildcard or public bind is the exposed state"; exit 0 else echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; exit 3 fi ) ``` Read every relevant listener. The exposed state includes an unintended wildcard or public listener. The fixed state listens only on loopback or deliberately selected private addresses. Check the actual configured ports, including any non-default ports, and inspect firewall rules separately. A private listener alone does not prove remote firewall behavior. See [network hardening](https://www.mongodb.com/docs/manual/administration/security-checklist/). ### Guarded connection probes (REASONED: all five connection modes, options, substitution and paste guards; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Bash parsing and ShellCheck 0.11.0 exited 0 without diagnostics; stub runs printed STUB_MONGOSH with the expected arguments for all five modes, while invalid values, omitted or shortened assignments and an unknown mode exited 1 with guard messages. These runs covered an earlier form of the block, whose guard refusals exited with status 1; on the current form, whose guard refusals exit with status 2 and whose modes exit 1 for a completed plaintext ping, 3 for a failed control or session and otherwise 0, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the stub runs were not repeated. Expected comparison: plaintext acceptance, anonymous access and missing-certificate acceptance must change as the corresponding controls are enabled; authenticated TLS remains the positive control. These deployment outcomes remain unobserved in this run, per the guide's cited mongosh connection, TLS, authentication, and password-prompt options; Bash redirection and noclobber behavior.) Use the whole Bash block below. Substitute inside the single quotes on its `set --` lines. Supply all six values even when a mode does not use every value. Use the real hostname covered by the server certificate and a valid application client PEM, never a cluster-member PEM. The modes are `plaintext`, `tls`, `scram`, `x509`, and `missing-certificate`. `tls` presents a client certificate but omits MongoDB authentication. `--norc` prevents a local startup script from authenticating the shell unexpectedly. The guard assumes normal Bash builtins. A literal apostrophe requires proper shell escaping rather than direct substitution inside the quotes. Paste the whole block: a fragment starting after its guards cannot be protected, and inherited arguments containing the exact marker and count are indistinguishable from the intended assignment. **REASONED for every connection mode:** no `mongod`, `mongosh`, Docker, or Podman is available. Expected exposed and fixed outcomes follow each probe below. The options are documented in the [mongosh reference](https://www.mongodb.com/docs/mongodb-shell/reference/options/). ```bash # Connection modes; no mongod, mongosh, Docker, Podman or authorized deployment. Expected outcomes and MongoDB sources follow. ( set -- PASTE_WHOLE_BLOCK 'scram' 'REPLACE_WITH_DB_HOST' \ 'REPLACE_WITH_CA_FILE' 'REPLACE_WITH_CLIENT_PEM' \ 'REPLACE_WITH_APP_USER' 'REPLACE_WITH_APP_DB' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including set --; not probing"; exit 2; } shift [ "$#" -eq 6 ] || { echo "the set -- line needs exactly 6 values; not probing"; exit 2; } check_values() { while [ "$#" -gt 0 ]; do case "$1" in ""|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|*example.net*|*example.org*|*203.0.113.*) echo "substitute all placeholders inside the quotes; not probing"; return 1 ;; esac shift done } check_values "$@" || exit 2 set -- "$@" 0 # the 7th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 case "$1" in plaintext|missing-certificate) # positive control first: a TLS ping that presents the client certificate if mongosh --norc --host "$2" --port 27017 --tls \ --tlsCAFile "$3" --tlsCertificateKeyFile "$4" \ --eval 'db.runCommand({ping: 1})'; then echo "positive control: the TLS ping presenting the client certificate completed" else echo "mongosh exit $?: the TLS ping presenting the client certificate, this mode's positive control, did not complete; inconclusive" set -- "$1" "$2" "$3" "$4" "$5" "$6" 3 fi ;; esac case "$1" in plaintext) if mongosh --norc --host "$2" --port 27017 \ --eval 'db.runCommand({ping: 1})'; then echo "FINDING: the plaintext ping completed, so this endpoint accepts connections without TLS" set -- "$1" "$2" "$3" "$4" "$5" "$6" 1 else echo "mongosh exit $?: the plaintext ping did not complete; with the TLS control above completed, that is the requireTLS result (read the error above to confirm a TLS rejection), but that control is an unauthenticated ping, so the guide's authenticated scram control still applies" fi ;; tls) if mongosh --norc --host "$2" --port 27017 --tls \ --tlsCAFile "$3" --tlsCertificateKeyFile "$4"; then echo "the tls session ended; what it showed is not classified, so judge it yourself" else echo "mongosh exit $?: the tls session did not connect or ended with an error; inconclusive" set -- "$1" "$2" "$3" "$4" "$5" "$6" 3 fi ;; scram) if mongosh --norc --host "$2" --port 27017 --tls \ --tlsCAFile "$3" --tlsCertificateKeyFile "$4" \ --username "$5" --authenticationDatabase "$6" \ --authenticationMechanism SCRAM-SHA-256 --password; then echo "the scram session ended; what it showed is not classified, so judge it yourself" else echo "mongosh exit $?: the scram session did not connect or ended with an error; inconclusive (an authentication failure is the expected result only from an excluded source or listener)" set -- "$1" "$2" "$3" "$4" "$5" "$6" 3 fi ;; x509) if mongosh --norc --host "$2" --port 27017 --tls \ --tlsCAFile "$3" --tlsCertificateKeyFile "$4" \ --authenticationDatabase "\$external" \ --authenticationMechanism MONGODB-X509; then echo "the x509 session ended; what it showed is not classified, so judge it yourself" else echo "mongosh exit $?: the x509 session did not connect or ended with an error; inconclusive (an authentication failure is the expected result only for an unregistered subject)" set -- "$1" "$2" "$3" "$4" "$5" "$6" 3 fi ;; missing-certificate) if mongosh --norc --host "$2" --port 27017 --tls \ --tlsCAFile "$3" --eval 'db.runCommand({ping: 1})'; then echo "the TLS ping without a client certificate completed: expected with allowConnectionsWithoutCertificates true and the exposed state once it is false, so judge it against your setting" else echo "mongosh exit $?: the TLS ping without a client certificate did not complete; with the TLS control above completed, that is the result the setting false requires (read the error above to confirm a certificate rejection)" fi ;; *) echo "unknown mode; not probing"; exit 2 ;; esac echo "block exit $7; any session's contents, any error text and, for missing-certificate, whether a completed ping matches your setting are left for you to judge" exit "$7" ) ``` ### Plaintext versus TLS (REASONED: plaintext and authenticated TLS comparison; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Bash parsing and ShellCheck 0.11.0 exited 0 without diagnostics; plaintext and scram dispatch printed STUB_MONGOSH with their respective arguments. Those runs of the connection block covered an earlier form, whose guard refusals exited with status 1 and whose plaintext mode had no in-run TLS control, and the stub dispatch was not repeated. On the current form, whose guard refusals exit with status 2 and whose plaintext mode exits 1 when its ping completes and 0 when it is refused after the TLS control completed, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. Expected comparison: plaintext ping succeeds when allowed and fails under requireTLS while authenticated TLS succeeds. These deployment outcomes remain unobserved in this run, per the guide's cited Configuration-file startup and network, TLS, and security options; Connectivity probe using ping.) **REASONED:** no `mongod`, `mongosh`, Docker, or Podman is available. Run `scram` mode from a permitted application source as the TLS positive control, then repeat the whole block with mode `plaintext` against the same endpoint. With plaintext accepted, the plaintext `ping` succeeds. With `requireTLS`, it must not complete over plaintext while the TLS control succeeds. A refusal or timeout alone is inconclusive; inspect the failure and server evidence to distinguish TLS rejection from an unreachable host. `ping` tests connectivity, not authorization. See [TLS modes](https://www.mongodb.com/docs/manual/reference/configuration-options/#net.tls.mode) and [ping](https://www.mongodb.com/docs/manual/reference/command/ping/). ### Unauthenticated collection access (REASONED: anonymous collection access, session identity and misleading results; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Node.js syntax parsing exited 0 without diagnostics; the unchanged JavaScript placeholders threw Substitute database and collection; not probing before database access. Expected comparison: an anonymous read succeeds without authorization and fails with Unauthorized code 13 when authorization is enabled; an authenticated read is the positive control. These deployment outcomes remain unobserved in this run, per the guide's cited Authenticated identities reported by connectionStatus; Collection read command; Privilege actions for collection operations; MongoDB authorization and authentication error codes; listDatabases privilege-dependent behavior.) **REASONED:** no `mongod`, `mongosh`, Docker, or Podman is available. Open a fresh session using `tls` mode, then substitute and run: Unauthenticated collection access; no MongoDB deployment. Expected authorization outcomes and MongoDB sources follow. ```javascript (() => { const appDB = "REPLACE_WITH_APP_DB"; const collection = "REPLACE_WITH_COLLECTION"; if ([appDB, collection].some(v => !v || /REPLACE_WITH_|[<>]/.test(v))) { throw new Error("Substitute database and collection; not probing"); } const status = db.runCommand({connectionStatus: 1}); if (status.ok !== 1 || status.authInfo.authenticatedUsers.length !== 0) { throw new Error("Use a fresh TLS session without MongoDB authentication"); } printjson(db.getSiblingDB(appDB).runCommand({ find: collection, filter: {}, limit: 1, singleBatch: true })); })(); ``` Use an existing application collection. With authorization disabled, the read succeeds. With authorization enabled, it must fail with `Unauthorized`, code `13`. Pair this with the authenticated collection read below. Once certificates are mandatory, this probe must still present a valid application client certificate while omitting MongoDB authentication. Otherwise it only tests the TLS certificate requirement. `connectionStatus` confirms that the session has no authenticated MongoDB identity. See [connectionStatus](https://www.mongodb.com/docs/manual/reference/command/connectionstatus/), [find privileges](https://www.mongodb.com/docs/manual/reference/privilege-actions/), and [error codes](https://www.mongodb.com/docs/manual/reference/error-codes/). Do not substitute `listDatabases` as proof of collection access. Its results depend on privileges and `authorizedDatabases`. A timeout, closed shell, empty output, or unrelated error is not an authorization denial. See [listDatabases behavior](https://www.mongodb.com/docs/manual/reference/command/listdatabases/). ### Application role: allow its collection, deny unrelated access and deletion (REASONED: collection-role allows, denials, result checks and cleanup; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Node.js syntax parsing exited 0 without diagnostics; unchanged placeholders threw Substitute all probe values; not probing before database access. Expected comparison: the fixed role permits own-collection insert, update and read while denying unrelated reads and inserts and own-collection deletion with code 13; database-wide readWrite defeats those denial checks. These deployment outcomes remain unobserved in this run, per the guide's cited Collection read command; Collection insert command and write results; Collection update command and write results; Collection delete command; Privilege actions for collection operations; Built-in role capabilities for database, collection, and index operations; MongoDB authorization and authentication error codes.) **REASONED:** no `mongod`, `mongosh`, Docker, or Podman is available. Use a disposable test deployment with two existing ordinary collections and the application's actual role configuration. The probe document must satisfy the collection's validation rules. Choose a fresh probe ID for each run. Use `scram` mode as the application identity, then run: Collection-role allows and denials; no disposable MongoDB deployment. Expected results and command sources follow. ```javascript (() => { const appDB = "REPLACE_WITH_APP_DB"; const own = "REPLACE_WITH_COLLECTION"; const other = "REPLACE_WITH_OTHER_COLLECTION"; const probeId = "REPLACE_WITH_UNIQUE_PROBE_ID"; if ([appDB, own, other, probeId].some(v => !v || /REPLACE_WITH_|[<>]/.test(v))) { throw new Error("Substitute all probe values; not probing"); } if (own === other) throw new Error("Use two different collections"); const target = db.getSiblingDB(appDB); function allow(command) { const result = target.runCommand(command); if (result.ok !== 1 || result.writeErrors?.length || result.writeConcernError) { printjson(result); throw new Error("Expected successful operation"); } return result; } function deny(command) { let result; try { result = target.runCommand(command); } catch (error) { if (error.code === 13) { print("DENY: Unauthorized (13)"); return; } throw error; } if (result.ok !== 0 || result.code !== 13) { printjson(result); throw new Error("Expected Unauthorized (13)"); } print("DENY: Unauthorized (13)"); } const inserted = allow({ insert: own, documents: [{_id: probeId, secureconfigProbe: 1}] }); if (inserted.n !== 1) throw new Error("Expected one inserted document"); const updated = allow({ update: own, updates: [{ q: {_id: probeId}, u: {$set: {secureconfigProbe: 2}}, multi: false, upsert: false }] }); if (updated.n !== 1) throw new Error("Expected one matched document"); const read = allow({ find: own, filter: {_id: probeId}, limit: 1, singleBatch: true }); if (read.cursor.firstBatch.length !== 1 || read.cursor.firstBatch[0].secureconfigProbe !== 2) { throw new Error("Expected to read the updated probe document"); } print("ALLOW: insert, update, find on own collection"); deny({find: other, filter: {}, limit: 1, singleBatch: true}); deny({insert: other, documents: [{_id: probeId, secureconfigProbe: 1}]}); deny({delete: own, deletes: [{q: {_id: probeId}, limit: 1}]}); })(); ``` The fixed role must allow insertion, update, and reading of the probe document, then deny the other collection's read and insert and deny deletion from its own collection. In the exposed comparison, database-wide `readWrite` allows those operations and the denial checks must fail. The script stops at its first unexpected result; demonstrate each denial separately against that exposed state. Have the deployment identity clean up probe documents afterward, including any written to the other collection during an exposed-state test. The command shapes and results are documented under [find](https://www.mongodb.com/docs/manual/reference/command/find/), [insert](https://www.mongodb.com/docs/manual/reference/command/insert/), [update](https://www.mongodb.com/docs/manual/reference/command/update/), and [delete](https://www.mongodb.com/docs/manual/reference/command/delete/). The required actions are documented under [privilege actions](https://www.mongodb.com/docs/manual/reference/privilege-actions/). ### Authentication-source restrictions (REASONED: clientSource, serverAddress, identity inspection and positive controls; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Node.js syntax parsing exited 0 without diagnostics; scram and tls dispatch printed STUB_MONGOSH with the respective arguments; a JavaScript stub printed STUB_DB runCommand with connectionStatus. Those runs of the connection block covered an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected comparison: authentication succeeds on both paths before restriction and only on the permitted source or listener afterward, with TLS reachability held constant. These deployment outcomes remain unobserved in this run, per the guide's cited Update users and apply authentication restrictions; Authenticated identities reported by connectionStatus.) **REASONED:** no `mongod`, `mongosh`, Docker, or Podman, or separate permitted and excluded source hosts, is available. Run the guarded `scram` connection with the same valid credentials from a permitted source and an excluded source. First establish TLS reachability from each using `tls` mode and the valid client certificate. Without the user restriction, authentication from both sources succeeds. With the restriction applied, the permitted source authenticates and can read its collection; the excluded source must receive a MongoDB authentication failure. A TLS error or timeout does not demonstrate the restriction. In the permitted session, inspect: User source and listener restrictions; no MongoDB deployment or permitted/excluded hosts. Expected results and sources follow. ```javascript db.runCommand({connectionStatus: 1}); ``` Confirm the expected application user and authentication database, then run the collection checks. Test `serverAddress` on its own: hold the permitted source, valid credentials, and client certificate constant, and connect through both an allowed listener address and a reachable listener address you will exclude. Before `serverAddress` is applied, authentication must succeed through both, which proves the excluded listener is reachable. After applying `serverAddress`, authentication must fail through the excluded listener and continue to succeed through the allowed one; MongoDB matches `serverAddress` against the address that accepted the connection. Repeating the probes through permitted listeners only would pass with `serverAddress` omitted, so it does not demonstrate the restriction, and a TLS error or timeout against the excluded listener does not either. See [source and listener address matching](https://www.mongodb.com/docs/manual/reference/method/db.updateuser/#authentication-restrictions) and [authenticated identity reporting](https://www.mongodb.com/docs/manual/reference/command/connectionstatus/). ### x.509 identity and certificate requirement (REASONED: registered, unregistered and missing client certificates, identity and role controls; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, x509 and missing-certificate stub dispatch printed STUB_MONGOSH with the respective arguments; x509 passed the literal $external database; a generated local certificate returned subject=CN=probe.internal from openssl x509 -subject -nameopt RFC2253. Those runs of the connection block covered an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected comparison: a trusted subject fails authentication before registration and succeeds afterward; missing certificates pass TLS only before mandatory-certificate enforcement. These deployment outcomes remain unobserved in this run, per the guide's cited Register and authenticate x.509 client identities; Configure TLS and client-certificate validation; mongosh connection, TLS, authentication, and password-prompt options.) **REASONED:** no `mongod`, `mongosh`, Docker, or Podman, or deployment-issued client certificates, is available. Use the guarded connection block for these cases: - `x509` with a valid registered client certificate: authentication must succeed. Run `connectionStatus` and confirm the exact subject in `$external`, then repeat the collection-role checks. - `x509` with a valid, trusted application certificate whose subject is not registered: TLS must be acceptable, but MongoDB authentication must fail. Use a different subject with no cluster-membership attributes. Pair it with the registered-certificate success; an expired or untrusted certificate only tests TLS validation. - `missing-certificate`: with `allowConnectionsWithoutCertificates: true`, the TLS `ping` can succeed without MongoDB authentication. With the setting `false`, the server must reject the certificate-less TLS connection while the registered-certificate control succeeds. For the identity demonstration, the same trusted certificate fails x.509 authentication before its subject is registered and succeeds afterward with only the assigned role. Missing-certificate rejection after enforcement demonstrates the TLS requirement separately. Repeat the unauthenticated collection probe with a valid certificate to demonstrate authorization as well. See [x.509 user registration](https://www.mongodb.com/docs/manual/tutorial/configure-x509-client-authentication/) and [certificate validation behavior](https://www.mongodb.com/docs/manual/tutorial/configure-ssl/). ### Distributed membership (REASONED: member credentials, source restrictions, status and sharded paths; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Node.js syntax parsing exited 0 without diagnostics; a JavaScript stub printed STUB_DB adminCommand with replSetGetStatus. Expected comparison: valid members remain healthy while invalid credentials or excluded sources are rejected after enforcement; unrestricted fixtures provide the exposed comparisons. These deployment outcomes remain unobserved in this run, per the guide's cited Internal membership authentication and certificate requirements; Configuration-file startup and network, TLS, and security options; Replica-set status and required privilege.) **REASONED:** no `mongod`, `mongosh`, Docker, or Podman, or multi-host cluster, is available. In an isolated replica-set test, compare a configured member with valid membership credentials against that member using wrong credentials, and compare permitted versus excluded member source addresses. Keep the application-facing TLS configuration constant. From a separate operator session with the `replSetGetStatus` privilege, inspect: Cluster membership; no multi-host MongoDB deployment. Expected results and membership sources follow. ```javascript db.adminCommand({replSetGetStatus: 1}); ``` The fixed configuration must permit valid members and reject invalid membership authentication or excluded sources. Correlate member health with explicit authentication failures in the participating processes' diagnostic output; an unhealthy member alone could reflect a network failure. Compare with an isolated exposed deployment lacking membership authentication and, separately, one lacking the source restriction. Include router-to-shard and config-server paths when demonstrating a sharded deployment. See [internal membership authentication](https://www.mongodb.com/docs/manual/core/security-internal-authentication/), [member source restrictions](https://www.mongodb.com/docs/manual/reference/configuration-options/#security.clusterIpSourceAllowlist), and [replica-set status](https://www.mongodb.com/docs/manual/reference/command/replsetgetstatus/). ### Verify Enterprise auditing (REASONED: audit destination, event coverage, filters and successful authorization; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Node.js syntax parsing exited 0 without diagnostics; a JavaScript stub printed STUB_DB adminCommand with getParameter and auditAuthorizationSuccess. Expected comparison: no destination produces no records; configured auditing records permitted event types, and successful authCheck records require auditAuthorizationSuccess true. These deployment outcomes remain unobserved in this run, per the guide's cited MongoDB 8.0 Enterprise auditing; MongoDB 8.0 audit event definitions (authCheck and authenticate results); MongoDB 8.0 server parameters (auditAuthorizationSuccess, redactClientLogData); MongoDB 8.0 audit filters.) **REASONED:** no MongoDB Enterprise deployment or readable audit destination is available. In an isolated Enterprise fixture with the auditing configuration from section 5, perform a successful authentication, a failed authentication, a collection creation, a permitted collection read, and a read attempted by an authenticated user that lacks find permission on the collection, then inspect the audit destination. Confirm the successful-authorization-check setting first: Enterprise audit coverage; no Enterprise deployment or readable audit destination. Expected events and MongoDB 8.0 sources follow. ```javascript db.adminCommand({getParameter: 1, auditAuthorizationSuccess: 1}); ``` With no destination configured, no audit records are produced. With the destination configured, a failed authentication is recorded as an `authenticate` event, a schema change such as the collection creation is recorded by default, and the unauthorized read is recorded as a failed `authCheck` (result 13) regardless of the setting. The permitted read's successful `authCheck` appears in the configured destination only once `auditAuthorizationSuccess` is `true`, at a performance cost, and an authentication-only filter records nothing else. The presence of an audit file alone proves neither event coverage nor successful-operation auditing; correlate each record with the test identity, operation, and time. See [auditing](https://www.mongodb.com/docs/v8.0/core/auditing/), [the auditAuthorizationSuccess parameter](https://www.mongodb.com/docs/v8.0/reference/parameters/), and the [audit event definitions](https://www.mongodb.com/docs/v8.0/reference/audit-message/mongo/). ### Verify diagnostic-log redaction (REASONED: diagnostic-log canary, verbosity and redaction; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Node.js syntax parsing exited 0 without diagnostics; JavaScript stubs printed STUB_DB setLogLevel and STUB_INSERT with the canary document. Expected comparison: matching log entries contain the canary before redaction and ### values afterward, retaining metadata. These deployment outcomes remain unobserved in this run, per the guide's cited MongoDB 8.0 server parameters (auditAuthorizationSuccess, redactClientLogData); MongoDB 8.0 log-redaction example.) **REASONED:** no MongoDB Enterprise binaries, running test server, or process-log access is available. Set the log verbosity to 1 in both fixtures so the operation is logged regardless of latency, insert a harmless canary, read the corresponding entry in the process log, then restore the previous verbosity: Diagnostic-log redaction; no Enterprise test server or process-log access. Expected canary results and MongoDB 8.0 sources follow. ```javascript db.setLogLevel(1); db.clients.insertOne({name: "Probe", note: "SECURECONFIG_REDACTION_CANARY"}); ``` Without redaction, the canary value appears in the matching log entry. With `security.redactClientLogData` enabled, the entry still exists but its attached values are shown as `###`, while metadata such as error and operation codes and line numbers remains visible. A missing log entry is inconclusive on its own. See [the redactClientLogData parameter](https://www.mongodb.com/docs/v8.0/reference/parameters/) and the [log-redaction example](https://www.mongodb.com/docs/v8.0/administration/monitoring/). ### Verify storage encryption (REASONED: encrypted and unencrypted storage, key initialization and KMIP failure; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, Bash parsing and ShellCheck 0.11.0 exited 0 without diagnostics; fixture grep printed Encryption key manager initialized and returned 1 without output for an unrelated line. Those runs covered an earlier form of the block, a bare grep whose exit status was grep's own; on the current form, which exits 0 when grep matches and 3 when it matches nothing or fails, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the fixture grep was not repeated. Expected comparison: encrypted files require the correct keys; missing keys or mismatched KMIP identity prevent startup, while unencrypted files reopen without keys. These deployment outcomes remain unobserved in this run, per the guide's cited MongoDB 8.0 encryption at rest; MongoDB 8.0 configure encryption and KMIP procedure.) **REASONED:** no MongoDB Enterprise binaries, disposable data directory, or KMIP endpoint is available. Create equivalent unencrypted and encrypted fixtures, each holding a known document, then confirm on disposable copies that the encrypted data files require the key configuration while a correctly configured server reads them. Inspect the startup log for the key-manager initialization: ```bash # Storage encryption; no Enterprise fixture, disposable data directory or KMIP endpoint. Expected key-manager results and MongoDB 8.0 sources follow. ( # a subshell, so the exits below end only the subshell if grep -iE 'encryption.*key|key manager' /var/log/mongodb/mongod.log; then echo "unclassified observation: read the matched lines above; with encryption enabled, the log records that the encryption key manager initialized"; exit 0 else case "$?" in 1) echo "no matching line in /var/log/mongodb/mongod.log, so it shows no key-manager initialization; inconclusive (the log may have rotated or be written elsewhere)"; exit 3 ;; *) echo "grep could not read /var/log/mongodb/mongod.log; inconclusive"; exit 3 ;; esac fi ) ``` With encryption enabled, the log records that the encryption key manager initialized; with a mismatched KMIP server identity or a missing key, initialization fails and the server does not start. The unencrypted fixture reopens with no key. Do not treat the absence of plaintext under `strings` as proof of encryption, since compression alone can produce that result. See the [encryption-at-rest reference](https://www.mongodb.com/docs/v8.0/core/security-encryption-at-rest/) and the [configure-encryption procedure](https://www.mongodb.com/docs/v8.0/tutorial/configure-encryption/). ### Verify field-level encryption (CSFLE) (REASONED: CSFLE client separation, ciphertext, decryption and plaintext-write rejection; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, static review found no runnable command or configuration block in this subsection. Expected comparison: ordinary fields are plaintext, protected fields are ciphertext to a client without decryption, key-authorized clients recover the value, and an encryption-requiring schema rejects plaintext writes. These deployment outcomes remain unobserved in this run, per the guide's cited MongoDB 8.0 client-side field level encryption; MongoDB 8.0 CSFLE manual (explicit) encryption; MongoDB 8.0 CSFLE client options.) **REASONED:** no MongoDB deployment, encryption-capable driver, or provisioned data-encryption key is available. CSFLE is verified with distinct client handles, not a single shell session, because a client configured for automatic encryption also decrypts on read. Through an encryption-configured client, insert a known canary into a protected collection and retain the returned `_id`; on the Community explicit path, encrypt the field value with `ClientEncryption.encrypt()` before the write. Read that document by `_id` through a client configured without automatic decryption: against an ordinary collection the field is plaintext, while against the protected collection it is encrypted binary data, and a key-authorized client recovers the original value. A plaintext write attempted against a server-side `$jsonSchema` that requires encryption is rejected. A denied read alone does not demonstrate field encryption, and a read through the same automatic-encryption client silently decrypts the field, so it cannot serve as the exposed control. See [CSFLE](https://www.mongodb.com/docs/v8.0/core/csfle/), its [manual-encryption reference](https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/manual-encryption/), and [CSFLE client options](https://www.mongodb.com/docs/v8.0/core/csfle/reference/csfle-options-clients/). ### Verify Queryable Encryption (REASONED: Queryable Encryption and the closing deployment-comparison row, under its own row declaration; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, static review found no runnable command or configuration block in the Queryable Encryption procedure. Expected comparison: ordinary fields are plaintext, protected fields are ciphertext without decryption, configured equality and numeric or date range queries match appropriately, and plaintext writes are rejected. These deployment outcomes remain unobserved in this run, per the guide's cited MongoDB 8.0 Queryable Encryption; MongoDB 8.0 Queryable Encryption limitations; MongoDB 8.0 Queryable Encryption manual encryption (edition split); MongoDB 8.0 Queryable Encryption supported operations (range BSON types).) **REASONED:** no replica-set or sharded MongoDB deployment, encryption-capable driver, or provisioned key is available. Create a Queryable Encryption collection with an `encryptedFields` definition and compare it against an ordinary collection, using distinct client handles as for CSFLE. Through the configured client, insert a canary and retain its `_id`; on the Community explicit path, encrypt values explicitly. Read by `_id` through a client configured without decryption: the ordinary collection returns plaintext, the encrypted collection ciphertext, and the configured client recovers the value. Query support depends on the field's configured query type: a field declared in `encryptedFields` without a `queryType` is encrypted but not queryable, so set `queryType: "equality"` on the encrypted string field and confirm the configured client matches an encrypted equality query. A range query is a separate fixture with `queryType: "range"`, which requires the encrypted field to be a numeric or date type (`int`, `long`, `double`, `decimal`, or `date`), not a string, with documents inside and outside the tested interval. A plaintext write against the declared encrypted fields is rejected. Missing results or a connection error alone do not establish confidentiality. See [Queryable Encryption](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/), its [limitations](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/limitations/), the [manual-encryption reference](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/fundamentals/manual-encryption/), and [supported operations](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/supported-operations/). | Check scope | Status | Deployment comparison and prerequisites | | --- | --- | --- | | Service behavior | REASONED: deployment-comparison inventory; a live MongoDB stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local Bash and JavaScript syntax checks exited 0, shell and JavaScript guard fixtures rejected unchanged placeholders, and stub dispatch tested argument construction only. Those Bash runs covered earlier forms of the listener, connection and storage-encryption blocks, whose connection guard refusals exited with status 1, and the shell guard fixtures and stub dispatch were not repeated. On the current forms, whose connection guard refusals exit with status 2 and whose listener, connection and log checks exit 0, 1 or 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. The comparisons specified in this row remain unobserved, per the guide's cited sources (part 1 of 3): MongoDB security checklist and Community authentication support; Configure TLS and client-certificate validation; Create custom roles and understand their scope; Update users and apply authentication restrictions. The guide's cited sources for the same unobserved comparisons as part 1 (part 2 of 3): Register and authenticate x.509 client identities; Internal membership authentication and certificate requirements; MongoDB 8.0 Enterprise auditing; MongoDB 8.0 server parameters (auditAuthorizationSuccess, redactClientLogData). The guide's cited sources for the same unobserved comparisons as part 1 (part 3 of 3): MongoDB 8.0 encryption at rest; MongoDB 8.0 configure encryption and KMIP procedure; MongoDB 8.0 client-side field level encryption; MongoDB 8.0 Queryable Encryption. REASONED from the cited MongoDB documentation; not demonstrated | On an authorized isolated deployment with MongoDB binaries, certificates, listener-inspection permission, and permitted/excluded source hosts, demonstrate (part 1 of 2): exposed versus fixed listener binding, plaintext/TLS behavior, unauthenticated collection access with a valid client certificate, collection-role allows and denials. The same demonstration on that authorized isolated deployment is to cover (part 2 of 2): user source/listener restrictions, registered/unregistered/missing client-certificate cases, membership credentials, and member/router source restrictions. Record commands, versions, responses, and positive controls. The sections 5 to 9 controls are also REASONED: on an authorized MongoDB Enterprise deployment with an audit destination, encryption keys or a KMIP endpoint, and an encryption-capable driver, additionally demonstrate (part 1 of 2): audit records for authentication and authorization events with auditAuthorizationSuccess off and on, diagnostic-log redaction of a canary value. The same demonstration on that authorized MongoDB Enterprise deployment is to cover (part 2 of 2): encrypted versus plaintext storage fixtures under local-key and KMIP configurations, and CSFLE and Queryable Encryption field ciphertext with a permitted encrypted query and a rejected plaintext write. | ## 5. Enable Enterprise auditing with deliberate event coverage MongoDB Enterprise provides an auditing facility for `mongod` and `mongos`; MongoDB Community has no equivalent, and the diagnostic log is not a substitute. Without a configured destination, no audit records are produced. Configure a destination on every process, including each shard and config-server member, since one unaudited process leaves a gap. See [MongoDB 8.0 auditing](https://www.mongodb.com/docs/v8.0/core/auditing/). ```yaml auditLog: destination: file format: JSON path: /var/log/mongodb/audit.json ``` A failed audit write can terminate the process, so confirm the destination is writable during deployment validation. Omitting a filter records every auditable event; a filter such as `auditLog.filter: '{"atype": "authenticate"}'` records only that event type and deliberately excludes the rest. See [configure auditing](https://www.mongodb.com/docs/v8.0/tutorial/configure-auditing/) and [audit filters](https://www.mongodb.com/docs/v8.0/tutorial/configure-audit-filters/). Recording successful authorization checks is a separate decision. In MongoDB 8.0, `auditAuthorizationSuccess` defaults to `false`, so `authCheck` events are logged only for authorization failures. Other auditable events, such as authentication, schema (DDL), and replica-set changes, do not depend on this setting and are recorded when auditing is enabled and the filter permits them. Enabling successful `authCheck` events carries a performance cost: ```yaml setParameter: auditAuthorizationSuccess: true ``` Set it only where the accountability requirement justifies the overhead. See the [auditAuthorizationSuccess parameter](https://www.mongodb.com/docs/v8.0/reference/parameters/#mongodb-parameter-param.auditAuthorizationSuccess). ## 6. Redact document values from the diagnostic log `redactClientLogData` is a MongoDB Enterprise feature; MongoDB Community does not offer it. Without redaction, logged operations can carry document field values into the diagnostic log, where a reader who is not a database user can see them. The documented startup routes are the `--redactClientLogData` flag or the configuration setting; use the configuration setting to keep redaction in the node's configuration file: ```yaml security: redactClientLogData: true ``` Redaction replaces the values accompanying a log message with `###`. Metadata such as error and operation codes, line numbers, and source-file names remain visible, and the documentation scopes redaction to the diagnostic log; do not rely on it for the audit log. MongoDB documents using it together with encryption at rest and TLS. See the [redactClientLogData parameter](https://www.mongodb.com/docs/v8.0/reference/parameters/), the [mongod flag reference](https://www.mongodb.com/docs/v8.0/reference/program/mongod/), and the [log-redaction example](https://www.mongodb.com/docs/v8.0/administration/monitoring/). ## 7. Encrypt the storage engine with a managed key MongoDB Enterprise 3.2 introduced native encryption for the WiredTiger storage engine; MongoDB Community has no native equivalent and depends on host or filesystem encryption. `security.enableEncryption` defaults to `false` (see the [encryption configuration options](https://www.mongodb.com/docs/v8.0/reference/configuration-options/)). Native encryption cannot encrypt existing data: enable it on a fresh member and populate it through initial sync or the documented migration, rather than restarting a populated data directory with the flag set. See [encryption at rest](https://www.mongodb.com/docs/v8.0/core/security-encryption-at-rest/). For a local key file: ```yaml security: enableEncryption: true encryptionKeyFile: /etc/mongodb/storage-master.key ``` This key encrypts storage and is unrelated to `security.keyFile`, which authenticates cluster members in section 2. The local key procedure does not support key rotation. See the [encryption configuration options](https://www.mongodb.com/docs/v8.0/reference/configuration-options/). MongoDB recommends a KMIP key server over a local key file. Keep `enableEncryption: true` and replace the key file with a `security.kmip` block: ```yaml security: enableEncryption: true kmip: serverName: kmip.example.internal port: 5696 serverCAFile: /etc/mongodb/kmip-ca.pem clientCertificateFile: /etc/mongodb/kmip-client.pem ``` The client PEM holds the client certificate and its private key. Supply `security.kmip.keyIdentifier` only to adopt an existing key when first enabling encryption; otherwise MongoDB requests a new key from the server. See the [configure encryption procedure](https://www.mongodb.com/docs/v8.0/tutorial/configure-encryption/). ## 8. Encrypt selected fields with client-side field level encryption Transport TLS and storage encryption still leave ordinary field values readable by a sufficiently privileged database or host user. Client-side field level encryption (CSFLE) encrypts chosen fields in the driver before they reach the server; there is no `mongod` switch. Explicit CSFLE is available in MongoDB Community, Enterprise Advanced, and Atlas, while automatic encryption requires Enterprise or Atlas. Automatic decryption is available in Community. See [CSFLE](https://www.mongodb.com/docs/v8.0/core/csfle/) and [manual encryption](https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/manual-encryption/). Automatic CSFLE configures the client with an `autoEncryption` object naming the key-vault namespace, the KMS providers, and a local `schemaMap` that marks the encrypted fields. Supply that schema locally: a schema fetched only from the server lets a compromised server drop the encryption requirement and induce plaintext writes. For the Community explicit path, set `bypassAutoEncryption: true` and call `ClientEncryption.encrypt()` before writing, encrypting query values explicitly as well. A randomized algorithm does not support equality matching on the field; the deterministic variant does, at the cost of revealing which stored values are equal. Consider a server-side `$jsonSchema` that rejects plaintext writes to the encrypted fields, and keep master-key access separate from ordinary database access. See the [automatic-encryption schemas](https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/automatic-encryption/), [CSFLE client options](https://www.mongodb.com/docs/v8.0/core/csfle/reference/csfle-options-clients/), and [CSFLE encryption algorithms](https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/encryption-algorithms/). ## 9. Query encrypted fields with Queryable Encryption Queryable Encryption (QE) encrypts fields in the driver while still allowing the server to run supported queries against the ciphertext. Equality queries became generally available in MongoDB 7.0 and range queries in MongoDB 8.0; the 6.0 public preview is incompatible with the generally available format and is not a production baseline. As with CSFLE, automatic encryption requires Enterprise or Atlas, while explicit QE and automatic decryption are available in Community. See the [7.0 GA release notes](https://www.mongodb.com/docs/v8.0/release-notes/7.0/), the [8.0 range-query release notes](https://www.mongodb.com/docs/v8.0/release-notes/8.0/), [Queryable Encryption](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/), and the [QE manual encryption edition split](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/fundamentals/manual-encryption/). QE requires a new encrypted collection created with an `encryptedFields` definition; an existing collection cannot have QE turned on in place, and one collection cannot combine CSFLE and QE. Automatic clients set `autoEncryption` with the key-vault namespace, the KMS providers, and a local `encryptedFieldsMap`. For the Community explicit path, set `bypassQueryAnalysis: true` and drive `ClientEncryption` directly. QE supports replica sets and sharded clusters, not standalones. Do not carry forward legacy `rangePreview` recipes; that option was removed in 8.0. See the [QE limitations](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/limitations/), [QE client options](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/qe-options-clients/), the [8.0 compatibility notes](https://www.mongodb.com/docs/v8.0/release-notes/8.0-compatibility/), and the [manual-encryption reference](https://www.mongodb.com/docs/v8.0/core/queryable-encryption/fundamentals/manual-encryption/). ## Common mistakes - `bindIp: 0.0.0.0` set to fix a connection problem, with `authorization` still unset; this is the classic leaked-database configuration. - Authorization enabled but every service sharing the `admin` account. - TLS on the server while the connection string still says `tls=false` because a container healthcheck was easier that way. - Appending another `net` or `security` mapping instead of merging settings. - Leaving `transitionToAuth: true` after a migration. - Treating a trusted TLS certificate as an authenticated, least-privilege MongoDB user. - Issuing application certificates with cluster-member attributes. - Treating a timeout or `listDatabases` output as proof of collection authorization. - Assuming MongoDB Community offers Enterprise auditing, `redactClientLogData`, or native storage encryption. - Enabling storage encryption on a populated data directory and assuming the existing files become encrypted. - Trusting a server-supplied CSFLE schema, which a compromised server can weaken to induce plaintext writes. - Combining CSFLE and Queryable Encryption on one collection, or carrying a legacy `rangePreview` recipe into 8.0. ## Sources (checked September 2026) - Official MongoDB Docker images (7.0, 8.0 and 8.3; pinned commit 0a29f3374c7fa7c38cfe280363b754f898e0a5eb): the Linux entrypoint turns arguments starting with `-` into `mongod`. That pinned Linux entrypoint, for `mongod`, reads both `MONGO_INITDB_ROOT_*` credentials with `file_env` (so `_FILE` works), adds `--auth` when both are set and exits when only one is, and adds `--bind_ip_all` when neither the arguments nor a file named by `--config` (not `-f`) sets `bindIp` or a true `bindIpAll`. Those pinned images' commands are the Linux `CMD ["mongod"]` and the Windows images' `CMD ["mongod", "--bind_ip_all"]`. Official MongoDB Docker images `docker-entrypoint.sh` sources (1 of 3): https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L4-L6 and https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L71-L87. Official MongoDB Docker images `docker-entrypoint.sh` sources (2 of 3): https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L247-L270 and https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L186. Official MongoDB Docker images `docker-entrypoint.sh` sources (3 of 3): https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L402-L413. Official MongoDB Docker images `Dockerfile` sources: https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/Dockerfile#L122-L125 and https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/windows/windowsservercore-ltsc2022/Dockerfile#L67 - `--bind_ip_all` sets `net.bindIp` to `*`, which binds `0.0.0.0`, and `::` as well only when `net.ipv6` is true (by `--ipv6` or a configuration file) (pinned tag r8.0.32; the same code in r7.0.43, r8.2.12 and r8.3.11). The `--bind_ip_all` sources (pinned tag r8.0.32): https://github.com/mongodb/mongo/blob/r8.0.32/src/mongo/db/server_options_base.cpp#L125-L132 and https://github.com/mongodb/mongo/blob/r8.0.32/src/mongo/db/server_options_server_helpers.cpp#L404-L410 - MongoDB security checklist and Community authentication support: https://www.mongodb.com/docs/manual/administration/security-checklist/ - MongoDB release lifecycle schedule: https://www.mongodb.com/legal/support-policy/lifecycles - Enable access control and bootstrap the administrator: https://www.mongodb.com/docs/manual/tutorial/enable-authentication/ - Configuration-file startup and network, TLS, and security options: https://www.mongodb.com/docs/manual/reference/configuration-options/ - mongod options and authorization: https://www.mongodb.com/docs/manual/reference/program/mongod/ - mongos options and internal authentication: https://www.mongodb.com/docs/manual/reference/program/mongos/ - Authenticate the current shell with db.auth(): https://www.mongodb.com/docs/manual/reference/method/db.auth/ - Create users, restrict SCRAM mechanisms, and protect credential transport: https://www.mongodb.com/docs/manual/reference/method/db.createuser/ - Create custom roles and understand their scope: https://www.mongodb.com/docs/manual/reference/method/db.createrole/ - Privilege actions for collection operations: https://www.mongodb.com/docs/manual/reference/privilege-actions/ - Built-in role capabilities for database, collection, and index operations: https://www.mongodb.com/docs/compass/connect/required-access/ - Collection creation and insert privilege: https://www.mongodb.com/docs/manual/reference/command/create/ - Update users and apply authentication restrictions: https://www.mongodb.com/docs/manual/reference/method/db.updateuser/ - Configure TLS and client-certificate validation: https://www.mongodb.com/docs/manual/tutorial/configure-ssl/ - Internal membership authentication and certificate requirements: https://www.mongodb.com/docs/manual/core/security-internal-authentication/ - Configure x.509 membership authentication: https://www.mongodb.com/docs/manual/tutorial/configure-x509-member-authentication/ - Keyfile setup, permissions, existing-cluster transition, and "Enforcing internal authentication also enforces user access control": https://www.mongodb.com/docs/manual/tutorial/enforce-keyfile-access-control-in-existing-replica-set/ - Register and authenticate x.509 client identities: https://www.mongodb.com/docs/manual/tutorial/configure-x509-client-authentication/ - x.509 certificate requirements and client/member separation: https://www.mongodb.com/docs/manual/core/security-x.509/ - mongosh connection, TLS, authentication, and password-prompt options: https://www.mongodb.com/docs/mongodb-shell/reference/options/ - Connection-string TLS options and SCRAM negotiation: https://www.mongodb.com/docs/manual/reference/connection-string-options/ - Connectivity probe using ping: https://www.mongodb.com/docs/manual/reference/command/ping/ - Authenticated identities reported by connectionStatus: https://www.mongodb.com/docs/manual/reference/command/connectionstatus/ - Collection read command: https://www.mongodb.com/docs/manual/reference/command/find/ - Collection insert command and write results: https://www.mongodb.com/docs/manual/reference/command/insert/ - Collection update command and write results: https://www.mongodb.com/docs/manual/reference/command/update/ - Collection delete command: https://www.mongodb.com/docs/manual/reference/command/delete/ - MongoDB authorization and authentication error codes: https://www.mongodb.com/docs/manual/reference/error-codes/ - listDatabases privilege-dependent behavior: https://www.mongodb.com/docs/manual/reference/command/listdatabases/ - Replica-set status and required privilege: https://www.mongodb.com/docs/manual/reference/command/replsetgetstatus/ - MongoDB 8.0 Enterprise auditing: https://www.mongodb.com/docs/v8.0/core/auditing/ - MongoDB 8.0 audit event definitions (authCheck and authenticate results): https://www.mongodb.com/docs/v8.0/reference/audit-message/mongo/ - MongoDB 8.0 configure auditing and destinations: https://www.mongodb.com/docs/v8.0/tutorial/configure-auditing/ - MongoDB 8.0 audit filters: https://www.mongodb.com/docs/v8.0/tutorial/configure-audit-filters/ - MongoDB 8.0 server parameters (auditAuthorizationSuccess, redactClientLogData): https://www.mongodb.com/docs/v8.0/reference/parameters/ - MongoDB 8.0 mongod redactClientLogData flag: https://www.mongodb.com/docs/v8.0/reference/program/mongod/ - MongoDB 8.0 encryption at rest: https://www.mongodb.com/docs/v8.0/core/security-encryption-at-rest/ - MongoDB 8.0 encryption configuration options: https://www.mongodb.com/docs/v8.0/reference/configuration-options/ - MongoDB 8.0 configure encryption and KMIP procedure: https://www.mongodb.com/docs/v8.0/tutorial/configure-encryption/ - MongoDB 8.0 client-side field level encryption: https://www.mongodb.com/docs/v8.0/core/csfle/ - MongoDB 8.0 CSFLE manual (explicit) encryption: https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/manual-encryption/ - MongoDB 8.0 CSFLE automatic-encryption schemas: https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/automatic-encryption/ - MongoDB 8.0 CSFLE client options: https://www.mongodb.com/docs/v8.0/core/csfle/reference/csfle-options-clients/ - MongoDB 8.0 Queryable Encryption: https://www.mongodb.com/docs/v8.0/core/queryable-encryption/ - MongoDB 8.0 Queryable Encryption 7.0 GA release notes: https://www.mongodb.com/docs/v8.0/release-notes/7.0/ - MongoDB 8.0 Queryable Encryption limitations: https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/limitations/ - MongoDB 8.0 Queryable Encryption client options: https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/qe-options-clients/ - MongoDB 8.0 Queryable Encryption manual encryption (edition split): https://www.mongodb.com/docs/v8.0/core/queryable-encryption/fundamentals/manual-encryption/ - MongoDB 8.0 Queryable Encryption supported operations (range BSON types): https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/supported-operations/ - MongoDB 8.0 log-redaction example: https://www.mongodb.com/docs/v8.0/administration/monitoring/ - MongoDB 8.0 CSFLE encryption algorithms: https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/encryption-algorithms/ - MongoDB 8.0 release notes (Queryable Encryption range queries): https://www.mongodb.com/docs/v8.0/release-notes/8.0/ - MongoDB 8.0 compatibility notes (rangePreview removal): https://www.mongodb.com/docs/v8.0/release-notes/8.0-compatibility/ - Bash file-creation mask: https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Builtins.html - Bash redirection and noclobber behavior: https://www.gnu.org/software/bash/manual/bash.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - bind: MongoDB bind source (r8.0.32) - v8: MongoDB documentation (8.0) - docker: Official MongoDB Docker images (0a29f3374c7fa7c38cfe280363b754f898e0a5eb) - docs: MongoDB documentation (unknown) - shell: mongosh documentation (unknown) - bash: Bash documentation (unknown) Claims (full source mapping in the JSON block below): - lifecycle (REASONED; docs): Use supported releases and security patches; 4.2 reached end of life on April 30, 2023. Examples use tls options rather than older ssl names. - private-listener (REASONED; docs): Configure port 27017 and loopback bindIp; widen only to required private addresses with firewall restrictions. - authorization (REASONED; docs): Enable security.authorization on mongod; a reachable listener with authorization off exposes data. - docker-bind (REASONED; docker): Linux 7.0, 8.0 and 8.3 images default to mongod and add --bind_ip_all unless arguments or a --config file set binding; a bind only in a -f file does not stop the addition. - docker-auth (REASONED; docker, docs): Linux entrypoint adds --auth only with both root credential variables or their _FILE forms; one alone exits. With neither credential nor explicit authorization/internal-authentication settings, authorization is off. - wildcard-ipv6 (REASONED; bind): --bind_ip_all listens on all IPv4 addresses; IPv6 is added only when net.ipv6 is true through --ipv6 or configuration. - docker-windows (REASONED; docker): Windows images run mongod --bind_ip_all without the Linux entrypoint; root credential variables do not enable authorization. - config-startup (REASONED; docs, docker): mongod reads a configuration file only through --config or -f; mounting alone is insufficient. Supply --config for the image bind check, use required container addresses, and publish privately. - bootstrap (REASONED; docs): On loopback, the localhost exception permits the first admin only with no existing users or roles; it is not recovery. Create the SCRAM-SHA-256 userAdminAnyDatabase user, then authenticate the current shell. - credential-transport (REASONED; docs): passwordPrompt() avoids password command text but does not encrypt createUser transport; reconnect as admin over TLS before creating application users. - application-identity (REASONED; docs): Use a separate application identity; its creation database is its authentication database. read is database-wide; readWrite includes collection and index management permissions. - collection-role (REASONED; docs): The custom role grants find, insert and update on one collection, with no deletion, collection dropping or index management; insert still permits creating that named non-capped collection. - role-scope (REASONED; docs): createRole requires privileges and roles; a role outside admin can grant and inherit only within its database. Use a separate deployment identity for provisioning. - scram-user-default (REASONED; docs): Omitting user mechanisms normally creates both SCRAM-SHA-1 and SCRAM-SHA-256 credentials; the example explicitly restricts the user to SCRAM-SHA-256. - scram-negotiation (REASONED; docs): Omitting authMechanism permits fallback from SCRAM-SHA-256 to SCRAM-SHA-1; explicitly select SCRAM-SHA-256 and check driver compatibility. - client-source (REASONED; docs): authenticationRestrictions.clientSource checks the source MongoDB sees, including NAT or proxy effects; it restricts authentication, not TCP reachability. - server-address (REASONED; docs): authenticationRestrictions.serverAddress checks the accepting listener address; include required alternate and failover paths. Preserve recovery access because incompatible inherited restrictions can prevent login. - human-mfa (REASONED; docs): Community supports SCRAM and x.509, without a wire-protocol TOTP dialogue; a machine certificate is not human MFA. Protect human host and admin-UI paths with MFA. - pem-file (REASONED; bash, docs): Initial PEM creation uses an unused path in a protected directory, umask 077, noclobber, service-account ownership and mode 600; this is not a rotation procedure. - tls-server (REASONED; docs): Merge requireTLS, certificateKeyFile and CAFile into the existing net mapping; SANs must cover client hostnames. Restart the configured service and confirm TLS before widening access. - tls-mixed-modes (REASONED; docs): allowTLS and preferTLS accept plaintext and TLS; use them only for transition and finish with requireTLS. - tls-client-certificates (REASONED; docs): With the shown CA configuration, certificates are required unless allowConnectionsWithoutCertificates is true; presented certificates are still validated. False also requires SCRAM clients to present certificates and does not assign roles. - mongot (REASONED; docs): The cited TLS documentation requires allowConnectionsWithoutCertificates: true for mongot; check topology before requiring certificates. - membership-x509 (REASONED; docs): Configure internal authentication on every member, config server and router. Production x.509 membership uses clusterAuthMode: x509 and a protected clusterFile for outgoing authentication, retaining TLS. - membership-certificates (REASONED; docs): Default x.509 membership needs matching O, OU and DC attributes with at least one populated, a common CA and matching SANs; EKU, if present, must cover serverAuth/clientAuth as used. Separate application membership attributes. - membership-keyfile (REASONED; docs, bash): The alternative generates one shared key with openssl rand -base64 756, service-account ownership and mode 400; securely distribute it to all members and routers, set keyFile and clusterAuthMode: keyFile, and retain TLS. - keyfile-rotation (REASONED; docs): Unix keyfiles require no group/world permissions and service-account readability; multiple rotation keys are allowed but every member must share a common key. - transition-auth (REASONED; docs): transitionToAuth: true permits unauthenticated operations without enforcing user access controls; keep it false in the final state and use vendor procedures for existing-cluster migration. - mongos-access-control (REASONED; docs): Keep authorization enabled on mongod; security.authorization is unavailable on mongos, where internal authentication enables client access control. - member-source (REASONED; docs): clusterIpSourceAllowlist, introduced in 5.0, restricts internal authentication only when authentication is enabled; include every member/router source after NAT. It does not restrict application accounts or TCP reachability. - scram-client (REASONED; shell): mongosh selects the user creation database and SCRAM-SHA-256 over TLS on 27017; final --password prompts without placing the password in argv. - driver-tls (REASONED; docs): Enable driver TLS and CA trust with certificate and hostname validation; tlsCAFile is not supported by every driver. Do not ship tlsAllowInvalidCertificates. - x509-identity (REASONED; docs, shell): Register the exact RFC2253 subject in $external with the application role and use MONGODB-X509; without a username mongosh uses the certificate subject. TLS validation alone does not authenticate a MongoDB user. - x509-client-certificate (REASONED; docs): Application certificates must be current, satisfy CA requirements and contain digitalSignature and clientAuth; keep subjects and membership attributes separate from server/member certificates to avoid internal privileges. - x509-source (REASONED; docs): Apply source restrictions separately to the $external subject; restrictions on a SCRAM identity do not transfer to the certificate identity. - local-guards (DEMONSTRATED; bash, docs): Recorded local placeholder and JavaScript guard tests rejected invalid inputs before database access; shell argument tracing did not verify MongoDB behavior. Tool versions are unrecorded. The shell guard tests covered an earlier form whose refusals exited with status 1; the current form was not re-run. - verify-listeners (REASONED; docs): Inspect all database/router listeners and configured ports; unintended wildcard/public binds are exposed. Private binds require separate firewall checks; the recorded netlink refusal establishes nothing about listeners. - verify-dispatch (REASONED; shell, bash): Use the whole guarded six-value connection block, real certificate hostname and application PEM; --norc prevents shell startup authentication. Guards assume normal Bash builtins and cannot distinguish inherited exact marker/count arguments. - verify-plaintext (REASONED; docs, shell): Plaintext ping succeeds when plaintext is accepted and must fail under requireTLS while a same-endpoint authenticated TLS control succeeds; timeout/refusal alone is inconclusive and ping does not prove authorization. - verify-anonymous (REASONED; docs, shell): With a valid client certificate but no authenticated MongoDB identity, an existing collection read succeeds with authorization off and fails Unauthorized (13) with it on; pair with an authenticated read and inspect connectionStatus. - verify-listdatabases (REASONED; docs): listDatabases depends on privileges and authorizedDatabases and cannot prove collection authorization; empty output, timeout or unrelated errors are not authorization denials. - verify-role-allow (REASONED; docs, shell): In a disposable fixture, the application role must insert, update and read its own probe document; check result counts and contents with fresh IDs and valid documents. - verify-role-read-denial (REASONED; docs, shell): The application role must deny reads of an unrelated existing collection with Unauthorized (13); database-wide readWrite is the exposed comparison. - verify-role-insert-denial (REASONED; docs, shell): The application role must deny inserts into the unrelated collection with Unauthorized (13); database-wide readWrite permits them. Test each denial separately because the script stops at its first unexpected result. - verify-role-delete-denial (REASONED; docs, shell): The application role must deny deletion from its own collection with Unauthorized (13); database-wide readWrite permits it. The deployment identity cleans up probe documents afterward. - verify-client-source (REASONED; docs, shell): With TLS reachability proven from both sources, valid credentials work from both before restriction; afterward only the permitted source authenticates and reads. TLS failures/timeouts do not prove clientSource enforcement. - verify-server-address (REASONED; docs, shell): Hold source, credentials and client certificate constant; both reachable listener addresses authenticate before serverAddress restriction, then only the allowed listener does. Permitted-listener tests alone cannot detect an omitted restriction. - verify-x509 (REASONED; docs, shell): The same trusted non-member certificate must fail x.509 authentication before subject registration and succeed afterward with only assigned roles; inspect the exact $external identity and repeat role checks. - verify-missing-certificate (REASONED; docs, shell): Certificate-less TLS ping can succeed when allowConnectionsWithoutCertificates is true; false must reject it while a registered-certificate control succeeds. Repeat anonymous collection access with a valid certificate to test authorization separately. - verify-membership (REASONED; docs): In an isolated cluster compare valid and invalid membership credentials; correlate replSetGetStatus with explicit authentication failures, since unhealthy members alone do not prove enforcement. Include router/shard/config-server paths. - verify-member-source (REASONED; docs): Compare permitted and excluded member source addresses against an isolated deployment lacking clusterIpSourceAllowlist; hold application TLS constant and correlate member health with authentication failures. - verify-audit-destination (REASONED; v8): Without a destination no audit records appear; with one, correlate failed authentication and collection-creation records with identity, operation and time. A file alone does not prove coverage; an authentication-only filter excludes other events. - verify-audit-authorization (REASONED; v8): The unauthorized read produces failed authCheck result 13 regardless of auditAuthorizationSuccess; the permitted read produces successful authCheck only when true. Inspect getParameter and compare off/on, accounting for performance cost. - verify-redaction (REASONED; v8): At verbosity 1, compare a logged canary with redaction off/on; the matching entry remains while values become ### and metadata remains. A missing entry is inconclusive; restore prior verbosity. - verify-storage (REASONED; v8): Disposable encrypted data copies require the correct key configuration and a working reader; an unencrypted fixture reopens without a key. Inspect key-manager initialization; a missing key or wrong KMIP identity prevents startup, and strings cannot prove encryption. - verify-csfle (REASONED; v8): Use separate clients to compare plaintext and encrypted field bytes by retained _id; a key-authorized client recovers the value. Community explicitly encrypts before writing; the automatic-encryption client silently decrypts and cannot serve as the exposed control. - verify-csfle-schema (REASONED; v8): A plaintext write to a server-side $jsonSchema requiring encryption is rejected; denied reads alone do not demonstrate field encryption. - verify-qe-ciphertext (REASONED; v8): Compare ordinary and encryptedFields collections through distinct clients: plaintext versus ciphertext without decryption, original value with keys; Community encrypts explicitly. Reject plaintext writes to declared encrypted fields. - verify-qe-equality (REASONED; v8): An encryptedFields field without queryType is encrypted but not queryable; set equality on the encrypted string field and confirm the configured client matches an encrypted equality query. - verify-qe-range (REASONED; v8): Test range separately with queryType: range on int, long, double, decimal or date, not a string, and documents inside/outside the interval; missing results or connection errors do not establish confidentiality. - audit-destination (REASONED; v8): Enterprise auditing covers mongod and mongos; Community has no equivalent and diagnostic logs are not a substitute. Configure a destination on every process; the example selects a JSON file. - audit-write (REASONED; v8): A failed audit write can terminate the process; validate that the destination is writable. - audit-filter (REASONED; v8): Omitting auditLog.filter permits all auditable event types, but successful authCheck events still require auditAuthorizationSuccess: true; an authenticate-only filter excludes other event types. - audit-success-default (REASONED; v8): In MongoDB 8.0, auditAuthorizationSuccess defaults false, so authCheck records only authorization failures; enabling it adds successful checks at a performance cost. - diagnostic-redaction (REASONED; v8): Enterprise-only redactClientLogData uses a startup flag or security.redactClientLogData; document values become ### while metadata remains. Its documented scope is diagnostic logs, not audit logs; combine with TLS and storage encryption. - storage-encryption (REASONED; v8): Enterprise 3.2 introduced native WiredTiger encryption; Community depends on host/filesystem encryption. security.enableEncryption defaults false. - storage-migration (REASONED; v8): Native encryption does not encrypt existing data in place; use a fresh member and initial sync or the documented migration. - storage-local-key (REASONED; v8): security.encryptionKeyFile supplies the storage master key, unrelated to membership keyFile; local key management does not support rotation. - storage-kmip (REASONED; v8): Prefer KMIP to a local key file; retain enableEncryption and configure serverName, port 5696, serverCAFile and clientCertificateFile containing the client certificate and private key. - storage-key-identifier (REASONED; v8): Set security.kmip.keyIdentifier only to adopt an existing key when first enabling encryption; otherwise MongoDB requests a new key. - csfle-editions (REASONED; v8): CSFLE encrypts fields in the driver before the server, with no mongod switch; explicit encryption and automatic decryption work in Community, while automatic encryption requires Enterprise or Atlas. - csfle-schema (REASONED; v8): Automatic CSFLE autoEncryption names the key vault, KMS providers and local schemaMap; relying only on a server-fetched schema lets a compromised server induce plaintext writes. - csfle-explicit (REASONED; v8): Community explicit CSFLE sets bypassAutoEncryption: true and calls ClientEncryption.encrypt() before writing and for query values; keep master-key access separate from database access. - csfle-algorithms (REASONED; v8): Randomized encryption does not support equality matching; deterministic encryption does but reveals equal stored values. A server-side $jsonSchema can reject plaintext writes. - qe-releases (REASONED; v8): QE equality queries became GA in 7.0 and range queries in 8.0; the incompatible 6.0 public preview is not a production baseline. - qe-editions (REASONED; v8): QE encrypts fields in the driver for supported ciphertext queries; automatic encryption requires Enterprise or Atlas, while Community supports explicit encryption and automatic decryption. - qe-collection (REASONED; v8): QE requires a new encryptedFields collection, cannot be enabled in place, and cannot share a collection with CSFLE. - qe-client (REASONED; v8): Automatic QE uses autoEncryption with key-vault namespace, KMS providers and local encryptedFieldsMap; Community explicit QE uses bypassQueryAnalysis: true and ClientEncryption. - qe-topology (REASONED; v8): QE supports replica sets and sharded clusters, not standalone servers. - qe-range-preview (REASONED; v8): rangePreview was removed in 8.0; do not carry legacy rangePreview recipes forward. - audit-other-events (REASONED; v8): With MongoDB 8.0 Enterprise/Atlas auditing enabled and the filter permitting them, authentication, schema (DDL) and replica-set events are recorded independently of auditAuthorizationSuccess. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "1bb9e73717f13c924ac93b39d92f444534291f5f1a536195180052d14b41d682", "components": { "bind": { "name": "MongoDB bind source", "basis": "r8.0.32", "sources": { "s754f20f4ec55": "https://github.com/mongodb/mongo/blob/r8.0.32/src/mongo/db/server_options_base.cpp#L125-L132", "s492eaf134763": "https://github.com/mongodb/mongo/blob/r8.0.32/src/mongo/db/server_options_server_helpers.cpp#L404-L410" } }, "v8": { "name": "MongoDB documentation", "basis": "8.0", "sources": { "s6f1f3f56616d": "https://www.mongodb.com/docs/v8.0/core/auditing/", "s9e86819e68db": "https://www.mongodb.com/docs/v8.0/reference/audit-message/mongo/", "seedf77aa3e73": "https://www.mongodb.com/docs/v8.0/tutorial/configure-auditing/", "s9fcdc05982a6": "https://www.mongodb.com/docs/v8.0/tutorial/configure-audit-filters/", "s1f3040d0fe4c": "https://www.mongodb.com/docs/v8.0/reference/parameters/", "s4ffa7b653364": "https://www.mongodb.com/docs/v8.0/reference/program/mongod/", "s3b7dabf8f41b": "https://www.mongodb.com/docs/v8.0/core/security-encryption-at-rest/", "see769a1a1e2b": "https://www.mongodb.com/docs/v8.0/reference/configuration-options/", "sf88108a9609d": "https://www.mongodb.com/docs/v8.0/tutorial/configure-encryption/", "sdd8ad5fe92b2": "https://www.mongodb.com/docs/v8.0/core/csfle/", "s23650ad42657": "https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/manual-encryption/", "s2a52e3acf56a": "https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/automatic-encryption/", "s2876af0be5e7": "https://www.mongodb.com/docs/v8.0/core/csfle/reference/csfle-options-clients/", "se39572dfb8ad": "https://www.mongodb.com/docs/v8.0/core/queryable-encryption/", "s1b94cb5e8305": "https://www.mongodb.com/docs/v8.0/release-notes/7.0/", "s1d229cb03243": "https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/limitations/", "se55ecf8e7f85": "https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/qe-options-clients/", "s47b976aa1f45": "https://www.mongodb.com/docs/v8.0/core/queryable-encryption/fundamentals/manual-encryption/", "sd08442f8ff38": "https://www.mongodb.com/docs/v8.0/core/queryable-encryption/reference/supported-operations/", "sc9621a9507cc": "https://www.mongodb.com/docs/v8.0/administration/monitoring/", "sb5d5b8d3dfa1": "https://www.mongodb.com/docs/v8.0/core/csfle/fundamentals/encryption-algorithms/", "s43213b00956d": "https://www.mongodb.com/docs/v8.0/release-notes/8.0/", "s09f3af996812": "https://www.mongodb.com/docs/v8.0/release-notes/8.0-compatibility/" } }, "docker": { "name": "Official MongoDB Docker images", "basis": "0a29f3374c7fa7c38cfe280363b754f898e0a5eb", "sources": { "s9904167cc177": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L4-L6", "s2b2b907c62f6": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L247-L270", "s24121291cdc4": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L186", "s1f17de6c0e57": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L402-L413", "sec084eaf4f47": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/Dockerfile#L122-L125", "s794426369608": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/windows/windowsservercore-ltsc2022/Dockerfile#L67", "sb1af26c1c65f": "https://github.com/docker-library/mongo/blob/0a29f3374c7fa7c38cfe280363b754f898e0a5eb/8.3/docker-entrypoint.sh#L71-L87" } }, "docs": { "name": "MongoDB documentation", "basis": "unknown", "sources": { "s8c3f376f412d": "https://www.mongodb.com/docs/manual/administration/security-checklist/", "sc8c1c4250459": "https://www.mongodb.com/legal/support-policy/lifecycles", "see7bce37d0f5": "https://www.mongodb.com/docs/manual/tutorial/enable-authentication/", "s22791f6f25b1": "https://www.mongodb.com/docs/manual/reference/configuration-options/", "s1f0c41f45f41": "https://www.mongodb.com/docs/manual/reference/program/mongod/", "sa7d56fbc7a01": "https://www.mongodb.com/docs/manual/reference/program/mongos/", "s5a6380eab815": "https://www.mongodb.com/docs/manual/reference/method/db.auth/", "sc1181cf8dff3": "https://www.mongodb.com/docs/manual/reference/method/db.createuser/", "s80531f5cd47d": "https://www.mongodb.com/docs/manual/reference/method/db.createrole/", "sbe43794c8d1c": "https://www.mongodb.com/docs/manual/reference/privilege-actions/", "sfb89d8101c88": "https://www.mongodb.com/docs/compass/connect/required-access/", "sa8e4a8275384": "https://www.mongodb.com/docs/manual/reference/command/create/", "s6a592c920ac2": "https://www.mongodb.com/docs/manual/reference/method/db.updateuser/", "sa9e55b825b2a": "https://www.mongodb.com/docs/manual/tutorial/configure-ssl/", "sb527c92150ce": "https://www.mongodb.com/docs/manual/core/security-internal-authentication/", "s2862bf749970": "https://www.mongodb.com/docs/manual/tutorial/configure-x509-member-authentication/", "s2093f0ae077e": "https://www.mongodb.com/docs/manual/tutorial/enforce-keyfile-access-control-in-existing-replica-set/", "s85f35d70e6ed": "https://www.mongodb.com/docs/manual/tutorial/configure-x509-client-authentication/", "s0b650ef2f8d8": "https://www.mongodb.com/docs/manual/core/security-x.509/", "s48d0e6640a2c": "https://www.mongodb.com/docs/manual/reference/connection-string-options/", "seee0e2f961d6": "https://www.mongodb.com/docs/manual/reference/command/ping/", "s6eb89ab49dad": "https://www.mongodb.com/docs/manual/reference/command/connectionstatus/", "sb59a2357999d": "https://www.mongodb.com/docs/manual/reference/command/find/", "s4012eeed7cf2": "https://www.mongodb.com/docs/manual/reference/command/insert/", "s051aa306cd4c": "https://www.mongodb.com/docs/manual/reference/command/update/", "s181c6bd1f977": "https://www.mongodb.com/docs/manual/reference/command/delete/", "s9a8c112b4424": "https://www.mongodb.com/docs/manual/reference/error-codes/", "s6fe5567c6b9f": "https://www.mongodb.com/docs/manual/reference/command/listdatabases/", "sc4c49175cea4": "https://www.mongodb.com/docs/manual/reference/command/replsetgetstatus/" } }, "shell": { "name": "mongosh documentation", "basis": "unknown", "sources": { "s5ed633d4d270": "https://www.mongodb.com/docs/mongodb-shell/reference/options/" } }, "bash": { "name": "Bash documentation", "basis": "unknown", "sources": { "sa2bb528509ad": "https://www.gnu.org/software/bash/manual/html_node/Bourne-Shell-Builtins.html", "s9900e479e21e": "https://www.gnu.org/software/bash/manual/bash.html" } } }, "claims": { "lifecycle": {"text": "Use supported releases and security patches; 4.2 reached end of life on April 30, 2023. Examples use tls options rather than older ssl names.", "components": ["docs"], "sources": ["docs:sc8c1c4250459", "docs:s22791f6f25b1"], "status": "REASONED"}, "private-listener": {"text": "Configure port 27017 and loopback bindIp; widen only to required private addresses with firewall restrictions.", "components": ["docs"], "sources": ["docs:s22791f6f25b1", "docs:s8c3f376f412d"], "status": "REASONED"}, "authorization": {"text": "Enable security.authorization on mongod; a reachable listener with authorization off exposes data.", "components": ["docs"], "sources": ["docs:see7bce37d0f5", "docs:s22791f6f25b1", "docs:s1f0c41f45f41"], "status": "REASONED"}, "docker-bind": {"text": "Linux 7.0, 8.0 and 8.3 images default to mongod and add --bind_ip_all unless arguments or a --config file set binding; a bind only in a -f file does not stop the addition.", "components": ["docker"], "sources": ["docker:s9904167cc177", "docker:s24121291cdc4", "docker:s1f17de6c0e57", "docker:sec084eaf4f47"], "status": "REASONED"}, "docker-auth": {"text": "Linux entrypoint adds --auth only with both root credential variables or their _FILE forms; one alone exits. With neither credential nor explicit authorization/internal-authentication settings, authorization is off.", "components": ["docker", "docs"], "sources": ["docker:s2b2b907c62f6", "docs:s22791f6f25b1", "docs:s1f0c41f45f41", "docker:sb1af26c1c65f"], "status": "REASONED"}, "wildcard-ipv6": {"text": "--bind_ip_all listens on all IPv4 addresses; IPv6 is added only when net.ipv6 is true through --ipv6 or configuration.", "components": ["bind"], "sources": ["bind:s754f20f4ec55", "bind:s492eaf134763"], "status": "REASONED"}, "docker-windows": {"text": "Windows images run mongod --bind_ip_all without the Linux entrypoint; root credential variables do not enable authorization.", "components": ["docker"], "sources": ["docker:s794426369608"], "status": "REASONED"}, "config-startup": {"text": "mongod reads a configuration file only through --config or -f; mounting alone is insufficient. Supply --config for the image bind check, use required container addresses, and publish privately.", "components": ["docs", "docker"], "sources": ["docs:s22791f6f25b1", "docker:s24121291cdc4", "docker:s1f17de6c0e57", "docs:s8c3f376f412d"], "status": "REASONED"}, "bootstrap": {"text": "On loopback, the localhost exception permits the first admin only with no existing users or roles; it is not recovery. Create the SCRAM-SHA-256 userAdminAnyDatabase user, then authenticate the current shell.", "components": ["docs"], "sources": ["docs:see7bce37d0f5", "docs:sc1181cf8dff3", "docs:s5a6380eab815"], "status": "REASONED"}, "credential-transport": {"text": "passwordPrompt() avoids password command text but does not encrypt createUser transport; reconnect as admin over TLS before creating application users.", "components": ["docs"], "sources": ["docs:sc1181cf8dff3", "docs:sa9e55b825b2a"], "status": "REASONED"}, "application-identity": {"text": "Use a separate application identity; its creation database is its authentication database. read is database-wide; readWrite includes collection and index management permissions.", "components": ["docs"], "sources": ["docs:sc1181cf8dff3", "docs:sfb89d8101c88", "docs:s8c3f376f412d"], "status": "REASONED"}, "collection-role": {"text": "The custom role grants find, insert and update on one collection, with no deletion, collection dropping or index management; insert still permits creating that named non-capped collection.", "components": ["docs"], "sources": ["docs:s80531f5cd47d", "docs:sbe43794c8d1c", "docs:sa8e4a8275384"], "status": "REASONED"}, "role-scope": {"text": "createRole requires privileges and roles; a role outside admin can grant and inherit only within its database. Use a separate deployment identity for provisioning.", "components": ["docs"], "sources": ["docs:s80531f5cd47d", "docs:sbe43794c8d1c"], "status": "REASONED"}, "scram-user-default": {"text": "Omitting user mechanisms normally creates both SCRAM-SHA-1 and SCRAM-SHA-256 credentials; the example explicitly restricts the user to SCRAM-SHA-256.", "components": ["docs"], "sources": ["docs:sc1181cf8dff3"], "status": "REASONED"}, "scram-negotiation": {"text": "Omitting authMechanism permits fallback from SCRAM-SHA-256 to SCRAM-SHA-1; explicitly select SCRAM-SHA-256 and check driver compatibility.", "components": ["docs"], "sources": ["docs:s48d0e6640a2c"], "status": "REASONED"}, "client-source": {"text": "authenticationRestrictions.clientSource checks the source MongoDB sees, including NAT or proxy effects; it restricts authentication, not TCP reachability.", "components": ["docs"], "sources": ["docs:s6a592c920ac2"], "status": "REASONED"}, "server-address": {"text": "authenticationRestrictions.serverAddress checks the accepting listener address; include required alternate and failover paths. Preserve recovery access because incompatible inherited restrictions can prevent login.", "components": ["docs"], "sources": ["docs:s6a592c920ac2"], "status": "REASONED"}, "human-mfa": {"text": "Community supports SCRAM and x.509, without a wire-protocol TOTP dialogue; a machine certificate is not human MFA. Protect human host and admin-UI paths with MFA.", "components": ["docs"], "sources": ["docs:s8c3f376f412d", "docs:s0b650ef2f8d8"], "status": "REASONED"}, "pem-file": {"text": "Initial PEM creation uses an unused path in a protected directory, umask 077, noclobber, service-account ownership and mode 600; this is not a rotation procedure.", "components": ["bash", "docs"], "sources": ["bash:sa2bb528509ad", "bash:s9900e479e21e", "docs:sa9e55b825b2a"], "status": "REASONED"}, "tls-server": {"text": "Merge requireTLS, certificateKeyFile and CAFile into the existing net mapping; SANs must cover client hostnames. Restart the configured service and confirm TLS before widening access.", "components": ["docs"], "sources": ["docs:s22791f6f25b1", "docs:sa9e55b825b2a"], "status": "REASONED"}, "tls-mixed-modes": {"text": "allowTLS and preferTLS accept plaintext and TLS; use them only for transition and finish with requireTLS.", "components": ["docs"], "sources": ["docs:s22791f6f25b1"], "status": "REASONED"}, "tls-client-certificates": {"text": "With the shown CA configuration, certificates are required unless allowConnectionsWithoutCertificates is true; presented certificates are still validated. False also requires SCRAM clients to present certificates and does not assign roles.", "components": ["docs"], "sources": ["docs:sa9e55b825b2a"], "status": "REASONED"}, "mongot": {"text": "The cited TLS documentation requires allowConnectionsWithoutCertificates: true for mongot; check topology before requiring certificates.", "components": ["docs"], "sources": ["docs:sa9e55b825b2a"], "status": "REASONED"}, "membership-x509": {"text": "Configure internal authentication on every member, config server and router. Production x.509 membership uses clusterAuthMode: x509 and a protected clusterFile for outgoing authentication, retaining TLS.", "components": ["docs"], "sources": ["docs:sb527c92150ce", "docs:s2862bf749970"], "status": "REASONED"}, "membership-certificates": {"text": "Default x.509 membership needs matching O, OU and DC attributes with at least one populated, a common CA and matching SANs; EKU, if present, must cover serverAuth/clientAuth as used. Separate application membership attributes.", "components": ["docs"], "sources": ["docs:sb527c92150ce", "docs:s2862bf749970"], "status": "REASONED"}, "membership-keyfile": {"text": "The alternative generates one shared key with openssl rand -base64 756, service-account ownership and mode 400; securely distribute it to all members and routers, set keyFile and clusterAuthMode: keyFile, and retain TLS.", "components": ["docs", "bash"], "sources": ["docs:s2093f0ae077e", "bash:sa2bb528509ad", "bash:s9900e479e21e"], "status": "REASONED"}, "keyfile-rotation": {"text": "Unix keyfiles require no group/world permissions and service-account readability; multiple rotation keys are allowed but every member must share a common key.", "components": ["docs"], "sources": ["docs:s2093f0ae077e"], "status": "REASONED"}, "transition-auth": {"text": "transitionToAuth: true permits unauthenticated operations without enforcing user access controls; keep it false in the final state and use vendor procedures for existing-cluster migration.", "components": ["docs"], "sources": ["docs:s22791f6f25b1", "docs:s2093f0ae077e"], "status": "REASONED"}, "mongos-access-control": {"text": "Keep authorization enabled on mongod; security.authorization is unavailable on mongos, where internal authentication enables client access control.", "components": ["docs"], "sources": ["docs:s1f0c41f45f41", "docs:sa7d56fbc7a01", "docs:s2093f0ae077e"], "status": "REASONED"}, "member-source": {"text": "clusterIpSourceAllowlist, introduced in 5.0, restricts internal authentication only when authentication is enabled; include every member/router source after NAT. It does not restrict application accounts or TCP reachability.", "components": ["docs"], "sources": ["docs:s22791f6f25b1"], "status": "REASONED"}, "scram-client": {"text": "mongosh selects the user creation database and SCRAM-SHA-256 over TLS on 27017; final --password prompts without placing the password in argv.", "components": ["shell"], "sources": ["shell:s5ed633d4d270"], "status": "REASONED"}, "driver-tls": {"text": "Enable driver TLS and CA trust with certificate and hostname validation; tlsCAFile is not supported by every driver. Do not ship tlsAllowInvalidCertificates.", "components": ["docs"], "sources": ["docs:s48d0e6640a2c"], "status": "REASONED"}, "x509-identity": {"text": "Register the exact RFC2253 subject in $external with the application role and use MONGODB-X509; without a username mongosh uses the certificate subject. TLS validation alone does not authenticate a MongoDB user.", "components": ["docs", "shell"], "sources": ["docs:s85f35d70e6ed", "shell:s5ed633d4d270"], "status": "REASONED"}, "x509-client-certificate": {"text": "Application certificates must be current, satisfy CA requirements and contain digitalSignature and clientAuth; keep subjects and membership attributes separate from server/member certificates to avoid internal privileges.", "components": ["docs"], "sources": ["docs:s0b650ef2f8d8"], "status": "REASONED"}, "x509-source": {"text": "Apply source restrictions separately to the $external subject; restrictions on a SCRAM identity do not transfer to the certificate identity.", "components": ["docs"], "sources": ["docs:s6a592c920ac2", "docs:s85f35d70e6ed"], "status": "REASONED"}, "local-guards": {"text": "Recorded local placeholder and JavaScript guard tests rejected invalid inputs before database access; shell argument tracing did not verify MongoDB behavior. Tool versions are unrecorded. The shell guard tests covered an earlier form whose refusals exited with status 1; the current form was not re-run.", "components": ["bash", "docs"], "sources": ["bash:s9900e479e21e", "docs:sb59a2357999d"], "status": "DEMONSTRATED", "evidence": "Placeholder tests rejected unchanged tokens, embedded `REPLACE_WITH_` values, angle brackets, empty values, example hosts, shortened assignments, an omitted assignment with ordinary inherited arguments, and a wrong argument count under a modified `IFS`. The JavaScript probe guards also rejected unchanged placeholders before database access."}, "verify-listeners": {"text": "Inspect all database/router listeners and configured ports; unintended wildcard/public binds are exposed. Private binds require separate firewall checks; the recorded netlink refusal establishes nothing about listeners.", "components": ["docs"], "sources": ["docs:s8c3f376f412d", "docs:s22791f6f25b1"], "status": "REASONED", "verify": [1]}, "verify-dispatch": {"text": "Use the whole guarded six-value connection block, real certificate hostname and application PEM; --norc prevents shell startup authentication. Guards assume normal Bash builtins and cannot distinguish inherited exact marker/count arguments.", "components": ["shell", "bash"], "sources": ["shell:s5ed633d4d270", "bash:s9900e479e21e"], "status": "REASONED", "verify": [2]}, "verify-plaintext": {"text": "Plaintext ping succeeds when plaintext is accepted and must fail under requireTLS while a same-endpoint authenticated TLS control succeeds; timeout/refusal alone is inconclusive and ping does not prove authorization.", "components": ["docs", "shell"], "sources": ["docs:s22791f6f25b1", "docs:seee0e2f961d6", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-anonymous": {"text": "With a valid client certificate but no authenticated MongoDB identity, an existing collection read succeeds with authorization off and fails Unauthorized (13) with it on; pair with an authenticated read and inspect connectionStatus.", "components": ["docs", "shell"], "sources": ["docs:s6eb89ab49dad", "docs:sb59a2357999d", "docs:sbe43794c8d1c", "docs:s9a8c112b4424", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-listdatabases": {"text": "listDatabases depends on privileges and authorizedDatabases and cannot prove collection authorization; empty output, timeout or unrelated errors are not authorization denials.", "components": ["docs"], "sources": ["docs:s6fe5567c6b9f", "docs:s9a8c112b4424"], "status": "REASONED"}, "verify-role-allow": {"text": "In a disposable fixture, the application role must insert, update and read its own probe document; check result counts and contents with fresh IDs and valid documents.", "components": ["docs", "shell"], "sources": ["docs:sb59a2357999d", "docs:s4012eeed7cf2", "docs:s051aa306cd4c", "docs:sbe43794c8d1c", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-role-read-denial": {"text": "The application role must deny reads of an unrelated existing collection with Unauthorized (13); database-wide readWrite is the exposed comparison.", "components": ["docs", "shell"], "sources": ["docs:sb59a2357999d", "docs:sbe43794c8d1c", "docs:sfb89d8101c88", "docs:s9a8c112b4424", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-role-insert-denial": {"text": "The application role must deny inserts into the unrelated collection with Unauthorized (13); database-wide readWrite permits them. Test each denial separately because the script stops at its first unexpected result.", "components": ["docs", "shell"], "sources": ["docs:s4012eeed7cf2", "docs:sbe43794c8d1c", "docs:sfb89d8101c88", "docs:s9a8c112b4424", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-role-delete-denial": {"text": "The application role must deny deletion from its own collection with Unauthorized (13); database-wide readWrite permits it. The deployment identity cleans up probe documents afterward.", "components": ["docs", "shell"], "sources": ["docs:s181c6bd1f977", "docs:sbe43794c8d1c", "docs:sfb89d8101c88", "docs:s9a8c112b4424", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-client-source": {"text": "With TLS reachability proven from both sources, valid credentials work from both before restriction; afterward only the permitted source authenticates and reads. TLS failures/timeouts do not prove clientSource enforcement.", "components": ["docs", "shell"], "sources": ["docs:s6a592c920ac2", "docs:s6eb89ab49dad", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-server-address": {"text": "Hold source, credentials and client certificate constant; both reachable listener addresses authenticate before serverAddress restriction, then only the allowed listener does. Permitted-listener tests alone cannot detect an omitted restriction.", "components": ["docs", "shell"], "sources": ["docs:s6a592c920ac2", "docs:s6eb89ab49dad", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-x509": {"text": "The same trusted non-member certificate must fail x.509 authentication before subject registration and succeed afterward with only assigned roles; inspect the exact $external identity and repeat role checks.", "components": ["docs", "shell"], "sources": ["docs:s85f35d70e6ed", "docs:s0b650ef2f8d8", "docs:s6eb89ab49dad", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-missing-certificate": {"text": "Certificate-less TLS ping can succeed when allowConnectionsWithoutCertificates is true; false must reject it while a registered-certificate control succeeds. Repeat anonymous collection access with a valid certificate to test authorization separately.", "components": ["docs", "shell"], "sources": ["docs:sa9e55b825b2a", "docs:seee0e2f961d6", "docs:s85f35d70e6ed", "shell:s5ed633d4d270"], "status": "REASONED", "verify": [2]}, "verify-membership": {"text": "In an isolated cluster compare valid and invalid membership credentials; correlate replSetGetStatus with explicit authentication failures, since unhealthy members alone do not prove enforcement. Include router/shard/config-server paths.", "components": ["docs"], "sources": ["docs:sb527c92150ce", "docs:sc4c49175cea4"], "status": "REASONED"}, "verify-member-source": {"text": "Compare permitted and excluded member source addresses against an isolated deployment lacking clusterIpSourceAllowlist; hold application TLS constant and correlate member health with authentication failures.", "components": ["docs"], "sources": ["docs:s22791f6f25b1", "docs:sc4c49175cea4"], "status": "REASONED"}, "verify-audit-destination": {"text": "Without a destination no audit records appear; with one, correlate failed authentication and collection-creation records with identity, operation and time. A file alone does not prove coverage; an authentication-only filter excludes other events.", "components": ["v8"], "sources": ["v8:s6f1f3f56616d", "v8:s9e86819e68db", "v8:s9fcdc05982a6"], "status": "REASONED"}, "verify-audit-authorization": {"text": "The unauthorized read produces failed authCheck result 13 regardless of auditAuthorizationSuccess; the permitted read produces successful authCheck only when true. Inspect getParameter and compare off/on, accounting for performance cost.", "components": ["v8"], "sources": ["v8:s9e86819e68db", "v8:s1f3040d0fe4c"], "status": "REASONED"}, "verify-redaction": {"text": "At verbosity 1, compare a logged canary with redaction off/on; the matching entry remains while values become ### and metadata remains. A missing entry is inconclusive; restore prior verbosity.", "components": ["v8"], "sources": ["v8:s1f3040d0fe4c", "v8:sc9621a9507cc"], "status": "REASONED"}, "verify-storage": {"text": "Disposable encrypted data copies require the correct key configuration and a working reader; an unencrypted fixture reopens without a key. Inspect key-manager initialization; a missing key or wrong KMIP identity prevents startup, and strings cannot prove encryption.", "components": ["v8"], "sources": ["v8:s3b7dabf8f41b", "v8:sf88108a9609d"], "status": "REASONED", "verify": [3]}, "verify-csfle": {"text": "Use separate clients to compare plaintext and encrypted field bytes by retained _id; a key-authorized client recovers the value. Community explicitly encrypts before writing; the automatic-encryption client silently decrypts and cannot serve as the exposed control.", "components": ["v8"], "sources": ["v8:sdd8ad5fe92b2", "v8:s23650ad42657", "v8:s2876af0be5e7"], "status": "REASONED"}, "verify-csfle-schema": {"text": "A plaintext write to a server-side $jsonSchema requiring encryption is rejected; denied reads alone do not demonstrate field encryption.", "components": ["v8"], "sources": ["v8:s2a52e3acf56a", "v8:s2876af0be5e7"], "status": "REASONED"}, "verify-qe-ciphertext": {"text": "Compare ordinary and encryptedFields collections through distinct clients: plaintext versus ciphertext without decryption, original value with keys; Community encrypts explicitly. Reject plaintext writes to declared encrypted fields.", "components": ["v8"], "sources": ["v8:se39572dfb8ad", "v8:s1d229cb03243", "v8:s47b976aa1f45"], "status": "REASONED"}, "verify-qe-equality": {"text": "An encryptedFields field without queryType is encrypted but not queryable; set equality on the encrypted string field and confirm the configured client matches an encrypted equality query.", "components": ["v8"], "sources": ["v8:se39572dfb8ad", "v8:sd08442f8ff38"], "status": "REASONED"}, "verify-qe-range": {"text": "Test range separately with queryType: range on int, long, double, decimal or date, not a string, and documents inside/outside the interval; missing results or connection errors do not establish confidentiality.", "components": ["v8"], "sources": ["v8:sd08442f8ff38"], "status": "REASONED"}, "audit-destination": {"text": "Enterprise auditing covers mongod and mongos; Community has no equivalent and diagnostic logs are not a substitute. Configure a destination on every process; the example selects a JSON file.", "components": ["v8"], "sources": ["v8:s6f1f3f56616d", "v8:seedf77aa3e73"], "status": "REASONED"}, "audit-write": {"text": "A failed audit write can terminate the process; validate that the destination is writable.", "components": ["v8"], "sources": ["v8:s6f1f3f56616d", "v8:seedf77aa3e73"], "status": "REASONED"}, "audit-filter": {"text": "Omitting auditLog.filter permits all auditable event types, but successful authCheck events still require auditAuthorizationSuccess: true; an authenticate-only filter excludes other event types.", "components": ["v8"], "sources": ["v8:s9fcdc05982a6", "v8:s1f3040d0fe4c"], "status": "REASONED"}, "audit-success-default": {"text": "In MongoDB 8.0, auditAuthorizationSuccess defaults false, so authCheck records only authorization failures; enabling it adds successful checks at a performance cost.", "components": ["v8"], "sources": ["v8:s1f3040d0fe4c", "v8:s9e86819e68db"], "status": "REASONED"}, "diagnostic-redaction": {"text": "Enterprise-only redactClientLogData uses a startup flag or security.redactClientLogData; document values become ### while metadata remains. Its documented scope is diagnostic logs, not audit logs; combine with TLS and storage encryption.", "components": ["v8"], "sources": ["v8:s1f3040d0fe4c", "v8:s4ffa7b653364", "v8:sc9621a9507cc"], "status": "REASONED"}, "storage-encryption": {"text": "Enterprise 3.2 introduced native WiredTiger encryption; Community depends on host/filesystem encryption. security.enableEncryption defaults false.", "components": ["v8"], "sources": ["v8:s3b7dabf8f41b", "v8:see769a1a1e2b"], "status": "REASONED"}, "storage-migration": {"text": "Native encryption does not encrypt existing data in place; use a fresh member and initial sync or the documented migration.", "components": ["v8"], "sources": ["v8:s3b7dabf8f41b", "v8:sf88108a9609d"], "status": "REASONED"}, "storage-local-key": {"text": "security.encryptionKeyFile supplies the storage master key, unrelated to membership keyFile; local key management does not support rotation.", "components": ["v8"], "sources": ["v8:see769a1a1e2b", "v8:sf88108a9609d"], "status": "REASONED"}, "storage-kmip": {"text": "Prefer KMIP to a local key file; retain enableEncryption and configure serverName, port 5696, serverCAFile and clientCertificateFile containing the client certificate and private key.", "components": ["v8"], "sources": ["v8:s3b7dabf8f41b", "v8:see769a1a1e2b", "v8:sf88108a9609d"], "status": "REASONED"}, "storage-key-identifier": {"text": "Set security.kmip.keyIdentifier only to adopt an existing key when first enabling encryption; otherwise MongoDB requests a new key.", "components": ["v8"], "sources": ["v8:sf88108a9609d"], "status": "REASONED"}, "csfle-editions": {"text": "CSFLE encrypts fields in the driver before the server, with no mongod switch; explicit encryption and automatic decryption work in Community, while automatic encryption requires Enterprise or Atlas.", "components": ["v8"], "sources": ["v8:sdd8ad5fe92b2", "v8:s23650ad42657"], "status": "REASONED"}, "csfle-schema": {"text": "Automatic CSFLE autoEncryption names the key vault, KMS providers and local schemaMap; relying only on a server-fetched schema lets a compromised server induce plaintext writes.", "components": ["v8"], "sources": ["v8:s2a52e3acf56a", "v8:s2876af0be5e7"], "status": "REASONED"}, "csfle-explicit": {"text": "Community explicit CSFLE sets bypassAutoEncryption: true and calls ClientEncryption.encrypt() before writing and for query values; keep master-key access separate from database access.", "components": ["v8"], "sources": ["v8:s23650ad42657", "v8:s2876af0be5e7"], "status": "REASONED"}, "csfle-algorithms": {"text": "Randomized encryption does not support equality matching; deterministic encryption does but reveals equal stored values. A server-side $jsonSchema can reject plaintext writes.", "components": ["v8"], "sources": ["v8:sb5d5b8d3dfa1", "v8:s2a52e3acf56a"], "status": "REASONED"}, "qe-releases": {"text": "QE equality queries became GA in 7.0 and range queries in 8.0; the incompatible 6.0 public preview is not a production baseline.", "components": ["v8"], "sources": ["v8:s1b94cb5e8305", "v8:s43213b00956d", "v8:s1d229cb03243"], "status": "REASONED"}, "qe-editions": {"text": "QE encrypts fields in the driver for supported ciphertext queries; automatic encryption requires Enterprise or Atlas, while Community supports explicit encryption and automatic decryption.", "components": ["v8"], "sources": ["v8:se39572dfb8ad", "v8:s47b976aa1f45"], "status": "REASONED"}, "qe-collection": {"text": "QE requires a new encryptedFields collection, cannot be enabled in place, and cannot share a collection with CSFLE.", "components": ["v8"], "sources": ["v8:s1d229cb03243"], "status": "REASONED"}, "qe-client": {"text": "Automatic QE uses autoEncryption with key-vault namespace, KMS providers and local encryptedFieldsMap; Community explicit QE uses bypassQueryAnalysis: true and ClientEncryption.", "components": ["v8"], "sources": ["v8:se55ecf8e7f85", "v8:s47b976aa1f45"], "status": "REASONED"}, "qe-topology": {"text": "QE supports replica sets and sharded clusters, not standalone servers.", "components": ["v8"], "sources": ["v8:s1d229cb03243"], "status": "REASONED"}, "qe-range-preview": {"text": "rangePreview was removed in 8.0; do not carry legacy rangePreview recipes forward.", "components": ["v8"], "sources": ["v8:s09f3af996812"], "status": "REASONED"}, "audit-other-events": {"text": "With MongoDB 8.0 Enterprise/Atlas auditing enabled and the filter permitting them, authentication, schema (DDL) and replica-set events are recorded independently of auditAuthorizationSuccess.", "components": ["v8"], "sources": ["v8:s6f1f3f56616d", "v8:s9e86819e68db", "v8:s1f3040d0fe4c"], "status": "REASONED"} } } ```` ====================================================================== ==> redis.md ====================================================================== # Redis: TLS and authentication Redis is an in-memory data store used as a cache, key-value database or message channel. This guide helps your AI assistant configure it securely, including a local bind unless remote access is deliberate, a required credential, locked-down runtime commands, and TLS where server and client are built with it. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/redis.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Redis configuration 6.0.20, 6.2.14, 6.2.0, 7.0.15, 8.2.1, and 8.6.0, Redis HA source 8.10.1, Redis image tag v8.10.2, and Redis compiled defaults 7.4.8 and 8.10.2; 1 further source is pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 59 claims: 1 demonstrated by a check that ran and 58 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Redis trusts its network by design, so the network boundary and credentials are your job. An exposed unauthenticated Redis leaks its data, and historic attack tooling has also used the CONFIG command against open instances to write files and take over hosts. Applies to Redis 6.0 and later for TLS and ACLs; the server and client must be built with TLS support. Additional controls below carry their minimum versions. See the [Redis security](https://redis.io/docs/latest/operate/oss_and_stack/management/security/) and [TLS documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/). This guide targets upstream Redis 6.0 and later. The existing configuration references through 8.2.1 pin syntax and historical behaviour, not a deployment version. Sections 9-15 add deployment-exposure controls for replication, Sentinel, and Redis Cluster, reviewed against upstream Redis 8.10.1. Version-specific examples carry their own qualifications; the Redis 8.10 peer-name control does not belong in a generic Redis 6.0+ configuration. These HA findings establish no equivalent Valkey or Redis Enterprise behaviour; the Valkey compatibility note remains separate. ## 1. Keep it local unless remote access is deliberate In `redis.conf`: ``` # Redis 6.2+: "-" makes an address optional if it is absent from local interfaces. # An address already in use still fails startup. # Redis 6.0: use "bind 127.0.0.1" instead. # Keep comments on separate lines. bind 127.0.0.1 -::1 protected-mode yes ``` `protected-mode` confines clients to the loopback interfaces and Unix sockets, but only while the default user still has its `nopass` flag and, on Redis 6.0/6.2, no explicit `bind` is configured. Redis 7.0 keeps the `nopass` condition and drops the `bind` condition. On Redis 6.0/6.2, setting any explicit `bind` disables this protected-mode restriction; a wildcard bind such as `bind 0.0.0.0` then permits remote passwordless access while the default user remains `on nopass`, whereas a loopback-only `bind 127.0.0.1` still confines access to the local host. Treat protected mode as a backstop for the unconfigured default; the bind address and credentials are the controls. The pinned [6.2 configuration](https://raw.githubusercontent.com/redis/redis/6.2.14/redis.conf) and [7.0 configuration](https://raw.githubusercontent.com/redis/redis/7.0.15/redis.conf) document the version difference. The official Docker images change the protected-mode default. Their build, from redis/docker-library-redis, patches Redis so `protected-mode` defaults to `no`; this holds for every image on the repository's default branch at the pinned commit (6.2 through 7.4, Debian and Alpine) and for the 8.10.2 release tag, the only 8.x build checked. Their default command runs `redis-server` with no configuration file, so the compiled-in defaults apply; in the 7.4.8 and 8.10.2 builds those are `bind * -::*` (every IPv4 address, and every IPv6 address where the container has IPv6) and a `default` user that is `on`, `nopass` and `+@all`. A container from those images, started without a configuration file or arguments of its own, therefore accepts unauthenticated connections from any address that reaches it, with the `default` user's `+@all` permissions (commands that Redis disables by default, such as `DEBUG` and `MODULE` on 7.0 and later, stay disabled; see section 5). Start it with a configuration file carrying the credential settings in section 2 (a loopback `bind` inside the container would also block published traffic), and publish only to host loopback or a private network. ## 2. Require a credential Minimum, using a single shared password sent by clients with `AUTH`: ``` requirepass REPLACE_WITH_LONG_RANDOM_PASSWORD ``` Better, per-service ACL users with least privilege, supported since Redis 6: ``` user app on >REPLACE_WITH_LONG_RANDOM_PASSWORD ~app:* +@read +@write -flushall -flushdb -swapdb ``` That grants the `app` user the read and write command categories, and `~app:*` restricts only the key arguments those commands take, to keys named `app:*`. It does not confine commands that take no key: `+@write` still includes `FLUSHALL` (clears every database), `FLUSHDB` (clears the current database), `SWAPDB` (swaps two databases), and, on Redis 7 and later, the FUNCTION write subcommands (`FUNCTION LOAD`, `FUNCTION DELETE`, `FUNCTION FLUSH`, `FUNCTION RESTORE`) that manage global libraries. `+@read` still includes keyspace-walking commands such as `KEYS`, `SCAN`, and `RANDOMKEY` that range outside `app:*`. The `-flushall -flushdb -swapdb` above removes those three commands only, not every keyless write. For real least privilege, allowlist only the commands the app uses, for example `~app:* +get +set`, and expand that list deliberately. Apply that replacement with `reset`, as shown in section 3, rather than adding it to an existing broad policy. See the [ACL rules](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/) and the command references in Sources. Generate passwords per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). The `default` user starts `on nopass ~* +@all` (Redis 6.2 adds `&*` for Pub/Sub channels), so with ACLs as your control you must secure it before any remote exposure: give it a strong password, or set `user default off resetpass` in `redis.conf` or the ACL file and apply it on a running server with `ACL SETUSER default off resetpass`. Do that only after every client authenticates as a named user and a separate named administrator has been tested. A connection already authenticated as `default` keeps working until it disconnects; section 3 covers revocation. See the [ACL authentication and user-state rules](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/). MFA: Redis has no second-factor dialogue. For machine clients, `tls-auth-clients yes` (mutual TLS, below) adds a possession factor, a certificate, alongside the password; that is stronger than a password alone but it is not MFA for a person. Human paths to the host go behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). On Redis 8.6+, the certificate-plus-password description assumes certificate-to-user automatic authentication is disabled. Redis 8.6 introduced `tls-auth-clients-user`, which defaults to `off`; enabling it can authenticate a connection without a separate password exchange. On Redis 8.6+, keep this setting `off` for the guide's certificate-plus-password and unauthenticated-certificate `NOAUTH` checks. Earlier releases do not support this directive; omit it entirely, because an unknown configuration directive prevents startup. See the [8.6 release notes](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.6-release-notes/), [pinned certificate authentication configuration](https://raw.githubusercontent.com/redis/redis/8.6.0/redis.conf), and [registered default](https://raw.githubusercontent.com/redis/redis/8.6.0/src/config.c). ## 3. Make ACL restrictions durable and replace old permissions explicitly `ACL SETUSER` modifies an existing user incrementally. A narrower-looking command does not remove earlier passwords, key patterns, or command grants. Start a replacement policy with `reset`; on Redis 7+, that also clears selectors, which otherwise provide alternative grants. Rules are processed from left to right. See [ACL SETUSER](https://redis.io/docs/latest/commands/acl-setuser/). Use a separate administrative account, called `ops` here, with the permissions needed for the management commands below. Test its login before changing application users or disabling `default`. Connect over TLS using section 7 with `--user ops`. The following is input at the Redis prompt, not a shell command. Replace the password placeholder there. Do not put the password or the complete `ACL SETUSER` command in the shell invocation's arguments. ``` ACL SETUSER app reset on >REPLACE_WITH_LONG_RANDOM_PASSWORD resetchannels ~app:* +get +set ``` This example requires Redis 6.2+ because of `resetchannels`. For Redis 6.0, omit that token; channel ACLs are unavailable there. Explicitly resetting channels avoids inheriting the Redis 6.2 `allchannels` default. Redis 7.0 changed that default to `resetchannels`. See the [pinned ACL configuration](https://raw.githubusercontent.com/redis/redis/8.2.1/redis.conf). Keep CLI history disabled as shown in section 7. Passwords entered in ACL commands can still appear on the terminal or in session recordings. Protect configuration and ACL files, including their password hashes. Choose one persistence method: - **Users in `redis.conf`:** after checking the runtime policy, run `CONFIG REWRITE` from the administrative Redis prompt. Redis must have been started with a configuration file and must be able to write it. Require an `OK` response. This rewrites other runtime configuration too, so review the resulting file. See [CONFIG REWRITE](https://redis.io/docs/latest/commands/config-rewrite/). - **Users in an external ACL file:** configure the following in `redis.conf`, and move all `user` definitions, including the administrator and secured `default` user, into that file. Redis refuses to start if inline `user` definitions and `aclfile` are both configured. ``` aclfile /etc/redis/users.acl ``` With `aclfile` configured, run `ACL SAVE` to persist runtime ACL changes. If instead you edit the ACL file, run `ACL LOAD` to replace the runtime rules from it. Require `OK`; an invalid file leaves the previous runtime rules in place. `CONFIG REWRITE` does not perform `ACL SAVE`. Do not combine this mode with `requirepass`: configure the default user's credential in the ACL file instead. See [ACL persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#use-an-external-acl-file), [ACL SAVE](https://redis.io/docs/latest/commands/acl-save/), and [ACL LOAD](https://redis.io/docs/latest/commands/acl-load/). Keep the deployment's configuration source synchronized with the saved policy so a redeployment does not restore old permissions. For a cutover from an old named user, first authenticate and test the replacement user. Then, from the separate administrator's Redis prompt, disable the old user and close its sessions: ``` ACL SETUSER app-old off resetpass CLIENT KILL USER app-old SKIPME no ``` Replace `app-old` with the actual retired username. Disabling the user prevents new authentication; `CLIENT KILL` closes existing authenticated connections. Its integer reply is the number closed, which may be zero. Persist the disabled user with the chosen method. When retiring `default`, use `default` in both commands after migrating every client. See [user disabling](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/) and [CLIENT KILL](https://redis.io/docs/latest/commands/client-kill/). If retaining the username while changing its password, existing connections are not forced to authenticate again. After applying the replacement policy, use `CLIENT KILL USER app SKIPME no` from the separate administrator to force reconnection, and account for the resulting application interruption. ## 4. Separate read/write key permissions and Pub/Sub channel permissions Redis 7.0+ supports `%R~pattern` for reading key values and `%W~pattern` for writing keys. Ordinary `~pattern` grants both. Command permission is also required; a key pattern alone grants no commands. Avoid adding a broader `~app:*` grant that would defeat the intended separation. See [key permissions](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#key-permissions). For a worker that reads inputs, writes outputs, and publishes events, enter this replacement policy at the administrative Redis prompt: ``` ACL SETUSER app-worker reset on >REPLACE_WITH_DISTINCT_WORKER_PASSWORD resetchannels %R~app:input:* %W~app:output:* &app:events:* +get +set +publish ``` Persist it using section 3. The intended permissions are: | Operation | Result | | --- | --- | | `GET app:input:probe` | Allowed | | `SET app:output:probe value` | Allowed | | `SET app:input:probe value` | Denied | | `GET app:output:probe` | Denied | | `PUBLISH app:events:probe value` | Allowed | | `PUBLISH other:events:probe value` | Denied | Key permissions and channel permissions are separate. `~app:*` does not restrict Pub/Sub channels; `&app:events:*` does not grant access to keys. Channel patterns require Redis 6.2+. Start with `resetchannels`, then add only the necessary channel patterns and commands. For an application without Pub/Sub, keep `resetchannels` and grant no Pub/Sub commands. See [ACL SETUSER channel rules](https://redis.io/docs/latest/commands/acl-setuser/). The worker above cannot subscribe. If a subscriber needs `SUBSCRIBE`, grant it deliberately. If it needs `PSUBSCRIBE`, its requested pattern must literally match an allowed channel pattern; an apparently narrower glob is not sufficient. See the [Pub/Sub ACL rules](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#acl-rules). ## 5. Lock down runtime configuration, debugging, modules, and scripting Keep administrative capabilities out of application users. The explicit command allowlists above already exclude them. When reviewing a broader policy, subtract administrative, dangerous, and scripting categories after any grants. For the selector-free `app` policy established in section 3, this administrative Redis command makes the exclusions explicit on Redis 7+: ``` ACL SETUSER app -@admin -@dangerous -@scripting -config -debug -module -script -function ``` On Redis 6.x, omit `-function`, because Redis Functions arrived in Redis 7.0. Persist the result. For an existing policy with unknown selectors, rebuild it with `reset` first; subtracting from the root rules does not remove an alternative selector's grants. See [ACL SETUSER](https://redis.io/docs/latest/commands/acl-setuser/). `-@admin -@dangerous` alone does not disable scripting. For example, `EVAL`, `EVALSHA`, and `FCALL` belong to `@scripting`; FUNCTION library writes also belong to `@write`. Removing `SCRIPT` and `FUNCTION` alone does not remove the separate execution commands. Use `-@scripting` when the application needs no scripting, including the read-only execution variants. See [EVAL](https://redis.io/docs/latest/commands/eval/), [EVALSHA](https://redis.io/docs/latest/commands/evalsha/), [FCALL](https://redis.io/docs/latest/commands/fcall/), and [FUNCTION LOAD](https://redis.io/docs/latest/commands/function-load/). If scripting is required, add only the execution commands the application needs and review its key permissions. Keep script and function management with the deployment administrator. On Redis 7.0+, keep these startup settings in `redis.conf`: ``` enable-debug-command no enable-module-command no ``` At the time of writing, `no` is the default in the pinned configuration. The alternatives are `yes` and `local`; a local connection is still an application connection that needs appropriate ACLs. These settings supplement command permissions. Disabling runtime module management does not remove modules loaded at startup or deny their own commands. Retain explicit command allowlists and review any modules enabled by the deployment. See the [pinned startup configuration](https://raw.githubusercontent.com/redis/redis/8.2.1/redis.conf). `rename-command` is deprecated. Use ACLs to restrict commands instead of relying on renamed commands as an access boundary. See [Redis command restrictions](https://redis.io/docs/latest/operate/oss_and_stack/management/security/#disallowing-specific-commands). ## 6. Enable TLS Get a certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)), then replace the plaintext port with a TLS listener: ``` # No plaintext listener. port 0 tls-port 6379 tls-cert-file /etc/redis/tls/server.crt tls-key-file /etc/redis/tls/server.key tls-ca-cert-file /etc/redis/tls/ca.crt # The default is yes: require a client certificate. # This deliberate downgrade relies on the password/ACL control in section 2. tls-auth-clients no ``` Set `tls-auth-clients yes` for machine-to-machine deployments where clients can hold certificates; it is stronger than passwords alone. With `yes`, clients must also pass `--cert client.crt --key client.key`, or the TLS handshake is rejected before any `AUTH`. Confirm that a missing or untrusted client certificate is refused. See [Redis TLS](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/). For HA deployments, the deliberate `tls-auth-clients no` above also removes the client-certificate requirement for incoming replicas on the data listener. Use section 13's mutual TLS settings for those connections. Enabling the client TLS listener alone does not enable outgoing replication TLS or cluster-bus TLS. The TLS cluster bus always requires a peer certificate, regardless of the data listener's `tls-auth-clients` setting. See the [replication TLS documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/) and pinned [cluster accept implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/cluster_legacy.c). ## 7. Client side Replace the host inside the single quotes with your Redis hostname, such as `redis.example.com`, and paste the whole block. `--askpass` reads the password from standard input with masking, keeping it out of process arguments. Use `--user ops` for administrative sessions. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_REDIS_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 host; not connecting"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute your Redis host; not connecting"; exit 2 ;; *) REDISCLI_HISTFILE=/dev/null redis-cli --tls \ --cacert /etc/redis/tls/ca.crt -h "$1" -p 6379 --user app --askpass ;; esac ) ``` With `tls-auth-clients yes`, also add `--cert /etc/redis/tls/client.crt --key /etc/redis/tls/client.key` to the TLS invocation. The client sends the named-user `AUTH` exchange. At the Redis prompt: ``` GET app:probe ``` `GET app:probe` is allowed by both the introductory ACL and the narrowed policy in section 3; `(nil)` is a successful read of a missing key. `PING` sits in the `@connection` and `@fast` categories, which neither policy grants, so it fails for `app`. Application clients take equivalent TLS and credential options; point them at the CA rather than disabling verification. See [Redis CLI](https://redis.io/docs/latest/develop/tools/cli/), [AUTH](https://redis.io/docs/latest/commands/auth/), [GET](https://redis.io/docs/latest/commands/get/), and [PING](https://redis.io/docs/latest/commands/ping/). ## 8. Verify (REASONED: deployment and HA comparisons from cited Redis documentation and pinned sources; no Redis runtime, live topology or permitted socket inspection.) REASONED: the deployment checks below, from the cited Redis documentation and pinned sources; they are not demonstrated. The authoring environment has no `redis-server`, `redis-cli`, Docker, or Podman, and denies socket inspection. No live deployment was supplied. Shell syntax and placeholder rejection were checked locally; Redis configuration parsing remains unconfirmed, and exposed/fixed runtime behaviour is REASONED from the cited documentation and pinned sources. Those local checks covered an earlier form of the guarded shell blocks, whose refusals exited with status 1 and whose probes set no status of their own. The current form of every shell block in this section is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026 under bash 5.3.9, with stand-ins for `ss`, `redis-cli`, `nc`, `openssl` and `timeout`; no Redis process or network was involved. Each block now exits once with one status: 1 for a finding, which outranks 3 for a failed command or an inconclusive result; 2 for a refusal before any probe; and 0 otherwise. Once its probes run, its last line names what it leaves for you to judge. The two listener inventories refuse with status 2, printing `ss is not installed here; not listing sockets`, when `ss` is missing, and the replica-certificate, cluster-bus certificate and peer-name checks refuse with status 2 when a CA, certificate or key file they read is not readable. For a plaintext request, a closed or reset connection without a RESP reply is the expected rejection, and any other plaintext failure is inconclusive. In the replica-certificate, cluster-bus certificate and peer-name checks, a failed negative probe counts as the expected rejection only when its error names a bad-certificate, certificate-unknown or certificate-required alert, or an unknown-CA alert for the untrusted certificate. A generic handshake failure, a closed or reset connection or any other failure there cannot be told apart from an unrelated failure, so it is inconclusive even in a fixed state, and the server's log line for the rejected handshake is left for you to read. Which error text `redis-cli` and OpenSSL print, and which alert a rejecting server sends, is this guide's assessment, not observed here, so a real rejection worded otherwise reads as inconclusive. **Listener inventory.** On the Redis host, inspect every listener: ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tlnp; then echo "unclassified observation: read every listener above; for the local-only configuration, Redis TCP listeners belong on loopback" exit 0 else echo "ss -tlnp exit $?: a socket-inspection error is inconclusive; read the error above" exit 3 fi ) ``` For the local-only configuration, Redis TCP listeners should be on loopback. An unintended wildcard or external-interface listener fails that configuration's check. Deliberate remote access needs the intended private listener and network restrictions. A socket-inspection error is inconclusive. The bind behaviour follows the [pinned configuration](https://raw.githubusercontent.com/redis/redis/8.2.1/redis.conf). **Plaintext and unauthenticated TLS checks.** Run from a client location that should reach Redis. Substitute the actual hostname inside the quotes. For mutual TLS, add the client certificate and key options to the TLS command. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_REDIS_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 host; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute your Redis host; not probing"; exit 2 ;; esac unset REDISCLI_AUTH || exit 2 set -- 0 "$1" "$(if redis-cli -h "$1" -p 6379 ping 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), the host, redis-cli's exit status, then its output printf '%s' "$4" case "$3:$4" in 0:*|*PONG*|*NOAUTH*) echo "FINDING: the plaintext ping got a RESP reply, so plaintext Redis is still available at this endpoint"; set -- 1 "$2" ;; *'Server closed the connection'*|*'Connection reset by peer'*) echo "no RESP reply recognized to the plaintext ping (redis-cli exit $3); the TLS ping below decides whether that is the pass"; set -- 0 "$2" ;; *) echo "the plaintext ping got neither a RESP reply nor a closed or reset connection (redis-cli exit $3), as a timeout, resolution failure, refusal or local error does; inconclusive"; set -- 3 "$2" ;; esac set -- "$1" "$2" "$(if redis-cli --tls --cacert /etc/redis/tls/ca.crt -h "$2" -p 6379 ping 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # status, host, redis-cli's exit status, then its output printf '%s' "$4" case "$4" in *NOAUTH*) echo "the TLS ping reached NOAUTH, the secured default user's reply" ;; *PONG*) echo "FINDING: the TLS ping got PONG without credentials, as an exposed default on nopass +@all does"; set -- 1 "$2" ;; *) echo "the TLS ping reached neither NOAUTH nor PONG (redis-cli exit $3), so it did not confirm the endpoint; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" ;; esac echo "block exit $1; the authenticated GET app:probe positive control from section 7 is left for you to run and judge" exit "$1" ) ``` Any RESP reply to the plaintext command, including `PONG`, `NOAUTH`, or another Redis error, means plaintext Redis is still available at that endpoint. With `port 0` and `tls-port 6379`, TCP 6379 remains open for TLS: expect the plaintext connection to close or reset without a RESP reply. A timeout, name-resolution failure, or connection refusal alone proves nothing. Confirm that the TLS command reaches the same endpoint. For the secured default user in this guide, the unauthenticated TLS request should return `NOAUTH`. An exposed `default on nopass +@all` returns `PONG` when the connection is otherwise permitted. With mutual TLS, a missing client certificate fails before this authentication check. Then authenticate as `app` using section 7 and confirm `GET app:probe` succeeds. These distinctions follow the [TLS listener documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/) and [ACL default-user rules](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/). **ACL readback and denied-privilege review.** In the separate administrative session: ``` ACL GETUSER app ACL GETUSER default ACL LOG 20 ``` After sections 3 and 5, `app` should be enabled, have a password hash without `nopass`, allow only `GET` and `SET`, have only `~app:*`, and have no channel grants or selectors. `default` should be disabled or separately password-protected. Redis may normalize the command-rule representation; compare effective permissions. Unexpected categories, wildcard patterns, additional passwords, or selectors need investigation. Do not publish the password hashes from the readback. See [ACL GETUSER](https://redis.io/docs/latest/commands/acl-getuser/). In a fresh session authenticated as `app`, run this non-destructive check: ``` GET app:probe CONFIG GET bind ``` The first command is the positive control. The second must return `NOPERM` for the policy above. An overprivileged account with CONFIG access returns the bind configuration instead. `NOAUTH`, connection errors, and unknown-command errors do not demonstrate the intended ACL denial. `CONFIG GET` reads configuration without changing it. See [CONFIG GET](https://redis.io/docs/latest/commands/config-get/). Back in the administrative session: ``` ACL LOG 20 ``` Look for the corresponding denied CONFIG command, the `app` username, and a recent event age. Review unexpected `auth`, `command`, `key`, and `channel` failures. Record relevant events before any reset; do not clear the log as part of this check. The log is bounded and held in memory, so an empty result is not proof that no failures occurred. See [ACL LOG](https://redis.io/docs/latest/commands/acl-log/) and the [pinned ACL-log configuration](https://raw.githubusercontent.com/redis/redis/8.2.1/redis.conf). **Read/write and channel policy checks, Redis 7.0+.** If section 4's worker is configured, run these from the administrative session: ``` ACL GETUSER app-worker ACL DRYRUN app-worker GET app:input:probe ACL DRYRUN app-worker SET app:output:probe value ACL DRYRUN app-worker SET app:input:probe value ACL DRYRUN app-worker GET app:output:probe ACL DRYRUN app-worker PUBLISH app:events:probe value ACL DRYRUN app-worker PUBLISH other:events:probe value ``` Expect `OK` for reading inputs, writing outputs, and publishing within the allowed channel pattern. Expect permission-denial descriptions for the other three requests. An overly broad policy would also allow those forbidden requests. `ACL DRYRUN` checks permission without executing the request, so these checks neither write keys nor publish messages. It does not demonstrate successful authentication or replace the actual denied CONFIG request above. See [ACL DRYRUN](https://redis.io/docs/latest/commands/acl-dryrun/). **REASONED: persistence and revocation.** During a planned restart of a disposable test instance, repeat `ACL GETUSER app`, `ACL GETUSER default`, and the applicable permission checks. A runtime-only change reverts; the persisted policy survives. After retiring `app-old`, its existing connection must close and a fresh authentication attempt must fail. Use section 7's masked prompt with the old username to test this, without putting its password in arguments. See [ACL persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#use-an-external-acl-file) and [CLIENT KILL](https://redis.io/docs/latest/commands/client-kill/). The following checks cover sections 9-15. All are **REASONED, not demonstrated**: the authoring environment has no `redis-server`, `redis-cli`, Docker, or Podman, and no live HA topology was supplied. Run exposed-state comparisons only in a disposable isolated environment. Redis-prompt commands below are not shell commands. Use section 7's guarded connection block, masked password prompt, and disabled CLI history for authenticated data-server sessions, changing the username as directed. Certificate and key options name files; never put their contents or passwords in process arguments. References below to disabling certificate-to-user automatic authentication apply only to Redis 8.6+, as explained in section 2. Earlier releases lack that setting; do not add the directive to their configuration. **Replication authentication and promotion, section 9.** No live primary/replica pair or failover topology was available. Compare an unsecured primary accepting an uncredentialed replica with a secured primary rejecting missing or wrong replication credentials. After restoring correct credentials, run this in an administrative session on the replica: ``` INFO replication ``` Require `master_link_status:up`, then write a disposable canary through an authorized primary session and read it from the replica. Separately repeat the unauthenticated data-listener check against the replica: it must reject the request even though its outbound replication login succeeds. See the [replication authentication implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/replication.c), [replica authentication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/#setting-a-replica-to-authenticate-to-a-master), and [INFO replication fields](https://redis.io/docs/latest/commands/info/). On Redis 7+, run these at the administrative Redis prompt: ``` ACL DRYRUN replication PSYNC ? -1 ACL DRYRUN app PSYNC ? -1 ``` Expect `OK` for `replication` and a permission-denial description for the guide's restricted `app` user. These are permission checks, not synchronization tests. They need one server; synchronization needs two processes; preserving authentication through promotion needs a live failover exercise with the checks repeated after roles change. See [ACL DRYRUN](https://redis.io/docs/latest/commands/acl-dryrun/) and the [replica command requirements](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#acl-rules-for-sentinel-and-replicas). **Sentinel listener, incoming authentication, and peers, section 10.** No live Sentinel or peer group was available. Repeat the unfiltered `ss -tlnp` inventory on the Sentinel host and account for its separate 26379 listener. Against Sentinel itself, compare this request before and after securing incoming authentication, while holding the transport configuration constant: ``` SENTINEL GET-MASTER-ADDR-BY-NAME mymaster ``` An exposed instance returns the monitored endpoint; the secured instance returns `NOAUTH` without Redis authentication, then permits discovery with valid credentials. Supply a trusted client certificate when mutual TLS is required, with certificate-to-user automatic authentication disabled for this comparison. The guarded Sentinel probe below supplies the TLS form. Authenticate as `discovery` and confirm administrative subcommands are denied with `NOPERM`; a read-only administrative check is `SENTINEL CONFIG GET '*'` on Redis 6.2+. A connection error or `NOAUTH` does not demonstrate the discovery user's ACL denial. See the [Sentinel command and authentication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication). One Sentinel suffices for listener and incoming-authentication checks. From an administrative Sentinel session, use `SENTINEL SENTINELS mymaster` to inspect peer announcements, and verify actual authenticated peer communication with multiple Sentinels. Announcement readback alone does not establish reachability. See the [pinned peer and announcement settings](https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf). **Sentinel monitored-server permissions and failover, section 11.** No live data server or Sentinel topology was available. In a data-server session authenticated as `sentinel-monitor`, run: ``` PING GET app:probe PUBLISH unrelated:probe value ``` Expect `PONG`, then `NOPERM` for both the key read and unrelated-channel publish. An unrestricted account permits the latter operations. Review `ACL LOG 20` from the separate administrator during monitoring; preserve relevant denials without clearing the log. These permission checks need one data server. See the [Sentinel-specific channel and command policy](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#redis-access-control-list-authentication). In a disposable Sentinel and primary/replica topology, missing or wrong `sentinel auth-pass` must prevent successful authenticated monitoring; correct credentials must restore it. Exercise promotion, then confirm Sentinel monitoring and replication continue with the former replica as primary. The [pinned authentication implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c) uses the monitored primary's credentials for its replicas too. **Cluster-bus exposure and announcements, section 12.** No cluster-enabled process or multi-node topology was available. Repeat unfiltered `ss -tlnp`, then run this through an authenticated administrative data-port session: ``` CLUSTER NODES ``` Inspect the advertised address and `@bus-port`. Compare bus connectivity from an unauthorized network with connectivity from an allowed peer: an exposed bus permits the former, while the fixed network boundary denies it and retains the working peer path. Failure from outside is meaningful only alongside a working authorized path and listener/network-policy evidence. A missing Redis `PING` response is not evidence that the binary bus is blocked. One cluster-enabled process can demonstrate its listener; working membership and advertised reachability need multiple nodes. See [CLUSTER NODES](https://redis.io/docs/latest/commands/cluster-nodes/), [cluster networking](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/#redis-cluster-tcp-ports), and the [pinned listener and announcement implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/cluster_legacy.c). The section 12 network comparison in the following block is REASONED from the cited cluster networking requirements, because no live bus endpoint or authorized and unauthorized probe locations were available. Run this concrete TCP probe from both locations, using OpenBSD `nc`. Substitute the actual host and bus port inside the quotes, and `unauthorized` or `allowed` for the location you run it from. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLUSTER_HOST' 'REPLACE_WITH_BUS_PORT' 'REPLACE_WITH_unauthorized_OR_allowed' # the 3rd value is where you run this from: unauthorized, or allowed for the authorized-peer control [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 1 host, 1 port, and 1 location; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host; not probing"; exit 2 ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a numeric port; not probing"; exit 2 ;; esac if [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; then :; else echo "port must be 1-65535; not probing"; exit 2; fi case "$3" in unauthorized|allowed) ;; *) echo "substitute unauthorized or allowed, the location you run this from; not probing"; exit 2 ;; esac set -- 0 "$@" "$(if nc -vz -w 5 "$1" "$2" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # the block's status so far, host, port, location, nc's exit status, then its messages printf '%s' "$6" if [ "$5" = 0 ]; then echo "TCP connected; the bus port is reachable from here" else printf 'TCP probe exit=%s; compare with the allowed-peer control\n' "$5" fi case "$4:$5:$6" in unauthorized:0:*) echo "FINDING: the unauthorized location reached the bus port, so the network boundary permits it"; set -- 1 ;; allowed:0:*) echo "the allowed-peer control connected" ;; allowed:*) echo "the allowed-peer control did not connect; inconclusive"; set -- 3 ;; *'Connection refused'*|*'timed out'*) echo "the unauthorized location was refused or timed out: the pass only alongside a connected allowed-peer run" ;; *) echo "nc reported neither a refusal nor a timeout (a local tool error or name-resolution failure, for example); inconclusive"; set -- 3 ;; esac echo "block exit $1; nc's messages and the listener and network-policy evidence are left for you to judge" exit "$1" ) ``` An exposed network boundary permits a TCP connection from the unauthorized location. The fixed boundary denies that connection while retaining the allowed peer's successful connection to the intended listener. A local tool error, name-resolution failure, or failed allowed-peer control is inconclusive. This probes TCP reachability only, not TLS, certificate acceptance, or cluster membership. See the [cluster networking requirements](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/#redis-cluster-tcp-ports). **REASONED: replication encryption and incoming certificates, section 13.** No live primary/replica pair, certificate fixtures, or connection evidence was available. Use a disposable pair connected directly, without a TLS-terminating proxy. Correlate the primary's actual listener, the replica's established connection, and successful replication. `INFO replication` reports link status, not encryption. At the primary's authenticated administrative Redis prompt: ``` CONFIG GET port CONFIG GET tls-port ``` For the fixed configuration, require `port` to be `0` and record the nonzero `tls-port`. At the replica's authenticated administrative Redis prompt: ``` CONFIG GET tls-replication INFO replication ``` Require `tls-replication` to be `yes`, `master_host` and `master_port` to identify that primary's TLS endpoint, and `master_link_status:up`. On the replica host, inspect established connections: ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tnp; then echo "unclassified observation: find the replica process's established connection to the recorded primary address and TLS port above; without process information the list cannot show which process owns it" exit 0 else echo "ss -tnp exit $?: a socket-inspection error is inconclusive; read the error above" exit 3 fi ) ``` Identify the replica process's established connection to the recorded primary address and TLS port. Use an account permitted to see process information. Missing process information or a socket-inspection error is inconclusive. From an allowed client location, probe that same primary endpoint. Substitute a DNS hostname or IPv4 address and the recorded TLS port inside the quotes. Use the installed CA and client certificate paths. The commands deliberately omit Redis credentials; on Redis 8.6+, keep certificate-to-user automatic authentication disabled for this comparison. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PRIMARY_HOST' 'REPLACE_WITH_PRIMARY_TLS_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 1 host and 1 port; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host; not probing"; exit 2 ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a numeric port; not probing"; exit 2 ;; esac if [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; then :; else echo "port must be 1-65535; not probing"; exit 2; fi if [ -r /etc/redis/tls/ha-ca.crt ] && [ -r /etc/redis/tls/client.crt ] && [ -r /etc/redis/tls/client.key ]; then :; else echo "the CA, client certificate and key files named below must be readable here; not probing"; exit 2 fi unset REDISCLI_AUTH || exit 2 printf '\nNo client certificate\n' set -- 0 "$1" "$2" "$(if timeout 10s redis-cli --tls --cacert /etc/redis/tls/ha-ca.crt \ -h "$1" -p "$2" PING 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), host, port, the probe's exit status, then its output printf '%s' "$5" if [ "$4" = 0 ]; then echo "inspect the RESP reply; exit=0 alone is not a pass" else printf 'probe exit=%s; inspect the TLS or authentication error\n' "$4" fi # under TLS 1.3 a server that rejects the missing certificate after the handshake may leave redis-cli reporting only a closed connection, which reads as inconclusive here even when the listener is fixed case "$4:$5" in 0:*|*NOAUTH*|*PONG*) echo "FINDING: the request without a client certificate reached Redis, so this listener does not require one"; set -- 1 "$2" "$3" ;; 124:*) echo "the request without a client certificate timed out, which does not demonstrate rejection; inconclusive"; set -- 3 "$2" "$3" ;; *'alert bad certificate'*|*'alert certificate unknown'*|*'alert certificate required'*) echo "the request without a client certificate ended with a bad-certificate, certificate-unknown or certificate-required TLS alert, the rejection shape" ;; *'alert handshake failure'*|*'alert unknown ca'*|*'Server closed the connection'*|*'Connection reset by peer'*) echo "the request without a client certificate ended with a generic handshake failure, an unknown-CA alert or a closed or reset connection, which does not show that the missing certificate caused it; inconclusive: read the server's log line for the rejected handshake"; set -- 3 "$2" "$3" ;; *) echo "the request without a client certificate failed without a certificate-related TLS alert or a closed connection (a local file, name-resolution or connection error, for example); inconclusive"; set -- 3 "$2" "$3" ;; esac printf '\nTrusted client certificate, positive control\n' set -- "$1" "$2" "$3" "$(if timeout 10s redis-cli --tls --cacert /etc/redis/tls/ha-ca.crt \ --cert /etc/redis/tls/client.crt --key /etc/redis/tls/client.key \ -h "$2" -p "$3" PING 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # status, host, port, the probe's exit status, then its output printf '%s' "$5" if [ "$4" = 0 ]; then echo "inspect the RESP reply; exit=0 alone is not a pass" else printf 'probe exit=%s; inspect the TLS or authentication error\n' "$4" fi case "$5" in *NOAUTH*) echo "the trusted-certificate positive control reached NOAUTH" ;; *PONG*) echo "FINDING: the trusted-certificate request got PONG without Redis credentials, as an exposed default on nopass +@all does"; set -- 1 ;; *) echo "the trusted-certificate positive control did not reach NOAUTH; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac echo "block exit $1; the first request's error is left for you to judge: it must be a TLS certificate rejection, not a missing certificate file or an unreachable endpoint" exit "$1" ) ``` The replica-certificate check above is REASONED, because no live primary/replica pair or certificate fixtures were available to run it. With `tls-auth-clients yes`, require a TLS certificate rejection for the first request and `NOAUTH` for the trusted-certificate positive control. In the isolated comparison with `tls-auth-clients no`, both requests reach `NOAUTH`. This tests the data listener's certificate requirement, which also applies to incoming replicas; it does not authenticate a replication session. A missing or unreadable CA, certificate or key file is refused with status 2 before any request. A timeout, an unreachable endpoint, a generic handshake failure, an unknown-CA alert, a closed or reset connection or any other failure without a bad-certificate, certificate-unknown or certificate-required alert is inconclusive. In this guide's assessment, under TLS 1.3 a server that rejects the missing certificate after the handshake may leave `redis-cli` reporting only a closed connection, so a fixed listener can read 3; read the server's log line for the rejected handshake. To confirm current synchronization, write a fresh disposable value at an authorized primary Redis prompt: ``` SET app:ha-tls-probe tls-check-1 ``` Then read it at an authorized replica Redis prompt: ``` GET app:ha-tls-probe ``` Require the newly written value, changing it on each repetition. In the exposed comparison, use `tls-replication no` and the primary's plaintext endpoint: replication and the canary can still succeed, while the connection terminates on the plaintext listener. In the fixed comparison, require the active replica connection to terminate on the primary's TLS-only listener, the successful TLS positive control, and the fresh canary together. Neither link status nor a separate successful TLS probe alone proves that replication is encrypted. See the [TLS replication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/), [pinned listener implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/server.c), [INFO replication fields](https://redis.io/docs/latest/commands/info/), and [Redis CLI options](https://redis.io/docs/latest/develop/tools/cli/). **REASONED: cluster-bus client certificates, section 13.** No live TLS cluster-bus listener or certificate fixtures were available. From an allowed peer location, probe the actual bus port with no certificate, an otherwise valid certificate from an untrusted CA, and a trusted certificate. If Redis 8.10+ peer-name checking is enabled, both certificate fixtures must contain the expected name so this comparison isolates CA trust. These OpenSSL probes require TLS 1.2 to be enabled on the test endpoint and GNU `timeout`. They select TLS 1.2 so a completed handshake includes the server's acceptance of the client certificate. Do not weaken a TLS 1.3-only deployment for this check; these particular probes are inconclusive there. Use protected PEM fixture files at the paths below, replace the DNS hostname or IPv4 address and actual bus port inside the quotes, and paste the whole block. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLUSTER_HOST' 'REPLACE_WITH_BUS_PORT' 'REPLACE_WITH_EXPECTED_PEER_NAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 1 host, 1 port, and 1 expected peer name; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host; not probing"; exit 2 ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a numeric port; not probing"; exit 2 ;; esac if [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; then :; else echo "port must be 1-65535; not probing"; exit 2; fi case "$3" in *REPLACE_WITH_*|"") echo "substitute the expected peer name; not probing"; exit 2 ;; esac if [ -r /etc/redis/tls/ha-ca.crt ] && [ -r /etc/redis/tls/untrusted-client.crt ] && [ -r /etc/redis/tls/untrusted-client.key ] && [ -r /etc/redis/tls/ha-client.crt ] && [ -r /etc/redis/tls/ha-client.key ]; then :; else echo "the CA, untrusted and trusted certificate and key files named below must be readable here; not probing"; exit 2 fi set -- 0 "$@" # the 1st value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 printf '\nNo client certificate\n' set -- "$1" "$2" "$3" "$4" "$(if timeout 10s openssl s_client -connect "$2:$3" -tls1_2 -brief \ -verify_hostname "$4" -verify_return_error -CAfile /etc/redis/tls/ha-ca.crt &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # status, host, port, peer name, the probe's exit status, then its output printf '%s' "$6" if [ "$5" = 0 ]; then echo "probe exit=0; inspect the handshake result" else printf 'probe exit=%s; inspect the TLS error\n' "$5" fi # with TLS 1.2 a server may reject a missing or untrusted certificate with only a generic handshake-failure alert, which reads as inconclusive here even when the bus is fixed case "$5:$6" in 0:*) echo "FINDING: the handshake completed without a client certificate, so the bus does not require one"; set -- 1 "$2" "$3" "$4" ;; 124:*) echo "a timeout does not demonstrate that the bus rejects a missing client certificate; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; *'alert bad certificate'*|*'alert certificate unknown'*|*'alert certificate required'*) echo "the TLS error names a certificate-related alert, the rejection shape" ;; *'alert handshake failure'*|*'alert unknown ca'*) echo "the TLS error is a generic handshake failure or an unknown-CA alert, which does not show that the missing certificate caused it; inconclusive: read the server's log line for the rejected handshake"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; *) echo "the TLS error names no certificate-related alert (a local file, connection, protocol-version or server-verification error, for example); inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; esac printf '\nUntrusted client certificate\n' set -- "$1" "$2" "$3" "$4" "$(if timeout 10s openssl s_client -connect "$2:$3" -tls1_2 -brief \ -verify_hostname "$4" -verify_return_error -CAfile /etc/redis/tls/ha-ca.crt \ -cert /etc/redis/tls/untrusted-client.crt -key /etc/redis/tls/untrusted-client.key &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # status, host, port, peer name, the probe's exit status, then its output printf '%s' "$6" if [ "$5" = 0 ]; then echo "probe exit=0; inspect the handshake result" else printf 'probe exit=%s; inspect the TLS error\n' "$5" fi case "$5:$6" in 0:*) echo "FINDING: the handshake completed with the untrusted client certificate, so the bus accepts it"; set -- 1 "$2" "$3" "$4" ;; 124:*) echo "a timeout does not demonstrate that the bus rejects the untrusted certificate; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; *'alert bad certificate'*|*'alert certificate unknown'*|*'alert unknown ca'*|*'alert certificate required'*) echo "the TLS error names a certificate-related alert, the rejection shape" ;; *'alert handshake failure'*) echo "the TLS error is a generic handshake failure, which does not show that the untrusted certificate caused it; inconclusive: read the server's log line for the rejected handshake"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; *) echo "the TLS error names no certificate-related alert (a local file, connection, protocol-version or server-verification error, for example); inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" "$4" ;; esac printf '\nTrusted client certificate, positive control\n' if timeout 10s openssl s_client -connect "$2:$3" -tls1_2 -brief \ -verify_hostname "$4" -verify_return_error -CAfile /etc/redis/tls/ha-ca.crt \ -cert /etc/redis/tls/ha-client.crt -key /etc/redis/tls/ha-client.key &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), the host, redis-cli's exit status, then its output printf '%s' "$4" case "$3:$4" in 0:*|*NOAUTH*) echo "FINDING: the plaintext request got a RESP reply, so plaintext Redis protocol is available at this endpoint"; set -- 1 "$2" ;; *'Server closed the connection'*|*'Connection reset by peer'*) echo "no RESP reply recognized to the plaintext request (exit $3); a timeout, resolution error or refusal alone proves nothing, so the TLS requests below decide"; set -- 0 "$2" ;; *) echo "the plaintext request got neither a RESP reply nor a closed or reset connection (exit $3), as a timeout, resolution failure, refusal or local error does; inconclusive"; set -- 3 "$2" ;; esac set -- "$1" "$2" "$(if timeout 10s redis-cli --tls --cacert /etc/redis/tls/ha-ca.crt \ --cert /etc/redis/tls/client.crt --key /etc/redis/tls/client.key \ -h "$2" -p 26379 SENTINEL GET-MASTER-ADDR-BY-NAME mymaster 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # status, host, redis-cli's exit status, then its output printf '%s' "$4" case "$3:$4" in *NOAUTH*) echo "the TLS request without Redis credentials reached NOAUTH, as the secured instance must" ;; 0:*) echo "FINDING: the TLS request without Redis credentials was answered, as an exposed instance returns the monitored endpoint"; set -- 1 "$2" ;; *) echo "the TLS request without Redis credentials reached neither NOAUTH nor a reply (exit $3); inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" ;; esac if REDISCLI_HISTFILE=/dev/null timeout --foreground 10s redis-cli --tls \ --cacert /etc/redis/tls/ha-ca.crt \ --cert /etc/redis/tls/client.crt --key /etc/redis/tls/client.key \ -h "$2" -p 26379 --user discovery --askpass \ SENTINEL GET-MASTER-ADDR-BY-NAME mymaster; then # --foreground: this guide's assessment is that it lets the hidden password prompt read from your terminal echo "unclassified observation: the authenticated request ended with exit 0; read its reply above, which must be the monitored endpoint" else echo "the authenticated request, or its hidden password prompt, failed with exit $?; inconclusive" [ "$1" = 1 ] || set -- 3 "$2" fi echo "block exit $1; the authenticated reply above is left for you to judge: it must be the monitored endpoint" exit "$1" ) ``` Any RESP reply to the first request proves plaintext Redis protocol is available at that endpoint. With the TLS-only configuration, expect no plaintext RESP reply; the trusted-certificate TLS request without Redis credentials must reach `NOAUTH`, and the authenticated request must return the monitored endpoint. A timeout, resolution error, or connection refusal alone proves nothing. Repeat the TLS requests without a client certificate and with an untrusted certificate; both must fail under `tls-auth-clients yes`. Restore the trusted certificate and valid credentials as the positive control. See [Sentinel TLS](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/#sentinel), [Sentinel authentication](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication), and [Redis CLI options](https://redis.io/docs/latest/develop/tools/cli/). One Sentinel can demonstrate its incoming listener. Outgoing TLS, peer discovery, and failover need a live Sentinel/data-node topology and independent connection evidence; success on 26379 does not prove those outgoing connections use TLS. See the [pinned outgoing connection and advertisement implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c). REASONED: preceding block; the Sentinel shell block above, whose Redis behaviour was not demonstrated. The Sentinel shell block above passed local Bash syntax and ShellCheck checks. With `set -u`, it rejected the unchanged placeholder, an embedded placeholder, an empty host, a marker-only assignment, an omitted assignment with no inherited arguments, and an extra host argument before any connection command ran. These local checks did not demonstrate Redis behaviour. Those checks covered an earlier form of the block, whose refusals exited with status 1 and whose requests set no status. The current form, whose refusals exit with status 2 and whose requests feed one exit status, is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by the 31 October 2026 stub runs recorded at the start of this section. Its authenticated request now runs under `timeout --foreground`; that this lets its hidden password prompt read from your terminal is this guide's assessment, with no cited source, and was not observed against a live Sentinel. **REASONED: enforced HA peer identity, Redis 8.10+, section 15.** No Redis 8.10 runtime, certificate fixtures, or HA endpoints were available. Prepare two otherwise valid client certificates signed by the same trusted CA: one matching the configured HA identity and one that does not match it through either SAN or CN fallback. Keep certificate purpose, validity, CA trust, and Redis credentials constant. For incoming identity enforcement, target the TLS cluster-bus port. An ordinary client handshake to the data port does not exercise this incoming peer-name restriction. At the test node's authenticated administrative data-port prompt, first establish the isolated comparison without the restriction: ``` CONFIG SET tls-expected-peer-name "" ``` Run the following block, then enable the intended identity at that same administrative prompt and run the block again: ``` CONFIG SET tls-expected-peer-name redis-ha.example.com ``` The peer-name comparison in the following block is REASONED from the cited pinned identity configuration and enforcement paths. Replace the example identity consistently with the identity issued to intended HA nodes. Require `OK` from each configuration change. The shell block uses the same TLS 1.2, GNU `timeout`, and protected PEM-file prerequisites as the preceding bus-certificate check. Substitute the DNS hostname or IPv4 address, actual bus port and expected peer name inside the quotes, and the phase: `unrestricted` for the first run and `restricted` for the run after enabling the intended identity. The block counts a bad-certificate, certificate-unknown or certificate-required alert to the wrong-name probe as the restricted phase's rejection. An unknown-CA alert there means the wrong-name fixture is not from the trusted CA, and a generic handshake failure does not show that the peer name caused it, so both are inconclusive. In this guide's assessment a node may report the mismatch with only that generic alert, so a working restriction can read 3; read the server's log line for the rejected handshake. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLUSTER_HOST' 'REPLACE_WITH_BUS_PORT' 'REPLACE_WITH_EXPECTED_PEER_NAME' 'REPLACE_WITH_unrestricted_OR_restricted' # the 4th value is the phase: unrestricted before tls-expected-peer-name is set, restricted after [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 1 host, 1 port, 1 expected peer name, and 1 phase; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host; not probing"; exit 2 ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a numeric port; not probing"; exit 2 ;; esac if [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; then :; else echo "port must be 1-65535; not probing"; exit 2; fi case "$3" in *REPLACE_WITH_*|"") echo "substitute the expected peer name; not probing"; exit 2 ;; esac case "$4" in unrestricted|restricted) ;; *) echo "substitute unrestricted or restricted, this run's phase; not probing"; exit 2 ;; esac if [ -r /etc/redis/tls/ha-ca.crt ] && [ -r /etc/redis/tls/wrong-name-client.crt ] && [ -r /etc/redis/tls/wrong-name-client.key ] && [ -r /etc/redis/tls/matching-name-client.crt ] && [ -r /etc/redis/tls/matching-name-client.key ]; then :; else echo "the CA, wrong-name and matching-name certificate and key files named below must be readable here; not probing"; exit 2 fi set -- 0 "$@" # the 1st value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 printf '\nSame CA, wrong peer name\n' set -- "$1" "$2" "$3" "$4" "$5" "$(if timeout 10s openssl s_client -connect "$2:$3" -tls1_2 -brief \ -verify_hostname "$4" -verify_return_error -CAfile /etc/redis/tls/ha-ca.crt \ -cert /etc/redis/tls/wrong-name-client.crt -key /etc/redis/tls/wrong-name-client.key &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # status, host, port, peer name, phase, the probe's exit status, then its output printf '%s' "$7" if [ "$6" = 0 ]; then echo "probe exit=0; inspect the handshake result" else printf 'probe exit=%s; inspect the TLS error\n' "$6" fi # a node may report the name mismatch with only a generic handshake-failure alert, which reads as inconclusive here even when the restriction works case "$5:$6:$7" in restricted:0:*) echo "FINDING: the wrong-name certificate was accepted with the setting enabled, which fails this enforcement check"; set -- 1 "$2" "$3" "$4" "$5" ;; unrestricted:0:*) ;; unrestricted:*) echo "without the name restriction both handshakes must complete; inconclusive"; set -- 3 "$2" "$3" "$4" "$5" ;; restricted:124:*) echo "a timeout does not demonstrate rejection of the wrong-name certificate; inconclusive"; set -- 3 "$2" "$3" "$4" "$5" ;; *'alert unknown ca'*) echo "the wrong-name probe got an unknown-CA alert, so the wrong-name fixture is not from the trusted CA; inconclusive"; set -- 3 "$2" "$3" "$4" "$5" ;; *'alert bad certificate'*|*'alert certificate unknown'*|*'alert certificate required'*) echo "the TLS error names a certificate-related alert, the rejection shape" ;; *'alert handshake failure'*) echo "the TLS error is a generic handshake failure, which does not show that the peer name caused it; inconclusive: read the server's log line for the rejected handshake"; set -- 3 "$2" "$3" "$4" "$5" ;; *) echo "the TLS error names no certificate-related alert (a local file, connection, protocol-version or server-verification error, for example); inconclusive"; set -- 3 "$2" "$3" "$4" "$5" ;; esac printf '\nSame CA, matching peer name, positive control\n' if timeout 10s openssl s_client -connect "$2:$3" -tls1_2 -brief \ -verify_hostname "$4" -verify_return_error -CAfile /etc/redis/tls/ha-ca.crt \ -cert /etc/redis/tls/matching-name-client.crt -key /etc/redis/tls/matching-name-client.key REPLACE_WITH_REPLICATION_PASSWORD +ping +replconf +psync ``` Put the outbound credentials in each data member's server configuration: ``` masteruser replication masterauth REPLACE_WITH_REPLICATION_PASSWORD ``` Retain the secured default user and separate administrator from sections 2 and 3. The replication account receives the full replication stream: omitting key-pattern grants does not restrict it to an application prefix. The pinned [configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf) documents both credential directives; the [replication implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/replication.c) sends username/password authentication when configured and aborts the handshake on authentication failure. See also [persistent replica authentication](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/#setting-a-replica-to-authenticate-to-a-master). ## 10. Secure Sentinel's separate listener and peer authentication Sentinel has a separate listener, normally TCP 26379. Its shipped configuration disables protected mode and supplies no active authentication rule. Protecting the Redis data port does not protect Sentinel. Password-only Sentinel authentication is available throughout this guide's Redis 6.0+ scope; Sentinel ACLs require Redis 6.2+. See the [pinned Sentinel configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf) and [Sentinel authentication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication). For a representative private Sentinel address, the password-only arrangement is: ``` bind 10.0.0.11 port 26379 requirepass REPLACE_WITH_SENTINEL_PASSWORD sentinel announce-ip 10.0.0.11 sentinel announce-port 26379 ``` Restrict network access to the required Sentinel peers, discovery clients, and administrators. Use the same Sentinel password across peers in this arrangement, with a credential distinct from the data-server password. The announcement directives publish discovery information; they neither bind sockets nor enforce access restrictions. Section 14 replaces the plaintext listener with TLS. See the [pinned listener, announcement, and password settings](https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf). Alternatively, on Redis 6.2+, use ACL authentication. Retain the private bind and announcements, and use these account rules instead of the password-only `requirepass` arrangement: ``` user sentinel-peer reset on >REPLACE_WITH_SENTINEL_PEER_PASSWORD &* +@all user discovery reset on >REPLACE_WITH_DISCOVERY_PASSWORD +ping +sentinel|get-master-addr-by-name user default off resetpass ``` Configure Sentinel's outgoing peer credentials separately: ``` sentinel sentinel-user sentinel-peer sentinel sentinel-pass REPLACE_WITH_SENTINEL_PEER_PASSWORD ``` Provision the peer account consistently across the Sentinel group. Establish and test administrative access before disabling `default`. The peer account above has administrative authority; the discovery account permits only `PING` and `SENTINEL GET-MASTER-ADDR-BY-NAME`. Add other commands only when the actual client library needs them. Use the chosen ACL persistence surface for `user` rules and Sentinel configuration for peer credentials. Choose ACL authentication or the password-only arrangement rather than combining their examples. See the [pinned ACL and peer settings](https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf). ## 11. Give Sentinel dedicated monitored-server credentials Sentinel's own password does not authenticate it to Redis data servers. Monitored-instance credentials are initially unset. Leaving data servers unauthenticated to permit monitoring exposes them; giving Sentinel an application or unrestricted administrator credential grants unnecessary authority. The monitored-server ACL mechanism requires Redis 6.0+, while the channel-restricted policy below requires Redis 6.2+. See the [pinned credential settings](https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf) and [Sentinel-specific ACL documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#redis-access-control-list-authentication). On every Sentinel monitoring the group named `mymaster`: ``` sentinel auth-user mymaster sentinel-monitor sentinel auth-pass mymaster REPLACE_WITH_MONITOR_PASSWORD ``` On every data member of that group, provision the same dedicated account in the ACL persistence surface chosen in section 3: ``` user sentinel-monitor reset on >REPLACE_WITH_MONITOR_PASSWORD resetchannels &__sentinel__:hello +ping +info +role +multi +exec +slaveof +subscribe +publish +config|rewrite +client|setname +client|kill +script|kill ``` This policy is adapted from the current Sentinel-specific vendor example and reviewed for Redis 8.10.1. It permits the reserved discovery channel specifically, rather than every Pub/Sub channel. It remains a powerful operational account. Redis 6.0 cannot enforce this channel restriction; do not describe an account there as having equivalent channel isolation. See the [Sentinel ACL requirements](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#redis-access-control-list-authentication). For password-only data servers, omit `auth-user` and set `auth-pass` to their `requirepass` value. Sentinel uses the monitored primary's credentials for its replicas too, so provision them on every member eligible for either role. Its monitored-server credentials are distinct from its Sentinel-peer credentials. See `sentinelSendAuthIfNeeded` in the [pinned implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c). ## 12. Restrict the cluster bus and announce private endpoints Cluster mode is opt-in. When enabled, it adds a binary node-to-node listener for discovery, failure detection, and failover coordination. Restrict this listener separately throughout this guide's Redis 6.0+ scope. Its default port is the data port plus 10000, normally 16379 beside 6379. Data-port password authentication does not provide a RESP `AUTH` gate on the binary bus. See the [cluster networking documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/#redis-cluster-tcp-ports). For an existing TLS cluster using section 13, this explicit listener and announcement example targets Redis 8.10.1; it is not a claim that every directive is available on every Redis 6.x release: ``` bind 10.0.0.11 cluster-port 16379 cluster-announce-ip 10.0.0.11 cluster-announce-port 0 cluster-announce-tls-port 6379 cluster-announce-bus-port 16379 ``` Permit the bus only between cluster nodes. Permit the data port to intended clients and nodes that need it. `cluster-port` selects the actual bus listener; `cluster-announce-*` publishes endpoint information and is not a firewall. Account for remapping rather than assuming the externally reachable bus port retains the 10000 offset. See the [pinned cluster and NAT configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf). In TLS cluster mode, the default offset is based on `tls-port`; the bus uses the configured bind addresses. The listener and announced endpoints must both match the intended deployment. See `defaultClientPort`, `deriveAnnouncedPorts`, and `clusterInitLast` in the [pinned cluster implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/cluster_legacy.c). ## 13. Enable TLS for replication and cluster traffic Redis 6.0+ with TLS support needs separate switches for outgoing replication and cluster traffic. Enabling the client TLS listener does not enable either one; both switches default to disabled. See the [TLS documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/) and [pinned defaults](https://raw.githubusercontent.com/redis/redis/8.10.1/src/config.c). On all applicable data nodes, retain section 6's certificate, key, and CA configuration and use: ``` port 0 tls-port 6379 tls-replication yes tls-auth-clients yes ``` On cluster nodes, also configure: ``` tls-cluster yes ``` Where replication is configured explicitly, point `replicaof` at the primary's TLS endpoint. `tls-auth-clients yes` is already the Redis default; restoring it from section 6's deliberate `no` requires incoming replicas, as well as ordinary clients, to present trusted certificates. Retain replication credentials from section 9, subject to the certificate-to-user authentication qualification in section 2. See the [pinned TLS and replication settings](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf). Redis normally reuses its certificate for outgoing connections. On Redis 6.2+, if certificates have separate server and client purposes, configure `tls-client-cert-file` and `tls-client-key-file` for the outgoing certificate and key. Readers on Redis releases before 6.2 must instead use one certificate suitable for both server and client authentication, configured through `tls-cert-file` and `tls-key-file`. Omit both `tls-client-*` directives on those releases: an unknown configuration directive prevents startup. Compare the pinned [6.0.20 certificate configuration](https://raw.githubusercontent.com/redis/redis/6.0.20/redis.conf) with the [6.2.0 certificate configuration](https://raw.githubusercontent.com/redis/redis/6.2.0/redis.conf). The TLS cluster bus always requests and requires a peer certificate. Its accept path explicitly uses `TLS_CLIENT_AUTH_YES`; setting data-port `tls-auth-clients no` does not disable cluster-bus certificate authentication. See `clusterAcceptHandler` in the [pinned implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/cluster_legacy.c). ## 14. Configure TLS on Sentinel and its outgoing connections Sentinel is a separate process; data-node TLS configuration does not configure it. On Redis 6.0+ with TLS support, a nonzero `tls-port` creates Sentinel's incoming TLS listener independently of `tls-replication`. The `tls-replication yes` setting selects TLS for outgoing connections to monitored servers and Sentinel peers. It also selects the configured TLS port for Sentinel's default port advertisement; an explicit `sentinel announce-port` overrides that choice. Configure both `tls-port` and `tls-replication yes` for end-to-end TLS, and set `port 0` to disable the plaintext listener. See `initListeners` in the [pinned server implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/server.c), and `sentinelReconnectInstance` and `sentinelSendHello` in the [pinned Sentinel implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c). In each Sentinel's configuration: ``` port 0 tls-port 26379 tls-replication yes tls-auth-clients yes tls-cert-file /etc/redis/tls/sentinel.crt tls-key-file /etc/redis/tls/sentinel.key tls-ca-cert-file /etc/redis/tls/ha-ca.crt sentinel announce-port 26379 ``` Retain section 10's private bind and chosen authentication arrangement, and section 11's monitored-server credentials. Set `sentinel monitor` to the primary's TLS data port. Provision certificates and trust for Sentinel-to-data-server connections, Sentinel-to-Sentinel connections, and discovery clients. `tls-cluster` is not Sentinel's TLS switch. Certificate directives are documented in the [pinned Redis configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf); `sentinelReconnectInstance`, `instanceLinkNegotiateTLS`, and `sentinelSendHello` in the [pinned Sentinel implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c) establish the outgoing TLS selection and TLS-port advertisement. ## 15. Restrict trusted HA peer identities Redis 8.10 introduced server-to-server certificate-name authentication. The setting is unset by default, leaving CA validation without this additional peer-name restriction. This control is newer than the guide's existing 8.2.1 configuration references and must not be added to the generic Redis 6.0+ baseline. See the [8.10 release notes](https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.10-release-notes/) and [pinned peer-name configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf). On Redis 8.10+ builds that enforce the feature: ``` tls-expected-peer-name redis-ha.example.com ``` Replace the example identity with the intended HA trust-group name. Issue intended nodes certificates containing that shared identity in their SANs, including the outgoing client certificates. This adds identity checking to CA validation for outgoing replication, cluster-bus, and `MIGRATE` connections, and incoming cluster-bus connections. It identifies membership in the configured trust group, not a unique certificate-to-node-ID mapping. See the [pinned configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf) and [`tlsSetVerifyName` and its callers](https://raw.githubusercontent.com/redis/redis/8.10.1/src/tls.c). Two limits apply: - Builds with `TLS_NO_PEER_NAME_VERIFICATION` warn and continue without enforcing the name; CA validation still applies. Configuration readback alone does not prove enforcement. See the [pinned TLS implementation](https://raw.githubusercontent.com/redis/redis/8.10.1/src/tls.c). - Sentinel's outgoing `instanceLinkNegotiateTLS` path creates an SSL object and invokes hiredis directly without applying this name check. This is a source-derived conclusion from Redis 8.10.1, not a demonstrated runtime result. Do not rely on this directive to protect Sentinel's outgoing peer identity. See the [separate Sentinel TLS path](https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c). Use a dedicated HA trust boundary for Sentinel and releases without this identity control. Do not trust a general-purpose CA whose other certificate holders should not be able to impersonate HA peers. The [pinned configuration](https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf) explains why shared CA trust alone is insufficient. ## Common mistakes - Commenting out `bind`, which listens everywhere, while `requirepass` is still empty. - One `requirepass` value shared across environments and committed to the repository. - TLS enabled but the plaintext `port` left open alongside it; set `port 0`. - Adding a narrower ACL without resetting old grants or reviewing selectors. - Saving `redis.conf` while runtime ACL changes belong in an external ACL file. - Disabling a user and assuming its existing connections have been disconnected. - Treating key patterns as restrictions on Pub/Sub channels or keyless commands. ## Valkey Valkey, the community fork of Redis, uses the same `requirepass`, ACL, and TLS configuration concepts described above. Apply the version-appropriate controls and check the installed release's syntax and defaults. See the Valkey [security](https://valkey.io/topics/security/), [ACL](https://valkey.io/topics/acl/), and [TLS](https://valkey.io/topics/tls/) documentation. The upstream Redis HA controls and source-derived limitations in sections 9-15 have not been established for Valkey. Verify those controls separately against the installed Valkey release before applying them. ## Sources (checked September 2026) The tagged configuration files below pin syntax and historical behaviour; they are not recommendations to install those patch releases. - Redis security model, authentication, and deprecated command renaming: https://redis.io/docs/latest/operate/oss_and_stack/management/security/ - Redis configuration format: https://redis.io/docs/latest/operate/oss_and_stack/management/config/ - Redis TLS build support, listeners, and client certificates: https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/ - Redis ACL rules, categories, selectors, key/channel permissions, and persistence: https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/ - Redis 8.2.1 pinned configuration, including ACL-file compatibility and debug/module defaults: https://raw.githubusercontent.com/redis/redis/8.2.1/redis.conf - Redis 6.0.20 pinned bind and protected-mode configuration: https://raw.githubusercontent.com/redis/redis/6.0.20/redis.conf - Redis 6.2.14 pinned optional-bind syntax and protected-mode configuration: https://raw.githubusercontent.com/redis/redis/6.2.14/redis.conf - Redis 7.0.15 pinned protected-mode and hardened-command configuration: https://raw.githubusercontent.com/redis/redis/7.0.15/redis.conf - Official Redis Docker images (redis/docker-library-redis): the build patch that sets the `protected-mode` default to 0, and `CMD ["redis-server"]`, present in every Dockerfile on the default branch at commit 584feaab6cd12a77866c121beff1bb16ff239486 (6.2 through 7.4; the patch is shown for 7.4 Debian and Alpine, 7.2 and 6.2) and at release tag v8.10.2. The pinned commit's 7.4 Debian Dockerfile sources: https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/debian/Dockerfile#L85-L90 and https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/debian/Dockerfile#L146-L150. The pinned commit's 7.4 Alpine and 7.2 Debian Dockerfile sources: https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/alpine/Dockerfile#L79-L84 and https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.2/debian/Dockerfile#L85-L90. The pinned commit's 6.2 Debian Dockerfile source: https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/6.2/debian/Dockerfile#L85-L90. The v8.10.2 tag's Debian Dockerfile sources: https://github.com/redis/docker-library-redis/blob/v8.10.2/debian/Dockerfile#L70-L75 and https://github.com/redis/docker-library-redis/blob/v8.10.2/debian/Dockerfile#L127-L130. The v8.10.2 tag's Alpine Dockerfile sources: https://github.com/redis/docker-library-redis/blob/v8.10.2/alpine/Dockerfile#L82-L87 and https://github.com/redis/docker-library-redis/blob/v8.10.2/alpine/Dockerfile#L142-L145. The entrypoints (every one at the pinned commit, and 8.10.2 Debian and Alpine) do not supply a configuration file or set bind, protected-mode or password. The pinned commit's entrypoint sources: https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/debian/docker-entrypoint.sh and https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/alpine/docker-entrypoint.sh. The v8.10.2 tag's entrypoint sources: https://github.com/redis/docker-library-redis/blob/v8.10.2/debian/docker-entrypoint.sh and https://github.com/redis/docker-library-redis/blob/v8.10.2/alpine/docker-entrypoint.sh - Redis compiled defaults, `bind` `* -::*`, `protected-mode` 1, and the `default` user `on nopass +@all` (pinned tags 7.4.8 and 8.10.2). The Redis 7.4.8 tag's sources: https://github.com/redis/redis/blob/7.4.8/src/server.h#L119-L120, https://github.com/redis/redis/blob/7.4.8/src/config.c#L3055 and https://github.com/redis/redis/blob/7.4.8/src/acl.c#L1407-L1415. The Redis 8.10.2 tag's sources: https://github.com/redis/redis/blob/8.10.2/src/server.h#L138-L139, https://github.com/redis/redis/blob/8.10.2/src/config.c#L3403 and https://github.com/redis/redis/blob/8.10.2/src/acl.c#L1430-L1438 - Redis CLI connection flags, masked password input, TLS options, and history control: https://redis.io/docs/latest/develop/tools/cli/ - AUTH named-user and default-user authentication: https://redis.io/docs/latest/commands/auth/ - ACL SETUSER replacement rules and version history: https://redis.io/docs/latest/commands/acl-setuser/ - CONFIG REWRITE persistence and prerequisites: https://redis.io/docs/latest/commands/config-rewrite/ - ACL SAVE persistence to the configured ACL file: https://redis.io/docs/latest/commands/acl-save/ - ACL LOAD replacement and failure behaviour: https://redis.io/docs/latest/commands/acl-load/ - CLIENT KILL user filtering and SKIPME behaviour: https://redis.io/docs/latest/commands/client-kill/ - ACL GETUSER effective-policy readback: https://redis.io/docs/latest/commands/acl-getuser/ - ACL LOG failure reasons and retrieval: https://redis.io/docs/latest/commands/acl-log/ - ACL DRYRUN checks without command execution: https://redis.io/docs/latest/commands/acl-dryrun/ - CONFIG GET read-only configuration inspection: https://redis.io/docs/latest/commands/config-get/ - GET and SET syntax and key access: https://redis.io/docs/latest/commands/get/ and https://redis.io/docs/latest/commands/set/ - PING command categories: https://redis.io/docs/latest/commands/ping/ - Global database write commands: https://redis.io/docs/latest/commands/flushall/ and https://redis.io/docs/latest/commands/flushdb/ and https://redis.io/docs/latest/commands/swapdb/ - Keyspace enumeration commands: https://redis.io/docs/latest/commands/keys/ and https://redis.io/docs/latest/commands/scan/ and https://redis.io/docs/latest/commands/randomkey/ - Global function-library write commands: https://redis.io/docs/latest/commands/function-load/ and https://redis.io/docs/latest/commands/function-delete/ and https://redis.io/docs/latest/commands/function-flush/ and https://redis.io/docs/latest/commands/function-restore/ - Script and function execution categories: https://redis.io/docs/latest/commands/eval/ and https://redis.io/docs/latest/commands/evalsha/ and https://redis.io/docs/latest/commands/fcall/ - Read-only scripting variants: https://redis.io/docs/latest/commands/eval_ro/ and https://redis.io/docs/latest/commands/evalsha_ro/ and https://redis.io/docs/latest/commands/fcall_ro/ - Pub/Sub publishing and subscriptions: https://redis.io/docs/latest/commands/publish/ and https://redis.io/docs/latest/commands/subscribe/ and https://redis.io/docs/latest/commands/psubscribe/ - Valkey security, ACLs, and TLS: https://valkey.io/topics/security/ and https://valkey.io/topics/acl/ and https://valkey.io/topics/tls/ - Redis 8.10.1 pinned server configuration: https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf - Redis 6.2.0 separate outgoing TLS certificate and key configuration: https://raw.githubusercontent.com/redis/redis/6.2.0/redis.conf - Redis 8.10.1 independent TCP and TLS listener initialization: https://raw.githubusercontent.com/redis/redis/8.10.1/src/server.c - CONFIG SET runtime configuration changes and multiple-parameter syntax: https://redis.io/docs/latest/commands/config-set/ - Redis 8.10.1 pinned Sentinel configuration: https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf - Redis 8.10.1 registered TLS and certificate-authentication defaults: https://raw.githubusercontent.com/redis/redis/8.10.1/src/config.c - Redis 8.6.0 tls-auth-clients-user introduction and default: https://raw.githubusercontent.com/redis/redis/8.6.0/redis.conf - Redis 8.6 release notes and tls-auth-clients-user introduction: https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.6-release-notes/ - Redis 8.10.1 replication authentication implementation: https://raw.githubusercontent.com/redis/redis/8.10.1/src/replication.c - Redis 8.10.1 Sentinel authentication, outgoing TLS, and announcements: https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c - Redis 8.10.1 cluster listeners, announcements, and mandatory TLS peer certificates: https://raw.githubusercontent.com/redis/redis/8.10.1/src/cluster_legacy.c - Redis 8.10.1 peer-name enforcement and build opt-out: https://raw.githubusercontent.com/redis/redis/8.10.1/src/tls.c - Replica ACL command requirements: https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#acl-rules-for-sentinel-and-replicas - Persistent replica authentication: https://redis.io/docs/latest/operate/oss_and_stack/management/replication/#setting-a-replica-to-authenticate-to-a-master - Sentinel incoming and peer authentication: https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication - Sentinel monitored-server ACL and channel requirements: https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#redis-access-control-list-authentication - Cluster data and bus networking: https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/#redis-cluster-tcp-ports - Sentinel TLS configuration: https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/#sentinel - Redis 8.10 release notes and peer-name feature boundary: https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.10-release-notes/ - Replication status fields: https://redis.io/docs/latest/commands/info/ - Cluster advertised addresses and bus ports: https://redis.io/docs/latest/commands/cluster-nodes/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - r60: Redis configuration (6.0.20) - r62: Redis configuration (6.2.14) - r620: Redis configuration (6.2.0) - r70: Redis configuration (7.0.15) - r82: Redis configuration (8.2.1) - r86: Redis configuration (8.6.0) - ha: Redis HA source (8.10.1) - image8: Redis image tag (v8.10.2) - r74: Redis compiled defaults (7.4.8) - r8102: Redis compiled defaults (8.10.2) - docs: Redis documentation (unknown) - image: Redis image source (584feaab6cd12a77866c121beff1bb16ff239486) - release-notes: Redis release notes (unknown) - valkey: Valkey documentation (unknown) Claims (full source mapping in the JSON block below): - baseline (REASONED; docs, r60): Redis 6.0+ supplies TLS and ACLs; TLS needs TLS-enabled server/client builds. Restrict bind and credentials before remote access. - optional-bind (REASONED; r62, r60): Redis 6.2+ -::1 makes an absent interface optional, not an occupied address; Redis 6.0 uses plain loopback binding. - protected-mode (REASONED; r60, r62, r70): 6.0/6.2 protected mode requires default nopass and no explicit bind; 7.0 drops the bind condition. Explicit wildcard bind can expose passwordless 6.x. - image-protected (REASONED; image, image8): Pinned 6.2-7.4 images and the checked 8.x tag patch protected-mode to no and run redis-server without a config; publish privately with explicit credentials. - compiled-bind (REASONED; r74, r8102): 7.4.8/8.10.2 compiled bind is * -::*; IPv6 wildcard applies where available. - compiled-user (REASONED; r74, r8102, r82): Checked 7.4.8/8.10.2 default users are on nopass +@all; disabled DEBUG/MODULE commands remain disabled. - password (REASONED; docs): requirepass supplies one shared AUTH password; prefer separate least-privilege ACL identities. - keyless (REASONED; docs): ~app:* constrains key arguments, not keyless commands: write categories include global flush/swap and 7+ FUNCTION writes; reads include keyspace walking. - allowlist (REASONED; docs): Removing flushall/flushdb/swapdb removes only those commands; replace broad grants with reset and explicit required commands such as GET/SET. - default-user (REASONED; docs): Default starts on nopass ~* +@all, adding &* from 6.2; secure or disable it only after named clients/admin work; existing sessions survive. - mfa (REASONED; docs): Redis has no second-factor dialogue; mutual TLS adds machine possession alongside passwords, not human MFA. - cert-user (REASONED; r86, release-notes): Redis 8.6+ tls-auth-clients-user defaults off; enabling it can authenticate without passwords. Keep off for these NOAUTH checks and omit the unknown directive on older releases. - reset (REASONED; docs): ACL SETUSER changes users incrementally and applies rules left-to-right; reset removes old grants/passwords and, on 7+, selectors. - channel-default (REASONED; r82, docs): resetchannels needs 6.2+; 6.0 lacks channel ACLs. Redis 6.2 default allchannels became resetchannels in 7.0. - inline-persistence (REASONED; docs): CONFIG REWRITE persists inline users only with a writable startup configuration and OK; it also rewrites other runtime settings. - acl-file (REASONED; r82, docs): External aclfile excludes inline users and requirepass; ACL SAVE persists, ACL LOAD replaces, invalid loads retain old policy, and CONFIG REWRITE does not save ACLs. - revocation (REASONED; docs): Disabling a user blocks new auth but CLIENT KILL USER closes existing sessions; password replacement also needs forced reconnect and persistence. - directional-keys (REASONED; docs): Redis 7+ %R~/%W~ separate read/write key access; commands still need grants and broader ~ patterns can defeat separation. - channels (REASONED; docs): Channel patterns are independent of keys; reset then allow required channels/commands. PSUBSCRIBE patterns must literally match an allowed pattern. - scripting (REASONED; docs): Subtract admin/dangerous/scripting after grants; SCRIPT/FUNCTION removal alone misses execution commands. Omit FUNCTION on 6.x and rebuild unknown selectors first. - startup-commands (REASONED; r82): Redis 7+ enable-debug-command and enable-module-command default no in the pin; local/yes and startup-loaded modules still require ACL review. - renaming (REASONED; docs): rename-command is deprecated; use ACL command restrictions. - tls-listener (REASONED; docs, r82): port 0 removes plaintext while tls-port 6379 serves TLS with configured CA/certificate/key; set mutual TLS for certificate-capable clients. - tls-cert-default (REASONED; ha): tls-auth-clients defaults yes; this guide's initial no removes client-certificate requirements, including incoming replicas, not the TLS cluster bus. - cli (REASONED; docs): Use CA trust, --user and masked --askpass with history disabled; GET app:probe is allowed while PING is not granted to app. - verify-listeners (REASONED; r82): Inventory all listeners; only intended loopback/private addresses pass, and socket errors establish nothing. - verify-plaintext (REASONED; docs): Any plaintext RESP reply proves plaintext availability; TLS-only 6379 must close/reset plaintext while TLS reaches the same endpoint. - verify-noauth (REASONED; docs): Secured default produces NOAUTH over TLS, exposed nopass produces PONG; certificate failures precede this test, and authenticated GET is the positive control. - verify-acl (REASONED; docs): Read effective users including selectors/passwords without publishing hashes; app GET succeeds and CONFIG GET bind returns NOPERM, not NOAUTH or unknown command. - verify-acl-log (REASONED; docs, r82): Correlate denied CONFIG with user and age in ACL LOG; preserve events before resets, and bounded in-memory emptiness proves no absence of failures. - verify-direction (REASONED; docs): Redis 7+ DRYRUN allows input reads/output writes/allowed publication and denies reverse access/unrelated publication; it does not execute or prove authentication. - verify-persistence (REASONED; docs): Repeat policy and permissions after restart; runtime-only changes revert. Retired users must lose old sessions and fail fresh auth. - replication-auth (REASONED; docs, ha): Use PING/REPLCONF/PSYNC-only replication users and masteruser/masterauth on every potential primary; outbound credentials do not protect incoming access or limit the stream by key. - sentinel-default (REASONED; ha): Sentinel normally listens on 26379 with protected mode disabled and no active auth rule; data-port protection does not protect it. - sentinel-auth (REASONED; docs, ha): Password-only Sentinel auth applies throughout 6.0+; ACLs require 6.2+. Provision separate peer/admin and narrow discovery identities; do not combine the two arrangements. - sentinel-announcement (REASONED; ha): Private bind and network policy restrict Sentinel; announce-ip/port only publish discovery information. Password-only peers share a distinct Sentinel password. - monitor-auth (REASONED; ha): Sentinel monitored-server credentials start unset and differ from peer credentials; provision every potential primary/replica. - monitor-acl (REASONED; docs, ha): Monitored-server ACLs require 6.0+, channel isolation 6.2+; the 8.10.1-reviewed operational policy permits only `__sentinel__:hello` publication/subscription. - cluster-bus (REASONED; docs): Opt-in cluster adds a binary bus, normally data port+10000 (16379); data RESP AUTH is not a bus gate. Restrict node and client paths separately. - cluster-announcement (REASONED; ha): 8.10.1 cluster-port selects the bus listener; announce fields are not firewalls. TLS offset uses tls-port; binds and remapped advertisements must agree. - ha-tls (REASONED; docs, ha): Client TLS does not enable outgoing replication or cluster TLS; tls-replication and tls-cluster default disabled and must be configured separately. - outgoing-cert (REASONED; r60, r620): Outgoing TLS normally reuses the server certificate; separate tls-client-cert/key need 6.2+. Older builds require one dual-purpose certificate and reject unknown directives. - bus-cert (REASONED; ha): The TLS cluster bus always requires a trusted peer certificate, independently of data-port tls-auth-clients. - sentinel-tls (REASONED; ha): Sentinel tls-port independently creates incoming TLS; tls-replication selects outgoing data/peer TLS and default TLS-port advertisement; explicit announce-port overrides it. - peer-name (REASONED; ha, release-notes): Redis 8.10+ tls-expected-peer-name defaults unset; it adds shared trust-group identity to outgoing replication/bus/MIGRATE and incoming bus, not node-ID binding. - peer-limits (REASONED; ha): TLS_NO_PEER_NAME_VERIFICATION builds warn and skip name enforcement; 8.10.1 Sentinel's outgoing hiredis path does not apply this check; use a dedicated trust boundary. - verify-replication (REASONED; ha, docs): Wrong/missing replica credentials must fail; correct auth restores link and fresh canary transfer. Test replica incoming auth and repeat through promotion. - verify-replication-acl (REASONED; docs): Redis 7+ DRYRUN PSYNC permits replication and denies app; synchronization and promotion require separate runtime tests. - verify-sentinel (REASONED; docs, ha): Uncredentialed discovery returns endpoint exposed and NOAUTH secured; discovery credentials restore it but administrative commands get NOPERM. Peer announcements alone prove no communication. - verify-monitor (REASONED; docs, ha): sentinel-monitor permits PING but denies GET/unrelated publication; wrong auth-pass stops monitoring, correct credentials restore it, including after promotion. - verify-bus (REASONED; docs, ha): Compare actual bus TCP access from forbidden and allowed locations; only forbidden access must fail. CLUSTER NODES announcements and listener evidence complement reachability. - verify-replication-tls (REASONED; ha, docs): Correlate TLS-only primary listener, replica connection to that port and a fresh replicated canary; link status or a separate TLS probe alone proves no encryption. - verify-replica-cert (REASONED; docs, ha): Without automatic certificate-user auth, missing client cert must fail TLS and trusted cert reach NOAUTH; with tls-auth-clients no both reach NOAUTH. - verify-bus-cert (REASONED; ha, docs): TLS 1.2 bus probes reject absent/untrusted certificates and accept trusted peers under either data-port client-auth setting; handshake success is not cluster membership. - verify-sentinel-tls (REASONED; docs, ha): 26379 must reject plaintext RESP, require client certificates and Redis credentials, and restore discovery with both; outgoing TLS/failover need separate topology evidence. - verify-peer-name (REASONED; ha): Same-CA bus certificates both pass without name restriction; enabled enforcement rejects wrong SAN/CN and accepts matching identity. Build warnings/readback alone are insufficient. - verify-outbound-name (REASONED; ha, docs): Force new replication connections: wrong-name server works unrestricted, fails with explicit name-verification evidence when restricted, then recovers with matching certificate/canary. - valkey (REASONED; valkey): Valkey shares requirepass/ACL/TLS concepts; verify its version-specific syntax. Redis HA findings and limitations have not been established for Valkey. - local-sentinel (DEMONSTRATED; docs): Historical Sentinel shell parsing and invalid-input rejection tested local dispatch only, not Redis behavior; those runs covered an earlier form whose refusals exited with status 1, and the current form is covered by gate bash -n and ShellCheck passes and stub runs. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "fa2cae058289856b567209194eda26b4e7b97d426b3f84f8c113132b92e5d0f3", "components": { "r60": { "name": "Redis configuration", "basis": "6.0.20", "sources": { "s2e8e20a079ac": "https://raw.githubusercontent.com/redis/redis/6.0.20/redis.conf" } }, "r62": { "name": "Redis configuration", "basis": "6.2.14", "sources": { "s0e0da970fda1": "https://raw.githubusercontent.com/redis/redis/6.2.14/redis.conf" } }, "r620": { "name": "Redis configuration", "basis": "6.2.0", "sources": { "sbf13a91d0de5": "https://raw.githubusercontent.com/redis/redis/6.2.0/redis.conf" } }, "r70": { "name": "Redis configuration", "basis": "7.0.15", "sources": { "s4b8359fa209a": "https://raw.githubusercontent.com/redis/redis/7.0.15/redis.conf" } }, "r82": { "name": "Redis configuration", "basis": "8.2.1", "sources": { "s4c78c91e3995": "https://raw.githubusercontent.com/redis/redis/8.2.1/redis.conf" } }, "r86": { "name": "Redis configuration", "basis": "8.6.0", "sources": { "s4afad097a602": "https://raw.githubusercontent.com/redis/redis/8.6.0/redis.conf" } }, "ha": { "name": "Redis HA source", "basis": "8.10.1", "sources": { "sf86ee0b4abaa": "https://raw.githubusercontent.com/redis/redis/8.10.1/redis.conf", "s81bf82ed258f": "https://raw.githubusercontent.com/redis/redis/8.10.1/src/cluster_legacy.c", "s0c0876f8a079": "https://raw.githubusercontent.com/redis/redis/8.10.1/src/replication.c", "sd23ed315b5f6": "https://raw.githubusercontent.com/redis/redis/8.10.1/sentinel.conf", "s4de235c7343e": "https://raw.githubusercontent.com/redis/redis/8.10.1/src/sentinel.c", "s9f433f5e78e3": "https://raw.githubusercontent.com/redis/redis/8.10.1/src/config.c", "saf8be6411363": "https://raw.githubusercontent.com/redis/redis/8.10.1/src/server.c", "s28f3f121848c": "https://raw.githubusercontent.com/redis/redis/8.10.1/src/tls.c" } }, "image8": { "name": "Redis image tag", "basis": "v8.10.2", "sources": { "sfc739e8ba37a": "https://github.com/redis/docker-library-redis/blob/v8.10.2/alpine/Dockerfile#L82-L87", "sc9481a9a0b3e": "https://github.com/redis/docker-library-redis/blob/v8.10.2/debian/docker-entrypoint.sh", "sf42044d0a0cf": "https://github.com/redis/docker-library-redis/blob/v8.10.2/debian/Dockerfile#L127-L130" } }, "r74": { "name": "Redis compiled defaults", "basis": "7.4.8", "sources": { "s255d08889d8e": "https://github.com/redis/redis/blob/7.4.8/src/server.h#L119-L120", "s71d2454bab19": "https://github.com/redis/redis/blob/7.4.8/src/acl.c#L1407-L1415" } }, "r8102": { "name": "Redis compiled defaults", "basis": "8.10.2", "sources": { "sd44747e97e5d": "https://github.com/redis/redis/blob/8.10.2/src/server.h#L138-L139", "see1833b741f4": "https://github.com/redis/redis/blob/8.10.2/src/acl.c#L1430-L1438" } }, "docs": { "name": "Redis documentation", "basis": "unknown", "sources": { "s676f92c46896": "https://redis.io/docs/latest/operate/oss_and_stack/management/security/", "s5f24cdfad62f": "https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/", "s6cf51f8deeec": "https://redis.io/docs/latest/commands/auth/", "s72fa2dffefe0": "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/", "s503353d67ab0": "https://redis.io/docs/latest/commands/flushall/", "s04727068bf6d": "https://redis.io/docs/latest/commands/flushdb/", "s77f119379267": "https://redis.io/docs/latest/commands/swapdb/", "s94f2d2ff2791": "https://redis.io/docs/latest/commands/keys/", "sbf9af1bcd939": "https://redis.io/docs/latest/commands/randomkey/", "sf208d01054d8": "https://redis.io/docs/latest/commands/scan/", "s845464defcbf": "https://redis.io/docs/latest/commands/function-delete/", "se6006d0ebe9d": "https://redis.io/docs/latest/commands/function-flush/", "sfe89bc1db7ec": "https://redis.io/docs/latest/commands/function-load/", "s2d7c63097f6b": "https://redis.io/docs/latest/commands/function-restore/", "s810656b5a50e": "https://redis.io/docs/latest/commands/acl-setuser/", "seaec1ae0ac17": "https://redis.io/docs/latest/commands/config-rewrite/", "s96598ec29ba4": "https://redis.io/docs/latest/commands/acl-save/", "s3e1c0de156e5": "https://redis.io/docs/latest/commands/acl-load/", "s89a679483b31": "https://redis.io/docs/latest/commands/client-kill/", "sb5b7454ab5ec": "https://redis.io/docs/latest/commands/psubscribe/", "s19cd1836b3cd": "https://redis.io/docs/latest/commands/publish/", "sedf741005697": "https://redis.io/docs/latest/commands/subscribe/", "s6e5d3141dadd": "https://redis.io/docs/latest/commands/eval/", "s783428f2ae71": "https://redis.io/docs/latest/commands/evalsha/", "s0b532abc6b3e": "https://redis.io/docs/latest/commands/fcall/", "se9b0821291f2": "https://redis.io/docs/latest/commands/eval_ro/", "sf9ad57a17ff4": "https://redis.io/docs/latest/commands/evalsha_ro/", "s4265e3593644": "https://redis.io/docs/latest/commands/fcall_ro/", "sc6b4e54687ac": "https://redis.io/docs/latest/develop/tools/cli/", "sd3d4315b25ce": "https://redis.io/docs/latest/commands/get/", "se987368157bc": "https://redis.io/docs/latest/commands/set/", "sc62448a319aa": "https://redis.io/docs/latest/commands/ping/", "s04c6a2a49051": "https://redis.io/docs/latest/commands/acl-getuser/", "s7ab1aac347a4": "https://redis.io/docs/latest/commands/config-get/", "sb5f5479eccf8": "https://redis.io/docs/latest/commands/acl-log/", "s8d6577576295": "https://redis.io/docs/latest/commands/acl-dryrun/", "s43b199002e27": "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#acl-rules-for-sentinel-and-replicas", "s34e2264081b7": "https://redis.io/docs/latest/operate/oss_and_stack/management/replication/#setting-a-replica-to-authenticate-to-a-master", "s426dcab02f8b": "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication", "s6c549585a510": "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#redis-access-control-list-authentication", "s075c67b3e9d7": "https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/#redis-cluster-tcp-ports", "sce02504d451b": "https://redis.io/docs/latest/commands/info/", "sfe348474049a": "https://redis.io/docs/latest/commands/cluster-nodes/", "s04d70801d688": "https://redis.io/docs/latest/operate/oss_and_stack/management/security/encryption/#sentinel", "sb384cc06fc43": "https://redis.io/docs/latest/commands/config-set/" } }, "image": { "name": "Redis image source", "basis": "584feaab6cd12a77866c121beff1bb16ff239486", "sources": { "s6e56989d73a1": "https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/6.2/debian/Dockerfile#L85-L90", "sdb0e9a2d1d56": "https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/debian/Dockerfile#L146-L150", "s65ef31503c67": "https://github.com/redis/docker-library-redis/blob/584feaab6cd12a77866c121beff1bb16ff239486/7.4/debian/docker-entrypoint.sh" } }, "release-notes": { "name": "Redis release notes", "basis": "unknown", "sources": { "sadcc4b0a2908": "https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.6-release-notes/", "sead103199248": "https://redis.io/docs/latest/operate/oss_and_stack/stack-with-enterprise/release-notes/redisce/redisos-8.10-release-notes/" } }, "valkey": { "name": "Valkey documentation", "basis": "unknown", "sources": { "sb488496d1f4b": "https://valkey.io/topics/acl/", "s0eebdf8ee73e": "https://valkey.io/topics/security/", "sdc8d63632a85": "https://valkey.io/topics/tls/" } } }, "claims": { "baseline": {"text": "Redis 6.0+ supplies TLS and ACLs; TLS needs TLS-enabled server/client builds. Restrict bind and credentials before remote access.", "components": ["docs", "r60"], "sources": ["docs:s676f92c46896", "docs:s5f24cdfad62f", "r60:s2e8e20a079ac"], "status": "REASONED"}, "optional-bind": {"text": "Redis 6.2+ -::1 makes an absent interface optional, not an occupied address; Redis 6.0 uses plain loopback binding.", "components": ["r62", "r60"], "sources": ["r62:s0e0da970fda1", "r60:s2e8e20a079ac"], "status": "REASONED"}, "protected-mode": {"text": "6.0/6.2 protected mode requires default nopass and no explicit bind; 7.0 drops the bind condition. Explicit wildcard bind can expose passwordless 6.x.", "components": ["r60", "r62", "r70"], "sources": ["r60:s2e8e20a079ac", "r62:s0e0da970fda1", "r70:s4b8359fa209a"], "status": "REASONED"}, "image-protected": {"text": "Pinned 6.2-7.4 images and the checked 8.x tag patch protected-mode to no and run redis-server without a config; publish privately with explicit credentials.", "components": ["image", "image8"], "sources": ["image:s6e56989d73a1", "image:sdb0e9a2d1d56", "image8:sfc739e8ba37a", "image8:sc9481a9a0b3e", "image:s65ef31503c67", "image8:sf42044d0a0cf"], "status": "REASONED"}, "compiled-bind": {"text": "7.4.8/8.10.2 compiled bind is * -::*; IPv6 wildcard applies where available.", "components": ["r74", "r8102"], "sources": ["r74:s255d08889d8e", "r8102:sd44747e97e5d"], "status": "REASONED"}, "compiled-user": {"text": "Checked 7.4.8/8.10.2 default users are on nopass +@all; disabled DEBUG/MODULE commands remain disabled.", "components": ["r74", "r8102", "r82"], "sources": ["r74:s71d2454bab19", "r8102:see1833b741f4", "r82:s4c78c91e3995"], "status": "REASONED"}, "password": {"text": "requirepass supplies one shared AUTH password; prefer separate least-privilege ACL identities.", "components": ["docs"], "sources": ["docs:s676f92c46896", "docs:s6cf51f8deeec", "docs:s72fa2dffefe0"], "status": "REASONED"}, "keyless": {"text": "~app:* constrains key arguments, not keyless commands: write categories include global flush/swap and 7+ FUNCTION writes; reads include keyspace walking.", "components": ["docs"], "sources": ["docs:s72fa2dffefe0", "docs:s503353d67ab0", "docs:s04727068bf6d", "docs:s77f119379267", "docs:s94f2d2ff2791", "docs:sbf9af1bcd939", "docs:sf208d01054d8", "docs:s845464defcbf", "docs:se6006d0ebe9d", "docs:sfe89bc1db7ec", "docs:s2d7c63097f6b"], "status": "REASONED"}, "allowlist": {"text": "Removing flushall/flushdb/swapdb removes only those commands; replace broad grants with reset and explicit required commands such as GET/SET.", "components": ["docs"], "sources": ["docs:s810656b5a50e"], "status": "REASONED"}, "default-user": {"text": "Default starts on nopass ~* +@all, adding &* from 6.2; secure or disable it only after named clients/admin work; existing sessions survive.", "components": ["docs"], "sources": ["docs:s72fa2dffefe0"], "status": "REASONED"}, "mfa": {"text": "Redis has no second-factor dialogue; mutual TLS adds machine possession alongside passwords, not human MFA.", "components": ["docs"], "sources": ["docs:s676f92c46896", "docs:s5f24cdfad62f"], "status": "REASONED"}, "cert-user": {"text": "Redis 8.6+ tls-auth-clients-user defaults off; enabling it can authenticate without passwords. Keep off for these NOAUTH checks and omit the unknown directive on older releases.", "components": ["r86", "release-notes"], "sources": ["r86:s4afad097a602", "release-notes:sadcc4b0a2908"], "status": "REASONED"}, "reset": {"text": "ACL SETUSER changes users incrementally and applies rules left-to-right; reset removes old grants/passwords and, on 7+, selectors.", "components": ["docs"], "sources": ["docs:s810656b5a50e"], "status": "REASONED"}, "channel-default": {"text": "resetchannels needs 6.2+; 6.0 lacks channel ACLs. Redis 6.2 default allchannels became resetchannels in 7.0.", "components": ["r82", "docs"], "sources": ["r82:s4c78c91e3995", "docs:s810656b5a50e"], "status": "REASONED"}, "inline-persistence": {"text": "CONFIG REWRITE persists inline users only with a writable startup configuration and OK; it also rewrites other runtime settings.", "components": ["docs"], "sources": ["docs:seaec1ae0ac17"], "status": "REASONED"}, "acl-file": {"text": "External aclfile excludes inline users and requirepass; ACL SAVE persists, ACL LOAD replaces, invalid loads retain old policy, and CONFIG REWRITE does not save ACLs.", "components": ["r82", "docs"], "sources": ["r82:s4c78c91e3995", "docs:s96598ec29ba4", "docs:s3e1c0de156e5"], "status": "REASONED"}, "revocation": {"text": "Disabling a user blocks new auth but CLIENT KILL USER closes existing sessions; password replacement also needs forced reconnect and persistence.", "components": ["docs"], "sources": ["docs:s72fa2dffefe0", "docs:s89a679483b31"], "status": "REASONED"}, "directional-keys": {"text": "Redis 7+ %R~/%W~ separate read/write key access; commands still need grants and broader ~ patterns can defeat separation.", "components": ["docs"], "sources": ["docs:s72fa2dffefe0"], "status": "REASONED"}, "channels": {"text": "Channel patterns are independent of keys; reset then allow required channels/commands. PSUBSCRIBE patterns must literally match an allowed pattern.", "components": ["docs"], "sources": ["docs:s810656b5a50e", "docs:s72fa2dffefe0", "docs:sb5b7454ab5ec", "docs:s19cd1836b3cd", "docs:sedf741005697"], "status": "REASONED"}, "scripting": {"text": "Subtract admin/dangerous/scripting after grants; SCRIPT/FUNCTION removal alone misses execution commands. Omit FUNCTION on 6.x and rebuild unknown selectors first.", "components": ["docs"], "sources": ["docs:s810656b5a50e", "docs:s6e5d3141dadd", "docs:s783428f2ae71", "docs:s0b532abc6b3e", "docs:se9b0821291f2", "docs:sf9ad57a17ff4", "docs:s4265e3593644", "docs:s845464defcbf", "docs:se6006d0ebe9d", "docs:sfe89bc1db7ec", "docs:s2d7c63097f6b"], "status": "REASONED"}, "startup-commands": {"text": "Redis 7+ enable-debug-command and enable-module-command default no in the pin; local/yes and startup-loaded modules still require ACL review.", "components": ["r82"], "sources": ["r82:s4c78c91e3995"], "status": "REASONED"}, "renaming": {"text": "rename-command is deprecated; use ACL command restrictions.", "components": ["docs"], "sources": ["docs:s676f92c46896"], "status": "REASONED"}, "tls-listener": {"text": "port 0 removes plaintext while tls-port 6379 serves TLS with configured CA/certificate/key; set mutual TLS for certificate-capable clients.", "components": ["docs", "r82"], "sources": ["docs:s5f24cdfad62f", "r82:s4c78c91e3995"], "status": "REASONED"}, "tls-cert-default": {"text": "tls-auth-clients defaults yes; this guide's initial no removes client-certificate requirements, including incoming replicas, not the TLS cluster bus.", "components": ["ha"], "sources": ["ha:sf86ee0b4abaa", "ha:s81bf82ed258f"], "status": "REASONED"}, "cli": {"text": "Use CA trust, --user and masked --askpass with history disabled; GET app:probe is allowed while PING is not granted to app.", "components": ["docs"], "sources": ["docs:sc6b4e54687ac", "docs:s6cf51f8deeec", "docs:sd3d4315b25ce", "docs:se987368157bc", "docs:sc62448a319aa"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory all listeners; only intended loopback/private addresses pass, and socket errors establish nothing.", "components": ["r82"], "sources": ["r82:s4c78c91e3995"], "status": "REASONED", "verify": [1]}, "verify-plaintext": {"text": "Any plaintext RESP reply proves plaintext availability; TLS-only 6379 must close/reset plaintext while TLS reaches the same endpoint.", "components": ["docs"], "sources": ["docs:s5f24cdfad62f"], "status": "REASONED", "verify": [2]}, "verify-noauth": {"text": "Secured default produces NOAUTH over TLS, exposed nopass produces PONG; certificate failures precede this test, and authenticated GET is the positive control.", "components": ["docs"], "sources": ["docs:s72fa2dffefe0", "docs:s5f24cdfad62f"], "status": "REASONED", "verify": [2]}, "verify-acl": {"text": "Read effective users including selectors/passwords without publishing hashes; app GET succeeds and CONFIG GET bind returns NOPERM, not NOAUTH or unknown command.", "components": ["docs"], "sources": ["docs:s04c6a2a49051", "docs:s7ab1aac347a4"], "status": "REASONED"}, "verify-acl-log": {"text": "Correlate denied CONFIG with user and age in ACL LOG; preserve events before resets, and bounded in-memory emptiness proves no absence of failures.", "components": ["docs", "r82"], "sources": ["docs:sb5f5479eccf8", "r82:s4c78c91e3995"], "status": "REASONED"}, "verify-direction": {"text": "Redis 7+ DRYRUN allows input reads/output writes/allowed publication and denies reverse access/unrelated publication; it does not execute or prove authentication.", "components": ["docs"], "sources": ["docs:s8d6577576295"], "status": "REASONED"}, "verify-persistence": {"text": "Repeat policy and permissions after restart; runtime-only changes revert. Retired users must lose old sessions and fail fresh auth.", "components": ["docs"], "sources": ["docs:s72fa2dffefe0", "docs:s89a679483b31"], "status": "REASONED"}, "replication-auth": {"text": "Use PING/REPLCONF/PSYNC-only replication users and masteruser/masterauth on every potential primary; outbound credentials do not protect incoming access or limit the stream by key.", "components": ["docs", "ha"], "sources": ["docs:s43b199002e27", "ha:sf86ee0b4abaa", "ha:s0c0876f8a079", "docs:s34e2264081b7"], "status": "REASONED"}, "sentinel-default": {"text": "Sentinel normally listens on 26379 with protected mode disabled and no active auth rule; data-port protection does not protect it.", "components": ["ha"], "sources": ["ha:sd23ed315b5f6"], "status": "REASONED"}, "sentinel-auth": {"text": "Password-only Sentinel auth applies throughout 6.0+; ACLs require 6.2+. Provision separate peer/admin and narrow discovery identities; do not combine the two arrangements.", "components": ["docs", "ha"], "sources": ["docs:s426dcab02f8b", "ha:sd23ed315b5f6"], "status": "REASONED"}, "sentinel-announcement": {"text": "Private bind and network policy restrict Sentinel; announce-ip/port only publish discovery information. Password-only peers share a distinct Sentinel password.", "components": ["ha"], "sources": ["ha:sd23ed315b5f6"], "status": "REASONED"}, "monitor-auth": {"text": "Sentinel monitored-server credentials start unset and differ from peer credentials; provision every potential primary/replica.", "components": ["ha"], "sources": ["ha:sd23ed315b5f6", "ha:s4de235c7343e"], "status": "REASONED"}, "monitor-acl": {"text": "Monitored-server ACLs require 6.0+, channel isolation 6.2+; the 8.10.1-reviewed operational policy permits only `__sentinel__:hello` publication/subscription.", "components": ["docs", "ha"], "sources": ["docs:s6c549585a510", "ha:s4de235c7343e"], "status": "REASONED"}, "cluster-bus": {"text": "Opt-in cluster adds a binary bus, normally data port+10000 (16379); data RESP AUTH is not a bus gate. Restrict node and client paths separately.", "components": ["docs"], "sources": ["docs:s075c67b3e9d7"], "status": "REASONED"}, "cluster-announcement": {"text": "8.10.1 cluster-port selects the bus listener; announce fields are not firewalls. TLS offset uses tls-port; binds and remapped advertisements must agree.", "components": ["ha"], "sources": ["ha:sf86ee0b4abaa", "ha:s81bf82ed258f"], "status": "REASONED"}, "ha-tls": {"text": "Client TLS does not enable outgoing replication or cluster TLS; tls-replication and tls-cluster default disabled and must be configured separately.", "components": ["docs", "ha"], "sources": ["docs:s5f24cdfad62f", "ha:s9f433f5e78e3"], "status": "REASONED"}, "outgoing-cert": {"text": "Outgoing TLS normally reuses the server certificate; separate tls-client-cert/key need 6.2+. Older builds require one dual-purpose certificate and reject unknown directives.", "components": ["r60", "r620"], "sources": ["r60:s2e8e20a079ac", "r620:sbf13a91d0de5"], "status": "REASONED"}, "bus-cert": {"text": "The TLS cluster bus always requires a trusted peer certificate, independently of data-port tls-auth-clients.", "components": ["ha"], "sources": ["ha:s81bf82ed258f"], "status": "REASONED"}, "sentinel-tls": {"text": "Sentinel tls-port independently creates incoming TLS; tls-replication selects outgoing data/peer TLS and default TLS-port advertisement; explicit announce-port overrides it.", "components": ["ha"], "sources": ["ha:saf8be6411363", "ha:s4de235c7343e"], "status": "REASONED"}, "peer-name": {"text": "Redis 8.10+ tls-expected-peer-name defaults unset; it adds shared trust-group identity to outgoing replication/bus/MIGRATE and incoming bus, not node-ID binding.", "components": ["ha", "release-notes"], "sources": ["ha:sf86ee0b4abaa", "ha:s28f3f121848c", "release-notes:sead103199248"], "status": "REASONED"}, "peer-limits": {"text": "TLS_NO_PEER_NAME_VERIFICATION builds warn and skip name enforcement; 8.10.1 Sentinel's outgoing hiredis path does not apply this check; use a dedicated trust boundary.", "components": ["ha"], "sources": ["ha:s28f3f121848c", "ha:s4de235c7343e"], "status": "REASONED"}, "verify-replication": {"text": "Wrong/missing replica credentials must fail; correct auth restores link and fresh canary transfer. Test replica incoming auth and repeat through promotion.", "components": ["ha", "docs"], "sources": ["ha:s0c0876f8a079", "docs:sce02504d451b", "docs:s34e2264081b7"], "status": "REASONED"}, "verify-replication-acl": {"text": "Redis 7+ DRYRUN PSYNC permits replication and denies app; synchronization and promotion require separate runtime tests.", "components": ["docs"], "sources": ["docs:s8d6577576295", "docs:s43b199002e27"], "status": "REASONED"}, "verify-sentinel": {"text": "Uncredentialed discovery returns endpoint exposed and NOAUTH secured; discovery credentials restore it but administrative commands get NOPERM. Peer announcements alone prove no communication.", "components": ["docs", "ha"], "sources": ["docs:s426dcab02f8b", "ha:sd23ed315b5f6"], "status": "REASONED", "verify": [7]}, "verify-monitor": {"text": "sentinel-monitor permits PING but denies GET/unrelated publication; wrong auth-pass stops monitoring, correct credentials restore it, including after promotion.", "components": ["docs", "ha"], "sources": ["docs:s6c549585a510", "ha:s4de235c7343e"], "status": "REASONED"}, "verify-bus": {"text": "Compare actual bus TCP access from forbidden and allowed locations; only forbidden access must fail. CLUSTER NODES announcements and listener evidence complement reachability.", "components": ["docs", "ha"], "sources": ["docs:s075c67b3e9d7", "docs:sfe348474049a", "ha:s81bf82ed258f"], "status": "REASONED", "verify": [3]}, "verify-replication-tls": {"text": "Correlate TLS-only primary listener, replica connection to that port and a fresh replicated canary; link status or a separate TLS probe alone proves no encryption.", "components": ["ha", "docs"], "sources": ["ha:saf8be6411363", "docs:sce02504d451b", "docs:s5f24cdfad62f"], "status": "REASONED", "verify": [4, 5]}, "verify-replica-cert": {"text": "Without automatic certificate-user auth, missing client cert must fail TLS and trusted cert reach NOAUTH; with tls-auth-clients no both reach NOAUTH.", "components": ["docs", "ha"], "sources": ["docs:s5f24cdfad62f", "ha:sf86ee0b4abaa"], "status": "REASONED", "verify": [5]}, "verify-bus-cert": {"text": "TLS 1.2 bus probes reject absent/untrusted certificates and accept trusted peers under either data-port client-auth setting; handshake success is not cluster membership.", "components": ["ha", "docs"], "sources": ["ha:s81bf82ed258f", "docs:s5f24cdfad62f"], "status": "REASONED", "verify": [6]}, "verify-sentinel-tls": {"text": "26379 must reject plaintext RESP, require client certificates and Redis credentials, and restore discovery with both; outgoing TLS/failover need separate topology evidence.", "components": ["docs", "ha"], "sources": ["docs:s04d70801d688", "ha:s4de235c7343e", "docs:sc6b4e54687ac"], "status": "REASONED", "verify": [7]}, "verify-peer-name": {"text": "Same-CA bus certificates both pass without name restriction; enabled enforcement rejects wrong SAN/CN and accepts matching identity. Build warnings/readback alone are insufficient.", "components": ["ha"], "sources": ["ha:sf86ee0b4abaa", "ha:s28f3f121848c"], "status": "REASONED", "verify": [8]}, "verify-outbound-name": {"text": "Force new replication connections: wrong-name server works unrestricted, fails with explicit name-verification evidence when restricted, then recovers with matching certificate/canary.", "components": ["ha", "docs"], "sources": ["ha:s9f433f5e78e3", "ha:s28f3f121848c", "docs:sb384cc06fc43", "docs:s89a679483b31", "docs:sce02504d451b"], "status": "REASONED"}, "valkey": {"text": "Valkey shares requirepass/ACL/TLS concepts; verify its version-specific syntax. Redis HA findings and limitations have not been established for Valkey.", "components": ["valkey"], "sources": ["valkey:sb488496d1f4b", "valkey:s0eebdf8ee73e", "valkey:sdc8d63632a85"], "status": "REASONED"}, "local-sentinel": {"text": "Historical Sentinel shell parsing and invalid-input rejection tested local dispatch only, not Redis behavior; those runs covered an earlier form whose refusals exited with status 1, and the current form is covered by gate bash -n and ShellCheck passes and stub runs.", "components": ["docs"], "sources": ["docs:sc6b4e54687ac"], "status": "DEMONSTRATED", "evidence": "With `set -u`, it rejected the unchanged placeholder, an embedded placeholder, an empty host, a marker-only assignment, an omitted assignment with no inherited arguments, and an extra host argument before any connection command ran."} } } ```` ====================================================================== ==> elasticsearch.md ====================================================================== # Elasticsearch and OpenSearch: keep security switched on Elasticsearch and OpenSearch are search engines that index documents and logs for searching. This guide helps your AI assistant keep their security switched on, including TLS and authentication, scoped roles, anonymous access off, CORS off unless a browser needs it, and audit logging on OpenSearch or paid Elasticsearch tiers. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/elasticsearch.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Elasticsearch API 8.x, Elasticsearch reference 8.19, Elasticsearch listener source 8.19.22 and 9.5.4, OpenSearch reference 3.6, OpenSearch S3 reference 3.1, OpenSearch S3 source 3.8.0, OpenSearch GCS source 3.8.0, and OpenSearch Azure source 3.8.0, plus 7 more recorded bases; 3 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-10-22; documentation checked September 2026. The guide records 83 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Open Elasticsearch instances produced some of the largest data leaks on record. Modern versions ship secure; the failure mode today is deliberately switching protection off to make an error message go away. ## Elasticsearch (8.0 and later) The examples below target self-managed Elasticsearch 8.x. TLS, native users, index-scoped RBAC, REST API keys, anonymous-access controls, and CORS settings are available with **free Basic**. LDAP authentication and audit logging require **paid Platinum/Enterprise**. At the time of writing, self-managed Platinum is available to existing customers only. See the [subscription matrix](https://www.elastic.co/subscriptions). - A fresh install auto-configures security on first start when it runs: authentication is enabled, TLS is set up for HTTP and transport, and a password is generated for the `elastic` superuser. Keep all of it. Auto-configuration is SKIPPED if startup output is redirected, if the config directory is not writable, or if certain security or discovery settings already exist (single-node discovery and a `cluster.initial_master_nodes` naming only the current node are exempted); Debian and RPM packages do not print the password (reset it with `elasticsearch-reset-password -u elastic`). Confirm security actually came up rather than assuming it did. See [automatic security setup](https://www.elastic.co/docs/deploy-manage/security/self-auto-setup). - Never set `xpack.security.enabled: false`, and never expose a node where TLS (`xpack.security.http.ssl.enabled`) has been turned off. If a client cannot connect, fix the client's CA trust ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) or issue a real certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md)); do not remove the lock. - Listener defaults are source-checked at Elasticsearch **8.19.22 and 9.5.4**. `network.host` defaults to `_local_`: loopback addresses on interfaces that are up, including IPv4 and IPv6 where available. `_local:ipv4_` and `_local:ipv6_` restrict the family. HTTP and transport use the network settings unless their own host/bind/publish settings override them: at both versions the HTTP server binds `http.bind_host` (itself defaulting to `http.host`) or, when that is empty, `network.bind_host`, and each transport profile binds its own bind host or, when that is empty, `network.bind_host` (pinned Sources below, with the [networking settings](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/networking-settings) reference). Read the effective configuration and route remote access through a private network, VPN or tunnel, with TLS everywhere. - When security auto-configuration reaches its configuration-writing step, it adds `http.host: 0.0.0.0` only if none of `http.host`, `http.bind_host`, `http.publish_host`, `network.host`, `network.bind_host`, or `network.publish_host` has a value. This condition applies in enrollment mode too. The standard archive/package configuration leaves these unset, so an eligible initial auto-configuration widens HTTP. For transport, the analogous guard checks `transport.host`, `transport.bind_host`, `transport.publish_host`, and the same three network settings. It writes a commented `#transport.host: 0.0.0.0` for a new cluster; enrollment makes that line active only if a discovered transport publish address resolves to a non-loopback address absent from the local host. It does not overwrite existing address settings. See the pinned `AutoConfigureNode` Sources below. - The official Docker images (8.19.22, 9.4.6 and 9.5.3 at the pinned library commit) ship an `elasticsearch.yml` that sets `network.host: 0.0.0.0`, which Elastic's networking settings reference describes as "The addresses of all available network interfaces". So unless an environment variable, a `-E` setting in the container command after a first argument of `eswrapper` or a path whose last component is `elasticsearch`, or a bind-mounted configuration file overrides it (for example with `transport.host`), transport (9300) listens on them as well as HTTP (9200). The entrypoint drops a first argument of `eswrapper` or of a path whose last component is `elasticsearch` and passes the rest to the server; a different first argument runs as its own command. Keep security auto-configuration, which sets up transport TLS, and keep 9300 unreachable: do not publish it (`-P` would, because the image EXPOSEs it), do not run the container with host networking, and do not share its network with untrusted containers. The Elasticsearch Docker source configurations at **8.19.22 and 9.5.4** also explicitly set `network.host: 0.0.0.0`. With that shipped setting present, both auto-configuration address guards above are false: the wildcard comes from the image configuration, without needing an added `http.host` or `transport.host` line. See the pinned Docker configuration Sources below. ### Application RBAC and scoped credentials **Free Basic.** Give each application its own identity and index-scoped role ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). The `superuser` role grants broad cluster and data access, including security administration; do not give it to applications. See [built-in roles](https://www.elastic.co/docs/reference/elasticsearch/roles). For both products' examples, substitute the host, port, and file paths before use. Use curl 7.76.0 or later. Provision credential files in an owner-only directory, with file permissions `0600`: - `/path/admin.header`: one complete `Authorization: Basic ...` header for the selected product's administrator. - `/path/app-user.header`: the same header format for the dedicated application user. - `/path/app-key.header`: one complete `Authorization: ApiKey ...` header for its issued key. For Basic authentication, the value is base64 of `username:password`; base64 is not encryption. Prepare these files through your secret manager or a protected local workflow, without putting secrets in shell command arguments or history. Keep request-body and key-response files equally protected, including files that already exist. File input keeps secrets out of curl's argv; it does not protect them from readers of those files or from verbose request tracing. 1. As an administrator with `manage_security`, create a role that reads only `app-data`. `manage_own_api_key` lets the dedicated user issue its own keys; it does not grant access to other indexes. See the [role API](https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role). ```bash curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/http_ca.crt --header @/path/admin.header \ --header 'Content-Type: application/json' --request PUT \ https://search.example.com:9200/_security/role/app_reader \ --data-binary '{ "cluster": ["manage_own_api_key"], "indices": [{"names": ["app-data"], "privileges": ["read"]}] }' ``` 2. Create `/path/es-app-user.json` with the following structure. Replace the password placeholder inside that protected file with a unique random password before submitting it. The [native-user API](https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-user) accepts the password in the JSON request body. ```json {"password":"REPLACE_WITH_RANDOM_PASSWORD","roles":["app_reader"]} ``` ```bash curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/http_ca.crt --header @/path/admin.header \ --header 'Content-Type: application/json' --request PUT \ --data-binary @/path/es-app-user.json \ https://search.example.com:9200/_security/user/app_reader ``` 3. Authenticate as that native user to create the application key. The explicit descriptor grants only reads of `app-data`; it does not copy the user's key-management privilege. ```bash (umask 077 curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/http_ca.crt --header @/path/app-user.header \ --header 'Content-Type: application/json' --request POST \ --output /path/es-app-key-response.json \ https://search.example.com:9200/_security/api_key \ --data-binary '{ "name": "app-reader", "expiration": "1d", "role_descriptors": { "app_read": { "cluster": [], "indices": [{"names": ["app-data"], "privileges": ["read"]}] } } }' ) ``` Effective key permissions are the intersection of `role_descriptors` and the creator's permissions captured at creation. Omitting descriptors inherits a snapshot of the creator's permissions; omitting `expiration` makes a non-expiring key. Authenticate key creation with the native user's credentials: an API key cannot create another privileged key. Store the response's `encoded` value in `/path/app-key.header` after `Authorization: ApiKey `. See [Create an API key](https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-create-api-key). Optional LDAP mapping is a **paid Platinum/Enterprise realm** example, not a requirement for the free native-user setup. With an existing, securely configured realm named `ldap1`, an administrator can map a specific directory group to the role: ```http PUT /_security/role_mapping/app_ldap { "enabled": true, "roles": ["app_reader"], "rules": { "all": [ {"field": {"realm.name": "ldap1"}}, {"field": {"groups": "cn=app-readers,ou=groups,dc=example,dc=com"}} ] } } ``` Use the actual realm and group DN. Native users receive their roles through the user API above; this mapping is for directory identities. See [LDAP authentication](https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap) and the [role-mapping API](https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role-mapping). ### Field-level and document-level security **Paid Platinum/Enterprise; unavailable with Basic. Minimum version within this guide: Elasticsearch 8.0; syntax pinned to 8.19.** FLS/DLS predate 8.0; their first historical release is not established here. At the time of writing, self-managed Platinum is available to existing customers only. Basic security became free in 6.8.0 and 7.1.0, explicitly excluding FLS/DLS. The default Elasticsearch distribution remains under ELv2; the additional AGPL source option does not make subscription features free. See the [dated subscription matrix](https://www.elastic.co/pdf/subscriptions-2025-07-29.pdf), [current subscription terms](https://www.elastic.co/subscriptions), [free-security announcement](https://www.elastic.co/blog/security-for-elasticsearch-is-now-free), and [licensing explanation](https://www.elastic.co/pricing/faq/licensing). The existing `app_reader` role permits reading every document and field in `app-data`. Where tenants or sensitive fields share an index, restrict both documents and fields. Map `tenant_id` as `keyword`, then create a separate read-only role: ```http PUT /_security/role/app_tenant_a_reader { "cluster": [], "indices": [ { "names": ["app-data"], "privileges": ["read"], "field_security": { "grant": ["tenant_id", "title", "category"] }, "query": { "term": { "tenant_id": "tenant-a" } } } ] } ``` `field_security.grant` selects readable fields; `query` selects readable documents. Use concrete field names, not field aliases. Some metadata fields remain accessible. Omitting field permissions permits all fields; omitting `query` removes DLS for that index permission entry. See the [8.19 role API](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/security-api-put-role.html), [FLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-level-security.html), and [DLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/document-level-security.html). Assign this role to the application identity **instead of also retaining unrestricted `app_reader` access**. Elasticsearch combines grants across roles: unrestricted access to the same index can defeat these restrictions. Keep the account read-only. Review application API keys separately, including permissions captured when they were created; do not assume changing a user role repairs previously issued credentials. See [8.19 access-control and role-combination rules](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-and-document-access-control.html). DLS does not remove every inference channel. Elasticsearch documents global scoring statistics and search requests that can reveal aggregate information about inaccessible documents. DLS role queries cannot use `has_child`, `has_parent`, terms lookup, indexed-shape lookup, or `percolate`; date ranges cannot use `now`. DLS also affects suggesters, profiling, and other APIs. Test the application's actual queries and aggregations. Prefer separate indices when stronger tenant or confidentiality isolation is required. See [8.19 security limitations](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/security-limitations.html). ### Anonymous access off and narrow CORS **Free Basic.** Remove `xpack.security.authc.anonymous.roles` from `elasticsearch.yml`, including any equivalent nested configuration. With no anonymous roles specified, anonymous access is disabled and requests without credentials receive `401`. Apply the static configuration change by restarting affected nodes. See [anonymous access](https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/anonymous-access). `http.cors.enabled` defaults to `false`; leave it disabled unless a browser application needs direct cross-origin access. If needed, configure one exact origin, including scheme and any non-default port. For a read-only browser client: ```yaml http.cors.enabled: true http.cors.allow-origin: "https://app.example.com" http.cors.allow-methods: "GET,OPTIONS" http.cors.allow-headers: "Authorization,Content-Type" ``` These are static settings in `elasticsearch.yml`. Restart affected nodes. Do not use `*` or a broad origin regex. CORS controls browser access; authentication and index permissions still apply. See [HTTP CORS settings](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/networking-settings). ### Audit logging **Paid Platinum/Enterprise; unavailable with Basic.** Audit logging is disabled by default. On Elasticsearch 8.x, put this in `elasticsearch.yml` on every node and perform a rolling restart: ```yaml xpack.security.audit.enabled: true xpack.security.audit.logfile.events.emit_request_body: false ``` Events go to `CLUSTERNAME_audit.json` in each node's log directory. The dynamic-enable support documented for **9.5 and later does not apply to 8.x**. See [enabling audit logs](https://www.elastic.co/docs/deploy-manage/monitor/stack-audit-logging/enabling-audit-logs). Keep request-body emission disabled, its default. Bodies can contain sensitive application data. The default included events contain `authentication_failed`; ensure custom event exclusions or ignore policies do not suppress the failures you need to detect. See [auditing settings](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/auding-settings). Collect audit files into a separately protected logging destination. Restrict access and retention administration separately from application credentials. ## OpenSearch The controls below are provided by **free Apache-2.0 OpenSearch Security**, with no paid security tier. Native scoped API keys have an additional version requirement: **OpenSearch 3.7 or later**. See the [Security plugin license](https://github.com/opensearch-project/security/blob/4ba3cc0e2ebec61b726b227fdf51e55f4a502182/LICENSE.txt). - The security plugin provides authentication and TLS; never run with it disabled, including in Docker examples. - Starting with 2.12, new installations using the demo configuration require an initial admin password through `OPENSEARCH_INITIAL_ADMIN_PASSWORD`. Make it long and random. See [demo configuration](https://docs.opensearch.org/latest/security/configuration/demo-configuration/). - The demo configuration also seeds `internal_users.yml` with seven built-in accounts (`admin`, `anomalyadmin`, `kibanaserver`, `kibanaro`, `logstash`, `readall`, `snapshotrestore`), meant for evaluation only. `OPENSEARCH_INITIAL_ADMIN_PASSWORD` sets the `admin` password and nothing else, so the other six keep the credentials shipped in the demo file. Before any real deployment, replace those users with your own or remove the demo accounts; on a cluster that has already initialized, editing the file is not enough, apply the change with `securityadmin.sh`, since the live configuration is the `.opendistro_security` index, not the file on disk. Then confirm each demo login is refused. - The demo configuration installs demo TLS certificates for evaluation; replace them with your own before any real deployment. Keep REST TLS enabled with `plugins.security.ssl.http.enabled: true` and your deployed certificates. The underlying REST TLS setting defaults to `false`; the presence of the Security plugin alone is not proof that HTTPS is enabled. See [TLS configuration](https://docs.opensearch.org/latest/security/configuration/tls/). ### Application RBAC and scoped credentials **Free Apache-2.0.** The `all_access` role grants broad cluster, index, and tenant access. Give applications a custom role instead. Use an administrator authorized for the Security REST API; ordinary RBAC permissions alone do not grant access to those administration endpoints. See [users and roles](https://docs.opensearch.org/latest/security/access-control/users-roles/) and [API permissions](https://docs.opensearch.org/latest/security/access-control/api/). 1. Create an index-scoped role through the [role API](https://docs.opensearch.org/latest/security/api/roles/create-role/): ```bash curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/production-ca.pem --header @/path/admin.header \ --header 'Content-Type: application/json' --request PUT \ https://search.example.com:9200/_plugins/_security/api/roles/app_reader \ --data-binary '{ "cluster_permissions": [], "index_permissions": [{"index_patterns": ["app-data"], "allowed_actions": ["read"]}], "tenant_permissions": [] }' ``` 2. Prepare `/path/os-app-user.json` as a protected request-body file. Replace its password placeholder before submitting: ```json {"password":"REPLACE_WITH_RANDOM_PASSWORD","backend_roles":[]} ``` Create the internal user and map it to the role. These endpoints create or replace resources; use new application-specific names and review existing mappings before replacing them. See the [user API](https://docs.opensearch.org/latest/security/api/users/create-user/) and [role-mapping API](https://docs.opensearch.org/latest/security/api/role-mappings/create-role-mapping/). ```bash curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/production-ca.pem --header @/path/admin.header \ --header 'Content-Type: application/json' --request PUT \ --data-binary @/path/os-app-user.json \ https://search.example.com:9200/_plugins/_security/api/internalusers/app_reader curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/production-ca.pem --header @/path/admin.header \ --header 'Content-Type: application/json' --request PUT \ --data-binary @- https://search.example.com:9200/_plugins/_security/api/rolesmapping/app_reader <<'JSON' {"users":["app_reader"],"backend_roles":[],"hosts":[]} JSON ``` Review other mappings too: this mapping does not remove grants received through another role. 3. **For native scoped API keys, require 3.7+.** Earlier versions can use the dedicated internal user's credentials over TLS. On 3.7+, merge the following into `config/opensearch-security/config.yml`, preserving existing authentication domains and other settings. These example limits cap key lifetime at one day and outstanding tokens at 100: ```yaml config: dynamic: api_tokens: enabled: true max_duration_seconds: 86400 max_tokens: 100 ``` Apply configuration-file changes with `securityadmin.sh`. Run from the OpenSearch installation directory, substitute the actual cluster name, and use the deployed CA and a separate admin certificate whose DN is configured in `plugins.security.authcz.admin_dn`. Keep hostname verification enabled. ```bash ./plugins/opensearch-security/tools/securityadmin.sh \ -h search.example.com -p 9200 -cn search-cluster \ -cacert /path/production-ca.pem \ -cert /path/security-admin.pem -key /path/security-admin-key.pem \ -f config/opensearch-security/config.yml -t config ``` The key path names a protected PKCS#8 file; no key contents or passphrase belong in argv. Back up the live security configuration before applying files: uploading a configuration type replaces that type, and stale files can overwrite REST changes. Use `-f` and `-t` to limit the upload. See [applying configuration changes](https://docs.opensearch.org/latest/security/configuration/security-admin/). Only security administrators create these keys. Create one with explicit permissions: ```bash (umask 077 curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/production-ca.pem --header @/path/admin.header \ --header 'Content-Type: application/json' --request POST \ --output /path/os-app-key-response.json \ https://search.example.com:9200/_plugins/_security/api/apitokens \ --data-binary '{ "name": "app-reader", "cluster_permissions": [], "index_permissions": [{"index_pattern": ["app-data"], "allowed_actions": ["read"]}], "duration_seconds": 86400 }' ) ``` The token API uses **`index_pattern`**, singular, while the role API uses **`index_patterns`**. Lifetime is **`duration_seconds`**, not Elasticsearch's `expiration`. If omitted, lifetime uses `max_duration_seconds`, whose documented default is 7,776,000 seconds; the configuration above explicitly reduces it. Store the returned `token` once, in `/path/app-key.header` after `Authorization: ApiKey `. It cannot be retrieved again. See [API-key configuration](https://docs.opensearch.org/latest/security/access-control/api-keys/) and [Create API Key](https://docs.opensearch.org/latest/security/api/api-keys/create/). ### Field-level and document-level security **Free Apache-2.0 OpenSearch Security; no paid feature tier. The FLS/DLS role schema is documented from OpenSearch 3.0 and applies across this guide's 3.x scope; the citations below are pinned to 3.6 and the behaviour is reviewed current to 3.8.0.** The role API was introduced in 1.0, and DLS appears in the 1.0 archive. These establish earlier availability, not the introduction release of every combined behavior below. See the [3.6 Security API](https://docs.opensearch.org/3.6/security/access-control/api/), [1.0 DLS archive](https://docs.opensearch.org/1.0/security-plugin/access-control/document-level-security/), and [3.8.0.0 Security licence](https://raw.githubusercontent.com/opensearch-project/security/3.8.0.0/LICENSE.txt). The existing `app_reader` role permits reading every document and field in `app-data`. Map `tenant_id` as `keyword`, then create a separate read-only role using OpenSearch's schema: ```http PUT /_plugins/_security/api/roles/app_tenant_a_reader { "cluster_permissions": [], "index_permissions": [ { "index_patterns": ["app-data"], "allowed_actions": ["read"], "dls": "{\"term\":{\"tenant_id\":\"tenant-a\"}}", "fls": ["tenant_id", "title", "category"] } ], "tenant_permissions": [] } ``` `dls` is a JSON-encoded string. Keep `tenant_id` visible in `fls`: fields used by DLS must remain readable. Map the dedicated application identity to this role and review every other effective role and mapping. Do not mix FLS inclusion and exclusion rules across roles without checking their documented interaction. See [3.6 FLS](https://docs.opensearch.org/3.6/security/access-control/field-level-security/) and [3.6 DLS](https://docs.opensearch.org/3.6/security/access-control/document-level-security/). OpenSearch's role-combination behavior differs from Elasticsearch's. In particular, `plugins.security.dfm_empty_overrides_all` affects whether a role without DLS overrides a restricted role. Inspect the effective setting and test the actual identity. DLS does not constrain writes, so grant no write permissions to this reader. Use the dedicated internal-user credential for this recipe. The earlier API-token example contains its own index permissions; assigning this role to a user does not establish that the token enforces equivalent restrictions. Any application token must independently pass the restricted-access checks below before use. OpenSearch's adaptive DLS uses filter-level evaluation for terms-lookup queries. Filter-level DLS permits retrieval through `get`, `search`, `mget`, and `msearch`, and limits cross-cluster search. Review the application's exact queries and aggregations against these OpenSearch-specific restrictions. Do not substitute Elasticsearch's limitations or claim that FLS/DLS eliminates every inference channel. Prefer separate indices when stronger isolation is required. See [3.6 DLS modes, role composition, and limitations](https://docs.opensearch.org/3.6/security/access-control/document-level-security/). ### Anonymous access off and narrow CORS **Free Apache-2.0.** In the security `config.yml`, set `config.dynamic.http.anonymous_auth_enabled: false`. Preserve a working authentication domain. For example, merge this into an existing internal-user configuration, keeping its `_meta` section and other required settings: ```yaml config: dynamic: http: anonymous_auth_enabled: false authc: basic_internal_auth_domain: http_enabled: true transport_enabled: true order: 0 http_authenticator: type: basic challenge: true authentication_backend: type: intern ``` Preserve any other authentication domains in use. Reapply the complete updated `config.yml` with the `securityadmin.sh` command above; editing the local file alone does not change the live configuration. See [security backend configuration](https://docs.opensearch.org/latest/security/configuration/configuration/) and [HTTP basic authentication](https://docs.opensearch.org/latest/security/authentication-backends/basic-authc/). `http.cors.enabled` defaults to `false`. Leave it disabled unless necessary. If a browser client needs it, put the following in `opensearch.yml` and restart affected nodes: ```yaml http.cors.enabled: true http.cors.allow-origin: "https://app.example.com" http.cors.allow-methods: "GET,OPTIONS" http.cors.allow-headers: "Authorization,Content-Type" ``` Use the actual exact origin, never `*` or a broad regex. Keep authentication enforced. See [HTTP CORS settings](https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/network-settings/). ### Audit logging **Free Apache-2.0.** Audit logging needs both a storage destination and an enabled audit configuration. Set this in `opensearch.yml` on each node, then restart affected nodes: ```yaml plugins.security.audit.type: internal_opensearch ``` This stores events in the current cluster, by default in daily `auditlog-YYYY.MM.dd` indexes. The shipped audit configuration has `config.enabled: true`, but without a storage type it does not record events. See [audit logging](https://docs.opensearch.org/latest/security/audit-logs/index/). Read the live configuration, then enable REST auditing and disable request-body logging. Use the production admin certificate for the audit PATCH operation: ```bash curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/production-ca.pem \ --cert /path/security-admin.pem --key /path/security-admin-key.pem \ https://search.example.com:9200/_plugins/_security/api/audit curl -q -g -sS --fail-with-body --noproxy '*' \ --cacert /path/production-ca.pem \ --cert /path/security-admin.pem --key /path/security-admin-key.pem \ --header 'Content-Type: application/json' --request PATCH \ --data-binary @- https://search.example.com:9200/_plugins/_security/api/audit <<'JSON' [ {"op":"add","path":"/config/enabled","value":true}, {"op":"add","path":"/config/audit/enable_rest","value":true}, {"op":"add","path":"/config/audit/log_request_body","value":false} ] JSON ``` Keep `config.audit.exclude_sensitive_headers` set to `true`, its documented default. If the live value is false, merge this into the full `config/opensearch-security/audit.yml`: ```yaml config: audit: exclude_sensitive_headers: true ``` Apply that complete file with the same `securityadmin.sh` certificate arguments, replacing the file/type arguments with `-f config/opensearch-security/audit.yml -t audit`. Preserve the enabled and request-body settings. The GET response's `_readonly` list identifies fields that REST updates cannot change; do not treat a rejected PATCH as success. Repeat GET and confirm `enabled: true`, `enable_rest: true`, `log_request_body: false`, and `exclude_sensitive_headers: true`. See [GET audit configuration](https://docs.opensearch.org/latest/security/api/audit/get-audit-configuration/), [PATCH audit configuration](https://docs.opensearch.org/latest/security/api/audit/patch-audit-configuration/), and [audit configuration fields](https://docs.opensearch.org/latest/security/api/audit/update-audit-configuration/). Ensure audit exclusions do not suppress `FAILED_LOGIN` or the identities and requests being tested. Collect audit events into a separately protected logging destination; application credentials must not be able to alter the evidence. ## Storage encryption **Both products: operating-system or storage-provider control, with no paid product feature tier required. Minimum versions within scope: Elasticsearch 8.0 and OpenSearch 3.0.** Elasticsearch guidance is pinned to 8.19; the cited OpenSearch encryption guidance is explicitly the 3.0 archive. TLS and authentication do not encrypt files on an unencrypted node volume. Use host/block-storage encryption, such as Linux dm-crypt, independently of the search security configuration. Elasticsearch documents dm-crypt beneath its data path; OpenSearch assigns encryption at rest to the operating system. Elastic's subscription row is **"Encryption at rest support"**, not a native encryption switch or a licence requirement for using dm-crypt. See [Elasticsearch 8.19 storage guidance](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/tune-for-search-speed.html), [OpenSearch 3.0 encryption guidance](https://docs.opensearch.org/3.0/troubleshoot/#encryption-at-rest), and the [dated subscription matrix](https://www.elastic.co/pdf/subscriptions-2025-07-29.pdf). Provision the encrypted volume before deploying data. Mount it at `/srv/search-data`, then select that directory in the respective `elasticsearch.yml` or `opensearch.yml`: ```yaml path.data: /srv/search-data ``` **`path.data` selects a directory; it does not enable encryption.** For an existing deployment, plan the storage migration and recovery procedure before changing the path. Include logs, swap, and local snapshot storage in the encryption assessment. See [Elasticsearch 8.19 path settings](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/path-settings-overview.html) and [OpenSearch 3.6 configuration settings](https://docs.opensearch.org/3.6/install-and-configure/configuring-opensearch/configuration-system/). Prevent startup from silently using an ordinary directory when the intended mount is unavailable. For a systemd deployment with `/srv/search-data` configured as a dedicated mount, add this service drop-in to the relevant Elasticsearch or OpenSearch unit: ```ini [Unit] RequiresMountsFor=/srv/search-data AssertPathIsMountPoint=/srv/search-data ``` Reload systemd after installing the drop-in. The dependency requires the configured mount; the assertion rejects an ordinary directory. Neither proves that the mounted device is encrypted: verify the device chain separately. Other service managers need equivalent startup enforcement. See [systemd mount dependencies and assertions](https://man7.org/linux/man-pages/man5/systemd.unit.5.html). ## Snapshot repository encryption **Elasticsearch: ordinary snapshot/restore is available with free Basic; the examples target 8.x and are pinned to 8.19. OpenSearch: free Apache-2.0; the S3 encryption syntax below requires 3.1 or later, with the changed default pinned to 3.8.0.** These are ordinary repositories, separate from paid Elasticsearch searchable-snapshot features. Provider storage and KMS charges are separate from product subscription tiers. See the [dated subscription matrix](https://www.elastic.co/pdf/subscriptions-2025-07-29.pdf) and [OpenSearch 3.1 repository API](https://docs.opensearch.org/3.1/api-reference/snapshots/create-repository/). An encrypted node disk does not protect a remote snapshot repository. Server-side encryption also does not prevent a principal with the necessary object and key permissions from downloading decrypted contents. Distinguish three properties: - **Server-side encryption:** the storage provider encrypts stored objects and decrypts authorized reads. - **Customer-managed keys:** the customer controls the encryption key and its permissions; this can still be server-side encryption. - **Client-side encryption:** data is encrypted before upload, requiring a compatible reader and separately managed decryption keys. Do not describe new AWS S3, GCS, or Azure objects as generally plaintext by default. AWS encrypts new S3 uploads automatically, GCS always applies server-side encryption, and Azure Storage encryption is enabled by default. Assess retained objects and required key ownership separately. See [AWS defaults](https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html), [GCS encryption distinctions](https://docs.cloud.google.com/storage/docs/encryption), and [Azure Storage encryption](https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption). ### Elasticsearch S3 **Free Basic; minimum version within this guide: 8.0; syntax pinned to 8.19.** After configuring repository credentials through the documented secure client settings, this requests SSE-S3: ```http PUT /_snapshot/private_s3 { "type": "s3", "settings": { "bucket": "REPLACE_WITH_BUCKET", "server_side_encryption": true } } ``` Elasticsearch 8.19's `server_side_encryption` defaults to `false`; `true` requests server-side AES256 encryption. Neither value enables client-side encryption. `false` does not mean AWS stores plaintext. For customer-managed KMS encryption through the bucket default, omit this AES256 request, configure the intended bucket encryption/key policy, and verify the resulting object metadata. Do not add SSE-S3 blindly to a repository intended to use bucket-default KMS. See [8.19 S3 repository settings](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-s3.html). ### OpenSearch S3 **Free Apache-2.0; minimum version for this syntax: 3.1.** OpenSearch removed `server_side_encryption` in 3.1. Use `server_side_encryption_type`, which accepts `AES256`, `aws:kms`, or `bucket_default`. The default was `bucket_default` in 3.1 and changed to **`AES256` in 3.8**. See the [3.1 repository settings](https://docs.opensearch.org/3.1/api-reference/snapshots/create-repository/) and [3.8.0-tagged implementation](https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/S3Repository.java). For an explicit customer-managed KMS key: ```http PUT /_snapshot/private_s3 { "type": "s3", "settings": { "bucket": "REPLACE_WITH_BUCKET", "server_side_encryption_type": "aws:kms", "server_side_encryption_kms_key_id": "REPLACE_WITH_KMS_KEY_ARN" } } ``` Grant the repository principal the required object-store and KMS permissions. The ARN identifies a key; do not supply key material. This remains server-side encryption. To follow the bucket's encryption policy instead, explicitly select `bucket_default`, especially when upgrading to 3.8. Do not copy these OpenSearch settings into Elasticsearch. See [AWS SSE-KMS permissions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html). **Native S3 store (OpenSearch sandbox; source reading at 3.7.0 and 3.8.0, without an OpenSearch run).** At 3.7.0, the `S3Repository` constructor takes a native store from a native provider when one is available, under the source comment "Initialize native store if provider is available (sandbox warm nodes only)". Of the repository's server-side encryption settings, the sandbox `native-repository-s3` provider passes that store only `server_side_encryption_kms_key_id`, as an SSE-KMS key when set, and `server_side_encryption_bucket_key_enabled`, as a bucket-key flag that it sends as `true` when unset. It reads neither `server_side_encryption_type` nor `server_side_encryption_encryption_context`, so the native store gets no `AES256` or `bucket_default` selection and gets the KMS key whatever the type, while the 3.7.0 Java store's `SseKmsUtil` sends that key only for `aws:kms`. The provider's Rust builder applies the key with `with_sse_kms_encryption` and the flag with `with_bucket_key` only when it is `true`, and it accepts a DSSE-KMS key that the provider never sends. This guide's advice: set `server_side_encryption_kms_key_id` only together with `aws:kms`, so that the native store does not request SSE-KMS where the Java store does not. Whether a deployment uses the native store, what it writes, and the encryption applied to those objects are **not established here**. ### GCS, Azure, and repository integrity **Elasticsearch: free Basic; minimum version within this guide: 8.0; declarations pinned to 8.19.** With the appropriate secure client credentials configured: ```http PUT /_snapshot/private_gcs { "type": "gcs", "settings": { "bucket": "REPLACE_WITH_BUCKET" } } ``` ```http PUT /_snapshot/private_azure { "type": "azure", "settings": { "container": "REPLACE_WITH_CONTAINER" } } ``` The complete Elasticsearch 8.19 setting lists document no client-side-encryption switch for these repositories. Configure required customer-managed encryption through the provider's bucket/account policy and grant the required key access. GCS customer-supplied encryption keys are also a server-side mechanism. Azure account keys and SAS tokens authenticate requests; they do not establish client-side encryption. See [8.19 GCS settings](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-gcs.html), [8.19 Azure settings](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-azure.html), and [GCS encryption distinctions](https://docs.cloud.google.com/storage/docs/encryption). **OpenSearch: free Apache-2.0; the cited repository workflow is version 3.6.** Follow OpenSearch's own plugin and credential instructions, including its Azure workflow. This guide's review of the OpenSearch 3.8.0 GCS and Azure plugin-specific settings, blob upload and client construction found no plugin-specific client-side-encryption setting. OpenSearch core accepts repository `crypto_settings` and wraps the non-server-side-encryption blob store in client-side `EncryptedBlobStore` when repository crypto metadata is supplied; its encrypted container encrypts writes before forwarding them. The cited common provider and GCS/Azure `createBlobStore` methods show a path to both repository types, and Azure points to repository-level encryption settings while rejecting per-object `CryptoMetadata`. This guide's assessment is that end-to-end applicability and live encryption behaviour for either type remain **not established here**. The core path requires `key_provider_name` and `key_provider_type` and a registered key-provider plugin of that type; the cited tree includes an `aws-kms` provider. Available providers in a deployment, a universal minimum version for GCS/Azure client-side encryption, and behaviour in unchecked releases remain **not established here**. This guide's assessment is that **the checked range for the GCS/Azure client-side-encryption reading is 2.10.0 to 3.9.0, and the wrap moves into `BlobStoreProvider` at 3.4.0**. The range starts at 2.10.0, based on the pinned 2.10.0 and 3.0.0 source methods, the 2.10.0 release notes listing the core crypto library, encryption-sdk and crypto-kms plugin, and the pinned 2.9.0 directory listings without the corresponding crypto packages, EncryptedBlobStore or crypto-kms entry. As a source reading without an OpenSearch run, git tree listings at each of the 41 release tags from 1.0.0 to 2.9.0 contain no `EncryptedBlobStore`, `CryptoSettings` or `crypto-kms` path, and at 2.9.0 `BlobStoreRepository.blobStore()` returns the `createBlobStore` result unwrapped, `PutRepositoryRequest` parses only `type` and `settings`, and `RepositoryMetadata` holds no crypto field. This guide then read `BlobStoreRepository`, `MeteredBlobStoreRepository`, `EncryptedBlobStore`, `GoogleCloudStorageRepository` and `AzureRepository` at each of the 31 release tags from 2.10.0 to 3.9.0; other files, including `CryptoSettings`, were read only at the tags their Sources entries name, and a git blob ID comparison extends a reading only where it says so. `CryptoSettings.validate()` is cited at 2.10.0, 3.0.0 and 3.8.0, and `CryptoSettings.java` has the cited 2.10.0 file's git blob ID at 2.11.0 and 2.11.1 and the cited 3.0.0 and 3.8.0 files' at every tag from 2.12.0 to 3.9.0, so `validate()` requires `key_provider_name` and `key_provider_type` at all 31 checked tags. At all 25 tags from 2.10.0 to 3.3.2, `BlobStoreRepository.blobStore()` has the same body: it calls `createBlobStore` and wraps the result in `EncryptedBlobStore` when `metadata.cryptoMetadata()` is non-null. From 3.4.0 to 3.9.0, `blobStore()` returns `blobStore(false)`, which creates a `BlobStoreProvider` and returns that provider's `blobStore(serverSideEncryptionEnabled)`, and `BlobStoreRepository` no longer constructs `EncryptedBlobStore` itself. The provider's `createBlobStore` wraps the repository's `createBlobStore` result in `EncryptedBlobStore` when server-side encryption is off, as `blobStore(false)` requests, and `metadata.cryptoMetadata()` is non-null. This guide read `BlobStoreProvider` at 3.4.0, 3.8.0 and 3.9.0, and the 3.5.0, 3.6.0 and 3.7.0 files have 3.4.0's git blob ID, so the wrap holds in source at every tag from 3.4.0 to 3.9.0. No OpenSearch release tag after 3.9.0 existed when the tag list was checked on 2026-10-22, or when it was checked again on 2026-10-24. Behaviour in later releases, repository code at release tags before 2.10.0 beyond the absent `EncryptedBlobStore`, `CryptoSettings` and `crypto-kms` paths (that code was read only at 2.9.0), and a universal minimum for deployed GCS/Azure encryption remain **not established here**. At both **2.10.0 and 3.0.0**, GCS and Azure extend `MeteredBlobStoreRepository` and override `createBlobStore` to return their respective stores; `BlobStoreRepository.blobStore()` calls `createBlobStore` and wraps the result in `EncryptedBlobStore` when `metadata.cryptoMetadata()` is non-null. At both versions the pinned `MeteredBlobStoreRepository` is an abstract class extending `BlobStoreRepository` that declares neither `blobStore()` nor `createBlobStore`; it has no `@Override` method at 2.10.0, and at 3.0.0 overrides only `reload`, which calls `super.reload`. At both versions the pinned `GoogleCloudStorageRepository` and `AzureRepository` class bodies declare no `blobStore()` method; `AzureRepository` overrides `getBlobStore()` only to return `super.getBlobStore()`. This guide infers from those files that the inherited `blobStore()` dispatch reaches the GCS and Azure `createBlobStore` overrides, so both repository types reach the wrap in source at those versions; deployed client-side encryption and live encryption behaviour remain **not established here**. The pinned `CryptoSettings.validate()` methods at both versions require `key_provider_name` and `key_provider_type`; source settings and a wrapper do not establish installed providers or live encryption behaviour. Across all 31 checked tags from 2.10.0 to 3.9.0, `MeteredBlobStoreRepository`, `GoogleCloudStorageRepository` and `AzureRepository` declare no `blobStore()` method, and `MeteredBlobStoreRepository` declares no `createBlobStore` and from 2.11.0 has `reload`, which calls `super.reload`, as its only `@Override` method. At all 31 checked tags, `AzureRepository` overrides `getBlobStore()` only to return `super.getBlobStore()`. From 3.7.0 to 3.9.0, both plugin classes also override `getNativeStore()` to return a native object store, which their source comments describe as a native (Rust) store created during construction if a native provider is available. Whether a deployment uses that path is **not established here**; its relation to the `EncryptedBlobStore` wrap is read below at 3.7.0 and 3.9.0 only. As a source reading without a run, the 3.7.0 and 3.9.0 `BlobStoreRepository` files contain no native-store reference. At 3.9.0, a text search for `getNativeStore` of a sparse checkout of the tag that holds only `sandbox/plugins/`, `plugins/`, `modules/`, `server/src/main/` and `libs/common/src/main/` finds `IndexService` as its only non-test caller in those paths. That search does not exclude callers outside those paths, in plugins distributed outside the OpenSearch repository or by reflection. In that 3.9.0 source, the `IndexService` call resolves the native store of a warm index's remote store repository when the writable-warm feature flag is enabled, the index is warm, its pluggable data format is enabled and a format-aware store directory factory is present. As a source reading without a run, the 3.7.0 and 3.8.0 `IndexService` files make the same call under the same four conditions, and the 3.8.0 file has 3.9.0's git blob ID. A search of each of those two tags for `getNativeStore` also finds `IndexService` as its only non-test caller, and every other non-test match declares the method: the `Repository` default, which returns an empty native store, and the overrides in `FsRepository`, `S3Repository`, `GoogleCloudStorageRepository` and `AzureRepository`. That search was a case-sensitive, ripgrep-based match of `getNativeStore` over the extracted 3.7.0 and 3.8.0 source archives, treating paths under `src/test/` and `src/internalClusterTest/` as test code. Being lexical, it cannot see a call by reflection, through another name or outside those archives, and ripgrep's defaults may skip hidden or binary files. At 3.7.0, the GCS and Azure constructors set the native store from `nativeStoreProvider.createNativeStore(metadata, clusterService.getSettings())`, which passes the repository's `RepositoryMetadata` to the provider, under the source comment "Initialize native store if provider is available (sandbox warm nodes only)". The git tree listings at 3.7.0, 3.8.0 and 3.9.0 show `META-INF/services` registrations for `NativeRemoteObjectStoreProvider` only in the `sandbox/plugins` directories `native-repository-azure`, `native-repository-fs`, `native-repository-gcs` and `native-repository-s3`. As a source reading of the 3.7.0 `native-repository-gcs` and `native-repository-azure` sources without a run, their providers build the native store's configuration from the repository's bucket (GCS), or from the client's account, the repository's container and the client's retry count (Azure), and nothing else. In this guide's 3.7.0 reading, the Rust builders they call set only those values, retry settings and credentials; that reading does not establish whether the 3.7.0 builders also set an HTTP connector. The 3.8.0 providers build the same configuration. Neither directory contains the text `crypt` or `kms` at 3.7.0 or 3.8.0. This guide infers from those files and the `BlobStoreProvider` wrap described above, which applies to the `createBlobStore` result, that at 3.7.0 the GCS and Azure native stores are built outside that wrap and apply no repository `crypto_settings`. The `native-repository-gcs` and `native-repository-azure` plugins sit under `sandbox/plugins` in the OpenSearch source tree. As a source reading of the 3.9.0 tag without a run, the GCS provider passes only the repository's `bucket` to its native (Rust) store builder, and the Azure provider only the node setting `azure.client..account`, the repository's `container` and, when it is 0 or more, the client's `max_retries`. `AzureStorageSettings` declares `azure.client..account` as a secure setting, and whether a keystore value for it reaches the native configuration needs a run. At 3.9.0 both providers pass `0L` as the credential-provider argument, so they pass no credentials, and their configurations carry no crypto setting; neither directory contains the text `crypt` or `kms`. The providers' source comments say credentials are not passed, and that the Rust side uses Application Default Credentials (GCS, line 37) or the Azure default credential chain (Azure, lines 39 to 40). This guide records those as source comments, not established behaviour. In the 3.9.0 GCS and Azure repository constructors, the native store again comes from `nativeStoreProvider.createNativeStore(metadata, clusterService.getSettings())`, and `getNativeStore()` returns it. At 3.9.0, `BlobStoreProvider` wraps only the repository's `createBlobStore()` result in `EncryptedBlobStore`, and from that and those constructors this guide infers that the native store is built outside that wrap. `IndexService` remains the only non-test `getNativeStore()` caller that the 3.9.0 text search above finds in the sparse checkout's included paths. This guide infers from those 3.9.0 files that, as at 3.7.0, the GCS and Azure native stores apply no repository `crypto_settings`. What the warm format-aware store handlers write through the native store, provider-side encryption of those objects and deployed use are **not established here**. From 3.5.0 to 3.9.0, and at no earlier checked tag, `BlobStoreRepository` also resolves an index-level `CryptoMetadata` from the settings of the `IndexMetadata` passed to `snapshotShard`, with `CryptoMetadata.fromIndexSettings` (null when that `IndexMetadata` is null). It passes that value through `executeOneFileSnapshot` to `snapshotFile`, which passes it to `writeBlobWithMetadata` for each shard file part, under the source comment "index-level SSE-KMS override". This is a source reading only, without an OpenSearch run. In that source the value comes from index settings, while `blobStore()` hands the repository's own `RepositoryMetadata` to `BlobStoreProvider`, and the shard container that receives the index-level value is obtained from `blobStore()`. This guide has since traced the value through the encrypted container and the GCS/Azure containers, as a source reading without an OpenSearch run. `EncryptedBlobContainer`'s `writeBlobWithMetadata` form that takes `CryptoMetadata`, read at 3.5.0 and 3.8.0 (the 3.6.0, 3.7.0 and 3.9.0 files have 3.8.0's git blob ID), encrypts with the container's repository-level crypto handler and passes the index-level value unchanged to the wrapped container, under the source comment "SSE-KMS settings are passed through to underlying container". At 3.5.0 and 3.9.0, `AzureBlobStore` returns an `AzureBlobContainer` as its blob container, and `AzureBlobContainer`, which has one git blob ID at every tag from 3.5.0 to 3.9.0, throws `UnsupportedOperationException` when that value is non-null, so in source an index-level value that reaches the Azure container, directly or through `EncryptedBlobContainer`, ends that write with the exception. At 3.5.0 and 3.9.0, `GoogleCloudStorageBlobContainer` overrides only the `writeBlobWithMetadata` form without `CryptoMetadata`, which calls `writeBlob`, and `BlobContainer`'s default for the `CryptoMetadata` form calls that form, so the GCS container drops the value. `BlobContainer.java` has one git blob ID at 3.6.0, 3.7.0 and 3.8.0 that differs from both the 3.5.0 and 3.9.0 files, and as a source reading of the 3.7.0 and 3.8.0 files without a run, its default for the `CryptoMetadata` form calls the form without it there too. `GoogleCloudStorageBlobContainer`, `GoogleCloudStorageBlobStore` and `AbstractBlobContainer` each have one git blob ID at every tag from 3.5.0 to 3.9.0, so in source the GCS container drops the value at every tag from 3.5.0 to 3.9.0. At 3.5.0 and 3.9.0, `CryptoMetadata.fromIndexSettings` returns null unless the index sets `index.store.crypto.key_provider`, with `key_provider_type` defaulting to `aws-kms`. The value's effective behaviour on GCS or Azure objects is **not established here**. **Index-level key providers and GCS/Azure snapshots (OpenSearch; source reading at 3.5.0 and 3.9.0, extended to 3.6.0 and 3.8.0 by git blob ID, without an OpenSearch run).** This guide's advice: do not rely on an index's `index.store.crypto.key_provider` setting to encrypt that index's snapshot shard files in a GCS or Azure repository. At 3.5.0 and 3.9.0, `BlobStoreRepository` resolves that setting into an index-level `CryptoMetadata` and passes it with each shard-file part to the shard container's `writeBlobWithMetadata`, and `snapshotFile` rethrows an exception from that write. On Azure, `AzureBlobStore` returns an `AzureBlobContainer`, which throws `UnsupportedOperationException` for a non-null value. With repository `crypto_settings`, `EncryptedBlobStore` wraps that container in `EncryptedBlobContainer`, which encrypts with the repository-level handler and passes the value on unchanged, so on Azure, in source those shard-file writes fail with or without repository `crypto_settings`. On GCS, the container's `writeBlobWithMetadata` form that takes the value is `BlobContainer`'s default, which calls the form without it, and the GCS override of that form calls `writeBlob`, so in source GCS drops the index-level value. On this GCS path, the client-side encryption wrapper is applied only where repository `crypto_settings` are configured, and the effect on stored objects is **not established here**. For encryption you control on either repository type, use repository `crypto_settings` or the provider-side customer-managed encryption this section describes, and if an index carries the setting, confirm that a first test snapshot of that index to that repository, taken after the index holds documents, finishes with no failed shards before relying on that repository for it. A snapshot that finishes that way does not show that the index-level provider was applied on GCS, or that encryption works on either repository type. As a source reading at both tags, `BlobStoreRepository` skips the shard-file write for a file whose stored hash equals its contents, even in a first snapshot of that index to that repository. This guide's assessment, resting on a premise about Lucene index files that this guide did not check, is that a shard whose index holds no documents may have only such files, so a test snapshot taken then could make no shard-file write and finish with no failed shards on Azure even where later snapshots of that index fail. In that earlier reading, at both tags, the directories this guide read reference `index.store.crypto.key_provider` only in `CryptoMetadata.fromIndexSettings`, with no registration of it found there, and other files were not read. At 3.9.0, a text search of the main sources of OpenSearch's `server`, `modules` and `plugins` directories and its sandbox plugins finds `index.store.crypto` only in `CryptoMetadata.fromIndexSettings`, which reads `key_provider`, `key_provider_type` and the whole `index.store.crypto` group, and finds no registration of any `index.store.crypto` key. Plugins distributed outside the OpenSearch repository were not read, and whether OpenSearch accepts the setting on an index needs a run. At 3.9.0, `CryptoKmsPlugin`, the `aws-kms` key-provider type, lists 12 settings in `getSettings()`, none of them an `index.*` key. Its secure settings are `kms.access_key`, `kms.secret_key`, `kms.session_token`, `kms.proxy.username` and `kms.proxy.password`, and its node-scope settings are `kms.endpoint`, `kms.region`, `kms.proxy.host`, `kms.proxy.port`, `kms.read_timeout`, `kms.encryption_context` and `kms.key_arn`. By git blob ID, `BlobStoreRepository.java` at 3.6.0 matches the 3.5.0 file and at 3.8.0 the 3.9.0 file, and `CryptoMetadata.java`, `AzureBlobStore.java` and `EncryptedBlobStore.java` each have one ID from 3.5.0 to 3.9.0, so with the container readings above, this warning's source reading also holds at 3.6.0 and 3.8.0. Whether an index can carry `index.store.crypto.key_provider` at all, what a snapshot reports when a shard-file write fails, the `snapshotFile` rethrow and hash-equality skip at 3.7.0, whose `BlobStoreRepository.java` differs from the 3.6.0 and 3.8.0 files, and the effective result on stored objects remain **not established here**. Do not infer broader absence from Elasticsearch's settings or from failed source retrieval. This guide's advice: Configure required customer-managed encryption through the provider's bucket/account policy and grant the required key access. See [OpenSearch 3.6 snapshot/repository guidance](https://docs.opensearch.org/3.6/tuning-your-cluster/availability-and-recovery/snapshots/snapshot-restore/) and the pinned GCS and Azure implementation Sources below. Keep repository access private and keys recoverable for the required retention period. Do not rewrite files inside an active repository as an improvised encryption layer. Elasticsearch configuration-file backups are separate from snapshots and should be assessed for encryption; snapshots containing security feature state are sensitive too. See [8.19 backup guidance](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-take-snapshot.html) and [repository-integrity requirements](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshot-restore.html). ## Kibana and OpenSearch Dashboards The UI is a separate service, normally on port **5601**. Securing cluster ports 9200/9300 does not configure browser-to-UI TLS. ### Kibana **Free Basic; minimum version within this guide: Kibana 8.0; settings pinned to 8.19.** No Platinum/Enterprise subscription is required for browser-listener TLS. The cited versions establish support, not the first historical introduction of each setting. Kibana defaults to HTTP with `server.host: localhost` and `server.port: 5601`, with `server.ssl.enabled: false`. The **9.5.4** HTTP schema defines the host and port defaults; the **8.19.22** sample configuration documents the same values. The Docker configuration templates at **both versions** explicitly set `server.host: "0.0.0.0"`. Check installation overrides, environment, process arguments, and container port publication. See the pinned Kibana Sources below, [8.19 Kibana TLS settings](https://www.elastic.co/guide/en/kibana/8.19/settings.html), and [Docker configuration overrides](https://www.elastic.co/guide/en/kibana/8.19/docker.html). Merge into `kibana.yml`, preserving the existing server identity and authentication configuration: ```yaml server.host: "127.0.0.1" server.port: 5601 server.ssl.enabled: true server.ssl.certificate: /etc/search-ui/tls/server.crt server.ssl.key: /etc/search-ui/tls/server.key elasticsearch.hosts: ["https://search.example.com:9200"] elasticsearch.ssl.certificateAuthorities: ["/path/http_ca.crt"] elasticsearch.ssl.verificationMode: full ``` The `server.ssl.*` settings protect incoming browser or proxy connections. The `elasticsearch.ssl.*` settings protect the separate Kibana-to-Elasticsearch connection. Keep full certificate and hostname verification on that connection. See [8.19 inbound and outbound TLS settings](https://www.elastic.co/guide/en/kibana/8.19/settings.html). ### OpenSearch Dashboards **Free Apache-2.0; the TLS recipe is documented from at least OpenSearch Dashboards 2.19 and applies across this guide's 3.x scope; defaults are checked against 3.8.0.** The 3.8.0 sample configuration identifies localhost, port 5601, and disabled server TLS. Check installation and container overrides separately. See the [3.8.0-tagged configuration](https://raw.githubusercontent.com/opensearch-project/OpenSearch-Dashboards/3.8.0/config/opensearch_dashboards.yml). Merge into `opensearch_dashboards.yml`, preserving the existing server identity and authentication configuration: ```yaml server.host: "127.0.0.1" server.port: 5601 server.ssl.enabled: true server.ssl.certificate: /etc/search-ui/tls/server.crt server.ssl.key: /etc/search-ui/tls/server.key opensearch_security.cookie.secure: true opensearch.hosts: ["https://search.example.com:9200"] opensearch.ssl.certificateAuthorities: ["/path/production-ca.pem"] opensearch.ssl.verificationMode: full ``` `server.ssl.*` protects the UI listener; `opensearch_security.cookie.secure` restricts the security cookie to HTTPS. `opensearch.ssl.*` controls the separate connection to OpenSearch. Retain full certificate and hostname verification. See [Dashboards 3.6 TLS settings](https://docs.opensearch.org/3.6/install-and-configure/install-dashboards/tls/). For either UI, loopback binding suits a proxy in the same network namespace. For direct private-network access, select the intended private interface and restrict network access. A proxy in another container does not share the UI container's loopback. Use certificates matching the hostname used by clients or the proxy, protect private-key files, and restart the UI after applying settings. ## Verify REASONED: verification scope from the recorded authoring limitations; no target cluster, container runtime, node access or audit collector is available. All deployment checks below are **REASONED, not demonstrated**: the authoring environment has no target cluster or Docker/Podman runtime, and no access to the target node or audit collector; the authoring host forbids opening listeners without an isolated network namespace, and has none. Expected outcomes follow the cited vendor behavior; no deployment result is claimed. DEMONSTRATED: local preparation on 2026-10-05 in private /dev/shm scratch with Bash 5.3.9 and Python 3.14.4. All nine shell blocks passed bash -n and all eight inline Verify JSON payloads parsed. On 2026-10-13 (UTC), with the same Bash release, the initial cluster guard rejected the shipped placeholders, the search.example.com and es.example.com hosts, a host containing an invalid character, a missing CA and a nonnumeric port with exit 1; a valid host with a readable CA reached the first curl call. In a further run on 2026-10-13 (UTC), after the cluster guard gained the REST guard's 1 to 65535 port range check, ports 0, 65536, 99999 and a 20-digit value exited 1 before any curl call, while 1, 9200 and 65535 reached the first curl call; before the change every one of those ports reached it. The REST guard rejected a placeholder, missing body file, unsupported method and relative path with exit 1; valid values reached its curl call. In a further run on 2026-10-13 (UTC), after the cluster, REST and UI guards' `example.com` test was made case-insensitive, each of the three refused `example.com`, `EXAMPLE.COM`, `Example.Com` and `es.EXAMPLE.com` with exit 1 before any curl call and passed `Dummy.Valid.test` to its first curl call unchanged. Before the change, the three forms that are not all lowercase reached curl in all three guards. The three guards were later extended to refuse `example.net` and `example.org` in any letter case. In a further run on 2026-10-13 (UTC), with Bash 5.3.9, each block was saved to a file and run with its commands unchanged except fixture values, and curl, python3 and date were stubbed. In that further run, each of the three guards refused `example.net`, `EXAMPLE.NET`, `es.Example.Net`, `example.org`, `EXAMPLE.ORG`, `es.Example.Org`, `example.com`, `EXAMPLE.COM`, `Example.Com`, `es.EXAMPLE.com` and `example.network.test` with exit 1 before any curl call. Each of the three guards passed `Dummy.Valid.test` to curl unchanged in that run, exiting 0 after 11, 1 and 2 curl calls in the cluster, REST and UI blocks. Before the extension, the `.net` and `.org` hosts and `example.network.test` reached curl while the `.com` forms were refused. The test is a substring match, so it also refuses longer names that contain `example.net` or `example.org`, such as `example.network.test`. An explicitly substituted curl argument recorder measured those calls, not HTTP responses. A disposable Basic header decoded to audit_probe:deliberately-wrong and had mode 0600; one parsed JSON payload was saved as a mode-0600 body file. These runs do not establish deployment CA identity, account nonexistence or per-request cluster results. The guard and stub runs recorded here, and the bash -n run of the nine shell blocks, covered an earlier form of the eight Verify blocks among them, in which the guarded blocks' refusals exited with status 1, the cluster checks were one block, now split into the first five blocks below, and the listener inventory was two bare `ss` commands, now a guarded subshell. The current forms, whose refusals exit with status 2 and whose results set the exit status, are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by stub runs of every Verify block on 2026-10-31, not by the runs recorded here. REASONED: introduction to the deployment prerequisites for the cited cluster API checks; local preparation observations are recorded above, while cluster identities and deployed trust remain unavailable. Before running: - REASONED: substitution and paste procedure for the five guarded cluster blocks; re-run at marking time, the unsubstituted guard refused with exit 1 and substituted values reached the first curl call against a local argument recorder; deployed hostname and certificate coverage is not established here; no deployed cluster or its CA certificate is available in the authoring environment. That marking-time re-run covered an earlier form, a single cluster block whose refusals exited with status 1; the five current blocks, whose refusals exit with status 2, are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs on 2026-10-31, not by that re-run. Substitute your actual hostname, HTTP port, CA path and each block's other values inside the single quotes on its `set --` line. Paste each whole block. Use a TLS hostname covered by the deployed certificate. - REASONED: CA selection from the cited product TLS references; no deployed cluster CA or administrator identity is available. Elasticsearch automatic setup supplies `http_ca.crt`. For OpenSearch, use the **deployed production HTTP CA**, such as `/path/production-ca.pem`, and your production administrator's header file. Do not verify production against the demo CA. - REASONED: fixture control from the cited privilege and document APIs; no seeded target cluster is available. Have an administrator confirm that `app-data` and `other-data` each contain a readable document with ID `rbac-probe`. A missing document is not an RBAC-denial result. - REASONED: header-file preparation only; re-run at marking time, a disposable Basic header decoded to audit_probe:deliberately-wrong and had mode 0600; no target cluster is available to confirm that the user does not exist. Prepare a header file, such as `/path/audit-probe.header`, with a Basic authentication header for a confirmed nonexistent user named `audit_probe` and a deliberately wrong password, and set its path on the audit block's `set --` line. Keep all credentials in header files. - REASONED: identity comparisons from the cited role and API-key references; no target users or keys are available. Run the RBAC block once with `/path/app-user.header` on its `set --` line, then again with `/path/app-key.header`. On OpenSearch before 3.7, only the internal-user check applies. REASONED: comparison criteria from the cited authorization, CORS and audit references; no target cluster or runtime is available. The expected distinctions are: | Check | Exposed or misconfigured outcome | Fixed outcome | | --- | --- | --- | | REASONED: RBAC comparison from the cited privilege references; no target cluster or scoped identities are available. Application RBAC | A broad application identity reads both known documents. | The scoped identity reads `app-data` with `200` and receives an authorization `403` for `other-data`. A `401`, `404`, or connection error does not demonstrate correct RBAC. See [Elasticsearch privileges](https://www.elastic.co/docs/reference/elasticsearch/security-privileges) and [OpenSearch permissions](https://docs.opensearch.org/latest/security/access-control/permissions/). | | REASONED: anonymous-access comparison from the cited security settings; no target cluster or effective node configuration is available. Anonymous access | An anonymous read grant returns the permitted document. In Elasticsearch, an enabled anonymous identity lacking permission also returns `401` when `xpack.security.authc.anonymous.authz_exception: false`. | A `401` establishes rejection only for the tested endpoint, not that anonymous access is disabled. After restarting affected nodes, require confirmation that `xpack.security.authc.anonymous.roles` is absent from EVERY node's EFFECTIVE configuration. Removal of the roles setting disables anonymous access; there is no `xpack.security.authc.anonymous.enabled` switch. Also require the previously anonymously readable document control below. For OpenSearch, confirm the live `config.dynamic.http.anonymous_auth_enabled: false` setting. See [Elasticsearch anonymous access](https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/anonymous-access), [Elasticsearch security settings](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/security-settings), and [OpenSearch anonymous authentication](https://docs.opensearch.org/latest/security/access-control/anonymous-authentication/). | | REASONED: origin comparison from the cited CORS settings; no target cluster or browser-origin fixture is available. CORS | An overly broad policy grants the untrusted origin. | No `Access-Control-Allow-Origin` grant for the untrusted origin. If CORS is enabled, the trusted-origin positive control succeeds and grants exactly that origin. If CORS is disabled, neither origin is granted. Judge the headers, not just the status. See [Elasticsearch CORS](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/networking-settings) and [OpenSearch CORS](https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/network-settings/). | | REASONED: event comparison from the cited audit references; no cluster, collector or eligible Elasticsearch audit licence is available. Audit | Failed authentication returns `401`, but disabled or suppressed auditing produces no corresponding audit event. | The same failure produces a new Elasticsearch `authentication_failed` or OpenSearch `FAILED_LOGIN` event. A `401` alone does not prove auditing. Elasticsearch requires the paid audit tier. See [Elasticsearch audit events](https://www.elastic.co/docs/reference/elasticsearch/elasticsearch-audit-events) and [OpenSearch audit fields](https://docs.opensearch.org/latest/security/audit-logs/field-reference/). | REASONED: event attribution from the cited audit-event references; no target cluster or audit collector is available. For the audit check, expect `401` and require a matching NEW REST-layer Elasticsearch `authentication_failed` or OpenSearch `FAILED_LOGIN` event. Inspect the receiving node's new `CLUSTERNAME_audit.json` entries or the corresponding OpenSearch audit index and separately collected copy. Match the recorded time interval, `audit_probe` identity, POST method, and `/app-data/_search` path. Run probes separately so each event can be attributed to its request. REASONED: body-logging comparison from the cited audit settings and fields; no target cluster, collector or eligible Elasticsearch audit licence is available. **Reasoned, not demonstrated:** no target cluster, audit collector, or eligible Elasticsearch audit license is available here. In an isolated test cluster, run the audit probe below with request-body logging enabled, then disabled, generating a fresh marker each time. With Elasticsearch `xpack.security.audit.logfile.events.emit_request_body: true`, require that run's marker in the matching event's `request.body`. With OpenSearch `config.audit.log_request_body: true`, require it in `audit_request_body`. After setting the respective setting to `false`, require another matching new event, with its request-body field absent and that run's marker absent from the event. A bodyless request, a missing event, or absence of a marker without the enabled-state positive control cannot establish request-body exclusion. See [Elasticsearch request-body auditing](https://www.elastic.co/docs/deploy-manage/monitor/stack-audit-logging/auditing-http-request-bodies), [Elasticsearch auditing settings](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/auding-settings), and [OpenSearch audit fields](https://docs.opensearch.org/latest/security/audit-logs/field-reference/). REASONED: configuration and header checks from the cited audit settings; no effective node configuration or collected events are available. Retain configuration readback: confirm Elasticsearch's effective `xpack.security.audit.logfile.events.emit_request_body: false`, accounting for transient and persistent cluster-setting overrides as well as each node's configuration. Repeat the OpenSearch audit configuration GET above and confirm `config.enabled: true`, `config.audit.enable_rest: true`, `config.audit.log_request_body: false`, and `config.audit.exclude_sensitive_headers: true`. Check that sensitive headers, including the Authorization value sent from the header file, are absent from the matching events and their collected copies in both test states. Header exclusion and body exclusion are separate checks. Restore and read back body logging as disabled after the isolated positive-control test. See [OpenSearch body logging](https://docs.opensearch.org/latest/security/audit-logs/index/) and [audit configuration fields](https://docs.opensearch.org/latest/security/api/audit/update-audit-configuration/). REASONED: audit-evidence limits from the cited event and collection references; no target collector or events are available. Missing events leave the audit check unresolved until collection delay, filtering, licensing, and configuration have been checked. A `401` alone proves neither audit delivery nor body exclusion. REASONED: following block; HTTP TLS expectations and comparisons follow the cited Elasticsearch and OpenSearch HTTP TLS references; no target cluster, runtime, node or effective node configuration was available. Local shell checks are recorded above. Set the host, CA file, HTTP port and your administrator's header file on its `set --` line. The administrative HTTPS control must return `200` and cluster JSON before interpreting the plaintext probe. With HTTP TLS enforced, plaintext must receive no HTTP response. Elasticsearch closes a plaintext connection to its HTTPS listener; OpenSearch's enabled REST TLS allows only HTTPS. Expect `http=000` with a reset or empty reply after connection. DNS failures, timeouts, or an unreachable service are inconclusive. Any actual HTTP status, including `401`, means that endpoint answered over plaintext and is a finding. The block exits 3 when the administrative control does not return `200`. Otherwise it exits 1 when the plaintext probe receives any HTTP status, 0 when it ends in an empty reply or a reset after connection, and 3 for any other curl error. The cluster JSON stays for you to judge. If the administrative control fails because HTTP TLS is not enabled, an HTTP status from the plaintext probe is the exposed state, although the block still exits 3 and leaves that status for you to judge. See [Elasticsearch HTTP TLS settings](https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/security-settings) and [OpenSearch REST TLS](https://docs.opensearch.org/latest/security/configuration/tls/). ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SEARCH_HOST' '/path/http_ca.crt' '9200' 'REPLACE_WITH_ADMIN_HEADER_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "set exactly host, CA file, HTTP port, and admin header file; not probing"; exit 2; } case "$1|$2|$3|$4" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your deployment; not probing"; exit 2 ;; esac [ -n "$1" ] || { echo "host required; not probing"; exit 2; } case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "readable CA file required; not probing"; exit 2; } [ -n "$3" ] || { echo "port required; not probing"; exit 2; } case "$3" in *[!0123456789]*) echo "HTTP port must be numeric"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "HTTP port must be 1 to 65535; not probing"; exit 2; fi [ -r "$4" ] || { echo "readable admin header file required; not probing"; exit 2; } set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Reasoned: no target cluster or container runtime. Administrative HTTPS control. # Elasticsearch: its HTTP CA (http_ca.crt from automatic setup). # OpenSearch: its deployed production HTTP CA and a production admin identity. set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS --fail-with-body --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" --header "@$4" "https://$1:$3/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # curl's exit status, then its output printf '%s' "$7" case "$6:${7##*http=}" in "0:200 "*) ;; *) echo "the administrative HTTPS control did not return 200 (curl exit $6); inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac # Reasoned: no target cluster or container runtime. Plaintext TLS probe. if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n' \ "http://$1:$3/"; then set -- "$1" "$2" "$3" "$4" "$5" 0 else set -- "$1" "$2" "$3" "$4" "$5" "$?" fi case "$5:$6" in 3:*) echo "the plaintext probe is not interpreted, because the administrative control did not return 200; if HTTPS failed because HTTP TLS is not enabled, an http= status above is the exposed state, so judge it yourself" ;; 0:0) echo "FINDING: the endpoint answered over plaintext HTTP with the http= status above"; set -- "$1" "$2" "$3" "$4" 1 ;; 0:52|0:56) echo "no HTTP response over plaintext (curl exit $6, an empty reply or a reset after connection)" ;; *) echo "curl exit $6 on the plaintext probe: a DNS failure, timeout or unreachable service is inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "block exit $5; the cluster JSON from the administrative control is not classified, so judge it yourself" exit "$5" ) ``` REASONED: following block; the RBAC comparison follows the cited Elasticsearch privileges and OpenSearch permissions references; no target cluster or scoped identities were available. Run it once with the application user's header file on its `set --` line and again with the API key's. It exits 1 when the identity reads `other-data` with `200`. Otherwise it exits 3 when the identity does not read `app-data` with `200` or `other-data` answers anything but `403`, and 0 for `200` then `403`. The response bodies stay for you to judge. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SEARCH_HOST' '/path/http_ca.crt' '9200' 'REPLACE_WITH_APP_HEADER_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "set exactly host, CA file, HTTP port, and application header file; not probing"; exit 2; } case "$1|$2|$3|$4" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your deployment; not probing"; exit 2 ;; esac [ -n "$1" ] || { echo "host required; not probing"; exit 2; } case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "readable CA file required; not probing"; exit 2; } [ -n "$3" ] || { echo "port required; not probing"; exit 2; } case "$3" in *[!0123456789]*) echo "HTTP port must be numeric"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "HTTP port must be 1 to 65535; not probing"; exit 2; fi [ -r "$4" ] || { echo "readable application header file required; not probing"; exit 2; } set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Reasoned: no target cluster or container runtime. # Run this block with app-user.header, then again with app-key.header. set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" --header "@$4" "https://$1:$3/app-data/_doc/rbac-probe"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # curl's exit status, then its output printf '%s' "$7" case "$6:${7##*http=}" in "0:200 "*) ;; *) echo "the identity did not read app-data with 200 (curl exit $6), so the other-data result cannot show scoped access; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" --header "@$4" "https://$1:$3/other-data/_doc/rbac-probe"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # curl's exit status, then its output printf '%s' "$7" case "$6:${7##*http=}" in "0:200 "*) echo "FINDING: the identity read other-data, so it is broader than app-data"; set -- "$1" "$2" "$3" "$4" 1 ;; "0:403 "*) ;; *) echo "other-data answered neither 200 nor 403 (curl exit $6); a 401, 404 or connection error does not demonstrate RBAC; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "block exit $5; the response bodies are not classified, so judge them yourself" exit "$5" ) ``` REASONED: following block; the anonymous-access comparison follows the cited Elasticsearch anonymous access and security settings references and OpenSearch anonymous authentication; no target cluster or effective node configuration was available. It exits 1 when `GET /`, the `app-data` request or the known document answers `200` without credentials. Otherwise it exits 3 when the administrator does not retrieve the known document with `200`, the anonymous known-document request answers anything but `401`, or a request fails, and 0 otherwise. A 0 covers only the tested endpoints: every node's effective anonymous settings and the recorded exposed-state success stay for you to judge. ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block # Positive discriminator: use the URL-encoded path of a document previously # observed returning 200 and its expected contents WITHOUT credentials. # Establish that exposed-state control only in an isolated test cluster. # Substitute inside the quotes, retaining the leading slash. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SEARCH_HOST' '/path/http_ca.crt' '9200' 'REPLACE_WITH_ADMIN_HEADER_FILE' '/REPLACE_WITH_KNOWN_DOCUMENT_PATH' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "set exactly host, CA file, HTTP port, admin header file, and known document path; not probing"; exit 2; } case "$1|$2|$3|$4|$5" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your deployment; not probing"; exit 2 ;; esac [ -n "$1" ] || { echo "host required; not probing"; exit 2; } case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "readable CA file required; not probing"; exit 2; } [ -n "$3" ] || { echo "port required; not probing"; exit 2; } case "$3" in *[!0123456789]*) echo "HTTP port must be numeric"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "HTTP port must be 1 to 65535; not probing"; exit 2; fi [ -r "$4" ] || { echo "readable admin header file required; not probing"; exit 2; } case "${5-}" in *REPLACE_WITH_*|"") echo "known document path required; not probing"; exit 2 ;; /*) ;; *) echo "document path must start with /; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" "$5" 0 # the 6th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Reasoned: no target cluster or container runtime. Anonymous root probe. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" "https://$1:$3/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" # curl's exit status, then its output printf '%s' "$8" case "$7:${8##*http=}" in "0:200 "*) echo "FINDING: GET / answered 200 without credentials; read the body above for cluster JSON"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:*) ;; *) echo "curl exit $7 on the anonymous root probe; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # Reasoned: no target cluster or container runtime. Endpoint-specific check. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" "https://$1:$3/app-data/_doc/rbac-probe"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" # curl's exit status, then its output printf '%s' "$8" case "$7:${8##*http=}" in "0:200 "*) echo "FINDING: app-data/_doc/rbac-probe answered 200 without credentials, an anonymous read grant"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:*) ;; *) echo "curl exit $7 on the endpoint-specific check; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # Effective configuration means what each restarted process actually loaded: # inspect its selected config directory, flattened and nested YAML, resolved # environment substitutions, and startup overrides. Record every node and # its restart. Inspecting one local file is insufficient. Settings omitted # from a filtered Nodes Info response are not proof of absence. # Require xpack.security.enabled: true and anonymous.roles absent everywhere. # Administrator must still retrieve that same document with 200 and its # expected contents; deletion or a different document invalidates the control. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --fail-with-body --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" --header "@$4" "https://$1:$3$5"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" # curl's exit status, then its output printf '%s' "$8" case "$7:${8##*http=}" in "0:200 "*) ;; *) echo "the administrator did not retrieve the known document with 200 (curl exit $7), so a 401 below proves nothing; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # Reasoned: exposed anonymous read grant -> 200 and expected document; # roles removed and every node restarted -> 401. Other outcomes unresolved. # For OpenSearch, apply and read back anonymous_auth_enabled: false instead. # Without the recorded exposed-state success, this discriminator is unresolved. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" "https://$1:$3$5"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" # curl's exit status, then its output printf '%s' "$8" case "$7:${8##*http=}" in "0:200 "*) echo "FINDING: the known document answered 200 without credentials"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; "0:401 "*) ;; *) echo "the anonymous known-document request answered neither 200 nor 401 (curl exit $7); unresolved, so inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac echo "block exit $6; the document contents, every node's effective anonymous settings and the recorded exposed-state success are not classified, so judge them yourself" exit "$6" ) ``` REASONED: following block; the CORS comparison follows the cited Elasticsearch and OpenSearch CORS settings; no target cluster or browser-origin fixture was available. Set your trusted origin, with its scheme, on its `set --` line. It exits 3 when either preflight request fails and otherwise 0, because the `Access-Control-Allow-Origin` headers stay for you to judge against the table above. ```bash ( set +e # errexit off here, so a probe that ends nonzero is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SEARCH_HOST' '/path/http_ca.crt' '9200' 'REPLACE_WITH_TRUSTED_ORIGIN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "set exactly host, CA file, HTTP port, and trusted origin; not probing"; exit 2; } case "$1|$2|$3|$4" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your deployment; not probing"; exit 2 ;; esac [ -n "$1" ] || { echo "host required; not probing"; exit 2; } case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "readable CA file required; not probing"; exit 2; } [ -n "$3" ] || { echo "port required; not probing"; exit 2; } case "$3" in *[!0123456789]*) echo "HTTP port must be numeric"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "HTTP port must be 1 to 65535; not probing"; exit 2; fi case "$4" in *[[:space:][:cntrl:]]*) echo "the trusted origin must not contain spaces or control characters; not probing"; exit 2 ;; https://?*|http://?*) ;; *) echo "the trusted origin must start with https:// or http://; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 3 (inconclusive) outranks 0 # Reasoned: no target cluster or container runtime. # Browser preflights send no credentials. if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ --cacert "$2" --include --request OPTIONS \ --header 'Origin: https://untrusted.example.org' \ --header 'Access-Control-Request-Method: GET' \ --header 'Access-Control-Request-Headers: authorization' \ "https://$1:$3/app-data/_doc/rbac-probe"; then :; else echo "curl exit $? on the untrusted-origin preflight; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 fi if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ --cacert "$2" --include --request OPTIONS \ --header "Origin: $4" \ --header 'Access-Control-Request-Method: GET' \ --header 'Access-Control-Request-Headers: authorization' \ "https://$1:$3/app-data/_doc/rbac-probe"; then :; else echo "curl exit $? on the trusted-origin preflight; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 fi echo "block exit $5; the Access-Control-Allow-Origin headers are not classified, so judge them yourself" exit "$5" ) ``` REASONED: following block; the audit probe follows the cited Elasticsearch audit events and OpenSearch audit fields references; no cluster, collector or eligible Elasticsearch audit licence was available. Set the audit-probe header file on its `set --` line. It also refuses with status 2 when `python3` is missing. It exits 3 when the marker cannot be generated, including when `python3` exits nonzero, a command fails or the probe does not receive `401`, and otherwise 0; the matching audit event stays for you to judge, as described above. ```bash ( set +e # errexit off here, so a command that ends nonzero is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SEARCH_HOST' '/path/http_ca.crt' '9200' 'REPLACE_WITH_AUDIT_PROBE_HEADER_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "set exactly host, CA file, HTTP port, and audit-probe header file; not probing"; exit 2; } case "$1|$2|$3|$4" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your deployment; not probing"; exit 2 ;; esac [ -n "$1" ] || { echo "host required; not probing"; exit 2; } case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "readable CA file required; not probing"; exit 2; } [ -n "$3" ] || { echo "port required; not probing"; exit 2; } case "$3" in *[!0123456789]*) echo "HTTP port must be numeric"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "HTTP port must be 1 to 65535; not probing"; exit 2; fi [ -r "$4" ] || { echo "readable audit-probe header file required; not probing"; exit 2; } # Reasoned: no cluster, audit collector, or eligible ES audit license here. # audit-probe.header contains Basic auth for a nonexistent audit_probe user. # Requires python3. Generate and record a fresh harmless marker for each run. command -v python3 > /dev/null || { echo "python3 is required to generate the marker; not probing"; exit 2; } set -- "$1" "$2" "$3" "$4" 0 "$(if python3 -c 'import uuid; print(uuid.uuid4().hex)'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (3, inconclusive, outranks 0), then python3's output and exit status case "${6##*$'\n'}" in 0) ;; *) echo "python3 exit ${6##*$'\n'}; marker generation failed; not probing"; exit 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" # python3's output without its exit status set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'}" # the marker, without print's newline case "$6" in ""|*[!0123456789abcdef]*) echo "marker generation failed; not probing"; exit 3 ;; esac [ "${#6}" -eq 32 ] || { echo "invalid marker; not probing"; exit 3; } printf 'audit body marker=%s\n' "$6" if date -u '+%Y-%m-%dT%H:%M:%SZ'; then :; else echo "date exit $?: record the start time yourself; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 "$6" fi set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if printf '{"query":{"term":{"audit_probe_marker":"%s"}}}\n' "$6" | curl \ -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --cacert "$2" --header "@$4" \ --header 'Content-Type: application/json' --request POST \ --data-binary @- "https://$1:$3/app-data/_search"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" # curl's exit status, then its output printf '%s' "$8" case "$7:${8##*http=}" in "0:401 "*) ;; *) echo "the audit probe did not receive 401 (curl exit $7), so it did not exercise failed authentication; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 "$6" ;; esac if date -u '+%Y-%m-%dT%H:%M:%SZ'; then :; else echo "date exit $?: record the end time yourself; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 "$6" fi echo "block exit $5; the audit records are not classified: find the matching new event yourself" exit "$5" ) ``` REASONED: following block; listener inventory and interpretation from the cited ss manual, port defaults and Docker configuration; target-node and container-runtime access are unavailable. Run the block below on the node itself to inspect listeners. It refuses with status 2 when `ss` is missing, before either inventory runs and so before anything is recorded; it records 3 when `ss` fails or a port has no listener, and otherwise exits 0 with the listener addresses left for you to judge. It uses `ss`'s own port filter, avoiding accidental PID matches. For Elasticsearch 8.19.22 and 9.5.4, the source defaults are `9200-9300` for HTTP and `9300-9399` for transport; each binds an available port. The transport upper bound is 9399, not 9400. See the pinned `HttpTransportSettings` and `TransportSettings` Sources below. Inspect the actual ports used by either product. Expect loopback or deliberate private listeners. The exception is an official Docker container on a bridge or user-defined network (not host networking): its bundled `network.host: 0.0.0.0` makes wildcard listeners inside the container the default unless it was overridden, so check instead that 9200 is reachable only from intended clients and 9300 only from cluster peers. With host networking, a wildcard listener is a finding like any other. An unexpected public or wildcard listener is a finding to review against the network controls; no output is inconclusive until the running process and its actual ports are located. ```bash # No access to the target node here. Run ON that node; substitute actual ports on the set -- line. ( # a subshell, so the exits below end only the subshell set +e # errexit off here, so an ss that ends nonzero is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK '9200' '9300' # the HTTP port, then the transport port: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "set exactly the HTTP and transport ports; not probing"; exit 2; } case "$1" in ""|*[!0123456789]*) echo "numeric HTTP port required; not probing"; exit 2 ;; esac case "$2" in ""|*[!0123456789]*) echo "numeric transport port required; not probing"; exit 2 ;; esac if [ "$1" -ge 1 ] && [ "$1" -le 65535 ] && [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; then :; else echo "ports must be 1 to 65535; not probing"; exit 2; fi command -v ss > /dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 3 (inconclusive) outranks 0; a missing ss refused with 2 above, before anything was recorded set -- "$1" "$2" "$3" "$(if ss -tlnp "sport = :$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # ss's exit status, then its listener table printf '%s' "$5" if [ "$4" != 0 ]; then echo "ss exit $4 for port $1; inconclusive"; set -- "$1" "$2" 3 else case "${5%$'\n'}" in *$'\n'*) ;; *) echo "no listener on port $1: inconclusive until the running process and its actual ports are located"; set -- "$1" "$2" 3 ;; esac fi set -- "$1" "$2" "$3" "$(if ss -tlnp "sport = :$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # ss's exit status, then its listener table printf '%s' "$5" if [ "$4" != 0 ]; then echo "ss exit $4 for port $2; inconclusive"; set -- "$1" "$2" 3 else case "${5%$'\n'}" in *$'\n'*) ;; *) echo "no listener on port $2: inconclusive until the running process and its actual ports are located"; set -- "$1" "$2" 3 ;; esac fi echo "block exit $3; the listener addresses are not classified, so judge each against the expected loopback or private binds" exit "$3" ) ``` ### Residual-control checks REASONED: deployment scope from the cited product APIs; no target cluster, eligible Elasticsearch licence, storage, repository or UI fixture is available. These additional deployment checks are **REASONED, not demonstrated**. No target Elasticsearch/OpenSearch cluster, eligible Elasticsearch FLS/DLS licence, target storage, provider repository access, or running UI with deployed certificates is available in the authoring environment. Expected outcomes rest on the cited vendor documentation and pinned source readings. REASONED: fixture and substitution instructions per the cited product APIs and the local preparation recorded above; no isolated test deployment is available in the authoring environment. Use isolated test deployments for exposed-state fixtures. Substitute values inside single quotes and paste whole shell blocks. Values containing a literal apostrophe need proper shell quoting. Keep credentials in the protected header files described above, with tracing disabled. The following blocks assume the shell's normal builtins. REASONED: body-file and per-request procedure for the REST block below, per the cited product APIs. In a re-run at marking time, before the guard's `example.com` test was made case-insensitive, the guard in that block refused a placeholder, a missing body file, an unsupported method and a relative path with exit 1 and dispatched valid values to a local argument recorder, and a parsed JSON body was saved as a mode-0600 file. On 2026-10-13 (UTC), with Bash 5.3.9 and a stub curl executable, the edited guard refused `example.com`, `EXAMPLE.COM`, `Example.Com` and `es.EXAMPLE.com` with exit 1 before any curl call and passed `Dummy.Valid.test` to curl unchanged, where before the edit the three forms that are not all lowercase reached curl. After the later extension of the guard to `example.net` and `example.org` in any letter case, a further run on 2026-10-13 (UTC) used Bash 5.3.9, with the block saved to a file and run with its commands unchanged except fixture values, and with curl, python3 and date stubbed. That further run refused `example.net`, `EXAMPLE.NET`, `es.Example.Net`, `example.org`, `EXAMPLE.ORG`, `es.Example.Org`, `example.com`, `EXAMPLE.COM`, `Example.Com`, `es.EXAMPLE.com` and `example.network.test` with exit 1 before any curl call and passed `Dummy.Valid.test` to its one curl call unchanged. Before the extension, the `.net` and `.org` hosts and `example.network.test` reached curl while the `.com` forms were refused. The test is a substring match, so it also refuses longer names that contain `example.net` or `example.org`, such as `example.network.test`; no target cluster is available. Every run recorded in this paragraph covered an earlier form of the REST block, whose refusals exited with status 1; the current form, whose refusals exit with status 2 and which exits 3 when curl fails, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs on 2026-10-31, not by those runs. For the REST request tables below, save each JSON body in a protected file. Run this block once per request, setting the method, path, and body-file arguments. Use `/dev/null` for a request with no body. Select the administrator or application header file as directed: REASONED: following block; REST fixture, request and response comparisons follow the cited product APIs and restriction rules; no target cluster, eligible Elasticsearch licence or repository access was available. Local file and argument checks are recorded above. Judge the HTTP status and response contents, not the shell's final status alone: the block exits 3 when curl fails and otherwise 0. A transport failure or `401` is not evidence of correct field/document restrictions. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SEARCH_HOST' 'REPLACE_WITH_CA_FILE' '9200' 'REPLACE_WITH_HEADER_FILE' 'GET' '/REPLACE_WITH_REQUEST_PATH' '/dev/null' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 7 ] || { echo "set host, CA, port, header file, method, path, and body file; not probing"; exit 2; } case "$1|$2|$3|$4|$5|$6|$7" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your deployment; not probing"; exit 2 ;; esac case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address; not probing"; exit 2 ;; esac case "$3" in ""|*[!0123456789]*) echo "numeric port required; not probing"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "port must be 1 to 65535; not probing"; exit 2; fi if [ -r "$2" ] && [ -r "$4" ] && [ -r "$7" ]; then :; else echo "readable CA, header, and body files required"; exit 2; fi case "$5" in GET|POST|PUT) ;; *) echo "unsupported method; not probing"; exit 2 ;; esac case "$6" in /*) ;; *) echo "request path must start with /; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" 0 # the 8th value is the block's status: 3 (inconclusive) or 0 # REASONED: no target cluster, eligible ES licence, or repository access. if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 120 \ --cacert "$2" --header "@$4" --header 'Content-Type: application/json' \ --request "$5" --data-binary "@$7" \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:$3$6"; then :; else echo "curl exit $?: the request failed; inconclusive"; set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" 3 fi echo "block exit $8; the HTTP status and response contents are not classified, so judge them against the tables yourself" exit "$8" ) ``` ### Verify field and document restrictions (REASONED: retrieval and aggregation comparisons from the cited Elasticsearch and OpenSearch FLS, DLS and API references; no live clusters or eligible Elasticsearch FLS/DLS licence are available) **REASONED, not demonstrated:** requires both live products and an eligible Elasticsearch FLS/DLS licence. In an isolated cluster where `app-data` does not already exist, use the administrator header and REST block to create this harmless fixture: | Method | Path | JSON body | | --- | --- | --- | | `PUT` | `/app-data` | `{"mappings":{"properties":{"tenant_id":{"type":"keyword"},"title":{"type":"keyword"},"category":{"type":"keyword"},"secret":{"type":"keyword"}}}}` | | `PUT` | `/app-data/_doc/fls-a?refresh=true` | `{"tenant_id":"tenant-a","title":"A","category":"public-a","secret":"fixture-secret-a"}` | | `PUT` | `/app-data/_doc/fls-b?refresh=true` | `{"tenant_id":"tenant-b","title":"B","category":"public-b","secret":"fixture-secret-b"}` | Require successful creation responses. These markers are deliberately harmless, not real secrets. See the [Elasticsearch 8.19 create-index API](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/indices-create-index.html), [index-document API](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/docs-index_.html), [OpenSearch 3.6 create-index API](https://docs.opensearch.org/3.6/api-reference/index-apis/create-index/), and [index-document API](https://docs.opensearch.org/3.6/api-reference/document-apis/index-document/). Run the following requests first as an identity with the existing unrestricted `app_reader` role, then as the restricted application identity: | Method | Path | JSON body | | --- | --- | --- | | `POST` | `/app-data/_search` | `{"query":{"match_all":{}},"fields":["tenant_id","title","category","secret"]}` | | `GET` | `/app-data/_doc/fls-a` | No body | | `GET` | `/app-data/_doc/fls-b` | No body | | `POST` | `/app-data/_search` | `{"size":0,"aggs":{"values":{"terms":{"field":"secret"}}}}` | | `POST` | `/app-data/_search` | `{"size":0,"aggs":{"values":{"terms":{"field":"category"}}}}` | The unrestricted control must retrieve both documents and both secret markers, including the secret aggregation buckets. With the restricted identity: - Search returns only `fls-a`; neither `_source` nor requested `fields` contains a secret marker. - Direct retrieval returns `fls-a` without `secret`, while `fls-b` is not retrievable. - The secret aggregation discloses neither secret value. Record the exact response; a failed query alone does not demonstrate working FLS. - The allowed `category` aggregation succeeds and returns `public-a`, excluding `public-b`. - The administrator still retrieves `fls-b` and its original marker, ruling out a missing fixture. A `401`, unavailable index, or broken allowed-field query leaves the result unresolved. Repeat with the actual application credential, including any API key intended for production, and retain its effective permission configuration. These checks exercise ordinary retrieval and aggregations; they do not prove absence of statistical inference channels. The distinctions follow [Elasticsearch 8.19 FLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-level-security.html), [DLS](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/document-level-security.html), [search syntax](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/search-search.html), [OpenSearch 3.6 FLS](https://docs.opensearch.org/3.6/security/access-control/field-level-security/), [DLS](https://docs.opensearch.org/3.6/security/access-control/document-level-security/), and [search syntax](https://docs.opensearch.org/3.6/api-reference/search-apis/search/). ### Verify storage encryption (REASONED: device-chain and startup comparisons from the cited storage and systemd manuals; no target node, storage or isolated unavailable-mount fixture is available) **REASONED, not demonstrated:** these inspections do not require a running cluster, but target-node and target-storage access are unavailable here. Run on the node, with sufficient permission to inspect the intended dm-crypt mapping: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DATA_PATH' 'REPLACE_WITH_CRYPT_MAPPING' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "set data path and mapping name; not probing"; exit 2; } case "$1|$2" in *REPLACE_WITH_*) echo "substitute your storage; not probing"; exit 2 ;; esac case "$1" in /*) ;; *) echo "absolute data path required"; exit 2 ;; esac case "$2" in ""|-*|*/*) echo "mapping name required"; exit 2 ;; esac [ -d "$1" ] || { echo "data directory missing; not probing"; exit 2; } set +e # errexit off here, so a command that ends nonzero is recorded below instead of ending the block set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 3 (inconclusive) outranks 0 # REASONED: no access to the target node or encrypted storage. if findmnt -T "$1"; then :; else echo "findmnt exit $?; inconclusive"; set -- "$1" "$2" 3; fi if lsblk -o NAME,TYPE,FSTYPE,MOUNTPOINTS; then :; else echo "lsblk exit $?; inconclusive"; set -- "$1" "$2" 3; fi if cryptsetup status "$2"; then :; else echo "cryptsetup status exit $?; inconclusive"; set -- "$1" "$2" 3; fi echo "block exit $3; the mount and device chain is not classified, so confirm yourself that the data path traverses the intended encrypted mapping" exit "$3" ) ``` Compare the exposed unencrypted volume with the fixed volume. Require the actual data path's mount/device chain to traverse the intended encrypted mapping. An unrelated active mapping is insufficient. For provider-managed volume encryption, inspect the actual attached volume's encryption and key metadata instead; the guest may not expose a dm-crypt device. Successful reads through an unlocked filesystem neither prove nor disprove encryption at rest. See [findmnt](https://man7.org/linux/man-pages/man8/findmnt.8.html), [lsblk](https://man7.org/linux/man-pages/man8/lsblk.8.html), and [cryptsetup status](https://man7.org/linux/man-pages/man8/cryptsetup-status.8.html). **REASONED, not demonstrated:** in an isolated systemd fixture, stop the service and make the required test mount unavailable, including preventing automatic remount. Run the following with the test service unit. Do not perform this fault injection on production storage. ```bash ( set +e # errexit off here, so a command that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SERVICE_UNIT' 'REPLACE_WITH_unavailable_OR_restored' # the test service unit, then unavailable (the test mount made unavailable) or restored (the encrypted mount restored, the positive control): replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "set exactly the service unit and the phase; not probing"; exit 2; } case "$1" in ""|-*|*REPLACE_WITH_*|*/*) echo "substitute the test service unit"; exit 2 ;; esac case "$2" in unavailable|restored) ;; *) echo "substitute unavailable or restored as the phase; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # REASONED: isolated target service and unavailable-mount fixture absent. if systemctl start "$1"; then case "$2" in unavailable) echo "FINDING: the service started with the test mount unavailable, so startup is not enforced"; set -- "$1" "$2" 1 ;; esac else set -- "$1" "$2" "$3" "$?" case "$2" in unavailable) echo "systemctl start exit $4: read the status below; the pass is a failure caused by the unavailable mount, and permission errors or unrelated failures are inconclusive" ;; restored) echo "systemctl start exit $4: the positive control failed; inconclusive"; set -- "$1" "$2" 3 ;; esac fi if systemctl --no-pager status "$1"; then :; else set -- "$1" "$2" "$3" "$?" case "$4" in 1|2|3) ;; # the unit's state, not a failed command *) echo "systemctl status exit $4; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac fi echo "block exit $3; the cause of a start failure and any writes to the ordinary directory are not classified, so judge them yourself" exit "$3" ) ``` Without startup enforcement, an otherwise valid fixture may start on the ordinary underlying directory. With the mount dependency/assertion, require the start job to fail specifically because the mount is unavailable, with no search process writing into that directory. Restore the intended encrypted mount and require successful startup as the positive control. Permission errors or unrelated startup failures are inconclusive. See [systemd assertions](https://man7.org/linux/man-pages/man5/systemd.unit.5.html) and [systemctl](https://man7.org/linux/man-pages/man1/systemctl.1.html). ### Verify snapshot encryption and key isolation (REASONED: snapshot, restore and object/key comparisons from the cited product and provider references; no live cluster, repository, provider access or KMS test identities are available) **REASONED, not demonstrated:** requires a live cluster, configured repository, provider access, and controlled test identities. Using the administrator header and REST block, snapshot only the harmless fixture. Select an unused snapshot name and an unused restore index; the names below are for the isolated fixture. | Method | Path | JSON body | | --- | --- | --- | | `PUT` | `/_snapshot/private_s3/encryption-probe?wait_for_completion=true` | `{"indices":"app-data","include_global_state":false}` | | `GET` | `/_snapshot/private_s3/encryption-probe` | No body | | `POST` | `/_snapshot/private_s3/encryption-probe/_restore?wait_for_completion=true` | `{"indices":"app-data","include_global_state":false,"include_aliases":false,"rename_pattern":"app-data","rename_replacement":"restored-app-data"}` | | `GET` | `/restored-app-data/_doc/fls-a` | No body | | `GET` | `/restored-app-data/_doc/fls-b` | No body | Require snapshot success, completed restore, and both original fixture documents. A client timeout does not establish failure or success; inspect operation state before retrying. The explicit index selection and `include_global_state: false` avoid restoring security state in this fixture. See [Elasticsearch 8.19 snapshot creation](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-take-snapshot.html), [restore](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-restore-snapshot.html), and [OpenSearch 3.6 snapshot/restore](https://docs.opensearch.org/3.6/tuning-your-cluster/availability-and-recovery/snapshots/snapshot-restore/). For AWS S3, identify actual newly written repository objects through the provider inventory or console. With AWS CLI v2 and preconfigured profiles, inspect a known object and attempt a download: ```bash ( set +e # errexit off here, so a command that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_AWS_PROFILE' 'REPLACE_WITH_BUCKET' 'REPLACE_WITH_OBJECT_KEY' 'REPLACE_WITH_permitted_OR_restricted' # the profile, bucket and object key, then permitted (a principal that must read the object) or restricted (the principal whose S3 or KMS access was removed): replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "set profile, bucket, object key, and phase; not probing"; exit 2; } case "$1|$2|$3|$4" in *REPLACE_WITH_*) echo "substitute the test object and identity"; exit 2 ;; esac if [ -n "$1" ] && [ -n "$2" ] && [ -n "$3" ]; then :; else echo "profile, bucket, and object key required; not probing"; exit 2; fi case "$4" in permitted|restricted) ;; *) echo "substitute permitted or restricted as the phase; not probing"; exit 2 ;; esac command -v aws > /dev/null || { echo "aws (AWS CLI v2) not found; not probing"; exit 2; } set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # REASONED: no repository, provider credentials, or KMS test identities. if aws --profile "$1" --no-cli-pager s3api head-object --bucket "$2" --key "$3"; then :; else set -- "$1" "$2" "$3" "$4" "$5" "$?" case "$4" in permitted) echo "head-object exit $6: the permitted principal could not read the object's metadata; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; restricted) echo "head-object exit $6 for the restricted principal: read the error" ;; esac fi if aws --profile "$1" --no-cli-pager s3api get-object --bucket "$2" --key "$3" /dev/null; then case "$4" in restricted) echo "FINDING: the restricted principal downloaded the object"; set -- "$1" "$2" "$3" "$4" 1 ;; esac else set -- "$1" "$2" "$3" "$4" "$5" "$?" case "$4" in permitted) echo "get-object exit $6: the permitted principal could not download the object, so the positive control failed; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; restricted) echo "get-object exit $6: read the error and the provider's authorization or audit evidence; only the S3 or KMS denial under test is the pass, and a missing object, expired credentials or a network error is inconclusive" ;; esac fi echo "block exit $5; the object's encryption mode and key, and the cause of a restricted failure, are not classified, so judge them yourself" exit "$5" ) ``` The profile name and object key are identifiers, not credentials or encryption-key material. Load authentication through the protected AWS credential mechanism. See [HeadObject](https://docs.aws.amazon.com/cli/latest/reference/s3api/head-object.html) and [GetObject](https://docs.aws.amazon.com/cli/latest/reference/s3api/get-object.html). Require the following distinctions: | Check | Exposed or misconfigured outcome | Fixed outcome | | --- | --- | --- | | Encryption/key policy | Objects use an unintended mode or key, even if provider default encryption is active. | Actual object metadata matches the required mode; for KMS, `SSEKMSKeyId` identifies the intended customer-managed key. | | Repository access | A deliberately overprivileged test principal downloads the known object. | After removing that access, the same principal cannot download it, while the authorized principal still can. | | KMS isolation | The controlled principal can decrypt the known KMS object. | Removing the necessary KMS permission causes the same operation to fail for that reason, while its S3 read permission remains and a permitted identity succeeds. | Use provider authorization/audit evidence to distinguish KMS denial from S3 denial, missing objects, expired credentials, or network errors. If caching or another effective grant prevents a clean KMS negative control, leave that check unresolved. Inspect retained objects as well as newly written ones; changing a default does not prove old objects were rewritten. For GCS/Azure repositories, retain equivalent object-encryption/key metadata and authorized/unauthorized download evidence from those providers. A successful repository `_verify` request establishes neither encryption mode nor key isolation. Server-side-encrypted downloads normally return decrypted contents; this is not a client-side-encryption test. See [AWS encryption behavior](https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html) and [SSE-KMS authorization](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html). ### Verify the separate UI listener REASONED: UI verification scope from the cited Kibana and Dashboards settings; no running UI, certificates or network test paths are available. **REASONED, not demonstrated:** requires Kibana or OpenSearch Dashboards, deployed certificates, and authorized/prohibited network test paths. REASONED: following block; UI TLS comparison from the cited Kibana and Dashboards settings; no isolated UI deployment, running UI, certificates or network test paths are available. In an isolated exposed fixture, record an HTTP login page or redirect on the actual UI port. After applying the fixed configuration, run the block below. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_UI_HOST' 'REPLACE_WITH_UI_CA_FILE' '5601' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "set UI host, CA file, and port; not probing"; exit 2; } case "$1|$2|$3" in *REPLACE_WITH_*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Cc][Oo][Mm]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Nn][Ee][Tt]*|*[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Oo][Rr][Gg]*) echo "substitute your UI deployment"; exit 2 ;; esac case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "use a DNS hostname or IPv4 address"; exit 2 ;; esac case "$3" in ""|*[!0123456789]*) echo "numeric UI port required"; exit 2 ;; esac if [ "$3" -ge 1 ] && [ "$3" -le 65535 ]; then :; else echo "UI port must be 1 to 65535; not probing"; exit 2; fi [ -r "$2" ] || { echo "readable UI CA file required"; exit 2; } set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # REASONED: no running UI, deployed certificates, or network test paths. if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --output /dev/null --dump-header - \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1:$3/"; then :; else echo "curl exit $? on the HTTPS request: CA-validated HTTPS did not succeed; inconclusive"; set -- "$1" "$2" "$3" 3 fi set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --output /dev/null --dump-header - \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:$3/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then its output printf '%s' "$6" case "$5:${6##*http=}" in 0:2??' '*|0:3??' '*) echo "FINDING: plaintext on the UI port answered with the http= status above, a page or redirect served without TLS"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "plaintext on the UI port answered with the http= status above: an explicit protocol rejection is the pass and a UI page a finding, so read the headers; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; 52:*|56:*) echo "no HTTP response over plaintext (curl exit $5, a connection close or reset)" ;; *) echo "curl exit $5 on the plaintext request: a timeout, DNS failure or other error alone cannot show rejection; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; the HTTPS response is not classified, so judge it against the expected UI response yourself" exit "$4" ) ``` REASONED: TLS response interpretation, network comparison and UI listener inventory from the cited UI settings, UI binding settings and ss manual; no running UI, deployed certificates, authorized and prohibited deployment paths or target-host access are available. On the authorized path, require successful CA-validated HTTPS with the expected UI response. Plaintext on that TLS listener must not serve the UI; a connection close or explicit protocol rejection can demonstrate rejection. A timeout or DNS failure alone cannot. The block above records 3 when plaintext gets an HTTP status other than a 2xx or 3xx, because it cannot tell an explicit protocol rejection from a UI error page, so a UI that rejects plaintext that way never exits 0 and its headers stay for you to judge. For loopback deployments, test locally using a certificate hostname that resolves to the UI's loopback listener. Separately test the real deployment address from the prohibited network, not that client's own loopback. Require the prohibited connection to fail while the authorized path still works, and retain network-control evidence. On the UI host, inspect the actual listener with the block below. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_UI_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "set exactly one UI port; not probing"; exit 2; } case "$1" in ""|*[!0123456789]*) echo "substitute the numeric UI port"; exit 2 ;; esac if [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; then :; else echo "UI port must be 1 to 65535; not probing"; exit 2; fi command -v ss > /dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- "$1" 0 # the 2nd value is the block's status: 3 (inconclusive) or 0 # REASONED: no access to the target UI host. Run on that host. if ss -tlnp "sport = :$1"; then :; else echo "ss exit $?; inconclusive"; set -- "$1" 3 fi echo "block exit $2; the listener addresses are not classified, so judge them against the intended loopback or private bind yourself" exit "$2" ) ``` REASONED: preceding block; UI listener inspection, binding, backend TLS and cookie inspection follow the cited UI settings and ss manual; no UI host, cluster connection or session fixture is available. Require the intended loopback/private bind. Inspect container port publication, selected configuration, environment, and process arguments too. Confirm the UI-to-cluster connection still uses HTTPS and full verification; successful browser TLS alone does not establish that. For Dashboards, confirm `opensearch_security.cookie.secure: true` and the Secure attribute on its session cookie. See [Kibana 8.19 settings](https://www.elastic.co/guide/en/kibana/8.19/settings.html), [Kibana Docker overrides](https://www.elastic.co/guide/en/kibana/8.19/docker.html), and [Dashboards 3.6 TLS settings](https://docs.opensearch.org/3.6/install-and-configure/install-dashboards/tls/). REASONED: exposure interpretation from the cited cluster authentication references; no target cluster is available. An unauthenticated `GET /` returning cluster JSON is the classic finding; so is `_cat/indices` listing your data to the world. REASONED: deployment expectations and source-audit limits recorded in this guide; no target deployment is available, and OpenSearch GCS/Azure end-to-end repository crypto applicability, live behaviour, available deployed key providers, minimum versions and later-release behaviour remain unestablished. The deployment checks below remain REASONED from the cited vendor documentation and pinned sources. This guide reviewed the OpenSearch 3.8.0 GCS/Azure plugin sources and core repository crypto path; live encryption behaviour remains untested. | Scope | Exposed/fixed checks and retained source work | Status | | --- | --- | --- | | Cluster checks | Run every check against exposed and fixed states in isolated Elasticsearch 8.x and OpenSearch test clusters, including OpenSearch 3.7+ keys and an eligible Elasticsearch audit license. Include the Elasticsearch counterexample: anonymous roles remain configured, authz_exception is false, and the anonymous identity lacks permission for both / and app-data, so both probes return 401 while a document in another permitted index remains anonymously readable with 200. Record that document and its contents, remove anonymous.roles, restart affected nodes, confirm its absence from every node's effective configuration, and require the same document request to return 401 while an administrator still retrieves it with 200. Also check fresh failed-authentication audit events with a unique harmless JSON marker present when body logging is enabled and absent when disabled; retain configuration-readback and sensitive-header-exclusion checks. Record versions, effective configuration, HTTP responses, listener output, and matching newly collected audit events. | REASONED: target clusters, container runtime, and audit collection are unavailable in the authoring environment. | | Listener defaults | Check Elasticsearch and Kibana 8.19.22 and 9.5.4 listener addresses and actual ports in exposed and fixed states. Include archive/package initial setup, Docker configuration, enrollment with local and remote peers, explicit address overrides, available IPv4/IPv6 loopback addresses, and container publication. Retain effective settings and authorized/prohibited network results. | REASONED: the authoring host forbids opening listeners without an isolated network namespace, and has none. Existing cluster/runtime and target-access blockers still apply. | | FLS/DLS | Check the two-tenant fixture on Elasticsearch 8.19 with an eligible Platinum/Enterprise licence and OpenSearch 3.8.0. Record unrestricted and restricted search, direct GET, `_source`, requested-field, secret-aggregation, and allowed-aggregation results. Retain administrator fixture controls, effective roles/settings, and actual application-credential results. Record documented inference limitations separately from ordinary retrieval results. | REASONED: live clusters and an eligible Elasticsearch FLS/DLS licence are unavailable. | | Storage | Record actual data-path mount/device chains before and after encryption. Check refusal to start when the required encrypted mount is unavailable and successful startup after restoration. Cover logs, swap, and local snapshot storage; use provider volume/key metadata where applicable. | REASONED: target-node, target-storage, and service-manager fixture access are unavailable. | | REASONED: whole row; live snapshot, key-metadata and KMS-denial checks per the cited snapshot and repository references, with clusters, repositories and provider or KMS test identities unavailable. Snapshots and source audit | On both products, take and restore a harmless snapshot. Record new and retained object encryption/key metadata, authorized and unauthorized object downloads, and a KMS-dependent denial with a working positive control. Record the OpenSearch version and explicit S3 encryption settings. Separately verify OpenSearch GCS/Azure client-side-encryption capability in the deployed version before asserting support or absence; this guide reviewed the 3.8.0 plugin sources and core repository crypto path. | REASONED for live checks: clusters, repositories and provider/KMS test identities are unavailable. End-to-end GCS/Azure repository crypto applicability, available deployed key providers, minimum versions and later-release behaviour remain unestablished. | | UI | Check exposed HTTP and fixed CA-validated HTTPS for Kibana 8.19 and Dashboards 3.8.0. Record plaintext rejection, listener addresses, container overrides/publication, prohibited-network denial with an authorized positive control, backend full verification, and the Dashboards Secure cookie. | REASONED: UI processes, deployed certificates, target hosts, and network test paths are unavailable. | ## Sources (checked September 2026) - Official Elasticsearch Docker images: the tag map (library file pinned commit 8480a06d8ca03ca7da29a469fdbddb5f9f31df61). For each published branch, the Docker images item cites the bundled `elasticsearch.yml` with `network.host: 0.0.0.0` and the Dockerfile lines that copy it and `EXPOSE 9200 9300` (8.19 at pinned commit 0208be14db8ebd2e40442d328e8f79921368c5a9, 9.4 at 25fdea1109a231cedf9d1d3cf78bbb7fce488561, 9.5 at 18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a). The Docker images item also cites the entrypoint lines that pass `-E` arguments through to the server after a first argument of `eswrapper` or a path whose last component is `elasticsearch`, Elastic's Docker configuration page for environment-variable settings and bind-mounted configuration, and Elastic's networking settings reference for `0.0.0.0`. Official Elasticsearch Docker image sources: https://github.com/docker-library/official-images/blob/8480a06d8ca03ca7da29a469fdbddb5f9f31df61/library/elasticsearch#L2-L19 and https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/config/elasticsearch.yml. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/Dockerfile#L38 and https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/Dockerfile#L123. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/config/elasticsearch.yml and https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/Dockerfile#L57. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/Dockerfile#L99 and https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/config/elasticsearch.yml. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/Dockerfile#L57 and https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/Dockerfile#L99. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/bin/docker-entrypoint.sh#L10-L22 and https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/bin/docker-entrypoint.sh#L84. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/bin/docker-entrypoint.sh#L10-L22 and https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/bin/docker-entrypoint.sh#L84. More official Elasticsearch Docker image sources: https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/bin/docker-entrypoint.sh#L10-L22 and https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/bin/docker-entrypoint.sh#L84. Elastic Docker configuration and networking settings sources: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-configure and https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/networking-settings - Elasticsearch self-managed subscription tiers and current Platinum availability: https://www.elastic.co/subscriptions - Elasticsearch current subscription matrix PDF: https://www.elastic.co/pdf/subscriptions-2026-08-04.pdf - Elasticsearch automatic security setup, skip conditions, and generated HTTP CA: https://www.elastic.co/docs/deploy-manage/security/self-auto-setup - Elasticsearch password-reset command: https://www.elastic.co/docs/reference/elasticsearch/command-line-tools/reset-password - Elasticsearch networking defaults and CORS settings: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/networking-settings - Elasticsearch security settings, HTTP TLS, and anonymous configuration: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/security-settings - Elasticsearch built-in roles and superuser breadth: https://www.elastic.co/docs/reference/elasticsearch/roles - Elasticsearch security privileges: https://www.elastic.co/docs/reference/elasticsearch/security-privileges - Elasticsearch 8.x create or update role API: https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role - Elasticsearch 8.x native-user API: https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-user - Elasticsearch 8.x API-key creation, permission intersection, and expiration: https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-create-api-key - Elasticsearch LDAP authentication: https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap - Elasticsearch 8.x role-mapping API: https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role-mapping - Elasticsearch anonymous access and rejection without configured roles: https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/anonymous-access - Elasticsearch audit enablement and the 9.5 dynamic-setting boundary: https://www.elastic.co/docs/deploy-manage/monitor/stack-audit-logging/enabling-audit-logs - Elasticsearch auditing settings and request-body default: https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/auding-settings - Elasticsearch audit event names and attributes: https://www.elastic.co/docs/reference/elasticsearch/elasticsearch-audit-events - Elasticsearch 8.x document retrieval API: https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-get - OpenSearch Security Apache-2.0 license: https://github.com/opensearch-project/security/blob/4ba3cc0e2ebec61b726b227fdf51e55f4a502182/LICENSE.txt - OpenSearch demo security configuration and initial admin password: https://docs.opensearch.org/latest/security/configuration/demo-configuration/ - OpenSearch shipped demo accounts: https://github.com/opensearch-project/security/blob/03a224d16045a8f561e2d7d84b8765c95309524c/config/internal_users.yml - OpenSearch TLS settings and admin certificates: https://docs.opensearch.org/latest/security/configuration/tls/ - OpenSearch applying security configuration files: https://docs.opensearch.org/latest/security/configuration/security-admin/ - OpenSearch users, roles, and all_access breadth: https://docs.opensearch.org/latest/security/access-control/users-roles/ - OpenSearch Security REST API permissions: https://docs.opensearch.org/latest/security/access-control/api/ - OpenSearch create or update role API: https://docs.opensearch.org/latest/security/api/roles/create-role/ - OpenSearch internal-user API: https://docs.opensearch.org/latest/security/api/users/create-user/ - OpenSearch role-mapping API: https://docs.opensearch.org/latest/security/api/role-mappings/create-role-mapping/ - OpenSearch API-key introduction in 3.7 and api_tokens configuration: https://docs.opensearch.org/latest/security/access-control/api-keys/ - OpenSearch API-key creation, index_pattern, and duration_seconds: https://docs.opensearch.org/latest/security/api/api-keys/create/ - OpenSearch security backend configuration: https://docs.opensearch.org/latest/security/configuration/configuration/ - OpenSearch HTTP basic authentication domain: https://docs.opensearch.org/latest/security/authentication-backends/basic-authc/ - OpenSearch anonymous authentication: https://docs.opensearch.org/latest/security/access-control/anonymous-authentication/ - OpenSearch networking and CORS settings: https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/network-settings/ - OpenSearch audit enablement, destinations, and categories: https://docs.opensearch.org/latest/security/audit-logs/index/ - OpenSearch audit configuration GET and read-only fields: https://docs.opensearch.org/latest/security/api/audit/get-audit-configuration/ - OpenSearch audit configuration PATCH: https://docs.opensearch.org/latest/security/api/audit/patch-audit-configuration/ - OpenSearch audit configuration fields and defaults: https://docs.opensearch.org/latest/security/api/audit/update-audit-configuration/ - OpenSearch audit event fields: https://docs.opensearch.org/latest/security/audit-logs/field-reference/ - OpenSearch security permissions: https://docs.opensearch.org/latest/security/access-control/permissions/ - OpenSearch read action group: https://docs.opensearch.org/latest/security/access-control/default-action-groups/ - OpenSearch document retrieval API: https://docs.opensearch.org/latest/api-reference/document-apis/get-documents/ - curl options, header-file input, and request-body file input: https://curl.se/docs/manpage.html - ss listener and port-filter syntax: https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html - Elastic distribution/source licensing: https://www.elastic.co/pricing/faq/licensing - Basic security introduction and FLS/DLS exclusion: https://www.elastic.co/blog/security-for-elasticsearch-is-now-free - Dated subscription matrix: https://www.elastic.co/pdf/subscriptions-2025-07-29.pdf - Current subscription terms and Platinum availability: https://www.elastic.co/subscriptions - Elasticsearch 8.19 role API: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/security-api-put-role.html - Elasticsearch 8.19 access control and role composition: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-and-document-access-control.html - Elasticsearch 8.19 FLS: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-level-security.html - Elasticsearch 8.19 DLS: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/document-level-security.html - Elasticsearch 8.19 security limitations: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/security-limitations.html - OpenSearch Security licence, tag 3.8.0.0: https://raw.githubusercontent.com/opensearch-project/security/3.8.0.0/LICENSE.txt - OpenSearch 3.6 Security API and role schema: https://docs.opensearch.org/3.6/security/access-control/api/ - OpenSearch 1.0 DLS archive: https://docs.opensearch.org/1.0/security-plugin/access-control/document-level-security/ - OpenSearch 3.6 FLS: https://docs.opensearch.org/3.6/security/access-control/field-level-security/ - OpenSearch 3.6 DLS, modes, and role composition: https://docs.opensearch.org/3.6/security/access-control/document-level-security/ - Elasticsearch 8.19 dm-crypt-backed storage: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/tune-for-search-speed.html - Elasticsearch 8.19 path settings: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/path-settings-overview.html - OpenSearch 3.6 configuration settings (path.data): https://docs.opensearch.org/3.6/install-and-configure/configuring-opensearch/configuration-system/ - OpenSearch 3.0 encryption-at-rest guidance: https://docs.opensearch.org/3.0/troubleshoot/#encryption-at-rest - Elasticsearch 8.19 S3 repository: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-s3.html - OpenSearch 3.1 repository API and encryption-setting change: https://docs.opensearch.org/3.1/api-reference/snapshots/create-repository/ - OpenSearch S3 implementation, tag 3.8.0: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/S3Repository.java - GCS plugin settings, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStoragePlugin.java#L125-L144 - GCS repository settings, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L74-L84 - GCS blob upload, tag 3.8.0 (checked October 2026). OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L259-L266 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L289-L291. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L370-L373 - GCS client construction, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageService.java#L247-L285 - Azure plugin settings, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepositoryPlugin.java#L133-L153 - Azure repository settings, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L75-L107 - Azure blob upload, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobStore.java#L351-L394 - Azure client construction, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureStorageService.java#L195-L239 - GCS/Azure blob-store creation and Azure repository-level encryption hint, tag 3.8.0 (checked October 2026). OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L147-L150 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L140-L157. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L185-L200 - OpenSearch core repository crypto path, tag 3.8.0 (checked October 2026). OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/action/admin/cluster/repositories/put/PutRepositoryRequest.java#L248-L271 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/action/admin/cluster/crypto/CryptoSettings.java#L54-L63. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreProvider.java#L44-L96 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1073-L1085. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L39-L51 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L60-L67. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L94-L110 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/crypto/CryptoHandlerRegistry.java#L144-L167. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/CryptoKmsPlugin.java#L26-L27 - GCS and Azure repository inheritance from BlobStoreRepository, tag 3.8.0 (checked October 2026). OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L60 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L72. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1098 - OpenSearch 2.10.0 repository crypto validation and conditional wrap, MeteredBlobStoreRepository class, GCS/Azure superclass declarations, store creation and class bodies (checked October 2026). OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/server/src/main/java/org/opensearch/action/admin/cluster/crypto/CryptoSettings.java#L52-L61. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L764-L788. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L56. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L126-L129. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L68. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L164-L179. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L56-L154. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L68-L195. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L51-L81 - OpenSearch 3.0.0 repository crypto validation and conditional wrap, MeteredBlobStoreRepository class, GCS/Azure superclass declarations, store creation and class bodies (checked October 2026). OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/server/src/main/java/org/opensearch/action/admin/cluster/crypto/CryptoSettings.java#L54-L63. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L989-L1013. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L121-L124. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L159-L174. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58-L158. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70-L199. More OpenSearch 3.0.0 sources: https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74 - OpenSearch BlobStoreRepository.blobStore() conditional wrap, MeteredBlobStoreRepository class and GCS/Azure class bodies, tag 3.3.2 (checked October 2026). OpenSearch 3.3.2 sources: https://github.com/opensearch-project/OpenSearch/blob/3.3.2/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1054-L1078 and https://github.com/opensearch-project/OpenSearch/blob/3.3.2/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74. More OpenSearch 3.3.2 sources: https://github.com/opensearch-project/OpenSearch/blob/3.3.2/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58-L158 and https://github.com/opensearch-project/OpenSearch/blob/3.3.2/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70-L199 - OpenSearch BlobStoreRepository.blobStore() delegation to BlobStoreProvider, MeteredBlobStoreRepository class and GCS/Azure class bodies, tag 3.4.0 (checked October 2026). OpenSearch 3.4.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1062-L1084 and https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74. More OpenSearch 3.4.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.4.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58-L158 and https://github.com/opensearch-project/OpenSearch/blob/3.4.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70-L199 - OpenSearch BlobStoreRepository repository metadata field, blobStore() delegation, shard-container lookup, snapshotShard IndexMetadata parameter, index-level CryptoMetadata resolution, handoff through executeOneFileSnapshot to snapshotFile and writeBlobWithMetadata call, tag 3.5.0 (checked October 2026). OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L219 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1063-L1085. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L2977-L2979 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L3950-L3962. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4147-L4148 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4155-L4189. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4819-L4870 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4958-L4964 - OpenSearch GCS/Azure class bodies and getNativeStore() overrides, tag 3.7.0 (checked October 2026). OpenSearch 3.7.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L60-L195 and https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L191-L194. More OpenSearch 3.7.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L72-L236 and https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L232-L235 - OpenSearch BlobStoreRepository.blobStore() delegation to BlobStoreProvider, MeteredBlobStoreRepository class and GCS/Azure class bodies, tag 3.9.0 (checked October 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1063-L1085 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L60-L195 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L72-L236 - OpenSearch BlobStoreRepository repository metadata field, shard-container lookup, snapshotShard IndexMetadata parameter, index-level CryptoMetadata resolution, handoff through executeOneFileSnapshot to snapshotFile and writeBlobWithMetadata call, tag 3.9.0 (checked October 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L219 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L2982-L2984. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L3955-L3967 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4152-L4153. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4160-L4194 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4824-L4875. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4963-L4969 - OpenSearch BlobStoreRepository reuse of a file already in the shard's snapshots and skipped write for a file whose stored hash equals its contents, tags 3.5.0 and 3.9.0 (checked October 2026). OpenSearch 3.5.0 and 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4038-L4068 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4043-L4073 - OpenSearch 2.10.0 release notes: core crypto library, encryption-sdk and crypto-kms additions (checked October 2026). OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/release-notes/opensearch.release-notes-2.10.0.md#L27 and https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/release-notes/opensearch.release-notes-2.10.0.md#L33. More OpenSearch 2.10.0 sources: https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/release-notes/opensearch.release-notes-2.10.0.md#L35 - OpenSearch 2.9.0 directory listings without the crypto packages, EncryptedBlobStore and crypto-kms entries (checked October 2026). OpenSearch 2.9.0 sources: https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/action/admin/cluster and https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/common/blobstore. More OpenSearch 2.9.0 sources: https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch and https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/plugins - OpenSearch BlobStoreProvider blobStore(), createBlobStore() wrap and initBlobStore(), tag 3.4.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreProvider.java#L41-L85 - OpenSearch BlobStoreProvider blobStore(), createBlobStore() wrap and initBlobStore(), tag 3.9.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreProvider.java#L44-L96 - OpenSearch EncryptedBlobContainer encrypted writes and CryptoMetadata forwarding, tag 3.5.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L94-L170 - OpenSearch EncryptedBlobContainer CryptoMetadata forwarding, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L147-L170 - OpenSearch BlobStoreRepository file without a native-store reference, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1-L4970 - OpenSearch BlobStoreRepository file without a native-store reference, IndexService warm remote-store getNativeStore() call, GCS and Azure blob container creation, GCS writeBlobWithMetadata override, BlobContainer CryptoMetadata default, AbstractBlobContainer body and CryptoMetadata.fromIndexSettings, tag 3.9.0 (checked October 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1-L4970 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/index/IndexService.java#L794-L812. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/index/IndexService.java#L1429-L1440 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L137-L140. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobStore.java#L173-L176 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L201-L212 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/support/AbstractBlobContainer.java#L43-L56. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/cluster/metadata/CryptoMetadata.java#L114-L124 - OpenSearch GCS and Azure blob container creation, GCS writeBlobWithMetadata override, BlobContainer CryptoMetadata default, AbstractBlobContainer body, AzureBlobContainer superclass, Azure CryptoMetadata rejection, EncryptedBlobStore container wrap and CryptoMetadata.fromIndexSettings, tag 3.5.0 (checked October 2026). OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L137-L140 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobStore.java#L173-L176. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L200-L211. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/support/AbstractBlobContainer.java#L43-L56 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L63. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L140-L157 and https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L60-L67. More OpenSearch 3.5.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/cluster/metadata/CryptoMetadata.java#L114-L124 - OpenSearch AzureBlobContainer superclass, Azure CryptoMetadata rejection, EncryptedBlobStore container wrap and EncryptedBlobContainer CryptoMetadata forwarding, tag 3.9.0 (checked October 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L63 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L140-L157. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L60-L67 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L147-L170 - OpenSearch GCS writeBlobWithMetadata override, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109 - OpenSearch BlobContainer CryptoMetadata default, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L201-L212 - OpenSearch IndexService warm remote-store getNativeStore() call, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/index/IndexService.java#L793-L811, https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/index/IndexService.java#L1428-L1439 - OpenSearch GCS native-store construction, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L132-L140 - OpenSearch Azure native-store construction, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L160-L168 - OpenSearch GCS writeBlobWithMetadata override, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109 - OpenSearch BlobContainer CryptoMetadata default, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L201-L212 - OpenSearch IndexService warm remote-store getNativeStore() call, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/index/IndexService.java#L794-L812, https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/index/IndexService.java#L1429-L1440 - OpenSearch Repository getNativeStore() default, BlobStoreRepository.blobStore() delegation to BlobStoreProvider, and GCS and Azure native-store provider configuration and Rust builders, tag 3.7.0 (checked October 2026). OpenSearch 3.7.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/repositories/Repository.java#L685-L694 and https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1063-L1085. More OpenSearch 3.7.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-gcs/src/main/java/org/opensearch/repositories/gcs/native_store/GcsNativeObjectStorePlugin.java#L118-L125. More OpenSearch 3.7.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-azure/src/main/java/org/opensearch/repositories/azure/native_store/AzureNativeObjectStorePlugin.java#L124-L143 and https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-gcs/src/main/rust/src/gcs.rs#L40-L59. More OpenSearch 3.7.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-azure/src/main/rust/src/azure.rs#L43-L64 - OpenSearch Repository getNativeStore() default and GCS and Azure native-store provider configuration, tag 3.8.0 (checked October 2026). OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/Repository.java#L685-L694 and https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-gcs/src/main/java/org/opensearch/repositories/gcs/native_store/GcsNativeObjectStorePlugin.java#L118-L125. More OpenSearch 3.8.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-azure/src/main/java/org/opensearch/repositories/azure/native_store/AzureNativeObjectStorePlugin.java#L124-L143 - OpenSearch native-repository-gcs and native-repository-azure provider credential source comments, store creation, credential-provider argument and configuration, under sandbox/plugins, and the Azure client account setting declaration, tag 3.9.0 (checked November 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/sandbox/plugins/native-repository-gcs/src/main/java/org/opensearch/repositories/gcs/native_store/GcsNativeObjectStorePlugin.java#L29-L125 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/sandbox/plugins/native-repository-azure/src/main/java/org/opensearch/repositories/azure/native_store/AzureNativeObjectStorePlugin.java#L29-L143. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureStorageSettings.java#L71-L76 - OpenSearch GCS and Azure native-store construction, tag 3.9.0 (checked November 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L132-L140 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L160-L168 - OpenSearch crypto-kms provider name and type and getSettings(), KmsClientSettings setting keys and KmsService setting keys, tag 3.9.0 (checked November 2026). OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/CryptoKmsPlugin.java#L27-L74 and https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/KmsClientSettings.java#L33-L65. More OpenSearch 3.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/KmsService.java#L53-L55 - OpenSearch S3 native-store construction, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/S3Repository.java#L485-L493 - OpenSearch S3 SseKmsUtil server-side encryption selection, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/utils/SseKmsUtil.java#L41-L59 - OpenSearch native-repository-s3 provider configuration, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-s3/src/main/java/org/opensearch/repositories/s3/native_store/S3NativeObjectStorePlugin.java#L135-L185 - OpenSearch native-repository-s3 Rust builder SSE-KMS and bucket-key calls, tag 3.7.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs#L95-L97 - OpenSearch native-repository-s3 provider configuration, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-s3/src/main/java/org/opensearch/repositories/s3/native_store/S3NativeObjectStorePlugin.java#L135-L185 - OpenSearch native-repository-s3 Rust builder SSE-KMS and bucket-key calls, tag 3.8.0 (checked October 2026): https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs#L113-L121 - OpenSearch 2.9.0 BlobStoreRepository.blobStore(), PutRepositoryRequest source parsing and RepositoryMetadata fields without crypto handling (checked October 2026). OpenSearch 2.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L738-L759. More OpenSearch 2.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/action/admin/cluster/repositories/put/PutRepositoryRequest.java#L215-L230. More OpenSearch 2.9.0 sources: https://github.com/opensearch-project/OpenSearch/blob/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/cluster/metadata/RepositoryMetadata.java#L54-L66 - Elasticsearch 8.19 GCS repository: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-gcs.html - Elasticsearch 8.19 Azure repository: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-azure.html - OpenSearch 3.6 snapshot/repository workflow: https://docs.opensearch.org/3.6/tuning-your-cluster/availability-and-recovery/snapshots/snapshot-restore/ - AWS S3 default encryption and retained objects: https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html - AWS SSE-KMS permissions: https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html - GCS encryption distinctions (checked October 2026): https://docs.cloud.google.com/storage/docs/encryption - Azure Storage encryption (checked October 2026): https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption - Elasticsearch 8.19 snapshot creation and configuration backups: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-take-snapshot.html - Elasticsearch 8.19 repository integrity: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshot-restore.html - Elasticsearch 8.19 restore: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-restore-snapshot.html - Kibana 8.19 settings: https://www.elastic.co/guide/en/kibana/8.19/settings.html - Kibana 8.19 Docker defaults and overrides: https://www.elastic.co/guide/en/kibana/8.19/docker.html - Dashboards configuration, tag 3.8.0: https://raw.githubusercontent.com/opensearch-project/OpenSearch-Dashboards/3.8.0/config/opensearch_dashboards.yml - Dashboards 3.6 TLS and cookie settings: https://docs.opensearch.org/3.6/install-and-configure/install-dashboards/tls/ - Elasticsearch 8.19 create index: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/indices-create-index.html - Elasticsearch 8.19 index document: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/docs-index_.html - Elasticsearch 8.19 search: https://www.elastic.co/guide/en/elasticsearch/reference/8.19/search-search.html - OpenSearch 3.6 create index: https://docs.opensearch.org/3.6/api-reference/index-apis/create-index/ - OpenSearch 3.6 index document: https://docs.opensearch.org/3.6/api-reference/document-apis/index-document/ - OpenSearch 3.6 search: https://docs.opensearch.org/3.6/api-reference/search-apis/search/ - AWS CLI HeadObject: https://docs.aws.amazon.com/cli/latest/reference/s3api/head-object.html - AWS CLI GetObject: https://docs.aws.amazon.com/cli/latest/reference/s3api/get-object.html - Mount inspection: https://man7.org/linux/man-pages/man8/findmnt.8.html - Block-device inspection: https://man7.org/linux/man-pages/man8/lsblk.8.html - Encryption-mapping inspection: https://man7.org/linux/man-pages/man8/cryptsetup-status.8.html - systemd mount dependencies and assertions: https://man7.org/linux/man-pages/man5/systemd.unit.5.html - systemd start/status commands: https://man7.org/linux/man-pages/man1/systemctl.1.html - curl options and file input: https://curl.se/docs/manpage.html - Listener inspection: https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html - Elasticsearch network setting definitions: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L28-L42), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L28-L42). - Elasticsearch default bind fallback: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L112-L121), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L112-L121); loopback token resolution: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L233-L239), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L233-L239). - Elasticsearch listener callers that apply the fallback: HTTP [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/http/AbstractHttpServerTransport.java#L132-L140), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/http/AbstractHttpServerTransport.java#L136-L144); transport profiles [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/transport/TcpTransport.java#L1073-L1075), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/transport/TcpTransport.java#L1079-L1081). - Elasticsearch loopback addresses on interfaces that are up, without an address-family filter: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkUtils.java#L142-L173), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkUtils.java#L147-L178). - Elasticsearch HTTP host/bind/publish settings and `9200-9300` port default: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/http/HttpTransportSettings.java#L70-L89), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/http/HttpTransportSettings.java#L64-L83). - Elasticsearch transport host/bind/publish settings and `9300-9399` port default: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/transport/TransportSettings.java#L34-L62), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/transport/TransportSettings.java#L34-L57). - Elasticsearch auto-configuration HTTP and transport address guards, writes, and enrollment condition: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L788-L821), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L800-L833); remote-peer test: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L876-L891), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L888-L903). - Elasticsearch standard configuration leaves network and HTTP settings commented: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/distribution/src/config/elasticsearch.yml#L51-L63), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/distribution/src/config/elasticsearch.yml#L51-L63); Docker configuration sets `network.host: 0.0.0.0`: [8.19.22](https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/distribution/docker/src/docker/config/elasticsearch.yml#L1-L2), [9.5.4](https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/distribution/docker/src/docker/config/elasticsearch.yml#L1-L2). - Kibana host and port schema defaults: [9.5.4](https://github.com/elastic/kibana/blob/abf4eb613d64cdb3a6290671ce1cb67399677ab3/src/core/packages/http/server-internal/src/http_config.ts#L146-L155); sample configuration: [8.19.22](https://github.com/elastic/kibana/blob/fd8e6f336cd7a18d0fd805de4608e1e38e0da163/config/kibana.yml#L4-L11), [9.5.4](https://github.com/elastic/kibana/blob/abf4eb613d64cdb3a6290671ce1cb67399677ab3/config/kibana.yml#L4-L11). - Kibana Docker wildcard host configuration: [8.19.22](https://github.com/elastic/kibana/blob/fd8e6f336cd7a18d0fd805de4608e1e38e0da163/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts#L22-L26), [9.5.4](https://github.com/elastic/kibana/blob/abf4eb613d64cdb3a6290671ce1cb67399677ab3/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts#L22-L26). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - es-api: Elasticsearch API (8.x) - es819: Elasticsearch reference (8.19) - es8: Elasticsearch listener source (8.19.22) - es9: Elasticsearch listener source (9.5.4) - os36: OpenSearch reference (3.6) - os31: OpenSearch S3 reference (3.1) - os38: OpenSearch S3 source (3.8.0) - gcs38: OpenSearch GCS source (3.8.0) - azure38: OpenSearch Azure source (3.8.0) - corecrypto38: OpenSearch repository crypto source (3.8.0) - dash: OpenSearch Dashboards (3.8.0) - dash36: Dashboards TLS reference (3.6) - es: Elasticsearch documentation (unknown) - images: Elasticsearch Docker library (8480a06d8ca03ca7da29a469fdbddb5f9f31df61) - os: OpenSearch documentation (unknown) - os-license-source: OpenSearch Security licence source (4ba3cc0e2ebec61b726b227fdf51e55f4a502182) - os-users-source: OpenSearch demo users source (03a224d16045a8f561e2d7d84b8765c95309524c) - kb: Kibana reference (8.19) - kb8: Kibana source (8.19.22) - kb9: Kibana source (9.5.4) - cloud: Cloud provider documentation (unknown) - host: Host inspection documentation (unknown) - corecrypto210: OpenSearch repository crypto source (2.10.0) - corecrypto300: OpenSearch repository crypto source (3.0.0) - corecrypto290: OpenSearch crypto directory source (2.9.0) - repo290: OpenSearch repository source (2.9.0) - corecrypto332: OpenSearch repository crypto source (3.3.2) - corecrypto340: OpenSearch repository crypto source (3.4.0) - corecrypto350: OpenSearch repository crypto source (3.5.0) - corecrypto370: OpenSearch repository crypto source (3.7.0) - corecrypto390: OpenSearch repository crypto source (3.9.0) Claims (full source mapping in the JSON block below): - es-tier (REASONED; es): Self-managed 8.x TLS, native users, RBAC, REST keys, anonymous controls and CORS are free Basic; LDAP/audit and FLS/DLS require Platinum/Enterprise, with current Platinum restricted to existing customers. - es-auto (REASONED; es): Eligible initial startup enables auth, HTTP/transport TLS and elastic password; redirected output, unwritable config or existing settings can skip setup, with stated discovery exceptions; packages require password reset. - es-tls (REASONED; es): Keep xpack.security.enabled and HTTP TLS enabled; repair CA trust/certificates instead of disabling protection. - es-bind (REASONED; es8, es9): network.host defaults `_local_` on up interfaces across available families; family-specific tokens and HTTP/profile bind overrides apply before network fallback. - es-http-range (REASONED; es8, es9): HTTP defaults to an available port in 9200-9300, subject to effective host/bind/publish settings. - es-transport-range (REASONED; es8, es9): Transport defaults to an available port in 9300-9399; inspect effective profile and actual listener ports. - es-auto-http (REASONED; es8, es9): Auto-configuration writes http.host=0.0.0.0 only when none of the three HTTP or three network address settings has a value, including enrollment. - es-auto-transport (REASONED; es8, es9): Transport address guard respects existing settings; new-cluster wildcard is commented and enrollment activates it only for a discovered nonlocal, nonloopback publish address. - es-docker (REASONED; images, es): Pinned image map covers 8.19.22/9.4.6/9.5.3; shipped network.host=0.0.0.0 widens API and transport unless overridden. EXPOSE includes 9300, so avoid -P, host networking and untrusted peers. - es-source-docker (REASONED; es8, es9): Source configurations at 8.19.22/9.5.4 also set network.host wildcard, making both auto-configuration address guards false. - es-role (REASONED; es-api): Give each app an index-read role and dedicated native user; manage_own_api_key permits self-issued keys without broader index access, and superuser is not an app role. - credential-files (REASONED; host): Use protected 0600 header/request/response files and verified CA input; base64 Basic is not encryption and file input does not prevent file readers or tracing. - es-key (REASONED; es-api): Explicit API-key role descriptors intersect creator permissions; omitted descriptors inherit a snapshot, omitted expiration never expires, and an API key cannot create another privileged key. - es-ldap (REASONED; es): Optional paid LDAP mapping binds the specific realm and group DN; native-user roles are assigned through the user API. - es-fls (REASONED; es819): 8.19 field_security.grant restricts concrete fields, not aliases; metadata remains accessible and omission grants all fields. Minimum guide scope 8.0 is not an introduction claim. - es-dls (REASONED; es819): 8.19 query restricts documents; omission removes DLS. Keep tenant readers read-only and remove unrestricted roles that combine to defeat restrictions. - es-fls-limits (REASONED; es819): DLS has aggregate/scoring inference channels and query/API restrictions including joins, lookups, percolate and date now; test actual queries and prefer separate indices for stronger isolation. - es-key-review (REASONED; es819): Review previously issued key permission snapshots separately after changing roles; role edits alone do not establish repaired key restrictions. - es-anonymous (REASONED; es): Remove anonymous.roles from every effective node configuration and restart; no roles disables anonymous access. A tested 401 alone cannot prove it is globally off. - es-cors (REASONED; es): CORS defaults disabled; if needed configure exact origin, methods and headers, restart nodes and retain authorization. - es-audit (REASONED; es): Audit defaults disabled; enable on each 8.x node with restart and collect CLUSTERNAME_audit.json independently. Dynamic enablement documented for 9.5+ does not apply to 8.x. - es-audit-body (REASONED; es): Request-body emission defaults false; keep it off and avoid event exclusions/ignore policies suppressing authentication_failed. - os-tier (REASONED; os-license-source): OpenSearch Security is free Apache-2.0 with no paid security tier; keep authentication and TLS enabled. - os-demo-users (REASONED; os, os-users-source): From 2.12 demo setup requires initial admin password but only changes admin; six other seeded users retain demo credentials until removed/replaced and applied to the live security index. - os-tls (REASONED; os): Replace demo certificates; REST TLS defaults false underneath demo setup and requires plugins.security.ssl.http.enabled=true with production certificates. - os-rbac (REASONED; os): Create a custom index-read role and internal user with explicit mapping; Security REST administration requires its own authorization and other mappings may add grants. - os-api-key (REASONED; os): Native scoped keys require 3.7+ and security administrators; enable api_tokens and explicit lifetime/count limits, using index_pattern and duration_seconds rather than Elasticsearch fields. - os-key-default (REASONED; os): Omitted token duration uses max_duration_seconds, documented default 7776000; example caps at 86400 with 100 tokens, and returned token is retrievable only once. - os-apply (REASONED; os): securityadmin applies local configuration to the live index; back up first, use file/type-limited upload, production CA/admin certificate and hostname verification because stale files overwrite live changes. - os-fls (REASONED; os36): 3.6 FLS/DLS recipe uses JSON-string dls and visible tenant_id in fls; review all roles and inclusion/exclusion interactions, not Elasticsearch role-combination assumptions. - os-dls (REASONED; os36): dfm_empty_overrides_all affects unrestricted-role precedence; DLS does not constrain writes and adaptive filter-level DLS limits APIs/cross-cluster search. Tokens need independent restriction tests. - os-anonymous (REASONED; os): Set live anonymous_auth_enabled=false while preserving authentication domains; update through securityadmin, not just local YAML. - os-cors (REASONED; os): CORS defaults false; exact-origin static settings go in opensearch.yml and require restart, independently of authentication. - os-audit (REASONED; os): Auditing needs destination and enabled config; internal_opensearch stores daily audit indexes, while shipped enabled=true alone records nothing without storage type. - os-audit-body (REASONED; os): Read live audit config, enable REST and disable request-body logging via admin-certificate PATCH; preserve defaults and do not suppress FAILED_LOGIN. - os-audit-headers (REASONED; os): exclude_sensitive_headers defaults true; readonly REST fields require complete audit-file application. Read back enabled, REST, body=false and headers=true separately. - storage (REASONED; es819, os): Use OS/provider encryption below path.data, including logs/swap/local snapshots; path.data only selects a directory and encryption-at-rest support is not a product encryption switch. - mount-enforcement (REASONED; host): RequiresMountsFor plus AssertPathIsMountPoint prevents ordinary-directory fallback with systemd; reload manager and verify encrypted device chain separately. - snapshot-boundary (REASONED; es, os31, cloud): Ordinary snapshots use free Basic/OpenSearch; remote repository encryption is independent of node disks and authorized server-side downloads are decrypted. - cloud-defaults (REASONED; cloud): New AWS S3, GCS and Azure objects are encrypted server-side by default; assess historical objects and customer key ownership separately. - es-s3 (REASONED; es819): 8.19 server_side_encryption defaults false; true requests AES256 SSE-S3, not client encryption. Omit it when using bucket-default KMS and verify resulting metadata. - os-s3 (REASONED; os31, os38): 3.1 removed server_side_encryption; server_side_encryption_type accepts AES256/aws:kms/bucket_default. Default changed from bucket_default to AES256 in 3.8; explicitly select intended mode. - os-s3-kms (REASONED; os31, cloud, corecrypto370, corecrypto38): Explicit KMS repository mode uses server_side_encryption_kms_key_id ARN with object/KMS permissions; this is server-side encryption and settings are not Elasticsearch-compatible. At 3.7.0 and 3.8.0 the sandbox native S3 provider's only SSE inputs are that key and the bucket-key flag (default true), not the encryption type. - es-gcs-azure (REASONED; es819): 8.19 GCS/Azure setting lists document no client-side-encryption switch; configure provider key policy and access. GCS supplied keys remain server-side and Azure keys/SAS are authentication. - os-gcs-azure (REASONED; os36, cloud): Follow the OpenSearch 3.6 plugin/credential workflow, including Azure; this guide's advice is to configure required customer-managed encryption through provider policy and grant key access. Do not infer broader absence from Elasticsearch settings or failed source retrieval. - os-gcs-azure-settings (REASONED; gcs38, azure38): This guide's review of OpenSearch 3.8.0 GCS/Azure plugin-specific settings, blob upload and client construction found no plugin-specific client-side-encryption setting. - os-gcs-azure-core (REASONED; gcs38, azure38, corecrypto38): In the cited OpenSearch 3.8.0 core path, crypto_settings triggers client-side EncryptedBlobStore wrapping of a non-server-side-encryption store and encryption before writes. Common provider and GCS/Azure createBlobStore methods show a path to both types and Azure rejects per-object CryptoMetadata; this guide assesses end-to-end applicability and live behaviour as unestablished. - os-gcs-azure-providers (REASONED; corecrypto38, corecrypto390): The cited 3.8.0 path requires key_provider_name, key_provider_type and a registered provider of that type; the tree includes aws-kms, whose 3.9.0 getSettings() lists only kms.* settings. This guide's assessment: deployed providers, end-to-end applicability, live behaviour, a universal GCS/Azure minimum and unchecked-release behaviour remain unestablished. - os-gcs-azure-versions (REASONED; corecrypto210, corecrypto300, corecrypto290, corecrypto332, corecrypto340, corecrypto390, corecrypto38): This guide's assessment from pinned 2.10.0/3.0.0 methods, 2.10.0 release notes and 2.9.0 listings without crypto entries: the checked range with repository crypto settings starts at 2.10.0. Across 31 tags to 3.9.0, blobStore() wraps through 3.3.2 and from 3.4.0 delegates to BlobStoreProvider, which wraps; other releases and a universal deployed GCS/Azure minimum remain unestablished. - os-gcs-azure-path (REASONED; corecrypto210, corecrypto300): At 2.10.0/3.0.0, GCS/Azure extend MeteredBlobStoreRepository, an abstract BlobStoreRepository subclass declaring neither blobStore() nor createBlobStore, and implement createBlobStore; BlobStoreRepository.blobStore() wraps its result when crypto metadata is non-null. This guide infers both reach the wrap in source; deployed encryption, installed providers and unchecked releases are unestablished. - os-gcs-azure-blobstore (REASONED; corecrypto210, corecrypto300, corecrypto332, corecrypto340, corecrypto370, corecrypto390, corecrypto38): At every checked tag 2.10.0 to 3.9.0, the pinned GoogleCloudStorageRepository, AzureRepository and MeteredBlobStoreRepository class bodies declare no blobStore() method; AzureRepository overrides getBlobStore() only to return super.getBlobStore(). This guide's inference that GCS and Azure reach the encryption wrap also relies on that absence and, from 3.4.0, on BlobStoreProvider's wrap. - os-gcs-azure-range (REASONED; corecrypto210, corecrypto300, corecrypto332, corecrypto340, corecrypto390, corecrypto38): At all 25 checked OpenSearch tags 2.10.0 to 3.3.2, BlobStoreRepository.blobStore() has the same body, wrapping the createBlobStore() result in EncryptedBlobStore when crypto metadata is non-null. From 3.4.0 to 3.9.0 it returns blobStore(false) via BlobStoreProvider, which wraps likewise with server-side encryption off (source reading at 3.4.0, 3.8.0, 3.9.0; 3.5.0 to 3.7.0 share 3.4.0's blob ID). - os-gcs-azure-native (REASONED; corecrypto370, corecrypto390): From 3.7.0 to 3.9.0, the checked GoogleCloudStorageRepository and AzureRepository also override getNativeStore() to return a native object store that the constructor sets when a native provider supplies a live one. Deployed use of that path, and its relation to the EncryptedBlobStore wrap at 3.8.0, are not established here. - os-gcs-azure-index (REASONED; corecrypto350, corecrypto390): At checked OpenSearch tags 3.5.0 to 3.9.0 only, BlobStoreRepository passes CryptoMetadata from index settings to writeBlobWithMetadata for shard files, commented as an index-level SSE-KMS override; repository metadata feeds BlobStoreProvider separately. Source reading, no run: its effective behaviour is unestablished. - os-gcs-azure-index-wrap (REASONED; corecrypto350, corecrypto38): From source reading without a run, EncryptedBlobContainer's writeBlobWithMetadata form taking CryptoMetadata, read at 3.5.0 and 3.8.0 and sharing 3.8.0's git blob ID at 3.6.0, 3.7.0 and 3.9.0, encrypts with the repository-level crypto handler and passes the index-level CryptoMetadata unchanged to the wrapped container. - os-gcs-azure-index-azure (REASONED; azure38, corecrypto350, corecrypto38, corecrypto390): From source reading without a run, AzureBlobContainer, with one git blob ID at every tag 3.5.0 to 3.9.0, throws UnsupportedOperationException when writeBlobWithMetadata receives non-null CryptoMetadata. From source reading without a run, an index-level value reaching AzureBlobContainer, directly or through EncryptedBlobContainer, therefore ends that shard-file write with the exception in source. - os-gcs-azure-index-gcs (REASONED; corecrypto350, corecrypto370, corecrypto38, corecrypto390): From source reading without a run at 3.5.0, 3.7.0, 3.8.0 and 3.9.0, with 3.6.0 by git blob ID, GoogleCloudStorageBlobContainer overrides only the writeBlobWithMetadata form without CryptoMetadata, which calls writeBlob. BlobContainer's default for the CryptoMetadata form calls that form, so GCS drops the index-level CryptoMetadata at every tag 3.5.0 to 3.9.0. - os-gcs-azure-index-setting (REASONED; corecrypto350, corecrypto390): From source reading at 3.5.0 and 3.9.0, CryptoMetadata.fromIndexSettings returns null unless the index setting index.store.crypto.key_provider is set, with key_provider_type defaulting to aws-kms, so in the snapshot path only such indices pass a non-null index-level value to writeBlobWithMetadata. A text search of the 3.9.0 main sources read here finds no registration of it. - os-gcs-azure-index-advice (REASONED; corecrypto350, corecrypto390): This guide's advice, from source reading at 3.5.0 and 3.9.0 without a run: do not rely on index.store.crypto.key_provider to encrypt GCS/Azure snapshots. Shard-file writes pass its CryptoMetadata to the container, which Azure rejects with UnsupportedOperationException, directly or through EncryptedBlobContainer, and GCS drops; settability and effective behaviour are unestablished. - os-gcs-azure-native-use (REASONED; corecrypto370, corecrypto38, corecrypto390): From source reading without a run, the GCS and Azure native providers pass no crypto settings at 3.7.0, 3.8.0 and 3.9.0. This guide infers that at 3.7.0 and 3.9.0 the native store, which IndexService resolves for a warm index's remote store repository, is built outside BlobStoreProvider's wrap; what is written through it is unestablished. - os-gcs-azure-native-caller (REASONED; corecrypto370, corecrypto38, corecrypto390): From source reading without a run, IndexService is the only non-test getNativeStore() caller found in searches of the 3.7.0 and 3.8.0 archives and, at 3.9.0, in a text search of sandbox/plugins/, plugins/, modules/, server/src/main/ and libs/common/src/main/. Callers outside those 3.9.0 paths, in external plugins or by reflection are not excluded. - os-gcs-azure-pre210 (REASONED; repo290, corecrypto290): From source reading, git tree listings at the 41 OpenSearch release tags 1.0.0 to 2.9.0 contain no EncryptedBlobStore, CryptoSettings or crypto-kms path; at 2.9.0, blobStore() returns createBlobStore() unwrapped, PutRepositoryRequest parses only type and settings, and RepositoryMetadata has no crypto field. - os-gcs-azure-validate (REASONED; corecrypto210, corecrypto300, corecrypto38): From source reading and git blob IDs, CryptoSettings.java at 2.11.0 and 2.11.1 matches the cited 2.10.0 file and at every tag 2.12.0 to 3.9.0 the cited 3.0.0 and 3.8.0 files, so validate() requires key_provider_name and key_provider_type at all 31 checked tags 2.10.0 to 3.9.0; no run. - snapshot-integrity (REASONED; es819): Keep repositories private and keys recoverable; do not rewrite active repository files. Configuration backups are separate and security-state snapshots are sensitive. - kibana-default (REASONED; kb, kb8, kb9): Kibana defaults HTTP localhost:5601 with server TLS disabled; 8.19.22/9.5.4 Docker templates explicitly use wildcard host, requiring override/publication review. - kibana-tls (REASONED; kb): Free Basic 8.19 server.ssl certificate/key protects incoming UI traffic; elasticsearch.ssl CA/full verification protects the separate backend hop. - dash-default (REASONED; dash): Dashboards 3.8.0 sample defaults localhost:5601 with server TLS disabled; inspect installation/container overrides. - dash-tls (REASONED; dash36): Dashboards server.ssl protects UI, cookie.secure restricts session cookie to HTTPS, and opensearch.ssl CA/full verification protects the backend hop; restart after changes. - ui-network (REASONED; kb, dash36): UI binding is independent of 9200/9300; loopback suits only a same-namespace proxy, while private access requires intended interface, matching certificates and network restrictions. - verify-rbac (REASONED; es, os): Known app-data/other-data documents distinguish broad access from scoped 200/403 for user and key; 401/404/errors are inconclusive and pre-3.7 OpenSearch uses the user only. - verify-anonymous (REASONED; es, os): Require effective anonymous settings on every node plus a formerly anonymous-readable known document becoming 401 while admin still gets 200; endpoint-specific 401 alone is insufficient. - verify-tls (REASONED; es, os): Admin HTTPS must return 200/cluster JSON before interpreting plaintext; TLS listener must return no HTTP status, with DNS/timeouts/unreachable states inconclusive. - verify-cors (REASONED; es, os): Inspect preflight headers: no grant to untrusted origin, exact grant for trusted enabled origin; with CORS disabled neither is granted. - verify-audit (REASONED; es, os): Fresh failed-authentication probe must correlate with new authentication_failed/FAILED_LOGIN in node and independent collector records; 401 alone proves no delivery. - verify-audit-body (REASONED; es, os): Compare unique marker present with body logging enabled and absent from a new matching event when disabled; independently verify header exclusion and effective configuration. - verify-listeners (REASONED; es8, es9, host): Locate actual running listeners/ports and inspect publication; intentional bridge-container wildcard differs from host-network exposure and no output alone is inconclusive. - verify-fls-dls (REASONED; es819, os36): Two-tenant fixture compares unrestricted and actual restricted credentials through search, GET, requested fields and aggregations, with allowed-field and administrator controls; no inference-proof claim. - verify-storage (REASONED; host): Data-path device chain must traverse intended encryption mapping; unrelated mapping or successful unlocked reads proves nothing, and provider volumes need their own metadata. - verify-mount (REASONED; host): Isolated unavailable-mount fixture must fail startup for that reason without writes to the ordinary directory; restored encrypted mount must start successfully. - verify-snapshot (REASONED; es819, os36): Harmless explicit-index snapshot/restore must complete and preserve both fixtures without global/security state; timeouts need operation-state inspection before retry. - verify-object-key (REASONED; cloud): Inspect actual new/retained repository object mode/key; controlled object-read and KMS-only denial need provider evidence plus authorized controls. Repository verify proves neither encryption nor isolation. - verify-ui (REASONED; kb, dash36, host): Require expected CA-validated HTTPS and rejected plaintext UI after an exposed HTTP fixture; verify prohibited-network denial with authorized success and effective private binding. - verify-ui-backend (REASONED; kb, dash36): Browser TLS does not prove backend full verification or Dashboards Secure cookie; inspect both separately with effective settings and publication. ````json { "schema": 1, "checked": "2026-10-22", "documentation_checked": "2026-09", "body_sha256": "88051e81b7452bd74ca2783ee714ad6ac4952c3a3c1872006309dd77fb3df0ec", "components": { "es-api": { "name": "Elasticsearch API", "basis": "8.x", "sources": { "s088db4f93f33": "https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role", "s0c10cfba4d6b": "https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role-mapping", "sb82b2da8da5d": "https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-user", "sbec1f2938348": "https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-create-api-key" } }, "es819": { "name": "Elasticsearch reference", "basis": "8.19", "sources": { "s805957cad810": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-level-security.html", "s52adbc714b10": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/document-level-security.html", "s15feb5b3e00c": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/field-and-document-access-control.html", "se332a9504a82": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/security-limitations.html", "s03b37cd41154": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/tune-for-search-speed.html", "s1ce03cd781c3": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/path-settings-overview.html", "s3cac63e6ba14": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-s3.html", "s28a1fe315963": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-gcs.html", "s6279b842bc10": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/repository-azure.html", "sb98bd971e2f6": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-take-snapshot.html", "s93987af8201d": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshot-restore.html", "s3e5630e4430d": "https://www.elastic.co/guide/en/elasticsearch/reference/8.19/snapshots-restore-snapshot.html" } }, "es8": { "name": "Elasticsearch listener source", "basis": "8.19.22", "sources": { "sa56cdc9270ea": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L112-L121", "s46c7adfdc6c4": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L233-L239", "s8ca2413b2af3": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L28-L42", "s59c2dd10f083": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/http/HttpTransportSettings.java#L70-L89", "s0310c5c2e42f": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/server/src/main/java/org/elasticsearch/transport/TransportSettings.java#L34-L62", "s6e6cf4d739bd": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L788-L821", "s70c493fe7770": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L876-L891", "s8d4c513fdf84": "https://github.com/elastic/elasticsearch/blob/3b2a41103de35e0af4064d647974032fcc1bcde9/distribution/docker/src/docker/config/elasticsearch.yml#L1-L2" } }, "es9": { "name": "Elasticsearch listener source", "basis": "9.5.4", "sources": { "s666c63ad2a02": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L112-L121", "s429c675c1092": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L233-L239", "sa27fc323d90c": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/common/network/NetworkService.java#L28-L42", "s8fded6a7640c": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/http/HttpTransportSettings.java#L64-L83", "s1f0a06e68675": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/server/src/main/java/org/elasticsearch/transport/TransportSettings.java#L34-L57", "scf100fafec80": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L800-L833", "s0ba542d80d53": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/AutoConfigureNode.java#L888-L903", "sa4fd281c6186": "https://github.com/elastic/elasticsearch/blob/9170df19cae1adb107b7b489b4d82dec66d7a337/distribution/docker/src/docker/config/elasticsearch.yml#L1-L2" } }, "os36": { "name": "OpenSearch reference", "basis": "3.6", "sources": { "s42d975e3cd92": "https://docs.opensearch.org/3.6/security/access-control/field-level-security/", "sade7ff6b2bb3": "https://docs.opensearch.org/3.6/security/access-control/document-level-security/", "sf0940d73ed6e": "https://docs.opensearch.org/3.6/tuning-your-cluster/availability-and-recovery/snapshots/snapshot-restore/" } }, "os31": { "name": "OpenSearch S3 reference", "basis": "3.1", "sources": { "s089decb3330d": "https://docs.opensearch.org/3.1/api-reference/snapshots/create-repository/" } }, "os38": { "name": "OpenSearch S3 source", "basis": "3.8.0", "sources": { "sc57180db39be": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/S3Repository.java" } }, "gcs38": { "name": "OpenSearch GCS source", "basis": "3.8.0", "sources": { "s631e8e354e26": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStoragePlugin.java#L125-L144", "s9ff434d5553c": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L74-L84", "s7e94d736aed9": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L259-L266", "s1f1e29ea0106": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L289-L291", "sfe65ec0f6f8c": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L370-L373", "s90d52029d8d8": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L147-L150", "sfa59a5382e57": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageService.java#L247-L285" } }, "azure38": { "name": "OpenSearch Azure source", "basis": "3.8.0", "sources": { "s85d096884518": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepositoryPlugin.java#L133-L153", "sc2902fffc253": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L75-L107", "s22bd450a8a30": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobStore.java#L351-L394", "sa4a6997abb47": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L140-L157", "s287622988f18": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L185-L200", "sdea961e1b3be": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureStorageService.java#L195-L239" } }, "corecrypto38": { "name": "OpenSearch repository crypto source", "basis": "3.8.0", "sources": { "sb8b00bcc45a2": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/action/admin/cluster/repositories/put/PutRepositoryRequest.java#L248-L271", "s6599b9d988eb": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/action/admin/cluster/crypto/CryptoSettings.java#L54-L63", "s72395c1b171c": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreProvider.java#L44-L96", "s85afe5ff10ee": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1073-L1085", "sa01a018a581d": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L39-L51", "s248fbcd97369": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L60-L67", "s0ed030669257": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L94-L110", "s726bdb2f7cab": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/crypto/CryptoHandlerRegistry.java#L144-L167", "s73ce68ed27f1": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/CryptoKmsPlugin.java#L26-L27", "sc99eb8d33be7": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L60", "sef269e173cbc": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L72", "s2ee18ff27a14": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49", "sb739f2d2b9bb": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1098", "s6624e4bb01fd": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L147-L170", "s5f9dc3a01b35": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109", "s9fafffa5e664": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L201-L212", "s87bc4fd690f0": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/index/IndexService.java#L794-L812", "s0099e29f7990": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/index/IndexService.java#L1429-L1440", "s367e63e71d2a": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/server/src/main/java/org/opensearch/repositories/Repository.java#L685-L694", "sc8836f82a384": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-gcs/src/main/java/org/opensearch/repositories/gcs/native_store/GcsNativeObjectStorePlugin.java#L118-L125", "s3926a11c10b5": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-azure/src/main/java/org/opensearch/repositories/azure/native_store/AzureNativeObjectStorePlugin.java#L124-L143", "s4a5aed4bf2ae": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-s3/src/main/java/org/opensearch/repositories/s3/native_store/S3NativeObjectStorePlugin.java#L135-L185", "s6f461674b296": "https://github.com/opensearch-project/OpenSearch/blob/3.8.0/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs#L113-L121" } }, "dash": { "name": "OpenSearch Dashboards", "basis": "3.8.0", "sources": { "sae1b7f23ca6c": "https://raw.githubusercontent.com/opensearch-project/OpenSearch-Dashboards/3.8.0/config/opensearch_dashboards.yml" } }, "dash36": { "name": "Dashboards TLS reference", "basis": "3.6", "sources": { "sea686fca5804": "https://docs.opensearch.org/3.6/install-and-configure/install-dashboards/tls/" } }, "es": { "name": "Elasticsearch documentation", "basis": "unknown", "sources": { "sad43b70af67a": "https://www.elastic.co/subscriptions", "s64e4e307f36c": "https://www.elastic.co/docs/deploy-manage/security/self-auto-setup", "se8bbf661f24e": "https://www.elastic.co/docs/reference/elasticsearch/command-line-tools/reset-password", "sce3095052f8d": "https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/security-settings", "s38b60fd8e2bf": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-elasticsearch-docker-configure", "sc3d6362bc8f4": "https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap", "s0c10cfba4d6b": "https://www.elastic.co/docs/api/doc/elasticsearch/v8/operation/operation-security-put-role-mapping", "s204396515260": "https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/anonymous-access", "s0f65658e7273": "https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/networking-settings", "sfa639f3e13a9": "https://www.elastic.co/docs/deploy-manage/monitor/stack-audit-logging/enabling-audit-logs", "s5603a653318d": "https://www.elastic.co/docs/reference/elasticsearch/configuration-reference/auding-settings", "s4fb9bf780cf5": "https://www.elastic.co/pdf/subscriptions-2025-07-29.pdf", "see6eaa520863": "https://www.elastic.co/docs/reference/elasticsearch/security-privileges", "sfc6fef9020b3": "https://www.elastic.co/docs/reference/elasticsearch/elasticsearch-audit-events" } }, "images": { "name": "Elasticsearch Docker library", "basis": "8480a06d8ca03ca7da29a469fdbddb5f9f31df61", "sources": { "s09177781dc63": "https://github.com/docker-library/official-images/blob/8480a06d8ca03ca7da29a469fdbddb5f9f31df61/library/elasticsearch#L2-L19", "sae45a2b7731f": "https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/config/elasticsearch.yml", "sab290cdf87a1": "https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/config/elasticsearch.yml", "s7088372053e9": "https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/config/elasticsearch.yml", "s0c3512f7e923": "https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/Dockerfile#L123", "s1683d032b409": "https://github.com/elastic/dockerfiles/blob/0208be14db8ebd2e40442d328e8f79921368c5a9/elasticsearch/Dockerfile#L38", "s6c51d507efec": "https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/Dockerfile#L57", "s5c4452cc7b82": "https://github.com/elastic/dockerfiles/blob/18bba4670aa9212cd0c5e8aeff5aa2f23d7f652a/elasticsearch/Dockerfile#L99", "s849d2f17dbc8": "https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/Dockerfile#L57", "s30233ea32bf6": "https://github.com/elastic/dockerfiles/blob/25fdea1109a231cedf9d1d3cf78bbb7fce488561/elasticsearch/Dockerfile#L99" } }, "os": { "name": "OpenSearch documentation", "basis": "unknown", "sources": { "s73e416ebf765": "https://docs.opensearch.org/latest/security/configuration/demo-configuration/", "s4e3281cb3d23": "https://docs.opensearch.org/latest/security/configuration/tls/", "s07ddf2781a70": "https://docs.opensearch.org/latest/security/access-control/users-roles/", "s204396515260": "https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/anonymous-access", "sc3d6362bc8f4": "https://www.elastic.co/docs/deploy-manage/users-roles/cluster-or-deployment-auth/ldap", "s0e349fff0934": "https://docs.opensearch.org/latest/security/api/roles/create-role/", "s4f63d2563816": "https://docs.opensearch.org/latest/security/api/users/create-user/", "s1a9aa4907daf": "https://docs.opensearch.org/latest/security/api/role-mappings/create-role-mapping/", "sfbdd4a41adf5": "https://docs.opensearch.org/latest/security/access-control/api-keys/", "sa596b3a14984": "https://docs.opensearch.org/latest/security/api/api-keys/create/", "s40051274ea31": "https://docs.opensearch.org/latest/security/configuration/security-admin/", "sc921fd158473": "https://docs.opensearch.org/latest/security/configuration/configuration/", "s4142fe7a4236": "https://docs.opensearch.org/latest/security/authentication-backends/basic-authc/", "s3f64f7b4cf84": "https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/network-settings/", "s2f39d5e48027": "https://docs.opensearch.org/latest/security/audit-logs/index/", "seafbd3b9677e": "https://docs.opensearch.org/latest/security/api/audit/patch-audit-configuration/", "s0d675c5ecd3c": "https://docs.opensearch.org/latest/security/api/audit/update-audit-configuration/", "seafc6e5e5595": "https://docs.opensearch.org/latest/security/api/audit/get-audit-configuration/", "s9ddd20a83871": "https://docs.opensearch.org/3.0/troubleshoot/#encryption-at-rest", "sbd093eef861b": "https://docs.opensearch.org/latest/security/access-control/permissions/", "s7d28cfa527a1": "https://docs.opensearch.org/latest/security/access-control/anonymous-authentication/", "sbad13e007c8f": "https://docs.opensearch.org/latest/security/audit-logs/field-reference/" } }, "os-license-source": { "name": "OpenSearch Security licence source", "basis": "4ba3cc0e2ebec61b726b227fdf51e55f4a502182", "sources": { "s6e10cf9ec35a": "https://github.com/opensearch-project/security/blob/4ba3cc0e2ebec61b726b227fdf51e55f4a502182/LICENSE.txt" } }, "os-users-source": { "name": "OpenSearch demo users source", "basis": "03a224d16045a8f561e2d7d84b8765c95309524c", "sources": { "se8c473c01153": "https://github.com/opensearch-project/security/blob/03a224d16045a8f561e2d7d84b8765c95309524c/config/internal_users.yml" } }, "kb": { "name": "Kibana reference", "basis": "8.19", "sources": { "s0fe3c0d2e958": "https://www.elastic.co/guide/en/kibana/8.19/settings.html", "sf71765842118": "https://www.elastic.co/guide/en/kibana/8.19/docker.html" } }, "kb8": { "name": "Kibana source", "basis": "8.19.22", "sources": { "sd77254a631b1": "https://github.com/elastic/kibana/blob/fd8e6f336cd7a18d0fd805de4608e1e38e0da163/config/kibana.yml#L4-L11", "s14662915ed7e": "https://github.com/elastic/kibana/blob/fd8e6f336cd7a18d0fd805de4608e1e38e0da163/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts#L22-L26" } }, "kb9": { "name": "Kibana source", "basis": "9.5.4", "sources": { "sd0f70697a909": "https://github.com/elastic/kibana/blob/abf4eb613d64cdb3a6290671ce1cb67399677ab3/src/core/packages/http/server-internal/src/http_config.ts#L146-L155", "s39aaa733de4b": "https://github.com/elastic/kibana/blob/abf4eb613d64cdb3a6290671ce1cb67399677ab3/src/dev/build/tasks/os_packages/docker_generator/templates/kibana_yml.template.ts#L22-L26" } }, "cloud": { "name": "Cloud provider documentation", "basis": "unknown", "sources": { "s710600aeb168": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html", "sdc6a5af591ff": "https://docs.cloud.google.com/storage/docs/encryption", "saeb8db497f20": "https://learn.microsoft.com/en-us/azure/storage/common/storage-service-encryption", "se1e1c91f80b4": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html", "se54783905d19": "https://docs.aws.amazon.com/cli/latest/reference/s3api/head-object.html", "sc53339f5429f": "https://docs.aws.amazon.com/cli/latest/reference/s3api/get-object.html" } }, "host": { "name": "Host inspection documentation", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html", "s64522c42b5bc": "https://man7.org/linux/man-pages/man5/systemd.unit.5.html", "s1b4e67e38127": "https://manpages.ubuntu.com/manpages/noble/man8/ss.8.html", "s42b4720299f5": "https://man7.org/linux/man-pages/man8/findmnt.8.html", "sd23c3d0f42a7": "https://man7.org/linux/man-pages/man8/lsblk.8.html", "s252144371961": "https://man7.org/linux/man-pages/man8/cryptsetup-status.8.html", "s9823c4e9efa4": "https://man7.org/linux/man-pages/man1/systemctl.1.html" } }, "corecrypto210": { "name": "OpenSearch repository crypto source", "basis": "2.10.0", "sources": { "s023fb522ed72": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/server/src/main/java/org/opensearch/action/admin/cluster/crypto/CryptoSettings.java#L52-L61", "scd89fded9c2c": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L764-L788", "s1753e87e88dc": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L56", "s0ead2431acaf": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L126-L129", "sc0efd9a1f6ae": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L68", "sf884a4d2d3de": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L164-L179", "sa2c0bdc87a61": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L56-L154", "sd5b6e9a0dcbf": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L68-L195", "s727ebb33c49f": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L51-L81", "sff599e8b12c9": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/release-notes/opensearch.release-notes-2.10.0.md#L27", "se48bbb367fc8": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/release-notes/opensearch.release-notes-2.10.0.md#L33", "s5a5e01f70ec4": "https://github.com/opensearch-project/OpenSearch/blob/eee49cb340edc6c4d489bcd9324dda571fc8dc03/release-notes/opensearch.release-notes-2.10.0.md#L35" } }, "corecrypto300": { "name": "OpenSearch repository crypto source", "basis": "3.0.0", "sources": { "s3c28c995eb5e": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/server/src/main/java/org/opensearch/action/admin/cluster/crypto/CryptoSettings.java#L54-L63", "s93b4c5889273": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L989-L1013", "sab6693a23f6b": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58", "sc211cd4c1f4c": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L121-L124", "s1917cf59aef1": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70", "s25b324a4f06c": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L159-L174", "s2666ebb6c559": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58-L158", "s9e2a009146a3": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70-L199", "se0f7b814bf36": "https://github.com/opensearch-project/OpenSearch/blob/dc4efa821904cc2d7ea7ef61c0f577d3fc0d8be9/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74" } }, "corecrypto290": { "name": "OpenSearch crypto directory source", "basis": "2.9.0", "sources": { "s3a525ebc7971": "https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/action/admin/cluster", "sead7f18b1232": "https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/common/blobstore", "s024162999a7b": "https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch", "s44e4cbf700e2": "https://github.com/opensearch-project/OpenSearch/tree/1164221ee2b8ba3560f0ff492309867beea28433/plugins" } }, "repo290": { "name": "OpenSearch repository source", "basis": "2.9.0", "sources": { "s1429acf43e09": "https://github.com/opensearch-project/OpenSearch/blob/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L738-L759", "s29b704159bed": "https://github.com/opensearch-project/OpenSearch/blob/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/action/admin/cluster/repositories/put/PutRepositoryRequest.java#L215-L230", "sde8f172dc83a": "https://github.com/opensearch-project/OpenSearch/blob/1164221ee2b8ba3560f0ff492309867beea28433/server/src/main/java/org/opensearch/cluster/metadata/RepositoryMetadata.java#L54-L66" } }, "corecrypto332": { "name": "OpenSearch repository crypto source", "basis": "3.3.2", "sources": { "sc9bf4ddcb6b8": "https://github.com/opensearch-project/OpenSearch/blob/3.3.2/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1054-L1078", "sc698b1eaf1b9": "https://github.com/opensearch-project/OpenSearch/blob/3.3.2/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74", "sc966e4e32c2d": "https://github.com/opensearch-project/OpenSearch/blob/3.3.2/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58-L158", "scbde9cb6c4f2": "https://github.com/opensearch-project/OpenSearch/blob/3.3.2/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70-L199" } }, "corecrypto340": { "name": "OpenSearch repository crypto source", "basis": "3.4.0", "sources": { "s2710220c82ef": "https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1062-L1084", "s93515a5194f1": "https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74", "s583f753b638a": "https://github.com/opensearch-project/OpenSearch/blob/3.4.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L58-L158", "saeb525d0d79c": "https://github.com/opensearch-project/OpenSearch/blob/3.4.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L70-L199", "s91e8f2833946": "https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreProvider.java#L41-L85" } }, "corecrypto350": { "name": "OpenSearch repository crypto source", "basis": "3.5.0", "sources": { "s62de82f8d4cb": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L219", "s61bf5c7bfd08": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1063-L1085", "s72ddf836f1b8": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L2977-L2979", "s57654075d803": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L3950-L3962", "s68ca8d1a394b": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4147-L4148", "s0140e5bf55b1": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4155-L4189", "s1c12f9fcef29": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4819-L4870", "s7ef8ce8a4ac4": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4958-L4964", "s95b294817514": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L94-L170", "s8d7160a829ab": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L137-L140", "s7bc6ade23073": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobStore.java#L173-L176", "sfe634bf6d4ab": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109", "sb54cc172630f": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L200-L211", "s506f986010d1": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/support/AbstractBlobContainer.java#L43-L56", "s4ed93634b781": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L63", "s69354bfdd8f2": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L140-L157", "sac7d3cdfc8be": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L60-L67", "scaf794c9d5af": "https://github.com/opensearch-project/OpenSearch/blob/3.5.0/server/src/main/java/org/opensearch/cluster/metadata/CryptoMetadata.java#L114-L124" } }, "corecrypto370": { "name": "OpenSearch repository crypto source", "basis": "3.7.0", "sources": { "sad5d975fe638": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L60-L195", "s8e950b4d9a68": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L191-L194", "s60ec9ebfbf05": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L72-L236", "s3aebcdd4cb2c": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L232-L235", "sc98196d18b9d": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1-L4970", "s4689de353395": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109", "s601bee94d98e": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L201-L212", "sbff3d3f4b222": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/index/IndexService.java#L793-L811", "sb3a5e33ea6a6": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/index/IndexService.java#L1428-L1439", "sfb2e3540fee5": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L132-L140", "s0155c75ef968": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L160-L168", "sb05d21f47399": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/repositories/Repository.java#L685-L694", "sb1b5e92dc364": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1063-L1085", "sb493d3dabaa9": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-gcs/src/main/java/org/opensearch/repositories/gcs/native_store/GcsNativeObjectStorePlugin.java#L118-L125", "sd0fb4126a358": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-azure/src/main/java/org/opensearch/repositories/azure/native_store/AzureNativeObjectStorePlugin.java#L124-L143", "s65640ab98d93": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-gcs/src/main/rust/src/gcs.rs#L40-L59", "s218c27ed61b0": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-azure/src/main/rust/src/azure.rs#L43-L64", "s4c05d0197613": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-s3/src/main/java/org/opensearch/repositories/s3/native_store/S3NativeObjectStorePlugin.java#L135-L185", "sac592366d6c9": "https://github.com/opensearch-project/OpenSearch/blob/3.7.0/sandbox/plugins/native-repository-s3/src/main/rust/src/s3.rs#L95-L97" } }, "corecrypto390": { "name": "OpenSearch repository crypto source", "basis": "3.9.0", "sources": { "se6ddbbe96249": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1063-L1085", "sc5bf05ecf0ea": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/MeteredBlobStoreRepository.java#L49-L74", "s00eccfed064d": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L60-L195", "se96a858ba821": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L72-L236", "sb290569596b4": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L219", "se3aad09d8497": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L2982-L2984", "s37f9458d6d94": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L3955-L3967", "s0f9808fc0616": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4152-L4153", "sf18e2d0fe0ca": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4160-L4194", "s14212c0feb93": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4824-L4875", "sf2a546029708": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L4963-L4969", "sadf2ab29004a": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreProvider.java#L44-L96", "s1a6b8421dc88": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/repositories/blobstore/BlobStoreRepository.java#L1-L4970", "sec74049ea092": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/index/IndexService.java#L794-L812", "s087ff902509f": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/index/IndexService.java#L1429-L1440", "s999b7dcb4962": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobContainer.java#L49-L109", "sc2b83444a4d8": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java#L201-L212", "sd4b63b629d2a": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/support/AbstractBlobContainer.java#L43-L56", "s880359b13972": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/cluster/metadata/CryptoMetadata.java#L114-L124", "sd4ffb3ed8742": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageBlobStore.java#L137-L140", "s772ecf3c6791": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobStore.java#L173-L176", "sf11ab30b7e98": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L63", "s3fb393e9a1cf": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureBlobContainer.java#L140-L157", "s17887e310f78": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobStore.java#L60-L67", "sd098c5f679f0": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/server/src/main/java/org/opensearch/common/blobstore/EncryptedBlobContainer.java#L147-L170", "s2ca9b61aa21b": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/sandbox/plugins/native-repository-gcs/src/main/java/org/opensearch/repositories/gcs/native_store/GcsNativeObjectStorePlugin.java#L29-L125", "s8ef470333c29": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/sandbox/plugins/native-repository-azure/src/main/java/org/opensearch/repositories/azure/native_store/AzureNativeObjectStorePlugin.java#L29-L143", "s20aa174b40f4": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureStorageSettings.java#L71-L76", "s99c98f054c54": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-gcs/src/main/java/org/opensearch/repositories/gcs/GoogleCloudStorageRepository.java#L132-L140", "s6bf15a717c4b": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/repository-azure/src/main/java/org/opensearch/repositories/azure/AzureRepository.java#L160-L168", "s6db9018bd3c5": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/CryptoKmsPlugin.java#L27-L74", "se02e0bfa987e": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/KmsClientSettings.java#L33-L65", "sfde1287357e7": "https://github.com/opensearch-project/OpenSearch/blob/3.9.0/plugins/crypto-kms/src/main/java/org/opensearch/crypto/kms/KmsService.java#L53-L55" } } }, "claims": { "es-tier": {"text": "Self-managed 8.x TLS, native users, RBAC, REST keys, anonymous controls and CORS are free Basic; LDAP/audit and FLS/DLS require Platinum/Enterprise, with current Platinum restricted to existing customers.", "components": ["es"], "sources": ["es:sad43b70af67a"], "status": "REASONED"}, "es-auto": {"text": "Eligible initial startup enables auth, HTTP/transport TLS and elastic password; redirected output, unwritable config or existing settings can skip setup, with stated discovery exceptions; packages require password reset.", "components": ["es"], "sources": ["es:s64e4e307f36c", "es:se8bbf661f24e"], "status": "REASONED"}, "es-tls": {"text": "Keep xpack.security.enabled and HTTP TLS enabled; repair CA trust/certificates instead of disabling protection.", "components": ["es"], "sources": ["es:sce3095052f8d"], "status": "REASONED"}, "es-bind": {"text": "network.host defaults `_local_` on up interfaces across available families; family-specific tokens and HTTP/profile bind overrides apply before network fallback.", "components": ["es8", "es9"], "sources": ["es8:sa56cdc9270ea", "es8:s46c7adfdc6c4", "es8:s8ca2413b2af3", "es9:s666c63ad2a02", "es9:s429c675c1092", "es9:sa27fc323d90c"], "status": "REASONED"}, "es-http-range": {"text": "HTTP defaults to an available port in 9200-9300, subject to effective host/bind/publish settings.", "components": ["es8", "es9"], "sources": ["es8:s59c2dd10f083", "es9:s8fded6a7640c"], "status": "REASONED"}, "es-transport-range": {"text": "Transport defaults to an available port in 9300-9399; inspect effective profile and actual listener ports.", "components": ["es8", "es9"], "sources": ["es8:s0310c5c2e42f", "es9:s1f0a06e68675"], "status": "REASONED"}, "es-auto-http": {"text": "Auto-configuration writes http.host=0.0.0.0 only when none of the three HTTP or three network address settings has a value, including enrollment.", "components": ["es8", "es9"], "sources": ["es8:s6e6cf4d739bd", "es9:scf100fafec80"], "status": "REASONED"}, "es-auto-transport": {"text": "Transport address guard respects existing settings; new-cluster wildcard is commented and enrollment activates it only for a discovered nonlocal, nonloopback publish address.", "components": ["es8", "es9"], "sources": ["es8:s6e6cf4d739bd", "es8:s70c493fe7770", "es9:scf100fafec80", "es9:s0ba542d80d53"], "status": "REASONED"}, "es-docker": {"text": "Pinned image map covers 8.19.22/9.4.6/9.5.3; shipped network.host=0.0.0.0 widens API and transport unless overridden. EXPOSE includes 9300, so avoid -P, host networking and untrusted peers.", "components": ["images", "es"], "sources": ["images:s09177781dc63", "images:sae45a2b7731f", "images:sab290cdf87a1", "images:s7088372053e9", "images:s0c3512f7e923", "images:s1683d032b409", "images:s6c51d507efec", "images:s5c4452cc7b82", "images:s849d2f17dbc8", "images:s30233ea32bf6", "es:s38b60fd8e2bf"], "status": "REASONED"}, "es-source-docker": {"text": "Source configurations at 8.19.22/9.5.4 also set network.host wildcard, making both auto-configuration address guards false.", "components": ["es8", "es9"], "sources": ["es8:s8d4c513fdf84", "es9:sa4fd281c6186"], "status": "REASONED"}, "es-role": {"text": "Give each app an index-read role and dedicated native user; manage_own_api_key permits self-issued keys without broader index access, and superuser is not an app role.", "components": ["es-api"], "sources": ["es-api:s088db4f93f33", "es-api:s0c10cfba4d6b", "es-api:sb82b2da8da5d"], "status": "REASONED"}, "credential-files": {"text": "Use protected 0600 header/request/response files and verified CA input; base64 Basic is not encryption and file input does not prevent file readers or tracing.", "components": ["host"], "sources": ["host:s2b2686afaf41"], "status": "REASONED"}, "es-key": {"text": "Explicit API-key role descriptors intersect creator permissions; omitted descriptors inherit a snapshot, omitted expiration never expires, and an API key cannot create another privileged key.", "components": ["es-api"], "sources": ["es-api:sbec1f2938348"], "status": "REASONED"}, "es-ldap": {"text": "Optional paid LDAP mapping binds the specific realm and group DN; native-user roles are assigned through the user API.", "components": ["es"], "sources": ["es:sc3d6362bc8f4", "es:s0c10cfba4d6b"], "status": "REASONED"}, "es-fls": {"text": "8.19 field_security.grant restricts concrete fields, not aliases; metadata remains accessible and omission grants all fields. Minimum guide scope 8.0 is not an introduction claim.", "components": ["es819"], "sources": ["es819:s805957cad810"], "status": "REASONED"}, "es-dls": {"text": "8.19 query restricts documents; omission removes DLS. Keep tenant readers read-only and remove unrestricted roles that combine to defeat restrictions.", "components": ["es819"], "sources": ["es819:s52adbc714b10", "es819:s15feb5b3e00c"], "status": "REASONED"}, "es-fls-limits": {"text": "DLS has aggregate/scoring inference channels and query/API restrictions including joins, lookups, percolate and date now; test actual queries and prefer separate indices for stronger isolation.", "components": ["es819"], "sources": ["es819:se332a9504a82"], "status": "REASONED"}, "es-key-review": {"text": "Review previously issued key permission snapshots separately after changing roles; role edits alone do not establish repaired key restrictions.", "components": ["es819"], "sources": ["es819:s15feb5b3e00c"], "status": "REASONED"}, "es-anonymous": {"text": "Remove anonymous.roles from every effective node configuration and restart; no roles disables anonymous access. A tested 401 alone cannot prove it is globally off.", "components": ["es"], "sources": ["es:s204396515260"], "status": "REASONED"}, "es-cors": {"text": "CORS defaults disabled; if needed configure exact origin, methods and headers, restart nodes and retain authorization.", "components": ["es"], "sources": ["es:s0f65658e7273"], "status": "REASONED"}, "es-audit": {"text": "Audit defaults disabled; enable on each 8.x node with restart and collect CLUSTERNAME_audit.json independently. Dynamic enablement documented for 9.5+ does not apply to 8.x.", "components": ["es"], "sources": ["es:sfa639f3e13a9"], "status": "REASONED"}, "es-audit-body": {"text": "Request-body emission defaults false; keep it off and avoid event exclusions/ignore policies suppressing authentication_failed.", "components": ["es"], "sources": ["es:s5603a653318d"], "status": "REASONED"}, "os-tier": {"text": "OpenSearch Security is free Apache-2.0 with no paid security tier; keep authentication and TLS enabled.", "components": ["os-license-source"], "sources": ["os-license-source:s6e10cf9ec35a"], "status": "REASONED"}, "os-demo-users": {"text": "From 2.12 demo setup requires initial admin password but only changes admin; six other seeded users retain demo credentials until removed/replaced and applied to the live security index.", "components": ["os", "os-users-source"], "sources": ["os:s73e416ebf765", "os-users-source:se8c473c01153"], "status": "REASONED"}, "os-tls": {"text": "Replace demo certificates; REST TLS defaults false underneath demo setup and requires plugins.security.ssl.http.enabled=true with production certificates.", "components": ["os"], "sources": ["os:s4e3281cb3d23"], "status": "REASONED"}, "os-rbac": {"text": "Create a custom index-read role and internal user with explicit mapping; Security REST administration requires its own authorization and other mappings may add grants.", "components": ["os"], "sources": ["os:s07ddf2781a70", "os:s204396515260", "os:sc3d6362bc8f4", "os:s0e349fff0934", "os:s4f63d2563816", "os:s1a9aa4907daf"], "status": "REASONED"}, "os-api-key": {"text": "Native scoped keys require 3.7+ and security administrators; enable api_tokens and explicit lifetime/count limits, using index_pattern and duration_seconds rather than Elasticsearch fields.", "components": ["os"], "sources": ["os:sfbdd4a41adf5", "os:sa596b3a14984"], "status": "REASONED"}, "os-key-default": {"text": "Omitted token duration uses max_duration_seconds, documented default 7776000; example caps at 86400 with 100 tokens, and returned token is retrievable only once.", "components": ["os"], "sources": ["os:sa596b3a14984"], "status": "REASONED"}, "os-apply": {"text": "securityadmin applies local configuration to the live index; back up first, use file/type-limited upload, production CA/admin certificate and hostname verification because stale files overwrite live changes.", "components": ["os"], "sources": ["os:s40051274ea31"], "status": "REASONED"}, "os-fls": {"text": "3.6 FLS/DLS recipe uses JSON-string dls and visible tenant_id in fls; review all roles and inclusion/exclusion interactions, not Elasticsearch role-combination assumptions.", "components": ["os36"], "sources": ["os36:s42d975e3cd92", "os36:sade7ff6b2bb3"], "status": "REASONED"}, "os-dls": {"text": "dfm_empty_overrides_all affects unrestricted-role precedence; DLS does not constrain writes and adaptive filter-level DLS limits APIs/cross-cluster search. Tokens need independent restriction tests.", "components": ["os36"], "sources": ["os36:sade7ff6b2bb3"], "status": "REASONED"}, "os-anonymous": {"text": "Set live anonymous_auth_enabled=false while preserving authentication domains; update through securityadmin, not just local YAML.", "components": ["os"], "sources": ["os:sc921fd158473", "os:s4142fe7a4236"], "status": "REASONED"}, "os-cors": {"text": "CORS defaults false; exact-origin static settings go in opensearch.yml and require restart, independently of authentication.", "components": ["os"], "sources": ["os:s3f64f7b4cf84"], "status": "REASONED"}, "os-audit": {"text": "Auditing needs destination and enabled config; internal_opensearch stores daily audit indexes, while shipped enabled=true alone records nothing without storage type.", "components": ["os"], "sources": ["os:s2f39d5e48027"], "status": "REASONED"}, "os-audit-body": {"text": "Read live audit config, enable REST and disable request-body logging via admin-certificate PATCH; preserve defaults and do not suppress FAILED_LOGIN.", "components": ["os"], "sources": ["os:seafbd3b9677e", "os:s0d675c5ecd3c"], "status": "REASONED"}, "os-audit-headers": {"text": "exclude_sensitive_headers defaults true; readonly REST fields require complete audit-file application. Read back enabled, REST, body=false and headers=true separately.", "components": ["os"], "sources": ["os:seafc6e5e5595", "os:s0d675c5ecd3c"], "status": "REASONED"}, "storage": {"text": "Use OS/provider encryption below path.data, including logs/swap/local snapshots; path.data only selects a directory and encryption-at-rest support is not a product encryption switch.", "components": ["es819", "os"], "sources": ["es819:s03b37cd41154", "es819:s1ce03cd781c3", "os:s9ddd20a83871"], "status": "REASONED"}, "mount-enforcement": {"text": "RequiresMountsFor plus AssertPathIsMountPoint prevents ordinary-directory fallback with systemd; reload manager and verify encrypted device chain separately.", "components": ["host"], "sources": ["host:s64522c42b5bc"], "status": "REASONED"}, "snapshot-boundary": {"text": "Ordinary snapshots use free Basic/OpenSearch; remote repository encryption is independent of node disks and authorized server-side downloads are decrypted.", "components": ["es", "os31", "cloud"], "sources": ["es:s4fb9bf780cf5", "os31:s089decb3330d", "cloud:s710600aeb168"], "status": "REASONED"}, "cloud-defaults": {"text": "New AWS S3, GCS and Azure objects are encrypted server-side by default; assess historical objects and customer key ownership separately.", "components": ["cloud"], "sources": ["cloud:s710600aeb168", "cloud:sdc6a5af591ff", "cloud:saeb8db497f20"], "status": "REASONED"}, "es-s3": {"text": "8.19 server_side_encryption defaults false; true requests AES256 SSE-S3, not client encryption. Omit it when using bucket-default KMS and verify resulting metadata.", "components": ["es819"], "sources": ["es819:s3cac63e6ba14"], "status": "REASONED"}, "os-s3": {"text": "3.1 removed server_side_encryption; server_side_encryption_type accepts AES256/aws:kms/bucket_default. Default changed from bucket_default to AES256 in 3.8; explicitly select intended mode.", "components": ["os31", "os38"], "sources": ["os31:s089decb3330d", "os38:sc57180db39be"], "status": "REASONED"}, "os-s3-kms": {"text": "Explicit KMS repository mode uses server_side_encryption_kms_key_id ARN with object/KMS permissions; this is server-side encryption and settings are not Elasticsearch-compatible. At 3.7.0 and 3.8.0 the sandbox native S3 provider's only SSE inputs are that key and the bucket-key flag (default true), not the encryption type.", "components": ["os31", "cloud", "corecrypto370", "corecrypto38"], "sources": ["os31:s089decb3330d", "cloud:se1e1c91f80b4", "corecrypto370:s4c05d0197613", "corecrypto370:sac592366d6c9", "corecrypto38:s4a5aed4bf2ae", "corecrypto38:s6f461674b296"], "status": "REASONED"}, "es-gcs-azure": {"text": "8.19 GCS/Azure setting lists document no client-side-encryption switch; configure provider key policy and access. GCS supplied keys remain server-side and Azure keys/SAS are authentication.", "components": ["es819"], "sources": ["es819:s28a1fe315963", "es819:s6279b842bc10"], "status": "REASONED"}, "os-gcs-azure": {"text": "Follow the OpenSearch 3.6 plugin/credential workflow, including Azure; this guide's advice is to configure required customer-managed encryption through provider policy and grant key access. Do not infer broader absence from Elasticsearch settings or failed source retrieval.", "components": ["os36", "cloud"], "sources": ["os36:sf0940d73ed6e", "cloud:sdc6a5af591ff", "cloud:saeb8db497f20"], "status": "REASONED"}, "os-gcs-azure-settings": {"text": "This guide's review of OpenSearch 3.8.0 GCS/Azure plugin-specific settings, blob upload and client construction found no plugin-specific client-side-encryption setting.", "components": ["gcs38", "azure38"], "sources": ["gcs38:s631e8e354e26", "gcs38:s9ff434d5553c", "gcs38:s7e94d736aed9", "gcs38:s1f1e29ea0106", "gcs38:sfe65ec0f6f8c", "gcs38:sfa59a5382e57", "azure38:s85d096884518", "azure38:sc2902fffc253", "azure38:s22bd450a8a30", "azure38:sdea961e1b3be"], "status": "REASONED"}, "os-gcs-azure-core": {"text": "In the cited OpenSearch 3.8.0 core path, crypto_settings triggers client-side EncryptedBlobStore wrapping of a non-server-side-encryption store and encryption before writes. Common provider and GCS/Azure createBlobStore methods show a path to both types and Azure rejects per-object CryptoMetadata; this guide assesses end-to-end applicability and live behaviour as unestablished.", "components": ["gcs38", "azure38", "corecrypto38"], "sources": ["gcs38:s90d52029d8d8", "azure38:sa4a6997abb47", "azure38:s287622988f18", "corecrypto38:sb8b00bcc45a2", "corecrypto38:s72395c1b171c", "corecrypto38:s85afe5ff10ee", "corecrypto38:sa01a018a581d", "corecrypto38:s248fbcd97369", "corecrypto38:s0ed030669257", "corecrypto38:sc99eb8d33be7", "corecrypto38:sef269e173cbc", "corecrypto38:s2ee18ff27a14", "corecrypto38:sb739f2d2b9bb"], "status": "REASONED"}, "os-gcs-azure-providers": {"text": "The cited 3.8.0 path requires key_provider_name, key_provider_type and a registered provider of that type; the tree includes aws-kms, whose 3.9.0 getSettings() lists only kms.* settings. This guide's assessment: deployed providers, end-to-end applicability, live behaviour, a universal GCS/Azure minimum and unchecked-release behaviour remain unestablished.", "components": ["corecrypto38", "corecrypto390"], "sources": ["corecrypto38:s6599b9d988eb", "corecrypto38:s726bdb2f7cab", "corecrypto38:s73ce68ed27f1", "corecrypto390:s6db9018bd3c5", "corecrypto390:se02e0bfa987e", "corecrypto390:sfde1287357e7"], "status": "REASONED"}, "os-gcs-azure-versions": {"text": "This guide's assessment from pinned 2.10.0/3.0.0 methods, 2.10.0 release notes and 2.9.0 listings without crypto entries: the checked range with repository crypto settings starts at 2.10.0. Across 31 tags to 3.9.0, blobStore() wraps through 3.3.2 and from 3.4.0 delegates to BlobStoreProvider, which wraps; other releases and a universal deployed GCS/Azure minimum remain unestablished.", "components": ["corecrypto210", "corecrypto300", "corecrypto290", "corecrypto332", "corecrypto340", "corecrypto390", "corecrypto38"], "sources": ["corecrypto210:s023fb522ed72", "corecrypto210:scd89fded9c2c", "corecrypto300:s3c28c995eb5e", "corecrypto300:s93b4c5889273", "corecrypto210:sff599e8b12c9", "corecrypto210:se48bbb367fc8", "corecrypto210:s5a5e01f70ec4", "corecrypto290:s3a525ebc7971", "corecrypto290:sead7f18b1232", "corecrypto290:s024162999a7b", "corecrypto290:s44e4cbf700e2", "corecrypto332:sc9bf4ddcb6b8", "corecrypto340:s2710220c82ef", "corecrypto390:se6ddbbe96249", "corecrypto38:s85afe5ff10ee", "corecrypto38:s72395c1b171c", "corecrypto340:s91e8f2833946", "corecrypto390:sadf2ab29004a"], "status": "REASONED"}, "os-gcs-azure-path": {"text": "At 2.10.0/3.0.0, GCS/Azure extend MeteredBlobStoreRepository, an abstract BlobStoreRepository subclass declaring neither blobStore() nor createBlobStore, and implement createBlobStore; BlobStoreRepository.blobStore() wraps its result when crypto metadata is non-null. This guide infers both reach the wrap in source; deployed encryption, installed providers and unchecked releases are unestablished.", "components": ["corecrypto210", "corecrypto300"], "sources": ["corecrypto210:scd89fded9c2c", "corecrypto210:s1753e87e88dc", "corecrypto210:s0ead2431acaf", "corecrypto210:sc0efd9a1f6ae", "corecrypto210:sf884a4d2d3de", "corecrypto210:s727ebb33c49f", "corecrypto300:s93b4c5889273", "corecrypto300:sab6693a23f6b", "corecrypto300:sc211cd4c1f4c", "corecrypto300:s1917cf59aef1", "corecrypto300:s25b324a4f06c", "corecrypto300:se0f7b814bf36"], "status": "REASONED"}, "os-gcs-azure-blobstore": {"text": "At every checked tag 2.10.0 to 3.9.0, the pinned GoogleCloudStorageRepository, AzureRepository and MeteredBlobStoreRepository class bodies declare no blobStore() method; AzureRepository overrides getBlobStore() only to return super.getBlobStore(). This guide's inference that GCS and Azure reach the encryption wrap also relies on that absence and, from 3.4.0, on BlobStoreProvider's wrap.", "components": ["corecrypto210", "corecrypto300", "corecrypto332", "corecrypto340", "corecrypto370", "corecrypto390", "corecrypto38"], "sources": ["corecrypto210:sa2c0bdc87a61", "corecrypto210:sd5b6e9a0dcbf", "corecrypto210:s727ebb33c49f", "corecrypto300:s2666ebb6c559", "corecrypto300:s9e2a009146a3", "corecrypto300:se0f7b814bf36", "corecrypto332:sc698b1eaf1b9", "corecrypto332:sc966e4e32c2d", "corecrypto332:scbde9cb6c4f2", "corecrypto340:s93515a5194f1", "corecrypto340:s583f753b638a", "corecrypto340:saeb525d0d79c", "corecrypto370:sad5d975fe638", "corecrypto370:s60ec9ebfbf05", "corecrypto390:sc5bf05ecf0ea", "corecrypto390:s00eccfed064d", "corecrypto390:se96a858ba821", "corecrypto340:s91e8f2833946", "corecrypto390:sadf2ab29004a", "corecrypto38:s72395c1b171c"], "status": "REASONED"}, "os-gcs-azure-range": {"text": "At all 25 checked OpenSearch tags 2.10.0 to 3.3.2, BlobStoreRepository.blobStore() has the same body, wrapping the createBlobStore() result in EncryptedBlobStore when crypto metadata is non-null. From 3.4.0 to 3.9.0 it returns blobStore(false) via BlobStoreProvider, which wraps likewise with server-side encryption off (source reading at 3.4.0, 3.8.0, 3.9.0; 3.5.0 to 3.7.0 share 3.4.0's blob ID).", "components": ["corecrypto210", "corecrypto300", "corecrypto332", "corecrypto340", "corecrypto390", "corecrypto38"], "sources": ["corecrypto210:scd89fded9c2c", "corecrypto300:s93b4c5889273", "corecrypto332:sc9bf4ddcb6b8", "corecrypto340:s2710220c82ef", "corecrypto390:se6ddbbe96249", "corecrypto38:s85afe5ff10ee", "corecrypto38:s72395c1b171c", "corecrypto340:s91e8f2833946", "corecrypto390:sadf2ab29004a"], "status": "REASONED"}, "os-gcs-azure-native": {"text": "From 3.7.0 to 3.9.0, the checked GoogleCloudStorageRepository and AzureRepository also override getNativeStore() to return a native object store that the constructor sets when a native provider supplies a live one. Deployed use of that path, and its relation to the EncryptedBlobStore wrap at 3.8.0, are not established here.", "components": ["corecrypto370", "corecrypto390"], "sources": ["corecrypto370:sad5d975fe638", "corecrypto370:s8e950b4d9a68", "corecrypto370:s60ec9ebfbf05", "corecrypto370:s3aebcdd4cb2c", "corecrypto390:s00eccfed064d", "corecrypto390:se96a858ba821", "corecrypto390:s99c98f054c54", "corecrypto390:s6bf15a717c4b"], "status": "REASONED"}, "os-gcs-azure-index": {"text": "At checked OpenSearch tags 3.5.0 to 3.9.0 only, BlobStoreRepository passes CryptoMetadata from index settings to writeBlobWithMetadata for shard files, commented as an index-level SSE-KMS override; repository metadata feeds BlobStoreProvider separately. Source reading, no run: its effective behaviour is unestablished.", "components": ["corecrypto350", "corecrypto390"], "sources": ["corecrypto350:s62de82f8d4cb", "corecrypto350:s61bf5c7bfd08", "corecrypto350:s72ddf836f1b8", "corecrypto350:s57654075d803", "corecrypto350:s68ca8d1a394b", "corecrypto350:s0140e5bf55b1", "corecrypto350:s1c12f9fcef29", "corecrypto350:s7ef8ce8a4ac4", "corecrypto390:se6ddbbe96249", "corecrypto390:sb290569596b4", "corecrypto390:se3aad09d8497", "corecrypto390:s37f9458d6d94", "corecrypto390:s0f9808fc0616", "corecrypto390:sf18e2d0fe0ca", "corecrypto390:s14212c0feb93", "corecrypto390:sf2a546029708"], "status": "REASONED"}, "os-gcs-azure-index-wrap": {"text": "From source reading without a run, EncryptedBlobContainer's writeBlobWithMetadata form taking CryptoMetadata, read at 3.5.0 and 3.8.0 and sharing 3.8.0's git blob ID at 3.6.0, 3.7.0 and 3.9.0, encrypts with the repository-level crypto handler and passes the index-level CryptoMetadata unchanged to the wrapped container.", "components": ["corecrypto350", "corecrypto38"], "sources": ["corecrypto350:s95b294817514", "corecrypto38:s6624e4bb01fd", "corecrypto38:s0ed030669257", "corecrypto38:sa01a018a581d", "corecrypto38:s248fbcd97369"], "status": "REASONED"}, "os-gcs-azure-index-azure": {"text": "From source reading without a run, AzureBlobContainer, with one git blob ID at every tag 3.5.0 to 3.9.0, throws UnsupportedOperationException when writeBlobWithMetadata receives non-null CryptoMetadata. From source reading without a run, an index-level value reaching AzureBlobContainer, directly or through EncryptedBlobContainer, therefore ends that shard-file write with the exception in source.", "components": ["azure38", "corecrypto350", "corecrypto38", "corecrypto390"], "sources": ["azure38:sa4a6997abb47", "corecrypto350:s95b294817514", "corecrypto350:s1c12f9fcef29", "corecrypto38:s6624e4bb01fd", "corecrypto390:s14212c0feb93", "corecrypto390:s772ecf3c6791"], "status": "REASONED"}, "os-gcs-azure-index-gcs": {"text": "From source reading without a run at 3.5.0, 3.7.0, 3.8.0 and 3.9.0, with 3.6.0 by git blob ID, GoogleCloudStorageBlobContainer overrides only the writeBlobWithMetadata form without CryptoMetadata, which calls writeBlob. BlobContainer's default for the CryptoMetadata form calls that form, so GCS drops the index-level CryptoMetadata at every tag 3.5.0 to 3.9.0.", "components": ["corecrypto350", "corecrypto370", "corecrypto38", "corecrypto390"], "sources": ["corecrypto350:sfe634bf6d4ab", "corecrypto350:sb54cc172630f", "corecrypto350:s506f986010d1", "corecrypto350:s8d7160a829ab", "corecrypto370:s4689de353395", "corecrypto370:s601bee94d98e", "corecrypto38:s5f9dc3a01b35", "corecrypto38:s9fafffa5e664", "corecrypto390:s999b7dcb4962", "corecrypto390:sc2b83444a4d8", "corecrypto390:sd4b63b629d2a", "corecrypto390:sd4ffb3ed8742"], "status": "REASONED"}, "os-gcs-azure-index-setting": {"text": "From source reading at 3.5.0 and 3.9.0, CryptoMetadata.fromIndexSettings returns null unless the index setting index.store.crypto.key_provider is set, with key_provider_type defaulting to aws-kms, so in the snapshot path only such indices pass a non-null index-level value to writeBlobWithMetadata. A text search of the 3.9.0 main sources read here finds no registration of it.", "components": ["corecrypto350", "corecrypto390"], "sources": ["corecrypto350:scaf794c9d5af", "corecrypto350:s7ef8ce8a4ac4", "corecrypto350:s1c12f9fcef29", "corecrypto390:s880359b13972", "corecrypto390:sf2a546029708", "corecrypto390:s14212c0feb93"], "status": "REASONED"}, "os-gcs-azure-index-advice": {"text": "This guide's advice, from source reading at 3.5.0 and 3.9.0 without a run: do not rely on index.store.crypto.key_provider to encrypt GCS/Azure snapshots. Shard-file writes pass its CryptoMetadata to the container, which Azure rejects with UnsupportedOperationException, directly or through EncryptedBlobContainer, and GCS drops; settability and effective behaviour are unestablished.", "components": ["corecrypto350", "corecrypto390"], "sources": ["corecrypto350:s68ca8d1a394b", "corecrypto350:s0140e5bf55b1", "corecrypto350:s1c12f9fcef29", "corecrypto350:s7ef8ce8a4ac4", "corecrypto350:scaf794c9d5af", "corecrypto350:s61bf5c7bfd08", "corecrypto350:s72ddf836f1b8", "corecrypto350:s7bc6ade23073", "corecrypto350:s4ed93634b781", "corecrypto350:s69354bfdd8f2", "corecrypto350:s506f986010d1", "corecrypto350:sac7d3cdfc8be", "corecrypto350:s95b294817514", "corecrypto350:s8d7160a829ab", "corecrypto350:sfe634bf6d4ab", "corecrypto350:sb54cc172630f", "corecrypto390:s0f9808fc0616", "corecrypto390:sf18e2d0fe0ca", "corecrypto390:s14212c0feb93", "corecrypto390:sf2a546029708", "corecrypto390:s880359b13972", "corecrypto390:se6ddbbe96249", "corecrypto390:se3aad09d8497", "corecrypto390:s772ecf3c6791", "corecrypto390:sf11ab30b7e98", "corecrypto390:s3fb393e9a1cf", "corecrypto390:sd4b63b629d2a", "corecrypto390:s17887e310f78", "corecrypto390:sd098c5f679f0", "corecrypto390:sd4ffb3ed8742", "corecrypto390:s999b7dcb4962", "corecrypto390:sc2b83444a4d8"], "status": "REASONED"}, "os-gcs-azure-native-use": {"text": "From source reading without a run, the GCS and Azure native providers pass no crypto settings at 3.7.0, 3.8.0 and 3.9.0. This guide infers that at 3.7.0 and 3.9.0 the native store, which IndexService resolves for a warm index's remote store repository, is built outside BlobStoreProvider's wrap; what is written through it is unestablished.", "components": ["corecrypto370", "corecrypto38", "corecrypto390"], "sources": ["corecrypto370:sc98196d18b9d", "corecrypto370:sbff3d3f4b222", "corecrypto370:sb3a5e33ea6a6", "corecrypto370:sfb2e3540fee5", "corecrypto370:s0155c75ef968", "corecrypto370:sb05d21f47399", "corecrypto370:sb1b5e92dc364", "corecrypto370:sb493d3dabaa9", "corecrypto370:sd0fb4126a358", "corecrypto370:s65640ab98d93", "corecrypto370:s218c27ed61b0", "corecrypto38:s87bc4fd690f0", "corecrypto38:s0099e29f7990", "corecrypto38:s367e63e71d2a", "corecrypto38:sc8836f82a384", "corecrypto38:s3926a11c10b5", "corecrypto390:s1a6b8421dc88", "corecrypto390:sec74049ea092", "corecrypto390:s087ff902509f", "corecrypto390:s2ca9b61aa21b", "corecrypto390:s8ef470333c29", "corecrypto390:s99c98f054c54", "corecrypto390:s6bf15a717c4b", "corecrypto390:sadf2ab29004a"], "status": "REASONED"}, "os-gcs-azure-native-caller": {"text": "From source reading without a run, IndexService is the only non-test getNativeStore() caller found in searches of the 3.7.0 and 3.8.0 archives and, at 3.9.0, in a text search of sandbox/plugins/, plugins/, modules/, server/src/main/ and libs/common/src/main/. Callers outside those 3.9.0 paths, in external plugins or by reflection are not excluded.", "components": ["corecrypto370", "corecrypto38", "corecrypto390"], "sources": ["corecrypto370:sbff3d3f4b222", "corecrypto370:sb3a5e33ea6a6", "corecrypto370:sb05d21f47399", "corecrypto38:s87bc4fd690f0", "corecrypto38:s0099e29f7990", "corecrypto38:s367e63e71d2a", "corecrypto390:sec74049ea092", "corecrypto390:s087ff902509f"], "status": "REASONED"}, "os-gcs-azure-pre210": {"text": "From source reading, git tree listings at the 41 OpenSearch release tags 1.0.0 to 2.9.0 contain no EncryptedBlobStore, CryptoSettings or crypto-kms path; at 2.9.0, blobStore() returns createBlobStore() unwrapped, PutRepositoryRequest parses only type and settings, and RepositoryMetadata has no crypto field.", "components": ["repo290", "corecrypto290"], "sources": ["repo290:s1429acf43e09", "repo290:s29b704159bed", "repo290:sde8f172dc83a", "corecrypto290:s3a525ebc7971", "corecrypto290:sead7f18b1232", "corecrypto290:s024162999a7b", "corecrypto290:s44e4cbf700e2"], "status": "REASONED"}, "os-gcs-azure-validate": {"text": "From source reading and git blob IDs, CryptoSettings.java at 2.11.0 and 2.11.1 matches the cited 2.10.0 file and at every tag 2.12.0 to 3.9.0 the cited 3.0.0 and 3.8.0 files, so validate() requires key_provider_name and key_provider_type at all 31 checked tags 2.10.0 to 3.9.0; no run.", "components": ["corecrypto210", "corecrypto300", "corecrypto38"], "sources": ["corecrypto210:s023fb522ed72", "corecrypto300:s3c28c995eb5e", "corecrypto38:s6599b9d988eb"], "status": "REASONED"}, "snapshot-integrity": {"text": "Keep repositories private and keys recoverable; do not rewrite active repository files. Configuration backups are separate and security-state snapshots are sensitive.", "components": ["es819"], "sources": ["es819:sb98bd971e2f6", "es819:s93987af8201d"], "status": "REASONED"}, "kibana-default": {"text": "Kibana defaults HTTP localhost:5601 with server TLS disabled; 8.19.22/9.5.4 Docker templates explicitly use wildcard host, requiring override/publication review.", "components": ["kb", "kb8", "kb9"], "sources": ["kb:s0fe3c0d2e958", "kb8:sd77254a631b1", "kb8:s14662915ed7e", "kb9:sd0f70697a909", "kb9:s39aaa733de4b"], "status": "REASONED"}, "kibana-tls": {"text": "Free Basic 8.19 server.ssl certificate/key protects incoming UI traffic; elasticsearch.ssl CA/full verification protects the separate backend hop.", "components": ["kb"], "sources": ["kb:s0fe3c0d2e958"], "status": "REASONED"}, "dash-default": {"text": "Dashboards 3.8.0 sample defaults localhost:5601 with server TLS disabled; inspect installation/container overrides.", "components": ["dash"], "sources": ["dash:sae1b7f23ca6c"], "status": "REASONED"}, "dash-tls": {"text": "Dashboards server.ssl protects UI, cookie.secure restricts session cookie to HTTPS, and opensearch.ssl CA/full verification protects the backend hop; restart after changes.", "components": ["dash36"], "sources": ["dash36:sea686fca5804"], "status": "REASONED"}, "ui-network": {"text": "UI binding is independent of 9200/9300; loopback suits only a same-namespace proxy, while private access requires intended interface, matching certificates and network restrictions.", "components": ["kb", "dash36"], "sources": ["kb:sf71765842118", "dash36:sea686fca5804"], "status": "REASONED"}, "verify-rbac": {"text": "Known app-data/other-data documents distinguish broad access from scoped 200/403 for user and key; 401/404/errors are inconclusive and pre-3.7 OpenSearch uses the user only.", "components": ["es", "os"], "sources": ["es:see6eaa520863", "os:sbd093eef861b"], "status": "REASONED", "verify": [2]}, "verify-anonymous": {"text": "Require effective anonymous settings on every node plus a formerly anonymous-readable known document becoming 401 while admin still gets 200; endpoint-specific 401 alone is insufficient.", "components": ["es", "os"], "sources": ["es:s204396515260", "os:s7d28cfa527a1"], "status": "REASONED", "verify": [3]}, "verify-tls": {"text": "Admin HTTPS must return 200/cluster JSON before interpreting plaintext; TLS listener must return no HTTP status, with DNS/timeouts/unreachable states inconclusive.", "components": ["es", "os"], "sources": ["es:sce3095052f8d", "os:s4e3281cb3d23"], "status": "REASONED", "verify": [1]}, "verify-cors": {"text": "Inspect preflight headers: no grant to untrusted origin, exact grant for trusted enabled origin; with CORS disabled neither is granted.", "components": ["es", "os"], "sources": ["es:s0f65658e7273", "os:s3f64f7b4cf84"], "status": "REASONED", "verify": [4]}, "verify-audit": {"text": "Fresh failed-authentication probe must correlate with new authentication_failed/FAILED_LOGIN in node and independent collector records; 401 alone proves no delivery.", "components": ["es", "os"], "sources": ["es:sfc6fef9020b3", "os:sbad13e007c8f"], "status": "REASONED", "verify": [5]}, "verify-audit-body": {"text": "Compare unique marker present with body logging enabled and absent from a new matching event when disabled; independently verify header exclusion and effective configuration.", "components": ["es", "os"], "sources": ["es:s5603a653318d", "os:sbad13e007c8f"], "status": "REASONED", "verify": [5]}, "verify-listeners": {"text": "Locate actual running listeners/ports and inspect publication; intentional bridge-container wildcard differs from host-network exposure and no output alone is inconclusive.", "components": ["es8", "es9", "host"], "sources": ["es8:s59c2dd10f083", "es9:s1f0a06e68675", "host:s1b4e67e38127"], "status": "REASONED", "verify": [6]}, "verify-fls-dls": {"text": "Two-tenant fixture compares unrestricted and actual restricted credentials through search, GET, requested fields and aggregations, with allowed-field and administrator controls; no inference-proof claim.", "components": ["es819", "os36"], "sources": ["es819:s805957cad810", "es819:s52adbc714b10", "os36:s42d975e3cd92", "os36:sade7ff6b2bb3"], "status": "REASONED", "verify": [7]}, "verify-storage": {"text": "Data-path device chain must traverse intended encryption mapping; unrelated mapping or successful unlocked reads proves nothing, and provider volumes need their own metadata.", "components": ["host"], "sources": ["host:s42b4720299f5", "host:sd23c3d0f42a7", "host:s252144371961"], "status": "REASONED", "verify": [8]}, "verify-mount": {"text": "Isolated unavailable-mount fixture must fail startup for that reason without writes to the ordinary directory; restored encrypted mount must start successfully.", "components": ["host"], "sources": ["host:s64522c42b5bc", "host:s9823c4e9efa4"], "status": "REASONED", "verify": [9]}, "verify-snapshot": {"text": "Harmless explicit-index snapshot/restore must complete and preserve both fixtures without global/security state; timeouts need operation-state inspection before retry.", "components": ["es819", "os36"], "sources": ["es819:sb98bd971e2f6", "es819:s3e5630e4430d", "os36:sf0940d73ed6e"], "status": "REASONED", "verify": [7]}, "verify-object-key": {"text": "Inspect actual new/retained repository object mode/key; controlled object-read and KMS-only denial need provider evidence plus authorized controls. Repository verify proves neither encryption nor isolation.", "components": ["cloud"], "sources": ["cloud:se54783905d19", "cloud:sc53339f5429f", "cloud:se1e1c91f80b4"], "status": "REASONED", "verify": [10]}, "verify-ui": {"text": "Require expected CA-validated HTTPS and rejected plaintext UI after an exposed HTTP fixture; verify prohibited-network denial with authorized success and effective private binding.", "components": ["kb", "dash36", "host"], "sources": ["kb:s0fe3c0d2e958", "dash36:sea686fca5804", "host:s1b4e67e38127"], "status": "REASONED", "verify": [11, 12]}, "verify-ui-backend": {"text": "Browser TLS does not prove backend full verification or Dashboards Secure cookie; inspect both separately with effective settings and publication.", "components": ["kb", "dash36"], "sources": ["kb:s0fe3c0d2e958", "dash36:sea686fca5804"], "status": "REASONED", "verify": [11, 12]} } } ```` ====================================================================== ==> minio.md ====================================================================== # MinIO: credentials, TLS, encryption, and object protection MinIO (now shipped as AIStor) is object storage you run yourself: apps store files in buckets through the same API as Amazon S3. This guide helps your AI assistant configure it securely, including real root credentials, TLS, scoped application access, and encryption with a key manager. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/minio.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against AIStor documentation (no recorded version) and curl curl-8_14_1; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 44 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. MinIO serves S3-compatible object storage; an exposed instance with weak or well-known credentials hands over every bucket. Both the S3 API port and the web console need the same care. Lifecycle note, as of September 2026: the MinIO community repository on GitHub was archived on 2026-04-25 and carries the notice that it is no longer maintained; MinIO now ships AIStor Free (a standalone edition under a free licence) and AIStor Enterprise. The settings below are documented for AIStor. An archived community build receives no security fixes, so treat running one as a finding and plan the migration. ## 1. Set real root credentials Provision `/etc/minio/root-user` and `/etc/minio/root-password` through your secret manager before starting the server. Each must be a regular file owned by the server account, mode `0600`, under a directory other accounts cannot traverse, write to or replace, with no ACL granting them access. Each contains only its credential, with no whitespace or trailing newline. Keep these files and their backups out of source control. In the server's launch configuration, set only the file paths: ```dotenv MINIO_ROOT_USER_FILE=/etc/minio/root-user MINIO_ROOT_PASSWORD_FILE=/etc/minio/root-password ``` AIStor reads these files at startup. Remove `MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` from the launch environment and any environment file: a direct value takes precedence over its `_FILE` counterpart. Start a fresh server process after changing its launch configuration. This assumes the protected files already exist and the launcher supplies these path variables; it does not create credentials or start a server. The shell and launch arguments contain only paths, while the server account and root can still read the credential files and server memory. See the vendor's [file-based settings](https://docs.min.io/aistor/reference/aistor-server/settings/#file-based-environment-variables) and [root credential file requirements](https://docs.min.io/aistor/reference/aistor-server/settings/root-credentials/). Never run with the `minioadmin`/`minioadmin` pair, which is still the built-in default when neither direct root credentials nor credential files are supplied; scanners try it constantly. Root credentials are for administration only: create per-application access keys with least-privilege policies (via the console or the `mc` client) so no app holds root ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). ## 2. Enable TLS MinIO serves HTTPS automatically when it finds a PEM key pair named `public.crt` and `private.key` in `${HOME}/.minio/certs` (or the directory given with `--certs-dir`): ```bash cp fullchain.pem "${HOME}/.minio/certs/public.crt" cp privkey.pem "${HOME}/.minio/certs/private.key" ``` Certificates per [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md); clients then use `https://` endpoints and, for self-signed, trust the CA rather than disabling verification. ## 3. Exposure posture MinIO's S3 API binds every interface by default: `--address` defaults to `:9000` (all IPv4 and IPv6 addresses), so a default install is reachable from any network the host is on, not loopback-only. Bind it explicitly with `--address 127.0.0.1:9000` (or a private address), and expose public access only via the TLS endpoints above or behind a proxy/tunnel ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)). The web console is a second listener, configured independently of the S3 API: MinIO serves its embedded console on the address set by `--console-address` (env `MINIO_CONSOLE_ADDRESS`), and left unset it picks a free port at startup and prints the console URL in its log. A console address with no host, whether the default or an explicit `:9001`, binds every interface even when the S3 API is bound to loopback, so the console can be reachable while the API is not. Bind it explicitly with `--console-address 127.0.0.1:9001` (or a private address), keep it off the public internet, and give human logins MFA at the fronting layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). Buckets are private unless a policy says otherwise; before exposing anything, inspect every bucket's full anonymous policy with `mc anonymous get-json ALIAS/BUCKET` (it shows the download and upload grants, resource prefixes, and conditions; the GET probes below cannot audit upload permissions or untested objects and prefixes) and remove any unintended anonymous access. ### 3.1. Disable the embedded Console when unnecessary Once Console-based setup is complete, disable the embedded Console if administration uses `mc` or other API clients. Add this to the protected server environment file on every node and restart AIStor: ```dotenv MINIO_BROWSER="off" ``` `MINIO_BROWSER_REDIRECT` controls automatic browser redirection to the Console; disabling redirection does not disable the Console. Use `MINIO_BROWSER` for that control. Source: [AIStor Console settings](https://docs.min.io/aistor/reference/aistor-server/settings/console/). ### 3.2. Set a workload-sized API concurrency budget The automatic request budget is based on available host RAM. If it exceeds what the deployment's storage, CPU, or workload can sustain, configure an explicit value in the server environment on every node and restart AIStor: ```dotenv MINIO_API_REQUESTS_MAX="64" ``` This is a cluster-wide budget for concurrent S3 and administrative API requests, divided among the nodes. For example, 64 across four nodes gives a request pool of 16 per node. It is neither requests per second nor a TCP-connection limit. The number is an illustrative workload-sizing choice, not a recommendation: measure representative workloads before selecting it. Source: [AIStor core settings](https://docs.min.io/aistor/reference/aistor-server/settings/core/). ## 4. Create scoped application access A leaked application key should expose only the application's required objects and operations. Give each application a non-root parent user and its own expiring access key. The built-in `readonly`, `writeonly`, `readwrite`, and `consoleAdmin` policies are broad: the first two allow their respective object operations across the deployment, `readwrite` grants every S3 action, and `consoleAdmin` also grants administrative access. `readonly` does not grant bucket listing. Create the parent user through the Console's IAM Users screen. The documented `mc admin user add` CLI requires a positional secret and has no stdin secret form, so do not use it to put a password in argv. Give the parent only the required policies; review group memberships too. Save this custom policy as `/path/to/REPLACE_WITH_APP_POLICY.json`, substituting the same bucket name in both resource entries. It permits bucket listing and object reads and writes in one bucket, with no deletion or administrative grants. Remove any action the application does not need. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetBucketLocation", "s3:ListBucket"], "Resource": ["arn:aws:s3:::REPLACE_WITH_APP_BUCKET"] }, { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::REPLACE_WITH_APP_BUCKET/*"] } ] } ``` Configure the administrative alias `mys3` using the stdin import in section 8 before running these commands. Choose an unused policy name and a new output file in an owner-only directory; creating an existing policy name replaces its policy. ```bash mc admin policy create mys3 app-bucket /path/to/REPLACE_WITH_APP_POLICY.json mc admin policy attach mys3 app-bucket --user REPLACE_WITH_APP_USER ( umask 077 mc admin accesskey create mys3/ REPLACE_WITH_APP_USER \ --policy /path/to/REPLACE_WITH_APP_POLICY.json \ --expiry-duration 24h > /path/to/REPLACE_WITH_NEW_ACCESSKEY_OUTPUT.txt ) ``` The explicit parent username prevents accidentally creating a child of the administrative identity. Omitting the access-key and secret-key options lets AIStor generate the pair. The output contains credentials: transfer them through your secret-management workflow, restrict the file to its owner, and keep it out of logs and version control. Populate the application's alias JSON from that pair using the format in section 8; the creation output is not itself an alias-import document. The `--policy` document is an inline restriction on the parent's permissions, not an independent grant. It cannot expand access beyond the parent's user and group policies, and its maximum size is 4096 bytes. Check the size after substitution. Arrange rotation before the example's 24-hour expiry; retire the old key through the Console after the application switches. Identity policies and anonymous bucket policies are separate controls. Review both: a scoped key does not remove an anonymous grant on another bucket. ### 4.1. Map OIDC identities to scoped policies Authentication does not establish an appropriate authorization boundary. With role-policy mapping, every authentication request using the provider's `RoleArn` receives the configured policies. Assigning a broad policy therefore spreads that access across the identities using that role. Source: [AIStor OIDC access management](https://docs.min.io/aistor/administration/iam/access/oidc-access/). For an already configured OIDC provider, choose exactly one mapping method. Remove the other method's explicit settings from both the environment and stored configuration; configuring both `role_policy` and `claim_name` is an error. Apply consistent settings across nodes and restart AIStor. The examples below set the default configuration. For a named configuration, append `_` followed by its case-sensitive name to each variable (for example `MINIO_IDENTITY_OPENID_ROLE_POLICY_PARTNERS` for a configuration named `PARTNERS`). When that configuration uses role-policy mapping, have clients supply its `RoleArn` in their STS request. Claim mapping can serve only one OIDC provider per deployment; every other provider must use role-policy mapping. Sources: [AIStor OIDC identity management](https://docs.min.io/aistor/administration/iam/identity/oidc-identity/), [OpenID settings](https://docs.min.io/aistor/reference/aistor-server/settings/iam/openid/) (multiple configurations), and [OIDC access management](https://docs.min.io/aistor/administration/iam/access/oidc-access/). Use role-policy mapping only when every identity admitted through that role should receive the section 4 application's access: ```dotenv MINIO_IDENTITY_OPENID_ROLE_POLICY="app-bucket" ``` The `app-bucket` policy must already exist with the intended bucket substitutions. Clients must supply the provider's corresponding `RoleArn` in their STS request. Source: [AIStor OpenID settings](https://docs.min.io/aistor/reference/aistor-server/settings/iam/openid/). Alternatively, select claim mapping when identities need different policies: ```dotenv MINIO_IDENTITY_OPENID_CLAIM_NAME="policy" ``` Configure the IdP to issue `"policy": "app-bucket"` only for identities entitled to that policy; other identities receive their own existing, narrowly scoped policy names. Treat this claim as an authorization decision controlled by the IdP administrator. An identity whose token maps to no policy gains no identity-based access; an anonymous bucket policy (sections 3 and 4) still grants its access to anyone. Sources: [AIStor OpenID settings](https://docs.min.io/aistor/reference/aistor-server/settings/iam/openid/), [OIDC identity management](https://docs.min.io/aistor/administration/iam/identity/oidc-identity/), and [OIDC access management](https://docs.min.io/aistor/administration/iam/access/oidc-access/). Do not describe a short `MINIO_STS_DURATION` as a universal maximum. `AssumeRoleWithWebIdentity` accepts explicit `DurationSeconds` values from 900 through 31536000 seconds (365 days); with a usable JWT expiry, an explicit duration takes precedence over that expiry. `MINIO_STS_DURATION` is a default, not this ceiling. AIStor separately documents the native `sts:DurationSeconds` policy condition for restricting WebIdentity credential duration. Sources: [AssumeRoleWithWebIdentity](https://docs.min.io/aistor/developers/security-token-service/assumerolewithwebidentity/), [STS settings](https://docs.min.io/aistor/reference/aistor-server/settings/iam/sts/), and [access-management condition keys](https://docs.min.io/aistor/administration/iam/access/). ### 4.2. Shorten presigned URLs and cap signature age A presigned download URL contains access credentials: possession permits its authorized download even when the bucket is private. Generate it with the scoped `app` identity and a short expiry; if the `app` alias does not exist yet, import it from the section 4 access key with the stdin form in section 8 (`mc alias import app < /path/to/REPLACE_WITH_APP_ALIAS.json`). Save the output in a new file inside an owner-only directory, and share the URL through a protected channel: ```bash ( umask 077 set -C mc share download --expire 5m \ app/REPLACE_WITH_APP_BUCKET/REPLACE_WITH_KNOWN_PRIVATE_OBJECT \ > /path/to/REPLACE_WITH_NEW_SHARE_OUTPUT.txt ) ``` The example requests five minutes instead of `mc share download`'s default seven days. Keep the output out of logs and version control. Source: [AIStor `mc share download`](https://docs.min.io/aistor/reference/cli/mc-share/mc-share-download/). Add a server-enforced policy restriction as well. AIStor documents support for `s3:signatureAge` and AWS-compatible policy evaluation. The following example uses the referenced S3 semantics: 600000 milliseconds is ten minutes, and `NumericGreaterThan` matches an older signature. Its expected AIStor behaviour remains subject to the REASONED comparison below. Sources: [AIStor access management](https://docs.min.io/aistor/administration/iam/access/) and the supplementary [S3 signature-condition reference](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/bucket-policy-s3-sigv4-conditions.html). Save this as `/path/to/REPLACE_WITH_SIGNATURE_AGE_POLICY.json`, substituting the section 4 bucket name: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Deny", "Action": ["s3:*"], "Resource": [ "arn:aws:s3:::REPLACE_WITH_APP_BUCKET", "arn:aws:s3:::REPLACE_WITH_APP_BUCKET/*" ], "Condition": { "NumericGreaterThan": { "s3:signatureAge": "600000" } } } ] } ``` Choose an unused policy name; creating an existing name overwrites its policy. Attach the restriction to the existing non-root application parent user, retaining its `app-bucket` grant: ```bash mc admin policy create mys3 app-signature-age /path/to/REPLACE_WITH_SIGNATURE_AGE_POLICY.json mc admin policy attach mys3 app-signature-age --user REPLACE_WITH_APP_USER ``` Sources: [AIStor `mc admin policy create`](https://docs.min.io/aistor/reference/cli/admin/mc-admin-policy/mc-admin-policy-create/) and [`mc admin policy attach`](https://docs.min.io/aistor/reference/cli/admin/mc-admin-policy/mc-admin-policy-attach/). A matching explicit deny overrides an allow. This attachment restricts the governed application identity and its inherited access on the named resources; it is not a deployment-wide ban on older presigned URLs. It does not attach the restriction to unrelated identities or OIDC roles, or remove anonymous grants. Review those separately. Source: [AIStor access management](https://docs.min.io/aistor/administration/iam/access/). ## 5. Encrypt stored data with a key manager Stolen storage should not yield readable object data or backend credentials. SSE-S3 uses the deployment's default external key; SSE-KMS supports a selected key for a bucket or write request. Both require a configured key manager. Server-side encryption does not replace TLS or access policy. Use MinIO KMS for new deployments. KES is the supported legacy integration with an external third-party KMS. Configure exactly one of these alternatives in the protected server environment file on every node, normally `/etc/default/minio`. These are file contents, not command-line arguments. Provision the named keys and authorized identities first, trust the key manager's CA, and restart AIStor after applying the settings. For MinIO KMS, use the actual endpoint, enclave, default key name, and complete API key issued for the enclave identity: ```dotenv MINIO_KMS_SERVER="https://kms.example.com:7373" MINIO_KMS_ENCLAVE="REPLACE_WITH_ENCLAVE" MINIO_KMS_SSE_KEY="REPLACE_WITH_EXISTING_DEFAULT_KEY_NAME" MINIO_KMS_API_KEY="REPLACE_WITH_KMS_API_KEY" MINIO_KMS_AUTO_ENCRYPTION="on" ``` For an existing KES integration, the certificate-based alternative is: ```dotenv MINIO_KMS_KES_ENDPOINT="https://kes.example.com:7373" MINIO_KMS_KES_KEY_NAME="REPLACE_WITH_EXISTING_DEFAULT_KEY_NAME" MINIO_KMS_KES_CERT_FILE="/etc/minio/kes/client.crt" MINIO_KMS_KES_KEY_FILE="/etc/minio/kes/client.key" MINIO_KMS_KES_CAPATH="/etc/minio/kes/ca.crt" MINIO_KMS_AUTO_ENCRYPTION="on" ``` Keep the MinIO KMS and KES configurations mutually exclusive. Within KES, the certificate and private-key settings must be supplied together and are mutually exclusive with `MINIO_KMS_KES_API_KEY`. `MINIO_KMS_KES_CAPATH` identifies the CA certificate used to validate the KES server when needed. Protect the API key, client private key, and environment file. `MINIO_KMS_AUTO_ENCRYPTION` defaults to `on` when a key manager is configured, automatically encrypting new object writes using SSE-KMS. An unconfigured installation does not acquire encryption merely because this default exists. Setting it to `off` disables that automatic object encryption; configure bucket encryption explicitly if doing so. Choose the required bucket default. For SSE-KMS with an existing key: ```bash mc encrypt set sse-kms REPLACE_WITH_EXISTING_BUCKET_KEY_NAME mys3/REPLACE_WITH_BUCKET ``` Alternatively, use SSE-S3 with the deployment's default key: ```bash mc encrypt set sse-s3 mys3/REPLACE_WITH_BUCKET ``` Enabling SSE also encrypts backend data, including IAM and configuration data. Backend encryption cannot be disabled or reset, and normal startup then depends on access to the key manager and the backend key. Preserve that key and test key-manager recovery; changing the automatic-object-encryption setting does not remove this dependency. Bucket encryption settings affect subsequent writes. They do not rewrite or establish the encryption state of historical objects; inspect existing versions and plan any required migration separately. ## 6. Protect object versions from deletion Versioning preserves older writes, but an identity with deletion rights can still remove individual versions. Object locking adds retention to protect those versions. For a new bucket, enable locking and set a default retention period: ```bash mc mb --with-lock mys3/REPLACE_WITH_NEW_BUCKET mc retention set --default GOVERNANCE 30d mys3/REPLACE_WITH_NEW_BUCKET ``` `--with-lock` also enables versioning; it does not itself set a retention period. Choose `GOVERNANCE` or `COMPLIANCE` and an `Nd` duration, such as `30d`, according to the required protection. AIStor `RELEASE.2025-05-20T20-30-00Z` or later can enable object locking on an existing bucket. Enable versioning first if necessary, then set default retention: ```bash mc version enable mys3/REPLACE_WITH_EXISTING_BUCKET mc retention set --default GOVERNANCE 30d mys3/REPLACE_WITH_EXISTING_BUCKET ``` The Console does not support this retrofit. Older releases required locking at bucket creation; that is not a blanket limitation of current AIStor. `GOVERNANCE` permits authorized bypass. Keep `s3:BypassGovernanceRetention` off application identities and their inherited policies. `COMPLIANCE` prevents deletion of the protected version even by root until retention expires; choose its period before applying it. `--default` ignores the other flags, including `--recursive`, and supplies retention for new objects without an explicit override. It does not protect historical versions. Apply retention to each required existing version and inspect its resulting retain-until date, especially for older data: ```bash mc retention set --version-id REPLACE_WITH_VERSION_ID GOVERNANCE 30d \ mys3/REPLACE_WITH_BUCKET/REPLACE_WITH_OBJECT mc legalhold set --version-id REPLACE_WITH_VERSION_ID \ mys3/REPLACE_WITH_BUCKET/REPLACE_WITH_OBJECT ``` The second command adds an indefinite legal hold. Only an identity with `s3:PutObjectLegalHold` can set or lift it; keep that permission restricted. A version with both protections remains locked until retention has expired and the legal hold has been lifted. An ordinary delete can still create a delete marker that hides the object from an unversioned read. Verify the retained version by its exact version ID. ## 7. Publish audit logs outside the object store Without an independent audit trail, misuse can leave no durable record outside the affected deployment. AIStor publishes no audit logs by default. Configure a remote receiver and protect its authentication, storage, and administrative access; records can contain sensitive request details, hostnames, IP addresses, object names, and headers. For an authenticated HTTPS webhook, add these settings to the protected server environment file on every node: ```dotenv MINIO_AUDIT_WEBHOOK_ENABLE="on" MINIO_AUDIT_WEBHOOK_ENDPOINT="https://audit.example.com/minio/events" MINIO_AUDIT_WEBHOOK_AUTH_TOKEN="Bearer REPLACE_WITH_RECEIVER_TOKEN" MINIO_AUDIT_WEBHOOK_TLS_SKIP_VERIFY="false" MINIO_AUDIT_QUEUE_DIR="/var/lib/minio/audit-queue" ``` The authentication value is sent as supplied; include `Bearer` only when the receiver expects that scheme. Trust the receiver's CA and keep TLS certificate verification enabled. Alternatively, configure an authenticated Kafka target with credentials limited to the audit topic: ```dotenv MINIO_AUDIT_KAFKA_ENABLE="on" MINIO_AUDIT_KAFKA_BROKERS="kafka.example.com:9093" MINIO_AUDIT_KAFKA_TOPIC="minio-audit" MINIO_AUDIT_KAFKA_TLS="on" MINIO_AUDIT_KAFKA_TLS_SKIP_VERIFY="false" MINIO_AUDIT_KAFKA_SASL="on" MINIO_AUDIT_KAFKA_SASL_USERNAME="REPLACE_WITH_AUDIT_PRODUCER" MINIO_AUDIT_KAFKA_SASL_PASSWORD="REPLACE_WITH_KAFKA_PASSWORD" MINIO_AUDIT_KAFKA_SASL_MECHANISM="plain" MINIO_AUDIT_QUEUE_DIR="/var/lib/minio/audit-queue" ``` Use the broker's actual TLS listener and supported SASL mechanism. Kafka TLS defaults to `off`, so enable it explicitly; the example uses SASL PLAIN only over verified TLS. For either target, provision `MINIO_AUDIT_QUEUE_DIR` on persistent storage on each node. The AIStor service account needs read, write, and directory-listing access; restrict other access. Restart AIStor to apply the environment settings. AIStor retries failed delivery, but events can be permanently lost when the queue fills. Monitor receiver availability, queue capacity, and delivery failures. A webhook 2xx means the receiver acknowledged the request; it does not prove durable storage, and AIStor cannot recover an acknowledged event that the receiver failed to store. Keep receiver retention and deletion rights independent of application access to the object store. ## 8. Verify (REASONED: every unit in this section rests on the local evidence and comparison-specific declarations throughout this section, citing Sources entries including AIStor access management (built-in policies, custom policy structure, actions, and resources); MinIO `mc anonymous get-json` (retrieve a bucket's anonymous policy as JSON to inspect grants, prefixes, and conditions). A live AIStor, KMS, audit receiver, and IdP stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md) REASONED: only local parsing, shell behaviour and arithmetic were exercised in an offline scratch clone. The eight Bash blocks this section held before this change passed bash -n and ShellCheck 0.11.0 with exit 0 and no output in earlier forms (for the exposure, presigned-URL and Console-disabled blocks, forms before their last guard changes); the three JSON examples in sections 4, 4.2 and 8 parsed with Python json.loads. Those earlier shell runs used mc, curl, and ss stubs, with the two alias-file input paths mapped to a scratch JSON fixture, and the first six blocks printed stub responses and forwarded their placeholders unchanged. Every Bash block here has changed since then. Each refuses with status 2, before it runs any probe, when a value on its `set --` line is missing or unusable or, in the two blocks that list listeners, when ss is not installed. The anonymous S3 probe and console reachability blocks also refuse with status 2 if the `PASTE_WHOLE_BLOCK` marker that opens their `set --` line is missing, as when that line is left out of the paste, printing "paste the whole block, including its set -- line; not probing", or if that line does not carry exactly three values after the marker (the probe) or two (the console block). Both also refuse with status 2 if the host is example.com, example.net, example.org or one of their subdomains, in any letter case, and the console block also refuses a port above 65535. In a shell other than bash, such as dash or busybox sh, where `shopt` does not exist, both refuse with status 2, printing "this block needs bash; run it in bash; not probing", but only once the marker, value-count and host-syntax checks have passed; before that, a missing marker, a wrong value count or an unusable host, such as the shipped placeholders, gets its own message. Each records every classified result and every failed command in one exit status: 0 for a pass or for an observation it says it cannot classify, 1 for a finding and 3 for an inconclusive result, a finding outranking an inconclusive result. The current form of all ten Bash blocks is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite. Stub runs with mc, curl and ss stubs checked each block's guards and its exit status for each classified result, each failed command and inherited readonly, declare -i and set -e, on the current form of eight blocks and on the earlier form of the anonymous S3 probe and console reachability blocks, which lowercased the host with `tr` before refusing reserved names. The records of those two blocks also give stub runs of their current form. Each block's record, which follows the expected results of its comparison, says what its runs covered, and the console reachability block's record says why it cannot report a pass. The live comparisons need AIStor, KMS, a durable audit receiver, and an IdP; a live AIStor, KMS, audit receiver, and IdP stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Credentials and authenticated STS/S3 and load clients with per-node metrics collection are also required. Sources entries include AIStor access management (built-in policies, custom policy structure, actions, and resources); MinIO `mc anonymous get-json` (retrieve a bucket's anonymous policy as JSON to inspect grants, prefixes, and conditions); each comparison's declaration identifies its specific sources. Stub responses establish argument and stdin handling only; every exposed/fixed service outcome below remains an expectation, not an observation. Substitute deployment-specific values and use harmless private fixtures. Run checks in stages, confirming every expected-success operation before interpreting a denial. A timeout, TLS failure, invalid credential, missing object, or proxy-generated error is not evidence that the intended security control worked. REASONED in this authoring environment: the existing probes below are retained, but their live outcomes were not demonstrated here. A live AIStor, KMS, audit receiver, and IdP stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. REASONED: the alias JSON block below; alias JSON follows the cited mc alias import schema; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Python json.loads parsed the five-field alias example; importing and authenticating with these fields still requires a live endpoint. Per the guide's cited AIStor `mc alias import` (stdin and JSON fields). These existing citations were not reopened. Prepare `/path/to/REPLACE_WITH_ADMIN_ALIAS.json` as an owner-only credential file with these fields: ```json { "url": "https://s3.example.com:9000", "accessKey": "REPLACE_WITH_ACCESS_KEY", "secretKey": "REPLACE_WITH_SECRET_KEY", "api": "s3v4", "path": "auto" } ``` Use root credentials in `mys3` only for the administrative comparisons below. Protect both the imported file and the resulting local `mc` configuration. Importing an alias is configuration, not proof of working authentication; confirm signed operations against MinIO's own endpoint. ```bash ( # a subshell, so the exits below end only the subshell command -v ss > /dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tlnp; then # read every listener; S3 API 9000 and the console (--console-address, ~9001); private unless deliberate echo "block exit 0; unclassified observation: this block does not judge listeners, so read every listener above against section 3"; exit 0 else echo "ss -tlnp exit $?: the listener list failed; inconclusive"; exit 3 fi ) ``` ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a request that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_S3_HOST' 'REPLACE_WITH_BUCKET' 'REPLACE_WITH_PRIVATE_OBJECT' # the host name MinIO's own S3 API answers on (its direct origin), no scheme, user name, port, path, query, fragment, percent sign or trailing dot; then a bucket name and the key of a known private object in that bucket: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|/*|*'?'*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the key of a known private object in that bucket (no leading slash, question mark, hash sign, whitespace or control character) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- 0 "$@" # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0; then the host, bucket and object # -S makes curl show an error message if it fails despite -s (cited curl show-error). This guide's assessment: a 000 status beside a "curl: (N) ..." error line means no HTTP response arrived; that is inconclusive, never a denial and never proof of isolation set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' "https://$2:9000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # service root: anonymous ListBuckets denied set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" case "$5:$6" in 0:403) ;; 0:2??) echo "FINDING: the service root answered $6 to an anonymous request, so anonymous ListBuckets is allowed"; set -- 1 "${@:2}" ;; 0:*) echo "HTTP $6 from the service root is neither the 403 denial nor a 2xx finding; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; *) echo "curl exit $5: no HTTP response from the service root; inconclusive, never a denial"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' "https://$2:9000/$3/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # per bucket: anonymous listing denied set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" case "$5:$6" in 0:403) ;; 0:2??) echo "FINDING: the bucket answered $6 to an anonymous listing request, so anonymous listing is allowed"; set -- 1 "${@:2}" ;; 0:*) echo "HTTP $6 from the bucket is neither the 403 denial nor a 2xx finding (a 404 means the bucket name is wrong); inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; *) echo "curl exit $5: no HTTP response for the bucket listing; inconclusive, never a denial"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' "https://$2:9000/$3/$4"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$6" # a known private object: 403, never 200, and the 403 must come from MinIO (behind a proxy a 403 may be the proxy's, so probe MinIO's direct origin). Anonymous policies are set per # bucket, so a denial at the service root does not prove any bucket is private case "$5:$6" in 0:403) ;; 0:2??) echo "FINDING: the known private object answered $6 to an anonymous read"; set -- 1 "${@:2}" ;; 0:*) echo "HTTP $6 from the known private object is neither the 403 denial nor a 2xx finding (a 404 means the key or bucket is wrong); inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; *) echo "curl exit $5: no HTTP response for the known private object; inconclusive, never a denial"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac echo "block exit $1; a 403 counts as MinIO's denial here only because the host is MinIO's direct origin, so confirm that yourself, and review every full anonymous policy as section 3 describes" exit "$1" ) ``` ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a failed request is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_S3_HOST' 'REPLACE_WITH_CONSOLE_PORT' # the same host name as the block above, then the ACTUAL console port from ss in the first block (or the startup-log URL), not a guessed 9001 (an unset --console-address picks a random port): replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*[!0123456789]*|0*|??????*) echo "substitute the console port, digits only, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ "$2" -le 65535 ] || { echo "the console port must be at most 65535; not probing"; exit 2; } set -- "$1" "$2" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' "https://$1:$2/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # console: any response from outside, not only 200, means it is reachable; a refusal, timeout, or TLS error is NOT proof of isolation set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" case "$4" in [123456789][0123456789][0123456789]) echo "FINDING: the console port answered HTTP $4 from this vantage, so the Console is reachable from here, which is a finding unless you exposed it here on purpose; block exit 1"; exit 1 ;; *) echo "curl exit $3: no HTTP response from the console port; a refusal, timeout, or TLS error is NOT proof of isolation, so this block cannot report a pass; block exit 3, inconclusive: judge the Console's exposure from the listener inventory and your network policy"; exit 3 ;; esac ) ``` For the exposure probes, an anonymous grant can produce successful listing or object reads; the private configuration must deny those operations at MinIO itself. Inspect every full anonymous policy as described in section 3, including upload grants. Listener inspection and probes from the relevant external network are both needed to assess console exposure. Sources: server address flags and anonymous-policy references below. REASONED: the listener inventory block above; listener scope follows the cited server address documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. This listener inventory was the first line of an earlier exposure block, whose local bash -n and ShellCheck 0.11.0 runs exited 0 without output and whose stub run printed a stub listener table. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: a missing ss exits 2, a failing ss exits 3, and a listing exits 0 with a line saying the listeners are not classified. Real listener scope was not observed. Per the guide's cited MinIO/AIStor server address flags (`--address` defaults to `:9000` on all interfaces; `--console-address` / `MINIO_CONSOLE_ADDRESS`: a static port for the embedded console UI, or a dynamic one logged at startup when omitted). This existing citation was not reopened. REASONED: the anonymous S3 probe block above; anonymous-denial expectations follow the cited anonymous-policy and curl documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. In an earlier form, before its S3 host, bucket and object moved to a guarded `set --` line and before it classified its results, local bash -n and ShellCheck 0.11.0 exited 0 without output on the exposure block, and its stub run recorded calls covering all four URLs. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite. Stub runs of the earlier form, which lowercased the S3 host with `tr` before refusing reserved names, showed that a 2xx from any of the three requests exits 1, a 403 from all three exits 0, and any other HTTP status or a curl failure exits 3 unless another request found a 2xx. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and it refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. In curl-stub runs of the current form on 2026-11-01 with no network, bash refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no request, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. In those runs a host name outside the reserved names reached all three requests. In stub runs of the current form on 2026-11-01 under Bash 5.3.9, dash and busybox sh, a missing `PASTE_WHOLE_BLOCK` marker, with and without caller arguments, and a `set --` line without exactly three values after the marker were refused with status 2, each with its own message and no request. Under dash and busybox sh, stub runs of the current form refused every host name that passed the host-syntax check with 2 and the bash message before any request, and the shipped placeholders with 2 and the host-syntax message. None of the 2026-11-01 runs used zsh, ksh or mksh. Anonymous grants versus MinIO denials were not observed. Per the guide's cited MinIO `mc anonymous get-json` (retrieve a bucket's anonymous policy as JSON to inspect grants, prefixes, and conditions); curl show-error. These existing citations were not reopened. REASONED: the console reachability block above; console reachability follows the cited server address documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. This console request was part of the earlier exposure block, whose local bash -n, ShellCheck 0.11.0 and stub runs covered it with a fixed port 9001. The current form takes the console port on its guarded `set --` line and is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite. Stub runs of the earlier form, which lowercased the host with `tr` before refusing reserved names, showed that any HTTP status exits 1 and no HTTP response exits 3. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and it refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. In curl-stub runs of the current form on 2026-11-01 with no network, bash refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no request, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. In those runs a host name outside the reserved names reached the console request. In stub runs of the current form on 2026-11-01 under Bash 5.3.9, dash and busybox sh, a missing `PASTE_WHOLE_BLOCK` marker, with and without caller arguments, and a `set --` line without exactly two values after the marker were refused with status 2, each with its own message and no request. Under Bash 5.3.9 those runs also refused a port above 65535 with 2, its own message and no request. Under dash and busybox sh, stub runs of the current form refused every host name that passed the host-syntax check with 2 and the bash message before the request, and the shipped placeholders with 2 and the host-syntax message. None of the 2026-11-01 runs used zsh, ksh or mksh. A refusal, timeout or TLS error is not proof of isolation, so that block cannot report a pass; run it only from a network the Console should not reach. Per the guide's cited MinIO/AIStor server address flags (`--address` defaults to `:9000` on all interfaces; `--console-address` / `MINIO_CONSOLE_ADDRESS`: a static port for the embedded console UI, or a dynamic one logged at startup when omitted). This existing citation was not reopened. **Scoped access, REASONED:** a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Prepare two different buckets containing known, harmless private objects. The application's policy must name only the application bucket. Build the app alias JSON with the generated scoped key and the same endpoint, `api`, and `path` fields as above. ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK '/path/to/REPLACE_WITH_ADMIN_ALIAS.json' '/path/to/REPLACE_WITH_APP_ALIAS.json' 'REPLACE_WITH_APP_BUCKET' 'REPLACE_WITH_OTHER_BUCKET' 'REPLACE_WITH_KNOWN_PRIVATE_OBJECT' 'REPLACE_WITH_UNIQUE_PROBE_OBJECT' # the root alias file, the app alias file, the app bucket, the other bucket, the known private object's key (present in both buckets) and a new key for the write probe: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 6 ] || { echo "the set -- line needs exactly 6 values; not probing"; exit 2; } case "$1|$2" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute both alias file paths inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ -f "$1" ] || { echo "the root alias file named on the set -- line above is not a readable regular file; not probing"; exit 2; } [ -r "$1" ] || { echo "the root alias file named on the set -- line above is not a readable regular file; not probing"; exit 2; } [ -f "$2" ] || { echo "the app alias file named on the set -- line above is not a readable regular file; not probing"; exit 2; } [ -r "$2" ] || { echo "the app alias file named on the set -- line above is not a readable regular file; not probing"; exit 2; } case "$3" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the app bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$4" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the other bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ "$3" != "$4" ] || { echo "the app bucket and the other bucket must be two different buckets; not probing"; exit 2; } case "$5|$6" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute both object keys inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$5" in ""|/*) echo "substitute the known private object's key, without a leading slash, on the set -- line above; not probing"; exit 2 ;; esac case "$6" in ""|/*) echo "substitute a new key for the write probe, without a leading slash, on the set -- line above; not probing"; exit 2 ;; esac [ "$5" != "$6" ] || { echo "the write probe needs a new key, not the known private object's; not probing"; exit 2; } set -- 0 "$@" # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0; then the six values above mc alias import mys3 < "$2" || { echo "mc alias import mys3 exit $?: the root alias did not import, so the reads below could use an older alias; inconclusive, not reading"; exit 3; } mc alias import app < "$3" || { echo "mc alias import app exit $?: the app alias did not import, so the reads below could use an older alias; inconclusive, not reading"; exit 3; } mc anonymous get-json "mys3/$4" || { echo "mc anonymous get-json exit $?: the app bucket's anonymous policy was not read; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } mc anonymous get-json "mys3/$5" || { echo "mc anonymous get-json exit $?: the other bucket's anonymous policy was not read; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } mc cat "mys3/$4/$6" || { echo "mc cat exit $?: root could not read the app bucket's known object, so the denial below cannot be interpreted; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } mc cat "mys3/$5/$6" || { echo "mc cat exit $?: root could not read the other bucket's known object, so the denial below cannot be interpreted; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } mc cat "app/$4/$6" || { echo "mc cat exit $?: the scoped key could not read its own bucket's known object; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } printf '%s\n' 'scoped write probe' | mc pipe "app/$4/$7" || { echo "mc pipe exit $?: the scoped key could not write its probe; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } set -- "${@:1:7}" "$(if mc cat "app/$5/$6" 2>&1 > /dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" # MinIO must deny this read set -- "${@:1:7}" "${8##*$'\n'}" "${8%$'\n'*}" # mc's exit status, then its error text printf '%s\n' "$9" case "$8:$9" in 0:*) echo "FINDING: the scoped key read the other bucket's known object, so its access reaches beyond the app bucket"; set -- 1 "${@:2}" ;; *'Access Denied'*) ;; *) echo "mc cat exit $8 without an Access Denied error: the read failed, but not visibly by MinIO's denial; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac echo "block exit $1; the anonymous policies printed above are not classified, so confirm they exclude anonymous access to both fixtures" exit "$1" ) ``` Confirm the policy reviews exclude anonymous access to both fixtures. Root must read both known objects. The scoped key must read its own fixture and write its probe, but MinIO must deny its read of the other bucket's known object. A root or overly broad application key would read both. A generic denial alone is insufficient: the successful reads establish the objects, endpoint, and working credentials. Sources: access management, access-key creation, `mc cat`, and `mc pipe`. REASONED: the scoped-access block above; scoped-access comparisons follow the cited access-management and mc documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. In an earlier form, before the root alias import moved here from the exposure block and before its values moved to a guarded `set --` line, local bash -n and ShellCheck 0.11.0 exited 0 without output on this block and on that import. A fixture alias input and mc stub then recorded alias JSON on stdin, both policy queries, all reads, and the write payload; output was stub responses, not authorization results. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: a failed import exits 3 before any read, and a failed policy query, control read or write records 3. In those runs, the scoped key reading the other bucket's object exits 1, and a failure of that read without an Access Denied error records 3. The expected broad-key versus scoped-key outcomes are described above. Per the guide's cited AIStor `mc alias import` (stdin and JSON fields); AIStor access management (built-in policies, custom policy structure, actions, and resources); AIStor `mc cat`; AIStor `mc pipe`. These existing citations were not reopened. **Encryption, REASONED:** a live AIStor and KMS stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. After configuring a working key manager and the intended bucket default, write a new object without a client encryption override: ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BUCKET' 'REPLACE_WITH_NEW_ENCRYPTION_PROBE' # the bucket, then a new object key for the encryption probe: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|/*|*[[:cntrl:]]*) echo "substitute a new object key, without a leading slash or control character, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- 0 "$@" # the block's status so far: 3 (inconclusive) outranks 0; then the bucket and the key mc encrypt info "mys3/$2" || { echo "mc encrypt info exit $?: the bucket's encryption configuration was not read; inconclusive"; set -- 3 "${@:2}"; } if printf '%s\n' 'encryption probe' | mc pipe "mys3/$2/$3"; then mc stat "mys3/$2/$3" || { echo "mc stat exit $?: the new object's metadata was not read; inconclusive"; set -- 3 "${@:2}"; } else echo "mc pipe exit $?: the write failed, and a failed write proves neither state; inconclusive, not inspecting"; set -- 3 "${@:2}" fi echo "block exit $1; the bucket default and the new object's SSE metadata above are not classified, so compare them with the intended configuration and KMS key yourself" exit "$1" ) ``` Confirm the bucket default and the newly written object's SSE metadata, including the intended KMS key for SSE-KMS. In a disposable unencrypted comparison deployment with no key manager, a successful ordinary write lacks SSE metadata; after the control is configured, the new write must carry it. A failed write proves neither state. `mc encrypt info` reports bucket configuration, not historical-object encryption, and absence of a bucket rule does not prove plaintext when server-wide automatic encryption applies. Sources: `mc encrypt info`, `mc encrypt set`, `mc stat`, and the MinIO KMS setup guide. REASONED: the encryption block above; encryption comparison follows the cited KMS, encrypt and stat documentation; a live AIStor and KMS stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. In an earlier form, before its values moved to a guarded `set --` line, local bash -n and ShellCheck 0.11.0 exited 0 without output, and the mc stub recorded encrypt info, the write payload, and stat, printing stub responses. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: a failed command records 3, a failed write skips the inspection, and success exits 0 with the SSE metadata left for the reader. Successful unencrypted versus encrypted writes still require the live KMS comparison described above. Per the guide's cited AIStor `mc encrypt info`; AIStor `mc stat` (metadata and specific-version inspection); AIStor MinIO KMS setup (environment settings, backend dependency, and object verification). These existing citations were not reopened. **Retention, REASONED:** a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Use a newly created disposable bucket with locking enabled and no default retention. Stop if creation or either write fails. These fixtures compare an unlocked version with a protected version under the same root identity. ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NEW_RETENTION_TEST_BUCKET' # a new bucket name for this test: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute a new bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- 0 "$1" # the block's status so far: 3 (inconclusive) outranks 0; then the bucket mc mb --with-lock "mys3/$2" || { echo "mc mb exit $?: the locked test bucket was not created; stop, block exit 3, inconclusive"; exit 3; } printf '%s\n' 'unlocked control' | mc pipe "mys3/$2/control.txt" || { echo "mc pipe exit $?: the control write failed; inconclusive"; set -- 3 "$2"; } printf '%s\n' 'retained probe' | mc pipe "mys3/$2/protected.txt" || { echo "mc pipe exit $?: the protected write failed; inconclusive"; set -- 3 "$2"; } [ "$1" = 0 ] || { echo "block exit 3; stop: a write failed, so there are no version IDs to record"; exit 3; } mc stat "mys3/$2/control.txt" || { echo "mc stat exit $?: the control object was not inspected; inconclusive"; set -- 3 "$2"; } mc stat "mys3/$2/protected.txt" || { echo "mc stat exit $?: the protected object was not inspected; inconclusive"; set -- 3 "$2"; } echo "block exit $1; the version IDs above are not classified: record them for the next block" exit "$1" ) ``` Record the exact version IDs from the successful object inspections. Substitute them below. The protected fixture receives a one-day COMPLIANCE lock and cannot be deleted early, including by root. ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NEW_RETENTION_TEST_BUCKET' 'REPLACE_WITH_PROTECTED_VERSION' 'REPLACE_WITH_CONTROL_VERSION' # the test bucket, then the protected and control version IDs recorded above: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the test bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2|$3" in *REPLACE_WITH_*|*[[:space:][:cntrl:]]*) echo "substitute both version IDs, without whitespace or control characters, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ -n "$2" ] || { echo "substitute both version IDs on the set -- line above; not probing"; exit 2; } [ -n "$3" ] || { echo "substitute both version IDs on the set -- line above; not probing"; exit 2; } [ "$2" != "$3" ] || { echo "the protected and control version IDs must differ; not probing"; exit 2; } set -- 0 "$@" # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0; then the bucket and the two version IDs mc retention set --version-id "$3" COMPLIANCE 1d \ "mys3/$2/protected.txt" || { echo "mc retention set exit $?: the protected version's retention was not set; inconclusive"; set -- 3 "${@:2}"; } mc retention info --version-id "$3" \ "mys3/$2/protected.txt" || { echo "mc retention info exit $?: the protected version's retention was not read; inconclusive"; set -- 3 "${@:2}"; } mc stat --version-id "$4" \ "mys3/$2/control.txt" || { echo "mc stat exit $?: the control version was not found; inconclusive"; set -- 3 "${@:2}"; } [ "$1" = 0 ] || { echo "block exit 3; stop: the protected version's retention or the control version is not established, so nothing was deleted"; exit 3; } mc rm --version-id "$4" \ "mys3/$2/control.txt" || { echo "mc rm exit $?: deleting the unlocked control failed, so the test has not isolated retention; inconclusive"; set -- 3 "${@:2}"; } set -- "${@:1:4}" "$(if mc stat --version-id "$4" \ "mys3/$2/control.txt" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${@:1:4}" "${5##*$'\n'}" "${5%$'\n'*}" # mc's exit status, then its output printf '%s\n' "$6" case "$5:$6" in 0:*) echo "the control version still answers after its delete, so the test has not isolated retention; inconclusive"; set -- 3 "${@:2}" ;; *'does not exist'*) ;; *) echo "mc stat exit $5 without a does-not-exist error: the control version's absence is not shown; inconclusive"; set -- 3 "${@:2}" ;; esac # This guide's assessment, not observed on a live server: MinIO refuses this delete with an error saying the object is WORM protected, so a failure without that text is not shown to be the retention refusal set -- "${@:1:4}" "$(if mc rm --version-id "$3" \ "mys3/$2/protected.txt" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # MinIO must refuse this delete set -- "${@:1:4}" "${5##*$'\n'}" "${5%$'\n'*}" # mc's exit status, then its output printf '%s\n' "$6" case "$5:$6" in 0:*) echo "FINDING: the COMPLIANCE-protected version was deleted before its retention expired"; set -- 1 "${@:2}" ;; *'WORM protected'*) ;; *) echo "mc rm exit $5 without a WORM-protected error: the delete failed, but not visibly by the COMPLIANCE retention refusal; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac mc stat --version-id "$3" \ "mys3/$2/protected.txt" || { echo "mc stat exit $?: the protected version did not answer after the delete attempt; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } mc retention info --version-id "$3" \ "mys3/$2/protected.txt" || { echo "mc retention info exit $?: the protected version's retention was not read after the delete attempt; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}"; } echo "block exit $1; the retention output above is not classified, so confirm the protected version still has COMPLIANCE retention with a future retain-until date" exit "$1" ) ``` Before deletion, confirm the protected version has COMPLIANCE retention with a future retain-until date and the control version exists. Deleting the unlocked control must succeed, and its subsequent inspection must report that specific version absent. Deleting the protected version must fail, while its subsequent inspection and retention query must still succeed for the same version ID. If both deletes fail, the test has not isolated retention. A delete marker or an unversioned read does not establish whether the retained version survived. Sources: object locking, `mc mb`, `mc retention set`, `mc retention info`, `mc rm`, and `mc stat`. REASONED: the retention fixture block above, the first of the two retention blocks; retention fixtures follow the cited object-locking and mc documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. In an earlier form, before its bucket name moved to a guarded `set --` line, local bash -n and ShellCheck 0.11.0 exited 0 without output, and the mc stub recorded bucket creation, both write payloads, and both inspections, printing stub responses. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: a failed creation exits 3 before any write, a failed write exits 3 before the inspections, a failed inspection records 3, and success exits 0 with the version IDs left for the reader. Real version IDs and unlocked versus protected versions require the live comparison described above. Per the guide's cited AIStor object locking (existing buckets, Console limitation, retention modes, and delete markers); AIStor `mc mb` (`--with-lock` enables versioning without setting retention); AIStor `mc stat` (metadata and specific-version inspection). These existing citations were not reopened. REASONED: the exact-version deletion block above; exact-version deletion comparison follows the cited retention, rm and stat documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. In an earlier form, before its bucket and version IDs moved to a guarded `set --` line, local bash -n and ShellCheck 0.11.0 exited 0 without output, and the mc stub recorded all eight operations with their version selectors and printed stub responses. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: a failure before the deletions exits 3 before any delete, and a successful delete of the protected version exits 1. In those runs, a failed control delete, a control version that still answers or fails without a does-not-exist error, a failed delete of the protected version without a WORM-protected error, or a failed later inspection records 3, and the retention output is left for the reader. Actual deletion of the unlocked version and survival of the COMPLIANCE version remain unobserved. Per the guide's cited AIStor `mc retention set` (default retention, existing buckets, ignored flags, and version IDs); AIStor `mc retention info`; AIStor `mc rm` (specific-version deletion); AIStor `mc stat` (metadata and specific-version inspection). These existing citations were not reopened. **Audit delivery, REASONED:** a live AIStor and durable audit receiver stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. After enabling the chosen target and restarting AIStor, use a unique object name and record the test time. Reuse the proven private object in the forbidden bucket for the denied request. ```bash ( # a subshell, so your own script arguments are untouched and the exits below end only the subshell set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_APP_BUCKET' 'REPLACE_WITH_UNIQUE_AUDIT_OBJECT' 'REPLACE_WITH_OTHER_BUCKET' 'REPLACE_WITH_KNOWN_PRIVATE_OBJECT' # the app bucket, a new object key for this test, the other bucket and its proven private object's key: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the app bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|*[!abcdefghijklmnopqrstuvwxyz0123456789.-]*) echo "substitute the other bucket name (lower-case letters, digits, dots and hyphens) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ "$1" != "$3" ] || { echo "the app bucket and the other bucket must be two different buckets; not probing"; exit 2; } case "$2|$4" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute both object keys inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|/*) echo "substitute a new object key, without a leading slash, on the set -- line above; not probing"; exit 2 ;; esac case "$4" in ""|/*) echo "substitute the proven private object's key, without a leading slash, on the set -- line above; not probing"; exit 2 ;; esac set -- 0 "$@" # the block's status so far: 3 (inconclusive) outranks 0; then the four values above printf '%s\n' 'audit delivery probe' | mc pipe "app/$2/$3" || { echo "mc pipe exit $?: the audit write failed; inconclusive"; set -- 3 "${@:2}"; } mc cat "app/$2/$3" || { echo "mc cat exit $?: the audit read failed; inconclusive"; set -- 3 "${@:2}"; } set -- "${@:1:5}" "$(if mc cat "app/$4/$5" 2>&1 > /dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the denied read the receiver must record set -- "${@:1:5}" "${6##*$'\n'}" "${6%$'\n'*}" # mc's exit status, then its error text printf '%s\n' "$7" case "$6:$7" in 0:*) echo "the read of the other bucket's object succeeded, so there is no denied request for the receiver to record; inconclusive (the scoped-access block classifies that read as a finding)"; set -- 3 "${@:2}" ;; *'Access Denied'*) ;; *) echo "mc cat exit $6 without an Access Denied error: the read failed, but not visibly by MinIO's denial; inconclusive"; set -- 3 "${@:2}" ;; esac echo "block exit $1; this block cannot see the audit receiver, so query its persisted records for these requests yourself: an S3 200 proves nothing about logging" exit "$1" ) ``` Query the receiver's persisted records for the matching bucket, object, operation, time, identity, status, and request ID. Confirm records for both the successful operations and the denied read, and confirm they remain available from durable storage, for example after restarting the receiver. With no target configured, these S3 requests can complete without any external audit record. With delivery configured, the matching records must actually be stored at the receiver. An S3 200 proves nothing about logging; a webhook 2xx proves only acknowledgement. Sources: audit logging, webhook delivery, and Kafka delivery. REASONED: the audit block above; durable delivery comparison follows the cited audit documentation; a live AIStor and durable audit receiver stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. In an earlier form, before its values moved to a guarded `set --` line, local bash -n and ShellCheck 0.11.0 exited 0 without output, and the mc stub recorded the write payload and both reads, printing stub responses. The current form is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: a failed write or read of the new object records 3, as does a successful read of the other bucket's object or a failure of that read without an Access Denied error. Otherwise it exits 0, because the receiver's records, which are the result, are left for the reader. Missing audit records without a target versus durable matching records with one still require the receiver comparison described above. Per the guide's cited AIStor audit logging (no default destination, queue loss, persistent queue, and event fields); AIStor webhook audit delivery (environment settings and acknowledgement limits); AIStor Kafka audit delivery (environment settings and restart). These existing citations were not reopened. REASONED: STS request fields, role/claim mapping, unmapped identities, and explicit duration are source reasoning only. Per the guide's cited AIStor OIDC access management (role and claim mapping); AIStor AssumeRoleWithWebIdentity (duration bounds and JWT-expiry precedence). These existing citations were not reopened. A live AIStor and IdP stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. The broad versus scoped reads and longer explicit session described below require an authenticated STS/S3 client and were not run. There is no executable SDK example here to exercise locally. **OIDC authorization, REASONED:** the live AIStor and IdP comparison requires the same unavailable isolation capability. Use two known private fixtures, one in the application bucket and one outside it, and establish administrative reads of both. Through an STS-capable SDK client, call `AssumeRoleWithWebIdentity` with `Version=2011-06-15`, a valid `WebIdentityToken`, and the configured `RoleArn` for role-policy mapping; omit `RoleArn` for claim mapping. Load tokens and returned credentials from protected storage, not command-line arguments or logs. Using the returned access key, secret key, and session token, issue `GetObject` for both fixtures. In a disposable broad-policy comparison, both reads succeed. After applying `app-bucket` mapping and obtaining fresh credentials, the application read must succeed and the other read must be denied by AIStor. For claim mapping, also test an identity without a mapped policy: it must gain no object access. A failed login alone does not demonstrate scoped authorization. Sources: [AIStor OIDC access management](https://docs.min.io/aistor/administration/iam/access/oidc-access/) and [AssumeRoleWithWebIdentity](https://docs.min.io/aistor/developers/security-token-service/assumerolewithwebidentity/). For the duration caveat, use a valid JWT expiring in approximately 20 minutes, `MINIO_STS_DURATION=15m`, and an explicit `DurationSeconds=3600` in a disposable deployment without an additional duration-restricting policy. Inspect the returned expiration and repeat the permitted read after JWT expiry but before STS expiry. The documented expectation is credentials lasting approximately one hour, despite the shorter default and JWT lifetime. This demonstrates the limitation of the default, not a failure of bucket scoping. Source: [AIStor AssumeRoleWithWebIdentity](https://docs.min.io/aistor/developers/security-token-service/assumerolewithwebidentity/). **Presigned URLs, REASONED:** a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Use a known private fixture and signing credentials that remain valid throughout the comparison. Confirm ordinary signed reads before and after each trial. Paste whole Bash blocks. Substitute inside the single quotes; values containing a literal apostrophe need proper shell quoting. Disable shell history recording and tracing before handling a real URL. The block keeps the URL out of curl's argv, but does not protect it from history, tracing, or the account owner. This block and the Console-disabled check below print the `exitcode` and `errormsg` write-out variables, which the cited curl manual dates to curl 7.75.0; use curl 7.75.0 or later. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +e # errexit off here, so a failed request is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PRESIGNED_URL' 'REPLACE_WITH_allow_OR_deny' # the URL, then allow where this GET should succeed (a new URL, the first GET, or the one-hour URL without the deny policy) or deny where it should be refused (the five-minute URL after six minutes, or the one-hour URL after eleven minutes with the deny policy) [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*example.com*|*[[:cntrl:]]*|"") echo "substitute the complete URL without control characters; not probing"; exit 2 ;; https://*) ;; *) echo "use the deployment HTTPS URL; not probing"; exit 2 ;; esac case "$2" in allow|deny) ;; *) echo "substitute allow (this GET should succeed) or deny (this GET should be refused) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "${1//\\/\\\\}" "$2" set -- "${1//\"/\\\"}" "$2" set -- "$1" "$2" "$(if printf 'url = "%s"\n' "$1" | curl -q -g -s --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --config -; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then its write-out line printf '%s\n' "$4" case "$2:$3:$4" in allow:0:http=2*) echo "block exit 0; the GET succeeded, as expected with allow; the comparisons below still need their other GETs"; exit 0 ;; deny:0:'http=403 '*) echo "block exit 0; the GET was refused with 403, as expected with deny: correlate that denial with AIStor's own request or audit evidence, which this block cannot see"; exit 0 ;; deny:0:http=2*) echo "FINDING: the GET succeeded although this URL should now be refused; block exit 1"; exit 1 ;; *:0:*) echo "the HTTP status above is not the result this block classifies for $2; block exit 3, inconclusive"; exit 3 ;; *) echo "curl exit $3: no HTTP response, and a network failure establishes neither control; block exit 3, inconclusive"; exit 3 ;; esac ) ``` Use URLs generated for the direct AIStor endpoint; do not alter their signed hostname or path. 1. Test a five-minute URL immediately and again after six minutes. The first GET must succeed; the later GET must fail while a newly generated URL succeeds. This isolates URL expiry before the ten-minute signature-age threshold. Source: [AIStor `mc share download`](https://docs.min.io/aistor/reference/cli/mc-share/mc-share-download/). 2. Separately generate a one-hour URL using the same section 4.2 command with `--expire 1h` and a new output filename. Without the deny policy in a disposable comparison, GETs immediately and after eleven minutes should succeed. Repeat with the deny policy attached: the immediate GET should succeed, but a new GET using that URL after eleven minutes should receive an AIStor denial while a fresh URL still succeeds. This distinguishes the signature-age restriction from the URL's own expiry. Sources: [AIStor supported condition keys](https://docs.min.io/aistor/administration/iam/access/) and [S3 signature-condition semantics](https://docs.aws.amazon.com/AmazonS3/latest/developerguide/bucket-policy-s3-sigv4-conditions.html). Correlate denials with AIStor's own request or audit evidence. A proxy error, expired signing key, missing object, or network failure does not establish either control. REASONED: the presigned-URL block above; URL expiry and signature-age comparisons follow the cited share-download and policy documentation; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Local bash -n and ShellCheck 0.11.0 exited 0 without output. With curl stubbed, the literal placeholder, empty value, embedded placeholder, example.com, control character, HTTP URL, wrong marker, and wrong argument counts printed their guard messages without invoking curl, including a short assignment under set -u. A fixture HTTPS URL reached curl via config stdin, not argv; a backslash/quote fixture was escaped in that input. Both admitted cases printed a stub response. The bash -n, ShellCheck and stub runs recorded here covered the presigned-URL block in an earlier form, whose guard refusals left the subshell with status 0 and which did not classify its request. The current form, which takes allow or deny on its `set --` line, is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: guard refusals exit 2, a 2xx exits 0 with allow and 1 with deny, a 403 exits 0 with deny, and any other result exits 3. These are local guard and formatting observations, not expiry or signature-age enforcement. Per the guide's cited AIStor `mc share download` (presigned credentials and expiry); AIStor access management (built-in policies, custom policy structure, actions, and resources); AWS S3 signature-condition reference (supplementary semantics for AIStor's documented condition key). These existing citations were not reopened. **Console disabled, REASONED:** a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Run this on each AIStor host with permission to inspect its listeners. Supply the direct Console URL established by section 3, without credentials or query parameters, and a proven application object: ```bash ( set +e # errexit off here, so a command that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DIRECT_CONSOLE_HTTPS_URL' 'app/REPLACE_WITH_APP_BUCKET/REPLACE_WITH_KNOWN_PRIVATE_OBJECT' 'REPLACE_WITH_enabled_OR_off' # the Console URL, the object, then enabled while the Console is still on or off after applying MINIO_BROWSER=off and restarting [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } [ -n "$1" ] || { echo "supply both values; not probing"; exit 2; } [ -n "$2" ] || { echo "supply both values; not probing"; exit 2; } case "$1|$2" in *REPLACE_WITH_*|*example.com*|*[[:cntrl:]]*) echo "substitute deployment values without control characters; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use the deployment HTTPS Console URL; not probing"; exit 2 ;; esac case "$3" in enabled|off) ;; *) echo "substitute enabled (the Console is still on) or off (after MINIO_BROWSER=off and a restart) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac command -v ss > /dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } mc cat "$2" > /dev/null || { echo "signed S3 read failed; stop"; exit 3; } set -- 0 "$@" # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0; a missing ss was refused with 2 above, before anything was recorded; then the three values above ss -tlnp || { echo "ss -tlnp exit $?: the listener list failed; inconclusive"; set -- 3 "${@:2}"; } set -- "${@:1:4}" "$(if curl -q -g -s --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${@:1:4}" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then its write-out line printf '%s\n' "$6" case "$6" in # the HTTP code from a write-out line that starts http= and three digits, or none when that line is missing or malformed 'http='[0123456789][0123456789][0123456789]' '*) set -- "${@:1:6}" "${6:5:3}" ;; *) set -- "${@:1:6}" none ;; esac case "$4:$5:$7" in *:none) echo "curl exit $5 without a valid http= write-out line: the result of the Console request is unknown; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; enabled:*:000) echo "no HTTP response from the Console URL while the Console should be on, so its UI is not established; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; enabled:0:*) ;; enabled:*) echo "curl exit $5 after HTTP $7: the request to the Console URL did not complete, so its UI is not established; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; off:0:000) echo "curl exit 0 with HTTP 000 is not a result this block classifies; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; off:*:000) echo "no HTTP response from the Console URL; a failed request to the previous URL alone is insufficient, so this is not classified as a pass" ;; off:*) echo "FINDING: the Console URL still answers after MINIO_BROWSER=off"; set -- 1 "${@:2}" ;; esac echo "block exit $1; the listener table and the Console response are left for you to judge: with off, confirm the running service received MINIO_BROWSER=off and no Console listener remains; with enabled, confirm the response is the Console UI" exit "$1" ) ``` With the Console enabled, establish that its UI is served. After applying `MINIO_BROWSER=off` and restarting, confirm that the running service received that setting, the embedded Console listener is absent, and signed S3 reads still succeed. A failed request to the previous URL alone is insufficient. Disabling only `MINIO_BROWSER_REDIRECT` leaves the direct Console available. Sources: [AIStor Console settings](https://docs.min.io/aistor/reference/aistor-server/settings/console/) and [`mc cat`](https://docs.min.io/aistor/reference/cli/mc-cat/). REASONED: the Console-disabled block above; Console disablement follows the cited Console settings; a live AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. Local bash -n and ShellCheck 0.11.0 exited 0 without output. With mc, ss, and curl stubbed, the literal placeholders, either empty value, embedded placeholder, example.com, control character, HTTP URL, wrong marker, and wrong argument counts printed guard messages without external calls, including a short assignment under set -u. Valid fixtures called mc, ss, and curl in order and printed stub listener/curl responses; a failing mc printed signed S3 read failed; stop, exited 1, and called neither ss nor curl. The bash -n, ShellCheck and stub runs recorded here covered the Console-disabled block in an earlier form, whose guard refusals left the subshell with status 0 and whose failed signed read exited 1. The current form, which takes enabled or off on its `set --` line, is covered by the bash -n and ShellCheck 0.11.0 passes of the repository's gate suite and by stub runs: guard refusals and a missing ss exit 2 before any call, a failed signed read exits 3, and a failed ss or a missing or malformed curl write-out line records 3. In those runs, with off an HTTP status from the Console URL exits 1 and a failed request with no HTTP response is left unclassified at 0, and with enabled a failed request or no HTTP response records 3. Enabled versus disabled Console behaviour still requires the live comparison described above. Per the guide's cited AIStor Console settings (disablement and browser redirection); AIStor `mc cat`. These existing citations were not reopened. REASONED: load admission and metrics interpretation are source reasoning only. Per the guide's cited AIStor core settings (automatic and explicit cluster-wide concurrency budgets); AIStor metrics v3 (active and queued requests). These existing citations were not reopened. Local Python arithmetic returned 16 for 64/4 and 32 for 128/4; a live four-node AIStor stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. The live comparison also requires an authenticated load client and per-node metrics collection. More than 16 admitted requests per node under automatic sizing versus pools constrained to 16 under the explicit budget remains an unobserved comparison. **API concurrency, REASONED:** the live multi-node AIStor comparison requires the same unavailable isolation capability. In a disposable four-node deployment, compare automatic sizing with the illustrative explicit budget of 64. Verify the effective setting on every node after restart. Have the load client issue signed `GetObject` requests for `REPLACE_WITH_APP_BUCKET/REPLACE_WITH_LARGE_PRIVATE_FIXTURE`, first individually, then with 128 concurrent requests distributed evenly across the four nodes. Use sufficiently long transfers to observe simultaneous processing. Keep credentials in the client's protected credential store. The automatic comparison must actually admit more than 16 simultaneous requests per node to discriminate this example. With the explicit budget, expect the per-node request pools to constrain admission to 16; client concurrency or throughput alone cannot establish enforcement. Source: [AIStor core settings](https://docs.min.io/aistor/reference/aistor-server/settings/core/). Record `minio_api_requests_inflight_total` and `minio_api_requests_waiting_total` by server, alongside client outcomes and latency. Account for concurrent administrative traffic. If the client, network, or storage cannot saturate the pools, record the comparison as inconclusive. Do not infer an RPS limit or require an undocumented HTTP rejection code. Source: [AIStor metrics v3 reference](https://docs.min.io/aistor/operations/monitoring/metrics-and-alerts/metrics-v3/). REASONED from the cited AIStor documentation: the exposure probes and all four service checks against exposed and fixed states on live AIStor with `mc`, a KMS, and an independent durable audit receiver; record versions, commands, outcomes, and receiver evidence. A live AIStor, KMS, and durable audit receiver stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. REASONED from the cited AIStor documentation: OIDC role-policy and claim-policy scoping, including an unmapped identity and explicit STS duration overriding a shorter default and JWT expiry; five-minute presigned-URL expiry and independent signature-age denial before a longer URL expires. Embedded Console disablement with successful S3 access and a redirect-only comparison, and cluster-wide API concurrency budgeting divided among nodes under measured load, are likewise REASONED from the cited AIStor documentation. Add an IdP, authenticated STS/S3 test client, load client, and per-node metrics collection to the existing prerequisites. Record AIStor and client RELEASE tags, redacted requests, effective settings, positive controls, denials, timing, listener evidence, and load measurements. A live AIStor and IdP stand-up opens listeners; the authoring host forbids that without an isolated network namespace, recorded as unavailable in DECISIONS.md and sqlite.md. All four additions are REASONED, not demonstrated. ## Sources (checked September 2026) - AIStor root credential files and file-variable precedence: https://docs.min.io/aistor/reference/aistor-server/settings/root-credentials/ and https://docs.min.io/aistor/reference/aistor-server/settings/#file-based-environment-variables - MinIO network encryption (certs directory, public.crt/private.key, --certs-dir): https://docs.min.io/aistor/installation/linux/network-encryption/ - MinIO: https://www.min.io/ - MinIO community repository (archived 2026-04-25, successor editions): https://github.com/minio/minio - MinIO `mc anonymous set` (anonymous policies are set per bucket or prefix, cover download and upload, and permit actions without authentication): https://docs.min.io/aistor/reference/cli/mc-anonymous/mc-anonymous-set/ - MinIO `mc anonymous get-json` (retrieve a bucket's anonymous policy as JSON to inspect grants, prefixes, and conditions): https://docs.min.io/aistor/reference/cli/mc-anonymous/mc-anonymous-get-json/ - MinIO/AIStor server address flags (`--address` defaults to `:9000` on all interfaces; `--console-address` / `MINIO_CONSOLE_ADDRESS`: a static port for the embedded console UI, or a dynamic one logged at startup when omitted): https://docs.min.io/aistor/reference/aistor-server/ - MinIO console listener in the server source (`--console-address` and its `MINIO_CONSOLE_ADDRESS` env var in `cmd/server-main.go`; `cmd/common-main.go` binds all interfaces when the host is omitted): https://github.com/minio/minio/blob/f0b91e5504663c4672da451877857b57c3345295/cmd/common-main.go - AIStor identity and access management (root defaults and child access keys): https://docs.min.io/aistor/administration/iam/ - AIStor Console security and access (user creation and key rotation): https://docs.min.io/aistor/administration/console/security-and-access/ - AIStor access management (built-in policies, custom policy structure, actions, and resources): https://docs.min.io/aistor/administration/iam/access/ - AIStor `mc admin user add` (positional secret): https://docs.min.io/aistor/reference/cli/admin/mc-admin-user/mc-admin-user-add/ - AIStor `mc admin policy create`: https://docs.min.io/aistor/reference/cli/admin/mc-admin-policy/mc-admin-policy-create/ - AIStor `mc admin policy attach`: https://docs.min.io/aistor/reference/cli/admin/mc-admin-policy/mc-admin-policy-attach/ - AIStor `mc admin accesskey create` (parent, generated credentials, expiry duration, and inline-policy limit): https://docs.min.io/aistor/reference/cli/admin/mc-admin-accesskey/mc-admin-accesskey-create/ - AIStor `mc alias import` (stdin and JSON fields): https://docs.min.io/aistor/reference/cli/mc-alias/mc-alias-import/ - AIStor server-side encryption (SSE types and key-manager choices): https://docs.min.io/aistor/installation/linux/server-side-encryption/ - AIStor encryption settings (MinIO KMS, KES credentials, and conditional automatic encryption): https://docs.min.io/aistor/reference/aistor-server/settings/server-side-encryption/ - AIStor MinIO KMS setup (environment settings, backend dependency, and object verification): https://docs.min.io/aistor/installation/linux/server-side-encryption/aistor-keymanager/ - AIStor KES setup (external KMS, backend IAM/configuration encryption, and startup dependency): https://docs.min.io/aistor/installation/linux/server-side-encryption/minio-key-encryption-service/ - AIStor `mc encrypt set` (SSE-KMS key selection, SSE-S3, and historical objects): https://docs.min.io/aistor/reference/cli/mc-encrypt/mc-encrypt-set/ - AIStor `mc encrypt info`: https://docs.min.io/aistor/reference/cli/mc-encrypt/mc-encrypt-info/ - AIStor object locking (existing buckets, Console limitation, retention modes, and delete markers): https://docs.min.io/aistor/administration/object-locking-and-immutability/ - AIStor `mc mb` (`--with-lock` enables versioning without setting retention): https://docs.min.io/aistor/reference/cli/mc-mb/ - AIStor `mc version enable`: https://docs.min.io/aistor/reference/cli/mc-version/mc-version-enable/ - AIStor `mc retention set` (default retention, existing buckets, ignored flags, and version IDs): https://docs.min.io/aistor/reference/cli/mc-retention/mc-retention-set/ - AIStor `mc retention info`: https://docs.min.io/aistor/reference/cli/mc-retention/mc-retention-info/ - AIStor `mc legalhold set`: https://docs.min.io/aistor/reference/cli/mc-legalhold/mc-legalhold-set/ - AIStor `mc rm` (specific-version deletion): https://docs.min.io/aistor/reference/cli/mc-rm/ - AIStor `mc stat` (metadata and specific-version inspection): https://docs.min.io/aistor/reference/cli/mc-stat/ - AIStor `mc cat`: https://docs.min.io/aistor/reference/cli/mc-cat/ - AIStor `mc pipe`: https://docs.min.io/aistor/reference/cli/mc-pipe/ - AIStor audit logging (no default destination, queue loss, persistent queue, and event fields): https://docs.min.io/aistor/operations/monitoring/audit-logging/ - AIStor webhook audit settings (authentication and certificate verification): https://docs.min.io/aistor/reference/aistor-server/settings/metrics-and-logging/webhook-audit-logs/ - AIStor webhook audit delivery (environment settings and acknowledgement limits): https://docs.min.io/aistor/operations/monitoring/audit-logging/webhook-audit-logging/ - AIStor Kafka audit settings (TLS defaults off and SASL authentication): https://docs.min.io/aistor/reference/aistor-server/settings/metrics-and-logging/kafka-audit-logs/ - AIStor Kafka audit delivery (environment settings and restart): https://docs.min.io/aistor/operations/monitoring/audit-logging/kafka-audit-logging/ - AIStor global audit event queue: https://docs.min.io/aistor/reference/aistor-server/settings/metrics-and-logging/audit-event-queue/ - AIStor Console settings (disablement and browser redirection): https://docs.min.io/aistor/reference/aistor-server/settings/console/ - AIStor core settings (automatic and explicit cluster-wide concurrency budgets): https://docs.min.io/aistor/reference/aistor-server/settings/core/ - AIStor OIDC access management (role and claim mapping): https://docs.min.io/aistor/administration/iam/access/oidc-access/ - AIStor OIDC identity management (mutually exclusive mappings and restart): https://docs.min.io/aistor/administration/iam/identity/oidc-identity/ - AIStor OpenID settings (role policy and claim name): https://docs.min.io/aistor/reference/aistor-server/settings/iam/openid/ - AIStor AssumeRoleWithWebIdentity (duration bounds and JWT-expiry precedence): https://docs.min.io/aistor/developers/security-token-service/assumerolewithwebidentity/ - AIStor STS settings (default duration): https://docs.min.io/aistor/reference/aistor-server/settings/iam/sts/ - AIStor `mc share download` (presigned credentials and expiry): https://docs.min.io/aistor/reference/cli/mc-share/mc-share-download/ - AWS S3 signature-condition reference (supplementary semantics for AIStor's documented condition key): https://docs.aws.amazon.com/AmazonS3/latest/developerguide/bucket-policy-s3-sigv4-conditions.html - AIStor metrics v3 (active and queued requests): https://docs.min.io/aistor/operations/monitoring/metrics-and-alerts/metrics-v3/ - AIStor release artifacts (server and client RELEASE baselines): https://docs.min.io/aistor/operations/release-artifacts/ - curl curl-8_14_1 (checked October 2026): [-S restores error messages with -s (4-19)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/show-error.md#L4-L19); [errormsg and exitcode added in 7.75.0 (75-79)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - aistor: AIStor documentation (unknown) - console: MinIO console source (f0b91e5504663c4672da451877857b57c3345295) - aws: AWS signature-condition semantics (unknown) - curl: curl (curl-8_14_1) Claims (full source mapping in the JSON block below): - lifecycle (REASONED; aistor, console): Community repository was archived 2026-04-25 and receives no fixes; guide settings target AIStor Free/Enterprise and call for migration. - root-files (REASONED; aistor): Root credentials come from protected regular 0600 files without whitespace/newlines; set only *_FILE paths, remove overriding direct values and start a fresh process. - root-default (REASONED; aistor): Without direct credentials or files, root defaults minioadmin/minioadmin; root is administrative and applications need scoped keys. - tls (REASONED; aistor): public.crt/private.key under HOME/.minio/certs or certs-dir enable HTTPS; clients trust the CA without disabling verification. - api-bind (REASONED; aistor): API address defaults :9000 across IPv4/IPv6 interfaces; bind explicit loopback/private and expose only intended TLS endpoints. - console-bind (REASONED; aistor, console): Console address is independent of API binding; unset chooses a logged dynamic port and a hostless address binds wildcard; set explicit private host/port. - anonymous (REASONED; aistor): Buckets are private unless policy grants access; inspect complete anonymous JSON including uploads, prefixes and conditions, independently of identity policies. - console-disable (REASONED; aistor): MINIO_BROWSER=off disables embedded Console after restart on every node; disabling browser redirection alone does not. - concurrency (REASONED; aistor): Automatic API budget follows RAM; REQUESTS_MAX is cluster-wide concurrent S3/admin requests divided among nodes, not RPS or connections; 64 is only an illustrative choice. - builtin-policies (REASONED; aistor): readonly/writeonly cover deployment-wide object operations; readwrite grants all S3 actions, consoleAdmin adds admin access, and readonly does not list buckets. - parent (REASONED; aistor): Create a non-root app parent in Console with narrow user/group grants; documented mc admin user add requires a positional secret. - policy (REASONED; aistor): Custom app policy grants location/list plus object read/write for one bucket, without deletion/admin; remove unused actions and avoid replacing an existing policy inadvertently. - access-key (REASONED; aistor): Create an explicit-parent generated key with 24h expiry and protected output; inline policy only restricts inherited rights and is limited to 4096 bytes; rotate before expiry. - alias (REASONED; aistor): Alias import reads protected JSON from stdin; protect imported and mc configuration credentials, and prove authentication with a signed operation. - oidc-role (REASONED; aistor): RoleArn mapping assigns configured policies to every admitted identity using the role; choose a narrow existing role policy. - oidc-claim (REASONED; aistor): Claim mapping uses IdP-controlled policy names; role_policy and claim_name are mutually exclusive, named suffixes are case-sensitive, and only one provider can use claim mapping. - oidc-unmapped (REASONED; aistor): Unmapped identities gain no identity-based access, but anonymous bucket grants still apply. - sts-duration (REASONED; aistor): WebIdentity explicit DurationSeconds accepts 900-31536000 and can override JWT expiry; MINIO_STS_DURATION is a default, with sts:DurationSeconds available as a policy restriction. - presign (REASONED; aistor): Presigned URLs are credentials; mc share download defaults seven days, while the example requests five minutes and stores output privately. - signature-age (REASONED; aistor, aws): Explicit deny for signatureAge above 600000 ms restricts the attached parent identity, not unrelated identities/OIDC/anonymous grants; AIStor behavior is reasoned from supported keys and AWS semantics. - kms (REASONED; aistor): SSE-S3 uses the default external key and SSE-KMS selects a key; configure MinIO KMS endpoint/enclave/key/API key with trusted CA before restarting. - kes (REASONED; aistor): Legacy KES supports third-party KMS; use certificate/key together with CAPATH, mutually exclusive with KES API key and with MinIO KMS configuration. - auto-encryption (REASONED; aistor): AUTO_ENCRYPTION defaults on only with a configured key manager and encrypts new writes using SSE-KMS; off needs explicit bucket encryption. - bucket-encryption (REASONED; aistor): mc encrypt set selects SSE-KMS key or SSE-S3 default; settings affect subsequent writes, not historical objects or versions. - backend-encryption (REASONED; aistor): Enabling SSE encrypts IAM/configuration backend data irreversibly; startup depends on retained backend key and key-manager access, regardless of automatic object encryption. - object-lock (REASONED; aistor): New bucket with-lock enables versioning but no retention duration; AIStor RELEASE.2025-05-20T20-30-00Z+ can retrofit existing versioned buckets, not through Console. - retention-modes (REASONED; aistor): GOVERNANCE permits authorized bypass; COMPLIANCE prevents protected-version deletion even by root until expiry. Remove bypass permission from application identities. - retention-default (REASONED; aistor): Default retention ignores other flags including recursive and governs new objects without overrides; explicitly protect historical version IDs and inspect retain-until dates. - legal-hold (REASONED; aistor): Legal hold is indefinite and requires PutObjectLegalHold to set/lift; both hold release and retention expiry are needed when combined. - delete-marker (REASONED; aistor): Ordinary deletes can hide retained versions behind a delete marker; verify exact version IDs. - audit-default (REASONED; aistor): No audit destination is enabled by default; publish sensitive records to independently protected remote storage. - audit-webhook (REASONED; aistor): Enable HTTPS webhook with authentication value supplied verbatim, certificate verification and persistent audit queue; restart every node. - audit-kafka (REASONED; aistor): Kafka TLS defaults off; explicitly enable verified TLS and supported SASL with topic-scoped credentials; example PLAIN is only over TLS. - audit-queue (REASONED; aistor): Persistent per-node queue needs service read/write/list access; delivery retries can lose events when full, and webhook 2xx proves acknowledgement rather than durability. - verify-exposure (REASONED; aistor, curl): Anonymous service/bucket/object reads must be denied at MinIO with known fixtures; full policy review and actual console-port external probes are independent controls. - verify-scope (REASONED; aistor): Root reads both known private fixtures; app reads/writes its own bucket but cannot read the other after anonymous grants are excluded. - verify-encryption (REASONED; aistor): New ordinary write must succeed with intended SSE/key metadata versus a successful unencrypted control; bucket info alone proves no historical-object state. - verify-retention (REASONED; aistor): Under the same root identity, unlocked exact-version deletion succeeds and COMPLIANCE-protected deletion fails while that version and future retention remain readable. - verify-audit (REASONED; aistor): Correlate successful write/read and denied read with persisted receiver events and durability; S3 success or webhook acknowledgement alone proves no stored audit. - verify-oidc (REASONED; aistor): Fresh mapped STS credentials must read only the app fixture versus a broad-policy control; also test an unmapped identity and explicit duration beyond shorter JWT/default. - verify-url-expiry (REASONED; aistor, curl): Same five-minute URL succeeds immediately and fails after six minutes while a new URL works; protect URL stdin and correlate AIStor denial. - verify-signature-age (REASONED; aistor, aws, curl): One-hour URL should work after eleven minutes without age denial, fail then with the policy, and still work fresh; exclude expiry and transport errors. - verify-console (REASONED; aistor, curl): After BROWSER=off restart, verify effective setting, absent Console listener and successful S3 read; redirect-only disablement leaves direct UI available. - verify-concurrency (REASONED; aistor): Four-node budget 64 should constrain admission to 16 per node under saturating signed load; compare measured inflight/waiting metrics with automatic sizing, not RPS or a guessed rejection code. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "85740b262253dedbe64449ab7f401420e3f298980c920d0f573476995316c498", "components": { "aistor": { "name": "AIStor documentation", "basis": "unknown", "sources": { "s9719f7f2368e": "https://github.com/minio/minio", "s010df9b91c3e": "https://docs.min.io/aistor/reference/aistor-server/settings/root-credentials/", "s832ebbd6b12d": "https://docs.min.io/aistor/reference/aistor-server/settings/#file-based-environment-variables", "s77cdb5121ee1": "https://docs.min.io/aistor/administration/iam/", "s3be1eade5af1": "https://docs.min.io/aistor/administration/iam/access/", "s72c067e99d16": "https://docs.min.io/aistor/administration/iam/access/oidc-access/", "s2a0ce722d930": "https://docs.min.io/aistor/administration/iam/identity/oidc-identity/", "sa62a16f9bfe7": "https://docs.min.io/aistor/installation/linux/network-encryption/", "sb9d5614881d7": "https://docs.min.io/aistor/reference/aistor-server/", "s410c1a1103fc": "https://docs.min.io/aistor/reference/aistor-server/settings/console/", "sedcc92753b24": "https://docs.min.io/aistor/reference/aistor-server/settings/core/", "s5e54dfb548ff": "https://docs.min.io/aistor/reference/aistor-server/settings/iam/openid/", "sce35ebdaa862": "https://docs.min.io/aistor/reference/aistor-server/settings/iam/sts/", "sfc2a9f6aaa82": "https://docs.min.io/aistor/reference/aistor-server/settings/metrics-and-logging/audit-event-queue/", "s077155dd0d0a": "https://docs.min.io/aistor/reference/aistor-server/settings/metrics-and-logging/kafka-audit-logs/", "s5ffb30993d29": "https://docs.min.io/aistor/reference/aistor-server/settings/metrics-and-logging/webhook-audit-logs/", "sd117ab9c22cf": "https://docs.min.io/aistor/reference/aistor-server/settings/server-side-encryption/", "sb91d4a3416c9": "https://docs.min.io/aistor/reference/cli/mc-anonymous/mc-anonymous-get-json/", "s9e52474eb6af": "https://docs.min.io/aistor/reference/cli/mc-anonymous/mc-anonymous-set/", "sdebcaf3c2435": "https://docs.min.io/aistor/administration/console/security-and-access/", "s23c3415f6dfd": "https://docs.min.io/aistor/reference/cli/admin/mc-admin-user/mc-admin-user-add/", "s5e275951f4af": "https://docs.min.io/aistor/reference/cli/admin/mc-admin-policy/mc-admin-policy-create/", "s3bd415ac0205": "https://docs.min.io/aistor/reference/cli/admin/mc-admin-policy/mc-admin-policy-attach/", "s6e38b824d6a7": "https://docs.min.io/aistor/reference/cli/admin/mc-admin-accesskey/mc-admin-accesskey-create/", "scd10702aba20": "https://docs.min.io/aistor/reference/cli/mc-alias/mc-alias-import/", "s53d35aac0c14": "https://docs.min.io/aistor/developers/security-token-service/assumerolewithwebidentity/", "s19e2a1dc1a62": "https://docs.min.io/aistor/reference/cli/mc-share/mc-share-download/", "s9f3d8a13f89a": "https://docs.min.io/aistor/installation/linux/server-side-encryption/", "s725b0aa69279": "https://docs.min.io/aistor/installation/linux/server-side-encryption/aistor-keymanager/", "scb4e8f6b45b9": "https://docs.min.io/aistor/installation/linux/server-side-encryption/minio-key-encryption-service/", "s7384f2f26863": "https://docs.min.io/aistor/reference/cli/mc-encrypt/mc-encrypt-set/", "s6203e074bac7": "https://docs.min.io/aistor/administration/object-locking-and-immutability/", "s8d71fab3c175": "https://docs.min.io/aistor/reference/cli/mc-mb/", "sbcb4c73423fb": "https://docs.min.io/aistor/reference/cli/mc-version/mc-version-enable/", "sd1b52461f1be": "https://docs.min.io/aistor/reference/cli/mc-retention/mc-retention-set/", "sf36b2625325e": "https://docs.min.io/aistor/reference/cli/mc-legalhold/mc-legalhold-set/", "sb375aff8811f": "https://docs.min.io/aistor/reference/cli/mc-rm/", "s7eb5037577a9": "https://docs.min.io/aistor/operations/monitoring/audit-logging/", "s624eb11b369c": "https://docs.min.io/aistor/operations/monitoring/audit-logging/kafka-audit-logging/", "s8b922d2cc573": "https://docs.min.io/aistor/operations/monitoring/audit-logging/webhook-audit-logging/", "s060498cf1d28": "https://docs.min.io/aistor/reference/cli/mc-cat/", "s8aab14fd9241": "https://docs.min.io/aistor/reference/cli/mc-pipe/", "sa1b121169978": "https://docs.min.io/aistor/reference/cli/mc-encrypt/mc-encrypt-info/", "s0c9db42c9d8d": "https://docs.min.io/aistor/reference/cli/mc-stat/", "s79346e27156b": "https://docs.min.io/aistor/reference/cli/mc-retention/mc-retention-info/", "s4e625044ee38": "https://docs.min.io/aistor/operations/monitoring/metrics-and-alerts/metrics-v3/" } }, "console": { "name": "MinIO console source", "basis": "f0b91e5504663c4672da451877857b57c3345295", "sources": { "s676cd1d021a2": "https://github.com/minio/minio/blob/f0b91e5504663c4672da451877857b57c3345295/cmd/common-main.go" } }, "aws": { "name": "AWS signature-condition semantics", "basis": "unknown", "sources": { "s724c81d09245": "https://docs.aws.amazon.com/AmazonS3/latest/developerguide/bucket-policy-s3-sigv4-conditions.html" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s07916104bf60": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/show-error.md#L4-L19", "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79" } } }, "claims": { "lifecycle": {"text": "Community repository was archived 2026-04-25 and receives no fixes; guide settings target AIStor Free/Enterprise and call for migration.", "components": ["aistor", "console"], "sources": ["aistor:s9719f7f2368e", "console:s676cd1d021a2"], "status": "REASONED"}, "root-files": {"text": "Root credentials come from protected regular 0600 files without whitespace/newlines; set only *_FILE paths, remove overriding direct values and start a fresh process.", "components": ["aistor"], "sources": ["aistor:s010df9b91c3e", "aistor:s832ebbd6b12d"], "status": "REASONED"}, "root-default": {"text": "Without direct credentials or files, root defaults minioadmin/minioadmin; root is administrative and applications need scoped keys.", "components": ["aistor"], "sources": ["aistor:s77cdb5121ee1", "aistor:s3be1eade5af1", "aistor:s72c067e99d16", "aistor:s2a0ce722d930"], "status": "REASONED"}, "tls": {"text": "public.crt/private.key under HOME/.minio/certs or certs-dir enable HTTPS; clients trust the CA without disabling verification.", "components": ["aistor"], "sources": ["aistor:sa62a16f9bfe7"], "status": "REASONED"}, "api-bind": {"text": "API address defaults :9000 across IPv4/IPv6 interfaces; bind explicit loopback/private and expose only intended TLS endpoints.", "components": ["aistor"], "sources": ["aistor:sb9d5614881d7", "aistor:s832ebbd6b12d", "aistor:s410c1a1103fc", "aistor:sedcc92753b24", "aistor:s5e54dfb548ff", "aistor:sce35ebdaa862", "aistor:sfc2a9f6aaa82", "aistor:s077155dd0d0a", "aistor:s5ffb30993d29", "aistor:s010df9b91c3e", "aistor:sd117ab9c22cf"], "status": "REASONED"}, "console-bind": {"text": "Console address is independent of API binding; unset chooses a logged dynamic port and a hostless address binds wildcard; set explicit private host/port.", "components": ["aistor", "console"], "sources": ["aistor:sb9d5614881d7", "aistor:s832ebbd6b12d", "aistor:s410c1a1103fc", "aistor:sedcc92753b24", "aistor:s5e54dfb548ff", "aistor:sce35ebdaa862", "aistor:sfc2a9f6aaa82", "aistor:s077155dd0d0a", "aistor:s5ffb30993d29", "aistor:s010df9b91c3e", "aistor:sd117ab9c22cf", "console:s676cd1d021a2"], "status": "REASONED"}, "anonymous": {"text": "Buckets are private unless policy grants access; inspect complete anonymous JSON including uploads, prefixes and conditions, independently of identity policies.", "components": ["aistor"], "sources": ["aistor:sb91d4a3416c9", "aistor:s9e52474eb6af"], "status": "REASONED"}, "console-disable": {"text": "MINIO_BROWSER=off disables embedded Console after restart on every node; disabling browser redirection alone does not.", "components": ["aistor"], "sources": ["aistor:s410c1a1103fc"], "status": "REASONED"}, "concurrency": {"text": "Automatic API budget follows RAM; REQUESTS_MAX is cluster-wide concurrent S3/admin requests divided among nodes, not RPS or connections; 64 is only an illustrative choice.", "components": ["aistor"], "sources": ["aistor:sedcc92753b24"], "status": "REASONED"}, "builtin-policies": {"text": "readonly/writeonly cover deployment-wide object operations; readwrite grants all S3 actions, consoleAdmin adds admin access, and readonly does not list buckets.", "components": ["aistor"], "sources": ["aistor:s3be1eade5af1", "aistor:s72c067e99d16"], "status": "REASONED"}, "parent": {"text": "Create a non-root app parent in Console with narrow user/group grants; documented mc admin user add requires a positional secret.", "components": ["aistor"], "sources": ["aistor:sdebcaf3c2435", "aistor:s23c3415f6dfd"], "status": "REASONED"}, "policy": {"text": "Custom app policy grants location/list plus object read/write for one bucket, without deletion/admin; remove unused actions and avoid replacing an existing policy inadvertently.", "components": ["aistor"], "sources": ["aistor:s3be1eade5af1", "aistor:s72c067e99d16", "aistor:s5e275951f4af", "aistor:s3bd415ac0205"], "status": "REASONED"}, "access-key": {"text": "Create an explicit-parent generated key with 24h expiry and protected output; inline policy only restricts inherited rights and is limited to 4096 bytes; rotate before expiry.", "components": ["aistor"], "sources": ["aistor:s6e38b824d6a7"], "status": "REASONED"}, "alias": {"text": "Alias import reads protected JSON from stdin; protect imported and mc configuration credentials, and prove authentication with a signed operation.", "components": ["aistor"], "sources": ["aistor:scd10702aba20"], "status": "REASONED"}, "oidc-role": {"text": "RoleArn mapping assigns configured policies to every admitted identity using the role; choose a narrow existing role policy.", "components": ["aistor"], "sources": ["aistor:s72c067e99d16", "aistor:s5e54dfb548ff"], "status": "REASONED"}, "oidc-claim": {"text": "Claim mapping uses IdP-controlled policy names; role_policy and claim_name are mutually exclusive, named suffixes are case-sensitive, and only one provider can use claim mapping.", "components": ["aistor"], "sources": ["aistor:s2a0ce722d930", "aistor:s5e54dfb548ff"], "status": "REASONED"}, "oidc-unmapped": {"text": "Unmapped identities gain no identity-based access, but anonymous bucket grants still apply.", "components": ["aistor"], "sources": ["aistor:s72c067e99d16"], "status": "REASONED"}, "sts-duration": {"text": "WebIdentity explicit DurationSeconds accepts 900-31536000 and can override JWT expiry; MINIO_STS_DURATION is a default, with sts:DurationSeconds available as a policy restriction.", "components": ["aistor"], "sources": ["aistor:s53d35aac0c14", "aistor:sce35ebdaa862", "aistor:s3be1eade5af1", "aistor:s72c067e99d16"], "status": "REASONED"}, "presign": {"text": "Presigned URLs are credentials; mc share download defaults seven days, while the example requests five minutes and stores output privately.", "components": ["aistor"], "sources": ["aistor:s19e2a1dc1a62"], "status": "REASONED"}, "signature-age": {"text": "Explicit deny for signatureAge above 600000 ms restricts the attached parent identity, not unrelated identities/OIDC/anonymous grants; AIStor behavior is reasoned from supported keys and AWS semantics.", "components": ["aistor", "aws"], "sources": ["aistor:s3be1eade5af1", "aistor:s72c067e99d16", "aws:s724c81d09245"], "status": "REASONED"}, "kms": {"text": "SSE-S3 uses the default external key and SSE-KMS selects a key; configure MinIO KMS endpoint/enclave/key/API key with trusted CA before restarting.", "components": ["aistor"], "sources": ["aistor:s9f3d8a13f89a", "aistor:s725b0aa69279", "aistor:scb4e8f6b45b9"], "status": "REASONED"}, "kes": {"text": "Legacy KES supports third-party KMS; use certificate/key together with CAPATH, mutually exclusive with KES API key and with MinIO KMS configuration.", "components": ["aistor"], "sources": ["aistor:sd117ab9c22cf", "aistor:scb4e8f6b45b9"], "status": "REASONED"}, "auto-encryption": {"text": "AUTO_ENCRYPTION defaults on only with a configured key manager and encrypts new writes using SSE-KMS; off needs explicit bucket encryption.", "components": ["aistor"], "sources": ["aistor:sd117ab9c22cf"], "status": "REASONED"}, "bucket-encryption": {"text": "mc encrypt set selects SSE-KMS key or SSE-S3 default; settings affect subsequent writes, not historical objects or versions.", "components": ["aistor"], "sources": ["aistor:s7384f2f26863"], "status": "REASONED"}, "backend-encryption": {"text": "Enabling SSE encrypts IAM/configuration backend data irreversibly; startup depends on retained backend key and key-manager access, regardless of automatic object encryption.", "components": ["aistor"], "sources": ["aistor:s725b0aa69279", "aistor:scb4e8f6b45b9"], "status": "REASONED"}, "object-lock": {"text": "New bucket with-lock enables versioning but no retention duration; AIStor RELEASE.2025-05-20T20-30-00Z+ can retrofit existing versioned buckets, not through Console.", "components": ["aistor"], "sources": ["aistor:s6203e074bac7", "aistor:s8d71fab3c175", "aistor:sbcb4c73423fb"], "status": "REASONED"}, "retention-modes": {"text": "GOVERNANCE permits authorized bypass; COMPLIANCE prevents protected-version deletion even by root until expiry. Remove bypass permission from application identities.", "components": ["aistor"], "sources": ["aistor:s6203e074bac7"], "status": "REASONED"}, "retention-default": {"text": "Default retention ignores other flags including recursive and governs new objects without overrides; explicitly protect historical version IDs and inspect retain-until dates.", "components": ["aistor"], "sources": ["aistor:sd1b52461f1be"], "status": "REASONED"}, "legal-hold": {"text": "Legal hold is indefinite and requires PutObjectLegalHold to set/lift; both hold release and retention expiry are needed when combined.", "components": ["aistor"], "sources": ["aistor:sf36b2625325e"], "status": "REASONED"}, "delete-marker": {"text": "Ordinary deletes can hide retained versions behind a delete marker; verify exact version IDs.", "components": ["aistor"], "sources": ["aistor:s6203e074bac7", "aistor:sb375aff8811f"], "status": "REASONED"}, "audit-default": {"text": "No audit destination is enabled by default; publish sensitive records to independently protected remote storage.", "components": ["aistor"], "sources": ["aistor:s7eb5037577a9", "aistor:s624eb11b369c", "aistor:s8b922d2cc573"], "status": "REASONED"}, "audit-webhook": {"text": "Enable HTTPS webhook with authentication value supplied verbatim, certificate verification and persistent audit queue; restart every node.", "components": ["aistor"], "sources": ["aistor:s5ffb30993d29", "aistor:s8b922d2cc573"], "status": "REASONED"}, "audit-kafka": {"text": "Kafka TLS defaults off; explicitly enable verified TLS and supported SASL with topic-scoped credentials; example PLAIN is only over TLS.", "components": ["aistor"], "sources": ["aistor:s077155dd0d0a", "aistor:s624eb11b369c"], "status": "REASONED"}, "audit-queue": {"text": "Persistent per-node queue needs service read/write/list access; delivery retries can lose events when full, and webhook 2xx proves acknowledgement rather than durability.", "components": ["aistor"], "sources": ["aistor:sfc2a9f6aaa82", "aistor:s8b922d2cc573"], "status": "REASONED"}, "verify-exposure": {"text": "Anonymous service/bucket/object reads must be denied at MinIO with known fixtures; full policy review and actual console-port external probes are independent controls.", "components": ["aistor", "curl"], "sources": ["aistor:sb91d4a3416c9", "aistor:sb9d5614881d7", "aistor:s832ebbd6b12d", "aistor:s410c1a1103fc", "aistor:sedcc92753b24", "aistor:s5e54dfb548ff", "aistor:sce35ebdaa862", "aistor:sfc2a9f6aaa82", "aistor:s077155dd0d0a", "aistor:s5ffb30993d29", "aistor:s010df9b91c3e", "aistor:sd117ab9c22cf", "curl:s07916104bf60"], "status": "REASONED", "verify": [1, 2, 3]}, "verify-scope": {"text": "Root reads both known private fixtures; app reads/writes its own bucket but cannot read the other after anonymous grants are excluded.", "components": ["aistor"], "sources": ["aistor:s3be1eade5af1", "aistor:s72c067e99d16", "aistor:s060498cf1d28", "aistor:s8aab14fd9241"], "status": "REASONED", "verify": [4]}, "verify-encryption": {"text": "New ordinary write must succeed with intended SSE/key metadata versus a successful unencrypted control; bucket info alone proves no historical-object state.", "components": ["aistor"], "sources": ["aistor:sa1b121169978", "aistor:s0c9db42c9d8d", "aistor:s725b0aa69279"], "status": "REASONED", "verify": [5]}, "verify-retention": {"text": "Under the same root identity, unlocked exact-version deletion succeeds and COMPLIANCE-protected deletion fails while that version and future retention remain readable.", "components": ["aistor"], "sources": ["aistor:s79346e27156b", "aistor:sb375aff8811f", "aistor:s0c9db42c9d8d"], "status": "REASONED", "verify": [6, 7]}, "verify-audit": {"text": "Correlate successful write/read and denied read with persisted receiver events and durability; S3 success or webhook acknowledgement alone proves no stored audit.", "components": ["aistor"], "sources": ["aistor:s7eb5037577a9", "aistor:s624eb11b369c", "aistor:s8b922d2cc573"], "status": "REASONED", "verify": [8]}, "verify-oidc": {"text": "Fresh mapped STS credentials must read only the app fixture versus a broad-policy control; also test an unmapped identity and explicit duration beyond shorter JWT/default.", "components": ["aistor"], "sources": ["aistor:s72c067e99d16", "aistor:s53d35aac0c14"], "status": "REASONED"}, "verify-url-expiry": {"text": "Same five-minute URL succeeds immediately and fails after six minutes while a new URL works; protect URL stdin and correlate AIStor denial.", "components": ["aistor", "curl"], "sources": ["aistor:s19e2a1dc1a62", "curl:s5e1383feef7d"], "status": "REASONED", "verify": [9]}, "verify-signature-age": {"text": "One-hour URL should work after eleven minutes without age denial, fail then with the policy, and still work fresh; exclude expiry and transport errors.", "components": ["aistor", "aws", "curl"], "sources": ["aistor:s3be1eade5af1", "aistor:s72c067e99d16", "aws:s724c81d09245", "curl:s5e1383feef7d"], "status": "REASONED", "verify": [9]}, "verify-console": {"text": "After BROWSER=off restart, verify effective setting, absent Console listener and successful S3 read; redirect-only disablement leaves direct UI available.", "components": ["aistor", "curl"], "sources": ["aistor:s410c1a1103fc", "aistor:s060498cf1d28", "curl:s5e1383feef7d"], "status": "REASONED", "verify": [10]}, "verify-concurrency": {"text": "Four-node budget 64 should constrain admission to 16 per node under saturating signed load; compare measured inflight/waiting metrics with automatic sizing, not RPS or a guessed rejection code.", "components": ["aistor"], "sources": ["aistor:sedcc92753b24", "aistor:s4e625044ee38"], "status": "REASONED"} } } ```` ====================================================================== ==> rabbitmq.md ====================================================================== # RabbitMQ: users, TLS listener, and the guest account RabbitMQ is a message broker: applications drop messages into its queues, and it delivers them to programs that process them. This guide helps your AI assistant configure it securely, including real users instead of the default guest account, a TLS listener, a protected management UI, and restricted Erlang distribution ports. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/rabbitmq.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against RabbitMQ source v4.4.0, rabbitmqadmin v2, RabbitMQ topic authorization introduction 3.7.0, Erlang/OTP distribution 27, and Pika source 1.3.2; 2 further sources are pinned to a commit and 3 carry no recorded version. Metadata reviewed 2026-11-07; documentation checked September 2026. The guide records 37 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. RabbitMQ's default `guest`/`guest` account can only connect from localhost, which protects fresh installs exactly until someone "fixes" it. The official Docker image ships that fix: its `/etc/rabbitmq/conf.d/10-defaults.conf` sets `loopback_users.guest = false`, so in a container from that image as shipped (a later configuration layer can set it back) the default `guest`/`guest` administrator, wherever it exists, can log in from any address that reaches the broker. The documented recommendation is to create real users and delete `guest` or change its password. The settings below were checked against the current RabbitMQ 4.3 documentation in September 2026. These controls are available in open source RabbitMQ; no commercial edition is required. The export command specifically requires a current `rabbitmqadmin` v2, not v1. ## 1. Accounts An unrestricted application grant on `/` lets a compromised application manipulate unrelated resources in that shared vhost. Give each application and environment its own user and vhost. For initial provisioning: ```bash sudo rabbitmqctl add_vhost 'app-prod' sudo rabbitmqctl add_user 'app' # Enter a generated password at the interactive prompt. # Permission order: configure, write, read. sudo rabbitmqctl set_permissions -p 'app-prod' 'app' \ '^app[.].*$' '^app[.].*$' '^app[.].*$' sudo rabbitmqctl set_user_tags 'app' sudo rabbitmqctl add_user 'ops' # Enter a different generated password at the interactive prompt. sudo rabbitmqctl set_user_tags 'ops' administrator sudo rabbitmqctl add_user 'observer' # Enter a third generated password at the interactive prompt. sudo rabbitmqctl set_user_tags 'observer' monitoring sudo rabbitmqctl set_permissions -p 'app-prod' 'observer' '^$' '^$' '^$' sudo rabbitmqctl delete_user 'guest' ``` For existing accounts or vhosts, omit their creation commands. Provision the required topology in `app-prod`, migrate clients to that vhost, then remove the application's old grant: ```bash sudo rabbitmqctl clear_permissions -p '/' 'app' sudo rabbitmqctl list_user_permissions 'app' ``` The patterns permit resource names beginning `app.`. They exclude the default exchange, whose permission-check name is `amq.default`. Queue binding requires write permission on the queue and read permission on the exchange. Reconnect clients after permission changes. Review patterns explicitly for applications using generated queue names or shared resources; see [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). These commands manage RabbitMQ's internal authorization store; external backends need their own rules. Vhosts provide logical isolation, not dedicated CPU or memory. See [resource permissions](https://www.rabbitmq.com/docs/access-control) and [vhost isolation](https://www.rabbitmq.com/docs/vhosts). Retain `ops` for administration and use `observer` for monitoring. Tags do not grant resource permissions; the observer's three `^$` patterns prevent message and topology operations. `set_user_tags` replaces existing tags. | Tag | Relevant authority | |---|---| | No tags | No management-plugin access. | | `management` | Management access within permitted vhosts. | | `monitoring` | Management access plus cluster-wide operational visibility. | | `policymaker` | Management access plus policy/parameter management in permitted vhosts. | | `administrator` | Monitoring and policymaker authority plus user, vhost, and permission administration. | Monitoring and policymaker are separate extensions of management access. Resource listings remain limited to vhosts with a permission entry. Review existing observer grants on other vhosts as well. See [management roles](https://www.rabbitmq.com/docs/management) and [tag replacement](https://www.rabbitmq.com/docs/man/rabbitmqctl.8). Do not loosen the guest account's localhost restriction; in a container from the official image as shipped, where `10-defaults.conf` loosens it, delete `guest`, wherever it exists, as above. Exported definitions contain password hashes and hashing metadata; keep exports access-restricted and out of images and version control ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Definitions imports are privileged provisioning input: a stale or untrusted file can introduce accounts, grants, policies, or plugin parameters. For boot provisioning, use a reviewed local file in `rabbitmq.conf`: ```ini definitions.import_backend = local_filesystem definitions.local.path = /etc/rabbitmq/definitions/approved.json ``` Keep the directory administrator-owned and the file readable by RabbitMQ but not writable by it. Review accounts, permissions, policies, and runtime parameters before deployment. Core boot imports do not require management. They do not overwrite existing broker definitions, so they cannot reconcile or revoke stale grants. A blank node importing definitions does not create the default user and vhost; the approved file must supply the intended accounts and vhosts. See [boot import behaviour](https://www.rabbitmq.com/docs/definitions). Runtime imports through `rabbitmqadmin definitions import` or `POST /api/definitions` merge with existing state: omitted objects remain, conflicting mutable objects are overwritten, and conflicting immutable queues, exchanges, and bindings retain their existing definitions. An error can leave a partial import. Do not treat runtime import as full reconciliation either. See [HTTP API import semantics](https://www.rabbitmq.com/docs/http-api-reference#post-apidefinitions). For a reduced export, provision `$HOME/.rabbitmqadmin.conf` as an owner-only TOML file through your secret-management workflow. Replace the illustrative hostname and password, preserving valid TOML escaping: ```toml [secureconfig] hostname = "management.example.com" port = 15671 username = "ops" password = "REPLACE_WITH_OPS_PASSWORD" ``` Run this in a private directory, using a new output pathname: ```bash ( umask 077 if [ -e ./definitions.redacted.json ] || [ -L ./definitions.redacted.json ]; then echo "choose a new export pathname; refusing to overwrite" exit 2 fi rabbitmqadmin --config "$HOME/.rabbitmqadmin.conf" --node secureconfig \ --use-tls --tls-ca-cert-file /etc/rabbitmq/tls/ca.pem \ definitions export --file ./definitions.redacted.json \ --transformations exclude_users,exclude_permissions,exclude_runtime_parameters ) ``` The v2 transformations remove users, permissions, and runtime parameters. Keep the result private pending review: arbitrary plugin data can still contain secrets. A sanitized export is not a complete account-recovery backup. See [export transformations](https://www.rabbitmq.com/docs/definitions) and [rabbitmqadmin configuration and TLS](https://www.rabbitmq.com/docs/management-cli). ## 2. TLS listener In `rabbitmq.conf`, replace `listeners.ssl.default` with the private listener below; do not retain a wildcard TLS listener alongside it. Substitute the broker's private IPv4 address: ```ini listeners.ssl.1 = REPLACE_WITH_BROKER_PRIVATE_IP:5671 ssl_options.cacertfile = /etc/rabbitmq/tls/ca.pem ssl_options.certfile = /etc/rabbitmq/tls/server.pem ssl_options.keyfile = /etc/rabbitmq/tls/server.key ssl_options.verify = verify_peer ssl_options.fail_if_no_peer_cert = true # After migrating every client to TLS: listeners.tcp = none ``` Certificates per [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) (an internal CA fits brokers well) or [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) cover the server certificate. Use a separate client certificate with `clientAuth` extended key usage and a chain trusted by `ssl_options.cacertfile`. Intermediate chains must fit `ssl_options.depth`; the documented default is 1. The Verify examples assume a client certificate issued directly by the trusted CA. Mutual TLS supplies a machine possession factor, not human MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). Requiring a trusted client certificate does not map it to a RabbitMQ username. Clients here still authenticate with their RabbitMQ username and password. Certificate-to-user authentication requires the separate certificate authentication plugin and the SASL `EXTERNAL` mechanism; it is not configured by these listener settings. See [TLS verification and certificate usage](https://www.rabbitmq.com/docs/ssl) and [certificate-based user authentication](https://www.rabbitmq.com/docs/access-control). ## 3. Management UI The management plugin's web UI is an admin panel: keep it off public interfaces and reach it per [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md) (SSH forward, tailnet, or Access), with its own TLS when remote. First add its independent restricted listener configuration to `rabbitmq.conf`, substituting the broker's private IPv4 address: ```ini management.tcp.ip = 127.0.0.1 management.tcp.port = 15672 management.ssl.ip = REPLACE_WITH_BROKER_PRIVATE_IP management.ssl.port = 15671 management.ssl.cacertfile = /etc/rabbitmq/tls/ca.pem management.ssl.certfile = /etc/rabbitmq/tls/management.pem management.ssl.keyfile = /etc/rabbitmq/tls/management.key ``` Start or restart the node through your deployment's service manager with this configuration in place BEFORE enabling the plugin. `rabbitmq.conf` changes require a node restart; editing the file alone does not update a running node. The plugin reads the effective listener configuration when it starts. Enabling it on a running broker starts it immediately; as of v4.4.0, with no management listener configured, the plugin starts a plain HTTP listener on port `15672`. That listener has no configured address, so at v4.4.0 `rabbit_networking:listener_per_ip_address/1` opens one socket per wildcard address, chosen by test binds when the listener starts. On a dual-stack host, where an IPv6 wildcard socket does not take IPv4 connections, it opens two sockets, `[::]:15672` and `0.0.0.0:15672`. On a single-stack host it opens `[::]:15672` alone, which also accepts IPv4 connections, and on an IPv4-only host `0.0.0.0:15672`. An example configuration in the RabbitMQ 4.3 management documentation, described as setting some parameters to their default values, includes `management.tcp.ip = 0.0.0.0` (all IPv4 interfaces). Set `management.tcp.ip` to an explicit loopback or private address before enabling the plugin, and check the actual listeners, IPv6 sockets included, with `ss` after startup. See [configuration application](https://www.rabbitmq.com/docs/configure#when-will-configuration-file-changes-be-applied) and [plugin activation](https://www.rabbitmq.com/docs/plugins#different-ways-to-enable-plugins). Only after that start/restart succeeds, enable the plugin if it is not already enabled: ```bash sudo rabbitmq-plugins enable rabbitmq_management ``` If the plugin is already enabled, apply the restricted configuration and restart the node before it becomes reachable, or keep management access firewalled until the restart and listener verification in check 2 are complete. This deliberately retains HTTP on loopback and exposes HTTPS only on the selected private address. At v4.4.0, `management.ssl.ip` is what limits the HTTPS listener to that one address; without it, the HTTPS listener binds the same wildcard addresses as the default HTTP listener above. Restrict HTTPS reachability to administration hosts. The management certificate must cover the management hostname. AMQP `ssl_options` do not configure management TLS. RabbitMQ 4.4.0 adds `management.listeners.tcp.` and `management.listeners.ssl.` entries, which start extra management listeners on top of the default ones. They do not disable the default listener: with only these entries set and no `management.tcp.*`, `management.ssl.*` or legacy `management.listener.*` settings, the plain HTTP listener on `15672` still starts on the wildcard addresses described above. An entry that gives only a port also drops `management.tcp.ip` or `management.ssl.ip` for that listener, which then binds without an address limit. Give each such entry an explicit private address as well as a port, and keep `management.tcp.ip` set so that the default HTTP listener stays on its chosen address, or leave these entries unset. Standard internal-user HTTP authentication uses RabbitMQ credentials. Use the observer role from section 1 for monitoring and keep `ops` separate. Human MFA guidance remains in [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md) and [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). See [management HTTPS and listener configuration](https://www.rabbitmq.com/docs/management) and [HTTP API authentication](https://www.rabbitmq.com/docs/http-api-reference). ## 4. epmd and the Erlang distribution port Two more listeners exist beyond AMQP and management: epmd, the Erlang Port Mapper Daemon, defaults to `4369`; the distribution listener defaults to `25672`, derived from `RABBITMQ_NODE_PORT` plus 20000 (as of v4.4.0). A non-empty `RABBITMQ_DIST_PORT`, or its unprefixed form `DIST_PORT`, replaces that derived port, and RabbitMQ pins the distribution listener to the resulting single port by setting the Erlang kernel's `inet_dist_listen_min` and `inet_dist_listen_max` both to it. The `distribution.listener.port_range.min` and `.max` keys set below map to those same two kernel settings. This guide has not established which value applies when they differ from the derived port, so keep them equal to the port you intend. The distribution listener carries clustering and CLI traffic. Pin its private IPv4 address and port in `rabbitmq.conf`: ```ini distribution.listener.interface = REPLACE_WITH_BROKER_PRIVATE_IP distribution.listener.port_range.min = 25672 distribution.listener.port_range.max = 25672 ``` When RabbitMQ starts epmd, supply this in the actual service/container launch environment, not merely an unrelated interactive shell: ```bash export ERL_EPMD_ADDRESS='REPLACE_WITH_BROKER_PRIVATE_IP' ``` Changing `ERL_EPMD_ADDRESS` requires stopping both RabbitMQ and epmd before starting them with the new environment. Restarting RabbitMQ alone is insufficient. Socket-activated epmd instead needs its `epmd.socket` binding configured. epmd implicitly retains loopback access. Use a private address reachable by cluster peers; loopback-only distribution suits a single node with local CLI access. Bindings do not replace firewall rules. Allow these ports only from cluster peers and authorized CLI hosts; do not publish them publicly in Compose port mappings. Remote CLI tools also use their own distribution-port range, by default `35672` through `35682` (as of v4.4.0, from `RABBITMQ_CTL_DIST_PORT_MIN` through ten above it unless `RABBITMQ_CTL_DIST_PORT_MAX` is set, where an empty value counts as unset and the unprefixed `CTL_DIST_PORT_MIN` or `CTL_DIST_PORT_MAX` then applies); restrict the actual range to the necessary peers. See [networking and epmd lifecycle](https://www.rabbitmq.com/docs/networking). The Erlang cookie is a shared secret granting powerful node and CLI access. On typical Unix installations the server uses `/var/lib/rabbitmq/.erlang.cookie`, and CLI users use `$HOME/.erlang.cookie`. Keep matching copies owner-only, normally mode `600`, with a long random value supplied through secret management. Keep it out of images and Compose files, and never pass its value through command-line arguments. Cookie possession plus distribution reachability can give full broker control. See [CLI authentication](https://www.rabbitmq.com/docs/cli). Restart the node through your deployment's service manager after applying listener configuration, including the separate epmd lifecycle change where needed. ## 5. Verify (REASONED: all checks 1-9, their fences, instructions, expected outcomes and authoring-record tables in this section; a live RabbitMQ stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No live exposed/fixed broker, external vantage, allowed administration/application/cluster peers, matching cookies, broker logs or multi-node TLS comparison was established. Per the guide's cited sources this declaration lists in four parts, the exposed and fixed outcomes are those stated at each step. The guide's cited sources this declaration lists (1 of 4): RabbitMQ access control, guest restrictions, interactive user creation, and resource permissions; RabbitMQ virtual hosts and logical isolation; RabbitMQ CLI user, vhost, permission, and tag commands; RabbitMQ management listeners, HTTPS, and roles. The guide's cited sources this declaration lists (2 of 4): RabbitMQ HTTP API authentication and endpoints; RabbitMQ TLS, peer verification, certificate usage, and verification depth; RabbitMQ networking, epmd lifecycle, private bindings, and distribution ports; RabbitMQ diagnostics, remote node selection, and authenticated ping. The guide's cited sources this declaration lists (3 of 4): RabbitMQ CLI authentication and Erlang cookie handling; RabbitMQ definitions imports, sensitive exports, and export transformations; RabbitMQ administration CLI v2 configuration and TLS flags; RabbitMQ inter-node TLS (distribution over TLS); Erlang/OTP 27 distribution over TLS; RabbitMQ queues, durability, and exclusive queue behaviour. The guide's cited sources this declaration lists (4 of 4): RabbitMQ channel exceptions and access-refused errors; Pika TLS context and client certificate example; Pika connection parameters; Pika blocking connection and channel methods; Pika authentication and authorization exceptions; curl password prompts, TLS validation, and diagnostic flags. The exposed and fixed outcomes (1 of 2) per the guide's cited sources this declaration lists: excessive grants and forbidden connections versus scoped grants and denied connections with successful authorized controls; wildcard or public listeners and broad cookie modes versus private bindings and owner-only cookies. The exposed and fixed outcomes (2 of 2) per the guide's cited sources this declaration lists: optional versus required client certificates; unwanted versus approved definitions; plaintext versus authenticated TLS distribution; unrestricted versus tenant-scoped topic operations. Marking review ran 19 Bash blocks through bash -n and ShellCheck 0.11.0 with no findings, and an SC2086 canary produced its expected diagnostic. Stub-only Verify execution exercised argument dispatch and 32 guard refusals under bash -u, printing the corresponding refusal without external commands; valid substitutions reached only stubs. Any run recorded here of check 2's block covered an earlier form, without its check for a missing ss. Its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs in which its ss part exited 0, 3 and 2 with ss passing, failing and missing. In a clean shell the block's own status stayed its last command's, and under an inherited set -e the first command or part that failed ended the pasted block with its status. Both embedded Python programs parsed in an earlier form, without the status 3 exception handlers and check 7's status 3 arm for a non-object export, and neither current form was re-run; the TOML example and four INI key/value structures parsed, without native schema validation. The reduced-export overwrite guard reached a stub for an absent path and refused both an existing file and a dangling symlink. The bash -n, ShellCheck and stub runs recorded here covered the section 1 reduced-export block and the guarded blocks of checks 3 to 6 in an earlier form, whose overwrite and placeholder refusals exited with status 1; none was re-run on the current form, whose refusals exit with status 2. That earlier form of checks 5 and 6 also exited with status 1 after a failed positive control, a certificate-absent run that exited 0 or an unexpected Python exception, where the current form exits with status 3 unless check 6 already reported an unexpected allow, which keeps status 1. Seven reduced-export JSON fixtures printed the empty-sections success, not a definitions object, or review required for each of the five nonempty sections. Those fixture runs and the bash -n and ShellCheck runs of check 7 covered an earlier form, which exited with status 1 for a non-object export or an unreadable or malformed file; none was re-run on the current form, which exits with status 3 there and keeps status 1 for review required. These local results do not demonstrate broker behaviour. Native RabbitMQ configuration parsing was not run in the October 2026 marking review and remains an open gap. All original absence statements and local-check counts below are historical authoring records, not new host observations; the original test receipts were not reauthenticated.) Historical authoring record: service behaviour was not demonstrated in the authoring environment. The original review recorded the nine service checks below as REASONED: RabbitMQ/Erlang binaries, `rabbitmqadmin`, Pika, Docker, and Podman were absent, and no broker or external peer test environment was supplied; this marking review does not reassert those historical host observations. Expected service outcomes remain REASONED from the Sources entries named in the section declaration. Use Bash and paste whole guarded blocks. Substitute inside the single quotes on each `set --` line, retaining the quotes; do not insert literal apostrophes. The guards assume real shell builtins. A fragment pasted below its guard is unguarded, and inherited arguments identical to the marker and expected values cannot be distinguished from a complete paste. Use curl 7.75.0 or newer for the diagnostic write-out fields. `--user` below contains only a username and prompts for the password; do not append a password. **1. Accounts and roles - REASONED: no `rabbitmqctl` or broker was available at authoring.** Run after migration and reconnecting clients: ```bash # Accounts and roles; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. sudo rabbitmqctl list_users sudo rabbitmqctl list_user_permissions 'app' sudo rabbitmqctl list_permissions -p 'app-prod' sudo rabbitmqctl list_vhosts ``` Compare the exposed state's unrestricted `/` grant and excessive operator privileges with the fixed state: `app` has only the intended `app-prod` grant, `observer` has `monitoring` without `administrator` and three `^$` patterns, and `guest` is absent. A failed listing proves nothing. Confirm an existing ungranted vhost for the protocol test below; do not substitute a nonexistent vhost. The [CLI permission commands](https://www.rabbitmq.com/docs/man/rabbitmqctl.8) expose these records; checks 4 and 6 test their effect. **2. Listener and cookie inventory - REASONED: no RabbitMQ node, epmd, or deployed cookie was available at authoring.** After the restart: ```bash # Listener and cookie inventory; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. # This fence has no single exit status of its own: read each part's output, including the ss part's refusal or failure line, not only the status. sudo rabbitmq-diagnostics -s listeners ( # a subshell, so the exits below end only this part: 2 if ss is not installed, 3 if ss fails, otherwise 0 command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp || { echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; exit 3; } ) epmd -names sudo stat -c '%a %U' /var/lib/rabbitmq/.erlang.cookie ``` Expect AMQPS on the selected private address at `5671`, HTTPS there at `15671`, HTTP only on `127.0.0.1:15672`, distribution on the private address at `25672`, and no plaintext AMQP listener on `5672`. epmd should bind privately and on loopback; `epmd -names` reports the registered distribution port, not epmd's interface bindings. Inspect all `ss` output, including IPv6 and additional plugin listeners. The exposed comparison has wildcard/public listeners or plaintext AMQP still enabled. On the conventional installation above, the cookie should be owned by `rabbitmq` and mode `600` or `400`; broader access is a finding. Mode alone does not prove randomness or absence from images. Listener addresses alone do not establish firewall isolation. These expectations follow [listener configuration](https://www.rabbitmq.com/docs/networking) and [cookie permissions](https://www.rabbitmq.com/docs/cli). **3. Reachability - REASONED: no broker, administration host, application host, or cluster peer was available at authoring.** Inventory actual ports first; update every corresponding probe if any port differs, and add probes for extra listeners. From an external non-peer host, target the broker's public address: ```bash # External reachability; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_PUBLIC_ADDR' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "need exactly 1 public address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the public address; not probing"; exit 2 ;; *) for p in 4369 25672 15672 15671 5671; do nc -vz -w 5 "$1" "$p" done # Removed plaintext AMQP is checked separately. nc -vz -w 5 "$1" 5672 ;; esac ) ``` From hosts allowed by the corresponding firewall rules, target the private address. Execute the cluster, application, and administration commands on their respective allowed hosts if those roles are separate: ```bash # Allowed-peer controls; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_PRIVATE_ADDR' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "need exactly 1 private address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the private address; not probing"; exit 2 ;; *) # Run from an allowed cluster/CLI host. for p in 4369 25672; do nc -vz -w 5 "$1" "$p"; done # Run these from the respective allowed application and administration hosts. nc -vz -w 5 "$1" 5671 nc -vz -w 5 "$1" 15671 # Neither listener should accept connections on the private interface. nc -vz -w 5 "$1" 15672 nc -vz -w 5 "$1" 5672 ;; esac ) ``` From an allowed CLI host with its owner-only matching cookie file, also require an authenticated distribution connection to the same broker. Supply its actual node name, such as the deployed `rabbit@` name. This form assumes short node names; add `--longnames` for a deployment using long names. The documented [diagnostics ping](https://www.rabbitmq.com/docs/man/rabbitmq-diagnostics.8) checks authentication as well as reachability: ```bash # Authenticated distribution ping; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_NODE_NAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "need exactly 1 node name; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the broker node name; not probing"; exit 2 ;; *) sudo rabbitmq-diagnostics -n "$1" ping ;; esac ) ``` On the broker itself, confirm that the retained HTTP management listener works with authentication: ```bash # Loopback management control; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. curl -q -g --noproxy '*' --connect-timeout 5 --max-time 20 \ -sS --user observer -o /dev/null \ -w 'loopback http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ 'http://127.0.0.1:15672/api/overview' ``` The exposed state accepts prohibited external connections; the fixed state refuses them while the allowed private ports connect and the loopback API returns HTTP 200. Pair these TCP controls with the authenticated API and AMQP checks below. Public and private targets must identify the same broker; do not reuse one split-DNS hostname for both. DNS/routing errors, a dead service, or an unidentified endpoint make a failure inconclusive. For removed plaintext AMQP, first demonstrate a connection to its old listener in an isolated exposed deployment, then show both its absence from the fixed listener inventory and refusal on `5672`, with authenticated AMQPS still working. This distinguishes listener removal from a stopped broker. See [RabbitMQ ports and binding](https://www.rabbitmq.com/docs/networking). **4. Management authentication and roles - REASONED: no broker or management endpoint was available at authoring.** From an allowed administration host, use the management certificate's hostname: ```bash # Management authentication and roles; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MANAGEMENT_HOSTNAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "need exactly 1 hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the management hostname; not probing"; exit 2 ;; *) # Username only: curl prompts for the password. curl -q -g --noproxy '*' --cacert /etc/rabbitmq/tls/ca.pem \ --connect-timeout 5 --max-time 20 -sS --user observer \ -w 'observer http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:15671/api/overview" curl -q -g --noproxy '*' --cacert /etc/rabbitmq/tls/ca.pem \ --connect-timeout 5 --max-time 20 -sS \ -w 'anonymous http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:15671/api/overview" # Paired administrative authority check; each password is prompted separately. curl -q -g --noproxy '*' --cacert /etc/rabbitmq/tls/ca.pem \ --connect-timeout 5 --max-time 20 -sS --user ops \ -w 'ops http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:15671/api/users" curl -q -g --noproxy '*' --cacert /etc/rabbitmq/tls/ca.pem \ --connect-timeout 5 --max-time 20 -sS --user observer \ -w 'observer http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:15671/api/users" ;; esac ) ``` Require HTTP 200 for the observer's overview and the administrator's user listing. The anonymous overview must return an authentication refusal, and the observer's user listing must return an authorization refusal; inspect the response body and correlate authentication failures before accepting a denial. Transport failures, HTTP 000, redirects, and server errors are inconclusive. Retain output privately because administrative responses may contain sensitive account metadata. An exposed management listener can still require authentication: external reachability is tested separately in check 3. For the excessive-role comparison, an observer carrying `administrator` can list users; after correction it retains overview access but loses that administrative access. If anonymous overview access succeeds through the deployed path, that is an additional finding. See [management roles](https://www.rabbitmq.com/docs/management) and the [overview and users endpoints](https://www.rabbitmq.com/docs/http-api-reference). **5. Mutual TLS - REASONED: no broker, trusted test certificates, or broker logs were available at authoring.** From an allowed application host, place `ca.pem`, `client.pem`, and the protected `client.key` in the working directory. Use a valid client certificate with `clientAuth`, not a server-only certificate: ```bash # Mutual TLS; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_AMQP_HOSTNAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "need exactly 1 hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the AMQP hostname; not probing"; exit 2 ;; *) # Keep stdin open to allow a delayed TLS rejection to arrive. if sleep 10 | openssl s_client -connect "$1:5671" -servername "$1" \ -CAfile ca.pem -cert client.pem -key client.key \ -verify_hostname "$1" -verify_return_error; then echo "certificate-present run exited 0; inspect the handshake and broker log" else echo "positive control failed; the pair is inconclusive" exit 3 fi if sleep 10 | openssl s_client -connect "$1:5671" -servername "$1" \ -CAfile ca.pem -verify_hostname "$1" -verify_return_error; then echo "certificate-absent run exited 0; required rejection was not demonstrated" exit 3 else echo "negative run failed; require a matching missing-client-certificate broker log" fi ;; esac ) ``` `Verification: OK` describes verification of the server certificate only. Require a successful certificate-present handshake plus a certificate-absent failure attributed by the broker to the missing client certificate. A DNS error, TCP failure, server-certificate failure, or unrelated timeout is not the required rejection. Keeping stdin open helps expose delayed TLS 1.3 alerts; ten seconds is a timing-dependent observation window, not a guarantee. Both runs exiting zero is inconclusive. If the positive run closes because no AMQP protocol handshake followed, use the protocol client in check 6 and broker logs to resolve it. In the isolated exposed comparison with client certificates optional, the certificate-absent handshake succeeds too. See [peer verification](https://www.rabbitmq.com/docs/ssl). **6. AMQP authentication and authorization - REASONED: no RabbitMQ broker or Pika client package was available at authoring.** Run with Python 3 and Pika 1.x using the TLS files from check 5. Passwords are prompted from the terminal. Use an isolated test deployment: the script creates a uniquely named durable queue and direct exchange and deletes them on normal completion; inspect and remove its `app.secureconfig.*` fixtures after an interrupted run. The positive control declares, binds, publishes, and retrieves a message as `app`. Separate channels test resource denials. The shared queue is durable because RabbitMQ 4.3 disables transient non-exclusive queues by default; an exclusive queue would make the observer's read test ambiguous. The script uses the [documented permission model](https://www.rabbitmq.com/docs/access-control), [channel error codes](https://www.rabbitmq.com/docs/channels), [queue rules](https://www.rabbitmq.com/docs/queues), and [Pika's blocking client](https://pika.github.io/pika/latest/modules/adapters/blocking/). ```bash # AMQP authentication and authorization; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_AMQP_HOSTNAME' 'REPLACE_WITH_EXISTING_UNGRANTED_VHOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "need hostname and existing ungranted vhost; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the AMQP hostname; not probing"; exit 2 ;; *) case "$2" in *REPLACE_WITH_*|"") echo "substitute the ungranted vhost; not probing"; exit 2 ;; *) python3 - "$1" "$2" <<'PY' import getpass import os import ssl import sys import uuid import warnings def inconclusive(kind, value, tb): # An unexpected exception, including a failed positive control, is inconclusive: # exit with status 3, unless an unexpected allow was already reported, which keeps status 1. sys.__excepthook__(kind, value, tb) sys.stdout.flush() sys.stderr.flush() os._exit(1 if findings else 3) findings = [] sys.excepthook = inconclusive import pika warnings.simplefilter("error", getpass.GetPassWarning) host, other_vhost = sys.argv[1:] context = ssl.create_default_context(cafile="ca.pem") context.load_cert_chain("client.pem", "client.key") app_password = getpass.getpass("app password: ") observer_password = getpass.getpass("observer password: ") def connect(user, password, vhost): return pika.BlockingConnection(pika.ConnectionParameters( host=host, port=5671, virtual_host=vhost, credentials=pika.PlainCredentials(user, password), ssl_options=pika.SSLOptions(context, host), socket_timeout=5, stack_timeout=15, blocked_connection_timeout=15)) def denied_operation(connection, label, operation): channel = connection.channel() channel.confirm_delivery() try: operation(channel) except pika.exceptions.ChannelClosedByBroker as exc: if exc.reply_code != 403: raise print(label + ": ACCESS_REFUSED; correlate with the broker log") else: findings.append(label) print(label + ": UNEXPECTED ALLOW") finally: if channel.is_open: channel.close() def denied_connection(user, password, vhost, expected_error, label): try: connection = connect(user, password, vhost) except expected_error: print(label + ": refused; require the matching broker-log reason") else: connection.close() findings.append(label) print(label + ": UNEXPECTED ALLOW") name = "app.secureconfig." + uuid.uuid4().hex with connect("app", app_password, "app-prod") as app: channel = app.channel() channel.exchange_declare(exchange=name, exchange_type="direct", durable=True) try: channel.queue_declare(queue=name, durable=True, arguments={"x-queue-type": "classic"}) try: channel.queue_bind(queue=name, exchange=name, routing_key="verify") channel.confirm_delivery() channel.basic_publish(exchange=name, routing_key="verify", body=b"secureconfig", mandatory=True) method, properties, body = channel.basic_get(queue=name, auto_ack=True) if method is None or body != b"secureconfig": raise RuntimeError("positive message round trip failed") print("app: authenticated; declare, bind, publish and get succeeded") denied_operation(app, "app unrelated queue", lambda ch: ch.queue_declare(queue="unrelated." + uuid.uuid4().hex, exclusive=True)) denied_operation(app, "app default exchange", lambda ch: ch.basic_publish(exchange="", routing_key=name, body=b"denial probe", mandatory=True)) with connect("observer", observer_password, "app-prod") as observer: print("observer: authenticated to app-prod") denied_operation(observer, "observer configure", lambda ch: ch.queue_declare(queue=name + ".observer", exclusive=True)) denied_operation(observer, "observer publish", lambda ch: ch.basic_publish(exchange=name, routing_key="verify", body=b"denial probe", mandatory=True)) denied_operation(observer, "observer get", lambda ch: ch.basic_get(queue=name, auto_ack=True)) denied_connection("secureconfig-unknown-" + uuid.uuid4().hex, "deliberately-invalid", "app-prod", pika.exceptions.ProbableAuthenticationError, "unknown user") denied_connection("app", app_password, other_vhost, pika.exceptions.ProbableAccessDeniedError, "app ungranted vhost") finally: if app.is_open: app.channel().queue_delete(queue=name) finally: if app.is_open: app.channel().exchange_delete(exchange=name) if findings: raise SystemExit("unexpectedly allowed: " + ", ".join(findings)) print("Expected results observed by this run; retain the matching broker logs.") PY ;; esac ;; esac ) ``` Require the positive message round trip and observer authentication, then resource-specific `403 ACCESS_REFUSED` responses. Unknown-user and ungranted-vhost connection errors must have matching broker-log reasons; Pika's `Probable*` exception names are not proof by themselves. Unexpected exceptions are inconclusive, not authorization successes. For the exposed comparison, use an isolated fixture with unrestricted application permissions in `app-prod` and the second existing vhost, and an observer with excessive resource grants. The same script should report unexpected allows for those operations. An unknown user should still be refused when authentication is working in either state. The fixed deployment must retain the positive operations while removing those unexpected allows. Reconnect between states. **7. Definitions provisioning and export - REASONED: no blank broker, `rabbitmqadmin`, or exported definitions file was available at authoring.** Boot an isolated blank node using the approved file and boot settings from section 1, then run: ```bash # Definitions provisioning and export; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. sudo rabbitmqctl list_users sudo rabbitmqctl list_permissions -p 'app-prod' python3 - <<'PY' import json import os import sys from pathlib import Path def inconclusive(kind, value, tb): # An unreadable or malformed export is inconclusive: exit with status 3, # keeping status 1 for the review-required findings below. sys.__excepthook__(kind, value, tb) sys.stdout.flush() sys.stderr.flush() os._exit(3) sys.excepthook = inconclusive data = json.loads(Path("definitions.redacted.json").read_text()) if not isinstance(data, dict): print("not a definitions object", file=sys.stderr) sys.exit(3) for field in ("users", "permissions", "topic_permissions", "parameters", "global_parameters"): if data.get(field): raise SystemExit("review required: non-empty " + field) print("Selected sensitive sections are absent or empty; review all other content.") PY ``` Compare users and grants with the approved list, including absence of `guest`. For the exposed comparison, use a separately reviewed test fixture containing an unwanted test account or grant and confirm that the inventory detects it. Then repeat on another blank node with the approved file; importing over the first node is not reconciliation. Run section 1's reduced export and compare it with a private untransformed export from the same test broker containing known test users, vhost grants, topic permissions, and runtime parameters. The selected sections must be removed or empty in the reduced JSON. The local JSON check is deliberately conservative: any retained topic permissions or global parameters also require review. It exits with status 1 when a selected section requires review, and with status 3 when the export cannot be read or is not a definitions object. Review all remaining fields for secrets; empty selected sections do not establish that the whole document is safe to share. See [definitions import and export](https://www.rabbitmq.com/docs/definitions) and [exported definition contents](https://www.rabbitmq.com/docs/http-api-reference#get-apidefinitions). **8. Inter-node and CLI distribution TLS - REASONED: no Erlang runtime, RabbitMQ broker, or multi-node cluster was available at authoring.** On an authorized test cluster with the section 6 configuration, from an authorized CLI host holding the matching cookie and substituting the deployed long node name for `rabbit@node1.internal`: ```bash # Distribution TLS; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. sudo rabbitmq-diagnostics --longnames -n rabbit@node1.internal ping sudo rabbitmqctl --longnames -n rabbit@node1.internal cluster_status ``` With plaintext distribution a plaintext CLI authenticates; with TLS required, a plaintext CLI connection fails on the transport, while a CLI given the matching `RABBITMQ_CTL_ERL_ARGS` succeeds, and a CLI presenting a missing or untrusted client certificate is rejected. Two configured nodes must form a cluster over the selected TLS transport, and a plaintext node must not join. Correlate a failure with the TLS logs; a wrong cookie, bad DNS, or a stopped node is inconclusive, and `ping` and `cluster_status` alone do not prove every peer link is encrypted. See the [inter-node TLS guide](https://www.rabbitmq.com/docs/clustering-ssl). **9. Topic-exchange routing-key authorization - REASONED: no RabbitMQ broker or AMQP client was available at authoring.** On an authorized broker with an administrator-provisioned `app.shared.topic` exchange, hold the section 1 resource permissions constant and vary only whether the section 7 topic permission exists: ```bash # Topic routing-key authorization; no RabbitMQ/Erlang broker, clients or peer fixtures. Expected outcomes and vendor sources are recorded in this section. sudo rabbitmqctl list_topic_permissions -p 'app-prod' ``` As the `app` user with publisher confirms, publish to `tenant1.created` and to `tenant2.created`, and bind a queue with each routing key, then repeat with `#` and `tenant1.#`. With no topic permission every publish and binding succeeds; with the section 7 permission `tenant1.created` succeeds while `tenant2.created`, `#`, and `tenant1.#` are refused with a broker-attributed `ACCESS_REFUSED`. Require that broker refusal rather than a client-side timeout; the listing shows the stored policy, not enforcement. Also test the exchange-to-exchange bypass across both grants. Under the baseline grant, where the `app` user can declare exchanges, it declares its own exchange, binds it to `app.shared.topic` with `tenant2.created`, and publishes through it; the message reaches the shared exchange, confirming that topic authorization alone is insufficient. Under the hardened grant, the exchange declaration is refused because the application has no configure permission, and an `exchange.bind` into `app.shared.topic` is refused with `ACCESS_REFUSED` because the application holds read permission on no exchange, so no relay route can be created. See the [topic authorization reference](https://www.rabbitmq.com/docs/access-control#topic-authorisation). Local authoring checks, completed without a broker: | Check | Observed result | |---|---| | Bash parsing and ShellCheck | All runnable Bash blocks passed `bash -n` and ShellCheck 0.11.0; an intentional SC2086 canary confirmed that lint diagnostics were active. These runs covered the section 1 reduced-export block and the guarded blocks of checks 3 to 6 in an earlier form, whose overwrite and placeholder refusals exited with status 1, and were not re-run on the current form. They also covered the check 7 block in an earlier form, which exited with status 1 for an unreadable, malformed or non-object export, and were not re-run on its current form, which exits with status 3 there. | | Placeholder guards | 32 cases stopped locally under `bash -u`: unreplaced values, each empty value, embedded `REPLACE_WITH_*`, marker-only assignment, and omitted assignment with inherited non-marker arguments. These cases ran against an earlier form, whose placeholder refusals exited with status 1; none was re-run on the current form, whose placeholder refusals exit with status 2. | | TOML | The example parsed with Python `tomllib`. | | Embedded Python | Both programs passed Python syntax parsing. Those runs covered an earlier form, without check 6's status 3 exception handler and check 7's status 3 arms for an unreadable, malformed or non-object export, and neither current form was re-run. | | RabbitMQ configuration | Key/value structure checked locally and settings traced to current vendor docs; native configuration/schema parsing remains unconfirmed because RabbitMQ and Erlang were absent at authoring. | Historical authoring record: no broker, container, or whole-corpus gate suite was run in the original review. Local syntax checks do not demonstrate service behaviour. | Check scope | Procedure and prerequisites | Status | |---|---|---| | Service checks | Demonstrate checks 1-9 against isolated exposed and fixed states (1 of 2): scoped grants and vhost refusal; monitoring versus administration and message operations; listener/epmd bindings, cookie permissions, external denial and allowed-peer controls including 15671 and removed 5672; management authenticated/anonymous responses; mutual TLS and AMQP user authentication. The items to demonstrate for checks 1-9 against isolated exposed and fixed states (2 of 2): approved blank-node imports and reduced exports; plaintext versus TLS-required inter-node and CLI distribution, with rejection of a missing or untrusted client certificate; and topic-exchange routing-key authorization, with a broker ACCESS_REFUSED for an out-of-pattern publish or binding. Include startup checks and retain commands, versions, outputs, and matching broker logs. | REASONED from the cited RabbitMQ documentation; missing RabbitMQ/Erlang, rabbitmqadmin, Pika, container runtime, test certificates, and peer infrastructure in the authoring environment. | | Native configuration/schema parsing | Parse the RabbitMQ configuration with the native tools. | Unconfirmed because RabbitMQ and Erlang were absent at authoring; retained local work. | ## 6. Encrypt inter-node and CLI distribution traffic with TLS Section 4 binds the Erlang distribution port privately but leaves that traffic in plaintext. Distribution carries clustering messages and the full authority of `rabbitmqctl` and `rabbitmq-diagnostics`, so encrypt it with mutually authenticated TLS across every node and CLI host. This is an Erlang runtime mechanism, not a `rabbitmq.conf` setting: it is selected through `rabbitmq-env.conf` and a separate Erlang-term options file. RabbitMQ 4.4.0 fully supports the Erlang/OTP 28 and 29 series, per the [v4.4.0 release notes](https://github.com/rabbitmq/rabbitmq-server/releases/tag/v4.4.0); this guide does not state a minimum Erlang/OTP version. This example was written against the OTP 27 references this section cites, so check them against the OTP release you run. See the [inter-node TLS guide](https://www.rabbitmq.com/docs/clustering-ssl) and the [OTP distribution-over-TLS reference](https://www.erlang.org/docs/27/apps/ssl/ssl_distribution.html). In `/etc/rabbitmq/rabbitmq-env.conf`, select the TLS distribution module for both the server and the CLI tools, pointing each at the options file: ```bash # shellcheck disable=SC2034 # rabbitmq-env.conf is sourced by RabbitMQ; these variables are read externally ERL_SSL_PATH="REPLACE_WITH_INSTALLED_SSL_EBIN_DIRECTORY" SERVER_ADDITIONAL_ERL_ARGS="-pa $ERL_SSL_PATH -proto_dist inet_tls -ssl_dist_optfile /etc/rabbitmq/inter_node_tls.config" RABBITMQ_CTL_ERL_ARGS="-pa $ERL_SSL_PATH -proto_dist inet_tls -ssl_dist_optfile /etc/rabbitmq/inter_node_tls.config" ``` Determine the installed Erlang `ssl` application `ebin` directory on the host rather than copying a version-specific example. `SERVER_ADDITIONAL_ERL_ARGS` configures the broker's distribution; `RABBITMQ_CTL_ERL_ARGS` gives `rabbitmqctl` and `rabbitmq-diagnostics` the matching configuration so CLI connections are not refused. In a systemd or container launch environment the server variable's prefixed form is `RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS`. See the [pinned launch-variable handling](https://raw.githubusercontent.com/rabbitmq/rabbitmq-server/v4.4.0/deps/rabbit/scripts/rabbitmq-env). In `/etc/rabbitmq/inter_node_tls.config`, require and verify peer certificates in both directions, because a node both accepts and initiates distribution connections: ```erlang [ {server, [ {cacertfile, "/etc/rabbitmq/distribution/ca.pem"}, {certfile, "/etc/rabbitmq/distribution/server.pem"}, {keyfile, "/etc/rabbitmq/distribution/server.key"}, {verify, verify_peer}, {fail_if_no_peer_cert, true} ]}, {client, [ {cacertfile, "/etc/rabbitmq/distribution/ca.pem"}, {certfile, "/etc/rabbitmq/distribution/client.pem"}, {keyfile, "/etc/rabbitmq/distribution/client.key"}, {verify, verify_peer} ]} ]. ``` Provision credentials readable by the effective process identity on every node and CLI host. Peer verification checks the certificate against the hostname portion of the target Erlang node name (the part after the `@`), so issue each distribution certificate with a subject or SAN DNS identity matching that hostname rather than assuming the AMQP hostname certificate satisfies it. This outbound hostname verification is distinct from the inbound client-certificate trust that `verify_peer` and `fail_if_no_peer_cert` enforce; never disable either to resolve a mismatch. Plan a coordinated switch: distribution endpoints with mismatched transports cannot communicate, so a plaintext node and a TLS node will not form a cluster during a rolling change. TLS does not remove the Erlang cookie, epmd, or firewall requirements from section 4. See the [OTP distribution semantics](https://www.erlang.org/docs/27/apps/ssl/ssl_distribution.html) and the [OTP 27 distribution TLS implementation](https://raw.githubusercontent.com/erlang/otp/OTP-27.0/lib/ssl/src/inet_tls_dist.erl). ## 7. Restrict routing keys on shared topic exchanges Section 1 bounds which exchanges and queues a user may configure, write, and read, but on a shared topic exchange those permissions do not restrict which routing keys a user may publish or bind. Topic authorization adds that layer. It is off until configured: with no topic permission set, RabbitMQ authorizes every topic operation, subject only to the section 1 resource permissions. Topic authorization has been available since [RabbitMQ 3.7](https://github.com/rabbitmq/rabbitmq-server/releases/tag/v3.7.0). See the [topic authorization documentation](https://www.rabbitmq.com/docs/access-control#topic-authorisation). Topic permissions are broker records managed with `rabbitmqctl`, not entries in `rabbitmq.conf` or `advanced.config`. For an administrator-provisioned topic exchange `app.shared.topic` where the `app` user should publish and bind only its own tenant's routing keys, grant the scoped topic permission. The argument order is the user, then the exchange, then the write and read patterns: ```bash sudo rabbitmqctl set_topic_permissions -p 'app-prod' 'app' \ 'app.shared.topic' \ '^tenant1[.][abcdefghijklmnopqrstuvwxyz0123456789_-]+$' \ '^tenant1[.][abcdefghijklmnopqrstuvwxyz0123456789_-]+$' ``` The write pattern gates publishing and the read pattern gates the routing keys a binding may use; both are regular expressions, not AMQP topic wildcards, so this example admits exact `tenant1.` keys and excludes `#` and `*` subscriptions. Consumers still read from their queues under ordinary queue permissions, so topic authorization does not filter messages already sitting in an accessible queue, nor does it remove existing broad bindings: audit current bindings and queue grants, and reconnect clients after changing them. Topic authorization checks routing keys only on a direct publish to the topic exchange. If the section 1 resource grant lets the application declare its own exchanges, the application can bind one to the shared exchange and route a disallowed key through it without the topic write check, because a resource-name grant does not distinguish an exchange from a queue. Strong tenant isolation therefore requires the administrator to own every route into the shared exchange: deny the application configure permission so it cannot declare a relay exchange, and scope its read grant so it matches no exchange at all (for example a queue-only naming scheme such as `^app[.]q[.]`). The read grant must match no exchange because binding any readable exchange into the shared exchange needs only the write grant the application already holds for publishing. The administrator provisions the application's queues, exchanges, and bindings. Clearing a topic permission returns that exchange to the unrestricted default; it is not a deny rule. Other authorization backends enforce their own topic rules. See the [topic authorization reference](https://www.rabbitmq.com/docs/access-control#topic-authorisation) and the [rabbitmqctl command reference](https://www.rabbitmq.com/docs/man/rabbitmqctl.8). ## Sources (checked September 2026) - RabbitMQ access control, guest restrictions, interactive user creation, and resource permissions: https://www.rabbitmq.com/docs/access-control - RabbitMQ virtual hosts and logical isolation: https://www.rabbitmq.com/docs/vhosts - RabbitMQ CLI user, vhost, permission, and tag commands: https://www.rabbitmq.com/docs/man/rabbitmqctl.8 - RabbitMQ inter-node TLS (distribution over TLS): https://www.rabbitmq.com/docs/clustering-ssl - Erlang/OTP 27 distribution over TLS: https://www.erlang.org/docs/27/apps/ssl/ssl_distribution.html - OTP 27 distribution TLS implementation (hostname verification): https://raw.githubusercontent.com/erlang/otp/OTP-27.0/lib/ssl/src/inet_tls_dist.erl - RabbitMQ v4.4.0 launch-variable handling (rabbitmq-env): https://raw.githubusercontent.com/rabbitmq/rabbitmq-server/v4.4.0/deps/rabbit/scripts/rabbitmq-env - RabbitMQ v4.4.0 release notes (fully supports the Erlang/OTP 28 and 29 series; they state no minimum Erlang/OTP version): https://github.com/rabbitmq/rabbitmq-server/releases/tag/v4.4.0 - RabbitMQ v4.4.0 web dispatch listeners, split into one socket per address, each socket's `ip` becoming its bind address. RabbitMQ v4.4.0 web dispatch sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_web_dispatch/src/rabbit_web_dispatch_sup.erl#L39-L46 and https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_web_dispatch/src/rabbit_web_dispatch_sup.erl#L174-L188 - RabbitMQ v4.4.0 per-address listener split: a listener with no `ip` gets the IPv6 wildcard, the IPv4 wildcard or both, by the host's IPv6 behaviour. RabbitMQ v4.4.0 networking sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/src/rabbit_networking.erl#L226-L248 and https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/src/rabbit_networking.erl#L775-L795 - RabbitMQ v4.4.0 management schema `management.tcp.ip` mapping (no default value): https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/priv/schema/rabbitmq_management.schema#L58-L60 - RabbitMQ v4.4.0 management schema `management.ssl.ip` mapping: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/priv/schema/rabbitmq_management.schema#L104-L105 - RabbitMQ v4.4.0 management schema `management.listeners.tcp.` and `management.listeners.ssl.` settings: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/priv/schema/rabbitmq_management.schema#L187-L203 - RabbitMQ 3.7.0 release notes (topic authorization introduction): https://github.com/rabbitmq/rabbitmq-server/releases/tag/v3.7.0 - RabbitMQ management listeners, HTTPS, and roles: https://www.rabbitmq.com/docs/management - RabbitMQ 4.3 management documentation source, example configuration described as setting some parameters to their default values, which includes `management.tcp.ip = 0.0.0.0` (rabbitmq-website commit db23826e): https://github.com/rabbitmq/rabbitmq-website/blob/db23826ebaa810b3cdd525fd290125d65f6c3005/versioned_docs/version-4.3/management/index.md#L1365-L1381 - RabbitMQ configuration files and restart requirements: https://www.rabbitmq.com/docs/configure - RabbitMQ plugin activation: https://www.rabbitmq.com/docs/plugins - RabbitMQ plugin enable command: https://www.rabbitmq.com/docs/man/rabbitmq-plugins.8 - RabbitMQ HTTP API authentication and endpoints: https://www.rabbitmq.com/docs/http-api-reference - RabbitMQ TLS, peer verification, certificate usage, and verification depth: https://www.rabbitmq.com/docs/ssl - RabbitMQ networking, epmd lifecycle, private bindings, and distribution ports: https://www.rabbitmq.com/docs/networking - RabbitMQ management plugin default port 15672 and the plain HTTP listener it starts when no management listener is configured (pinned tag v4.4.0). RabbitMQ management plugin sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L23 and https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L102-L108. More RabbitMQ management plugin sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L203-L208 and https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L234-L242 - RabbitMQ management plugin extra listeners from `management.listeners.*`, added after the default listeners, with a port-only entry dropping the configured `ip`, and the HTTPS listener's `ip` as its bind address (pinned tag v4.4.0). RabbitMQ management listener sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L134-L152 and https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L164-L177. More RabbitMQ management listener sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L188-L201 - RabbitMQ Erlang distribution port: `RABBITMQ_NODE_PORT` (default 5672) plus 20000 unless `RABBITMQ_DIST_PORT` is set, pinned as both `inet_dist_listen_min` and `inet_dist_listen_max`, with each `RABBITMQ_*` variable falling back to its unprefixed form and an empty value treated as unset (pinned tag v4.4.0). RabbitMQ Erlang distribution port sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit_common/src/rabbit_env.erl#L1247-L1283 and https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit_common/src/rabbit_env.erl#L2083-L2099. More RabbitMQ Erlang distribution port sources: https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit_common/src/rabbit_env.erl#L361-L369 - RabbitMQ `distribution.listener.port_range.min` and `.max` mapped to the Erlang kernel's `inet_dist_listen_min` and `inet_dist_listen_max` (pinned tag v4.4.0): https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/priv/schema/rabbit.schema#L2287-L2293 - RabbitMQ CLI distribution port range `RABBITMQ_CTL_DIST_PORT_MIN` (default 35672) to `RABBITMQ_CTL_DIST_PORT_MAX` (default ten above it), each empty-as-unset and falling back to the unprefixed `CTL_DIST_PORT_MIN`/`CTL_DIST_PORT_MAX` (pinned tag v4.4.0): https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/scripts/rabbitmq-env#L143-L146 - RabbitMQ `default_user`/`default_pass` guest/guest with the administrator tag, and `loopback_users` [guest] (pinned tag v4.4.0): https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/Makefile#L33-L38 - Official RabbitMQ 4.3 Docker image: `10-defaults.conf` sets `loopback_users.guest = false`, and both variants copy it into `/etc/rabbitmq/conf.d/` (pinned commit 76efb370838e36e4b120e252e75cf0dac70a7d55). Official RabbitMQ 4.3 Docker image sources: https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/ubuntu/10-defaults.conf#L8 and https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/ubuntu/Dockerfile#L321. More official RabbitMQ 4.3 Docker image sources: https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/alpine/10-defaults.conf#L8 and https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/alpine/Dockerfile#L327 - RabbitMQ diagnostics, remote node selection, and authenticated ping: https://www.rabbitmq.com/docs/man/rabbitmq-diagnostics.8 - RabbitMQ CLI authentication and Erlang cookie handling: https://www.rabbitmq.com/docs/cli - RabbitMQ definitions imports, sensitive exports, and export transformations: https://www.rabbitmq.com/docs/definitions - RabbitMQ administration CLI v2 configuration and TLS flags: https://www.rabbitmq.com/docs/management-cli - RabbitMQ queues, durability, and exclusive queue behaviour: https://www.rabbitmq.com/docs/queues - RabbitMQ channel exceptions and access-refused errors: https://www.rabbitmq.com/docs/channels - Pika TLS context and client certificate example (mutual authentication; checked October 2026): https://pika.github.io/pika/latest/examples/tls_mutual_authentication/ - Pika 1.x `SSLOptions(context, server_hostname)` class taking an `ssl.SSLContext` (pinned tag 1.3.2; checked October 2026): https://github.com/pika/pika/blob/1.3.2/pika/connection.py#L937-L958 - Pika 1.x `ConnectionParameters` `ssl_options` argument (pinned tag 1.3.2; checked October 2026): https://github.com/pika/pika/blob/1.3.2/pika/connection.py#L579-L580 - Pika connection parameters: https://pika.github.io/pika/latest/modules/parameters/ - Pika blocking connection and channel methods: https://pika.github.io/pika/latest/modules/adapters/blocking/ - Pika authentication and authorization exceptions: https://pika.github.io/pika/latest/modules/exceptions/ - curl password prompts, TLS validation, and diagnostic flags: https://curl.se/docs/manpage.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - source: RabbitMQ source (v4.4.0) - admin: rabbitmqadmin (v2) - topic-min: RabbitMQ topic authorization introduction (3.7.0) - docs: RabbitMQ documentation (unknown) - image: RabbitMQ image source (76efb370838e36e4b120e252e75cf0dac70a7d55) - management-source: RabbitMQ management documentation source (db23826ebaa810b3cdd525fd290125d65f6c3005) - otp: Erlang/OTP distribution (27) - curl: curl documentation (unknown) - pika: Pika documentation (unknown) - pika-source: Pika source (1.3.2) Claims (full source mapping in the JSON block below): - guest (REASONED; docs, source): Upstream guest/guest is an administrator restricted to localhost; create real users and delete or change guest. - docker-guest (REASONED; image): Shipped official 4.3 images disable guest loopback restriction; later configuration can restore it. - vhosts (REASONED; docs): Separate users/vhosts and remove old grants; vhosts isolate resources, not CPU or memory. - resource-grants (REASONED; docs): Configure/write/read regexes scope app.*; amq.default is excluded; binding needs queue write and exchange read. - roles (REASONED; docs): Tags replace previous tags and grant management authority, not resource permissions; observer gets monitoring and empty grants. - definitions-boot (REASONED; docs): Reviewed local boot imports need no management plugin, do not overwrite existing definitions, and suppress blank-node default creation. - definitions-runtime (REASONED; docs): Runtime imports merge, retain omitted objects and immutable conflicts, and can fail partially; they are not reconciliation. - definitions-export (REASONED; docs, admin): v2 export transformations remove users, permissions and runtime parameters; protect TOML credentials and review remaining secrets. - amqp-tls (REASONED; docs): Private 5671 requires trusted client certificates; remove plaintext 5672 after migration. - tls-depth (REASONED; docs): ssl_options.depth defaults to 1; clientAuth usage and intermediate-chain depth must fit deployed certificates. - tls-identity (REASONED; docs): mTLS does not map a RabbitMQ username; password auth remains unless certificate plugin/SASL EXTERNAL is configured. - management-bind (REASONED; docs): Apply and restart with loopback HTTP 15672/private HTTPS 15671 before enabling management. - management-default (REASONED; source, management-source): v4.4.0 starts plain HTTP 15672 when no management listener is configured; with no address set it binds the IPv6 and IPv4 wildcards on dual stack, the IPv6 wildcard alone on single stack and the IPv4 wildcard on IPv4-only hosts; a 4.3 docs example includes management.tcp.ip = 0.0.0.0. - management-listeners (REASONED; source): v4.4.0 management.listeners.tcp and .ssl entries add listeners on top of the defaults, so default HTTP 15672 still starts; a port-only entry drops management.tcp.ip or management.ssl.ip and binds without an address limit. - management-ssl-ip (REASONED; source): At v4.4.0 management.ssl.ip limits the HTTPS listener to that address; without it, HTTPS binds the same wildcard addresses as the default HTTP listener. - management-auth (REASONED; docs): Management has separate TLS and RabbitMQ HTTP credentials; restrict admin networks and human access. - epmd (REASONED; docs): epmd defaults to 4369; ERL_EPMD_ADDRESS requires broker and epmd restart, or socket-unit changes; loopback remains. - distribution-port (REASONED; source, docs): Distribution defaults to node port plus 20000 (25672), pinned as equal kernel min and max; nonempty DIST_PORT replaces it; port_range keys map to the same kernel settings, with precedence not established. - cli-ports (REASONED; source, docs): CLI defaults 35672-35682; prefixed variables fall back to unprefixed forms, treating empty as unset. - cookie (REASONED; docs): Matching owner-only cookie files grant powerful node/CLI access; protect secret delivery and restrict peer reachability. - distribution-tls (REASONED; docs, otp, source): Use Erlang TLS distribution for server and CLI with matching launch arguments; 4.4.0 fully supports OTP 28 and 29, and no minimum is stated. - distribution-identity (REASONED; otp): Verify peer certificates both ways and outbound node hostnames; coordinate transport changes and retain cookie/firewall controls. - topic-default (REASONED; docs, topic-min): Topic authorization dates from 3.7; absent or cleared topic permissions leave topic operations unrestricted by that layer. - topic-patterns (REASONED; docs): Topic write/read regexes gate publish/binding keys; tenant1 exact keys exclude wildcard bindings, not existing queued messages. - topic-bypass (REASONED; docs): Exchange relay can bypass direct topic-write checks; strong isolation needs admin-owned routes, no configure and queue-only read grants. - verify-accounts (REASONED; docs): Inspect migrated vhost grants, observer tags/empty patterns and guest removal; failed listings prove nothing. - verify-inventory (REASONED; docs): Inventory all listeners and cookie ownership/mode; local binds and mode do not establish isolation or secrecy. - verify-network (REASONED; docs): Forbidden external connections must fail while allowed private peers connect; confirm plaintext 5672 removal with live AMQPS. - verify-distribution (REASONED; docs): Allowed CLI host must authenticate ping to the actual node with matching cookie; TCP reachability alone is insufficient. - verify-loopback (REASONED; docs, curl): Authenticated loopback /api/overview must return 200 while private HTTP 15672 is unreachable. - verify-management (REASONED; docs, curl): Observer overview and ops user listing return 200; anonymous overview and observer user listing refuse with actual auth errors. - verify-mtls (REASONED; docs): Certificate-present handshake succeeds and absent certificate gets broker-attributed rejection; server Verification: OK is insufficient. - queue-default (REASONED; docs): RabbitMQ 4.3 disables transient non-exclusive queues by default; the protocol fixture uses a durable queue. - verify-amqp (REASONED; docs, pika, pika-source): Pika 1.x fixture requires app round trip, observer authentication and resource-specific 403 ACCESS_REFUSED; log connection refusal reasons. - verify-definitions (REASONED; docs, admin): Compare approved blank-node users/grants and reduced versus private full exports; empty selected sections do not prove secrecy. - verify-distribution-tls (REASONED; docs, otp): TLS CLI/nodes succeed; plaintext and missing/untrusted certificate peers fail with TLS evidence, not cookie/DNS errors. - verify-topic (REASONED; docs): Tenant1 operations succeed; other keys/wildcards fail; compare relay bypass with hardened route-creation denial. ````json { "schema": 1, "checked": "2026-11-07", "documentation_checked": "2026-09", "body_sha256": "827e8f253c2455dc18dc845a54d8015cd0eea915fc6eb6ac13ae7ee913a06d5a", "components": { "source": { "name": "RabbitMQ source", "basis": "v4.4.0", "sources": { "s9f1f8db14acc": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/Makefile#L33-L38", "s3e14b93fd407": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L23", "sfcd87fb1a469": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L102-L108", "s19c597b9b0c4": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L203-L208", "s55fa6710ead4": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L234-L242", "sfecb265794ac": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L134-L152", "s7e312a375671": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L164-L177", "s58bda198ac55": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/src/rabbit_mgmt_app.erl#L188-L201", "s70d7b769d1de": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit_common/src/rabbit_env.erl#L1247-L1283", "s752f79a56561": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit_common/src/rabbit_env.erl#L2083-L2099", "sc0a5d56dfcce": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit_common/src/rabbit_env.erl#L361-L369", "s22313020f9fb": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/priv/schema/rabbit.schema#L2287-L2293", "s2b3127ddf2a9": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/scripts/rabbitmq-env#L143-L146", "s0ac4ba8e9551": "https://raw.githubusercontent.com/rabbitmq/rabbitmq-server/v4.4.0/deps/rabbit/scripts/rabbitmq-env", "s815b3e32b081": "https://github.com/rabbitmq/rabbitmq-server/releases/tag/v4.4.0", "s176c9d9d819c": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_web_dispatch/src/rabbit_web_dispatch_sup.erl#L39-L46", "s108676baa88f": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_web_dispatch/src/rabbit_web_dispatch_sup.erl#L174-L188", "s5c31104b0807": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/src/rabbit_networking.erl#L226-L248", "s38f7e5ca992d": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbit/src/rabbit_networking.erl#L775-L795", "s886babdfc3ec": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/priv/schema/rabbitmq_management.schema#L58-L60", "s85b75e716115": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/priv/schema/rabbitmq_management.schema#L104-L105", "s256c6b44600f": "https://github.com/rabbitmq/rabbitmq-server/blob/v4.4.0/deps/rabbitmq_management/priv/schema/rabbitmq_management.schema#L187-L203" } }, "admin": { "name": "rabbitmqadmin", "basis": "v2", "sources": { "sdd5688854073": "https://www.rabbitmq.com/docs/management-cli" } }, "topic-min": { "name": "RabbitMQ topic authorization introduction", "basis": "3.7.0", "sources": { "se34f4e7886f1": "https://github.com/rabbitmq/rabbitmq-server/releases/tag/v3.7.0" } }, "docs": { "name": "RabbitMQ documentation", "basis": "unknown", "sources": { "s8f066afff416": "https://www.rabbitmq.com/docs/access-control", "s348b5e1d08ae": "https://www.rabbitmq.com/docs/vhosts", "sf81e470be6fa": "https://www.rabbitmq.com/docs/man/rabbitmqctl.8", "s021280c36c46": "https://www.rabbitmq.com/docs/management", "s62cc3cf19b55": "https://www.rabbitmq.com/docs/definitions", "s59532e00cbad": "https://www.rabbitmq.com/docs/http-api-reference", "sffc9f05633d5": "https://www.rabbitmq.com/docs/ssl", "s7d28cf5cc259": "https://www.rabbitmq.com/docs/configure", "s73c5514e15ab": "https://www.rabbitmq.com/docs/plugins", "s5a539132e128": "https://www.rabbitmq.com/docs/man/rabbitmq-plugins.8", "sc3e3083ffb2f": "https://www.rabbitmq.com/docs/networking", "sa40d365d073f": "https://www.rabbitmq.com/docs/cli", "s02ce3c777d63": "https://www.rabbitmq.com/docs/clustering-ssl", "sf274b2ed7c10": "https://www.rabbitmq.com/docs/man/rabbitmq-diagnostics.8", "sef3085ec2cc9": "https://www.rabbitmq.com/docs/queues", "s008b12a42f70": "https://www.rabbitmq.com/docs/channels" } }, "image": { "name": "RabbitMQ image source", "basis": "76efb370838e36e4b120e252e75cf0dac70a7d55", "sources": { "s9d5790722619": "https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/ubuntu/10-defaults.conf#L8", "s6e9f51045566": "https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/ubuntu/Dockerfile#L321", "se5da6700ffd7": "https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/alpine/10-defaults.conf#L8", "sb5b8462e18fd": "https://github.com/docker-library/rabbitmq/blob/76efb370838e36e4b120e252e75cf0dac70a7d55/4.3/alpine/Dockerfile#L327" } }, "management-source": { "name": "RabbitMQ management documentation source", "basis": "db23826ebaa810b3cdd525fd290125d65f6c3005", "sources": { "s0aa37c76e851": "https://github.com/rabbitmq/rabbitmq-website/blob/db23826ebaa810b3cdd525fd290125d65f6c3005/versioned_docs/version-4.3/management/index.md#L1365-L1381" } }, "otp": { "name": "Erlang/OTP distribution", "basis": "27", "sources": { "s18749583e522": "https://www.erlang.org/docs/27/apps/ssl/ssl_distribution.html", "s2e4092cc0780": "https://raw.githubusercontent.com/erlang/otp/OTP-27.0/lib/ssl/src/inet_tls_dist.erl" } }, "curl": { "name": "curl documentation", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "pika": { "name": "Pika documentation", "basis": "unknown", "sources": { "sf5430d0d7914": "https://pika.github.io/pika/latest/examples/tls_mutual_authentication/", "s4a943925d547": "https://pika.github.io/pika/latest/modules/parameters/", "s2eb4516d20ec": "https://pika.github.io/pika/latest/modules/adapters/blocking/", "sbb232ff4c000": "https://pika.github.io/pika/latest/modules/exceptions/" } }, "pika-source": { "name": "Pika source", "basis": "1.3.2", "sources": { "s1c536ddcecc3": "https://github.com/pika/pika/blob/1.3.2/pika/connection.py#L937-L958", "s60d4abe2318e": "https://github.com/pika/pika/blob/1.3.2/pika/connection.py#L579-L580" } } }, "claims": { "guest": {"text": "Upstream guest/guest is an administrator restricted to localhost; create real users and delete or change guest.", "components": ["docs", "source"], "sources": ["docs:s8f066afff416", "source:s9f1f8db14acc"], "status": "REASONED"}, "docker-guest": {"text": "Shipped official 4.3 images disable guest loopback restriction; later configuration can restore it.", "components": ["image"], "sources": ["image:s9d5790722619", "image:s6e9f51045566", "image:se5da6700ffd7", "image:sb5b8462e18fd"], "status": "REASONED"}, "vhosts": {"text": "Separate users/vhosts and remove old grants; vhosts isolate resources, not CPU or memory.", "components": ["docs"], "sources": ["docs:s8f066afff416", "docs:s348b5e1d08ae", "docs:sf81e470be6fa"], "status": "REASONED"}, "resource-grants": {"text": "Configure/write/read regexes scope app.*; amq.default is excluded; binding needs queue write and exchange read.", "components": ["docs"], "sources": ["docs:s8f066afff416", "docs:sf81e470be6fa"], "status": "REASONED"}, "roles": {"text": "Tags replace previous tags and grant management authority, not resource permissions; observer gets monitoring and empty grants.", "components": ["docs"], "sources": ["docs:s021280c36c46", "docs:sf81e470be6fa"], "status": "REASONED"}, "definitions-boot": {"text": "Reviewed local boot imports need no management plugin, do not overwrite existing definitions, and suppress blank-node default creation.", "components": ["docs"], "sources": ["docs:s62cc3cf19b55"], "status": "REASONED"}, "definitions-runtime": {"text": "Runtime imports merge, retain omitted objects and immutable conflicts, and can fail partially; they are not reconciliation.", "components": ["docs"], "sources": ["docs:s59532e00cbad", "docs:s62cc3cf19b55"], "status": "REASONED"}, "definitions-export": {"text": "v2 export transformations remove users, permissions and runtime parameters; protect TOML credentials and review remaining secrets.", "components": ["docs", "admin"], "sources": ["docs:s62cc3cf19b55", "admin:sdd5688854073"], "status": "REASONED"}, "amqp-tls": {"text": "Private 5671 requires trusted client certificates; remove plaintext 5672 after migration.", "components": ["docs"], "sources": ["docs:sffc9f05633d5"], "status": "REASONED"}, "tls-depth": {"text": "ssl_options.depth defaults to 1; clientAuth usage and intermediate-chain depth must fit deployed certificates.", "components": ["docs"], "sources": ["docs:sffc9f05633d5"], "status": "REASONED"}, "tls-identity": {"text": "mTLS does not map a RabbitMQ username; password auth remains unless certificate plugin/SASL EXTERNAL is configured.", "components": ["docs"], "sources": ["docs:s8f066afff416", "docs:sffc9f05633d5"], "status": "REASONED"}, "management-bind": {"text": "Apply and restart with loopback HTTP 15672/private HTTPS 15671 before enabling management.", "components": ["docs"], "sources": ["docs:s021280c36c46", "docs:s7d28cf5cc259", "docs:s73c5514e15ab", "docs:s5a539132e128"], "status": "REASONED"}, "management-default": {"text": "v4.4.0 starts plain HTTP 15672 when no management listener is configured; with no address set it binds the IPv6 and IPv4 wildcards on dual stack, the IPv6 wildcard alone on single stack and the IPv4 wildcard on IPv4-only hosts; a 4.3 docs example includes management.tcp.ip = 0.0.0.0.", "components": ["source", "management-source"], "sources": ["source:s3e14b93fd407", "source:sfcd87fb1a469", "source:s19c597b9b0c4", "source:s55fa6710ead4", "source:s176c9d9d819c", "source:s108676baa88f", "source:s5c31104b0807", "source:s38f7e5ca992d", "source:s886babdfc3ec", "management-source:s0aa37c76e851"], "status": "REASONED"}, "management-listeners": {"text": "v4.4.0 management.listeners.tcp and .ssl entries add listeners on top of the defaults, so default HTTP 15672 still starts; a port-only entry drops management.tcp.ip or management.ssl.ip and binds without an address limit.", "components": ["source"], "sources": ["source:s256c6b44600f", "source:sfecb265794ac", "source:s7e312a375671", "source:sfcd87fb1a469"], "status": "REASONED"}, "management-ssl-ip": {"text": "At v4.4.0 management.ssl.ip limits the HTTPS listener to that address; without it, HTTPS binds the same wildcard addresses as the default HTTP listener.", "components": ["source"], "sources": ["source:s85b75e716115", "source:s58bda198ac55", "source:s5c31104b0807", "source:s38f7e5ca992d", "source:s108676baa88f"], "status": "REASONED"}, "management-auth": {"text": "Management has separate TLS and RabbitMQ HTTP credentials; restrict admin networks and human access.", "components": ["docs"], "sources": ["docs:s021280c36c46", "docs:s59532e00cbad"], "status": "REASONED"}, "epmd": {"text": "epmd defaults to 4369; ERL_EPMD_ADDRESS requires broker and epmd restart, or socket-unit changes; loopback remains.", "components": ["docs"], "sources": ["docs:sc3e3083ffb2f"], "status": "REASONED"}, "distribution-port": {"text": "Distribution defaults to node port plus 20000 (25672), pinned as equal kernel min and max; nonempty DIST_PORT replaces it; port_range keys map to the same kernel settings, with precedence not established.", "components": ["source", "docs"], "sources": ["source:s70d7b769d1de", "source:s752f79a56561", "source:sc0a5d56dfcce", "source:s22313020f9fb", "docs:sc3e3083ffb2f"], "status": "REASONED"}, "cli-ports": {"text": "CLI defaults 35672-35682; prefixed variables fall back to unprefixed forms, treating empty as unset.", "components": ["source", "docs"], "sources": ["source:s2b3127ddf2a9", "docs:sc3e3083ffb2f"], "status": "REASONED"}, "cookie": {"text": "Matching owner-only cookie files grant powerful node/CLI access; protect secret delivery and restrict peer reachability.", "components": ["docs"], "sources": ["docs:sa40d365d073f"], "status": "REASONED"}, "distribution-tls": {"text": "Use Erlang TLS distribution for server and CLI with matching launch arguments; 4.4.0 fully supports OTP 28 and 29, and no minimum is stated.", "components": ["docs", "otp", "source"], "sources": ["docs:s02ce3c777d63", "otp:s18749583e522", "source:s0ac4ba8e9551", "source:s815b3e32b081"], "status": "REASONED"}, "distribution-identity": {"text": "Verify peer certificates both ways and outbound node hostnames; coordinate transport changes and retain cookie/firewall controls.", "components": ["otp"], "sources": ["otp:s18749583e522", "otp:s2e4092cc0780"], "status": "REASONED"}, "topic-default": {"text": "Topic authorization dates from 3.7; absent or cleared topic permissions leave topic operations unrestricted by that layer.", "components": ["docs", "topic-min"], "sources": ["docs:s8f066afff416", "topic-min:se34f4e7886f1"], "status": "REASONED"}, "topic-patterns": {"text": "Topic write/read regexes gate publish/binding keys; tenant1 exact keys exclude wildcard bindings, not existing queued messages.", "components": ["docs"], "sources": ["docs:s8f066afff416", "docs:sf81e470be6fa"], "status": "REASONED"}, "topic-bypass": {"text": "Exchange relay can bypass direct topic-write checks; strong isolation needs admin-owned routes, no configure and queue-only read grants.", "components": ["docs"], "sources": ["docs:s8f066afff416"], "status": "REASONED"}, "verify-accounts": {"text": "Inspect migrated vhost grants, observer tags/empty patterns and guest removal; failed listings prove nothing.", "components": ["docs"], "sources": ["docs:s8f066afff416", "docs:sf81e470be6fa"], "status": "REASONED", "verify": [1]}, "verify-inventory": {"text": "Inventory all listeners and cookie ownership/mode; local binds and mode do not establish isolation or secrecy.", "components": ["docs"], "sources": ["docs:sc3e3083ffb2f", "docs:sf274b2ed7c10", "docs:sa40d365d073f"], "status": "REASONED", "verify": [2]}, "verify-network": {"text": "Forbidden external connections must fail while allowed private peers connect; confirm plaintext 5672 removal with live AMQPS.", "components": ["docs"], "sources": ["docs:sc3e3083ffb2f"], "status": "REASONED", "verify": [3, 4]}, "verify-distribution": {"text": "Allowed CLI host must authenticate ping to the actual node with matching cookie; TCP reachability alone is insufficient.", "components": ["docs"], "sources": ["docs:sf274b2ed7c10", "docs:sa40d365d073f"], "status": "REASONED", "verify": [5]}, "verify-loopback": {"text": "Authenticated loopback /api/overview must return 200 while private HTTP 15672 is unreachable.", "components": ["docs", "curl"], "sources": ["docs:s021280c36c46", "docs:s59532e00cbad", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [6]}, "verify-management": {"text": "Observer overview and ops user listing return 200; anonymous overview and observer user listing refuse with actual auth errors.", "components": ["docs", "curl"], "sources": ["docs:s021280c36c46", "docs:s59532e00cbad", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [7]}, "verify-mtls": {"text": "Certificate-present handshake succeeds and absent certificate gets broker-attributed rejection; server Verification: OK is insufficient.", "components": ["docs"], "sources": ["docs:sffc9f05633d5"], "status": "REASONED", "verify": [8]}, "queue-default": {"text": "RabbitMQ 4.3 disables transient non-exclusive queues by default; the protocol fixture uses a durable queue.", "components": ["docs"], "sources": ["docs:sef3085ec2cc9"], "status": "REASONED"}, "verify-amqp": {"text": "Pika 1.x fixture requires app round trip, observer authentication and resource-specific 403 ACCESS_REFUSED; log connection refusal reasons.", "components": ["docs", "pika", "pika-source"], "sources": ["docs:s8f066afff416", "docs:sef3085ec2cc9", "docs:s008b12a42f70", "pika:sf5430d0d7914", "pika:s4a943925d547", "pika:s2eb4516d20ec", "pika:sbb232ff4c000", "pika-source:s1c536ddcecc3", "pika-source:s60d4abe2318e"], "status": "REASONED", "verify": [9]}, "verify-definitions": {"text": "Compare approved blank-node users/grants and reduced versus private full exports; empty selected sections do not prove secrecy.", "components": ["docs", "admin"], "sources": ["docs:sf81e470be6fa", "docs:s62cc3cf19b55", "admin:sdd5688854073", "docs:s59532e00cbad"], "status": "REASONED", "verify": [10]}, "verify-distribution-tls": {"text": "TLS CLI/nodes succeed; plaintext and missing/untrusted certificate peers fail with TLS evidence, not cookie/DNS errors.", "components": ["docs", "otp"], "sources": ["docs:s02ce3c777d63", "otp:s18749583e522"], "status": "REASONED", "verify": [11]}, "verify-topic": {"text": "Tenant1 operations succeed; other keys/wildcards fail; compare relay bypass with hardened route-creation denial.", "components": ["docs"], "sources": ["docs:s8f066afff416", "docs:sf81e470be6fa"], "status": "REASONED", "verify": [12]} } } ```` ====================================================================== ==> mosquitto.md ====================================================================== # Mosquitto (MQTT): no anonymous clients, TLS listener Mosquitto is an MQTT broker: devices and programs publish short messages to named topics, and it passes them to whoever has subscribed. This guide helps your AI assistant make sure it is configured securely, including credentials for each device, no anonymous clients, a TLS listener, and access checked per topic. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mosquitto.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Mosquitto v2.0.22 and v2.1.2 and Mosquitto legacy v1.6.15; 2 further sources are pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-10-05; documentation checked October 2026. The guide records 45 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. MQTT brokers back IoT and agent projects, and open brokers leak live telemetry and accept injected commands. Mosquitto's defaults are sane on version 2.0 and later (with a listener defined, anonymous access is off; without any listener it binds the loopback interface only); the job is to keep them sane while adding real listeners. The no-listener loopback mode allows anonymous clients by default; it is not authenticated. Defining an explicit listener in 2.0 changes the default to rejecting clients unless authentication is configured. See [migrating to 2.0](https://mosquitto.org/documentation/migrating-to-2-0/). The official Docker images do not all keep those defaults. The `latest`, `2`, `alpine`, `2.1-alpine` and `2.1.2-alpine` tags (Mosquitto 2.1.2 at the pinned library commit) ship their own `/mosquitto/config/mosquitto.conf`: `listener 1883` with `allow_anonymous true`, and `listener 9883` with `protocol http_api`. The 9883 listener in those tags serves the web dashboard and a read-only HTTP API (JSON from `/api/v1/systree` and `/api/v1/listeners`, and the version as plain text from `/api/v1/version`). Neither listener names an address, so the MQTT listener binds the IPv4 wildcard, and the IPv6 one where the container has IPv6, and the HTTP API listener starts on a single dual-stack socket. Both listeners as shipped, with no password file, authentication plugin or ACL, serve clients that give no credentials. Anything that can reach the container on 9883 (another container on its network, a published port, or host networking) therefore gets broker statistics and listener details without a login. The `1.6-openssl` and `1.6.15-openssl` tags ship upstream's configuration, every line commented, so 1.6's defaults apply: a listener on 1883 with no address, on the IPv4 wildcard and the IPv6 one where available, that allows anonymous clients. The 2.0 tags (`2.0`, `2.0.22`, `2.0-openssl`, `2.0.22-openssl`, `2-openssl` and `openssl`; Mosquitto 2.0.22) ship upstream's 2.0.22 configuration, also every line commented, and keep 2.0's loopback-only default. To use one of the 2.1 or 1.6 images, mount your own `/mosquitto/config/mosquitto.conf` (or give the container a whole `mosquitto -c ` command naming another) that sets `allow_anonymous false` and a password file and drops the 9883 listener or binds it to `127.0.0.1`, and publish 1883 only to host loopback or a private network. The file-based examples below target Mosquitto 2.0.22 and remain available, but deprecated, in 2.1. At the time of writing, `password_file`, `acl_file`, and `per_listener_settings` are deprecated in 2.1, with removal announced for 3.0. Plan migration to the password-file and ACL-file plugins; section 5 identifies the listener-policy replacements. See the [2.1 release notes](https://mosquitto.org/blog/2026/01/version-2-1-0-released/). ## 1. Credentials per device Use the `mosquitto_passwd` shipped with your broker version. These commands prompt for passwords; do not use its batch option to put passwords in command arguments. Run the first command only when creating the password file: `-c` overwrites an existing file. ```bash sudo mosquitto_passwd -c /etc/mosquitto/passwd device-01 # -c only the first time sudo mosquitto_passwd /etc/mosquitto/passwd device-02 ``` `/etc/mosquitto/conf.d/secure.conf`: ``` per_listener_settings false allow_anonymous false password_file /etc/mosquitto/passwd ``` One credential per device, so a leaked unit can be revoked alone; add an `acl_file` to limit each identity to its own topics. `per_listener_settings false` is valid when all listeners deliberately share authentication and ACL policy. It is deprecated in 2.1 along with `true`; changing the boolean is not the migration. For separate policies on 2.0, use the complete alternative in section 5. To remove a compromised device, substitute the password-file path and its MQTT username inside the quotes, then paste the whole block: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PASSWORD_FILE' 'REPLACE_WITH_REVOKED_USERNAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not changing anything"; exit 2; } shift [ "$#" -eq 2 ] || { echo "provide exactly 2 values; not changing anything"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the password-file path"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the revoked username"; exit 2 ;; *) sudo mosquitto_passwd -D "$1" "$2" ;; esac ) ``` `mosquitto_passwd -D` edits the file; it does not terminate existing connections or update the running broker's loaded credentials. Reload the broker with SIGHUP through your deployment's service-management procedure so new authentication uses the changed file. Incident response also needs connection termination, or a controlled broker restart, and confirmation that the revoked identity cannot reconnect. See [password-file authentication and reload](https://mosquitto.org/documentation/authentication-methods/) and [mosquitto_passwd](https://mosquitto.org/man/mosquitto_passwd-1.html). Do not assume uniform connection handling on reload: the `password_file` manual says connected clients are unaffected, while the [2.0.22 implementation](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/security_default.c) rechecks connected credentials and disconnects failures. File deletion alone is not connection termination; confirm termination on the deployed version and authentication backend. ## 2. TLS listener ``` listener 8883 cafile /etc/mosquitto/tls/ca.pem certfile /etc/mosquitto/tls/server.pem keyfile /etc/mosquitto/tls/server.key # mutual TLS: clients must present certificates # require_certificate true ``` Port 8883 is the conventional MQTT-over-TLS port. Certificates per [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) (an internal CA suits device fleets) or [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md). `require_certificate true` makes a client certificate a possession factor for the connecting device, stronger than a password alone but not MFA for a person ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). Remove or firewall any plaintext `listener 1883` that is not strictly local. These snippets take effect only if the running broker loads `/etc/mosquitto/conf.d/` (confirm `include_dir` in the active config); a `listener` change is not applied on a reload signal, so restart the broker and check its startup log after editing. ## 3. Authorize topics, not just connections Password authentication alone does not restrict topics: without ACLs or another authorization mechanism, authenticated clients can read and write every application topic. Add this to the shared-policy configuration in section 1: ``` acl_file /etc/mosquitto/acl ``` A complete `/etc/mosquitto/acl` for two devices and a separate command controller: ``` # Rules before the first user apply only to anonymous clients. topic deny $SYS/# user device-01 topic deny $SYS/# topic read fleet/device-01/telemetry/# topic write fleet/device-01/telemetry/# topic read fleet/device-01/commands/# user device-02 topic deny $SYS/# topic read fleet/device-02/telemetry/# topic write fleet/device-02/telemetry/# topic read fleet/device-02/commands/# user controller topic deny $SYS/# topic write fleet/device-01/commands/# topic write fleet/device-02/commands/# ``` Create the controller's separate credential at the password prompt: ```bash sudo mosquitto_passwd /etc/mosquitto/passwd controller ``` Unlisted access is denied. `user` names the MQTT username, not the client id. A `topic` rule before any `user` applies to anonymous clients, not every user; that is why the explicit `$SYS` denial is repeated under each identity. Reload after changing the password or ACL file. See the [ACL-file syntax](https://mosquitto.org/documentation/plugins/acl-file/) and [authentication reload instructions](https://mosquitto.org/documentation/authentication-methods/). For a uniform fleet, replace the device-specific telemetry and command-read grants with these patterns, retaining the controller's explicit grants and the `$SYS` deny entries: ``` pattern read fleet/%u/telemetry/# pattern write fleet/%u/telemetry/# pattern read fleet/%u/commands/# ``` Patterns apply to all users, regardless of preceding `user` lines. `%u` substitutes the username; `%c` substitutes the client id. Each substitution must occupy a whole topic level: `fleet/%u/telemetry/#` is valid; embedding `%u` inside a level is not. Prefer authenticated `%u` because a client ordinarily chooses its own client id. Provision usernames as single topic levels. See [pattern ACLs](https://mosquitto.org/documentation/plugins/acl-file/). Keep `$SYS`, the broker's telemetry hierarchy, ungranted to devices. `topic deny $SYS/#` makes that decision explicit for each listed user; it is not a global denial merely because it appears at the top of the file. `deny` was added in 2.0. Subscribing to `#` does not include `$SYS`; testing telemetry isolation requires an explicit `$SYS/#` subscription. See the [2.0 release notes](https://mosquitto.org/blog/2020/12/version-2-0-0-released/) and [broker status and wildcard subscriptions](https://mosquitto.org/man/mosquitto-8.html). File ACLs filter message access, not SUBSCRIBE requests. An accepted `#` subscription is not by itself an ACL bypass. Test actual delivery and rejected writes with matched authorized delivery, as in Verify. This distinction is explicit in the [2.0.22 built-in ACL implementation](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/security_default.c) and [2.1.2 ACL-file plugin](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/plugins/acl-file/acl_check.c). ## 4. Bound resource use Authentication does not stop a compromised device exhausting the broker. These defaults were checked against the versioned configuration and source at the time of writing: | Option | Scope | Default | | --- | --- | --- | | `max_connections` | Per listener | `-1`, unlimited by this setting | | `max_packet_size` | Global; incoming MQTT packet size | Unset in 2.0.22, with no configured cap; 2,000,000 bytes in 2.1 | | `message_size_limit` | Global; PUBLISH payload size | `0`, no configured payload limit | | `max_inflight_messages` | Global setting, enforced per client for outgoing QoS 1/2 messages | `20` | | `max_queued_messages` | Global setting, enforced per client above inflight messages | `1000` in 2.x; `100` before 2.0 | | `max_queued_bytes` | Global setting, enforced per client for queued outgoing messages | `0`, unlimited by this setting | | `max_keepalive` | Global; client keepalive ceiling in seconds | `0` in 2.0.22 and 2.1.2, permitting unlimited keepalive | Sources: [2.0.22 configuration](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/mosquitto.conf), [2.0.22 initialized defaults](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/conf.c), [2.1.2 initialized defaults](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/conf.c), and [the 2.0 queue-default change](https://mosquitto.org/blog/2020/12/version-2-0-0-released/). The 2.0.22 sample configuration's `max_keepalive 65535` commentary is stale; the implementation initializes it to `0`. Do not copy the sample's commented `max_packet_size 0` as an explicit setting: the parser requires a configured value of at least 20. Illustrative global values: ``` max_packet_size 65536 message_size_limit 32768 max_inflight_messages 10 max_queued_messages 100 max_queued_bytes 1048576 max_keepalive 60 ``` Within each explicit listener's configuration, choose a connection limit, for example: ``` max_connections 200 ``` These are values to size against real traffic, client counts, reconnect behaviour, and available memory, not universal safe limits. Packet budgets need room for topic names and protocol overhead as well as payloads. On 2.0.22 TCP listeners, `max_packet_size` rejects oversized packets before incoming-body allocation in the [packet reader](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/lib/packet_mosq.c). The [2.0.22 WebSockets receive callback](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/websockets.c) instead allocates the declared MQTT body without checking this limit before dispatch. The [2.1.2 libwebsockets callback](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/websockets.c) retains this allocation gap; do not generalize across WebSockets implementations. TLS termination does not fix it. Disable affected WebSockets listeners unless an independent boundary enforces the MQTT declared packet length before forwarding it, or use an implementation whose allocation bound has been demonstrated. `message_size_limit` rejects an oversized PUBLISH without forwarding it, while completing the QoS 1/2 acknowledgement flow; it is not a packet-allocation defence. The [PUBLISH handler](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/handle_publish.c) checks the payload after packet reception. Queue limits can cause message loss. When both queue limits are set, the first reached limits further queuing. Inflight limits bound concurrent outstanding delivery, not messages per second. A positive `max_keepalive` supplies a server override to MQTT 5 clients requesting an excessive interval; MQTT 3.1.1 clients requesting an incompatible interval are refused with identifier-rejected. Keepalive does not disconnect a client that keeps sending traffic. See the [resource-limit reference](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/man/mosquitto.conf.5.xml). Mosquitto 2.1 adds these optional global controls; do not put them in a 2.0 configuration: ``` global_max_connections 500 global_max_clients 1000 ``` Both default to `-1`. The first bounds connected clients across listeners; the second also counts disconnected persistent sessions. A connection cap alone does not bound that session population. See [global connection and session limits](https://mosquitto.org/man/mosquitto-conf-5.html). On 2.0, repeatedly connecting with different IDs and leaving persistent sessions can grow broker state despite connection and per-client queue limits. Restrict the allowed identities and client IDs. Choose a global expiration policy, for example `persistent_client_expiration 14d`, to remove sessions that remain disconnected beyond that interval. Expiration is not a hard population cap. See the [2.0.22 expiration reference](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/man/mosquitto.conf.5.xml) and [expiry implementation](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/session_expiry.c). ## 5. Scope every listener, including WebSockets An explicit `listener 1883` without a bind address listens on all interfaces. The no-listener loopback default does not carry over. Omit plaintext MQTT unless needed locally; if needed, use `listener 1883 127.0.0.1`. `bind_address` is deprecated and affects only the implicit default listener. See [2.0 listener changes](https://mosquitto.org/documentation/migrating-to-2-0/) and the [listener reference](https://mosquitto.org/man/mosquitto-conf-5.html). For separate policies on 2.0, replace the shared authentication and listener snippets with this alternative; do not append it to them. Put `per_listener_settings true` before every listener and before other security settings, including settings in earlier included files. Create each referenced password and ACL file using the formats above. ``` per_listener_settings true listener 8883 allow_anonymous false password_file /etc/mosquitto/passwd acl_file /etc/mosquitto/acl cafile /etc/mosquitto/tls/ca.pem certfile /etc/mosquitto/tls/server.pem keyfile /etc/mosquitto/tls/server.key # require_certificate true max_connections 200 # Optional local plaintext MQTT; omit this entire listener if unnecessary. listener 1883 127.0.0.1 allow_anonymous false password_file /etc/mosquitto/passwd-local acl_file /etc/mosquitto/acl-local max_connections 20 # Optional local WebSockets; requires a build with WebSockets support. listener 9001 127.0.0.1 protocol websockets allow_anonymous false password_file /etc/mosquitto/passwd-ws acl_file /etc/mosquitto/acl-ws max_connections 20 ``` Do not merely flip the original `false` to `true`: move each listener's `allow_anonymous`, `password_file`, and `acl_file` into its scope. Keep its TLS directives after that listener as well. The ordering requirement is enforced by the [2.0.22 configuration parser](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/conf.c). The WebSockets example is local plaintext transport. A public WebSockets endpoint needs TLS plus its own authentication and ACL policy. For broker-terminated TLS, configure `cafile`, `certfile`, and `keyfile` on that WebSockets listener as in section 2, with certificates for its real endpoint. A TLS-terminating proxy must keep the backend private. Do not assume the MQTT listener's TLS configuration protects a separate WebSockets listener. See [protocol and WebSockets TLS settings](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/man/mosquitto.conf.5.xml). On 2.0.22 WebSockets listeners, `max_connections` is ineffective: the [establishment callback](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/websockets.c) checks `client_count` without incrementing it. The example's `max_connections 20` therefore does not enforce a WebSockets connection cap. Require an independently enforced concurrent-connection limit with no backend bypass, or a version and WebSockets implementation whose cap is demonstrated. The [2.1.2 ChangeLog](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/ChangeLog.txt) records the libwebsockets fix for issue #3455 under 2.1.1; verify the deployed build across disconnect/reconnect cycles. TLS alone supplies neither this cap nor the packet-allocation protection missing in section 4. Listeners are not separate topic or session namespaces. With per-listener settings, a disconnected persistent client retains the ACL policy of the listener it most recently used. See [per-listener policy behaviour](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/mosquitto.conf). A valid password does not authorize a client ID: [2.0.22 duplicate-ID handling](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/handle_connect.c) can disconnect another device when a client authenticates with its own credentials but supplies that device's ID. For one connection per identity, set `use_username_as_clientid true` on every applicable listener. It replaces the supplied ID with the authenticated username; a second connection with that username displaces the first. Usernames must identify the same principal globally across all listeners and authentication stores, never different devices. Every other listener must also prevent unauthorized use of those IDs. If multiple connections per identity are needed, require authenticated client-ID authorization before session takeover, with a bounded set of IDs per identity; topic ACLs alone do not provide it. See the [2.0.22 mosquitto.conf(5) entry](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/man/mosquitto.conf.5.xml): this option is per listener and requires a restart, not SIGHUP. In 2.1, replace `per_listener_settings` with explicit listener policy: `listener_allow_anonymous` controls anonymous access, and `plugin_load` plus `plugin_use` loads and attaches authentication/ACL plugins to selected listeners. Use the `mosquitto_password_file` and `mosquitto_acl_file` plugins for the corresponding file-based controls. Follow the vendor's [replacement procedure](https://mosquitto.org/documentation/listeners/per-listener-settings/); this is a configuration migration, not a boolean change. ## 6. Secure bridges when present A bridge creates another route into and out of the topic tree. Configure TLS explicitly; putting `:8883` in `address` does not enable bridge TLS. Store this connection stanza in a protected configuration file loaded by the broker: ``` connection site-01-upstream address upstream.example.com:8883 bridge_cafile /etc/mosquitto/tls/upstream-ca.pem bridge_insecure false remote_username bridge-site-01 remote_password REPLACE_WITH_REMOTE_BRIDGE_PASSWORD topic fleet/device-01/telemetry/# out 1 topic fleet/device-01/commands/# in 1 try_private true restart_timeout 10 60 notifications false # If the remote broker requires a client certificate: # bridge_certfile /etc/mosquitto/tls/bridge-client.pem # bridge_keyfile /etc/mosquitto/tls/bridge-client.key ``` `out` forwards local telemetry to the remote broker; `in` brings remote commands into the local broker. Avoid broad `topic # both` forwarding. Require a trusted upstream: locally configured bridge contexts bypass local ACL checks in [2.0.22](https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/src/security.c#L691-L693) and [2.1.2](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/plugin_acl_check.c). Bridge topic declarations control routing and subscriptions, not a local ingress ACL; the [2.1.2 incoming remapper](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/bridge_topic.c) returns success for unmatched topics. Neither these declarations nor the remote account's ACL contains a malicious upstream. If upstream trust is insufficient, replace the direct bridge with an independently enforced boundary, such as an isolated gateway that republishes only allowed topics through an ordinary authenticated client subject to the local broker's ACL. On the trusted remote broker, give this dedicated username only the matching permissions: ``` user bridge-site-01 topic deny $SYS/# topic write fleet/device-01/telemetry/# topic read fleet/device-01/commands/# ``` `bridge_insecure false` keeps hostname verification enabled. `try_private true` identifies a bridge to peers that support it, helping loop detection and retained-message handling; it is not authentication. `restart_timeout 10 60` uses reconnect backoff rather than an immediate retry loop. `notifications false` suppresses the bridge's default `$SYS` status publications, so this example needs no `$SYS` write grant. Monitor bridge health through protected logs. The remote password is plaintext in the configuration file. Restrict that file and its backups as credentials; there is no universal credential-redaction switch to rely on. Add `bridge_certfile` and `bridge_keyfile` only when the remote requires a client certificate. Syntax and behaviour: [bridge configuration and TLS options](https://mosquitto.org/man/mosquitto-conf-5.html) and the [2.0.22 bridge configuration reference](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/mosquitto.conf). ## 7. Protect the runtime account and files Use a dedicated runtime account. For a deployment that intentionally enables built-in persistence: ``` user mosquitto persistence true persistence_location /var/lib/mosquitto/ log_dest file /var/log/mosquitto/mosquitto.log ``` `persistence_location` only sets the path; persistence defaults to `false`. The example explicitly enables it. When started as root on POSIX systems, `user mosquitto` drops privileges; it does not change the account of an already unprivileged process. Ensure the account exists. See [`user`, persistence, and file logging](https://mosquitto.org/man/mosquitto-conf-5.html). Protect persistence data, logs, password files, ACL files, TLS private keys, and bridge configuration containing `remote_password`. As deployment guidance, use 0700 private directories and 0600 sensitive files, owned by the account that must access them; these are filesystem modes, not Mosquitto directives. Account for parent-directory traversal and protect backups and log rotation output too. The broker needs to read credentials, ACLs, certificates, and keys, and write its persistence and log locations. Mosquitto 2.0 drops privileges immediately after reading configuration, earlier than 1.x. Root-readable files alone are therefore insufficient: the required paths must be accessible to the runtime account after the drop. See [privilege-drop changes](https://mosquitto.org/documentation/migrating-to-2-0/). ## 8. Verify **REASONED:** whole Verify section; every service comparison below rests on the Sources entries cited at that comparison, and no exposed or fixed broker behaviour has been observed. The October 2026 marking pass also had no Mosquitto broker or client binaries, no container runtime and no network access; it ran only the shell, guard, stub, Python file-mode and shlex, and OpenSSL fixture checks recorded at the units below that record them, and none of them exercised Mosquitto. Those checks ran the section 1 credential-removal block in an earlier form, whose guard refusals exited with status 1; none was re-run on that block's current form, whose guard refusals exit with status 2. They also ran the connection, observer and publisher blocks in an earlier form, whose guard refusals exited with status 1, and the section 8 listener checks, then one block, in an earlier form whose guard refusals left the subshell with status 0. The current forms of those checks, now five blocks, exit 2 on a guard refusal and otherwise with one status for each block; they are covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes, and no stub run of them is recorded here. This guide's service checks are **REASONED, not demonstrated**. The authoring environment has no Mosquitto broker or client binaries, container runtime, deployment credentials, or external test hosts. Filesystem and network restrictions prevent provisioning a live comparison here. Exposed/fixed behaviour is REASONED from the cited documentation and pinned sources; broker configuration acceptance remains unconfirmed. **REASONED:** run provenance for this section's local checks, none of which exercised Mosquitto. The last four sentences of this paragraph record the original authoring pass, whose counts this marking pass did not rerun. Separately, in the October 2026 marking pass, with no Mosquitto broker or client binaries in the authoring environment, `bash -n` and ShellCheck 0.11.0 printed nothing for each of the four bash blocks this section then had, and under Bash 5.3.9 each block ran against stub `mosquitto_sub`, `mosquitto_pub`, `nc` and `ss` commands that print their arguments. The output of each October 2026 marking-pass run is recorded at the unit covering the block that the run exercised. For the listener checks, then one block, the runs recorded in this paragraph, in the marking pass and in the original authoring pass, used an earlier form, whose guard refusals left the subshell with status 0. For the section 1 credential-removal block, the runs recorded in this paragraph used an earlier form, whose guard refusals exited with status 1; none was re-run on that block's current form, whose guard refusals exit with status 2. For the connection, observer and publisher blocks, they used an earlier form, whose guard refusals exited with status 1. The current forms of the connection, listener-inventory, listener-probe, observer and publisher blocks are covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Local validation covered all seven fenced shell blocks with `bash -n`, ShellCheck 0.11.0, and the repository's strict guard-convention scanner. Guard-only tests rejected 90 invalid positional inputs; five substituted controls reached local markers. These checks exercised shell behaviour, not Mosquitto. No repository files were changed and the whole-corpus gate suite was not run. **REASONED:** client-version guidance for every client command below; no Mosquitto 2.0 or 2.1 client binaries in the authoring environment, so how a client treats `-o` and its default configuration was not observed, per the guide's cited mosquitto_sub manual and Mosquitto publisher manual (protected configuration files) entries. Locally, each of the 16 stubbed `mosquitto_sub` and `mosquitto_pub` calls made in this marking pass received `-o` followed by the protected configuration path. Those calls came from runs of the connection, observer and publisher blocks in an earlier form, whose guard refusals exited with status 1; their current forms, which still pass `-o` with the protected configuration path, are covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected, from this paragraph and the connection block's comments: a 2.1 client given `-o` skips the default client configuration, so a default file carrying credentials, a SOCKS proxy or `--insecure` cannot change the result; a 2.0 client has no `-o`, so there an unchecked default file can. Use Mosquitto 2.1 client tools for the commands below, including when testing a 2.0 broker. Their `-o` option selects an explicit configuration file and skips the default client configuration. On 2.0 clients, use protected default configuration files in isolated test accounts instead; `-o` is unavailable. See [client configuration files](https://mosquitto.org/man/mosquitto_sub-1.html). **REASONED:** paste, quoting and credential handling for every block below; no Mosquitto client binaries in the authoring environment, per the guide's cited mosquitto_sub manual and Mosquitto publisher manual (protected configuration files) entries. Locally, none of the 16 stubbed `mosquitto_sub` and `mosquitto_pub` calls received `-P` or the password placeholder, since each took its identity from a protected file through `-o`, and every block refused a paste without its `set --` line even with caller arguments present, as recorded at each block. For the listener checks, then one block, the recorded refusal runs used an earlier form, whose guard refusals left the subshell with status 0. For the connection, observer and publisher blocks, the runs recorded here used an earlier form, whose guard refusals exited with status 1. The current forms of those blocks, in which every guard refusal exits with status 2, are covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. In the observer block, a configuration path with its apostrophe escaped as `'\''` inside the quotes reached the stub as one argument ending `it's.conf`, while the same apostrophe left raw made bash print an "unexpected EOF while looking for matching" error and exit 2 without calling the stub. Expected, from this paragraph: credentials stay in the protected files and out of command arguments, shell history and recordings; a password passed as a command argument is the case this paragraph rules out. Paste whole blocks and substitute inside the single quotes. A literal apostrophe needs proper shell escaping. Keep credentials out of command arguments, shell history, recordings, and debug-log sharing. The `-P` line in the example below belongs in the protected file, not the shell. **REASONED:** preparation of the protected client configuration files; no Mosquitto client binaries in the authoring environment, so no client read these files, per the guide's cited mosquitto_sub manual and Mosquitto publisher manual (protected configuration files) entries. Locally, Python created a mode 0700 directory holding `device-01.conf`, `device-02.conf` and `controller.conf`, each written with mode 0600 from the two lines below with its identity substituted and the password placeholder left in place, and an empty mode 0600 `anonymous.conf`; `os.stat` reported those modes. Expected, from this paragraph: each identity's password stays in its owner-readable file and off every command line; a file other accounts can read would expose it. Prepare an owner-readable file, mode 0600 inside a private directory, for each test identity; the two-line block after the next paragraph is `device-01.conf`. **REASONED:** following block; covers its two protected client credential configuration lines and the further test configurations this paragraph describes. No Mosquitto client binaries, broker, client-certificate deployment or other deployment fixtures in the authoring environment, so no client parsed these files, per the guide's cited mosquitto_sub manual (`-u`/`-P`, `--cert`/`--key`) and mosquitto.conf manual (require_certificate) entries. Locally, Python's `shlex`, which is not the client's parser, split each of the block's two lines into one option and one value. The local test files were created as the preparation paragraph above records. The local mode 0600 `device-01-mtls.conf` and `anonymous-mtls.conf` variants added `--cert` and `--key` lines naming a throwaway client certificate made with OpenSSL 3.5.5. A local `openssl verify` against the throwaway CA printed `client.pem: OK` for that throwaway client certificate, and the certificate's public key matched its key file. A local `openssl verify` against the throwaway CA printed `error 18 at 0 depth lookup: self-signed certificate` for a self-signed certificate outside that CA. Expected: the anonymous configuration, carrying no username or password, is refused at CONNECT (fixed) or accepted (exposed) while the authenticated one connects; with mutual TLS, the configuration without a client certificate fails during TLS (fixed) or connects (exposed). Prepare corresponding files for `device-02` and `controller`, with their distinct passwords. Create a separate empty `anonymous.conf`. Inspect these files: use only the intended authentication options, with no proxy, insecure TLS, extra subscriptions, fixed client id, or persistent-session options. If mutual TLS is required, add the valid client certificate and key paths to both the authenticated and anonymous test configurations; the latter then tests absence of MQTT username/password. Use a separate configuration without the client certificate for the missing-certificate check. ``` -u device-01 -P REPLACE_WITH_DEVICE_PASSWORD ``` ### Connection authentication and TLS ```bash # The block reads the -d CONNACK output, not the exit status: mosquitto_sub exits 27 on a # -W timeout even after a successful connect, and nonzero on a refusal, so each is wrapped to capture its # status without aborting under set -e. Keep the client config clean: a default mosquitto client config # (e.g. ~/.config/mosquitto_sub) can carry credentials, a SOCKS proxy, or an insecure-TLS setting, so run # these where there is none, or inspect it first. If require_certificate true is set, TLS rejects before # the password check, so add --cert 'client.pem' --key 'client.key' (your paths, inside the quotes) to BOTH controls below and test a # missing-client-certificate connection separately. -x sets MQTT session expiry (NOT a TLS bypass), and # the clients' insecure-TLS option disables hostname verification; use neither here. # With 2.1 clients, that option disables all server-certificate verification. # Positive control: a valid device connects. Expect "received CONNACK (0)" in the output; the block takes # acceptance from that CONNACK alone. Exit 27 is only a timeout, even if messages arrived first. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_BROKER_PUBLIC_HOST' \ 'REPLACE_WITH_CA_FILE' 'REPLACE_WITH_DEVICE_01_CONFIG' 'REPLACE_WITH_ANONYMOUS_CONFIG' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "provide exactly 4 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker host"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the CA path"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the device config path"; exit 2 ;; esac case "$4" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the anonymous config path"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$@" "$(if mosquitto_sub -o "$3" -d -W 2 -h "$1" -p 8883 --cafile "$2" -t 'test' -u device-01 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" # the four values, the status, the client's output, then its exit status printf '%s' "$6" case "$7" in 0) echo "exit 0; confirm CONNACK (0) above" ;; *) echo "exit $7; confirm CONNACK separately; 27 means timeout" ;; esac case "$6" in *'received CONNACK (0)'*) ;; *) echo "no CONNACK (0) for device-01, so the positive control failed; inconclusive"; set -- "$1" "$2" "$3" "$4" 3 ;; esac # Negative control: NO credentials must be refused AT CONNECT, not merely denied the subscription. # Expect "received CONNACK (5)" (MQTT 3.1.1 not authorized) or "(135)" (MQTT 5). An anonymous CONNECT # that succeeds (CONNACK (0)) and only fails the SUBSCRIBE (e.g. under an acl_file) means the broker # still accepts anonymous clients: that is a finding. set -- "$1" "$2" "$3" "$4" "$5" "$(if mosquitto_sub -o "$4" -d -W 2 -h "$1" -p 8883 --cafile "$2" -t 'test' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" printf '%s' "$6" case "$7" in 0) echo "exit 0; FINDING if anonymous CONNACK (0) appears" ;; *) echo "exit $7 (expected: CONNACK 5/135 refusal - confirm above, NOT a SUBACK denial)" ;; esac case "$6" in *'received CONNACK (0)'*) echo "FINDING: the anonymous client received CONNACK (0), so the broker accepts anonymous clients"; set -- "$1" "$2" "$3" "$4" 1 ;; *'received CONNACK (5)'*|*'received CONNACK (135)'*) echo "the anonymous client was refused at CONNECT" ;; *) echo "no CONNACK (0), (5) or (135) for the anonymous client: a DNS, TCP, TLS or local-file failure, or another CONNACK code, is inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "block exit $5; the rest of the -d output above is not classified, so judge it yourself" exit "$5" ) ``` **REASONED:** preceding block; covers every check in it: the guard, the authenticated positive control and the anonymous negative control, each read from the `-d` CONNACK output rather than the exit status. No Mosquitto broker, client binaries, TLS fixture, device credentials or client certificates in the authoring environment; the CONNECT outcomes rest on the guide's cited Authentication methods, mosquitto.conf manual and mosquitto_sub manual entries, and the accepted SUBACK on its cited Mosquitto 2.0.22 built-in security implementation and Mosquitto 2.1.2 ACL-file plugin implementation entries. Run locally against a stub `mosquitto_sub`: `bash -n` and ShellCheck 0.11.0 printed nothing; the block as written, an angle-bracket host and a host under example.com printed "substitute the broker host", an unsubstituted CA path printed "substitute the CA path", and an empty anonymous path printed "substitute the anonymous config path", each exiting 1 without calling the stub. Five values passed to the block locally printed "provide exactly 4 values; not probing", and a paste without the `set --` lines printed "paste the whole block; not probing" despite four caller arguments, each also exiting 1 without calling the stub. Substituted values reached the stub as two calls carrying `-o`, `-d`, `-W 2`, port 8883 and `--cafile`, the first also passing the username device-01. The block printed "exit 0; confirm CONNACK (0) above" and "exit 0; FINDING if anonymous CONNACK (0) appears" for stub status 0 and "exit 27; confirm CONNACK separately; 27 means timeout" for 27, and under `bash -e -u` with stub status 5 both controls still ran. The bash -n, ShellCheck and stub runs recorded here covered the connection block in an earlier form, whose guard refusals exited with status 1. The current form exits 2 on a guard refusal and otherwise once: 1 when the anonymous client receives CONNACK (0), 3 when the device control shows no CONNACK (0) or the anonymous client shows none of CONNACK (0), (5) and (135), and 0 otherwise. It is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: the device control shows CONNACK (0) in both states; the anonymous control shows CONNACK (0) when anonymous clients are accepted (exposed) and CONNACK (5) or (135) when they are refused at CONNECT (fixed); with file ACLs the subscription is accepted and only message access is filtered. The SUBACK warning above concerns the distinction between authentication and authorization. With file ACLs specifically, expect the subscription itself to be accepted and unauthorized message access to be filtered, as section 3 explains. The `test` topic is deliberately ungranted; this first pair establishes CONNECT behaviour only. Run both controls against the intended exposed endpoint. The authenticated control must produce successful CONNACK; the anonymous control must be refused at CONNECT. Anonymous CONNACK success is the exposed outcome even if no messages arrive. DNS, TCP, TLS, and local-file failures are inconclusive authentication results. The discriminators come from [authentication configuration](https://mosquitto.org/documentation/authentication-methods/) and [client CONNACK reporting](https://mosquitto.org/man/mosquitto_sub-1.html). **REASONED:** exit-status interpretation for the connection and observer blocks; no Mosquitto client binaries in the authoring environment, per the guide's cited Mosquitto 2.1.2 subscriber implementation (timeout result independent of message receipt) and Mosquitto 2.1 release notes entries. Locally, with a stub `mosquitto_sub` exiting 27, the connection block printed "exit 27; confirm CONNACK separately; 27 means timeout" and the observer block printed "exit 27; inspect CONNACK and deliveries; timeout alone proves no ACL result"; the stub's status is not the client's, so this shows only how the blocks report 27. Those stub runs covered the connection and observer blocks in an earlier form, whose guard refusals exited with status 1; their current forms, which still print those lines for exit 27, are covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. No `--insecure` run was made. Expected: a 2.1 client exits 27 on a `-W` timeout whether or not it was accepted or received messages, so that status separates neither state; with 2.1 clients `--insecure` disables all server-certificate verification, so a run using it could connect to an untrusted broker. Exit 27 is a timeout, including after received messages; it is never shorthand for accepted authentication. See the [client timeout implementation](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/client/sub_client.c). The [2.1 release notes](https://mosquitto.org/blog/2026/01/version-2-1-0-released/) also document the broader effect of `--insecure`. **REASONED:** Where `require_certificate true` is enabled, repeat the authenticated request with the no-client-certificate configuration. The fixed outcome is TLS rejection before CONNACK, paired with successful connection using the trusted certificate. Acceptance without a certificate is the exposed outcome. This comparison is unavailable here because there is no broker or deployed client-certificate fixture. See [`require_certificate`](https://mosquitto.org/man/mosquitto-conf-5.html). ### Listener exposure **REASONED:** following block; covers the `ss` listener inventory on the broker host. No deployed broker namespace, Mosquitto broker, deployed listener or external probe host is available in the authoring environment, per the guide's cited Migrating to 2.0 (anonymous loopback mode, explicit-listener exposure and authentication) and Mosquitto 2.1.2 MQTT listener with no address entries. Run the inventory on the broker host and the network probe from another host. Check every actual public IPv4 and IPv6 address, including NAT paths. The inventory's earlier stub runs, from the form in which the inventory and the probe were one block, are recorded after the probe block below. Its current form refuses with status 2, printing `ss is not installed here; not listing sockets`, when `ss` is missing, exits 3 if `ss` fails and otherwise exits 0 with an unclassified-observation line, because `ss` shows a bind, not a firewall. It is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs with `ss` replaced by a stub, in which a missing `ss` exited 2, a failing `ss` 3 and a working one 0. Expected: a 1883 listener on a non-loopback address that is not firewalled to localhost (exposed) or none (fixed). ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null 2>&1 || { echo 'ss is not installed here; not listing sockets'; exit 2; } if ss -tlnp; then # inventory the listeners (needs sudo for the process column); ss shows a local BIND, not a # firewall or external reachability. There must be no 1883 listener on ANY non-loopback # address - 0.0.0.0, ::, or a specific external IP, IPv4 or IPv6 - unless it is firewalled to # localhost. The plaintext 1883 listener is the main exposure (section 2), so also probe each # public address from ANOTHER host with the block below echo "unclassified observation: a 1883 listener on a non-loopback address is the exposure unless a firewall limits it to localhost, which ss cannot show; probe from another host with the block below" else echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; exit 3 fi ) ``` ```bash # Probe each public address from ANOTHER host, using the corpus guard so an unsubstituted target cannot # masquerade as a pass: ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_BROKER_PUBLIC_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker's public host on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if nc -vz -w 5 "$1" 1883 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # refused/timeout = TCP 1883 unreachable from here (the pass); a completed connect means TCP 1883 is reachable off-host. nc sends no MQTT, so this shows reachability, not the service or its encryption; the positive control against the intended endpoint must have succeeded first set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the host, nc's report, then its exit status printf '%s' "$2" # This block expects OpenBSD netcat, whose refusal and timeout wording the pass arm below matches; another netcat may word them differently, and then a refusal or timeout exits 3, so read its diagnostic above yourself case "$3:$2" in 0:*) echo "FINDING: TCP 1883 is reachable off-host"; exit 1 ;; *'Connection refused'*|*'timed out'*) echo "TCP 1883 is unreachable from here; this is the pass only after the positive control against the intended endpoint succeeded"; exit 0 ;; *) echo "nc exit $3 without a refusal or timeout: a DNS failure, wrong destination or local socket error is inconclusive"; exit 3 ;; esac ) ``` **REASONED:** preceding block; covers every check in it: the guard and the off-host `nc` reachability probe. No Mosquitto broker, deployed listener, second host or network access in the authoring environment, per the guide's cited Migrating to 2.0 (anonymous loopback mode, explicit-listener exposure and authentication) and Mosquitto 2.1.2 MQTT listener with no address entries. Run locally against stub `ss` and `nc` commands, in an earlier form in which the inventory above and this probe were one block: `bash -n` and ShellCheck 0.11.0 printed nothing; the stub `ss` received `-tlnp` first in every run, because the inventory preceded the guard. The block as written, an angle-bracket host and an empty host printed "substitute the broker's public host on the set -- line above; not probing" in the local stub runs, each without calling `nc` and each exiting 0. Two values passed to the block locally printed "the set -- line needs exactly 1 value; not probing", and a paste without the `set --` line printed "paste the whole block, including its set -- line; not probing" despite a caller argument, each also without calling `nc` and each exiting 0. A substituted `broker.internal` reached the stub `nc` with `-vz -w 5` and port 1883, and the block exited 0 whether the stub `nc` exited 0 or 1, also under `bash -e -u`. The bash -n, ShellCheck and stub runs recorded here covered that earlier single block, whose guard refusals left the subshell with status 0. The current probe block exits 2 on a guard refusal, 1 when `nc` connects, 0 when `nc` reports a refusal or a timeout in the OpenBSD netcat wording its comment says it expects, and 3 otherwise, so a refusal or timeout that another netcat words otherwise exits 3. It is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs with `nc` replaced by a stub, in which a completed connect exited 1, a line containing `Connection refused` or `timed out` exited 0, and other text exited 3. Expected: from another host, a completed connection to 1883 (exposed) or a refusal or timeout after the positive control succeeded (fixed). A DNS failure, wrong destination, or local socket error is not a pass. Confirm the address belongs to this broker and inspect the actual diagnostic. A refusal or timeout exits 0 because it is the fixed outcome, and it is a pass only after the connection block's device control succeeded against the intended endpoint. Repeat for port 9001 if using the local-only WebSockets example. The fixed state has no externally reachable plaintext endpoint; a completed external connection is the exposure. See [listener binding behaviour](https://mosquitto.org/documentation/migrating-to-2-0/). ### Actual topic access **REASONED:** No live broker, scoped identities, or disposable topic fixtures are available here. Use an isolated deployment with the section 3 ACL and the three test credentials. Keep real actuators and command consumers disconnected. Compare the same requests with password authentication alone and with the ACL applied; never remove production ACLs for this test. **REASONED:** the `use_username_as_clientid true` variant; no Mosquitto broker, client binaries or long-lived test clients in the authoring environment, and the separate-client blocks below cannot show it, so nothing in this variant ran locally, per the guide's cited Mosquitto authenticated duplicate client-ID displacement (pinned tag v2.0.22) entry. Expected: under this policy a separate publisher using an observer's username displaces that observer, so separate clients lose delivery for a reason unrelated to the ACL; with one long-lived client per identity, the table's cross-identity deliveries occur without the ACL (exposed) and are absent while authorized deliveries arrive with it (fixed). With `use_username_as_clientid true`, a separate publisher using an observer's username disconnects that observer. For this policy, reproduce the table below using one long-lived client per identity that both subscribes and publishes, retaining the before-and-after delivery controls. This variant is REASONED from the pinned session-takeover source; the separate-client commands below cannot demonstrate it. See [session takeover](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/handle_connect.c). **REASONED:** following block; covers every check in it: the guard and one observer subscribed to `#` and `$SYS/#` at QoS 1 for 60 seconds. No Mosquitto broker, client binaries or scoped test identities in the authoring environment, per the guide's cited ACL-file plugin, Mosquitto 2.1.2 ACL-file plugin implementation and Mosquitto broker manual entries. Run locally against a stub `mosquitto_sub`: `bash -n` and ShellCheck 0.11.0 printed nothing. The block as written printed "substitute the broker host", an unsubstituted observer path printed "substitute the observer config path", four values printed "provide exactly 3 values; not probing", and a paste without the `set --` lines printed "paste the whole block; not probing" despite three caller arguments, each exiting 1 without calling the stub. With `device-01.conf` and then `device-02.conf` substituted, the stub received `-o` with that path, `-d -v -q 1 -W 60` and the topic filters `#` and `$SYS/#`, and the block printed "exit 0; inspect received topics and this run's markers" for stub status 0 and "exit 27; inspect CONNACK and deliveries; timeout alone proves no ACL result" for 27, also under `bash -e -u`. The bash -n, ShellCheck and stub runs recorded here covered the observer block in an earlier form, whose guard refusals exited with status 1. The current form exits 2 on a guard refusal, 0 when the client exits 0 or with the `-W` timeout's 27, and 3 for any other client status; the deliveries it prints stay for you to judge. It is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: each observer receives its own identity's markers in both states; without the ACL it also receives the other device's telemetry and, when broker telemetry publication is enabled, `$SYS` topics (exposed), and with it neither (fixed). Otherwise, run this observer block in separate terminals once with `device-01.conf` and once with `device-02.conf`. Where client-ID authorization is enforced, give each observer and publisher a distinct authorized ID in its protected test configuration, as an explicit exception to the preparation rule against fixed IDs. Wait for successful CONNACK and SUBACK in both observers before publishing. Keep both running throughout each comparison; repeat the window if either exits. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_BROKER_PUBLIC_HOST' \ 'REPLACE_WITH_CA_FILE' 'REPLACE_WITH_OBSERVER_CONFIG' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "provide exactly 3 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker host"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the CA path"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the observer config path"; exit 2 ;; esac if mosquitto_sub -o "$3" -h "$1" -p 8883 --cafile "$2" \ -d -v -q 1 -W 60 -t '#' -t "\$SYS/#" then echo "exit 0; inspect received topics and this run's markers" else set -- "$?" echo "exit $1; inspect CONNACK and deliveries; timeout alone proves no ACL result" case "$1" in 27) ;; *) echo "exit $1 is neither 0 nor the -W timeout's 27, so this observer may not have watched its whole window; inconclusive"; exit 3 ;; esac fi echo "unclassified observation: the deliveries above are not classified, so judge them against the table below" exit 0 ) ``` **REASONED:** following block; the publisher block and the instructions for it. No Mosquitto broker, client binaries or test identities in the authoring environment, per the guide's cited Mosquitto publisher manual (protected configuration files, QoS, protocol selection, message publishing) and Mosquitto 2.0.22 PUBLISH handler (payload-size checks, denied writes, QoS responses) entries. Run locally against a stub `mosquitto_pub`: `bash -n` and ShellCheck 0.11.0 printed nothing for that block. The block as written printed "substitute the broker host", an unsubstituted marker printed "substitute the non-secret marker", six values printed "provide exactly 5 values; not probing", and a paste without the `set --` lines printed "paste the whole block; not probing" despite five caller arguments, each exiting 1 without calling the stub. Each table row's identity file, topic and a distinct marker reached the stub with `-o`, `-d -V mqttv5 -q 1`, `-t` and `-m` and no retain option, and the block printed "publisher exited 0; check PUBACK reason and actual observer delivery" for stub status 0 and "publisher exit 5; inspect the broker response, not just this status" for 5 under `bash -e -u`. The bash -n, ShellCheck and stub runs recorded here covered the publisher block in an earlier form, whose guard refusals exited with status 1 and which took five values. The current form takes a sixth value, `allowed` or `denied`, exits 2 on a guard refusal and exits 3 when the publisher shows no CONNACK (0). With `allowed` it then exits 3 if the publisher fails and 0 otherwise; with `denied` it exits 0 when the publisher's output shows a PUBACK, leaving the broker's response for you to judge with the observers' deliveries, and 3 when it shows none, because the publish then failed before its result. It is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs with `mosquitto_pub` replaced by a stub, in which the denied row exited 0 with a `received PUBACK` line and 3 without one, and the allowed row exited 0 on success and 3 on failure. Expected: authorized rows deliver their markers in both states; device-01's write to device-02's command topic reaches device-02 without the ACL (exposed) and is denied with it (fixed). Run this publisher block for each row below, substituting the selected credential-file path, topic, and a distinct non-secret marker for this run, with `denied` for the row in which device-01 writes to device-02's command topic and `allowed` for every other row. It does not retain messages. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_BROKER_PUBLIC_HOST' \ 'REPLACE_WITH_CA_FILE' 'REPLACE_WITH_PUBLISHER_CONFIG' \ 'REPLACE_WITH_TEST_TOPIC' 'REPLACE_WITH_NONSECRET_UNIQUE_MARKER' \ 'REPLACE_WITH_allowed_OR_denied' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 6 ] || { echo "provide exactly 6 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker host"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the CA path"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the publisher config path"; exit 2 ;; esac case "$4" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the test topic"; exit 2 ;; esac case "$5" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the non-secret marker"; exit 2 ;; esac case "$6" in allowed|denied) ;; *) echo "substitute allowed, or denied for the row whose write the fixed state denies; not probing"; exit 2 ;; esac set -- "$@" "$(if mosquitto_pub -o "$3" -h "$1" -p 8883 --cafile "$2" \ -d -V mqttv5 -q 1 -t "$4" -m "$5" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7%$'\n'*}" "${7##*$'\n'}" # the six values, the publisher's output, then its exit status printf '%s' "$7" case "$8" in 0) echo "publisher exited 0; check PUBACK reason and actual observer delivery" ;; *) echo "publisher exit $8; inspect the broker response, not just this status" ;; esac case "$7" in *'received CONNACK (0)'*) ;; *) echo "no CONNACK (0) for the publisher, so it did not connect and this row shows nothing; inconclusive"; exit 3 ;; esac case "$6:$8" in allowed:0) echo "unclassified observation: the authorized write was sent; judge the observers' deliveries against the table below"; exit 0 ;; allowed:*) echo "the authorized write failed after CONNACK (0), so this row's positive control failed; inconclusive"; exit 3 ;; esac case "$7" in # the denied row: a publish that completed shows the broker's PUBACK, whatever its reason *'received PUBACK'*) echo "unclassified observation: read the PUBACK reason above and the observers' deliveries; this row's fixed outcome is a denied write that device-02 never receives"; exit 0 ;; *) echo "no PUBACK for the denied row's publish, so it failed before the broker answered and this row shows nothing; inconclusive"; exit 3 ;; esac ) ``` | Publisher identity | Topic | Fixed outcome | | --- | --- | --- | | `device-01` | `fleet/device-01/telemetry/check` | **REASONED:** whole row; no Mosquitto broker, client binaries or test identities in the authoring environment, per the guide's cited ACL-file plugin and Mosquitto 2.1.2 ACL-file plugin implementation entries. Locally, the publisher block with `device-01.conf`, this topic and a distinct marker reached only a stub `mosquitto_pub`, with those values. The stub runs recorded in this row covered the publisher block in an earlier form, whose guard refusals exited with status 1; the current form, which takes `allowed` for this row, is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: without the ACL both observers receive the marker (exposed); with it device-01 receives it and device-02 does not (fixed). Device-01 receives its marker; device-02 does not | | `device-02` | `fleet/device-02/telemetry/check` | **REASONED:** whole row; no Mosquitto broker, client binaries or test identities in the authoring environment, per the guide's cited ACL-file plugin and Mosquitto 2.1.2 ACL-file plugin implementation entries. Locally, the publisher block with `device-02.conf`, this topic and a distinct marker reached only a stub `mosquitto_pub`, with those values. The stub runs recorded in this row covered the publisher block in an earlier form, whose guard refusals exited with status 1; the current form, which takes `allowed` for this row, is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: without the ACL both observers receive the marker (exposed); with it device-02 receives it and device-01 does not (fixed). Device-02 receives its marker; device-01 does not | | `controller` | `fleet/device-02/commands/check` | **REASONED:** whole row; no Mosquitto broker, client binaries or test identities in the authoring environment, per the guide's cited ACL-file plugin and Mosquitto 2.1.2 ACL-file plugin implementation entries. Locally, the publisher block with `controller.conf`, this topic and a distinct marker reached only a stub `mosquitto_pub`, with those values. The stub runs recorded in this row covered the publisher block in an earlier form, whose guard refusals exited with status 1; the current form, which takes `allowed` for this row, is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: device-02 receives the marker in both states, the authorized positive control; without the ACL device-01 also receives it (exposed), and with it device-01 does not (fixed). Device-02 receives the authorized command marker | | `device-01` | `fleet/device-02/commands/check` | **REASONED:** whole row; no Mosquitto broker, client binaries or test identities in the authoring environment, per the guide's cited ACL-file plugin and Mosquitto 2.0.22 PUBLISH handler (payload-size checks, denied writes, QoS responses) entries. Locally, the publisher block with `device-01.conf`, this topic and a distinct marker reached only a stub `mosquitto_pub`, with those values; with the stub exiting 5 under `bash -e -u` it printed "publisher exit 5; inspect the broker response, not just this status". The stub runs recorded in this row covered the publisher block in an earlier form, whose guard refusals exited with status 1; the current form, which takes `denied` for this row, is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: without the ACL device-02 receives the injected marker (exposed); with it the MQTT 5 QoS 1 write is denied with a PUBACK reason and device-02 receives nothing (fixed). Write is denied; device-02 receives no injected marker | | `controller` | `fleet/device-02/commands/check` | **REASONED:** whole row; no Mosquitto broker, client binaries or test identities in the authoring environment, per the guide's cited ACL-file plugin and Mosquitto 2.1.2 ACL-file plugin implementation entries. Locally, the publisher block with `controller.conf`, this topic and a fresh distinct marker reached only a stub `mosquitto_pub`, with those values. The stub runs recorded in this row covered the publisher block in an earlier form, whose guard refusals exited with status 1; the current form, which takes `allowed` for this row, is covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes. Expected: device-02 receives this marker in both states; after the denied attempt (fixed) its arrival shows the observer still works, so the missing injected marker was a denial. Device-02 receives a fresh authorized marker after the denied attempt | Without ACLs, the authenticated observers can receive each other's telemetry and device-01 can inject the command. With the ACL, require both isolation and successful authorized deliveries. The controller's before-and-after messages distinguish write denial from a broken observer. MQTT 5 QoS 1 supplies a PUBACK reason for denied writes; actual delivery remains part of the decision. See the [ACL check](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/plugins/acl-file/acl_check.c), [PUBLISH denial handling](https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/handle_publish.c), and [publisher options](https://mosquitto.org/man/mosquitto_pub-1.html). The explicit `$SYS/#` subscription should receive broker telemetry in the exposed comparison when telemetry publication is enabled, and none under either device's fixed ACL. Require that exposed positive control and the device's authorized live marker; an empty `$SYS` result alone proves nothing. See [broker status publication](https://mosquitto.org/man/mosquitto-8.html). ### Verification scope | Check scope | Status | Deployment comparison and prerequisites | | --- | --- | --- | | Service behaviour | **REASONED:** every comparison listed in this row; none ran, because the authoring environment has no Mosquitto broker or client binaries, container runtime or network access and none of the listed fixtures. It rests on the Sources entries cited at each Verify comparison above and on the guide's cited entries for the other behaviours named here. The other behaviours named here have cited entries in the guide, including the Mosquitto 2.0.22 packet reader, Mosquitto libwebsockets connection-cap implementation, Mosquitto disconnected-session expiry scheduling and timed cleanup, Mosquitto authenticated duplicate client-ID displacement and Mosquitto local bridge contexts bypassing ACL checks entries. Only the shell, guard, stub, Python file-mode and shlex, and OpenSSL fixture checks recorded at the units above ran locally, and none exercised Mosquitto. Those checks ran the section 1 credential-removal block in an earlier form, whose guard refusals exited with status 1; none was re-run on that block's current form, whose guard refusals exit with status 2. They also ran the connection, observer and publisher blocks in an earlier form, whose guard refusals exited with status 1, and the section 8 listener checks, then one block, in an earlier form whose guard refusals left the subshell with status 0. The current forms of those checks, now five blocks, exit 2 on a guard refusal and otherwise with one status for each block; they are covered by the repository gate suite's `bash -n` and ShellCheck 0.11.0 passes, and no stub run of them is recorded here. Expected, as the next cell states item by item: the exposed state accepts the excess, foreign or unauthorized input, for example device-01 credentials with device-02's active ID disconnecting device-02, and the fixed state refuses or neutralizes it while a matched positive control succeeds. REASONED from the cited 2.0.22 and 2.1.2 sources and vendor documentation; broker configuration acceptance and service behaviour not demonstrated | On pinned 2.0.22 and 2.1.2 deployments, load the applicable configurations and reproduce exposed/fixed authentication, TLS/client-certificate, listener, WebSockets, and actual-message ACL comparisons above. Confirm credential reload and termination of revoked connections. Exercise packet and payload limits separately on TCP and each deployed WebSockets implementation, including declared-length probes without the body; instrument allocation to distinguish rejection before allocation from rejection after receipt. Demonstrate the section 4 WebSockets gap and the chosen boundary or replacement implementation's rejection before oversized allocation, with an accepted packet below the limit. Exercise queue loss and inflight bounds with slow clients, keepalive negotiation/refusal, per-listener connection caps, and 2.1 global connection/session caps. For each listener cap N, hold N connections with distinct authorized identities/IDs and attempt N+1; demonstrate the 2.0.22 WebSockets failure without an independent cap and refusal with the chosen enforcement. Release a slot, confirm a replacement connects, refill to N, and repeat the excess attempt across multiple disconnect/reconnect cycles; distinguish cap refusal from authentication or transport failure. Test device-01 credentials with device-02's active ID: the exposed state disconnects device-02; the fixed state rejects or rewrites the claimant's ID and preserves device-02's connection and delivery. Repeat across listeners, and confirm the documented same-identity takeover when username binding is enabled. Run message ACL comparisons with one publishing/subscribing client per identity under that policy. On 2.0, demonstrate persistent-session growth with varying IDs, the chosen ID restriction, and expiration after the configured disconnected interval; do not treat expiration as a population cap. Where bridges exist, demonstrate trusted TLS and hostname rejection, optional client certificates, scoped inbound/outbound delivery, remote ACL denial, and reconnect backoff. Separately use a controlled upstream that sends outside the configured inbound topics despite its account ACL; demonstrate the direct bridge's missing ingress containment and, where required, denial at the independent boundary with matched allowed delivery. Confirm runtime ownership, readable security files, writable protected state/log paths, and persistence only when enabled. Record versions, WebSockets implementations, commands, diagnostics, matched positive controls, and cleanup without credentials. Configuration inspection alone does not demonstrate service behaviour. | ## Sources (checked October 2026) - Official eclipse-mosquitto Docker images: the tag-to-directory map (library file pinned commit 920b00976e6e8335bab0a3fd293b95b6b2404ce3). The image source at pinned commit 5b74cce8a4fe2a73b57df6c703bfde2cfd535d60 covers the 2.1-alpine build's 2.1.2 release download, configuration and dashboard install and `CMD`, an entrypoint that only sets data-directory ownership, its `mosquitto.conf` (`listener 1883`, `allow_anonymous true`, `listener 9883` with `protocol http_api`), and the README's description of it. The image source at pinned commit 5b74cce8a4fe2a73b57df6c703bfde2cfd535d60 also covers the 2.0-openssl and 1.6-openssl builds installing upstream's `mosquitto.conf`. Sources for this item (1 of 5): https://github.com/docker-library/official-images/blob/920b00976e6e8335bab0a3fd293b95b6b2404ce3/library/eclipse-mosquitto#L3-L13 and https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/Dockerfile#L30. Sources for this item (2 of 5): https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/Dockerfile#L77-L80 and https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/Dockerfile#L99-L101. Sources for this item (3 of 5): https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/mosquitto.conf (identical in the v2.1.2 release it is installed from: https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/docker/2.1-alpine/mosquitto.conf). Sources for this item (4 of 5): https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/docker-entrypoint.sh and https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/README.md#L27-L28. Sources for this item (5 of 5): https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.0-openssl/Dockerfile#L69 and https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/1.6-openssl/Dockerfile#L66 - Mosquitto 2.1.2 MQTT listener with no address (a passive `getaddrinfo` over both families, binding each result), the HTTP API listener with no address (libmicrohttpd with `MHD_USE_DUAL_STACK`), the `http_api` endpoints, and their JSON and plain-text handlers and their access check (pinned tag v2.1.2). This item's Mosquitto 2.1.2 sources also cover a `listener` line resetting that listener's `allow_anonymous` to unset so the global value applies, anonymous success with no authentication plugin, ACL success with no ACL plugin, and the manual's `http_api` entry (pinned tag v2.1.2). Sources for this item (1 of 3): https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/net.c#L815-L918, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L449-L545, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L160-L163 and https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L224. Sources for this item (2 of 3): https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L251, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L336-L378, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/conf.c#L2001 and https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/listeners.c#L27-L42. Sources for this item (3 of 3): https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/plugin_basic_auth.c#L56-L113, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/plugin_acl_check.c#L141-L201 and https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/man/mosquitto.conf.5.xml#L1663-L1671 - Mosquitto 1.6.15 default listener on 1883 with no address when no listener is configured, and `allow_anonymous` defaulting to true, with upstream's `mosquitto.conf` having no active line (pinned tags v1.6.15 and v2.0.22). Sources for this item (1 of 2): https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/src/conf.c#L420-L468, https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/src/net.c#L605-L690 and https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/man/mosquitto.conf.5.xml#L167-L185. Sources for this item (2 of 2): https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/mosquitto.conf and https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/mosquitto.conf - mosquitto.conf manual (allow_anonymous defaults, password_file, listener, certfile/keyfile/cafile, require_certificate): https://mosquitto.org/man/mosquitto-conf-5.html - mosquitto_sub manual (`-d` debug/CONNACK, `-W` timeout, `-u`/`-P`, `--cafile`, `--cert`/`--key`, `-x` session expiry, `--insecure`): https://mosquitto.org/man/mosquitto_sub-1.html - mosquitto_passwd manual (`-c` creates/overwrites the password file): https://mosquitto.org/man/mosquitto_passwd-1.html - Migrating to 2.0 (anonymous loopback mode, explicit-listener exposure and authentication, early privilege drop): https://mosquitto.org/documentation/migrating-to-2-0/ - Authentication methods (password files and SIGHUP reload): https://mosquitto.org/documentation/authentication-methods/ - Mosquitto 2.0 release notes (`deny` ACLs and queue-default change): https://mosquitto.org/blog/2020/12/version-2-0-0-released/ - Mosquitto 2.1 release notes (deprecations, announced 3.0 removals, packet-size default, global limits, client TLS behaviour): https://mosquitto.org/blog/2026/01/version-2-1-0-released/ - Replacing per_listener_settings (listener_allow_anonymous, plugin_load, plugin_use, file plugins): https://mosquitto.org/documentation/listeners/per-listener-settings/ - ACL-file plugin (user/topic rules, deny, patterns, username and client-id substitutions): https://mosquitto.org/documentation/plugins/acl-file/ - Mosquitto broker manual ($SYS telemetry, wildcard exclusions, reload signals): https://mosquitto.org/man/mosquitto-8.html - Mosquitto publisher manual (protected configuration files, QoS, protocol selection, message publishing): https://mosquitto.org/man/mosquitto_pub-1.html - Mosquitto 2.0.22 sample configuration (listener connection defaults, per-listener policy, bridge options): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/mosquitto.conf - Mosquitto 2.0.22 configuration implementation (actual resource defaults, numeric validation, per-listener ordering): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/conf.c - Mosquitto 2.1.2 configuration implementation (actual packet, keepalive, queue, and global-limit defaults): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/conf.c - Mosquitto 2.0.22 configuration manual source (resource-limit mechanisms, listener policy, WebSockets TLS): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/man/mosquitto.conf.5.xml - Mosquitto 2.0.22 built-in security implementation (message ACL checks, accepted subscriptions, credential rechecks on reload): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/security_default.c - Mosquitto 2.1.2 ACL-file plugin implementation (subscription acceptance and message-access filtering): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/plugins/acl-file/acl_check.c - Mosquitto 2.0.22 packet reader (packet-size enforcement before incoming-body allocation): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/lib/packet_mosq.c - Mosquitto 2.0.22 PUBLISH handler (payload-size checks, denied writes, QoS responses): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/handle_publish.c - Mosquitto 2.1.2 subscriber implementation (timeout result independent of message receipt): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/client/sub_client.c - Mosquitto libwebsockets packet allocation before a packet-size check (pinned tags v2.0.22 and v2.1.2): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/websockets.c, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/websockets.c#L296-L324 - Mosquitto libwebsockets connection-cap implementation (v2.0.22) and fix recorded under 2.1.1 in the v2.1.2 ChangeLog: https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/websockets.c, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/ChangeLog.txt#L14-L27 - Mosquitto disconnected-session expiry scheduling and timed cleanup (pinned tag v2.0.22): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/session_expiry.c - Mosquitto authenticated duplicate client-ID displacement (pinned tag v2.0.22): https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/src/handle_connect.c#L120-L133 - Mosquitto local bridge contexts bypassing ACL checks (pinned tags v2.0.22 and v2.1.2): https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/src/security.c#L691-L693, https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/plugin_acl_check.c#L149-L151 - Mosquitto incoming topic remapping returning success after unmatched topics (pinned tag v2.1.2): https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/bridge_topic.c ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - v20: Mosquitto (v2.0.22) - v21: Mosquitto (v2.1.2) - v16: Mosquitto legacy (v1.6.15) - docs: Mosquitto documentation (unknown) - library: Mosquitto image catalogue (920b00976e6e8335bab0a3fd293b95b6b2404ce3) - image: Mosquitto image source (5b74cce8a4fe2a73b57df6c703bfde2cfd535d60) - release-notes: Mosquitto release announcements (unknown) Claims (full source mapping in the JSON block below): - loopback-default (REASONED; docs): 2.0+ without listeners binds loopback and permits anonymous clients; it is not authenticated. - listener-default (REASONED; docs, v20): Explicit 2.0 listeners default to rejecting unauthenticated clients; an unspecified bind address is wildcard. - image21 (REASONED; library, image): Recorded 2.1.2 Alpine tags ship anonymous MQTT 1883 and HTTP API/dashboard 9883; override configuration and restrict publication. - image-api (REASONED; v21): 2.1.2 HTTP API uses a dual-stack socket and exposes systree/listeners JSON and version text without shipped credentials/ACLs. - image16 (REASONED; image, v16): Recorded 1.6.15 image tags ship commented upstream config: wildcard 1883 and anonymous access. - image20 (REASONED; image, v20): Recorded 2.0.22 image tags ship commented upstream config and retain loopback-only defaults. - deprecation (REASONED; docs, release-notes): File-based examples target 2.0.22; password_file, acl_file and per_listener_settings are deprecated in 2.1, removal announced for 3.0. - password-file (REASONED; docs): Prompt for distinct device credentials; -c overwrites, and -D edits the file without terminating connections by itself. - reload (REASONED; docs, v20): Reload changed credentials/ACLs; manual says connections unaffected, but 2.0.22 rechecks credentials; confirm deployed termination. - shared-policy (REASONED; v20): per_listener_settings false shares authentication/ACL policy; flipping it is neither separation nor 2.1 migration. - tls (REASONED; docs, v20): 8883 TLS needs CA/certificate/key; optional require_certificate adds machine possession, not human MFA. - listener-apply (REASONED; docs): Remove/firewall plaintext; confirm include_dir and restart for listener changes, which reload does not apply. - acl-default (REASONED; docs, v20): Password authentication alone leaves application topics unrestricted without an authorization mechanism. - acl-users (REASONED; docs, v20): Unlisted access is denied; user means username; pre-user topic rules apply only to anonymous clients. - acl-patterns (REASONED; docs): Patterns apply to all users; %u/%c must occupy a whole topic level; prefer authenticated usernames over chosen client IDs. - sys (REASONED; docs, release-notes): deny was added in 2.0; repeat $SYS denial per user and probe $SYS/# explicitly because # excludes it. - subscription (REASONED; v20, v21): File ACLs filter message access, not SUBSCRIBE acceptance; accepted # alone is no bypass. - connection-default (REASONED; v20): Per-listener max_connections defaults to -1, unlimited by this setting. - packet-default (REASONED; v20, v21, release-notes): max_packet_size is unset in 2.0.22 and 2,000,000 bytes in 2.1; explicit 2.0.22 values must be at least 20. - payload-default (REASONED; v20): message_size_limit defaults to 0; payload rejection follows reception and completes QoS acknowledgements. - inflight-default (REASONED; v20): max_inflight_messages defaults to 20 per client for outgoing QoS 1/2; it is not a message-rate limit. - queue-default (REASONED; v20, release-notes): max_queued_messages defaults to 1000 in 2.x, 100 earlier; queue overflow can lose messages. - queue-bytes (REASONED; v20): max_queued_bytes defaults to 0; when both queue limits are set the first reached stops further queuing. - keepalive (REASONED; v20, v21): max_keepalive defaults to 0 in 2.0.22/2.1.2; positive limits override MQTT 5 and reject incompatible MQTT 3.1.1. - packet-boundary (REASONED; v20, v21): 2.0.22 TCP checks size before body allocation; 2.0.22/2.1.2 libwebsockets allocate without that check; require an independent boundary. - global-limits (REASONED; docs, v21, release-notes): 2.1 global_max_connections/global_max_clients default to -1; the latter also counts disconnected persistent sessions. - session-expiry (REASONED; v20): 2.0 persistent-session growth needs identity/client-ID limits; persistent_client_expiration is not a hard population cap. - listener-policy (REASONED; v20): 2.0 per-listener settings must precede listeners/security options; place each listener's auth, ACL and TLS in its scope. - websockets (REASONED; v20, v21): Local 9001 needs separate auth/ACL/TLS; 2.0.22 WebSockets connection cap is ineffective; 2.1.1 fix needs deployed-build testing. - persistent-policy (REASONED; v20): Listeners share topic/session namespaces; disconnected persistent clients retain their most recent listener's ACL policy. - client-id (REASONED; v20): use_username_as_clientid is per-listener and needs restart; globally consistent usernames prevent cross-identity ID takeover, not same-user displacement. - migration (REASONED; docs): 2.1 listener_allow_anonymous and plugin_load/plugin_use replace listener policy; attach password-file and ACL-file plugins explicitly. - bridge-tls (REASONED; v20): Port 8883 alone enables no bridge TLS; require CA/hostname verification, protected remote password and optional client certificate. - bridge-topics (REASONED; v20, v21): Bridge contexts bypass local ACLs and 2.1.2 accepts unmatched ingress topics; narrow routes/remote ACLs do not contain malicious upstream. - bridge-options (REASONED; v20): try_private aids bridge handling, not auth; restart_timeout supplies backoff; notifications false suppresses default $SYS status writes. - runtime (REASONED; docs): Dedicated runtime identity needs readable security files and writable protected state/log paths after the early 2.0 privilege drop. - persistence (REASONED; docs): Persistence defaults false; persistence_location alone does not enable it; protect data, logs, secrets and backups. - client-config (REASONED; docs): Verify uses protected 2.1 client -o files; 2.0 lacks -o and needs isolated default configurations. - verify-auth (REASONED; docs, v21): Authenticated CONNACK succeeds and anonymous CONNECT rejects; timeout exit 27, SUBACK and transport failures prove no authentication. - verify-mtls (REASONED; docs): Missing certificate must fail at TLS while trusted certificate succeeds; then separately test MQTT credentials. - verify-network (REASONED; docs, v20): Inspect actual namespace and external IPv4/IPv6/NAT paths; removed plaintext/WebSocket endpoints must not answer. - verify-delivery (REASONED; docs, v20, v21): Require own telemetry, cross-device isolation and controller before/after markers around denied injection; inspect PUBACK and delivery. - verify-sys (REASONED; docs): Require exposed telemetry plus authorized marker control; fixed devices receive no $SYS messages. - verify-resources (REASONED; v20, v21): Separate packet allocation, payload, queue, inflight, keepalive, connection and session tests need matched healthy controls. - verify-bridges (REASONED; v20): Test scoped delivery, TLS identity, remote ACLs and malicious-upstream containment independently; inspect runtime permissions and persistence. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-10", "body_sha256": "1a338374bc949536319551bfd205fe9cee9b44986c2d2bb5521ddf90c2955941", "components": { "v20": { "name": "Mosquitto", "basis": "v2.0.22", "sources": { "se7823193963b": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/man/mosquitto.conf.5.xml", "s10aff5e66af1": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/mosquitto.conf", "s06316e74cf90": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/security_default.c", "s0705841d4141": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/mosquitto.conf", "s1f256b35dde7": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/conf.c", "sa4ab07ef74c3": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/handle_publish.c", "se4dab27858b2": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/lib/packet_mosq.c", "s1172d84efc29": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/websockets.c", "saf5cd7c6f12b": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.0.22/src/session_expiry.c", "saff73f3b983e": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/src/handle_connect.c#L120-L133", "sa116e59b51a4": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.0.22/src/security.c#L691-L693" } }, "v21": { "name": "Mosquitto", "basis": "v2.1.2", "sources": { "s539d2574fc70": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/net.c#L815-L918", "s7f6c7c2e6cff": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L449-L545", "sca7b250a1900": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L160-L163", "s9c9708acb8f8": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L224", "sedb9b3beddce": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L251", "s5fbac0184a26": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/http_api.c#L336-L378", "sd54804631b53": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/conf.c#L2001", "sb292974b4326": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/listeners.c#L27-L42", "s7d30df48bf91": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/plugin_basic_auth.c#L56-L113", "sfe5a6152e92f": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/plugin_acl_check.c#L141-L201", "s12077f64a53c": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/man/mosquitto.conf.5.xml#L1663-L1671", "sfe6c64ab8f91": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/plugins/acl-file/acl_check.c", "sd5c8a4af302f": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/conf.c", "s2ad092f62c47": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/client/sub_client.c", "s36fbdb22e941": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/websockets.c#L296-L324", "s77ce3e259e2e": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/ChangeLog.txt#L14-L27", "sccb5a918eab8": "https://github.com/eclipse-mosquitto/mosquitto/blob/v2.1.2/src/plugin_acl_check.c#L149-L151", "sac794dfa8179": "https://raw.githubusercontent.com/eclipse-mosquitto/mosquitto/v2.1.2/src/bridge_topic.c" } }, "v16": { "name": "Mosquitto legacy", "basis": "v1.6.15", "sources": { "scddf9e86ad6b": "https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/src/conf.c#L420-L468", "seb5311de9f93": "https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/src/net.c#L605-L690", "sbfaf2572ea61": "https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/man/mosquitto.conf.5.xml#L167-L185", "s0d1a10094e93": "https://github.com/eclipse-mosquitto/mosquitto/blob/v1.6.15/mosquitto.conf" } }, "docs": { "name": "Mosquitto documentation", "basis": "unknown", "sources": { "s4d96861c4f39": "https://mosquitto.org/documentation/migrating-to-2-0/", "s66b826cab3e9": "https://mosquitto.org/documentation/listeners/per-listener-settings/", "sddca3657b9c1": "https://mosquitto.org/man/mosquitto_passwd-1.html", "s9aa3260c5b79": "https://mosquitto.org/documentation/authentication-methods/", "s1791866ca31b": "https://mosquitto.org/man/mosquitto-conf-5.html", "s280527f289d0": "https://mosquitto.org/documentation/plugins/acl-file/", "sd34bd50f5f0f": "https://mosquitto.org/man/mosquitto-8.html", "s6711164e3e5f": "https://mosquitto.org/man/mosquitto_sub-1.html", "s1476cea737a4": "https://mosquitto.org/man/mosquitto_pub-1.html" } }, "library": { "name": "Mosquitto image catalogue", "basis": "920b00976e6e8335bab0a3fd293b95b6b2404ce3", "sources": { "s0d99329a59ae": "https://github.com/docker-library/official-images/blob/920b00976e6e8335bab0a3fd293b95b6b2404ce3/library/eclipse-mosquitto#L3-L13" } }, "image": { "name": "Mosquitto image source", "basis": "5b74cce8a4fe2a73b57df6c703bfde2cfd535d60", "sources": { "sdfa81718b7ff": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/Dockerfile#L30", "s38217a9ac8cc": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/Dockerfile#L77-L80", "see5ae9bc6aa9": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/Dockerfile#L99-L101", "s90852e30653e": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/mosquitto.conf", "s2653cf87d9d0": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/docker-entrypoint.sh", "s48d123c4c6d9": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.1-alpine/README.md#L27-L28", "s270129425e74": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/1.6-openssl/Dockerfile#L66", "sd127367f6308": "https://github.com/eclipse-mosquitto/mosquitto/blob/5b74cce8a4fe2a73b57df6c703bfde2cfd535d60/docker/2.0-openssl/Dockerfile#L69" } }, "release-notes": { "name": "Mosquitto release announcements", "basis": "unknown", "sources": { "s0c9ec2d6b65b": "https://mosquitto.org/blog/2026/01/version-2-1-0-released/", "s5529605a6416": "https://mosquitto.org/blog/2020/12/version-2-0-0-released/" } } }, "claims": { "loopback-default": {"text": "2.0+ without listeners binds loopback and permits anonymous clients; it is not authenticated.", "components": ["docs"], "sources": ["docs:s4d96861c4f39"], "status": "REASONED"}, "listener-default": {"text": "Explicit 2.0 listeners default to rejecting unauthenticated clients; an unspecified bind address is wildcard.", "components": ["docs", "v20"], "sources": ["docs:s4d96861c4f39", "v20:se7823193963b"], "status": "REASONED"}, "image21": {"text": "Recorded 2.1.2 Alpine tags ship anonymous MQTT 1883 and HTTP API/dashboard 9883; override configuration and restrict publication.", "components": ["library", "image"], "sources": ["library:s0d99329a59ae", "image:sdfa81718b7ff", "image:s38217a9ac8cc", "image:see5ae9bc6aa9", "image:s90852e30653e", "image:s2653cf87d9d0", "image:s48d123c4c6d9"], "status": "REASONED"}, "image-api": {"text": "2.1.2 HTTP API uses a dual-stack socket and exposes systree/listeners JSON and version text without shipped credentials/ACLs.", "components": ["v21"], "sources": ["v21:s539d2574fc70", "v21:s7f6c7c2e6cff", "v21:sca7b250a1900", "v21:s9c9708acb8f8", "v21:sedb9b3beddce", "v21:s5fbac0184a26", "v21:sd54804631b53", "v21:sb292974b4326", "v21:s7d30df48bf91", "v21:sfe5a6152e92f", "v21:s12077f64a53c"], "status": "REASONED"}, "image16": {"text": "Recorded 1.6.15 image tags ship commented upstream config: wildcard 1883 and anonymous access.", "components": ["image", "v16"], "sources": ["image:s270129425e74", "v16:scddf9e86ad6b", "v16:seb5311de9f93", "v16:sbfaf2572ea61", "v16:s0d1a10094e93"], "status": "REASONED"}, "image20": {"text": "Recorded 2.0.22 image tags ship commented upstream config and retain loopback-only defaults.", "components": ["image", "v20"], "sources": ["image:sd127367f6308", "v20:s10aff5e66af1"], "status": "REASONED"}, "deprecation": {"text": "File-based examples target 2.0.22; password_file, acl_file and per_listener_settings are deprecated in 2.1, removal announced for 3.0.", "components": ["docs", "release-notes"], "sources": ["release-notes:s0c9ec2d6b65b", "docs:s66b826cab3e9"], "status": "REASONED"}, "password-file": {"text": "Prompt for distinct device credentials; -c overwrites, and -D edits the file without terminating connections by itself.", "components": ["docs"], "sources": ["docs:sddca3657b9c1", "docs:s9aa3260c5b79"], "status": "REASONED"}, "reload": {"text": "Reload changed credentials/ACLs; manual says connections unaffected, but 2.0.22 rechecks credentials; confirm deployed termination.", "components": ["docs", "v20"], "sources": ["docs:s9aa3260c5b79", "v20:s06316e74cf90"], "status": "REASONED"}, "shared-policy": {"text": "per_listener_settings false shares authentication/ACL policy; flipping it is neither separation nor 2.1 migration.", "components": ["v20"], "sources": ["v20:s0705841d4141", "v20:se7823193963b"], "status": "REASONED"}, "tls": {"text": "8883 TLS needs CA/certificate/key; optional require_certificate adds machine possession, not human MFA.", "components": ["docs", "v20"], "sources": ["docs:s1791866ca31b", "v20:se7823193963b"], "status": "REASONED"}, "listener-apply": {"text": "Remove/firewall plaintext; confirm include_dir and restart for listener changes, which reload does not apply.", "components": ["docs"], "sources": ["docs:s1791866ca31b", "docs:s4d96861c4f39"], "status": "REASONED"}, "acl-default": {"text": "Password authentication alone leaves application topics unrestricted without an authorization mechanism.", "components": ["docs", "v20"], "sources": ["docs:s9aa3260c5b79", "v20:s06316e74cf90"], "status": "REASONED"}, "acl-users": {"text": "Unlisted access is denied; user means username; pre-user topic rules apply only to anonymous clients.", "components": ["docs", "v20"], "sources": ["docs:s280527f289d0", "v20:s06316e74cf90"], "status": "REASONED"}, "acl-patterns": {"text": "Patterns apply to all users; %u/%c must occupy a whole topic level; prefer authenticated usernames over chosen client IDs.", "components": ["docs"], "sources": ["docs:s280527f289d0"], "status": "REASONED"}, "sys": {"text": "deny was added in 2.0; repeat $SYS denial per user and probe $SYS/# explicitly because # excludes it.", "components": ["docs", "release-notes"], "sources": ["release-notes:s5529605a6416", "docs:sd34bd50f5f0f"], "status": "REASONED"}, "subscription": {"text": "File ACLs filter message access, not SUBSCRIBE acceptance; accepted # alone is no bypass.", "components": ["v20", "v21"], "sources": ["v20:s06316e74cf90", "v21:sfe6c64ab8f91"], "status": "REASONED"}, "connection-default": {"text": "Per-listener max_connections defaults to -1, unlimited by this setting.", "components": ["v20"], "sources": ["v20:s0705841d4141"], "status": "REASONED"}, "packet-default": {"text": "max_packet_size is unset in 2.0.22 and 2,000,000 bytes in 2.1; explicit 2.0.22 values must be at least 20.", "components": ["v20", "v21", "release-notes"], "sources": ["v20:s1f256b35dde7", "v21:sd5c8a4af302f", "release-notes:s0c9ec2d6b65b"], "status": "REASONED"}, "payload-default": {"text": "message_size_limit defaults to 0; payload rejection follows reception and completes QoS acknowledgements.", "components": ["v20"], "sources": ["v20:s0705841d4141", "v20:sa4ab07ef74c3"], "status": "REASONED"}, "inflight-default": {"text": "max_inflight_messages defaults to 20 per client for outgoing QoS 1/2; it is not a message-rate limit.", "components": ["v20"], "sources": ["v20:s0705841d4141"], "status": "REASONED"}, "queue-default": {"text": "max_queued_messages defaults to 1000 in 2.x, 100 earlier; queue overflow can lose messages.", "components": ["v20", "release-notes"], "sources": ["v20:s0705841d4141", "release-notes:s5529605a6416"], "status": "REASONED"}, "queue-bytes": {"text": "max_queued_bytes defaults to 0; when both queue limits are set the first reached stops further queuing.", "components": ["v20"], "sources": ["v20:s0705841d4141"], "status": "REASONED"}, "keepalive": {"text": "max_keepalive defaults to 0 in 2.0.22/2.1.2; positive limits override MQTT 5 and reject incompatible MQTT 3.1.1.", "components": ["v20", "v21"], "sources": ["v20:s1f256b35dde7", "v21:sd5c8a4af302f", "v20:se7823193963b"], "status": "REASONED"}, "packet-boundary": {"text": "2.0.22 TCP checks size before body allocation; 2.0.22/2.1.2 libwebsockets allocate without that check; require an independent boundary.", "components": ["v20", "v21"], "sources": ["v20:se4dab27858b2", "v20:se7823193963b", "v20:s1172d84efc29", "v21:s36fbdb22e941"], "status": "REASONED"}, "global-limits": {"text": "2.1 global_max_connections/global_max_clients default to -1; the latter also counts disconnected persistent sessions.", "components": ["docs", "v21", "release-notes"], "sources": ["docs:s1791866ca31b", "release-notes:s0c9ec2d6b65b", "v21:sd5c8a4af302f"], "status": "REASONED"}, "session-expiry": {"text": "2.0 persistent-session growth needs identity/client-ID limits; persistent_client_expiration is not a hard population cap.", "components": ["v20"], "sources": ["v20:se7823193963b", "v20:saf5cd7c6f12b"], "status": "REASONED"}, "listener-policy": {"text": "2.0 per-listener settings must precede listeners/security options; place each listener's auth, ACL and TLS in its scope.", "components": ["v20"], "sources": ["v20:s1f256b35dde7", "v20:se7823193963b"], "status": "REASONED"}, "websockets": {"text": "Local 9001 needs separate auth/ACL/TLS; 2.0.22 WebSockets connection cap is ineffective; 2.1.1 fix needs deployed-build testing.", "components": ["v20", "v21"], "sources": ["v20:se7823193963b", "v20:s1172d84efc29", "v21:s77ce3e259e2e"], "status": "REASONED"}, "persistent-policy": {"text": "Listeners share topic/session namespaces; disconnected persistent clients retain their most recent listener's ACL policy.", "components": ["v20"], "sources": ["v20:s0705841d4141"], "status": "REASONED"}, "client-id": {"text": "use_username_as_clientid is per-listener and needs restart; globally consistent usernames prevent cross-identity ID takeover, not same-user displacement.", "components": ["v20"], "sources": ["v20:se7823193963b", "v20:saff73f3b983e"], "status": "REASONED"}, "migration": {"text": "2.1 listener_allow_anonymous and plugin_load/plugin_use replace listener policy; attach password-file and ACL-file plugins explicitly.", "components": ["docs"], "sources": ["docs:s66b826cab3e9"], "status": "REASONED"}, "bridge-tls": {"text": "Port 8883 alone enables no bridge TLS; require CA/hostname verification, protected remote password and optional client certificate.", "components": ["v20"], "sources": ["v20:s0705841d4141", "v20:se7823193963b"], "status": "REASONED"}, "bridge-topics": {"text": "Bridge contexts bypass local ACLs and 2.1.2 accepts unmatched ingress topics; narrow routes/remote ACLs do not contain malicious upstream.", "components": ["v20", "v21"], "sources": ["v20:s0705841d4141", "v20:s06316e74cf90", "v21:sfe5a6152e92f", "v20:sa116e59b51a4", "v21:sccb5a918eab8", "v21:sac794dfa8179"], "status": "REASONED"}, "bridge-options": {"text": "try_private aids bridge handling, not auth; restart_timeout supplies backoff; notifications false suppresses default $SYS status writes.", "components": ["v20"], "sources": ["v20:s0705841d4141"], "status": "REASONED"}, "runtime": {"text": "Dedicated runtime identity needs readable security files and writable protected state/log paths after the early 2.0 privilege drop.", "components": ["docs"], "sources": ["docs:s1791866ca31b", "docs:s4d96861c4f39"], "status": "REASONED"}, "persistence": {"text": "Persistence defaults false; persistence_location alone does not enable it; protect data, logs, secrets and backups.", "components": ["docs"], "sources": ["docs:s1791866ca31b"], "status": "REASONED"}, "client-config": {"text": "Verify uses protected 2.1 client -o files; 2.0 lacks -o and needs isolated default configurations.", "components": ["docs"], "sources": ["docs:s6711164e3e5f", "docs:s1476cea737a4"], "status": "REASONED"}, "verify-auth": {"text": "Authenticated CONNACK succeeds and anonymous CONNECT rejects; timeout exit 27, SUBACK and transport failures prove no authentication.", "components": ["docs", "v21"], "sources": ["docs:s6711164e3e5f", "docs:s9aa3260c5b79", "v21:s2ad092f62c47"], "status": "REASONED", "verify": [1]}, "verify-mtls": {"text": "Missing certificate must fail at TLS while trusted certificate succeeds; then separately test MQTT credentials.", "components": ["docs"], "sources": ["docs:s1791866ca31b", "docs:s6711164e3e5f"], "status": "REASONED", "verify": [1]}, "verify-network": {"text": "Inspect actual namespace and external IPv4/IPv6/NAT paths; removed plaintext/WebSocket endpoints must not answer.", "components": ["docs", "v20"], "sources": ["docs:s4d96861c4f39", "v20:se7823193963b"], "status": "REASONED", "verify": [2, 3]}, "verify-delivery": {"text": "Require own telemetry, cross-device isolation and controller before/after markers around denied injection; inspect PUBACK and delivery.", "components": ["docs", "v20", "v21"], "sources": ["docs:s280527f289d0", "docs:s1476cea737a4", "v20:s06316e74cf90", "v21:sfe6c64ab8f91", "v20:sa4ab07ef74c3"], "status": "REASONED", "verify": [4, 5]}, "verify-sys": {"text": "Require exposed telemetry plus authorized marker control; fixed devices receive no $SYS messages.", "components": ["docs"], "sources": ["docs:sd34bd50f5f0f"], "status": "REASONED", "verify": [4]}, "verify-resources": {"text": "Separate packet allocation, payload, queue, inflight, keepalive, connection and session tests need matched healthy controls.", "components": ["v20", "v21"], "sources": ["v20:s1f256b35dde7", "v21:sd5c8a4af302f", "v20:se7823193963b", "v20:se4dab27858b2", "v20:sa4ab07ef74c3"], "status": "REASONED"}, "verify-bridges": {"text": "Test scoped delivery, TLS identity, remote ACLs and malicious-upstream containment independently; inspect runtime permissions and persistence.", "components": ["v20"], "sources": ["v20:s0705841d4141", "v20:s06316e74cf90"], "status": "REASONED"} } } ```` ====================================================================== ==> kafka.md ====================================================================== # Apache Kafka: SASL_SSL listeners, SCRAM credentials, and ACLs Apache Kafka is a message broker: applications write records to named topics and other applications read them. This guide helps your AI assistant make sure it is configured securely, including the plaintext listener replaced, TLS on the broker, SASL/SCRAM credentials, ACL authorization, bounded connection pressure, and authorization decisions logged. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/kafka.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Kafka listener default 4.3.1, Kafka documentation 4.3, Kafka source 4.3.0, Kafka legacy authorization 3.9, and OpenSSL documentation 3.5; 2 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 41 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Kafka's broker defaults are `listeners=PLAINTEXT://:9092` (as of Kafka 4.3.1), `security.inter.broker.protocol=PLAINTEXT`, and no authorizer, so anyone who reaches port 9092 can read every topic, produce to it, and create or delete topics with no credential and no encryption. Property names below come from the Kafka 4.x documentation (KRaft mode). The examples below target Kafka 4.3. Kafka 4.0 and later support KRaft only; the ZooKeeper authorizer note applies to legacy 3.9 deployments. Replace example addresses, certificate subjects, and secrets before deployment. ## 1. Replace the plaintext listener A client-facing socket should not also be the cluster's replication socket. Give clients, brokers, and controllers distinct listeners. Bind internal listeners to private addresses, restrict their network reachability, and authenticate them even on the private network. Remove the plaintext listener entirely: an unadvertised or loopback-bound socket still accepts direct connections. This example uses SCRAM over TLS for applications and mutual TLS for internal traffic. Keep step 2's keystore/truststore settings on every node. Each node needs its own certificate, with SANs matching the hostnames used to reach it. Internal truststores must trust the intended internal certificate issuers. These are security overlays for separate KRaft roles. Retain each node's unique `node.id` and storage configuration. Apply the common settings to brokers and controllers: ```properties listener.security.protocol.map=CLIENT:SASL_SSL,INTERNAL:SSL,CONTROLLER:SSL inter.broker.listener.name=INTERNAL controller.listener.names=CONTROLLER controller.quorum.bootstrap.servers=controller-1.internal:9094,controller-2.internal:9094,controller-3.internal:9094 listener.name.internal.ssl.client.auth=required listener.name.controller.ssl.client.auth=required ssl.endpoint.identification.algorithm=https ``` Broker-specific settings, substituting the broker's real private address and advertised hostnames: ```properties process.roles=broker listeners=CLIENT://0.0.0.0:9093,INTERNAL://10.0.0.11:9095 advertised.listeners=CLIENT://kafka.example.com:9093,INTERNAL://broker-1.internal:9095 sasl.enabled.mechanisms=SCRAM-SHA-512 listener.name.client.sasl.enabled.mechanisms=SCRAM-SHA-512 listener.name.client.scram-sha-512.sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required; ``` Restrict access to `CLIENT` to the intended application networks too. Its wildcard bind is not a firewall policy. Controller-specific settings, substituting that controller's real private address: ```properties process.roles=controller listeners=CONTROLLER://10.0.0.21:9094 ``` For this layout, advertise only `CLIENT` and `INTERNAL` on brokers; omit `advertised.listeners` from the controller overlay. Broker-only nodes still need the controller listener's protocol mapping and security settings for outbound connections, although they do not bind that listener. The first entry in `controller.listener.names` selects outbound controller traffic. Replace the previous listener definitions and obsolete listener-prefixed properties. Remove the previous controller PLAIN JAAS configuration, `sasl.mechanism.controller.protocol`, and `sasl.mechanism.inter.broker.protocol`: the internal connections now use certificate authentication. Select the inter-broker listener by name with `inter.broker.listener.name=INTERNAL`; do not also set `security.inter.broker.protocol`. Setting both is a startup error. The shipped configuration already selects an inter-broker listener, so replace that selection. The prefix in `listener.name..*` uses the listener name lowercased. Rename every affected prefix when renaming a listener; properties under an unused listener name do not configure the intended listener. Kafka advises against combined broker/controller roles for critical deployments. If retaining a combined node, its `listeners` must include the controller listener as well as the broker listeners. Keep distinct ports for this layout. The distribution's example controller uses 9093, which this guide assigns to clients; move both the controller binding and quorum bootstrap endpoints to 9094. Otherwise overlapping listener bindings can prevent startup. The quorum example is for a dynamic KRaft quorum. Do not combine it with a static `controller.quorum.voters` configuration. An existing static quorum needs the documented migration procedure, not just replacement of its voter list with bootstrap addresses. See [KRaft process roles and quorum provisioning](https://kafka.apache.org/43/operations/kraft/) and [listener configuration](https://kafka.apache.org/43/security/listener-configuration/). Retain the controller SCRAM caution narrowly: at the time of writing, [KAFKA-15513](https://issues.apache.org/jira/browse/KAFKA-15513) remains unresolved, lists affected versions 3.5.1 and 3.6.0, and lists no fixed version. It describes controller-to-controller SCRAM bootstrap failures; it does not establish a reproduced failure on every Kafka 4.3 deployment. Internal mutual TLS avoids relying on controller SCRAM bootstrap here. Apply step 4's deny-by-default authorizer and authorize the actual internal certificate principals before starting a new quorum. Controllers must be authenticated and private. ## 2. TLS on the broker Get a certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md); an internal CA fits a cluster) and point the broker at it. Kafka 2.7.0 and later also take PEM: `ssl.keystore.type=PEM` with `ssl.keystore.certificate.chain` and `ssl.keystore.key` (PKCS#8), and `ssl.truststore.type=PEM` with `ssl.truststore.certificates`. Hostname verification (`ssl.endpoint.identification.algorithm`) is on by default since 2.0.0; the documentation discourages blanking it. Keep these settings in the service-owned server properties file, with mode 0600 established before populating it. Protect keystores, private keys, JAAS files, client properties, and secret provisioning files the same way; restrict their parent directories. Passwords belong in those files, never in command-line arguments. ```properties ssl.keystore.location=/var/private/ssl/server.keystore.jks ssl.keystore.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.key.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.truststore.location=/var/private/ssl/server.truststore.jks ssl.truststore.password=REPLACE_WITH_LONG_RANDOM_VALUE # Client certificates on CLIENT: none (default), requested (optional), or required (mutual TLS). # On a SASL_SSL listener this property needs the listener prefix. listener.name.client.ssl.client.auth=none ``` Install the keystore/truststore settings on controllers too, using each node's own files. The last line belongs on brokers exposing `CLIENT`; it does not override the required client certificates on `INTERNAL` and `CONTROLLER`. The unprefixed `ssl.client.auth` applies to `SSL` listeners; a `SASL_SSL` listener needs the listener-prefixed setting. `requested` permits clients without certificates and is not equivalent to `required`. For PEM, store passwords are not supported; an encrypted PKCS#8 key uses `ssl.key.password`. Keep the inline PEM material in a protected properties file if using that form. Do not copy the old TLSv1/TLSv1.1 examples still present on the SSL page. The generated Kafka 4.3 reference gives `TLSv1.2,TLSv1.3` as the `ssl.enabled.protocols` default. See [SSL configuration](https://kafka.apache.org/43/security/encryption-and-authentication-using-ssl/) and the [broker configuration reference](https://kafka.apache.org/43/configuration/broker-configs/). ## 3. SASL/SCRAM credentials The documentation says SCRAM should be used only with TLS, hence `SASL_SSL` rather than `SASL_PLAINTEXT`. Give each application role its own credential ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). Keep SCRAM-SHA-512 and the listener JAAS configuration from step 1. KRaft SCRAM identities reside in the metadata log. Protect controller storage, snapshots/backups, and controller access. Legacy ZooKeeper credential-store descriptions do not describe this deployment. If inter-broker communication uses SCRAM, its credentials must exist before brokers start. This guide instead uses internal mutual TLS, so no inter-broker SCRAM password is needed during storage formatting. For a new dynamic quorum, generate the cluster ID once and reuse that exact value on every node. The following bootstraps only the first controller: ```bash # Run ONCE; retain the same cluster ID for every node. if CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)" && [ -n "$CLUSTER_ID" ]; then printf 'Cluster ID: %s\n' "$CLUSTER_ID" # First controller only, when bootstrapping a new dynamic quorum with one voter: bin/kafka-storage.sh format --cluster-id "$CLUSTER_ID" \ --config config/controller.properties --standalone else echo "cluster ID generation failed or printed nothing; storage not formatted" fi ``` The `if` keeps `CLUSTER_ID` in your shell for the later nodes, and when generation fails or prints nothing the block prints a message instead of formatting storage. The block runs no `exit` itself and assumes a fresh shell, as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) rule 6 does for Verify blocks; a shell with `set -e` in effect still closes if `format` fails. Do not run `--standalone` independently on every controller. Follow the documented membership workflow to add the remaining controllers before relying on quorum redundancy. New brokers and controllers joining an existing dynamic quorum are formatted with the same cluster ID and `--no-initial-controllers`. Alternatively, bootstrap multiple controllers with `--initial-controllers`, using the same complete `id@hostname:port:directory-id` list on every initial controller and a distinct directory ID for each controller. Generate directory IDs separately from the shared cluster ID. Formatting is for new storage, not an authentication migration on an existing cluster. See [KRaft provisioning](https://kafka.apache.org/43/operations/kraft/). Before starting the nodes, configure the certificate super users in step 4. Then use an administrative mTLS client on the private broker `INTERNAL` listener to provision the first SCRAM administrator. Its restricted `bootstrap-admin.properties` contains: ```properties security.protocol=SSL ssl.truststore.location=/var/private/ssl/client.truststore.jks ssl.truststore.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.keystore.location=/var/private/ssl/bootstrap-admin.keystore.jks ssl.keystore.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.key.password=REPLACE_WITH_LONG_RANDOM_VALUE ``` The client certificate must have the exact bootstrap administrator principal authorized in step 4. Create separate service-owned, mode-0600 files for `admin`, `orders-writer`, and `orders-reader`, establishing permissions before writing secrets. For example, `/etc/kafka/secrets/orders-writer.scram.properties` contains: ```properties SCRAM-SHA-512=iterations=8192,password=REPLACE_WITH_LONG_RANDOM_VALUE ``` Use a different long random password in each file. This is Java properties syntax: there are no square brackets around the value. The inline `--add-config` parser removes brackets; the `--add-config-file` path does not. Preserve any necessary Java properties escaping when inserting a real secret. Bootstrap the administrator through the private listener: ```bash bin/kafka-configs.sh --bootstrap-server broker-1.internal:9095 \ --command-config bootstrap-admin.properties \ --alter --entity-type users --entity-name admin \ --add-config-file /etc/kafka/secrets/admin.scram.properties ``` Create `admin.properties` using step 7's SASL client configuration with username `admin` and its own password. Once that credential works, provision the application roles: ```bash bin/kafka-configs.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --alter --entity-type users --entity-name orders-writer \ --add-config-file /etc/kafka/secrets/orders-writer.scram.properties bin/kafka-configs.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --alter --entity-type users --entity-name orders-reader \ --add-config-file /etc/kafka/secrets/orders-reader.scram.properties ``` On an already running cluster, an existing authorized administrator can use these file-based commands directly. Keep provisioning files and diagnostic output private: malformed SCRAM input can be included in CLI error messages. Do not substitute a file's contents into `--add-scram` or `--add-config`; that still puts the password in argv. The storage tool is not used here as a password-file interface. The empty server-side SCRAM login module options in step 1 are intentional: these brokers accept application SCRAM connections but authenticate their own internal connections with certificates. See [SASL/SCRAM configuration and credential storage](https://kafka.apache.org/43/security/authentication-using-sasl/) and [ConfigCommand's file loading and SCRAM parser](https://github.com/apache/kafka/blob/4.3.0/core/src/main/scala/kafka/admin/ConfigCommand.scala). ## 4. Authorization Without an authorizer every authenticated user can do everything. Enable the KRaft authorizer on every broker and controller and keep deny-by-default. A fresh quorum also needs its internal node identities authorized before ACL metadata is available. Enumerate the exact certificate principals for every broker, every controller, and the bootstrap administrator. The following is an example only where the certificates' complete subjects are exactly the shown CNs: ```properties authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer allow.everyone.if.no.acl.found=false super.users=User:admin;User:CN=broker-1;User:CN=controller-1;User:CN=controller-2;User:CN=controller-3;User:CN=bootstrap-admin ``` Expand the list for the actual cluster. A certificate subject containing other components needs its complete principal, not just its CN. The separator between super users is a semicolon, because certificate subjects can contain commas. Never add application principals. Protect node and bootstrap administrator private keys as administrative credentials. See [KIP-801 bootstrapping](https://cwiki.apache.org/confluence/spaces/KAFKA/pages/195728007/KIP-801+Implement+an+Authorizer+that+stores+metadata+in+__cluster_metadata). For a legacy ZooKeeper deployment on Kafka 3.9, the authorizer class is `kafka.security.authorizer.AclAuthorizer`, not `kafka.security.auth.SimpleAclAuthorizer`. Do not substitute that class into Kafka 4.3. See the [3.9 authorization reference](https://kafka.apache.org/39/security/authorization-and-acls/). A producer credential should not also read orders, and a consumer credential should not write them. Provision separate `orders-writer` and `orders-reader` identities. Create topics through an administrative workflow and grant applications only the operations they need. Disable automatic topic creation in the broker configuration: ```properties auto.create.topics.enable=false ``` Create `orders` administratively before starting the applications, using the partition count and replication factor appropriate to the deployment. Grant the application roles: ```bash bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --add --allow-principal User:orders-writer \ --operation Write --operation Describe \ --topic orders --resource-pattern-type literal bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --add --allow-principal User:orders-reader \ --operation Read --operation Describe \ --topic orders --resource-pattern-type literal bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --add --allow-principal User:orders-reader \ --operation Read --group app-workers --resource-pattern-type literal ``` Replace the old grants rather than accumulating permissions. After migrating the application, remove the previous shared `User:app` grants: ```bash bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --remove --force --allow-principal User:app --producer \ --topic orders --resource-pattern-type literal bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --remove --force --allow-principal User:app --consumer \ --topic orders --group app-workers --resource-pattern-type literal ``` Each removal here and in Verify passes `--force`. Without it, Kafka 4.3.0's `kafka-acls.sh` prints a confirmation question before every removal, reads the answer with `System.console().readLine()` and removes nothing unless the answer is `y` or `Y`. If Java provides no console, that call fails and `main`'s handler exits with status 1; whether a console exists without a terminal depends on the Java version. See [AclCommand's removal](https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java#L130-L148), [confirmation](https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java#L383-L389) and [error exit](https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java#L84-L88). Inspect matching wildcard and prefix ACLs, including grants to `User:*`, and cluster-level grants. Removing these literal grants does not remove permissions conferred elsewhere. Retire the old shared credential after its remaining uses have been removed. The `--producer` convenience option grants topic `Create` as well as `Write` and `Describe`; use the explicit grants above for the new writer. Disabling automatic creation alone does not remove explicit creation rights. If the producer uses transactions, additionally authorize its specific transaction identity: ```bash bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --add --allow-principal User:orders-writer \ --operation Write --transactional-id orders-writer-1 \ --resource-pattern-type literal ``` Use that identity as the producer's `transactional.id`. Do not automatically add cluster-wide `IdempotentWrite`: Kafka 4.3.0's non-transactional producer initialization also accepts a principal with topic `Write` permission. The manual's authorization table is less precise on this point; the tagged [InitProducerId implementation](https://github.com/apache/kafka/blob/4.3.0/core/src/main/scala/kafka/server/KafkaApis.scala) supplies the distinction. See [Authorization and ACLs](https://kafka.apache.org/43/security/authorization-and-acls/) and [multi-tenancy topic-creation controls](https://kafka.apache.org/43/operations/multi-tenancy/). ## 5. Bound authenticated traffic and connection pressure Authentication does not stop a compromised application from exhausting broker resources. Set quotas by authenticated user, and limit connections on the client listener. Tune these example values against normal traffic before rollout. ```bash bin/kafka-configs.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --alter --entity-type users --entity-name orders-writer \ --add-config 'producer_byte_rate=1048576,request_percentage=25' bin/kafka-configs.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --alter --entity-type users --entity-name orders-reader \ --add-config 'consumer_byte_rate=2097152,request_percentage=25' ``` These byte rates apply per broker. `request_percentage=25` permits processing time equivalent to 25 percent of one thread, accumulated across the broker's network and request-handler I/O threads. It is not 25 percent of the whole machine. A client chooses its own `client.id`; use authenticated-user quotas as the primary boundary. Inspect more-specific user/client quotas and user/default-client overrides, which take precedence over a user-only quota. See [setting quotas](https://kafka.apache.org/43/operations/basic-kafka-operations/) and [quota grouping, precedence, and enforcement](https://kafka.apache.org/43/design/design/). In broker `server.properties`: ```properties listener.name.client.max.connections=500 listener.name.client.max.connection.creation.rate=50 ``` The first limit bounds simultaneous client connections; the second bounds new connections per second. They act on connection pressure before a client has necessarily authenticated. Keep replication on its separate listener. Kafka 4.3 supports these listener-specific limits; inter-broker connections have documented exceptions to broker-wide limits. These controls supplement network filtering and do not prevent upstream network saturation. See the [connection-limit configuration reference](https://kafka.apache.org/43/configuration/broker-configs/). For a tenant that is separately authorized to create, delete, or alter topics, an additional conditional quota is: ```bash bin/kafka-configs.sh --bootstrap-server kafka.example.com:9093 \ --command-config admin.properties \ --alter --entity-type users --entity-name topic-provisioner \ --add-config 'controller_mutation_rate=10' ``` This bounds partition mutations, not ordinary message throughput, and does not grant topic-management permissions. See [multi-tenancy quotas and rate limiting](https://kafka.apache.org/43/operations/multi-tenancy/). ## 6. Retain authorization decisions Authentication failures and authorization denials need an operational record. Kafka provides authorizer logging; it is not a complete audit trail of every administrative change or message. Authentication failures occur before authorization and need separate collection from the normal broker/controller logs. In the distribution's `config/log4j2.yaml`, replace the existing `kafka.authorizer.logger` entry under `Configuration.Loggers.Logger` with the following, matching the surrounding indentation. Retain the existing `AuthorizerAppender`: ```yaml - name: kafka.authorizer.logger level: DEBUG additivity: false AppenderRef: ref: AuthorizerAppender ``` Collect `kafka-authorizer.log` from brokers and controllers, alongside their normal server logs. Protect the collector and retention policy independently of broker access. Estimate logging volume before enabling allowed-access records. Kafka 4.0 migrated to Log4j2; do not paste legacy `log4j.logger...` syntax into its YAML configuration. The tagged 4.3.0 distribution defines this logger, appender, and filename. `StandardAuthorizer` logs explicitly requested denials at INFO and allowed access at DEBUG when the corresponding audit flag is set. Some filtering and introspection decisions use TRACE instead. DEBUG therefore does not mean every authorization evaluation. The manual does not document this logger's full configuration. This recipe is additionally checked against the [tagged logging configuration](https://github.com/apache/kafka/blob/4.3.0/config/log4j2.yaml), [StandardAuthorizer's audit implementation](https://github.com/apache/kafka/blob/4.3.0/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java#L279-L308), and the [Action audit-logging API](https://kafka.apache.org/43/javadoc/org/apache/kafka/server/authorizer/Action.html). The migration is documented in [upgrading to Kafka 4.0](https://kafka.apache.org/43/getting-started/upgrade/). ## 7. Client side Keep `client.properties` out of the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)), owned by the account that needs it, with mode 0600 established before writing secrets. Use separate copies as `orders-writer.properties` and `orders-reader.properties`, with the corresponding username and distinct password. Keep `admin.properties` separate from application deployments. MFA: the Kafka protocol has no second-factor dialogue. `listener.name.client.ssl.client.auth=required` adds mutual TLS as a possession control for machine clients ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)); the unprefixed `ssl.client.auth` applies only to `SSL` listeners, and Kafka warns when it is set without the prefix on a `SASL_SSL` broker. Each client then presents its own keystore as in the last three lines below. Human paths to brokers or a management UI go behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Example `orders-writer.properties`: ```properties security.protocol=SASL_SSL sasl.mechanism=SCRAM-SHA-512 sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="orders-writer" password="REPLACE_WITH_LONG_RANDOM_VALUE"; ssl.truststore.location=/var/private/ssl/client.truststore.jks ssl.truststore.password=REPLACE_WITH_LONG_RANDOM_VALUE # only when the listener requires client certificates ssl.keystore.location=/var/private/ssl/client.keystore.jks ssl.keystore.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.key.password=REPLACE_WITH_LONG_RANDOM_VALUE ``` For the reader, change the username to `orders-reader` and use its password. For the administrator, use `admin` and its password. Omit the optional keystore lines when client certificates are not required. Leave hostname verification enabled. The commands below use Kafka 4.3's `--command-config` option, including on the console producer and consumer. Older distributions may use different client-file flags; use the CLI shipped with the documented version. ## 8. Redpanda (and other Kafka-API-compatible systems) Redpanda implements the Kafka wire protocol, so the client-side and protocol-level controls above carry over: SASL/SCRAM authentication, TLS, and ACL management through the Kafka API all work the same way against a Redpanda cluster, and the same fronting rules apply too. What does not carry over is how you wire that up on the broker: Redpanda configures brokers through `redpanda.yaml` and the `rpk` CLI, not Kafka's `server.properties`, JAAS configuration files, or `kafka-storage.sh`. Redpanda Console, its bundled web UI, ships without its own login screen: it only gains one once you configure OIDC or basic authentication, and that built-in Console login requires an enterprise licence, so until you configure it anyone who reaches Console reaches the cluster behind it. Enabling the enterprise features without a valid licence does not open Console: it restricts access to a licence-expiration page instead. Front Console the same way as any other admin panel: never public, reached through SSH forwarding, a tailnet, or an access proxy, with MFA at that layer. See the [Redpanda security documentation](https://docs.redpanda.com/streaming/current/manage/security/). Check Redpanda's own references for listener separation, quotas, and logging; the Kafka server configuration additions above are not Redpanda configuration. ## Verify REASONED: Service-comparison prerequisites and overall scope; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka broker or KRaft controller deployment, deployed node certificates, provisioned client principals, external test hosts, quota metrics or collected broker/controller logs. The locally executed stub, parser and certificate checks below do not demonstrate deployment exposure. The bash -n, ShellCheck, guard, stub and fixture-log runs below of the ten guarded blocks used an earlier form of those blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. The listener-inventory and client TLS block changed again after those runs: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. Expect exposed access and fixed enforcement as specified by each check, per the guide's cited Kafka security overview (Kafka 4.3 documentation). This guide has not been demonstrated against a running Kafka cluster. The service checks below are **REASONED**: a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available; the authoring environment therefore has no Kafka broker or KRaft controller deployment, deployed node certificates, provisioned client principals, external test hosts, quota metrics or collected broker/controller logs. These missing capabilities prevent the exposed/fixed service comparisons here. REASONED: Historical local-run narrative; live Kafka comparisons require a Kafka broker or KRaft controller deployment, which opens listeners; the authoring host forbids that without an isolated network namespace, and none was available during marking review (October 2026). The original runs and tagged-parser comparison are not independently authenticated by this review. Local runs during marking review (October 2026) gave empty successful bash -n and ShellCheck 0.11.0 output for all ten Verify shell blocks, YAML OK, eleven properties lexical OK results and metadata JSON OK; six unsafe inputs per block printed not probing without stub calls. Substituted inputs reached stubs for service commands and grep over fixture logs for the logging block. The marking-review bash -n, ShellCheck, guard, stub and fixture-log runs of the ten guarded blocks used an earlier form of those blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. The listener-inventory and client TLS block changed again after those runs: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. No live enforcement was observed; the service expectations remain source reasoning per the guide's cited Kafka security overview (Kafka 4.3 documentation). Locally, the shell blocks passed `bash -n`, ShellCheck, and the repository's guard-convention scanner. YAML parsing, properties lexical checks, and the bracket-free SCRAM value's match against the tagged parser expression were checked. For each of the ten guarded blocks, six unsafe inputs were exercised: the original placeholder, an embedded placeholder, each angle bracket, an `example.com` hostname, and an empty value. All rejected without executing a probe. Isolated positive controls confirmed that a substituted value reaches each guard's intended arm. These are bounded local results, not a claim of aggregate bypass resistance or Kafka enforcement. The shell-block runs recorded in this paragraph, in marking review and in the original local pass, used an earlier form of the ten guarded blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. If the original local pass also covered the step 3 cluster ID block and the step 4 grant removals, it covered earlier forms of both: the cluster ID block now formats storage inside an `if` instead of ending with `|| exit 1`, the grant removals now pass `--force`, and that pass did not cover either current form, which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. The listener-inventory and client TLS block changed again after every run recorded in this paragraph: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. REASONED: Whole-block substitution and network vantage; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available; the authoring environment therefore has no Kafka node network namespaces or second host for public IPv4/IPv6 and allowed/disallowed network comparisons. All ten blocks passed syntax/lint; six unsafe inputs printed not probing without stub calls, and wrong marker or value count exited 1. Those runs used an earlier form of the ten blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. The listener-inventory and client TLS block changed again after those runs: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. Expect exposed reachability versus blocked unauthorized paths with working authorized controls, per the guide's cited Listener configuration (controller listener, combined-node requirement, protocol map) (Kafka 4.3 documentation). Substitute inside the single quotes and paste each whole block. A literal apostrophe requires proper shell escaping. Run external checks from another host over the actual public IPv4 and IPv6 paths; run the listener inventory in each Kafka node's network namespace. REASONED: Disposable topics, credentials, diagnostics and cleanup; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no isolated Kafka deployment with an orders topic fixture, provisioned administrator/writer/reader credentials and ACLs, or live topic and ACL administration endpoint. Local syntax/lint and guard checks passed; lifecycle stubs printed positive control OK, topic deny-by-default observed and application creation denied; early failure paths printed findings requiring manual cleanup. Those syntax/lint, guard and stub runs used an earlier form of the guarded blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. The listener-inventory and client TLS block changed again after those runs: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. Expect exposed unauthorized operations versus fixed denials with successful administrator controls, per the guide's cited Basic Kafka operations, topic administration, and quota commands (Kafka 4.3 documentation). Use an isolated test deployment with an administratively created `orders` fixture for commands that produce records or create topics. Provision the named credentials and ACLs first. The single-replica temporary topics below are disposable test fixtures, not a production replication recommendation. Keep diagnostics private. If a block reports a failure or stops early, remove any of its temporary ACLs and topics that remain, using the printed names. ### Listener inventory and client TLS **REASONED:** No Kafka node namespace, deployed certificate, or external network vantage is available here. On an exposed deployment, the inventory can show a plaintext or publicly bound internal listener. The fixed inventory should show client 9093, private broker-internal 9095, and private controller 9094 only where their roles require them. `ss` is an inventory in this namespace, not proof of firewall enforcement. REASONED: Optional client-certificate and encrypted-key handling; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no deployed Kafka client listener or deployed client certificate credentials (distinct from the synthetic fixture generated in the review) for comparing optional and required client authentication. Local OpenSSL generated an encrypted fixture key and certificate; file-based password loading printed Key is valid, while matching/wrong hostname checks printed OK/hostname mismatch. Expect an exposed optional-client-cert listener to accept missing credentials and a fixed required-cert listener to reject them while accepting valid credentials, per the guide's cited Encryption and authentication using SSL (Kafka 4.3 documentation) and the guide's cited OpenSSL file-based private-key passphrases. The probe below matches the default client listener above, which does not require client certificates. If setting `listener.name.client.ssl.client.auth=required`, add the client's `-cert` and `-key`, and use `-pass file:client-key.pass` for an encrypted key. Protect that password file. `Verification: OK` alone does not prove the broker accepted the client certificate; it can appear before the server rejects the connection. REASONED: following block; inventory and hostname/chain verification; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka node network namespace, deployed broker certificate and its CA trust material, or external client host. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. Stub inventory and TLS printed STUB LISTEN and STUB TLS; local OpenSSL verification printed OK for the matching hostname and hostname mismatch for the wrong one. The bash -n, ShellCheck, guard and stub runs recorded here covered the listener-inventory and client TLS block in an earlier form, whose placeholder refusal arm left the subshell with status 0, whose paste and value-count refusals exited with status 1 and which ran `ss` without first checking that it is installed. Its current form leaves the subshell with status 2 on a placeholder, paste or value-count refusal and when `ss` is not installed, which it checks with `command -v ss` before any probe, so a missing `ss` also skips the TLS check; it is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs with `ss` present and passing, present and failing, and missing. That earlier form also exited with status 1 after a failed listener inventory or TLS check, where the current form exits with status 3. Expect exposed plaintext/public internal listeners versus private role-specific listeners and verified client TLS, per the guide's cited Encryption and authentication using SSL (Kafka 4.3 documentation), Listener configuration (controller listener, combined-node requirement, protocol map) (Kafka 4.3 documentation), and OpenSSL s_client certificate and hostname verification. The client TLS positive control must validate the broker hostname and certificate chain. A plaintext endpoint cannot complete this TLS exchange. A certificate error is a TLS defect; a DNS or local failure is inconclusive. Sources: [listener configuration](https://kafka.apache.org/43/security/listener-configuration/), [Kafka SSL](https://kafka.apache.org/43/security/encryption-and-authentication-using-ssl/), and [OpenSSL s_client](https://docs.openssl.org/3.5/man1/openssl-s_client/). Point `ca.pem` at the CA that signed the broker certificate. Omit `-CAfile` only when using an appropriate public CA already trusted by OpenSSL. Without the verification flags, a completed handshake shows only that TLS is present. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one broker hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker hostname; not probing"; exit 2 ;; *) command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp || { echo "listener inventory failed"; exit 3; } openssl s_client -connect "$1:9093" -servername "$1" \ -verify_hostname "$1" -verify_return_error -CAfile ca.pem '*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) bin/kafka-broker-api-versions.sh --bootstrap-server "$1" \ --command-config bootstrap-admin.properties \ || { echo "valid internal mTLS control failed"; exit 3; } if bin/kafka-broker-api-versions.sh --bootstrap-server "$1" \ --command-config internal-no-cert.properties; then echo "FINDING: internal Kafka access without a client certificate" exit 1 else echo "Inspect for a TLS client-certificate rejection; other failures are inconclusive" fi if bin/kafka-broker-api-versions.sh --bootstrap-server "$1" \ --command-config internal-untrusted-cert.properties; then echo "FINDING: internal Kafka access with an untrusted client certificate" exit 1 else echo "Inspect for a TLS trust rejection; other failures are inconclusive" fi ;; esac ) ``` **REASONED:** No controller listener, deployed node certificate, or controller logs are available here. Test each controller with a valid node certificate, then without a certificate. Fixed TLS must accept the valid certificate and reject the missing one; an exposed listener without client-certificate enforcement can complete both handshakes. Source: [controller listeners](https://kafka.apache.org/43/security/listener-configuration/) and [OpenSSL verification](https://docs.openssl.org/3.5/man1/openssl-s_client/). ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CONTROLLER_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one controller hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the controller hostname; not probing"; exit 2 ;; *) openssl s_client -connect "$1:9094" -servername "$1" \ -verify_hostname "$1" -verify_return_error -CAfile ca.pem \ -cert node.crt -key node.key -pass file:node-key.pass '*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) umask 077 set -- "$1" "$(mktemp -d)" [ -d "$2" ] || { echo "cannot create private results directory"; exit 3; } printf 'Results: %s\n' "$2" bin/kafka-broker-api-versions.sh --bootstrap-server "$1" \ --command-config orders-reader.properties \ || { echo "authenticated positive control failed"; exit 3; } if bin/kafka-broker-api-versions.sh --bootstrap-server "$1" \ --command-config plain.properties >"$2/plain.log" 2>&1; then echo "FINDING: plaintext Kafka is served" exit 1 else echo "Plaintext failed; inspect plain.log and broker logs to exclude local, DNS and routing errors" fi bin/kafka-console-consumer.sh --bootstrap-server "$1" \ --command-config orders-reader.properties --group app-workers \ --topic orders --from-beginning --max-messages 1 --timeout-ms 10000 \ >"$2/authenticated.out" 2>"$2/authenticated.log" \ || { echo "authenticated consumer control failed; inspect authenticated.log"; exit 3; } [ -s "$2/authenticated.out" ] \ || { echo "authenticated consumer returned no records; not probing denial"; exit 3; } bin/kafka-console-consumer.sh --bootstrap-server "$1" \ --command-config wrong.properties --group app-workers \ --topic orders --from-beginning --max-messages 1 --timeout-ms 10000 \ >"$2/wrong.out" 2>"$2/wrong.log" \ || printf 'Consumer exit=%s; checking wrong.log for the specific denial\n' "$?" if [ -s "$2/wrong.out" ]; then echo "FINDING: wrong-password consumer returned records" exit 1 fi grep -F 'SaslAuthenticationException' "$2/wrong.log" >/dev/null \ || { echo "not the expected authentication denial; inspect wrong.log"; exit 3; } echo "wrong password rejected at SASL authentication" ;; esac ) ``` ### Marker round trip and split application permissions **REASONED:** No live broker, test topic, or application identities are available here. The matched positive control produces a unique marker as the writer and reads that exact marker as the reader using a fresh consumer group. Then the same reader identity attempts a write. An overprivileged reader can write; the fixed reader receives `TopicAuthorizationException`. REASONED: following block; marker delivery, reader-write denial and temporary group ACL cleanup; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka deployment with an orders topic fixture and provisioned writer, reader and administrator principals for marker delivery, write-denial and temporary group ACL checks. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. Stubs printed positive control OK and reader write denied at topic authorization; accepted writes printed FINDING. The bash -n, ShellCheck, guard and stub runs recorded here covered the marker round-trip block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after a failed control, an unexpected denial or a failed cleanup, and it stopped before removing its temporary group ACL, after a finding too; the current form exits with status 3 after such a failure and runs that cleanup before it exits. The current form's temporary group ACL removal also passes `--force`; the runs recorded here covered an earlier form without it, and the current form was not re-run. It also exited with status 1 when it could not create its private results directory, where the current form exits with status 3 before creating any broker fixture. Expect an exposed reader to write and a fixed reader to receive TopicAuthorizationException while the marker round trip succeeds, per the guide's cited Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation). A stub consumer returning the marker with nonzero status still printed positive control OK; a mismatched marker printed marker missing. Idle-timeout semantics remain source reasoning per the guide's cited Console consumer exception logging and exit behavior, tagged 4.3.0. Only the negative producer probe disables idempotence, so a producer-ID authorization check does not mask the topic-write check. This is a test override, not an application configuration recommendation. Sources: [ACL operations](https://kafka.apache.org/43/security/authorization-and-acls/), [producer configuration](https://kafka.apache.org/43/configuration/producer-configs/), and the tagged console-tool sources in Sources. The consumer timeout is an idle timeout, not a wall-clock limit. Use a quiet test fixture. Receiving the exact marker proves that round trip even if the consumer subsequently exits on idle timeout; it does not turn unrelated errors into success. Preserve and inspect stderr separately. ```bash # Writer/reader separation; no live Kafka cluster, deployed broker certificates or deployed client credentials (distinct from synthetic certificate fixtures) or external test hosts. Expected outcomes are per the guide's cited Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation). ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_HOST:9093' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one broker endpoint; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) umask 077 set -- "$1" "verify-$(date +%s)-$$" "$(mktemp -d)" [ -d "$3" ] || { echo "cannot create private results directory"; exit 3; } printf 'Results: %s\n' "$3" bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --add --allow-principal User:orders-reader --operation Read \ --group "$2" --resource-pattern-type prefixed \ || { echo "temporary group ACL failed"; exit 3; } # The probes run in a nested subshell, so the cleanup below runs whatever they report. ( printf '%s\n' "$2" | bin/kafka-console-producer.sh \ --bootstrap-server "$1" --command-config orders-writer.properties \ --topic orders --sync \ || { echo "writer positive control failed"; exit 3; } if bin/kafka-console-consumer.sh --bootstrap-server "$1" \ --command-config orders-reader.properties --group "$2" --topic orders \ --from-beginning --timeout-ms 15000 >"$3/records" 2>"$3/consumer.log"; then echo "consumer exited normally" else echo "Consumer exited nonzero; inspect consumer.log, including any idle timeout" fi grep -Fx -- "$2" "$3/records" >/dev/null \ || { echo "marker missing: positive control FAILED or inconclusive"; exit 3; } echo "positive control OK: exact marker received" if printf '%s\n' "$2-denied" | bin/kafka-console-producer.sh \ --bootstrap-server "$1" --command-config orders-reader.properties \ --command-property enable.idempotence=false \ --topic orders --sync >"$3/reader-write.log" 2>&1; then echo "FINDING: reader produced a record" exit 1 else grep -F 'TopicAuthorizationException' "$3/reader-write.log" >/dev/null \ || { echo "not the expected topic denial; inspect reader-write.log"; exit 3; } echo "reader write denied at topic authorization" fi ) || set -- "$@" "$?" bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --remove --force --allow-principal User:orders-reader --operation Read \ --group "$2" --resource-pattern-type prefixed \ || { echo "remove the temporary group ACL manually"; set -- "$@" 3; } # Status: 1 after a finding; otherwise 3 after a failed control, check or cleanup; otherwise 0. case "${4-}" in 1) exit 1 ;; ?*) exit 3 ;; esac ;; esac ) ``` ### Topic deny-by-default with an authenticated low-privilege user REASONED: Low-user credential and privilege preconditions; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka deployment with a provisioned low-user SCRAM principal, inspectable effective privileges and administrator credentials for temporary topic and ACL lifecycle checks. The local deny-by-default block passed syntax/lint and guards; stubs printed topic deny-by-default observed or FINDING for unauthorized marker delivery. Those syntax/lint, guard and stub runs covered the deny-by-default block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after a failed control, an unconfirmed ACL precondition, an unexpected denial or a failed cleanup, and it stopped before removing its temporary group ACL and topic, after a finding too; the current form exits with status 3 after such a failure and runs that cleanup before it exits. The current form also passes `--force` to both ACL removals, removes its temporary topic ACL outside its probes whatever the positive control reports, and runs its denial probe only after that removal succeeds; the runs recorded here covered an earlier form, and the current form was not re-run. It also exited with status 1 when it could not create its private results directory, where the current form exits with status 3 before creating any broker fixture. Actual SCRAM provisioning and absence of super-user/other grants were not observed. Expect a valid low user to read under the temporary grant and be denied after removal in the fixed state, per the guide's cited Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation). Provision `low` through the same restricted-file SCRAM procedure as step 3. Its `low.properties` uses its own valid credential. It must not be a super user or have other grants. **REASONED:** No live authorizer, low-privilege identity, or seeded topic is available here. This block creates and seeds a real temporary topic, grants `low` a fresh group prefix and a temporary topic `Read`, and proves that `low` receives the marker. It then removes only the topic permission and checks the same topic with a fresh group. That removal runs outside the nested subshells, so it also runs when seeding, a grant or the positive control fails, and the denial check runs only after the positive control and the removal succeed. Kafka 4.3.0's AclCommand passes a removal to the Admin API's `deleteAcls` without first checking for a matching ACL, and an exception from that call reaches the handler in its `main`, which exits with status 1 ([removal](https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java#L182-L189), [error exit](https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java#L84-L88)). Whether the broker's reply to a removal that matches nothing raises such an exception is outside that file. A clean run removes the topic permission only once, while it exists, so an ACL that is already gone cannot turn a clean run into status 3; when no ACL matches because an earlier step failed, the run already has status 3. REASONED: following block; temporary topic creation/seeding, low-user positive control, ACL removal/inspection, denial and cleanup; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka deployment with a provisioned low-user SCRAM principal, inspectable effective privileges and administrator credentials for temporary topic and ACL lifecycle checks. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. Stubs printed topic deny-by-default observed for the fixed response and FINDING for unauthorized marker delivery. The bash -n, ShellCheck, guard and stub runs recorded here covered the deny-by-default block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after a failed control, an unconfirmed ACL precondition, an unexpected denial or a failed cleanup, and it stopped before removing its temporary group ACL and topic, after a finding too; the current form exits with status 3 after such a failure and runs that cleanup before it exits. The current form also passes `--force` to both ACL removals, removes its temporary topic ACL outside its probes whatever the positive control reports, and runs its denial probe only after that removal succeeds; the runs recorded here covered an earlier form, and the current form was not re-run. It also exited with status 1 when it could not create its private results directory, where the current form exits with status 3 before creating any broker fixture. Expect exposed marker access versus TopicAuthorizationException without a matching topic ACL, per the guide's cited Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation). Effective configuration attribution and absence of wildcard/prefix grants still require live inspection. After ACL propagation, the fixed state must report `TopicAuthorizationException`. In an isolated exposed configuration with no matching topic ACL and `allow.everyone.if.no.acl.found=true`, the marker can be read. A group error tests the wrong boundary. Empty output, offsets, and timeouts remain inconclusive. Source: [behavior without ACLs and matching resource patterns](https://kafka.apache.org/43/security/authorization-and-acls/). The ACL inspection must show no literal, wildcard, or prefix match for the temporary topic. Do not remove unrelated production ACLs to manufacture that condition. Run the comparison in an isolated fixture. A successful unauthorized read is a finding, but attributing it specifically to `allow.everyone.if.no.acl.found` also requires checking the effective authorizer configuration. ```bash # Deny-by-default ACLs; no live Kafka cluster, deployed broker certificates or deployed client credentials (distinct from synthetic certificate fixtures) or external test hosts. Expected outcomes are per the guide's cited Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation). ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_HOST:9093' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one broker endpoint; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) umask 077 set -- "$1" "verify-deny-$(date +%s)-$$" "$(mktemp -d)" [ -d "$3" ] || { echo "cannot create private results directory"; exit 3; } printf 'Test topic/group: %s; results: %s\n' "$2" "$3" bin/kafka-topics.sh --bootstrap-server "$1" --command-config admin.properties \ --create --topic "$2" --partitions 1 --replication-factor 1 \ || { echo "isolated test-topic creation failed"; exit 3; } # The grants and positive control run in a nested subshell, so the topic ACL removal below runs whatever they report. ( printf '%s\n' "$2" | bin/kafka-console-producer.sh \ --bootstrap-server "$1" --command-config admin.properties --topic "$2" --sync \ || { echo "seeding the test topic failed"; exit 3; } bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --add --allow-principal User:low --operation Read \ --group "$2" --resource-pattern-type prefixed \ || { echo "low user's group ACL failed"; exit 3; } bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --add --allow-principal User:low --operation Read \ --topic "$2" --resource-pattern-type literal \ || { echo "temporary positive-control topic ACL failed"; exit 3; } bin/kafka-console-consumer.sh --bootstrap-server "$1" \ --command-config low.properties --group "$2-positive" --topic "$2" \ --from-beginning --max-messages 1 --timeout-ms 10000 \ >"$3/positive" 2>"$3/positive.log" \ || { echo "low-user positive control failed"; exit 3; } grep -Fx -- "$2" "$3/positive" >/dev/null \ || { echo "low-user positive marker missing"; exit 3; } ) || set -- "$@" "$?" bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --remove --force --allow-principal User:low --operation Read \ --topic "$2" --resource-pattern-type literal \ || { echo "temporary topic ACL removal failed; remove it manually if it remains"; set -- "$@" 3; } # The denial probe runs only after a clean positive control and topic ACL removal. if [ "$#" -eq 3 ]; then ( bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --list --topic "$2" --resource-pattern-type match \ || { echo "matching ACL inspection failed"; exit 3; } printf 'Type NO_TOPIC_ACLS only after confirming no matching topic ACL remains: ' read -r REPLY || { echo "ACL confirmation unavailable"; exit 3; } [ "$REPLY" = NO_TOPIC_ACLS ] || { echo "ACL precondition not confirmed"; exit 3; } bin/kafka-console-consumer.sh --bootstrap-server "$1" \ --command-config low.properties --group "$2-negative" --topic "$2" \ --from-beginning --max-messages 1 --timeout-ms 10000 \ >"$3/negative" 2>"$3/negative.log" \ || printf 'Consumer exit=%s; checking negative.log for the specific denial\n' "$?" if grep -Fx -- "$2" "$3/negative" >/dev/null; then echo "FINDING: low user read the marker without a matching topic ACL" exit 1 fi if [ -s "$3/negative" ]; then echo "FINDING: low user returned records without a matching topic ACL" exit 1 fi grep -F 'TopicAuthorizationException' "$3/negative.log" >/dev/null \ || { echo "not the expected topic denial; inspect negative.log"; exit 3; } echo "topic deny-by-default observed" ) || set -- "$@" "$?" fi bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --remove --force --allow-principal User:low --operation Read \ --group "$2" --resource-pattern-type prefixed \ || { echo "remove the temporary group ACL manually if it remains"; set -- "$@" 3; } bin/kafka-topics.sh --bootstrap-server "$1" --command-config admin.properties \ --delete --topic "$2" || { echo "remove the test topic manually"; set -- "$@" 3; } # Status: 1 after a finding; otherwise 3 after a failed control, check or cleanup; otherwise 0. case "${4-}" in 1) exit 1 ;; ?*) exit 3 ;; esac ;; esac ) ``` ### Application topic creation REASONED: following block; writer and reader creation denial, administrator control, cleanup and old literal grants; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka deployment with provisioned writer, reader and administrator principals and inspectable topic-creation ACLs. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. Both identity variants printed application creation denied; matching admin creation succeeded for denial stubs and FINDING: application created a topic for accepting stubs. The bash -n, ShellCheck, guard and stub runs recorded here covered the topic-creation block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after an unexpected denial, a failed administrator control or a failed cleanup, and it stopped before deleting its test topic, after a finding too; the current form exits with status 3 after such a failure and deletes the test topic before it exits. The current form's failed-deletion message also asks for manual removal only if the test topic remains; the runs recorded here covered an earlier form, and the current form was not re-run. It also exited with status 1 when it could not create its private results directory, where the current form exits with status 3 before creating any broker fixture. Expect exposed application creation versus fixed denial with administrator success; old literal grants still require inspection, per the guide's cited Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation) and the guide's cited Basic Kafka operations, topic administration, and quota commands (Kafka 4.3 documentation). No live cluster with the application roles is available here. First complete the writer/reader marker control above. An overprivileged writer can create a new topic; the fixed writer must receive a topic authorization denial, while the administrator can create that same topic. This tests explicit creation rights independently of automatic creation. After an unexpected creation denial or a failed administrator control, the test topic may not exist, but the cleanup still tries to delete it, because a client can fail after the broker has created a topic. That run already has status 3, so a failed deletion of a missing topic cannot change the status; a clean run or a finding always leaves a topic to delete. Sources: [ACL protocol operations](https://kafka.apache.org/43/security/authorization-and-acls/) and [topic administration](https://kafka.apache.org/43/operations/basic-kafka-operations/). Repeat for the reader identity. This new-name probe must be combined with ACL inspection: an old literal `Create` grant on `orders` could permit recreating `orders` while still denying the new test name. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_HOST:9093' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one broker endpoint; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) umask 077 set -- "$1" "verify-create-$(date +%s)-$$" "$(mktemp -d)" [ -d "$3" ] || { echo "cannot create private results directory"; exit 3; } printf 'Test topic: %s; results: %s\n' "$2" "$3" # The probes run in a nested subshell, so the cleanup below runs whatever they report. ( if bin/kafka-topics.sh --bootstrap-server "$1" \ --command-config orders-writer.properties \ --create --topic "$2" --partitions 1 --replication-factor 1 \ >"$3/create.log" 2>&1; then echo "FINDING: application created a topic" exit 1 else grep -F 'TopicAuthorizationException' "$3/create.log" >/dev/null \ || { echo "not the expected creation denial; inspect create.log"; exit 3; } fi bin/kafka-topics.sh --bootstrap-server "$1" --command-config admin.properties \ --create --topic "$2" --partitions 1 --replication-factor 1 \ || { echo "admin creation control failed"; exit 3; } echo "application creation denied; matching admin creation succeeded" ) || set -- "$@" "$?" bin/kafka-topics.sh --bootstrap-server "$1" --command-config admin.properties \ --delete --topic "$2" || { echo "remove the test topic manually if it remains"; set -- "$@" 3; } # Status: 1 after a finding; otherwise 3 after a failed control, check or cleanup; otherwise 0. case "${4-}" in 1) exit 1 ;; ?*) exit 3 ;; esac ;; esac ) ``` ### ACL, quota, and connection-limit inspection **REASONED:** No live admin endpoint is available here. These commands inspect configuration; they do not demonstrate enforcement. Expected exposed state: overly broad matching grants, application `Create`, missing quotas, or unrestricted client connection settings. Expected fixed state: the intended topic/group grants, no application creation grants, explicit user quotas, and the client limits from step 5. REASONED: following block; topic/group/cluster ACLs, user/client quotas and effective broker settings; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no live Kafka administrative endpoint and credentials for describing matching ACLs, user/client quotas and every broker's effective settings. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. Stubs printed three STUB ACL and four STUB config description lines; failed inspection printed ACL inspection failed. The bash -n, ShellCheck, guard and stub runs recorded here covered the ACL and quota inspection block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after a failed inspection, where the current form exits with status 3. Expect broad grants and absent limits when exposed, versus intended grants and explicit limits after fixing; descriptions alone do not establish enforcement, per the guide's cited Basic Kafka operations, topic administration, and quota commands (Kafka 4.3 documentation). Transaction identities and conditional provisioner quotas require their own live descriptions. The successful administrative descriptions establish that the inspection path works; the marker and denial comparisons above test application behavior. Sources: [ACL listing](https://kafka.apache.org/43/security/authorization-and-acls/), [quota description](https://kafka.apache.org/43/operations/basic-kafka-operations/), and [broker configuration](https://kafka.apache.org/43/configuration/broker-configs/). Inspect every broker's effective values and their configuration sources. For a transactional writer, also inspect matching ACLs for its transaction identity. If using the conditional topic-provisioner quota, describe that user too. ```bash # ACL/quota/connection inspection; no live Kafka cluster, deployed broker certificates or deployed client credentials (distinct from synthetic certificate fixtures) or external test hosts. Expected outcomes are per the guide's cited Basic Kafka operations, topic administration, and quota commands (Kafka 4.3 documentation). ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_HOST:9093' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one broker endpoint; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --list --topic orders --resource-pattern-type match \ || { echo "ACL inspection failed"; exit 3; } bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --list --group app-workers --resource-pattern-type match \ || { echo "group ACL inspection failed"; exit 3; } bin/kafka-acls.sh --bootstrap-server "$1" --command-config admin.properties \ --list --cluster || { echo "cluster ACL inspection failed"; exit 3; } bin/kafka-configs.sh --bootstrap-server "$1" --command-config admin.properties \ --describe --entity-type users --entity-name orders-writer \ || { echo "writer quota inspection failed"; exit 3; } bin/kafka-configs.sh --bootstrap-server "$1" --command-config admin.properties \ --describe --entity-type users --entity-name orders-reader \ || { echo "reader quota inspection failed"; exit 3; } bin/kafka-configs.sh --bootstrap-server "$1" --command-config admin.properties \ --describe --entity-type users --entity-type clients \ || { echo "user/client quota override inspection failed"; exit 3; } bin/kafka-configs.sh --bootstrap-server "$1" --command-config admin.properties \ --describe --entity-type brokers --all \ || { echo "effective broker configuration inspection failed"; exit 3; } ;; esac ) ``` ### Bounded quota comparison **REASONED:** No isolated Kafka workload environment or quota metrics are available here. On an isolated one-partition `orders` fixture, run this identical bounded producer workload before and after applying the writer quota. Keep authentication and ACLs fixed. The baseline must be capable of exceeding the configured byte rate; otherwise the comparison is inconclusive. REASONED: following block; bounded producer workload and limits of quota/connection evidence; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no isolated Kafka workload deployment, one-partition orders fixture, provisioned writer credentials or live quota and connection metrics. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. The stub printed 100000 records sent with synthetic produce-throttle-time-avg values 0 and 5; its failure printed bounded workload failed. The bash -n, ShellCheck, guard and stub runs recorded here covered the bounded quota block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after a failed workload, where the current form exits with status 3. Real exposed/fixed runs must deliver records with zero/nonzero throttling when exceeding the quota; consumer, request, mutation and connection limits need separate live workloads, per the guide's cited Quota grouping, precedence, request-time accounting, and enforcement (Kafka 4.3 documentation) and Kafka monitoring and quota-throttle metrics (Kafka 4.3 documentation). Both runs must deliver the requested records without authorization or delivery errors. With no applicable quota, quota throttle time should remain zero; with the workload exceeding the fixed quota, `produce-throttle-time-avg` or `produce-throttle-time-max` should become nonzero. Inspect request-quota metrics separately to distinguish request-time throttling from bandwidth throttling. A low-rate successful marker round trip is the positive control that the fixed system still serves the application. Sources: [quota enforcement](https://kafka.apache.org/43/design/design/), [monitoring metrics](https://kafka.apache.org/43/operations/monitoring/), and [ProducerPerformance options](https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/ProducerPerformance.java). This producer workload does not demonstrate consumer quotas, controller mutation quotas, or connection-limit enforcement. Those need their own bounded workloads. In particular, compare accepted client connections and connection-creation throttling around the configured limits while confirming that replication and quorum operation remain healthy; a TCP connect result or a configuration listing alone is insufficient. ```bash # Bounded producer quota comparison; no live Kafka cluster, deployed broker certificates or deployed client credentials (distinct from synthetic certificate fixtures) or external test hosts. Expected outcomes are per the guide's cited Quota grouping, precedence, request-time accounting, and enforcement (Kafka 4.3 documentation) and Kafka monitoring and quota-throttle metrics (Kafka 4.3 documentation). ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BROKER_HOST:9093' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one broker endpoint; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the broker endpoint; not probing"; exit 2 ;; *) bin/kafka-producer-perf-test.sh --bootstrap-server "$1" \ --command-config orders-writer.properties --topic orders \ --num-records 100000 --record-size 1024 --throughput -1 --print-metrics \ || { echo "bounded workload failed; inspect authentication, authorization and delivery errors"; exit 3; } ;; esac ) ``` ### Authorization logging **REASONED:** No live authorizer or collected logs are available here. Run the marker/write-denial pair above in a quiet test window, then inspect only that window's collected authorizer log. Correlate timestamps, client host, principal, operation, resource, and outcome so old records cannot satisfy the check. REASONED: following block; DEBUG log filtering, INFO comparison and broker/controller collection; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no live Kafka authorizer or collected broker/controller logs from matched INFO/DEBUG requests. Local bash -n and ShellCheck 0.11.0 exited 0; six unsafe inputs printed not probing without stub calls. Grep over fixture logs printed both matching records, allowed write record missing, or denied write record missing. The bash -n, ShellCheck, guard and stub runs recorded here covered the authorization-logging block in an earlier form, whose placeholder refusal arm left the subshell with status 0 and whose paste and value-count refusals exited with status 1; none was re-run on the current form, whose placeholder, paste and value-count refusals leave the subshell with status 2. That earlier form also exited with status 1 after a missing log record, where the current form exits with status 3. Expect requested denials at INFO and both outcomes at DEBUG; absent denial records indicate failed collection, per the guide's cited StandardAuthorizer audit levels and message fields, tagged 4.3.0. No audit generation was observed. Authentication failures belong to server-log review, per the guide's cited Authentication using SASL (SCRAM) (Kafka 4.3 documentation). At INFO, the requested denial should be present while the allowed write is absent. At DEBUG, both should be present. If the denied record is also absent, logger routing or collection has failed; absence of an allowed record alone is not proof of the intended INFO behavior. Sources: [StandardAuthorizer audit messages](https://github.com/apache/kafka/blob/4.3.0/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java) and [the logging configuration](https://github.com/apache/kafka/blob/4.3.0/config/log4j2.yaml). The block checks the DEBUG outcome. Repeat the same requests at INFO to establish the matched comparison. Check controller log collection with known controller-authorized operations as well. The wrong-password probe belongs in the authentication-failure review of normal server logs, not in an expectation that the authorizer records failed authentication. ```bash # Authorization logging; no collected broker or controller authorizer log from matched requests is available. Expected outcomes are per the guide's cited StandardAuthorizer audit levels and message fields, tagged 4.3.0. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_COLLECTED_AUTHORIZER_LOG' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one log path; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the log path; not probing"; exit 2 ;; *) grep -F 'Principal = User:orders-writer is Allowed operation = WRITE' "$1" \ | grep -F 'on resource = Topic:LITERAL:orders' \ || { echo "allowed write record missing"; exit 3; } grep -F 'Principal = User:orders-reader is Denied operation = WRITE' "$1" \ | grep -F 'on resource = Topic:LITERAL:orders' \ || { echo "denied write record missing"; exit 3; } ;; esac ) ``` ### REASONED service checks REASONED: Service-check inventory and evidence boundary; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka broker or KRaft controller deployment, deployed node certificates, provisioned client principals, external test hosts, quota metrics or collected broker/controller logs. Ten blocks passed syntax/lint; unsafe inputs printed not probing, stub comparisons exercised the documented branches, and local certificate verification accepted/rejected matching/wrong hostnames. The syntax/lint, guard and stub runs used an earlier form of the ten blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. The listener-inventory and client TLS block changed again after those runs: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. Expect the exposed/fixed outcomes listed in the table, per the guide's cited Kafka security overview (Kafka 4.3 documentation); real listeners, identities, quotas, quorum and logs remain unobserved. The checks below remain REASONED from the cited Kafka documentation and pinned sources. No live service behavior is claimed. | Scope | Exposed/fixed checks and prerequisites | Status | | --- | --- | --- | | Kafka controls | REASONED: Entire service-check inventory; live Kafka comparisons are unavailable because a Kafka deployment opens listeners, which the authoring host forbids without an isolated network namespace, and none was available. The authoring environment therefore has no Kafka broker or KRaft controller deployment, deployed node certificates, provisioned client principals, external test hosts, quota metrics or collected broker/controller logs. Ten blocks passed bash -n and ShellCheck 0.11.0; six unsafe inputs per block printed not probing without stub calls. Stub comparisons printed findings for accepted unauthorized operations and the intended denial messages for fixed responses; fixture logs matched or reported missing records. Those bash -n, ShellCheck, guard, stub and fixture-log runs used an earlier form of the ten blocks, whose placeholder refusal arm left each subshell with status 0 and whose paste and value-count refusals exited with status 1. Those runs did not cover the current form, whose placeholder, paste and value-count refusals leave each subshell with status 2 and which the gate suite's bash -n and ShellCheck 0.11.0 passes cover. That earlier form also exited with status 1 after a failed control, check or cleanup that was not a finding, and its three blocks with temporary ACLs or topics stopped before their cleanup; the current form exits with status 3 after such a failure, and those three blocks run their cleanup before they exit. The marker round-trip, deny-by-default and topic-creation blocks changed again after those runs: every ACL removal now passes `--force`, the deny-by-default block removes its temporary topic ACL outside its probes whatever the positive control reports, and the topic-creation cleanup message changed; those runs did not cover that current form either. The listener-inventory and client TLS block changed again after those runs: it now checks `command -v ss` before running `ss -tlnp` and refuses with status 2 when `ss` is not installed, so those runs covered an earlier form of it, and its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs. Local OpenSSL printed OK/hostname mismatch. Expect the exposed/fixed outcomes detailed above; live network, Kafka, quorum, quota and log-generation evidence remains unavailable, per the guide's cited sources (part 1 of 2): Kafka security overview (Kafka 4.3 documentation); Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation). The guide's cited sources for the same expectation and evidence limit as part 1 (part 2 of 2): Kafka monitoring and quota-throttle metrics (Kafka 4.3 documentation); and StandardAuthorizer audit levels and message fields, tagged 4.3.0. Run every REASONED check above on a pinned Kafka deployment. Cover (part 1 of 3): namespace inventory and real IPv4/IPv6 exposure; client TLS and plaintext refusal; correct and wrong SCRAM credentials; marker round trip; authenticated low-user topic denial with no matching ACL; writer/reader separation and application-create denial with administrator success. The run on that pinned Kafka deployment is to cover (part 2 of 3): allowed/disallowed networks for each listener; valid, missing, and untrusted internal certificates; healthy replication and controller quorum after bootstrap; producer, consumer, request-time, and conditional partition-mutation quotas with matched bounded workloads and throttle metrics. The run on that pinned Kafka deployment is to cover (part 3 of 3): simultaneous-connection and connection-creation limits while internal traffic remains healthy; and INFO/DEBUG authorization records collected from brokers and controllers, plus authentication-failure records. Record commands, versions, diagnostics, matched positive controls, and cleanup. Configuration inspection alone does not demonstrate service behavior. | REASONED from the cited documentation and pinned sources; service behavior is not demonstrated. | ## Common mistakes - `SASL_SSL` added as a second listener while `PLAINTEXT://:9092` stays advertised, so clients quietly keep using it. - Authorizer enabled with `allow.everyone.if.no.acl.found=true` "temporarily". The flag opens only resources that have no ACL at all; existing ACLs are still enforced. That still means every new topic is world-readable and world-writable until someone adds its first ACL, so keep it `false`. - Treating an unadvertised, loopback, or private controller socket as a substitute for authentication. - Starting a deny-by-default quorum before authorizing its actual node certificate principals. - Adding narrow writer/reader ACLs without removing the old shared, wildcard, prefix, or cluster-level grants. - Assuming `auto.create.topics.enable=false` removes an application's explicit `Create` permission. - Treating a quota listing as an enforcement test, or DEBUG authorizer logging as a complete audit trail. - Treating an empty consumer result, a timeout, or `Verification: OK` alone as proof of the intended security boundary. ## Sources (checked September 2026) - Kafka security overview (Kafka 4.3 documentation): https://kafka.apache.org/43/security/security-overview/ - Listener configuration (controller listener, combined-node requirement, protocol map) (Kafka 4.3 documentation): https://kafka.apache.org/43/security/listener-configuration/ - Encryption and authentication using SSL (Kafka 4.3 documentation): https://kafka.apache.org/43/security/encryption-and-authentication-using-ssl/ - Authentication using SASL (SCRAM) (Kafka 4.3 documentation): https://kafka.apache.org/43/security/authentication-using-sasl/ - Authorization and ACLs (`allow.everyone.if.no.acl.found`, resource-pattern-type) (Kafka 4.3 documentation): https://kafka.apache.org/43/security/authorization-and-acls/ - KRaft provisioning / storage formatting (`--standalone`, cluster ID reuse) (Kafka 4.3 documentation): https://kafka.apache.org/43/operations/kraft/ - Broker configuration reference (defaults for `listeners`, `sasl.enabled.mechanisms`, `inter.broker.listener.name`) (Kafka 4.3 documentation): https://kafka.apache.org/43/configuration/broker-configs/ - SSL client authentication on `SASL_SSL` listeners needs the listener prefix (source, `ChannelBuilders.java`, tagged 4.3.0): https://raw.githubusercontent.com/apache/kafka/4.3.0/clients/src/main/java/org/apache/kafka/common/network/ChannelBuilders.java - Redpanda security documentation: https://docs.redpanda.com/streaming/current/manage/security/ - Redpanda Console authentication (built-in login requires an enterprise licence): https://docs.redpanda.com/streaming/current/console/config/security/authentication/ - Apache Kafka controller SCRAM bootstrap issue (KAFKA-15513): https://issues.apache.org/jira/browse/KAFKA-15513 - Kafka 3.9 authorization and ZooKeeper AclAuthorizer: https://kafka.apache.org/39/security/authorization-and-acls/ - KIP-801 authorizer bootstrapping and early-start listeners: - Basic Kafka operations, topic administration, and quota commands (Kafka 4.3 documentation): https://kafka.apache.org/43/operations/basic-kafka-operations/ - Multi-tenancy, topic-creation controls, and partition-mutation quotas (Kafka 4.3 documentation): https://kafka.apache.org/43/operations/multi-tenancy/ - Quota grouping, precedence, request-time accounting, and enforcement (Kafka 4.3 documentation): https://kafka.apache.org/43/design/design/ - Producer configuration, idempotence, and transactional identity (Kafka 4.3 documentation): https://kafka.apache.org/43/configuration/producer-configs/ - Consumer configuration and client security properties (Kafka 4.3 documentation): https://kafka.apache.org/43/configuration/consumer-configs/ - Admin client configuration and security properties (Kafka 4.3 documentation): https://kafka.apache.org/43/configuration/admin-configs/ - Kafka upgrades, ZooKeeper removal, and the Log4j2 migration (Kafka 4.3 documentation): https://kafka.apache.org/43/getting-started/upgrade/ - Kafka monitoring and quota-throttle metrics (Kafka 4.3 documentation): https://kafka.apache.org/43/operations/monitoring/ - ConfigCommand file loading, SCRAM parsing, and configuration descriptions, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/core/src/main/scala/kafka/admin/ConfigCommand.scala - InitProducerId authorization, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/core/src/main/scala/kafka/server/KafkaApis.scala - Distribution server configuration and example listener ports, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/config/server.properties - Distribution Log4j2 configuration, authorizer appender, and log filenames, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/config/log4j2.yaml - StandardAuthorizer audit levels and message fields, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java - Authorizer Action audit-logging flags (Kafka 4.3 documentation): https://kafka.apache.org/43/javadoc/org/apache/kafka/server/authorizer/Action.html - Console producer options, configuration files, and synchronous sends, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/ConsoleProducer.java - Console consumer options, configuration files, groups, and timeouts, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/consumer/ConsoleConsumerOptions.java - Console consumer exception logging and exit behavior, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/consumer/ConsoleConsumer.java - Broker API versions CLI options, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/BrokerApiVersionsCommand.java - Topic administration CLI options and error handling, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/TopicCommand.java - ACL CLI removal, confirmation prompt, `--force` option and error exit, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java - Producer performance CLI options and metrics, tagged 4.3.0: https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/ProducerPerformance.java - OpenSSL s_client certificate and hostname verification: https://docs.openssl.org/3.5/man1/openssl-s_client/ - OpenSSL file-based private-key passphrases: https://docs.openssl.org/3.5/man1/openssl-passphrase-options/ - Kafka `LISTENERS_DEFAULT = "PLAINTEXT://:9092"` (pinned tag 4.3.1): https://github.com/apache/kafka/blob/4.3.1/server/src/main/java/org/apache/kafka/network/SocketServerConfigs.java#L64 - Kafka defines the `listeners` config with that default (pinned tag 4.3.1): https://github.com/apache/kafka/blob/4.3.1/server/src/main/java/org/apache/kafka/network/SocketServerConfigs.java#L156 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - default: Kafka listener default (4.3.1) - docs: Kafka documentation (4.3) - source: Kafka source (4.3.0) - legacy: Kafka legacy authorization (3.9) - kafka-design: Apache Kafka issue tracker and KIPs (unknown) - redpanda: Redpanda documentation (unknown) - openssl: OpenSSL documentation (3.5) Claims (full source mapping in the JSON block below): - plaintext (REASONED; default): Default listener is PLAINTEXT://:9092 as of 4.3.1. - internal-default (REASONED; docs): security.inter.broker.protocol defaults to PLAINTEXT. - authorizer-default (REASONED; docs): No authorizer by default; reachable clients can read, write, create and delete topics. - roles (REASONED; docs): Examples target 4.3; 4.0+ is KRaft-only; critical deployments should separate broker/controller roles. - listeners (REASONED; docs, source): CLIENT 9093 uses SASL_SSL; private INTERNAL 9095 and CONTROLLER 9094 use mutual TLS; remove plaintext. - advertise (REASONED; docs): Advertise broker CLIENT/INTERNAL only; broker-only nodes still need outbound controller security. - listener-selection (REASONED; docs): Use inter.broker.listener.name without security.inter.broker.protocol; lowercase listener prefixes must match. - quorum (REASONED; docs): Dynamic quorum bootstrap is not static-voter migration; reuse cluster ID and use the documented membership workflow. - controller-scram (REASONED; kafka-design): KAFKA-15513 lists 3.5.1/3.6.0 and no fix; it does not prove failure on every 4.3 deployment. - tls-files (REASONED; docs): Protect per-node keystores, truststores, keys and properties before writing secrets; require matching SANs. - pem (REASONED; docs): PEM is supported since 2.7.0; PKCS#8 key passwords use ssl.key.password, not PEM store passwords. - hostname (REASONED; docs): Hostname verification defaults on since 2.0.0; leave it enabled. - client-cert (REASONED; docs, source): SASL_SSL needs listener-prefixed ssl.client.auth; none is default, requested is optional, required enforces mTLS. - tls-protocols (REASONED; docs): Generated 4.3 reference defaults ssl.enabled.protocols to TLSv1.2,TLSv1.3. - scram (REASONED; docs): Use distinct SCRAM-SHA-512 identities only over TLS; KRaft credentials reside in the metadata log. - scram-files (REASONED; source): Bootstrap through authorized internal mTLS; protected --add-config-file values omit brackets and need Java escaping. - bootstrap-acl (REASONED; docs, kafka-design): StandardAuthorizer on every broker/controller denies by default; authorize exact node/admin principals before quorum startup. - legacy-authorizer (REASONED; legacy): Legacy 3.9 ZooKeeper uses kafka.security.authorizer.AclAuthorizer, not SimpleAclAuthorizer or the KRaft class. - topic-grants (REASONED; docs, source): Separate writer Write/Describe from reader Read/Describe and app-workers group Read; remove broader old grants. - topic-create (REASONED; docs): Disable auto.create.topics.enable; this does not revoke explicit Create rights. - producer-grants (REASONED; docs): --producer includes Create; use explicit writer grants and inspect wildcard, prefix and cluster ACLs. - transactions (REASONED; docs, source): Grant the specific transactional ID; 4.3.0 non-transactional initialization also accepts topic Write without IdempotentWrite. - quotas (REASONED; docs): Per-broker user byte quotas and request_percentage (one thread's time) need sizing; user/client overrides take precedence. - connections (REASONED; docs): Client listener connection and connection-creation limits are separate from traffic quotas and internal health. - mutation-quota (REASONED; docs): Conditional controller_mutation_rate bounds partition mutations, not throughput or permissions. - logging (REASONED; docs, source): 4.0+ uses Log4j2; requested denials log at INFO, allows at DEBUG, some evaluations at TRACE; collect authentication separately. - clients (REASONED; docs, source): Protect separate client properties; 4.3 CLI uses --command-config, including console producer/consumer. - mfa (REASONED; docs, source): Kafka has no second-factor dialogue; client mTLS is a machine possession control; human access needs an MFA layer. - redpanda (REASONED; redpanda): Protocol controls carry over, but Redpanda uses redpanda.yaml/rpk; check its own quotas, listeners and logging. - console (REASONED; redpanda): Console login needs configured OIDC/basic auth and enterprise licence; enabled features without valid licence show an expiry page. - verify-tls (REASONED; docs, openssl): Inventory role-specific listeners and verify hostname/chain; TLS errors differ from inconclusive DNS/local failures. - verify-internal (REASONED; docs, source): Valid internal certificate must permit Kafka access; missing/untrusted certificates need TLS-attributed rejection. - verify-controller (REASONED; docs, openssl): Controller valid/missing/untrusted certificate and allowed/disallowed-network pairs need logs; TLS alone proves no quorum health. - verify-password (REASONED; docs, source): Authenticated control succeeds; plaintext is refused and wrong password reports SaslAuthenticationException, not merely nonzero exit. - verify-acl (REASONED; docs, source): Require marker delivery and opposite-role operation denials; an empty consumer result is inconclusive. - verify-idempotence (REASONED; docs, source): Only the negative producer probe sets enable.idempotence=false, so IdempotentWrite authorization cannot mask the topic-write denial; test override only. - verify-default-deny (REASONED; docs, source): Authenticated low-user access to a fixture with no matching ACL must yield TopicAuthorizationException; inspect effective authorizer. - verify-create (REASONED; docs, source): Application creation must be denied while administrator creation succeeds; inspect old literal Create grants too. - verify-inspection (REASONED; docs, source): Inspect matching ACLs, user/client quotas and every broker's effective limits; listings do not establish enforcement. - verify-quota (REASONED; docs, source): Matched bounded producer runs must deliver records and discriminate nonzero throttle metrics; other limits need separate workloads. - verify-logs (REASONED; source, docs): Matched INFO/DEBUG requests need correlated denial/allow records; missing denial means collection failed. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "2304267a5a8a5b1b0ca602626ceb071263e2e7dfc105a4c0e1883f6c5e49f8ca", "components": { "default": { "name": "Kafka listener default", "basis": "4.3.1", "sources": { "s2ede3d269b73": "https://github.com/apache/kafka/blob/4.3.1/server/src/main/java/org/apache/kafka/network/SocketServerConfigs.java#L64", "saec05a4d2baa": "https://github.com/apache/kafka/blob/4.3.1/server/src/main/java/org/apache/kafka/network/SocketServerConfigs.java#L156" } }, "docs": { "name": "Kafka documentation", "basis": "4.3", "sources": { "s580ea02caa84": "https://kafka.apache.org/43/configuration/broker-configs/", "sd24b0c4f4311": "https://kafka.apache.org/43/security/authorization-and-acls/", "s7fba6211f3a6": "https://kafka.apache.org/43/operations/kraft/", "s30363626e954": "https://kafka.apache.org/43/getting-started/upgrade/", "s4afda8ace175": "https://kafka.apache.org/43/security/listener-configuration/", "sd9b5ccc53563": "https://kafka.apache.org/43/security/encryption-and-authentication-using-ssl/", "s91a5821170c7": "https://kafka.apache.org/43/security/authentication-using-sasl/", "sfba7bc13e360": "https://kafka.apache.org/43/operations/multi-tenancy/", "sbd4fe5c33a0a": "https://kafka.apache.org/43/configuration/producer-configs/", "sd350211459d4": "https://kafka.apache.org/43/operations/basic-kafka-operations/", "sc61f06541d5d": "https://kafka.apache.org/43/design/design/", "s1eed378849f6": "https://kafka.apache.org/43/javadoc/org/apache/kafka/server/authorizer/Action.html", "sbe7bcb0772dd": "https://kafka.apache.org/43/configuration/consumer-configs/", "s8e46d7864976": "https://kafka.apache.org/43/configuration/admin-configs/", "sc76c88a31320": "https://kafka.apache.org/43/security/security-overview/", "s4d4a39897877": "https://kafka.apache.org/43/operations/monitoring/" } }, "source": { "name": "Kafka source", "basis": "4.3.0", "sources": { "s6daae2bb5f15": "https://github.com/apache/kafka/blob/4.3.0/config/server.properties", "s91dba4e26cbb": "https://raw.githubusercontent.com/apache/kafka/4.3.0/clients/src/main/java/org/apache/kafka/common/network/ChannelBuilders.java", "sf7d512582608": "https://github.com/apache/kafka/blob/4.3.0/core/src/main/scala/kafka/admin/ConfigCommand.scala", "s9976d63fb7b3": "https://github.com/apache/kafka/blob/4.3.0/core/src/main/scala/kafka/server/KafkaApis.scala", "sa25d406ac801": "https://github.com/apache/kafka/blob/4.3.0/config/log4j2.yaml", "s2252053e1108": "https://github.com/apache/kafka/blob/4.3.0/metadata/src/main/java/org/apache/kafka/metadata/authorizer/StandardAuthorizerData.java", "s3ee12f40f1fb": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/ConsoleProducer.java", "s3d5d50816fb9": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/consumer/ConsoleConsumerOptions.java", "s12ac0c3869f7": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/BrokerApiVersionsCommand.java", "s50461fdc2c6e": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/consumer/ConsoleConsumer.java", "s0f1e046e30fd": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/TopicCommand.java", "se259d0150b9c": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/ProducerPerformance.java", "sf40d74b6484b": "https://github.com/apache/kafka/blob/4.3.0/tools/src/main/java/org/apache/kafka/tools/AclCommand.java" } }, "legacy": { "name": "Kafka legacy authorization", "basis": "3.9", "sources": { "se10d0fcd0e46": "https://kafka.apache.org/39/security/authorization-and-acls/" } }, "kafka-design": { "name": "Apache Kafka issue tracker and KIPs", "basis": "unknown", "sources": { "s2083e6339454": "https://issues.apache.org/jira/browse/KAFKA-15513", "sdeef4a8b642f": "https://cwiki.apache.org/confluence/spaces/KAFKA/pages/195728007/KIP-801+Implement+an+Authorizer+that+stores+metadata+in+__cluster_metadata" } }, "redpanda": { "name": "Redpanda documentation", "basis": "unknown", "sources": { "s4261500401cb": "https://docs.redpanda.com/streaming/current/manage/security/", "sec8155c4a049": "https://docs.redpanda.com/streaming/current/console/config/security/authentication/" } }, "openssl": { "name": "OpenSSL documentation", "basis": "3.5", "sources": { "s6338b4e2e47d": "https://docs.openssl.org/3.5/man1/openssl-s_client/", "sccd4f8550707": "https://docs.openssl.org/3.5/man1/openssl-passphrase-options/" } } }, "claims": { "plaintext": {"text": "Default listener is PLAINTEXT://:9092 as of 4.3.1.", "components": ["default"], "sources": ["default:s2ede3d269b73", "default:saec05a4d2baa"], "status": "REASONED"}, "internal-default": {"text": "security.inter.broker.protocol defaults to PLAINTEXT.", "components": ["docs"], "sources": ["docs:s580ea02caa84"], "status": "REASONED"}, "authorizer-default": {"text": "No authorizer by default; reachable clients can read, write, create and delete topics.", "components": ["docs"], "sources": ["docs:sd24b0c4f4311", "docs:s580ea02caa84"], "status": "REASONED"}, "roles": {"text": "Examples target 4.3; 4.0+ is KRaft-only; critical deployments should separate broker/controller roles.", "components": ["docs"], "sources": ["docs:s7fba6211f3a6", "docs:s30363626e954"], "status": "REASONED"}, "listeners": {"text": "CLIENT 9093 uses SASL_SSL; private INTERNAL 9095 and CONTROLLER 9094 use mutual TLS; remove plaintext.", "components": ["docs", "source"], "sources": ["docs:s4afda8ace175", "source:s6daae2bb5f15"], "status": "REASONED"}, "advertise": {"text": "Advertise broker CLIENT/INTERNAL only; broker-only nodes still need outbound controller security.", "components": ["docs"], "sources": ["docs:s4afda8ace175", "docs:s580ea02caa84"], "status": "REASONED"}, "listener-selection": {"text": "Use inter.broker.listener.name without security.inter.broker.protocol; lowercase listener prefixes must match.", "components": ["docs"], "sources": ["docs:s4afda8ace175", "docs:s580ea02caa84"], "status": "REASONED"}, "quorum": {"text": "Dynamic quorum bootstrap is not static-voter migration; reuse cluster ID and use the documented membership workflow.", "components": ["docs"], "sources": ["docs:s7fba6211f3a6"], "status": "REASONED"}, "controller-scram": {"text": "KAFKA-15513 lists 3.5.1/3.6.0 and no fix; it does not prove failure on every 4.3 deployment.", "components": ["kafka-design"], "sources": ["kafka-design:s2083e6339454"], "status": "REASONED"}, "tls-files": {"text": "Protect per-node keystores, truststores, keys and properties before writing secrets; require matching SANs.", "components": ["docs"], "sources": ["docs:sd9b5ccc53563"], "status": "REASONED"}, "pem": {"text": "PEM is supported since 2.7.0; PKCS#8 key passwords use ssl.key.password, not PEM store passwords.", "components": ["docs"], "sources": ["docs:sd9b5ccc53563"], "status": "REASONED"}, "hostname": {"text": "Hostname verification defaults on since 2.0.0; leave it enabled.", "components": ["docs"], "sources": ["docs:sd9b5ccc53563"], "status": "REASONED"}, "client-cert": {"text": "SASL_SSL needs listener-prefixed ssl.client.auth; none is default, requested is optional, required enforces mTLS.", "components": ["docs", "source"], "sources": ["docs:sd9b5ccc53563", "source:s91dba4e26cbb"], "status": "REASONED"}, "tls-protocols": {"text": "Generated 4.3 reference defaults ssl.enabled.protocols to TLSv1.2,TLSv1.3.", "components": ["docs"], "sources": ["docs:s580ea02caa84"], "status": "REASONED"}, "scram": {"text": "Use distinct SCRAM-SHA-512 identities only over TLS; KRaft credentials reside in the metadata log.", "components": ["docs"], "sources": ["docs:s91a5821170c7"], "status": "REASONED"}, "scram-files": {"text": "Bootstrap through authorized internal mTLS; protected --add-config-file values omit brackets and need Java escaping.", "components": ["source"], "sources": ["source:sf7d512582608"], "status": "REASONED"}, "bootstrap-acl": {"text": "StandardAuthorizer on every broker/controller denies by default; authorize exact node/admin principals before quorum startup.", "components": ["docs", "kafka-design"], "sources": ["docs:sd24b0c4f4311", "kafka-design:sdeef4a8b642f"], "status": "REASONED"}, "legacy-authorizer": {"text": "Legacy 3.9 ZooKeeper uses kafka.security.authorizer.AclAuthorizer, not SimpleAclAuthorizer or the KRaft class.", "components": ["legacy"], "sources": ["legacy:se10d0fcd0e46"], "status": "REASONED"}, "topic-grants": {"text": "Separate writer Write/Describe from reader Read/Describe and app-workers group Read; remove broader old grants.", "components": ["docs", "source"], "sources": ["docs:sd24b0c4f4311", "source:sf40d74b6484b"], "status": "REASONED"}, "topic-create": {"text": "Disable auto.create.topics.enable; this does not revoke explicit Create rights.", "components": ["docs"], "sources": ["docs:s580ea02caa84", "docs:sfba7bc13e360"], "status": "REASONED"}, "producer-grants": {"text": "--producer includes Create; use explicit writer grants and inspect wildcard, prefix and cluster ACLs.", "components": ["docs"], "sources": ["docs:sd24b0c4f4311"], "status": "REASONED"}, "transactions": {"text": "Grant the specific transactional ID; 4.3.0 non-transactional initialization also accepts topic Write without IdempotentWrite.", "components": ["docs", "source"], "sources": ["docs:sbd4fe5c33a0a", "source:s9976d63fb7b3"], "status": "REASONED"}, "quotas": {"text": "Per-broker user byte quotas and request_percentage (one thread's time) need sizing; user/client overrides take precedence.", "components": ["docs"], "sources": ["docs:sd350211459d4", "docs:sc61f06541d5d"], "status": "REASONED"}, "connections": {"text": "Client listener connection and connection-creation limits are separate from traffic quotas and internal health.", "components": ["docs"], "sources": ["docs:s580ea02caa84"], "status": "REASONED"}, "mutation-quota": {"text": "Conditional controller_mutation_rate bounds partition mutations, not throughput or permissions.", "components": ["docs"], "sources": ["docs:sfba7bc13e360"], "status": "REASONED"}, "logging": {"text": "4.0+ uses Log4j2; requested denials log at INFO, allows at DEBUG, some evaluations at TRACE; collect authentication separately.", "components": ["docs", "source"], "sources": ["docs:s30363626e954", "source:sa25d406ac801", "source:s2252053e1108", "docs:s1eed378849f6"], "status": "REASONED"}, "clients": {"text": "Protect separate client properties; 4.3 CLI uses --command-config, including console producer/consumer.", "components": ["docs", "source"], "sources": ["docs:sbd4fe5c33a0a", "docs:sbe7bcb0772dd", "docs:s8e46d7864976", "source:s3ee12f40f1fb", "source:s3d5d50816fb9"], "status": "REASONED"}, "mfa": {"text": "Kafka has no second-factor dialogue; client mTLS is a machine possession control; human access needs an MFA layer.", "components": ["docs", "source"], "sources": ["docs:sc76c88a31320", "docs:sd9b5ccc53563", "source:s91dba4e26cbb"], "status": "REASONED"}, "redpanda": {"text": "Protocol controls carry over, but Redpanda uses redpanda.yaml/rpk; check its own quotas, listeners and logging.", "components": ["redpanda"], "sources": ["redpanda:s4261500401cb"], "status": "REASONED"}, "console": {"text": "Console login needs configured OIDC/basic auth and enterprise licence; enabled features without valid licence show an expiry page.", "components": ["redpanda"], "sources": ["redpanda:sec8155c4a049"], "status": "REASONED"}, "verify-tls": {"text": "Inventory role-specific listeners and verify hostname/chain; TLS errors differ from inconclusive DNS/local failures.", "components": ["docs", "openssl"], "sources": ["docs:s4afda8ace175", "docs:sd9b5ccc53563", "openssl:s6338b4e2e47d"], "status": "REASONED", "verify": [1]}, "verify-internal": {"text": "Valid internal certificate must permit Kafka access; missing/untrusted certificates need TLS-attributed rejection.", "components": ["docs", "source"], "sources": ["docs:sd9b5ccc53563", "source:s12ac0c3869f7"], "status": "REASONED", "verify": [2]}, "verify-controller": {"text": "Controller valid/missing/untrusted certificate and allowed/disallowed-network pairs need logs; TLS alone proves no quorum health.", "components": ["docs", "openssl"], "sources": ["docs:s4afda8ace175", "docs:sd9b5ccc53563", "openssl:s6338b4e2e47d", "openssl:sccd4f8550707"], "status": "REASONED", "verify": [3]}, "verify-password": {"text": "Authenticated control succeeds; plaintext is refused and wrong password reports SaslAuthenticationException, not merely nonzero exit.", "components": ["docs", "source"], "sources": ["docs:s91a5821170c7", "source:s3d5d50816fb9", "source:s50461fdc2c6e", "source:s12ac0c3869f7"], "status": "REASONED", "verify": [4]}, "verify-acl": {"text": "Require marker delivery and opposite-role operation denials; an empty consumer result is inconclusive.", "components": ["docs", "source"], "sources": ["docs:sd24b0c4f4311", "source:s3ee12f40f1fb", "source:s3d5d50816fb9", "source:s50461fdc2c6e", "source:sf40d74b6484b"], "status": "REASONED", "verify": [5]}, "verify-idempotence": {"text": "Only the negative producer probe sets enable.idempotence=false, so IdempotentWrite authorization cannot mask the topic-write denial; test override only.", "components": ["docs", "source"], "sources": ["docs:sd24b0c4f4311", "docs:sbd4fe5c33a0a", "source:s3ee12f40f1fb"], "status": "REASONED", "verify": [5]}, "verify-default-deny": {"text": "Authenticated low-user access to a fixture with no matching ACL must yield TopicAuthorizationException; inspect effective authorizer.", "components": ["docs", "source"], "sources": ["docs:sd24b0c4f4311", "source:sf40d74b6484b"], "status": "REASONED", "verify": [6]}, "verify-create": {"text": "Application creation must be denied while administrator creation succeeds; inspect old literal Create grants too.", "components": ["docs", "source"], "sources": ["docs:sd24b0c4f4311", "docs:sd350211459d4", "source:s0f1e046e30fd"], "status": "REASONED", "verify": [7]}, "verify-inspection": {"text": "Inspect matching ACLs, user/client quotas and every broker's effective limits; listings do not establish enforcement.", "components": ["docs", "source"], "sources": ["docs:sd24b0c4f4311", "docs:s580ea02caa84", "docs:sd350211459d4", "source:sf7d512582608"], "status": "REASONED", "verify": [8]}, "verify-quota": {"text": "Matched bounded producer runs must deliver records and discriminate nonzero throttle metrics; other limits need separate workloads.", "components": ["docs", "source"], "sources": ["docs:sc61f06541d5d", "docs:s4d4a39897877", "source:se259d0150b9c"], "status": "REASONED", "verify": [9]}, "verify-logs": {"text": "Matched INFO/DEBUG requests need correlated denial/allow records; missing denial means collection failed.", "components": ["source", "docs"], "sources": ["source:sa25d406ac801", "source:s2252053e1108", "docs:s1eed378849f6"], "status": "REASONED", "verify": [10]} } } ```` ====================================================================== ==> clickhouse.md ====================================================================== # ClickHouse: listen address, the default user, and TLS ports ClickHouse is a database server built for analytical queries over very large tables. This guide helps your AI assistant configure it securely, including a narrow listen address, a password on the default user with real accounts beside it, query limits and quotas, and TLS ports with plaintext ports off. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/clickhouse.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against ClickHouse documentation (no recorded version); 5 further sources are pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 53 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. With no `listen_host` configured, the pinned server code falls back to `::1` and `127.0.0.1`; the pinned base `config.xml` comments out all its listener examples. A separately pinned upstream `users.xml` defines `default` with an empty password, `::/0` networks and `access_management=1`. Together, a wildcard listener and this user configuration can expose a passwordless account on enabled plaintext interfaces. The official image instead copies a config.d override with `::` and `0.0.0.0` listeners. When the container gets no arguments or its first argument starts with `--`, its entrypoint writes a loopback-only `default` networks override only when `CLICKHOUSE_SKIP_USER_SETUP` is not `1`, none of the user-creation inputs listed next requests user creation, and the processed `default` user matches the original. The user-creation inputs are a non-default `CLICKHOUSE_USER`, a nonempty password (an existing `CLICKHOUSE_PASSWORD_FILE` replaces `CLICKHOUSE_PASSWORD`), and `CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT` with a nonempty value other than `0`. With another command, the entrypoint runs `exec "$@"` without user setup. If those conditions differ, inspect the effective listener and user configuration; the entrypoint can create a user with `::/0` networks or leave a customized `default` untouched. For other packages, images, or versions, inspect the effective merged configuration. Complete account and TLS setup before allowing remote access. The full administrator bootstrap needs the additional capabilities in step 2. [Server fallback](https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/Server.cpp#L644-L652), [base configuration](https://raw.githubusercontent.com/ClickHouse/ClickHouse/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/config.xml), [separately pinned user configuration](https://raw.githubusercontent.com/ClickHouse/ClickHouse/b40982f9416bb4bc2d423d7e75ec384e79494b99/programs/server/users.xml), [image override](https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/docker_related_config.xml#L1-L5), [image copy](https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/Dockerfile.ubuntu#L160-L168), [entrypoint](https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/entrypoint.sh#L45-L270). ## 1. Keep `listen_host` narrow The pinned base `config.xml` comments out every `listen_host` example; when no listener is configured, `getListenHosts` supplies `::1` and `127.0.0.1`. The official image adds wildcard listeners through its config.d override, so remove those inherited entries before using this narrow example. [Base listener examples](https://raw.githubusercontent.com/ClickHouse/ClickHouse/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/config.xml), [server fallback](https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/Server.cpp#L644-L652), [image override](https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/docker_related_config.xml#L1-L5). For remote clients, name the one private address rather than `::`. Keep only loopback while preparing authentication and TLS; add the private address afterwards. The following shows the relevant children of the existing `` root, not a replacement for the entire base file: ```xml 127.0.0.1 REPLACE_WITH_PRIVATE_IP ``` Use a `config.d` override for server settings and a `users.d` override for user settings, as named below. Each server or user XML document retains the `` root. If editing base `config.xml` or `users.xml` instead, modify the existing children in place rather than replacing the whole file. Remove any inherited wildcard listener when adapting an existing configuration. Inspect the merged configuration rather than assuming an additional narrow entry cancels a broad one. [Listener settings](https://clickhouse.com/docs/reference/settings/server-settings/settings/listen#listen_host), [configuration merging](https://clickhouse.com/docs/concepts/features/configuration/server-config/configuration-files). Inventory the listeners actually enabled. Documented port numbers include HTTP 8123, native TCP 9000, MySQL emulation 9004, PostgreSQL emulation 9005, and interserver HTTP 9009; listing a port does not mean it is active. PostgreSQL emulation can also use TLS when configured. Replica communication follows `interserver_listen_host`, which defaults to `listen_host` but can be set separately. Inspect every effective listener before writing firewall rules per [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) or [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md). [Network ports](https://clickhouse.com/docs/concepts/features/security/network-ports), [interserver listener setting](https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver#interserver_listen_host). ## 2. Put a password on `default`, and create real users Replace the empty password in the separately pinned upstream `users.xml` with the SHA-256 hash of a long, separately generated random password, and restrict the bootstrap account to loopback. Install `/etc/clickhouse-server/users.d/default-hardening.xml`: ```xml REPLACE_WITH_THE_SHA256_HEX_OF_A_LONG_RANDOM_PASSWORD ::1 127.0.0.1 default default 1 ``` This example replaces the shipped single `` authentication method. Inspect existing customizations before applying it. `password_double_sha1_hex` exists for MySQL-protocol clients; plain `` stores the secret in clear. SQL users also support `bcrypt_password`, with the documented 72-character password limit. Keep passwords out of command-line arguments and shell history. [User settings](https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users), [SQL authentication methods](https://clickhouse.com/docs/reference/statements/create/user#identification). A shared administrator account makes an application compromise an administrative compromise. Keep the password and loopback restrictions on `default` while creating a separate SQL administrator. `access_management` enables SQL access management, but current documentation requires three capabilities for a complete administrator able to delegate `ALL`. Install this temporary `/etc/clickhouse-server/users.d/bootstrap-admin.xml` override: ```xml 1 1 1 ``` Ensure the effective SQL access storage is persistent. `access_control_path` specifies that directory when used; if `user_directories` is configured, its storage definitions take precedence and `access_control_path` is not used. Preserve the effective persistent storage across restarts and container replacement. [Administrator bootstrap](https://clickhouse.com/docs/concepts/features/security/access-rights#enabling-sql-driven-access-control-and-account-management), [access storage](https://clickhouse.com/docs/reference/settings/server-settings/settings/user#user_directories). Apply the configuration before bootstrapping. Prepare step 4's TLS listener and client trust configuration while the server remains on loopback, then use the guarded interactive connection in Verify as `default`. Enter its password at the prompt. Create the replacement administrator with a separately generated password hash: ```sql CREATE USER sql_admin IDENTIFIED WITH sha256_hash BY 'REPLACE_WITH_ADMIN_SHA256_HEX' HOST IP '127.0.0.1', IP '::1', IP '10.20.30.10/32'; GRANT ALL ON *.* TO sql_admin WITH GRANT OPTION; ``` Replace `10.20.30.10/32` with the actual administrator workstation address. Both IPv4 and IPv6 loopback permit local administration and the matched retirement check below. The global grant belongs only to this dedicated administrator. `HOST IP` restricts connection origins; it does not require encryption. Enforce TLS through step 4's secure listeners and plaintext-listener removal. The current `CREATE USER` grammar does not document a `REQUIRE SSL` clause. [CREATE USER](https://clickhouse.com/docs/reference/statements/create/user). After authenticating successfully as `sql_admin`, create a separate reader for an existing application table: ```sql CREATE ROLE app_read; GRANT SELECT ON appdb.events TO app_read; CREATE USER app_reader IDENTIFIED WITH sha256_hash BY 'REPLACE_WITH_READER_SHA256_HEX' HOST IP '10.20.40.0/24'; GRANT app_read TO app_reader; SET DEFAULT ROLE app_read TO app_reader; ``` Use the actual application subnet and table. Keep ingestion permissions in a separate account. Roles combine with direct grants and other enabled roles, so adding a narrow role does not remove existing broad privileges. Review inherited grants as well as direct grants. See [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). [CREATE ROLE](https://clickhouse.com/docs/reference/statements/create/role), [SET DEFAULT ROLE](https://clickhouse.com/docs/reference/statements/set-role#set-default-role). After testing the replacement administrator and migrating every dependency on `default`, including any inter-node use, remove `bootstrap-admin.xml` and install `/etc/clickhouse-server/users.d/disable-default.xml`: ```xml ``` This removes the inherited XML definition. Inspect the merged configuration, ensure later fragments do not restore it, and perform the fresh-login retirement checks in Verify. Keep the password-protected, loopback-only account until the handoff is demonstrated. [Configuration merging](https://clickhouse.com/docs/concepts/features/configuration/server-config/configuration-files). Version note: at the time of writing, current OSS documentation explicitly requires `named_collection_control` and `show_named_collections_secrets` alongside `access_management` for the complete-administrator bootstrap. Do not assume an older deployment's effective permissions match the current shipped configuration. ## 3. Lock query limits and add a quota An authenticated reader can still exhaust database resources. Assign limits directly to the account, so disabling its active role does not remove the profile. These example budgets are starting values to size for the deployment: ```sql CREATE SETTINGS PROFILE app_reader_limits SETTINGS readonly = 1 READONLY, allow_ddl = 0 READONLY, max_memory_usage = 1073741824 READONLY, max_memory_usage_for_user = 2147483648 READONLY, max_execution_time = 30 READONLY, timeout_before_checking_execution_speed = 0 READONLY TO app_reader; ``` `READONLY` after a setting locks that setting; `readonly = 1` restricts query classes. Review other assigned profiles for conflicting values or constraints. [CREATE SETTINGS PROFILE](https://clickhouse.com/docs/reference/statements/create/settings-profile), [constraints on settings](https://clickhouse.com/docs/concepts/features/configuration/settings/constraints-on-settings). The memory limits cover one query and one user's queries, respectively, on each server. They are not a complete process-memory bound, and some allocations are not fully tracked. Setting the execution-speed checking delay to zero makes the timeout use elapsed time, but cancellation occurs at supported checkpoints and may exceed 30 seconds. [Memory settings](https://clickhouse.com/docs/reference/settings/session-settings/max-memory-usage), [execution-time settings](https://clickhouse.com/docs/reference/settings/session-settings/max-execution#max_execution_time). Add a separate cumulative budget: ```sql CREATE QUOTA app_reader_hourly KEYED BY user_name FOR INTERVAL 1 HOUR MAX queries = 1000, execution_time = 300 TO app_reader; ``` This quota counts usage per username. It does not limit simultaneous queries. For distributed processing, accumulated usage is held on the receiving server; it is not a shared cluster-wide counter. Changing the receiving server gives another counter, and restarting a server resets quotas. Some system-table reads are exempt from quota accounting, so use an ordinary application table for the quota discriminator in Verify. [CREATE QUOTA](https://clickhouse.com/docs/reference/statements/create/quota), [quota accounting and exceptions](https://clickhouse.com/docs/concepts/features/configuration/server-config/quotas). Keep the table-scoped grants from step 2. Neither `readonly` nor `allow_ddl` is a complete authorization policy. Current documentation describes exceptions involving backups, temporary tables, named collections, and access-management statements. Do not give this reader those privileges. An ingestion account needs its own profile without `readonly = 1`. [Permissions for queries](https://clickhouse.com/docs/concepts/features/configuration/settings/permissions-for-queries). Version note: use the documented `READONLY` constraint syntax. Do not substitute `readonly = 2`: it permits additional operations, including settings changes and some operations that write data. ## 4. TLS listeners, plaintext ports off Get a certificate per [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). Install `/etc/clickhouse-server/config.d/hardening.xml`, adapting the certificate paths. This baseline disables interserver listeners; replicated deployments must apply the replica variant below before restarting: ```xml 8443 9440 /etc/clickhouse-server/certs/server.crt /etc/clickhouse-server/certs/server.key sslv2,sslv3 true ``` Do not leave the shipped plaintext `interserver_http_port` 9009 inherited. For replicas, replace the two interserver removal entries in the same `hardening.xml` with the corresponding settings below, add the other children to its existing `` root, and merge `` into its existing `` block. Retain `` above for the HTTPS certificate and key. Do not install these alternatives side by side. [Shipped replication listener](https://raw.githubusercontent.com/ClickHouse/ClickHouse/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/config.xml), [interserver HTTPS settings](https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-https). ```xml 9010 REPLACE_WITH_PRIVATE_IP REPLACE_WITH_REPLICA_CERTIFICATE_HOSTNAME replication REPLACE_WITH_LONG_RANDOM_REPLICATION_SECRET false /etc/clickhouse-server/certs/ca.crt strict true RejectCertificateHandler ``` Use each replica's private address and certificate-covered hostname; that hostname must resolve to its private address from its peers. Remove inherited wildcard interserver bindings, inspect the merged configuration, and allow 9010 only from replica addresses in the firewall. Install the trusted CA on every server for outbound HTTPS verification. A native client's separate configuration does not configure the server's outbound connections. [Interserver bind](https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver#interserver_listen_host), [HTTPS hostname](https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-https#interserver_https_host), [OpenSSL verification](https://clickhouse.com/docs/reference/settings/server-settings/settings/other#openSSL). Require matching `interserver_http_credentials` on every replica, with a separately generated secret and `allow_empty` false. Omitting the credentials section disables replication authentication; setting `allow_empty` true admits unauthenticated peers. These credentials apply to both HTTP and HTTPS and are independent of SQL-user credentials; TLS alone does not supply them. Provision the secret through a secure editor or secret-management mechanism, protect the configuration and its preprocessed copies from other users, and keep it out of argv, shell history, and source control. Coordinate the rollout across replicas before enabling access. [Replication authentication](https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-http#interserver_http_credentials). This secures the replication data listener. Distributed-query and Keeper connections need their own TLS configuration; complete the vendor's [cluster TLS procedure](https://clickhouse.com/docs/concepts/features/security/tls/configuring-tls) for those enabled paths. Remove unused emulation listeners with `/etc/clickhouse-server/config.d/disable-emulation.xml`: ```xml ``` PostgreSQL emulation on 9005 can use TLS; the removal above closes an unused interface rather than assuming every connection on that port is plaintext. Inventory other enabled listeners separately. [TLS setup](https://clickhouse.com/docs/concepts/features/security/tls/configuring-tls), [network ports](https://clickhouse.com/docs/concepts/features/security/network-ports). When editing the base `config.xml` directly, remove or comment out the active plaintext `http_port`, `tcp_port`, and `interserver_http_port` elements, checking that no override re-enables them. Commenting out an element in an override does not remove an inherited setting. The explicit `remove` attributes above do. Inspect the effective merged configuration and apply the listener changes before opening the private address to clients. [Configuration files](https://clickhouse.com/docs/concepts/features/configuration/server-config/configuration-files). On each native client machine, install the trusted CA at `/etc/clickhouse-client/ca.crt` and merge this into `~/.config/clickhouse/config.xml`: ```xml /etc/clickhouse-client/ca.crt strict true RejectCertificateHandler ``` The Verify commands explicitly select this file so a different default configuration cannot silently take precedence. Connect using a hostname covered by the certificate. For local bootstrap and retirement checks, provision a certificate name that resolves to loopback on the server. At the time of writing, the OpenSSL reference lists `extendedVerification` as false by default; enable it explicitly to check the peer name. [Client configuration](https://clickhouse.com/docs/concepts/features/interfaces/client), [OpenSSL verification](https://clickhouse.com/docs/reference/settings/server-settings/settings/other#openSSL). Users can also authenticate with a client certificate using `IDENTIFIED WITH ssl_certificate CN 'name'`, a possession factor for machine clients; see [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md). [Certificate authentication](https://clickhouse.com/docs/reference/statements/create/user#identification). MFA: ClickHouse 26.2 introduced native TOTP. Current documentation supports `time_based_one_time_password` for users defined in XML alongside password authentication, with `secret`, `period`, `digits`, and `algorithm`; SQL-driven access control does not support it. Configure it for supported human accounts and route unsupported human paths, including the host and dashboards, behind an MFA-enforcing layer per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Keep machine authentication separate. Each TOTP code is accepted at most once, so clients that authenticate per request need a fresh code for each authentication; do not reuse a code across a probe loop. [TOTP configuration](https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users#totp-authentication-configuration), [26.2 release](https://clickhouse.com/blog/clickhouse-release-26-02). ## 5. Restrict external sources and executable discovery SQL that reaches external sources can read local data or use the database's network access. Withhold the blanket `SOURCES` privilege and every individual source privilege from ordinary application accounts, including all source-specific `READ` and `WRITE` grants when enabled. Remove any such access and grant options from direct grants and inherited roles; withholding only the blanket grant does not cancel individual or inherited grants. This covers every source type in the deployed version, including database connectors, message queues, object stores, and local files. Review existing externally backed tables and dictionaries. Separate READ/WRITE source grants require ClickHouse 25.7 or later and `access_control_improvements.enable_read_write_grants`; filtered source grants require 25.8 or later with the same switch. Otherwise, use the documented legacy source privileges. [Source privileges](https://clickhouse.com/docs/reference/statements/grant#sources), [upstream source privilege registry](https://raw.githubusercontent.com/ClickHouse/ClickHouse/37c6c8c9bfc1de323e9a27711c5649352364bcba/src/Access/Common/AccessType.h). ClickHouse can fetch HTTPS data through `url()` and HTTP dictionaries, read local files through `file()` relative to `user_files_path`, and reach other servers through `remote()`. `file()` is a local-file interface, not an HTTP fetcher. [URL function](https://clickhouse.com/docs/reference/functions/table-functions/url), [file function](https://clickhouse.com/docs/reference/functions/table-functions/file), [remote function](https://clickhouse.com/docs/reference/functions/table-functions/remote), [HTTP dictionary sources](https://clickhouse.com/docs/reference/statements/create/dictionary/sources/http). For a deployment that requires one approved HTTPS source, install `/etc/clickhouse-server/config.d/external-sources.xml`: ```xml data.example.com:443 true ``` Omitting `remote_url_allow_hosts` allows all hosts for the URL interfaces it covers, as the [shipped configuration](https://raw.githubusercontent.com/ClickHouse/ClickHouse/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/config.xml) documents. Replace the hostname and use an explicit `:443` in the permitted source URL. A hostname-only entry permits every port on that hostname. Matching occurs before DNS resolution and on redirects. This is not a universal restriction on every external protocol, nor does a host-and-port entry itself require HTTPS. Retain network egress restrictions on metadata endpoints and internal addresses per [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md). [URL host allow-list](https://clickhouse.com/docs/reference/settings/server-settings/settings/remote#remote_url_allow_hosts). For accounts that must create tables, enable engine-grant enforcement before relying on an engine allow-list. Grant only the required engine, for example to a separately provisioned schema-management role: ```sql GRANT TABLE ENGINE ON MergeTree TO schema_manager; ``` This supplements the role's separately scoped `CREATE TABLE` privilege. It does not grant access to existing tables, restrict every table function, or remove existing grants. At the time of writing, engine-grant enforcement is documented for self-managed OSS and is disabled by default. Roll it out with the grants required by legitimate schema-management jobs. [Engine privileges](https://clickhouse.com/docs/reference/statements/grant#table-engine), [access-control settings](https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_improvements). Keep dictionary creation and dictionary use separate. Ordinary readers should not receive dictionary-management privileges; grant `dictGet` only on an approved dictionary when required. [Dictionary privileges](https://clickhouse.com/docs/reference/statements/grant#dictget). Executable functions need an additional filesystem boundary. Install `/etc/clickhouse-server/config.d/executable-paths.xml`: ```xml /var/lib/clickhouse/approved_scripts/ /etc/clickhouse-server/approved-functions/*.xml ``` Provision these administrator-controlled directories before applying the configuration. Give the ClickHouse process the access needed to read configuration and execute approved scripts, while preventing application identities from modifying their contents or parent directories. Leave them empty when executable functionality is unused. Review already installed executable tables, dictionaries, and UDFs separately; changing discovery paths is not a SQL sandbox. [Script directory](https://clickhouse.com/docs/reference/settings/server-settings/settings/user#user_scripts_path), [executable-UDF configuration path](https://clickhouse.com/docs/reference/settings/server-settings/settings/user-defined#user_defined_executable_functions_config), [executable table function](https://clickhouse.com/docs/reference/functions/table-functions/executable). For an approved executable UDF, keep this fragment inside its existing `` definition: ```xml 1 ``` It resolves the configured command from the script directory. Setting it to zero invokes a shell. Direct execution still runs the approved program and does not sandbox that program. There is no `allow_executable_user_defined_functions` setting prescribed by this guide. [Executable UDF configuration](https://clickhouse.com/docs/reference/functions/regular-functions/udf). ## 6. Configure and protect query and session logs Authentication without retained evidence makes misuse harder to investigate. Configure query and session logging on each server and choose a retention period. Install `/etc/clickhouse-server/config.d/security-logs.xml`: ```xml query_log
toYYYYMM(event_date) event_date + INTERVAL 30 DAY 7500
session_log
toYYYYMM(event_date) event_date + INTERVAL 30 DAY 7500
``` Thirty days is an explicit local policy, not an OSS retention default. Current documentation marks the system-log `database` option deprecated and places these tables in `system`, so it is omitted here. Review existing storage customization before replacing either subtree: a custom `engine` definition conflicts with separate `partition_by` and `ttl` settings. [System-log configuration](https://clickhouse.com/docs/reference/system-tables/overview), [query-log configuration](https://clickhouse.com/docs/reference/settings/server-settings/settings/query#query_log). Prevent the application account from disabling query logging or reducing it to a sample: ```sql CREATE SETTINGS PROFILE app_reader_logging SETTINGS log_queries = 1 READONLY, log_queries_probability = 1 READONLY, log_queries_min_query_duration_ms = 0 READONLY, log_queries_min_type = 'QUERY_START' READONLY TO app_reader; ``` Apply an equivalent profile to each account whose queries must be recorded, and review overlapping profiles. [Query logging settings](https://clickhouse.com/docs/reference/settings/session-settings/log-queries). As an administrator, inspect query activity and authentication failures: ```sql -- REASONED: no ClickHouse server/client binary or container runtime is available here. SYSTEM FLUSH LOGS; SELECT event_time, type, user, address, query_id, query, exception FROM system.query_log ORDER BY event_time DESC LIMIT 50; SELECT event_time, type, user, client_address, failure_reason FROM system.session_log WHERE type = 'LoginFailure' ORDER BY event_time DESC LIMIT 50; ``` The query log records query activity and exceptions, not result sets. The session log records login success, login failure, and logout events. Check every serving node and every enabled authentication interface. [Query log](https://clickhouse.com/docs/reference/system-tables/query_log), [session log](https://clickhouse.com/docs/reference/system-tables/session_log), [SYSTEM FLUSH LOGS](https://clickhouse.com/docs/reference/statements/system#system-flush-logs). Ensure `access_control_improvements.select_from_system_db_requires_grant` is enabled, and keep log-table grants out of application roles. Export security records to independently controlled storage. Query text can contain sensitive values; consider deployment-specific `query_masking_rules`, configured on every participating server. [System-table access control](https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_improvements), [query masking](https://clickhouse.com/docs/reference/settings/server-settings/settings/query#query_masking_rules). Version note: log schemas can change during upgrades, causing older log tables to be renamed. Verify retention and collection across those tables. ClickHouse's database-audit documentation uses query and session logs; it does not establish a separate immutable OSS `system.audit_log`. The separately documented console audit log concerns ClickHouse Cloud. Do not import Cloud retention defaults into this guide. [System-log upgrades](https://clickhouse.com/docs/reference/system-tables/overview), [database audit logging](https://clickhouse.com/docs/products/cloud/guides/security/audit-logging/database-audit-log), [console audit logging](https://clickhouse.com/docs/products/cloud/guides/security/audit-logging/console-audit-log). ## Verify **REASONED: scope is every service-level check in this section; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so each step below rests on the guide's cited Sources entries named at that step. The only parts that ran, therefore, are the local syntax, parsing, guard and stub checks, the curl version check and the refused loopback-listener attempt recorded at each step and in the closing paragraph.** Service-level checks below are **REASONED, not demonstrated**. The authoring environment has no ClickHouse server/client binary or container runtime, and no authorized live test deployment was supplied. SQL execution, configuration semantics, and exposed-versus-fixed behaviour remain unobserved. Run the exposed fixtures only in an isolated test deployment. **REASONED: guard behaviour of the three shell blocks in this section; the October 2026 checks ran under bash -u with stub ss, curl and clickhouse-client first on PATH. The 32 rejection cases in those checks (unsubstituted, embedded and empty placeholders, angle brackets, `example.com`, a missing marker, a wrong value count, a missing `set --` line, a missing header file and four bad usernames) each printed the guard message and exited 2 with no stub call, and a valid value in each block reached its stub. The command lines below each guard also ran separately on review, with stub ss, curl, clickhouse-client and sudo, a scratch HOME and a synthetic header file, in plain Bash and bash -u, with no positional arguments and with the block's placeholder argument (plus app_reader for the interactive connection). With no arguments, plain Bash printed the inventory label with an empty value and called stub ss. The authentication fragment, in plain Bash with no arguments, printed its six request labels, called stub curl with http://:8123/?query=SELECT%201 and three https://:8443/?query=SELECT%201 URLs, called stub clickhouse-client twice with an empty host and printed native_exit=0 twice. The interactive fragment, in plain Bash with no arguments, called stub clickhouse-client with empty host and user. With no arguments, bash -u stopped each fragment on an unbound $1 with exit 1 and no stub call; the authentication fragment first printed [plaintext HTTP 8123]. With placeholder arguments, both modes printed the placeholder inventory label and called stub ss, made the authentication fragment's four curl and two client calls with REPLACE_WITH_CLICKHOUSE_HOST and printed native_exit=0 twice, and called the interactive client stub with that host and app_reader. All stub-reaching fragments exited 0; those exits are stub results, not service results. These runs covered the three blocks in an earlier form, which ended with its last command's status; the current forms, which set their exit status from their results, are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** Paste each whole shell block into Bash, substituting values inside the quotes. The guards reject unresolved placeholders, angle brackets, `example.com`, and empty input. They do not protect a fragment pasted from below the guard. **REASONED: following block; covers that whole listener-inventory block, its guard and its listener listing, on every replica; the inventory requires a ClickHouse deployment and access to its network namespace, and there is no ClickHouse deployment, server binary or container runtime in the authoring environment, so neither fixture was inventoried. The exposed fixture lists the plaintext listeners and the fixed deployment lists 8443 and 9440 on the intended addresses with no 9009, per the guide's cited "Network ports and PostgreSQL TLS support", "Configuring TLS" and "Replication HTTPS port and advertised hostname" sources. The local checks showed that `bash -n` and ShellCheck 0.11.0 reported nothing on the block, and that under bash -u against a stub ss the block's guard refused all 9 bad-input cases with exit 2 and ran the stub once for a valid label. These checks covered an earlier form of the block, which ended with its last command's status; the current form, which exits 2 when `ss` is not installed and otherwise sets its exit status from its classified results, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** The exposed fixture has the plaintext listeners enabled. The fixed deployment should show 8443 and 9440 on the intended addresses, no removed listeners (including plaintext replication 9009), and only other listeners deliberately retained, such as replication HTTPS 9010 on its explicit private address. A node without replication should expose neither 9009 nor 9010. Repeat the inventory on every replica; listener presence does not demonstrate replication authentication. Inspect host/container publication and firewalls separately. `ss` observes the current network namespace; it proves neither remote reachability nor authentication. [Listener configuration](https://clickhouse.com/docs/concepts/features/security/tls/configuring-tls), [port inventory](https://clickhouse.com/docs/concepts/features/security/network-ports). Run this block in the server's actual namespace; the label records your identification and does not switch namespaces: ```bash # No ClickHouse server or container runtime is available here. # Run in the server's network namespace; substitute its name inside the quotes. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SERVER_NETWORK_NAMESPACE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly one namespace label; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the server namespace label; not probing" exit 2 ;; *) set +e # errexit off here, so each result below is recorded instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } printf 'Inventory of the current namespace, identified by you as %s:\n' "$1" set -- "$1" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" 0 # the label, the listener table, ss's exit status, then the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0; a missing ss has already ended the block with 2 above printf '%s' "$2" [ "$3" = 0 ] || { echo "ss -tlnp exit $3: the inventory failed; inconclusive"; set -- "$1" "$2" "$3" 3; } if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:(8123|9000|9009)$/ { found = 1 } END { exit !found }'; then echo "FINDING: a listener on 8123, 9000 or 9009, a port this guide removes, is in the table above"; set -- "$1" "$2" "$3" 1 else case "$?" in 1) ;; *) echo "awk could not read the listener table; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac fi if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:8443$/ { https = 1 } NR > 1 && $4 ~ /:9440$/ { native = 1 } END { exit !(https && native) }'; then :; else case "$?" in 1) echo "no 8443 or no 9440 listener in the table above, so the TLS listeners this guide keeps were not seen; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "awk could not read the listener table; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac fi echo "block exit $4; the listener addresses, 9010 and any other listener you retained are not classified, so judge them yourself, and repeat the inventory on every replica" exit "$4" ;; esac ) ``` **REASONED: following block; transport and authentication require the missing server/client runtime and reachable TLS endpoints.** For the exposed authentication fixture, provision the same named account, `app_reader`, with `IDENTIFIED WITH no_password`, the same allowed client origin, and sufficient access to execute `SELECT 1`. This is an isolated alternative to the password-authenticated account in step 2. A stock passwordless `default` account does not establish anything about `app_reader`. Keep the host, certificate, interface, and request constant between fixture states. [Authentication methods](https://clickhouse.com/docs/reference/statements/create/user#identification). **REASONED: owner-only header file provisioning and the argv and tracing claims for the correct-password request; no ClickHouse server, client binary or reachable HTTPS endpoint in the authoring environment, so no server accepted these headers, per the guide's cited "HTTP interface and authentication" and "curl empty-valued headers and header input from stdin" sources. The local checks, with a synthetic owner-only header file under a scratch HOME and a stub curl, showed that the correct-password request delivered the fixture password only on curl's stdin and never in any stub argv, that a bash -x run of the whole block traced no password, and that without the file the block printed its provisioning message and exited 2 with no stub call. The owner and root readability statements were not tested. These checks covered an earlier form of the block, which ended with its last command's status; the current form, which sets its exit status from its classified results, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** For the correct HTTP request, provision `~/.config/clickhouse/app-reader.headers` through a secure editor or secret-management mechanism, readable only by its owner, with two lines: `X-ClickHouse-User: app_reader` and `X-ClickHouse-Key: ` followed by the actual password. Use a nonempty, single-line password without control characters, different from the deliberate wrong-password value below. Do not put the secret in a shell command. The correct request reads both headers from that protected file on stdin, keeping the password out of curl's argv and shell history; no shell command or variable carries it, so `set -x` tracing does not record it either. Stdin closes the argv channel only: the account owner and root can still read the password in that file and in curl's memory. Install the CA and native client configuration from step 4 first. For a certificate trusted by curl's system CA store, omit each `--cacert /etc/clickhouse-client/ca.crt` pair from the HTTP probes; retain certificate verification. **REASONED: following block; covers that whole transport and authentication block: its plaintext 8123 probe, its empty, wrong and correct password HTTPS requests on 8443 and its wrong and correct native TLS logins on 9440; no ClickHouse server or client binary, container runtime or reachable TLS endpoint in the authoring environment, and no live authentication request ran. The review found that curl --version reported curl 8.18.0, meeting the stated 7.75.0 minimum, but that creating a loopback listener's socket raised PermissionError: [Errno 1] Operation not permitted in the execution sandbox. The authentication-disabled fixture returns 1 for every password over HTTPS. The fixed state over HTTPS rejects empty and wrong passwords and returns 1 for the correct one. The fixed state over native TLS rejects the wrong password with a nonzero exit and returns 1 for the correct one. The HTTPS and native TLS outcomes rest on the guide's cited "HTTP interface and authentication", "Native client options and configuration" and "CREATE USER and authentication methods" sources. No test deployment was available either, so neither fixture state was observed. No reachable ClickHouse endpoint, plaintext or TLS, was available, so no 8123 status or `http=000` result was observed. Any HTTP status on 8123 shows the exposed state, and only `http=000` together with the inventory and successful TLS positive controls supports the fixed state, per the guide's cited "Network ports and PostgreSQL TLS support" and "HTTP interface and authentication" sources. Because that loopback listener could not be created, no loopback request ran, and the header the empty-password request transmits remains unobserved. The semicolon form sends the header with an empty value and a trailing colon omits it, per the guide's cited "curl empty-valued headers and header input from stdin" source. The local checks showed that a stub curl received the header line `X-ClickHouse-Key;` on stdin exactly as written. The local checks showed that `bash -n` and ShellCheck 0.11.0 reported nothing on the block, and that under bash -u with stub curl and clickhouse-client the block's guard refused all 10 bad-input cases with exit 2 and no stub call, while a valid host made four curl calls and two clickhouse-client calls with the password on stdin only. These checks covered an earlier form of the block, which ended with its last command's status; the current form, which sets its exit status from its classified results, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** Clients use native TLS on 9440 or HTTPS on 8443. HTTP supports Basic authentication and the `X-ClickHouse-User`/`X-ClickHouse-Key` headers; avoid `user` and `password` URL parameters because intermediaries can log them. The block requires curl 7.75.0 or later for its diagnostic write-out fields. [HTTP authentication](https://clickhouse.com/docs/concepts/features/interfaces/http), [native client](https://clickhouse.com/docs/concepts/features/interfaces/client). Any received HTTP status on 8123, including an error status or a status followed by a transfer error, proves that the plaintext port answered. `http=000` is only a transport failure; combine it with the server inventory and successful TLS positive controls before concluding that the listener was removed. The empty-password request uses curl's `X-ClickHouse-Key;` form to transmit an empty-valued header; a trailing colon without a value would omit the header. [curl header syntax](https://curl.se/docs/manpage.html#-H). In the deliberately authentication-disabled fixture, empty, wrong, and supplied passwords should all allow the HTTPS `SELECT 1` result. In the fixed state, empty and wrong passwords must produce ClickHouse authentication errors, while the correct password returns `1`. For native TLS, the fixed state must reject the wrong password with an authentication error and nonzero exit, then return `1` with exit zero for the correct password. A DNS, proxy, certificate, or connection error is inconclusive. ```bash # No ClickHouse server/client binary or container runtime is available here. # Run from the allowed application network. Substitute the host inside the quotes. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLICKHOUSE_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly one host; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute your host inside the quotes; not probing" exit 2 ;; *) set +e [ -r "$HOME/.config/clickhouse/app-reader.headers" ] || { echo "provision the protected credential header file first"; exit 2; } set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 echo '[plaintext HTTP 8123]' set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "http://$1:8123/?query=SELECT%201"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its output printf '%s' "$4" case "$4" in *'http=000 '*) ;; # no HTTP answer: only the inventory and the TLS positive controls below can support the fixed state *http=*) echo "FINDING: 8123 returned an HTTP status, so the plaintext port answered"; set -- "$1" 1 "$3" "$4" ;; *) echo "curl exit $3 printed no status for 8123; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; esac echo '[HTTPS empty password]' set -- "$1" "$2" "$(if printf 'X-ClickHouse-User: app_reader\nX-ClickHouse-Key;\n' | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert /etc/clickhouse-client/ca.crt --header @- \ -w '\nhttp=%{http_code} exit=%{exitcode} remote=%{remote_ip} err=%{errormsg}\n' \ "https://$1:8443/?query=SELECT%201"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" printf '%s' "$4" case "$3:$4" in 0:*'http=200 '*) echo "FINDING: the empty password ran SELECT 1 over HTTPS"; set -- "$1" 1 "$3" "$4" ;; 0:*) ;; # refused: the error above must be a ClickHouse authentication error, which you judge *) echo "curl exit $3: the empty-password request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; esac echo '[HTTPS deliberately wrong password]' set -- "$1" "$2" "$(if printf 'X-ClickHouse-User: app_reader\nX-ClickHouse-Key: definitely-not-the-password\n' | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert /etc/clickhouse-client/ca.crt --header @- \ -w '\nhttp=%{http_code} exit=%{exitcode} remote=%{remote_ip} err=%{errormsg}\n' \ "https://$1:8443/?query=SELECT%201"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" printf '%s' "$4" case "$3:$4" in 0:*'http=200 '*) echo "FINDING: the wrong password ran SELECT 1 over HTTPS"; set -- "$1" 1 "$3" "$4" ;; 0:*) ;; # refused: the error above must be a ClickHouse authentication error, which you judge *) echo "curl exit $3: the wrong-password request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; esac echo '[HTTPS correct password]' set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert /etc/clickhouse-client/ca.crt \ --header @- \ -w '\nhttp=%{http_code} exit=%{exitcode} remote=%{remote_ip} err=%{errormsg}\n' \ "https://$1:8443/?query=SELECT%201" \ < "$HOME/.config/clickhouse/app-reader.headers"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" printf '%s' "$4" case "$3:$4" in 0:$'1\n\nhttp=200 '*) ;; 0:*) echo "the correct password did not return 1 with HTTP 200, so the HTTPS positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; *) echo "curl exit $3: the correct-password request failed, so the HTTPS positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; esac # The password prompts keep the password out of the command; the account owner and root can still read it in the clickhouse-client process. echo '[native wrong password: enter a deliberately wrong password]' if clickhouse-client --config-file "$HOME/.config/clickhouse/config.xml" \ --host "$1" --port 9440 --secure --user app_reader --query 'SELECT 1' --password; then set -- "$1" "$2" 0 else set -- "$1" "$2" "$?" fi printf 'native_exit=%s\n' "$3" case "$3" in 0) echo "FINDING: the native login with a deliberately wrong password succeeded"; set -- "$1" 1 "$3" ;; esac echo '[native correct password: enter the configured password]' if clickhouse-client --config-file "$HOME/.config/clickhouse/config.xml" \ --host "$1" --port 9440 --secure --user app_reader --query 'SELECT 1' --password; then set -- "$1" "$2" 0 else set -- "$1" "$2" "$?" fi printf 'native_exit=%s\n' "$3" case "$3" in 0) ;; *) echo "the native login with the configured password failed, so the native positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" ;; esac echo "block exit $2; the 8123 result counts only beside the listener inventory, and whether each refused request above shows a ClickHouse authentication error is not classified, so judge them yourself" exit "$2" ;; esac ) ``` **REASONED: following block; interactive SQL checks require the missing server/client runtime, per the Sources entry "Native client options and configuration".** Use this guarded connection for the remaining checks. For administrator checks, change the quoted username to `sql_admin`; for bootstrap or retirement checks, use `default` as specified below. Enter passwords only at the prompt. [Client connection options](https://clickhouse.com/docs/concepts/features/interfaces/client). ```bash # No ClickHouse server/client binary or container runtime is available here. # Substitute the host and, for administrative checks, the username inside the quotes. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLICKHOUSE_HOST' 'app_reader' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not connecting"; exit 2; } shift [ "$#" -eq 2 ] || { echo "supply exactly one host and one user; not connecting"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute your host inside the quotes; not connecting" exit 2 ;; *) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute your user inside the quotes; not connecting" exit 2 ;; *) # The password prompt keeps the password out of the command; the account owner and root can still read it in the clickhouse-client process. if clickhouse-client --config-file "$HOME/.config/clickhouse/config.xml" \ --host "$1" --port 9440 --secure --user "$2" --password; then echo "unclassified observation: the session ended with exit 0; judge each query you ran against the expected outcome stated for it in this section" else echo "clickhouse-client exit $?: the connection failed or the session ended on an error; inconclusive, so read the session above"; exit 3 fi ;; esac ;; esac ) ``` **REASONED: table authorization requires the missing runtime and two populated fixture tables.** In the isolated deployment, have the administrator provision both `appdb.events` and `appdb.private_events`. Run these individually as `app_reader`: ```sql -- Table authorization; no server/client runtime or deployment fixtures. Expected outcomes and vendor sources are recorded in this section. SELECT * FROM appdb.events LIMIT 1; SELECT * FROM appdb.private_events LIMIT 1; ``` **REASONED: preceding block; covers both table reads in that block and the allowed and disallowed origin logins described here; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no second client network was available. The broad baseline reads both tables and the fixed reader reads `events` and gets an authorization error for `private_events`. A disallowed origin that authenticated to the permissive fixture is the only kind of origin that can show host enforcement. The table-read and host-enforcement statements rest on the guide's cited "CREATE USER and authentication methods", "CREATE ROLE and combined privileges" and "Privileges, external sources, engines, and dictionaries" sources.** A broadly granted baseline reads both tables. The fixed reader reads `events` and receives an authorization error for `private_events`. A missing table is not a passing denial. Repeat a successful login from the allowed application subnet and a login with the same credentials from a disallowed origin. For the host-restriction discriminator, first demonstrate that both origins can reach and authenticate to the permissive fixture; a fixed-state timeout alone does not prove `HOST IP` enforcement. [User hosts](https://clickhouse.com/docs/reference/statements/create/user#user-host), [roles and combined grants](https://clickhouse.com/docs/reference/statements/create/role). **REASONED: default retirement requires the missing runtime and access to server loopback, per the Sources entries "Administrator bootstrap and account management" and "Configuration merging, remove, and replace".** Before retirement, use the guarded connection locally with the certificate-valid loopback hostname as `default`, enter its configured password, and run `SELECT 1`. After retirement, repeat a fresh connection with the same credentials: it must fail authentication. From the same loopback origin and TLS endpoint, a fresh `sql_admin` connection must still run `SELECT 1` successfully. Repeat the credential check on HTTPS using an owner-only header file for each account and the guarded correct-password request shape above. Inspect the merged configuration for removal of `default`; a rejection from a previously disallowed network proves nothing about retirement. [Default-account handoff](https://clickhouse.com/docs/concepts/features/security/access-rights), [XML removal](https://clickhouse.com/docs/concepts/features/configuration/server-config/configuration-files). **REASONED: following block; covers that whole settings block: its override attempt, its four setting reads and its `SET ROLE NONE` and `SET ROLE DEFAULT` sequence; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed. Resource enforcement also requires a calibrated finite workload. The unconstrained baseline accepts the override, and the fixed account rejects it, reports the four values given here and keeps its memory profile with roles disabled. The baseline and fixed-account outcomes rest on the guide's cited "Settings constraints and profile interactions", "Query and per-user memory limits", "Execution-time limits and cancellation checkpoints", "SET ROLE and SET DEFAULT ROLE" and "Session role changes and their separate execution path" sources.** An unconstrained baseline accepts the override. The fixed account rejects it and reports `1073741824`, `2147483648`, `30`, and `0` for the four settings. The account-bound memory profile remains effective with its role disabled. `SET ROLE NONE` and `SET ROLE DEFAULT` change the session's active roles and are permitted with `readonly = 1`; they do not use the settings-changing `SET` path. Require both role statements to succeed; an error does not demonstrate profile persistence. [SET ROLE implementation](https://raw.githubusercontent.com/ClickHouse/ClickHouse/1163c482760fb0204e40f8fb5d81ffa1272bc9ea/src/Interpreters/Access/InterpreterSetRoleQuery.cpp). These checks establish settings and constraints, not actual resource enforcement. [Constraints](https://clickhouse.com/docs/concepts/features/configuration/settings/constraints-on-settings), [memory settings and inspection](https://clickhouse.com/docs/reference/settings/session-settings/max-memory-usage), [SET ROLE](https://clickhouse.com/docs/reference/statements/set-role). Run individually as `app_reader`: ```sql SELECT 1 SETTINGS max_memory_usage = 0; SELECT getSetting('max_memory_usage'); SELECT getSetting('max_memory_usage_for_user'); SELECT getSetting('max_execution_time'); SELECT getSetting('timeout_before_checking_execution_speed'); SET ROLE NONE; SELECT getSetting('max_memory_usage'); SET ROLE DEFAULT; ``` **REASONED: following block; covers that whole memory-discriminator block and the per-user and elapsed-time tests described here; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed. No populated fixture or calibrated workload was available, so nothing was measured. The baseline aggregation succeeds and, under a locked budget below its measured peak, fails with a memory-limit error while a simple read succeeds. Concurrent copies exceed the per-user budget while each stays below its own limit, and a lowered timeout cancels the calibrated workload while a shorter control succeeds. The aggregation, per-user and timeout outcomes rest on the guide's cited "Query and per-user memory limits", "Execution-time limits and cancellation checkpoints" and "Exact distinct aggregation for the bounded memory fixture" sources.** For a memory discriminator, give the isolated `events` fixture an `x UInt64` column with a finite, recorded number of distinct values. Measure a successful baseline of the aggregation below, then assign the test reader a locked query-memory budget below that measured peak while retaining enough memory for a simple table read. That aggregation must fail with a memory-limit error, while `SELECT * FROM appdb.events LIMIT 1` succeeds. Choose small test budgets instead of attempting to exhaust production memory. Test the per-user budget with concurrent copies whose combined measured usage exceeds that budget while each copy remains below its individual limit. Test elapsed-time enforcement separately with a calibrated finite workload and a lower test timeout, retaining a successful shorter-workload control; account for the documented cancellation checkpoints. [Exact distinct aggregation](https://clickhouse.com/docs/reference/functions/aggregate-functions/uniqExact), [memory limits](https://clickhouse.com/docs/reference/settings/session-settings/max-memory-usage), [execution-time limits](https://clickhouse.com/docs/reference/settings/session-settings/max-execution#max_execution_time). Use this aggregation: ```sql -- Bounded memory comparison; no server/client runtime or deployment fixtures. Expected outcomes and vendor sources are recorded in this section. SELECT uniqExact(x) FROM appdb.events; ``` **REASONED: following block; quota enforcement requires the missing runtime, a fresh test account, and a fresh quota interval.** Provision `quota_reader` separately with password authentication, an appropriate host restriction, and permission to read the populated `events` fixture. Give it only this test quota: ```sql CREATE QUOTA verify_reader_hourly KEYED BY user_name FOR INTERVAL 1 HOUR MAX queries = 2 TO quota_reader; ``` **REASONED: run order for the quota reads; no ClickHouse server or client binary, no container runtime and no fresh `quota_reader` account in the authoring environment, so the reads did not run; queries between them would count against the two-query quota, per the guide's cited "Quota accounting, exemptions, and resets" source. The local checks showed that the guarded connection's guard refused all 13 of its bad-input cases under bash -u with no call to a stub clickhouse-client. These checks covered an earlier form of the block, which ended with its last command's status; the current form, which exits 3 when clickhouse-client exits nonzero, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** Through the guarded connection as `quota_reader`, issue these individually without intervening application queries: ```sql -- Quota enforcement; no server/client runtime or deployment fixtures. Expected outcomes and vendor sources are recorded in this section. SELECT * FROM appdb.events LIMIT 1; SELECT * FROM appdb.events LIMIT 1; SELECT * FROM appdb.events LIMIT 1; ``` **REASONED: preceding block; covers all three reads in that block; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no fresh quota interval was available. The unrestricted baseline accepts all three and the fixed fixture accepts two, then rejects the third with a quota error, per the guide's cited "CREATE QUOTA" and "Quota accounting, exemptions, and resets" sources.** The unrestricted baseline accepts all three. In the fixed fixture, the first two succeed and excess usage is rejected with a quota error. Avoid `SELECT 1` as the quota discriminator: current documentation exempts reads of `system.one` and several quota-inspection tables. Demonstrate restart reset and, when a second test server is available, independent receiving-server counters. [Quota syntax](https://clickhouse.com/docs/reference/statements/create/quota), [quota exceptions and reset behaviour](https://clickhouse.com/docs/concepts/features/configuration/server-config/quotas). **REASONED: following block; covers that whole local-file read and the dedicated FILE-only test account described here; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no `user_files_path` fixture was available. Source restrictions require the missing runtime, a harmless local file, and reachable HTTPS fixtures. The source-authorized baseline reads the fixture, app_reader must not read it, and the dedicated FILE-only test account must get the corresponding authorization error when FILE access is removed, per the guide's cited "File table function and user_files_path", "Privileges, external sources, engines, and dictionaries" and "Query-class permissions and exceptions" sources.** Have the administrator place `probe.tsv`, containing a valid `UInt8` row, under the effective `user_files_path`. A source-authorized baseline reads the fixture; `app_reader` must not. To isolate the source privilege itself from `readonly` and table-function prerequisites, repeat with a dedicated test account whose other required permissions remain constant and whose only changed permission is FILE access. Require the corresponding authorization error. A missing file or a read-only-mode error does not establish that source-grant enforcement was tested. [File function](https://clickhouse.com/docs/reference/functions/table-functions/file), [source privileges](https://clickhouse.com/docs/reference/statements/grant#sources), [table functions in read-only mode](https://clickhouse.com/docs/concepts/features/configuration/settings/permissions-for-queries). Run: ```sql SELECT * FROM file('probe.tsv', 'TSV', 'x UInt8'); ``` **REASONED: following block; covers both URL reads in that block; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no certificate-valid HTTPS fixtures were available, so the unguarded placeholders were not exercised. Both URL reads succeed in the permissive baseline, and with the allow-list the approved host succeeds while the unlisted host gets an allow-list rejection, per the guide's cited "URL table function" and "URL host allow-list" sources. No reachable HTTPS fixtures were available. Neither destination was queried, and source-privilege, DNS, certificate and reachability errors are inconclusive.** For URL allow-list enforcement, use a separately source-authorized test account with the required table-function permissions and two harmless, certificate-valid HTTPS fixtures. Both destinations must succeed in the permissive baseline. With the allow-list configured, the approved destination still succeeds and the unlisted destination produces an allow-list rejection. Missing source privileges, DNS failures, certificate failures, and unreachable fixture servers are inconclusive for this check. [URL function](https://clickhouse.com/docs/reference/functions/table-functions/url), [host allow-list matching](https://clickhouse.com/docs/reference/settings/server-settings/settings/remote#remote_url_allow_hosts). Substitute the actual hosts before executing: ```sql -- URL allow-list; no server/client runtime or deployment fixtures. Expected outcomes and vendor sources are recorded in this section. SELECT * FROM url( 'https://REPLACE_WITH_APPROVED_SOURCE_HOST:443/probe.tsv', 'TSV', 'x UInt8' ); SELECT * FROM url( 'https://REPLACE_WITH_UNLISTED_SOURCE_HOST:443/probe.tsv', 'TSV', 'x UInt8' ); ``` **REASONED: engine-grant enforcement requires the missing runtime and a scratch database.** Give a dedicated test schema user scoped `CREATE TABLE` permission in `scratch`, permission to use `MergeTree`, no permission to use `TinyLog`, and settings that allow DDL. Run individually: ```sql CREATE TABLE scratch.allowed (x UInt8) ENGINE = MergeTree ORDER BY x; CREATE TABLE scratch.denied (x UInt8) ENGINE = TinyLog; ``` **REASONED: preceding block; covers both table creations in that block; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no scratch database was available. Both table creations succeed with enforcement disabled, and with it enabled `MergeTree` succeeds and `TinyLog` gets an engine-privilege error, per the guide's cited "Privileges, external sources, engines, and dictionaries", "Engine and system-table grant enforcement", "CREATE TABLE", "MergeTree syntax and TTL" and "TinyLog engine" sources.** With engine enforcement disabled, both creations succeed. In a fresh fixed fixture with enforcement enabled, `MergeTree` succeeds and `TinyLog` receives an engine-privilege error. Have the administrator remove scratch tables between runs so an existing-table error cannot masquerade as a denial. [Engine privileges](https://clickhouse.com/docs/reference/statements/grant#table-engine), [CREATE TABLE](https://clickhouse.com/docs/reference/statements/create/table). **REASONED: following block; covers all four statements in that block, the wrong-password login and the administrator inspection described here; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no log collector was available. Logging and log protection require the missing runtime and a functioning log collector. The disabled-logging baseline adds no matching records, and the fixed state records the marker query and login failure, rejects the logging override and denies the reader the log tables, per the guide's cited "Query logging session settings", "Query-log schema", "Session-log schema", "SYSTEM FLUSH LOGS" and "Engine and system-table grant enforcement" sources. The local checks showed that the guarded authentication block's guard refused all 10 of its bad-input cases under bash -u with no stub call. These checks covered an earlier form of the block, which ended with its last command's status; the current form, which sets its exit status from its classified results, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** After running the statements below, also perform one deliberately wrong-password login using the guarded authentication block. Then, as `sql_admin`, execute step 6's `SYSTEM FLUSH LOGS` and inspection queries. As `app_reader`, run individually: ```sql SELECT 703307 AS security_probe; SELECT 1 SETTINGS log_queries = 0; SELECT * FROM system.query_log LIMIT 1; SELECT * FROM system.session_log LIMIT 1; ``` **REASONED: expected logging and log-protection outcomes; no ClickHouse server or client binary, container runtime, log collector or independent storage in the authoring environment, so no record was observed, per the guide's cited "Query logging session settings", "Query-log schema", "Session-log schema" and "Engine and system-table grant enforcement" sources.** The disabled-logging baseline lacks new matching records. The fixed state records the marker query and login failure, rejects the logging override, and denies the reader access to the log tables while the administrator can read them. Correlate query ID, node, account, client address, and test time; older rows are not evidence. Repeat authentication-event collection for every enabled interface. Check that the same records reach independently controlled storage. [Query logging settings](https://clickhouse.com/docs/reference/settings/session-settings/log-queries), [query log](https://clickhouse.com/docs/reference/system-tables/query_log), [session log](https://clickhouse.com/docs/reference/system-tables/session_log), [system-table authorization](https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_improvements). As administrator, inspect the current table definitions: ```sql -- Log retention; no server/client runtime or deployment fixtures. Expected outcomes and vendor sources are recorded in this section. SHOW CREATE TABLE system.query_log; SHOW CREATE TABLE system.session_log; ``` **REASONED: preceding block; covers both table-definition reads in that block and the isolated retention test described here; no ClickHouse server or client binary, no local ClickHouse SQL parser and no container runtime in the authoring environment, and no network access to obtain them, so no SQL ran or was parsed, and no retention fixture was available, so no deletion was observed. A definition without the intended TTL is the exposed state and one showing it is the fixed state. Expired records are removed through TTL processing while recent records remain, per the guide's cited "SHOW statements", "System-log configuration, retention, and schema changes" and "MergeTree syntax and TTL" sources.** Confirm the intended TTL and inspect any renamed historical log tables separately. In an isolated retention fixture, confirm expired records are removed through TTL processing while recent records remain; the XML alone does not demonstrate deletion or export durability. [SHOW](https://clickhouse.com/docs/reference/statements/show), [system-log retention and upgrades](https://clickhouse.com/docs/reference/system-tables/overview), [MergeTree TTL](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/mergetree#ttl). **REASONED: TOTP requires the missing runtime and an enrolled XML human account, per the Sources entry "Native TOTP for XML users".** Use the guarded interactive connection with that account. The password-only baseline succeeds without an OTP. The fixed account must reject missing or invalid OTP authentication and accept the correct password with a fresh valid code. Enter authentication material at prompts; do not reuse a previously accepted code for the positive control. [TOTP authentication](https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users#totp-authentication-configuration). **REASONED: summary of the service checks above and of the table below; no ClickHouse server or client binary and no container runtime in the authoring environment, so nothing in it was observed; each check rests on the Sources entries cited at its own step.** The service checks remain REASONED from the cited ClickHouse documentation and pinned sources. The following summarizes their scope and missing capabilities, not observed service results. | Scope | Missing capabilities and exposed/fixed checks | |---|---| | Service-level checks | REASONED: check every service-level control above in isolated exposed and fixed ClickHouse deployments. Requires server/client binaries or a container runtime, allowed and disallowed client networks, TLS certificates, populated table and bounded resource fixtures, a fresh quota account, local-file and HTTPS fixtures, a scratch schema user, retained logs and an independent collector, and an enrolled XML TOTP account. Record matched positive controls, exact server/client versions, actual errors, merged configuration, default retirement on both interfaces, resource and quota enforcement, source and engine denials, and log collection/retention. Use a second test server for server-local quota accounting and replication HTTPS. Check replica credential enforcement with missing, wrong, and matching secrets, private binding, removal of 9009, and peer certificate verification. | **REASONED: authoring-time local validation record, partly rerun in October 2026 in a fresh clone: all 12 XML fences parsed as well-formed with Python's xml.dom.minidom, and `bash -n` and ShellCheck 0.11.0 reported nothing on the three shell blocks. The October 2026 rerun also showed that the guard-conventions scanner reported PASS with no findings in `tools/run_all_checks.sh`, and that 32 guard rejection cases under bash -u against stub ss, curl and clickhouse-client exited 2 with no stub call. The original thirty cases and the statements about what did not run during authoring are carried from the authoring record and were not re-observed. These runs covered the three blocks in an earlier form, which ended with its last command's status; the current forms, which set their exit status from their results, are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes.** Local validation completed during authoring: all 12 XML documents/fragments passed well-formedness parsing; all three shell blocks passed `bash -n` and ShellCheck; the guard-conventions scanner reported no findings. During authoring, thirty rejection cases passed under `bash -u`, covering unresolved and embedded placeholders, angle brackets, `example.com`, empty input, missing marker/arguments, and invalid usernames. These checks do not validate ClickHouse configuration semantics or SQL execution. During authoring, no service probes ran, no files were edited, and `run_all_checks.sh` was not run. ## Common mistakes - A wildcard listener enabled manually or inherited from the official image, while the effective `default` user remains passwordless and reachable from remote networks. - A password set on `default` while its effective `` still says `::/0`, leaving a privileged account reachable from anywhere the listener and firewall allow. - `https_port` added while `http_port` 8123 stays open beside it. - Client TLS enabled while plaintext replication 9009 remains inherited, or replication HTTPS enabled without private binding and interserver credentials. - Commenting out a setting in an override and expecting the inherited setting to disappear. - Assuming `access_management` alone permits the complete `GRANT ALL` handoff. - Adding a narrow role while leaving broad direct grants or inherited roles active. - Treating `SELECT 1`, a timeout, or an absent fixture as proof of authorization or quota enforcement. - Treating query logs as immutable audit storage, or assuming Cloud retention applies to OSS. ## Sources (checked September 2026) - Listen address settings: https://clickhouse.com/docs/reference/settings/server-settings/settings/listen#listen_host - Base server configuration with commented-out listener examples: https://raw.githubusercontent.com/ClickHouse/ClickHouse/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/config.xml - Separately pinned upstream default-user configuration: https://raw.githubusercontent.com/ClickHouse/ClickHouse/b40982f9416bb4bc2d423d7e75ec384e79494b99/programs/server/users.xml - Server listener fallback: https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/Server.cpp#L644-L652 - Official image listener override: https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/docker_related_config.xml#L1-L5 - Official image copy into config.d and entrypoint: https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/Dockerfile.ubuntu#L160-L168 - Official image default-user initialization: https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/entrypoint.sh#L45-L270 - User settings, password hashes, networks, and bootstrap capabilities: https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users - Administrator bootstrap and account management: https://clickhouse.com/docs/concepts/features/security/access-rights - SQL access storage: https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_path - User directories and access-storage precedence: https://clickhouse.com/docs/reference/settings/server-settings/settings/user#user_directories - CREATE USER and authentication methods: https://clickhouse.com/docs/reference/statements/create/user - CREATE ROLE and combined privileges: https://clickhouse.com/docs/reference/statements/create/role - SET ROLE and SET DEFAULT ROLE: https://clickhouse.com/docs/reference/statements/set-role - Session role changes and their separate execution path: https://raw.githubusercontent.com/ClickHouse/ClickHouse/1163c482760fb0204e40f8fb5d81ffa1272bc9ea/src/Interpreters/Access/InterpreterSetRoleQuery.cpp - Privileges, external sources, engines, and dictionaries: https://clickhouse.com/docs/reference/statements/grant - Complete upstream source privilege registry: https://raw.githubusercontent.com/ClickHouse/ClickHouse/37c6c8c9bfc1de323e9a27711c5649352364bcba/src/Access/Common/AccessType.h - CREATE SETTINGS PROFILE: https://clickhouse.com/docs/reference/statements/create/settings-profile - Settings constraints and profile interactions: https://clickhouse.com/docs/concepts/features/configuration/settings/constraints-on-settings - Query and per-user memory limits: https://clickhouse.com/docs/reference/settings/session-settings/max-memory-usage - Execution-time limits and cancellation checkpoints: https://clickhouse.com/docs/reference/settings/session-settings/max-execution#max_execution_time - CREATE QUOTA: https://clickhouse.com/docs/reference/statements/create/quota - Quota accounting, exemptions, and resets: https://clickhouse.com/docs/concepts/features/configuration/server-config/quotas - Query-class permissions and exceptions: https://clickhouse.com/docs/concepts/features/configuration/settings/permissions-for-queries - Network ports and PostgreSQL TLS support: https://clickhouse.com/docs/concepts/features/security/network-ports - Interserver listener address: https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver#interserver_listen_host - Replication credentials, allow_empty, and plaintext interserver port: https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-http - Replication HTTPS port and advertised hostname: https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-https - Configuring TLS: https://clickhouse.com/docs/concepts/features/security/tls/configuring-tls - OpenSSL certificate and hostname verification: https://clickhouse.com/docs/reference/settings/server-settings/settings/other#openSSL - Configuration merging, remove, and replace: https://clickhouse.com/docs/concepts/features/configuration/server-config/configuration-files - Native client options and configuration: https://clickhouse.com/docs/concepts/features/interfaces/client - HTTP interface and authentication: https://clickhouse.com/docs/concepts/features/interfaces/http - curl empty-valued headers and header input from stdin: https://curl.se/docs/manpage.html#-H - Native TOTP for XML users: https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users#totp-authentication-configuration - ClickHouse 26.2 TOTP introduction: https://clickhouse.com/blog/clickhouse-release-26-02 - URL host allow-list: https://clickhouse.com/docs/reference/settings/server-settings/settings/remote#remote_url_allow_hosts - URL table function: https://clickhouse.com/docs/reference/functions/table-functions/url - File table function and user_files_path: https://clickhouse.com/docs/reference/functions/table-functions/file - Remote table functions: https://clickhouse.com/docs/reference/functions/table-functions/remote - HTTP dictionary sources: https://clickhouse.com/docs/reference/statements/create/dictionary/sources/http - Engine and system-table grant enforcement: https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_improvements - Executable script directory: https://clickhouse.com/docs/reference/settings/server-settings/settings/user#user_scripts_path - Executable-UDF configuration discovery: https://clickhouse.com/docs/reference/settings/server-settings/settings/user-defined#user_defined_executable_functions_config - Executable table function: https://clickhouse.com/docs/reference/functions/table-functions/executable - Executable UDFs and execute_direct: https://clickhouse.com/docs/reference/functions/regular-functions/udf - System-log configuration, retention, and schema changes: https://clickhouse.com/docs/reference/system-tables/overview - Query-log configuration and query masking: https://clickhouse.com/docs/reference/settings/server-settings/settings/query - Query logging session settings: https://clickhouse.com/docs/reference/settings/session-settings/log-queries - Query-log schema: https://clickhouse.com/docs/reference/system-tables/query_log - Session-log schema: https://clickhouse.com/docs/reference/system-tables/session_log - SYSTEM FLUSH LOGS: https://clickhouse.com/docs/reference/statements/system#system-flush-logs - Database audit logging: https://clickhouse.com/docs/products/cloud/guides/security/audit-logging/database-audit-log - ClickHouse Cloud console audit logging: https://clickhouse.com/docs/products/cloud/guides/security/audit-logging/console-audit-log - SELECT and query-level settings: https://clickhouse.com/docs/reference/statements/select - Exact distinct aggregation for the bounded memory fixture: https://clickhouse.com/docs/reference/functions/aggregate-functions/uniqExact - CREATE TABLE: https://clickhouse.com/docs/reference/statements/create/table - MergeTree syntax and TTL: https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/mergetree - TinyLog engine: https://clickhouse.com/docs/reference/engines/table-engines/log-family/tinylog - SHOW statements: https://clickhouse.com/docs/reference/statements/show ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - docs: ClickHouse documentation (unknown) - config: ClickHouse server config source (9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6) - docker: ClickHouse Docker source (9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6) - users: ClickHouse users source (b40982f9416bb4bc2d423d7e75ec384e79494b99) - totp: ClickHouse TOTP release announcement (unknown) - sources: ClickHouse privilege registry (37c6c8c9bfc1de323e9a27711c5649352364bcba) - roles: ClickHouse role source (1163c482760fb0204e40f8fb5d81ffa1272bc9ea) - curl: curl documentation (unknown) Claims (full source mapping in the JSON block below): - listen-default (REASONED; config, docs): Base config comments out listen_host; server code falls back to IPv4/IPv6 loopback when unset; widening listeners can expose services. - default-user (REASONED; users): Upstream default has empty password, ::/0 origins and access_management; full bootstrap needs additional capabilities. - docker-default (REASONED; docker): With no arguments or a first argument starting --, entrypoint writes a loopback-only default networks override only when setup is not skipped, no non-default user, nonempty password (an existing password file replaces the variable), or nonempty access-management value other than 0 is requested, and default is unchanged. Other commands bypass this setup; official image installs wildcard listeners. - ports (REASONED; docs): 8123 HTTP, 9000 native, 9004 MySQL, 9005 PostgreSQL and 9009 interserver are documented, not necessarily active. - interserver-bind (REASONED; docs): interserver_listen_host defaults to listen_host but can independently widen replica exposure. - xml-merge (REASONED; docs): config.d/users.d retain clickhouse roots; remove/replace inherited entries explicitly and inspect merged configuration. - passwords (REASONED; docs): Replace inherited password with protected SHA-256 hash and loopback origins; SQL bcrypt has a 72-character limit. - bootstrap (REASONED; docs): Complete administrator bootstrap needs access_management, named_collection_control and show_named_collections_secrets. - access-storage (REASONED; docs): Persist SQL access storage; configured user_directories takes precedence over access_control_path. - admin (REASONED; docs): Separate SQL administrator gets global grants and restricted HOST IP; host restrictions do not enforce TLS. - reader (REASONED; docs): Table-scoped reader role combines with direct/inherited grants; remove excessive privileges and separate ingestion. - retire-default (REASONED; docs): After matched replacement login and dependency migration, remove temporary bootstrap and inherited default definition. - profiles (REASONED; docs): Bind locked settings directly to the account so disabling roles cannot remove them; review overlapping profiles. - memory (REASONED; docs): Query/per-user memory limits apply per server, are incompletely tracked, and do not bound all process memory. - timeout (REASONED; docs): Zero speed-check delay makes timeout elapsed-time based; cancellation checkpoints can exceed the example 30 seconds. - quota (REASONED; docs): Username-keyed cumulative quotas are receiving-server local, reset on restart, and do not limit concurrency; some reads are exempt. - readonly (REASONED; docs): readonly/allow_ddl do not replace grants; readonly=2 permits more operations and is not a READONLY constraint. - tls-ports (REASONED; docs): Enable HTTPS 8443/native TLS 9440 and remove plaintext 8123/9000; baseline removes both replication listeners. - replica-tls (REASONED; docs): Replica variant uses private HTTPS 9010, matching advertised hostname and verified outbound TLS, while removing 9009. - replica-auth (REASONED; docs): Omitted interserver credentials disable auth; allow_empty true admits anonymous peers; match protected secrets across replicas. - cluster-tls (REASONED; docs): Distributed-query and Keeper paths require separate TLS configuration beyond replication data HTTPS. - emulation (REASONED; docs): Remove unused 9004/9005 listeners; PostgreSQL emulation can support TLS. - client-tls (REASONED; docs): Explicit client trust file and certificate hostname are required; extendedVerification defaults false, so enable it. - certificate-auth (REASONED; docs): ssl_certificate CN authentication supplies a machine possession factor, separate from human MFA. - totp (REASONED; docs, totp): 26.2 introduced XML-user TOTP with password auth; SQL access control lacks it and each code is accepted at most once. - source-grants (REASONED; docs, sources): Withhold all source privileges and inherited grants; separate READ/WRITE needs 25.7+, filtering 25.8+, and enable_read_write_grants. - source-interfaces (REASONED; docs): url/HTTP dictionaries fetch remote data, file reads under user_files_path, and remote reaches other servers. - url-default (REASONED; config, docs): Omitted remote_url_allow_hosts allows covered URL hosts; hostname-only permits every port. - url-matching (REASONED; docs): Host matching precedes DNS and applies on redirects; host:port does not require HTTPS or constrain every protocol. - engine-grants (REASONED; docs): OSS engine enforcement defaults disabled; enable it and grant only required engines alongside scoped CREATE TABLE. - dictionaries (REASONED; docs): Separate dictionary management from scoped dictGet use; ordinary readers need no management privilege. - executables (REASONED; docs): Administrator-controlled script/UDF discovery paths restrict modification; existing executable objects still need review. - direct-exec (REASONED; docs): execute_direct runs the approved program directly; zero invokes a shell; neither is a sandbox. - log-retention (REASONED; docs): Configure query/session logs and explicit 30-day TTL; deprecated database option is omitted; custom engine conflicts need review. - log-profile (REASONED; docs): Lock query logging and sampling settings for each relevant account; overlapping profiles can conflict. - log-events (REASONED; docs): Query log records activity/errors, not results; session log records login/logout/failure; inspect every serving interface/node. - log-access (REASONED; docs): Require system-table grants, withhold application log access, mask sensitive query text and export to independent storage. - log-upgrades (REASONED; docs): Upgrades can rename old log tables; inspect their retention; no separate immutable OSS audit_log or imported Cloud retention is established. - verify-inventory (REASONED; docs): Inventory intended 8443/9440 and optional private 9010; removed listeners absent; local ss proves no remote isolation/authentication. - verify-http (REASONED; docs, curl): Matched no_password fixture accepts supplied/empty/wrong passwords; fixed HTTPS requires correct password and returns 1. - verify-plaintext (REASONED; docs): Any HTTP status on 8123 proves reachability; HTTP 000 needs inventory and successful TLS controls. - verify-native (REASONED; docs): Native wrong password needs authentication error/nonzero exit; correct credentials return 1; transport failures are inconclusive. - verify-tables (REASONED; docs): Fixed reader reads populated events and rejects private_events; test allowed/disallowed origins with matched reachable controls. - verify-retirement (REASONED; docs): Fresh default login fails after retirement on native/HTTPS while same-origin sql_admin still succeeds. - verify-settings (REASONED; roles, docs): Reject unlocked override; inspect four limits and require successful SET ROLE NONE/DEFAULT with account profile retained. - verify-memory (REASONED; docs): Calibrated aggregation fails at lower memory limit while simple read succeeds; test concurrent user budget and timeout separately. - verify-quota (REASONED; docs): Fresh fixture accepts first two application reads and rejects the third; test restart reset and independent server counters. - verify-file (REASONED; docs): Isolate FILE privilege with existing fixture and constant prerequisites; missing-file/readonly errors prove no source denial. - verify-url (REASONED; docs): Approved HTTPS fixture succeeds and unlisted host gets allow-list rejection; other errors are inconclusive. - verify-engine (REASONED; docs): With engine enforcement enabled MergeTree succeeds and TinyLog gets engine-privilege denial; remove fixtures between runs. - verify-logs (REASONED; docs): Require new marker/login-failure records, locked logging, reader denial and admin/collector success with correlated provenance. - verify-retention (REASONED; docs): Inspect table TTLs and historical tables; expired records disappear while recent ones remain in isolated retention test. - verify-totp (REASONED; docs): Fixed XML human account rejects missing/invalid OTP and accepts password with fresh code; do not reuse positive code. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "11efeeda48aaebe6d2002d69242d23aad7332a230d5b6f8084da586e72bb0a47", "components": { "docs": { "name": "ClickHouse documentation", "basis": "unknown", "sources": { "sd76f8dcfe61f": "https://clickhouse.com/docs/reference/settings/server-settings/settings/listen#listen_host", "sd891987369e6": "https://clickhouse.com/docs/concepts/features/security/network-ports", "sd156261650ee": "https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver#interserver_listen_host", "sfd95effa4d9b": "https://clickhouse.com/docs/concepts/features/configuration/server-config/configuration-files", "s2ee7469699a5": "https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users", "sc7a527d82e50": "https://clickhouse.com/docs/reference/statements/create/user", "s324525fe685e": "https://clickhouse.com/docs/concepts/features/security/access-rights", "sa133286c107f": "https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_path", "s38015aaf3159": "https://clickhouse.com/docs/reference/settings/server-settings/settings/user#user_directories", "s6b90e2c1092e": "https://clickhouse.com/docs/reference/statements/grant", "s25556959ddfa": "https://clickhouse.com/docs/reference/statements/create/role", "sd137a7449c51": "https://clickhouse.com/docs/reference/statements/set-role", "s26cbbfd750d7": "https://clickhouse.com/docs/reference/statements/create/settings-profile", "sae310a80cd58": "https://clickhouse.com/docs/concepts/features/configuration/settings/constraints-on-settings", "s02fa1a2ea84b": "https://clickhouse.com/docs/reference/settings/session-settings/max-memory-usage", "sfd00c9c1a9a1": "https://clickhouse.com/docs/reference/settings/session-settings/max-execution#max_execution_time", "sf738cc05299a": "https://clickhouse.com/docs/reference/statements/create/quota", "s8d50f48221f7": "https://clickhouse.com/docs/concepts/features/configuration/server-config/quotas", "sad8b1924b3ce": "https://clickhouse.com/docs/concepts/features/configuration/settings/permissions-for-queries", "sd78f0c21ad7b": "https://clickhouse.com/docs/concepts/features/security/tls/configuring-tls", "s4e31b63753ab": "https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-https", "s6255e1dd351e": "https://clickhouse.com/docs/reference/settings/server-settings/settings/other#openSSL", "s5b738c4b057c": "https://clickhouse.com/docs/reference/settings/server-settings/settings/interserver-http", "s01de3f86a875": "https://clickhouse.com/docs/concepts/features/interfaces/client", "s4d5d0964fabb": "https://clickhouse.com/docs/concepts/features/configuration/settings/settings-users#totp-authentication-configuration", "s8db5ea270091": "https://clickhouse.com/docs/reference/functions/table-functions/url", "s78d0544db7eb": "https://clickhouse.com/docs/reference/functions/table-functions/file", "sbb755048d8c0": "https://clickhouse.com/docs/reference/functions/table-functions/remote", "s32fae5e0bf85": "https://clickhouse.com/docs/reference/statements/create/dictionary/sources/http", "saa2d39175e49": "https://clickhouse.com/docs/reference/settings/server-settings/settings/remote#remote_url_allow_hosts", "scce6af3a28a6": "https://clickhouse.com/docs/reference/settings/server-settings/settings/access-control#access_control_improvements", "sc172859d92a4": "https://clickhouse.com/docs/reference/settings/server-settings/settings/user#user_scripts_path", "sba7a666c54ee": "https://clickhouse.com/docs/reference/settings/server-settings/settings/user-defined#user_defined_executable_functions_config", "s0be4d04f7874": "https://clickhouse.com/docs/reference/functions/table-functions/executable", "s247524807405": "https://clickhouse.com/docs/reference/functions/regular-functions/udf", "s3ebb0f281977": "https://clickhouse.com/docs/reference/system-tables/overview", "sc67e973f5ab7": "https://clickhouse.com/docs/reference/settings/server-settings/settings/query", "s41bbbde68a83": "https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/mergetree", "s67072795b79e": "https://clickhouse.com/docs/reference/settings/session-settings/log-queries", "scfe38c988cb3": "https://clickhouse.com/docs/reference/system-tables/query_log", "sde710fc1a785": "https://clickhouse.com/docs/reference/system-tables/session_log", "s1b518ea764fb": "https://clickhouse.com/docs/reference/statements/system#system-flush-logs", "s5b17854afa6d": "https://clickhouse.com/docs/products/cloud/guides/security/audit-logging/database-audit-log", "s6298fa9f4719": "https://clickhouse.com/docs/products/cloud/guides/security/audit-logging/console-audit-log", "s3919007d159e": "https://clickhouse.com/docs/concepts/features/interfaces/http", "s201098033e10": "https://clickhouse.com/docs/reference/functions/aggregate-functions/uniqExact", "sde8cc2811bb5": "https://clickhouse.com/docs/reference/statements/create/table", "s463e85e20441": "https://clickhouse.com/docs/reference/engines/table-engines/log-family/tinylog", "s2d3e9d1310e4": "https://clickhouse.com/docs/reference/statements/show" } }, "config": { "name": "ClickHouse server config source", "basis": "9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6", "sources": { "s5bcf465239d6": "https://raw.githubusercontent.com/ClickHouse/ClickHouse/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/config.xml", "s0c01b74057d5": "https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/programs/server/Server.cpp#L644-L652" } }, "docker": { "name": "ClickHouse Docker source", "basis": "9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6", "sources": { "s401d0e9b66f2": "https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/docker_related_config.xml#L1-L5", "se333b3771927": "https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/Dockerfile.ubuntu#L160-L168", "sf3f0510525e2": "https://github.com/ClickHouse/ClickHouse/blob/9363bf26fecd0984e6e4e5a3c5ba2a4aba01cda6/docker/server/entrypoint.sh#L45-L270" } }, "users": { "name": "ClickHouse users source", "basis": "b40982f9416bb4bc2d423d7e75ec384e79494b99", "sources": { "s20eec88438df": "https://raw.githubusercontent.com/ClickHouse/ClickHouse/b40982f9416bb4bc2d423d7e75ec384e79494b99/programs/server/users.xml" } }, "totp": { "name": "ClickHouse TOTP release announcement", "basis": "unknown", "sources": { "sf7b9d01ede30": "https://clickhouse.com/blog/clickhouse-release-26-02" } }, "sources": { "name": "ClickHouse privilege registry", "basis": "37c6c8c9bfc1de323e9a27711c5649352364bcba", "sources": { "s04a87783572d": "https://raw.githubusercontent.com/ClickHouse/ClickHouse/37c6c8c9bfc1de323e9a27711c5649352364bcba/src/Access/Common/AccessType.h" } }, "roles": { "name": "ClickHouse role source", "basis": "1163c482760fb0204e40f8fb5d81ffa1272bc9ea", "sources": { "sa66cde5c2b6e": "https://raw.githubusercontent.com/ClickHouse/ClickHouse/1163c482760fb0204e40f8fb5d81ffa1272bc9ea/src/Interpreters/Access/InterpreterSetRoleQuery.cpp" } }, "curl": { "name": "curl documentation", "basis": "unknown", "sources": { "s5345a985007a": "https://curl.se/docs/manpage.html#-H" } } }, "claims": { "listen-default": {"text": "Base config comments out listen_host; server code falls back to IPv4/IPv6 loopback when unset; widening listeners can expose services.", "components": ["config", "docs"], "sources": ["config:s5bcf465239d6", "config:s0c01b74057d5", "docs:sd76f8dcfe61f"], "status": "REASONED"}, "default-user": {"text": "Upstream default has empty password, ::/0 origins and access_management; full bootstrap needs additional capabilities.", "components": ["users"], "sources": ["users:s20eec88438df"], "status": "REASONED"}, "docker-default": {"text": "With no arguments or a first argument starting --, entrypoint writes a loopback-only default networks override only when setup is not skipped, no non-default user, nonempty password (an existing password file replaces the variable), or nonempty access-management value other than 0 is requested, and default is unchanged. Other commands bypass this setup; official image installs wildcard listeners.", "components": ["docker"], "sources": ["docker:s401d0e9b66f2", "docker:se333b3771927", "docker:sf3f0510525e2"], "status": "REASONED"}, "ports": {"text": "8123 HTTP, 9000 native, 9004 MySQL, 9005 PostgreSQL and 9009 interserver are documented, not necessarily active.", "components": ["docs"], "sources": ["docs:sd891987369e6", "docs:sd156261650ee"], "status": "REASONED"}, "interserver-bind": {"text": "interserver_listen_host defaults to listen_host but can independently widen replica exposure.", "components": ["docs"], "sources": ["docs:sd156261650ee"], "status": "REASONED"}, "xml-merge": {"text": "config.d/users.d retain clickhouse roots; remove/replace inherited entries explicitly and inspect merged configuration.", "components": ["docs"], "sources": ["docs:sfd95effa4d9b"], "status": "REASONED"}, "passwords": {"text": "Replace inherited password with protected SHA-256 hash and loopback origins; SQL bcrypt has a 72-character limit.", "components": ["docs"], "sources": ["docs:s2ee7469699a5", "docs:sc7a527d82e50"], "status": "REASONED"}, "bootstrap": {"text": "Complete administrator bootstrap needs access_management, named_collection_control and show_named_collections_secrets.", "components": ["docs"], "sources": ["docs:s2ee7469699a5", "docs:s324525fe685e"], "status": "REASONED"}, "access-storage": {"text": "Persist SQL access storage; configured user_directories takes precedence over access_control_path.", "components": ["docs"], "sources": ["docs:sa133286c107f", "docs:s38015aaf3159"], "status": "REASONED"}, "admin": {"text": "Separate SQL administrator gets global grants and restricted HOST IP; host restrictions do not enforce TLS.", "components": ["docs"], "sources": ["docs:s324525fe685e", "docs:sc7a527d82e50", "docs:s6b90e2c1092e"], "status": "REASONED"}, "reader": {"text": "Table-scoped reader role combines with direct/inherited grants; remove excessive privileges and separate ingestion.", "components": ["docs"], "sources": ["docs:s25556959ddfa", "docs:sd137a7449c51", "docs:s6b90e2c1092e"], "status": "REASONED"}, "retire-default": {"text": "After matched replacement login and dependency migration, remove temporary bootstrap and inherited default definition.", "components": ["docs"], "sources": ["docs:s324525fe685e", "docs:sfd95effa4d9b"], "status": "REASONED"}, "profiles": {"text": "Bind locked settings directly to the account so disabling roles cannot remove them; review overlapping profiles.", "components": ["docs"], "sources": ["docs:s26cbbfd750d7", "docs:sae310a80cd58"], "status": "REASONED"}, "memory": {"text": "Query/per-user memory limits apply per server, are incompletely tracked, and do not bound all process memory.", "components": ["docs"], "sources": ["docs:s02fa1a2ea84b"], "status": "REASONED"}, "timeout": {"text": "Zero speed-check delay makes timeout elapsed-time based; cancellation checkpoints can exceed the example 30 seconds.", "components": ["docs"], "sources": ["docs:sfd00c9c1a9a1"], "status": "REASONED"}, "quota": {"text": "Username-keyed cumulative quotas are receiving-server local, reset on restart, and do not limit concurrency; some reads are exempt.", "components": ["docs"], "sources": ["docs:sf738cc05299a", "docs:s8d50f48221f7"], "status": "REASONED"}, "readonly": {"text": "readonly/allow_ddl do not replace grants; readonly=2 permits more operations and is not a READONLY constraint.", "components": ["docs"], "sources": ["docs:sad8b1924b3ce"], "status": "REASONED"}, "tls-ports": {"text": "Enable HTTPS 8443/native TLS 9440 and remove plaintext 8123/9000; baseline removes both replication listeners.", "components": ["docs"], "sources": ["docs:sd891987369e6", "docs:sd78f0c21ad7b", "docs:sfd95effa4d9b"], "status": "REASONED"}, "replica-tls": {"text": "Replica variant uses private HTTPS 9010, matching advertised hostname and verified outbound TLS, while removing 9009.", "components": ["docs"], "sources": ["docs:sd156261650ee", "docs:s4e31b63753ab", "docs:sd78f0c21ad7b", "docs:s6255e1dd351e"], "status": "REASONED"}, "replica-auth": {"text": "Omitted interserver credentials disable auth; allow_empty true admits anonymous peers; match protected secrets across replicas.", "components": ["docs"], "sources": ["docs:s5b738c4b057c"], "status": "REASONED"}, "cluster-tls": {"text": "Distributed-query and Keeper paths require separate TLS configuration beyond replication data HTTPS.", "components": ["docs"], "sources": ["docs:sd78f0c21ad7b"], "status": "REASONED"}, "emulation": {"text": "Remove unused 9004/9005 listeners; PostgreSQL emulation can support TLS.", "components": ["docs"], "sources": ["docs:sd891987369e6", "docs:sfd95effa4d9b"], "status": "REASONED"}, "client-tls": {"text": "Explicit client trust file and certificate hostname are required; extendedVerification defaults false, so enable it.", "components": ["docs"], "sources": ["docs:s6255e1dd351e", "docs:s01de3f86a875"], "status": "REASONED"}, "certificate-auth": {"text": "ssl_certificate CN authentication supplies a machine possession factor, separate from human MFA.", "components": ["docs"], "sources": ["docs:sc7a527d82e50"], "status": "REASONED"}, "totp": {"text": "26.2 introduced XML-user TOTP with password auth; SQL access control lacks it and each code is accepted at most once.", "components": ["docs", "totp"], "sources": ["docs:s4d5d0964fabb", "totp:sf7b9d01ede30"], "status": "REASONED"}, "source-grants": {"text": "Withhold all source privileges and inherited grants; separate READ/WRITE needs 25.7+, filtering 25.8+, and enable_read_write_grants.", "components": ["docs", "sources"], "sources": ["docs:s6b90e2c1092e", "sources:s04a87783572d"], "status": "REASONED"}, "source-interfaces": {"text": "url/HTTP dictionaries fetch remote data, file reads under user_files_path, and remote reaches other servers.", "components": ["docs"], "sources": ["docs:s8db5ea270091", "docs:s78d0544db7eb", "docs:sbb755048d8c0", "docs:s32fae5e0bf85"], "status": "REASONED"}, "url-default": {"text": "Omitted remote_url_allow_hosts allows covered URL hosts; hostname-only permits every port.", "components": ["config", "docs"], "sources": ["config:s5bcf465239d6", "docs:saa2d39175e49"], "status": "REASONED"}, "url-matching": {"text": "Host matching precedes DNS and applies on redirects; host:port does not require HTTPS or constrain every protocol.", "components": ["docs"], "sources": ["docs:saa2d39175e49"], "status": "REASONED"}, "engine-grants": {"text": "OSS engine enforcement defaults disabled; enable it and grant only required engines alongside scoped CREATE TABLE.", "components": ["docs"], "sources": ["docs:s6b90e2c1092e", "docs:scce6af3a28a6"], "status": "REASONED"}, "dictionaries": {"text": "Separate dictionary management from scoped dictGet use; ordinary readers need no management privilege.", "components": ["docs"], "sources": ["docs:s6b90e2c1092e"], "status": "REASONED"}, "executables": {"text": "Administrator-controlled script/UDF discovery paths restrict modification; existing executable objects still need review.", "components": ["docs"], "sources": ["docs:sc172859d92a4", "docs:sba7a666c54ee", "docs:s0be4d04f7874"], "status": "REASONED"}, "direct-exec": {"text": "execute_direct runs the approved program directly; zero invokes a shell; neither is a sandbox.", "components": ["docs"], "sources": ["docs:s247524807405"], "status": "REASONED"}, "log-retention": {"text": "Configure query/session logs and explicit 30-day TTL; deprecated database option is omitted; custom engine conflicts need review.", "components": ["docs"], "sources": ["docs:s3ebb0f281977", "docs:sc67e973f5ab7", "docs:s41bbbde68a83"], "status": "REASONED"}, "log-profile": {"text": "Lock query logging and sampling settings for each relevant account; overlapping profiles can conflict.", "components": ["docs"], "sources": ["docs:s26cbbfd750d7", "docs:s67072795b79e"], "status": "REASONED"}, "log-events": {"text": "Query log records activity/errors, not results; session log records login/logout/failure; inspect every serving interface/node.", "components": ["docs"], "sources": ["docs:scfe38c988cb3", "docs:sde710fc1a785", "docs:s1b518ea764fb"], "status": "REASONED"}, "log-access": {"text": "Require system-table grants, withhold application log access, mask sensitive query text and export to independent storage.", "components": ["docs"], "sources": ["docs:scce6af3a28a6", "docs:sc67e973f5ab7"], "status": "REASONED"}, "log-upgrades": {"text": "Upgrades can rename old log tables; inspect their retention; no separate immutable OSS audit_log or imported Cloud retention is established.", "components": ["docs"], "sources": ["docs:s3ebb0f281977", "docs:s5b17854afa6d", "docs:s6298fa9f4719"], "status": "REASONED"}, "verify-inventory": {"text": "Inventory intended 8443/9440 and optional private 9010; removed listeners absent; local ss proves no remote isolation/authentication.", "components": ["docs"], "sources": ["docs:sd891987369e6", "docs:sd78f0c21ad7b"], "status": "REASONED", "verify": [1]}, "verify-http": {"text": "Matched no_password fixture accepts supplied/empty/wrong passwords; fixed HTTPS requires correct password and returns 1.", "components": ["docs", "curl"], "sources": ["docs:sc7a527d82e50", "docs:s3919007d159e", "curl:s5345a985007a"], "status": "REASONED", "verify": [2]}, "verify-plaintext": {"text": "Any HTTP status on 8123 proves reachability; HTTP 000 needs inventory and successful TLS controls.", "components": ["docs"], "sources": ["docs:sd891987369e6", "docs:sd78f0c21ad7b", "docs:s3919007d159e"], "status": "REASONED", "verify": [2]}, "verify-native": {"text": "Native wrong password needs authentication error/nonzero exit; correct credentials return 1; transport failures are inconclusive.", "components": ["docs"], "sources": ["docs:sc7a527d82e50", "docs:s01de3f86a875"], "status": "REASONED", "verify": [2, 3]}, "verify-tables": {"text": "Fixed reader reads populated events and rejects private_events; test allowed/disallowed origins with matched reachable controls.", "components": ["docs"], "sources": ["docs:sc7a527d82e50", "docs:s25556959ddfa", "docs:s6b90e2c1092e"], "status": "REASONED", "verify": [3]}, "verify-retirement": {"text": "Fresh default login fails after retirement on native/HTTPS while same-origin sql_admin still succeeds.", "components": ["docs"], "sources": ["docs:s324525fe685e", "docs:sfd95effa4d9b", "docs:s01de3f86a875", "docs:s3919007d159e"], "status": "REASONED", "verify": [2, 3]}, "verify-settings": {"text": "Reject unlocked override; inspect four limits and require successful SET ROLE NONE/DEFAULT with account profile retained.", "components": ["roles", "docs"], "sources": ["roles:sa66cde5c2b6e", "docs:s26cbbfd750d7", "docs:sae310a80cd58", "docs:s02fa1a2ea84b", "docs:sfd00c9c1a9a1"], "status": "REASONED", "verify": [3]}, "verify-memory": {"text": "Calibrated aggregation fails at lower memory limit while simple read succeeds; test concurrent user budget and timeout separately.", "components": ["docs"], "sources": ["docs:s02fa1a2ea84b", "docs:sfd00c9c1a9a1", "docs:s201098033e10"], "status": "REASONED", "verify": [3]}, "verify-quota": {"text": "Fresh fixture accepts first two application reads and rejects the third; test restart reset and independent server counters.", "components": ["docs"], "sources": ["docs:sf738cc05299a", "docs:s8d50f48221f7"], "status": "REASONED", "verify": [3]}, "verify-file": {"text": "Isolate FILE privilege with existing fixture and constant prerequisites; missing-file/readonly errors prove no source denial.", "components": ["docs"], "sources": ["docs:s6b90e2c1092e", "docs:sad8b1924b3ce", "docs:s78d0544db7eb"], "status": "REASONED", "verify": [3]}, "verify-url": {"text": "Approved HTTPS fixture succeeds and unlisted host gets allow-list rejection; other errors are inconclusive.", "components": ["docs"], "sources": ["docs:saa2d39175e49", "docs:s8db5ea270091"], "status": "REASONED", "verify": [3]}, "verify-engine": {"text": "With engine enforcement enabled MergeTree succeeds and TinyLog gets engine-privilege denial; remove fixtures between runs.", "components": ["docs"], "sources": ["docs:s6b90e2c1092e", "docs:scce6af3a28a6", "docs:sde8cc2811bb5", "docs:s463e85e20441"], "status": "REASONED", "verify": [3]}, "verify-logs": {"text": "Require new marker/login-failure records, locked logging, reader denial and admin/collector success with correlated provenance.", "components": ["docs"], "sources": ["docs:scce6af3a28a6", "docs:s67072795b79e", "docs:scfe38c988cb3", "docs:sde710fc1a785", "docs:s1b518ea764fb"], "status": "REASONED", "verify": [3]}, "verify-retention": {"text": "Inspect table TTLs and historical tables; expired records disappear while recent ones remain in isolated retention test.", "components": ["docs"], "sources": ["docs:s3ebb0f281977", "docs:s41bbbde68a83", "docs:s2d3e9d1310e4"], "status": "REASONED", "verify": [3]}, "verify-totp": {"text": "Fixed XML human account rejects missing/invalid OTP and accepts password with fresh code; do not reuse positive code.", "components": ["docs"], "sources": ["docs:s4d5d0964fabb"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> neo4j.md ====================================================================== # Neo4j: listen address, initial password, and TLS on Bolt and HTTPS Neo4j is a graph database that stores data as connected nodes and relationships, queried over Bolt or HTTP. This guide helps your AI assistant configure it securely, including the initial password changed before remote access, a deliberate bind, TLS with plain HTTP off, and roles on Enterprise Edition only. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/neo4j.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Neo4j Enterprise 5.26, Packaged Neo4j 5, Neo4j password setting minimum 5.3, Neo4j LOAD minimum 5.13, Neo4j CIDR LOAD minimum 5.16, Cypher Shell history minimum 2025.08, Cypher Shell timeout minimum 2025.12, and Neo4j server timeout exception 4.2 through 5.2, plus 7 more recorded bases; 1 further source is pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 76 claims: 1 demonstrated by a check that ran and 75 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A packaged Neo4j 5 listens on `localhost` only by default and ships with authentication on, but with the well-known `neo4j`/`neo4j` credential, Bolt TLS at `DISABLED`, and plaintext HTTP enabled instead of HTTPS. Setting `server.default_listen_address=0.0.0.0` on such an install to "make it reachable" therefore exposes a database with a guessable password over plaintext. These defaults remain documented at the time of writing. The official Docker image is the exception: its entrypoint sets `server.default_listen_address=0.0.0.0` by default, so publishing a container port exposes the listener immediately, subject to the host's port binding and firewall. Do not assume the packaged loopback boundary protects a published container port. Docker's `NEO4J_AUTH=neo4j/REPLACE_WITH_LONG_RANDOM_VALUE` initializes authentication for a NEW database; it does not rotate an existing database's password. Supply credentials through the execution environment or secret-management mechanism, never as literal command-line arguments, and never use `NEO4J_AUTH=none`, which disables authentication. See [network connectors](https://neo4j.com/docs/operations-manual/5/configuration/connectors/), [Docker authentication](https://neo4j.com/docs/operations-manual/current/docker/introduction/), [Docker configuration](https://neo4j.com/docs/operations-manual/current/docker/configuration/), and the [official Neo4j 5 entrypoint](https://raw.githubusercontent.com/neo4j/docker-neo4j/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/docker-entrypoint.sh). Examples use current configuration names, also used in Neo4j 5 except where a newer option is explicitly marked. Neo4j 4.x names differ. Configuration belongs in `neo4j.conf` unless another file is named. ## 1. Change the initial password before allowing remote access For a packaged, isolated first start, block network access and retain the loopback listen address. Start with authentication enabled, then change the initial password through prompts: ```bash ( unset NEO4J_PASSWORD || { echo "cannot clear NEO4J_PASSWORD"; exit 2; } cypher-shell -a bolt://localhost:7687 -u neo4j --change-password ) ``` Enter the initial password and its long, randomly generated replacement at the prompts. This plaintext loopback connection is only for isolated bootstrap before TLS setup. Use certificate-verified TLS for an already configured remote server. The documented `neo4j-admin dbms set-initial-password` interface is intended for one-time use before the database's first start, not password rotation. Its `--require-password-change=false` option avoids requiring another change at first login. It takes a positional password. The documentation warns that typing a password into the command stores it in shell history. Secret-store substitution and disabling shell history do not remove that password from process arguments such as `/proc//cmdline`; there is no documented stdin or password-file option to substitute here. Cypher Shell provides the prompted alternative. For automation, it also documents `NEO4J_PASSWORD`: supply it through the execution environment, never expand it into `-p`. Environment delivery does not protect against every form of local process inspection. See [initial-password configuration](https://neo4j.com/docs/operations-manual/current/configuration/set-initial-password/) and [Cypher Shell](https://neo4j.com/docs/operations-manual/current/cypher-shell/). At the time of writing, the default minimum password length is 8 characters. `dbms.security.auth_minimum_password_length` was introduced in 5.3; a minimum is not a recommendation to use an eight-character password. Leave `dbms.security.auth_enabled` at its default `true`. The authentication documentation reserves disabling it for recovery with network access blocked. See [configuration changes in Neo4j 5](https://neo4j.com/docs/upgrade-migration-guide/current/version-5/changelogs/configuration-settings/) and [authentication configuration](https://neo4j.com/docs/operations-manual/current/authentication-authorization/). ## 2. Bind deliberately `server.default_listen_address` supplies the host when a connector's listen address omits it. At the time of writing, `server.bolt.listen_address` defaults to `:7687`, `server.http.listen_address` to `:7474`, and `server.https.listen_address` to `:7473`. Keep the default `localhost` unless remote clients are deliberate, then prefer a specific private address over `0.0.0.0`. See [network connector configuration](https://neo4j.com/docs/operations-manual/5/configuration/connectors/). Online backup is Enterprise-only. Its `server.backup.listen_address` defaults to the explicit `127.0.0.1:6362` at the time of writing, so changing the shared listen address does not widen this default; keep it off external interfaces. Community readers should not expect an online-backup listener. See [online backup](https://neo4j.com/docs/operations-manual/current/backup-restore/online-backup/) and the [configuration reference](https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/). Firewall per [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) or [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md), and widen access only after TLS and authentication are configured. ```properties server.default_listen_address=REPLACE_WITH_PRIVATE_IP ``` ### Enterprise cluster and monitoring listeners (Neo4j 5.26) The version-5 vendor pages checked on 2026-09-26 identify themselves as **5.26 (LTS)**. The [ports page](https://neo4j.com/docs/operations-manual/5/configuration/ports/#_cluster) says Enterprise opens cluster ports even on a single server, whether or not it is in a multi-process configuration. Do not infer that a standalone Enterprise deployment has only the client and backup listeners. Discovery and routing have the conditions below. | Traffic | Listen setting and default | Advertised setting and default | Condition | | --- | --- | --- | --- | | Discovery v1 | `server.discovery.listen_address=:5000` | `server.discovery.advertised_address=:5000` | When discovery v1 runs; deprecated since 5.23. | | Transaction shipping and catch-up, also capable of serving backups | `server.cluster.listen_address=:6000` | `server.cluster.advertised_address=:6000` | Enterprise; also discovery v2 when selected. | | Raft communication | `server.cluster.raft.listen_address=:7000` | `server.cluster.raft.advertised_address=:7000` | Enterprise. | | Server-side routing over an additional Bolt connector | `server.routing.listen_address=:7688` | `server.routing.advertised_address=:7688` | Server-side routing enabled; `dbms.routing.enabled` defaults to `true`. | All four listen defaults inherit their host from `server.default_listen_address`, whose packaged default is `localhost`. Their advertised defaults inherit `server.default_advertised_address`, also `localhost`; advertising an address does not restrict a bind. Set advertised addresses to names or private IPs reachable by the other members, and match any changed ports. See the [configuration reference](https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/#config_server.cluster.listen_address) and [cluster settings](https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/). Discovery v2 is documented for deployment from 5.23 and uses 6000 instead of a separate 5000 listener. The 5.26 reference still defaults `dbms.cluster.discovery.version` to `V1_ONLY`; `V2_ONLY` runs only v2, while `V1_OVER_V2` and `V2_OVER_V1` run both. Do not drop 5000 from the inventory merely because the version is 5.23 or later. The selector and v2 endpoints carry introduction labels of 5.22, while the deployment instructions describe v2 from 5.23. See [the selector](https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/#config_dbms.cluster.discovery.version) and [deployment instructions](https://neo4j.com/docs/operations-manual/5/clustering/setup/deploy/#cluster-example-configure-a-three-primary-cluster). Keep cluster ports reachable only by cluster members on a private network, with a firewall allowlist even after enabling TLS. Port 6000 permits **unauthenticated database replication** by default, and a backup client can use it too. Protecting 6362 alone does not protect this data path. For a single-server deployment without clustering, explicitly retain loopback on the cluster listeners even when client connectors need a private remote bind: ```properties # Enterprise 5.26; discovery setting applies when v1 runs. server.discovery.listen_address=localhost:5000 server.cluster.listen_address=localhost:6000 server.cluster.raft.listen_address=localhost:7000 server.routing.listen_address=localhost:7688 ``` For a cluster, use the intended private interface instead of those loopback hosts. Enable the SSL framework's **`cluster`** scope on every member: it covers discovery, transaction shipping, Raft, and server-side routing, including 7688. The policy is disabled by default, so default cluster traffic has neither TLS encryption nor TLS client-certificate authentication. The documented default `client_auth=REQUIRE` only takes effect once the policy is enabled; a database password does not secure the unauthenticated replication endpoint. See [cluster ports](https://neo4j.com/docs/operations-manual/5/configuration/ports/#_cluster) and [intra-cluster SSL configuration](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-cluster-config). Before enabling it, create `certificates/cluster`, its `trusted` and `revoked` directories, and install each member's own PKCS#8 PEM `private.key` and matching `public.crt`. Make the key readable only by the Neo4j service account. Populate each member's trust directory with the approved peer certificates or their trusted CA as described by the vendor; retain `trust_all=false`. Cluster certificates must include both **TLS Web Server Authentication** and **TLS Web Client Authentication** in Extended Key Usage, because each member authenticates as a client to its peers. Inspect each member's certificate with `openssl x509 -in public.crt -noout -text` and look for both usages under `X509v3 Extended Key Usage`. In `public.crt`, concatenate PEM certificates leaf first, then toward the root. See the [vendor certificate requirements](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-certificates). Use the same policy settings on every member, with distinct private keys and certificates: ```properties dbms.ssl.policy.cluster.enabled=true dbms.ssl.policy.cluster.base_directory=certificates/cluster dbms.ssl.policy.cluster.private_key=private.key dbms.ssl.policy.cluster.public_certificate=public.crt dbms.ssl.policy.cluster.client_auth=REQUIRE ``` This does not enable TLS on the separate 6362 backup connector, which uses the **`backup`** scope, or replace the client-facing Bolt and HTTPS policies below. See [SSL policy defaults](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-configuration) and [backup SSL](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-backup-config). **Prometheus is separate from clustering.** Enterprise's `server.metrics.prometheus.enabled` defaults to `false`; if enabled on either a standalone server or a cluster member, `server.metrics.prometheus.endpoint` defaults to the explicit `localhost:2004`. That default does not inherit a widened shared host; an explicitly hostless `:2004` does. No Prometheus scope is listed in the SSL framework, so do not assume the cluster policy protects metrics. Keep it loopback-only or reachable only by authorized monitoring systems. Graphite's `server.metrics.graphite.server=:2003` is an outbound destination, disabled by default, not a Neo4j listener. JMX 3637 and debugging 5005 in the ports table require explicit JVM options; do not enable remote management or debugging as part of this baseline. See [Prometheus settings](https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/#config_server.metrics.prometheus.endpoint) and [monitoring ports](https://neo4j.com/docs/operations-manual/5/configuration/ports/#_graphite_monitoring). **Docker:** at the guide's pinned entrypoint commit, the default `server.default_listen_address=0.0.0.0` applies to both editions and widens all four hostless cluster listen defaults when those listeners run. It does not override explicit listener hosts, the backup default, or the Prometheus default. Enterprise additionally defaults the four cluster advertised addresses to the container hostname and their respective ports, and maps legacy `NEO4J_causal__clustering_*` advertised-address variables to the newer names. Explicit configuration overrides Docker defaults; environment configuration then overrides file values. For a real cluster, use peer-reachable private addresses and the cluster TLS policy, and restrict container-network access as well as host publication. All three Dockerfile variants declare only `EXPOSE 7474 7473 7687`; that declaration is not a listener inventory or firewall. See the [entrypoint](https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/docker-entrypoint.sh#L547-L617) and [Debian Dockerfile](https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-debian#L53). For the single-server loopback overrides above, use these Docker environment entries. They are derived from the pinned entrypoint's translation rules: prefix `NEO4J_`, replace each setting underscore with `__`, and each dot with `_`. The entrypoint reverses that translation before writing the setting. See [the naming convention](https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/docker-entrypoint.sh#L549-L555) and [the translation](https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/docker-entrypoint.sh#L607). | Environment name | Value | | --- | --- | | `NEO4J_server_discovery_listen__address` | `localhost:5000` | | `NEO4J_server_cluster_listen__address` | `localhost:6000` | | `NEO4J_server_cluster_raft_listen__address` | `localhost:7000` | | `NEO4J_server_routing_listen__address` | `localhost:7688` | ## 3. TLS on Bolt and HTTPS, HTTP off Put a PKCS#8 PEM private key and certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) under each policy directory. Use the service account as owner, normally `neo4j:neo4j`, with key mode `0400` and certificate mode `0644`. Legacy PKCS#1 keys, whose PEM header labels the key type as RSA rather than the generic PKCS#8 form, are deprecated; convert them to the documented PKCS#8 format. `server.bolt.tls_level=REQUIRED` refuses unencrypted Bolt; `OPTIONAL` continues accepting it. `server.http.enabled=false` removes the plaintext HTTP endpoint. This baseline uses server authentication through TLS and database credentials for clients. For machine clients that can hold certificates, replace `dbms.ssl.policy.bolt.client_auth=NONE` with `dbms.ssl.policy.bolt.client_auth=REQUIRE` to add mutual TLS, and configure the policy's trusted certificates and the clients' certificates and private keys. See the [SSL framework](https://neo4j.com/docs/operations-manual/current/security/ssl-framework/). ```properties dbms.ssl.policy.bolt.enabled=true dbms.ssl.policy.bolt.base_directory=certificates/bolt dbms.ssl.policy.bolt.private_key=private.key dbms.ssl.policy.bolt.public_certificate=public.crt dbms.ssl.policy.bolt.client_auth=NONE server.bolt.tls_level=REQUIRED dbms.ssl.policy.https.enabled=true dbms.ssl.policy.https.base_directory=certificates/https dbms.ssl.policy.https.private_key=private.key dbms.ssl.policy.https.public_certificate=public.crt dbms.ssl.policy.https.client_auth=NONE server.https.enabled=true server.http.enabled=false ``` ## 4. Users and roles Use a separate account per application instead of sharing `neo4j` ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)). Community Edition supports multiple native users, but every user has implied administrator privileges. It has no roles or fine-grained privilege system. A separate Community username does not create a restricted database identity. See [Manage users](https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-users/). For native authentication, failed logins trigger `dbms.security.auth_lock_time` after `dbms.security.auth_max_failed_attempts`. At the time of writing, these defaults remain `5s` and `3`. These controls do not establish the lockout policy of an external identity provider. See [authentication configuration](https://neo4j.com/docs/operations-manual/current/authentication-authorization/). **Enterprise-only: restrict the application's database, labels, and properties.** An application credential should expose only the data it needs. Enterprise supports built-in roles `reader`, `editor`, `publisher`, `architect`, and `admin`, plus custom roles. `editor` and `publisher` provide write capabilities; choose only the required privileges for writers. The built-in `reader` role grants broad graph reads and access across databases; it is not a database-specific application role. Create a custom role for narrower access and keep administrator credentials out of application configuration. The example permits reading two properties on `Product` nodes in the existing `neo4j` database. See [built-in roles](https://neo4j.com/docs/operations-manual/current/authentication-authorization/built-in-roles/), [database access privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/database-administration/), and [MATCH privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/privileges-reads/). Run this Cypher as an administrator in an interactive session with persistent history disabled. Substitute the password there; do not pass this password-bearing statement as a Cypher Shell command-line argument. The guarded session in Verify can be used with the administrator username and `-d system`. ```cypher CREATE ROLE catalog_reader; CREATE USER catalog_app SET PASSWORD 'REPLACE_WITH_LONG_RANDOM_VALUE' CHANGE NOT REQUIRED SET HOME DATABASE neo4j; GRANT ACCESS ON DATABASE neo4j TO catalog_reader; GRANT MATCH {sku, name} ON GRAPH neo4j NODES Product TO catalog_reader; GRANT ROLE catalog_reader TO catalog_app; SHOW USER catalog_app PRIVILEGES AS COMMANDS; ``` Use a fresh application account and role. Adding a narrow role to an existing account does not remove its broader roles. Review all effective privileges, including inherited `PUBLIC` grants, before switching the application. These are current documented user, role, and privilege commands; they do not depend on newer property predicates, tags, or authentication rules. See [Manage roles](https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-roles/) and [showing user privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-privileges/). Community Edition cannot enforce these grants. Restrict direct database access to trusted services and enforce end-user authorization in the application or a fronting service that controls the operations exposed. MFA: native password authentication has no second-factor step. Enterprise can delegate authentication to an OIDC provider whose policy enforces MFA ([oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md)). Enterprise's LDAP `simple` authentication uses a username and password; that alone is not MFA. Confirm second-factor enforcement and ensure alternate native or password paths cannot bypass it ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). For services that can hold client certificates, mutual TLS adds a possession factor alongside database credentials. Put every human access path to the host and application behind MFA. See [SSO integration](https://neo4j.com/docs/operations-manual/current/authentication-authorization/sso-integration/) and [LDAP integration](https://neo4j.com/docs/operations-manual/current/authentication-authorization/ldap-integration/). ## 5. Restrict procedures, file import, and outbound requests Authentication and roles decide who connects and what data they can touch. Enterprise can additionally restrict procedure execution and data loading, but read-only graph access does not provide those restrictions by itself. Community has no equivalent role privileges, so application controls, plugin configuration, and network egress restrictions carry the containment. **Enterprise-only: deny capabilities the application does not need.** At the time of writing, the default `PUBLIC` role allows procedure execution, user-defined functions, and data loading. For the application above, if it needs ordinary Cypher queries but none of those capabilities, run as an administrator: ```cypher DENY EXECUTE PROCEDURES * ON DBMS TO catalog_reader; DENY EXECUTE FUNCTIONS * ON DBMS TO catalog_reader; DENY LOAD ON ALL DATA TO catalog_reader; SHOW USER catalog_app PRIVILEGES AS COMMANDS; ``` These denials affect users holding `catalog_reader`; they do not change `PUBLIC` for every user. Built-in Cypher functions remain executable. A narrower `GRANT` cannot override a matching `DENY`. If an application needs selected extensions, design explicit execution grants and review inherited `PUBLIC` privileges instead of appending exceptions to these blanket denials. See [PUBLIC privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/built-in-roles/), [EXECUTE privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/dbms-administration/dbms-execute-privileges/), and [GRANT and DENY semantics](https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-privileges/). `LOAD ON ALL DATA` requires Enterprise Neo4j 5.13 or later; CIDR-specific load privileges followed in 5.16. Both are present in current documentation. See [LOAD privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/load-privileges/) and [Neo4j 5 additions](https://neo4j.com/docs/cypher-manual/5/deprecations-additions-removals-compatibility/). Do not grant application roles boosted execution. APOC documents that boosted procedures can bypass graph and loading restrictions. Keep these additional boundaries: - Keep `dbms.security.procedures.unrestricted` empty unless a reviewed extension specifically requires access to internal APIs. Unrestricted extensions can bypass security, as the [configuration reference](https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/) warns. Narrow `dbms.security.procedures.allowlist` to the procedures and functions the workload needs; its default is `*` at the time of writing. This controls which extensions load, rather than providing Community with per-user execution privileges. Install only necessary plugins. See [securing extensions](https://neo4j.com/docs/operations-manual/current/security/securing-extensions/). - If APOC is installed, put its settings in `apoc.conf`, not `neo4j.conf`, for Neo4j 5 and later. Retain `apoc.import.file.enabled=false` unless local file import is required, and keep `apoc.import.file.use_neo4j_config=true` so Neo4j's file-access and configured import-directory checks still apply. These are the defaults in the [APOC configuration reference](https://neo4j.com/docs/apoc/current/config/). - Set `dbms.security.allow_csv_import_from_file_urls=false` when local `LOAD CSV` is unnecessary. The current [Neo4j configuration reference](https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/) directly documents its default as `true`. The APOC security overview disagrees on that default and uses an inconsistent APOC setting spelling; use the configuration references for these values and the dotted `apoc.import.file.use_neo4j_config` spelling. - `LOAD CSV` and APOC procedures such as `apoc.load.json` and `apoc.load.jdbc` can make outbound requests, creating an SSRF risk when queries can select internal destinations. `apoc.load.jdbc` belongs to APOC Extended and requires a compatible JDBC driver; see its [vendor reference](https://neo4j.com/labs/apoc/5/overview/apoc.load/apoc.load.jdbc/). Restrict egress so queries cannot reach internal services or cloud metadata at `169.254.169.254` ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). Where supported, set `internal.dbms.cypher_ip_blocklist` to block internal CIDRs; check compatibility with the installed version and retain network egress restrictions. See [APOC security guidance](https://neo4j.com/docs/apoc/current/security-guidelines/) and [Protecting against SSRF](https://support.neo4j.com/s/article/8584271681427-Protecting-against-Server-Side-Request-Forgery-SSRF), the current destination of the former `https://neo4j.com/developer/kb/protecting-against-ssrf/` URL. ## 6. Bound transaction memory and set a default timeout An authenticated client can exhaust resources with expensive queries. The following settings are available in Community and Enterprise in the current configuration reference. These values are example budgets, not vendor defaults or universal production sizing: ```ini db.transaction.timeout=30s db.memory.transaction.max=64m db.memory.transaction.total.max=256m dbms.memory.transaction.total.max=512m ``` The memory limits cover one transaction, all transactions in one database, and all transactions on the server, respectively. Size them against the configured heap and measured workload, leaving room for other heap consumers. Transactions that reach tracked memory limits are terminated. Accounting is estimated and workload-dependent, so these limits do not describe exact process memory consumption. See [transaction memory configuration](https://neo4j.com/docs/operations-manual/current/performance/memory-configuration/) and the [exact settings](https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/). The timeout is a default, not a hard ceiling against hostile clients. A client-supplied transaction timeout can override it with a larger value. Restrict direct database access and enforce application request budgets too. The current Python driver reference specifically distinguishes servers 4.2 through 5.2 inclusive, which ignore overrides larger than the server default. See [transaction timeout behavior](https://neo4j.com/docs/operations-manual/current/database-internals/transaction-management/) and [driver timeout semantics](https://neo4j.com/docs/api/python-driver/current/api.html). ## 7. Retain security events without unnecessary query secrets **Enterprise-only:** retain `security.log` and `query.log`. With authentication enabled, the security log records login events, administration commands, and authorization failures. Keep successful-login events as well. Configure query logging while suppressing parameter values and obfuscating literals: ```ini dbms.security.log_successful_authentication=true db.logs.query.enabled=VERBOSE db.logs.query.parameter_logging_enabled=false db.logs.query.obfuscate_literals=true db.logs.query.early_raw_logging_enabled=false ``` At the time of writing, `VERBOSE` is already the current default and records query starts and finishes. Setting it explicitly records the intended policy. Protect and collect both logs, and review retention and rotation in `conf/server-logs.xml`. See [logging configuration and edition availability](https://neo4j.com/docs/operations-manual/current/monitoring/logging/). When changing literal obfuscation dynamically, clear cached queries as an administrator so the change affects them immediately: ```cypher CALL db.clearQueryCaches(); ``` On versions supporting error obfuscation, also set: ```ini db.logs.query.obfuscate_errors=true ``` The Neo4j 5 configuration reference marks `db.logs.query.obfuscate_errors` as introduced in **5.26.21**; the current-series reference marks it as introduced in **2026.01.3**. It is therefore available in Neo4j 5.26.21 and later 5.26 patches, as well as current releases that include it. Check the exact installed version before adding the setting; do not add it to an older release that lacks it. Literal obfuscation alone does not sanitize error details. See the [Neo4j 5 setting](https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/#config_db.logs.query.obfuscate_errors) and the [current-series setting](https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/#config_db.logs.query.obfuscate_errors). Obfuscation does not hide every identifier or query detail. Treat the logs as sensitive. Community provides operational logs and optional HTTP request logging: ```ini dbms.logs.http.enabled=true ``` Enable HTTP request logging only when an HTTP/HTTPS connector is in use. It does not provide equivalent Bolt, query, or security auditing; collect application and fronting-layer events as well. See [available log files](https://neo4j.com/docs/operations-manual/current/monitoring/logging/). ## Verify REASONED: every service comparison in this Verify section, per the guide's cited Configure network connectors (Neo4j 5), Cypher Shell prompts, environment credentials, history, parameters, and timeouts, OpenSSL 3 TLS probe options, TLS 1.3 client-certificate validation and alerts, Transactional HTTP API deprecation and Query API availability entries and the other entries listed under Sources. During marking review (October 2026) neo4j, cypher-shell, docker and podman were still absent from the authoring environment, with no network access to obtain them; the local checks that did run are recorded in the next paragraph. **Service verification status: REASONED, not demonstrated.** The authoring environment has no Neo4j server, Cypher Shell, Docker, or Podman available, and no authorized running deployment was supplied. All service comparisons below require capabilities missing here. Run exposed/fixed comparisons only in disposable test deployments. Record server, edition, client, and plugin versions alongside results. REASONED: local syntax, guard and fragment-parsing checks only, which observe no Neo4j service. During marking review (October 2026) they were repeated: bash -n under bash 5.3.9 and ShellCheck 0.11.0 passed all seven shell blocks. The guard checks during marking review (October 2026), with stub ss, openssl, curl and cypher-shell and no network, showed that each of the six guarded blocks refused unchanged, partial and miscounted input and example.com or angle-bracket hosts, and that each block that clears NEO4J_PASSWORD refused when it was readonly. The cluster block, during marking review (October 2026), also refused a malformed DNS name, port or URI, and with locally generated fixtures its three s_client calls reached the stub, while openssl verify accepted member.crt and rejected untrusted.crt (error 20) against ca.pem. The eight configuration fragments, during marking review (October 2026), showed no malformed lines or duplicate keys. Local validation of this revision: all seven shell blocks passed `bash -n` and ShellCheck 0.11.0. Guard-only checks exercised placeholder and malformed marker/count rejection in all six guarded blocks. Eight configuration fragments passed basic key/value parsing and duplicate-key checks. These are local syntax checks, not Neo4j configuration validation or service verification. Both generated bundles were rebuilt and the whole-corpus gate suite passed with ShellCheck 0.11.0; no Neo4j service was run. The bash -n, ShellCheck, guard and stub runs recorded in this paragraph covered an earlier form of the section 1 block and the six guarded blocks, whose refusals exited with status 1. The six guarded Verify blocks now each end with one exit status covering every probe they classify and every command that fails: 1 for a finding, 3 for an inconclusive result, 2 for a guard refusal and 0 otherwise, and each block that runs its probes ends with a line naming what it leaves you to judge. The current forms are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes over every block, and in October 2026, while this change was drafted, the six guarded Verify blocks also ran with stub ss, openssl, timeout, curl and cypher-shell and no network, the two HTTPS pairs parsing their stub responses with the local python3. REASONED: guard behaviour only, with stub commands and no Neo4j service. During marking review (October 2026) each guarded block, pasted whole with a hostname substituted inside the quotes, reached only its stub commands, and unchanged or without its set -- line it refused. A substituted value containing one literal apostrophe made the listener block a syntax error that ran nothing, and its ss line pasted alone ran unguarded. A shadowed builtin was not tested. Those stub runs covered an earlier form of each guarded block, whose refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the drafting stub runs recorded in the preceding paragraph. For each guarded block, replace the hostname inside the single quotes and paste the whole block. The blocks assume ordinary shell builtins; do not insert a literal apostrophe into the quoted substitution. A fragment pasted below the guards is unguarded. REASONED: per the guide's cited SSL framework and certificate-verified connection schemes entry; no Cypher Shell or Neo4j TLS service is available in the authoring environment, so a +s connection failing on an untrusted certificate (fixed) and a +ssc connection succeeding regardless (exposed) were not observed. During marking review (October 2026), with stubs, the Verify blocks passed bolt+s:// URIs to cypher-shell everywhere except the deliberate plaintext bolt:// test. Those stub runs covered an earlier form of the guarded blocks, whose refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the drafting stub runs recorded with the local checks above. Use `neo4j+s://` for certificate-verified routing connections or `bolt+s://` for a certificate-verified direct connection. The `+ssc` variants, including `neo4j+ssc://`, skip certificate verification and do not establish server identity; reserve them for development. The checks below use direct Bolt to avoid routing discovery obscuring the result. Install the signing CA in the Cypher Shell client's trust store when necessary; OpenSSL's `-CAfile` and curl's `--cacert` do not configure Cypher Shell's trust. See [TLS connection schemes](https://neo4j.com/docs/operations-manual/current/security/ssl-framework/). ### Listener inventory ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NEO4J_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "expected exactly one hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the hostname inside the quotes; not probing" exit 2 ;; esac # Run inside the Neo4j service's network namespace. # The hostname is a confirmation only; ss does not connect to it. command -v ss >/dev/null 2>&1 || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- "$1" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the hostname, the listener table, then ss's exit status printf '%s' "$2" if [ "$3" != 0 ]; then echo "ss -tlnp exit $3: the listener table could not be read; inconclusive"; set -- 3 elif printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:7474$/ { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 7474, plaintext HTTP, which the fixed state leaves absent"; set -- 1 else case "$?" in 1) if printf '%s' "$2" | awk 'NR > 1 { found = 1 } END { exit !found }'; then echo "no 7474 listener"; set -- 0 else case "$?" in 1) echo "the listener table is empty, and an empty inventory is not a secure-service result; inconclusive"; set -- 3 ;; *) echo "awk could not read the listener list; inconclusive"; set -- 3 ;; esac fi ;; *) echo "awk could not read the listener list; inconclusive"; set -- 3 ;; esac fi echo "block exit $1; listeners other than 7474 are not classified, so judge them yourself against the expectations below" exit "$1" ) ``` REASONED: preceding block; the listener inventory block above and these port expectations, per the guide's cited Ports, Enterprise online backup and Configure network connectors (Neo4j 5) entries. The block requires the Neo4j service's network namespace and, for the Enterprise comparisons, a standalone Enterprise server and an Enterprise cluster, all unavailable in the authoring environment. Run the block inside that namespace; the substituted hostname identifies the deployment being inspected and does not make `ss` inspect a remote machine. During marking review (October 2026), in the authoring environment, a real ss -tlnp reported `Cannot open netlink socket: Operation not permitted`; the listener inventory could not be established from that run. No Neo4j installation is available in the authoring environment, so neither an exposed 7474 listener nor the fixed inventory was observed. During marking review (October 2026) the block ran with a stub ss and no network: it refused unchanged, partial, miscounted, example.com and angle-bracket input, and reached ss -tlnp only with neo4j-1.internal substituted. Those stub runs covered an earlier form of the block, whose refusals exited with status 1. The block now exits 1 when a listener is on port 7474, exits 3 when `ss` or `awk` fails or the table is empty, refuses with status 2 when `ss` is not installed, and otherwise exits 0, leaving the other listeners for you to judge; this form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the drafting stub runs recorded with the local checks above. Compare an exposed disposable installation with the configured state. Expect Bolt 7687 and HTTPS 7473 on the intended address after setup, with plaintext HTTP 7474 absent. If Enterprise online backup is running, confirm 6362 remains loopback-only for this baseline. This backup observation does not apply to Community. Confirm the retained listeners actually serve the positive queries below; an empty inventory is not a secure-service result. See [ports](https://neo4j.com/docs/operations-manual/current/configuration/ports/) and [online backup](https://neo4j.com/docs/operations-manual/current/backup-restore/online-backup/). REASONED: per the guide's cited Neo4j 5.26 (LTS) Enterprise cluster and monitoring ports, cluster settings and configuration reference entries; no Enterprise standalone server, cluster or Docker host is available in the authoring environment. No listener inventory was obtained, so nothing is recorded about these ports. **REASONED, Enterprise listener comparisons:** also read every listener for 5000 (discovery v1 only), 6000, 7000, and 7688 (server-side routing), including configured replacement ports and IPv6 bindings. In the exposed disposable state, a widened shared host can expose these even on a standalone server; in the fixed single-server state, expect loopback binds. In a cluster, expect the intended private binds and peer-reachable advertised addresses, with firewall access confined to members. Under `V2_ONLY`, do not require 5000; discovery shares 6000. Check 2004 only if Prometheus is enabled, on loopback or the explicitly restricted monitoring interface. There should be no inbound Graphite 2003 listener from enabling its exporter; investigate any JMX 3637 or debugger 5005 listener against the JVM configuration. Inspect the container's namespace as well as host publication rules for Docker. These expectations follow the [5.26 ports inventory](https://neo4j.com/docs/operations-manual/5/configuration/ports/#_cluster) and [setting defaults](https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/). ### Cluster mutual TLS (REASONED: cluster policy and probe expectations follow the cited Neo4j, OpenSSL and TLS sources; no disposable Enterprise cluster is available) **REASONED, not demonstrated: the authoring host forbids opening listeners without an isolated network namespace, and has none.** A disposable Enterprise cluster and its certificates are required. A listener inventory does not prove TLS or peer authentication. The vendor's [Nmap cipher enumeration](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-cluster-config) establishes that TLS is offered, not rejection of unauthenticated peers. The OpenSSL procedure below is reasoned from the [documented cluster mutual-authentication policy](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-cluster-config); it is not a vendor-documented cluster-port check. REASONED: the probe targets and the values the block below takes, per the guide's cited Neo4j 5.26 ports inventory and cluster SSL policy entries; no disposable Enterprise cluster is available in the authoring environment. Run from an allowed peer location against each member and every active cluster listener: 6000, 7000, 5000 when discovery v1 runs, and 7688 when server-side routing runs, or their configured replacements. Substitute the member's certificate DNS name, cluster port, direct client Bolt TLS URI, and `none` or `require` for the members' current client_auth policy inside the quotes. The Bolt URI uses the client connector, normally 7687, not the cluster port. Install the Bolt signing CA in Cypher Shell's trust store as described above; the command assumes section 3's Bolt policy and prompts for the administrator password. Provide `ca.pem` containing the cluster server's trusted signing CA; `member.crt` and `member.key` for a trusted test member; and `untrusted.crt` and `untrusted.key` for an otherwise valid certificate signed by a different CA absent from every member's trust directory. Both certificates need the EKUs above, current validity, and matching keys. Supply each issuing chain in `member-chain.pem` or `untrusted-chain.pem`, starting with the issuing CA; for direct root issuance, use that root certificate. Keep the private keys protected and do not add the untrusted CA to the cluster's trust directory. Use OpenSSL 3 and a `timeout` command supporting `15s`. `-cert_chain` supplies the client chain, `-verify_hostname` and `-verify_return_error` enforce server verification, and `-ign_eof` keeps the client reading after stdin closes so a later TLS alert is visible. The time limit bounds that wait. See [OpenSSL s_client](https://docs.openssl.org/3.0/man1/openssl-s_client/) and [timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html). ```bash ( set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLUSTER_DNS_NAME' 'REPLACE_WITH_CLUSTER_PORT' 'REPLACE_WITH_BOLT_TLS_URI' 'REPLACE_WITH_none_OR_require' # the 4th value is the members' current policy: none for client_auth=NONE, require for client_auth=REQUIRE with trust_all=false [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "expected a DNS name, cluster port, Bolt TLS URI, and none or require; not probing"; exit 2; } case "$1|$2|$3|$4" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo "substitute all four values inside the quotes; not probing"; exit 2 ;; esac case "$1" in ""|*[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-]*) echo "supply the member's certificate DNS name; not probing"; exit 2 ;; esac case "$2" in ""|*[!0123456789]*) echo "supply a numeric cluster port; not probing"; exit 2 ;; esac [ "${#2}" -le 5 ] || { echo "cluster port must be 1 through 65535; not probing"; exit 2; } [ "$2" -ge 1 ] || { echo "cluster port must be 1 through 65535; not probing"; exit 2; } [ "$2" -le 65535 ] || { echo "cluster port must be 1 through 65535; not probing"; exit 2; } case "$3" in bolt+s://?*) ;; *) echo "supply a direct bolt+s:// URI; not probing"; exit 2 ;; esac case "$4" in none|require) ;; *) echo "supply none (client_auth=NONE on every member) or require (client_auth=REQUIRE with trust_all=false) as the 4th value; not probing"; exit 2 ;; esac if [ -r ca.pem ] && [ -r member.crt ] && [ -r member.key ] && [ -r member-chain.pem ] && [ -r untrusted.crt ] && [ -r untrusted.key ] && [ -r untrusted-chain.pem ]; then :; else echo "provide the CA, both certificates, keys, and issuing chains; not probing"; exit 2 fi unset NEO4J_PASSWORD || { echo "cannot clear NEO4J_PASSWORD; not probing"; exit 2; } set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Each probe below reads as: alert (a received fatal TLS alert), held (a completed handshake, no alert, # and the connection still open when the 15s wait expired, exit 124), closed (a completed handshake, # no alert, then a clean end, exit 0), failed (a completed handshake, no alert, then any other exit # status, such as a reset), or neither. The probe's exit status is kept beside its reading. echo "(a) No client certificate" set -- "$1" "$2" "$3" "$4" "$5" "$(if timeout 15s openssl s_client -connect "$1:$2" -servername "$1" \ -verify_hostname "$1" -verify_return_error -CAfile ca.pem \ -state -brief -ign_eof &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" # the status, the probe's output, then its exit status printf '%s\n' "$6" printf 'no-client exit=%s\n' "$7" case "$6" in *'alert read:fatal'*|*'sslv3 alert '*|*'tlsv1 alert '*|*'tlsv13 alert '*) set -- "$1" "$2" "$3" "$4" "$5" alert "$7" ;; *'CONNECTION ESTABLISHED'*) case "$7" in 124) set -- "$1" "$2" "$3" "$4" "$5" held "$7" ;; 0) set -- "$1" "$2" "$3" "$4" "$5" closed "$7" ;; *) set -- "$1" "$2" "$3" "$4" "$5" failed "$7" ;; esac ;; *) set -- "$1" "$2" "$3" "$4" "$5" neither "$7" ;; esac case "$4:$6" in require:alert) echo "(a) $6, as $4 should give; correlate a generic alert with the member's logs" ;; none:held|none:closed) echo "(a) $6, as $4 should give; this shows only that the client_auth=NONE baseline behaved as declared, not that mutual TLS works" ;; *:failed) echo "(a) failed: the handshake completed, then the client exited $7 without a received alert or an expired 15s wait, an operational failure such as a reset rather than a certificate rejection; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; require:held) echo "FINDING: (a) completed the handshake without a client certificate and was not rejected within 15s"; set -- "$1" "$2" "$3" "$4" 1 ;; require:closed) echo "(a) closed: the connection ended after the handshake without a received alert, so correlate it with the member's logs; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; none:alert) echo "(a) alert under none, where the baseline should complete the handshake; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "(a) neither a received alert nor a completed handshake: a refusal, DNS failure, reset, local key-loading failure or timeout alone proves nothing about mutual TLS; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "(b) Certificate from an untrusted CA" set -- "$1" "$2" "$3" "$4" "$5" "$(if timeout 15s openssl s_client -connect "$1:$2" -servername "$1" \ -verify_hostname "$1" -verify_return_error -CAfile ca.pem \ -cert untrusted.crt -key untrusted.key -cert_chain untrusted-chain.pem \ -state -brief -ign_eof &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" # the status, the probe's output, then its exit status printf '%s\n' "$6" printf 'untrusted-client exit=%s\n' "$7" case "$6" in *'alert read:fatal'*|*'sslv3 alert '*|*'tlsv1 alert '*|*'tlsv13 alert '*) set -- "$1" "$2" "$3" "$4" "$5" alert "$7" ;; *'CONNECTION ESTABLISHED'*) case "$7" in 124) set -- "$1" "$2" "$3" "$4" "$5" held "$7" ;; 0) set -- "$1" "$2" "$3" "$4" "$5" closed "$7" ;; *) set -- "$1" "$2" "$3" "$4" "$5" failed "$7" ;; esac ;; *) set -- "$1" "$2" "$3" "$4" "$5" neither "$7" ;; esac case "$4:$6" in require:alert) echo "(b) $6, as $4 should give; correlate a generic alert with the member's logs" ;; none:held|none:closed) echo "(b) $6, as $4 should give; this shows only that the client_auth=NONE baseline behaved as declared, not that mutual TLS works" ;; *:failed) echo "(b) failed: the handshake completed, then the client exited $7 without a received alert or an expired 15s wait, an operational failure such as a reset rather than a certificate rejection; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; require:held) echo "FINDING: (b) completed the handshake with a certificate from an untrusted CA and was not rejected within 15s"; set -- "$1" "$2" "$3" "$4" 1 ;; require:closed) echo "(b) closed: the connection ended after the handshake without a received alert, so correlate it with the member's logs; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; none:alert) echo "(b) alert under none, where the baseline should complete the handshake; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "(b) neither a received alert nor a completed handshake: a refusal, DNS failure, reset, local key-loading failure or timeout alone proves nothing about mutual TLS; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "(c) Trusted member certificate" set -- "$1" "$2" "$3" "$4" "$5" "$(if timeout 15s openssl s_client -connect "$1:$2" -servername "$1" \ -verify_hostname "$1" -verify_return_error -CAfile ca.pem \ -cert member.crt -key member.key -cert_chain member-chain.pem \ -state -brief -ign_eof &1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" # the status, the probe's output, then its exit status printf '%s\n' "$6" printf 'trusted-client exit=%s\n' "$7" case "$6" in *'alert read:fatal'*|*'sslv3 alert '*|*'tlsv1 alert '*|*'tlsv13 alert '*) set -- "$1" "$2" "$3" "$4" "$5" alert "$7" ;; *'CONNECTION ESTABLISHED'*) case "$7" in 124) set -- "$1" "$2" "$3" "$4" "$5" held "$7" ;; 0) set -- "$1" "$2" "$3" "$4" "$5" closed "$7" ;; *) set -- "$1" "$2" "$3" "$4" "$5" failed "$7" ;; esac ;; *) set -- "$1" "$2" "$3" "$4" "$5" neither "$7" ;; esac case "$6" in held|closed) echo "(c) $6: the trusted member completed the handshake, as both policies should give" ;; failed) echo "(c) failed: the handshake completed, then the client exited $7 without a received alert or an expired 15s wait, an operational failure such as a reset; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "(c) $6: the trusted member did not complete the handshake, so the positive control failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 ;; esac echo "(d) Cluster status: enter the administrator password at the prompt" if cypher-shell -a "$3" -u neo4j -d system 'SHOW SERVERS;'; then echo "(d) unclassified observation: the expected members must show state Enabled and health Available, and missing or unavailable members invalidate the positive control" else echo "cypher-shell exit $?: SHOW SERVERS did not complete, so the positive control failed; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 fi echo "block exit $5; the SHOW SERVERS rows, each handshake's protocol, cipher and server verification, and the reason behind any generic alert are not classified, so judge them yourself and correlate them with the member's logs" exit "$5" ) ``` **REASONED outcomes:** in a disposable, network-isolated comparison, first enable cluster TLS with `client_auth=NONE` on every member: all three TLS probes should complete a handshake. Then use `client_auth=REQUIRE` with `trust_all=false` on every member: (a) must be rejected for lacking a client certificate, (b) for an untrusted issuer, and (c) must complete the handshake. This TLS-enabled baseline isolates the client-authentication control. Against the default TLS-disabled cluster policy, `s_client` cannot complete a TLS handshake; that failure is not evidence of peer authentication. See [policy settings and defaults](https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-configuration). For (a) and (b), look for a received fatal TLS alert, such as `certificate required`, `unknown ca`, `bad certificate`, or `handshake failure`; correlate generic alerts with the member's logs to establish the certificate rejection reason. For (c), require a negotiated protocol and cipher, verified server identity, and no subsequent client-certificate rejection. `Verification: OK` alone verifies only the server; in TLS 1.3 a client can print connection details before receiving the server's rejection. Inspect the full output and correlate the member's logs if acceptance is ambiguous. Refusal, DNS failure, reset, local key-loading failure, or timeout alone proves nothing about mutual TLS. Exit 124 only reports that the wait expired, even if the handshake completed first. These probes do not speak the cluster application protocol. See [TLS client-certificate validation](https://www.rfc-editor.org/rfc/rfc8446.html#section-4.4.2.4) and [TLS alerts](https://www.rfc-editor.org/rfc/rfc8446.html#section-6.2). Run (d) in both states and after enabling the final policy on all members. The documented [`SHOW SERVERS` cluster check](https://neo4j.com/docs/operations-manual/5/clustering/setup/deploy/#cluster-example-configure-a-three-primary-cluster) must return the expected members with state `Enabled` and health `Available`. Missing or unavailable members invalidate the positive control. This checks cluster status, not every replication or routing operation; successful authorized discovery, replication, and routed-query comparisons remain REASONED from the cited cluster documentation, not observed. A closed port or failed cluster is not evidence that mutual TLS works. ### Transport, default password, and prompted bootstrap (REASONED: connector and authentication comparisons require the unavailable isolated Neo4j deployment and client tools) **REASONED: requires an isolated fresh installation, a reachable TLS deployment, client tools, and an external test vantage, unavailable here.** Supply the signing CA as `ca.pem`. For a publicly trusted certificate only, the explicit `-CAfile ca.pem` and `--cacert ca.pem` options and the corresponding file-readability checks may be omitted if the clients' default stores already trust its issuer. Keep them for a private or self-signed CA. Run against the actual deployment hostname, including each relevant public IPv4/IPv6 path from another host. The first password prompt below is deliberately for the public default password only. Never enter a real password into the plaintext test. The OpenSSL command and Cypher Shell checks below assume `client_auth=NONE`. If Bolt requires mutual TLS, add `-cert client.crt -key client.key` to the guarded OpenSSL command, using a client certificate trusted by the server. Without it, the server can refuse the client even when OpenSSL prints `Verification: OK` for the server certificate. Use a certificate-capable client with its certificate configured for the remaining Bolt comparisons. Keep `-verify_hostname` and `-verify_return_error`: merely connecting with `s_client` does not enforce the required server identity checks. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NEO4J_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "expected exactly one hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the hostname inside the quotes; not probing" exit 2 ;; esac [ -r ca.pem ] || { echo "provide the signing CA in ca.pem"; exit 2; } unset NEO4J_PASSWORD || { echo "cannot clear NEO4J_PASSWORD"; exit 2; } set +e # errexit off here, so a probe that ends nonzero is classified below instead of ending the block set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 if openssl s_client -connect "$1:7687" -servername "$1" \ -verify_hostname "$1" -verify_return_error -CAfile ca.pem '*|*example.com*|"") echo "substitute the hostname inside the quotes; not probing" exit 2 ;; esac unset NEO4J_PASSWORD || { echo "cannot clear NEO4J_PASSWORD"; exit 2; } if cypher-shell -a "bolt+s://$1:7687" -u catalog_app -d neo4j --history disable; then set -- 0 else echo "cypher-shell exit $?: the session failed or ended with an error, so later denials cannot be read against a working control; inconclusive"; set -- 3 fi echo "block exit $1; the session's results, such as the ok=1 control and the denials below, are not classified, so judge them yourself" exit "$1" ) ``` For administrative work, replace the non-secret username with the administrator account. Use `-d system` for account and privilege administration and `-d neo4j` for the fixture queries. Community readers must use an existing Community account; the Enterprise setup above does not create a restricted Community account. See [Cypher Shell](https://neo4j.com/docs/operations-manual/current/cypher-shell/). Start each application comparison with: ```cypher RETURN 1 AS ok; ``` Expect `ok=1` in both states. Failure to connect or run this query prevents interpreting later denials as successful controls. ### Scoped graph access (REASONED: Enterprise privilege comparisons follow the cited documentation; no Enterprise runtime or seeded fixtures are available) **REASONED, Enterprise-only: requires an Enterprise runtime and seeded fixtures, unavailable here.** In an empty disposable `neo4j` database, seed as an administrator: ```cypher CREATE (:Product { sku: 'SECURECONFIG-SKU-1', name: 'Secureconfig test product', internalNote: 'SECURECONFIG_PRIVATE_PROPERTY_CANARY' }); CREATE (:SecureconfigPrivateCanary { marker: 'SECURECONFIG_PRIVATE_NODE_CANARY' }); ``` As `catalog_app`, run: ```cypher MATCH (p:Product) RETURN p.sku, p.name, p.internalNote; MATCH (n:SecureconfigPrivateCanary) RETURN count(n); CREATE (:SecureconfigWriteCanary); ``` Compare a disposable exposed account with broad read/write privileges against the fixed account holding only `catalog_reader` and the reviewed `PUBLIC` privileges. **REASONED outcomes:** the exposed account reads the property canary, counts the unrelated node, and creates the write canary. With section 4 applied, `sku` and `name` remain readable, `internalNote` appears as `null`, the unrelated-node count is zero, and creation fails authorization. Confirm the fixtures and hidden property exist by running the reads as an administrator. Empty reads alone are insufficient. These restrictions can hide data rather than return authorization errors. See [privilege behavior](https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-privileges/) and [access-control limitations](https://neo4j.com/docs/operations-manual/current/authentication-authorization/limitations/). Review `SHOW USER catalog_app PRIVILEGES AS COMMANDS;` as an administrator in both states. Record inherited privileges as well as the custom role. ### Procedure, function, and LOAD denials (REASONED: documented privilege comparisons need the unavailable Enterprise runtime, compatible UDF and readable CSV fixture) **REASONED, Enterprise-only: requires an Enterprise runtime, a compatible installed UDF, and a known readable CSV fixture, unavailable here.** Test each denial independently against the same account before and after applying it: ```cypher CALL db.labels(); RETURN apoc.text.join(['SECURECONFIG', 'UDF'], '_') AS canary; LOAD CSV FROM 'file:///secureconfig-canary.csv' AS row RETURN count(row); RETURN 1 AS ok; ``` For the UDF comparison, use a compatible APOC installation with `apoc.text.join` loaded and permitted by the plugin allowlist. The function remains documented but is deprecated in Cypher 25. This fixture is for a disposable test; it is not a reason to install APOC in production. See [apoc.text.join](https://neo4j.com/docs/apoc/current/overview/apoc.text/apoc.text.join/). Before the denials, require a successful procedure call, the UDF result `SECURECONFIG_UDF`, and the CSV's known nonzero row count. Keep file loading enabled throughout the isolated LOAD privilege comparison so a separate file setting cannot masquerade as privilege enforcement. After each denial, require an authorization failure for the corresponding operation and a successful `RETURN 1 AS ok;`. Missing procedures, missing functions, unreadable files, and plugin allowlist failures do not demonstrate these privileges. See [db.labels](https://neo4j.com/docs/operations-manual/current/procedures/built-in-procedures/), [EXECUTE privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/dbms-administration/dbms-execute-privileges/), [LOAD privileges](https://neo4j.com/docs/operations-manual/current/authentication-authorization/load-privileges/), and [LOAD CSV](https://neo4j.com/docs/cypher-manual/current/clauses/load-csv/). Separately, to test the existing local-file setting, use an account whose LOAD privilege permits this fixture. The same CSV must load when `dbms.security.allow_csv_import_from_file_urls=true` and be rejected when it is `false`, while `RETURN 1 AS ok;` succeeds in both states. Do not attribute that rejection to the role denial. ### Transaction budgets (REASONED: documented budget comparisons need the unavailable runtime, calibrated workloads and concurrent clients) **REASONED, Community and Enterprise: requires a disposable runtime, calibrated workloads, concurrent clients, and resource measurements, unavailable here.** Substitute positive integer workload sizes before submitting these queries interactively. A candidate memory workload is: ```cypher UNWIND range(1, REPLACE_WITH_ROW_COUNT) AS i WITH collect({value: i}) AS rows RETURN size(rows) AS rowCount; ``` Calibrate it with sufficient heap and larger test limits so it completes, then test each memory limit separately. For the database and server totals, use concurrent transactions and raise the other test limits enough to isolate the limit under examination. In particular, the example per-database limit can fire before the server limit on a single-database deployment. **REASONED outcome:** a workload that completes with a larger budget is terminated with a memory-limit error when the isolated tracked limit is exceeded. A small query must still succeed. Record workload size, concurrency, heap, effective limits, and the actual error; an out-of-memory crash or timeout is not evidence of the intended memory guard. See [transaction memory limits](https://neo4j.com/docs/operations-manual/current/performance/memory-configuration/). A candidate CPU workload for the timeout comparison is: ```cypher UNWIND range(1, REPLACE_WITH_OUTER_COUNT) AS i UNWIND range(1, REPLACE_WITH_INNER_COUNT) AS j RETURN sum(i + j) AS total; ``` Calibrate a workload that completes in more than 30 seconds but less than 60 seconds under a larger test timeout. Keep memory limits from being the terminating condition. Compare a client transaction with no custom timeout under the larger server default, then under `db.transaction.timeout=30s`. The latter should terminate for timeout, while `RETURN 1 AS ok;` still succeeds. Finally, repeat with an explicit 60-second client timeout. Cypher Shell 2025.12 or later supports adding `--transaction-timeout 60s` to the guarded interactive-session command. On current servers, that override should allow the calibrated workload to exceed the 30-second server default and complete. Record actual timings and errors; do not infer a hard server ceiling. See [transaction timeout behavior](https://neo4j.com/docs/operations-manual/current/database-internals/transaction-management/), [Cypher Shell timeout support](https://neo4j.com/docs/operations-manual/current/cypher-shell/), and [older-server exceptions](https://neo4j.com/docs/api/python-driver/current/api.html). ### Security events and query-log contents (REASONED: documented logging comparisons need unavailable Enterprise log access and a running deployment) **REASONED, Enterprise-only: requires Enterprise log access and a running deployment, unavailable here.** Generate one failed login, one successful login, the privilege changes above, and the denied write. Correlate the account and timestamps with `security.log`. For successful-login retention, compare the event with successful-login logging disabled and enabled while keeping the authentication operation successful in both states. In the guarded interactive session, set a non-secret parameter using the Cypher Shell command `:param {canary: 'SECURECONFIG_PARAMETER_CANARY'};`, then run: ```cypher RETURN 'SECURECONFIG_LITERAL_CANARY' AS literal, $canary AS parameter; ``` Compare a disposable logging configuration that records parameter values and unobfuscated literals with section 7's policy. Clear cached queries as an administrator when changing literal obfuscation dynamically. **REASONED outcome:** both queries return the expected values. Before the policy, the canaries are visible in the query log; afterward, the corresponding query events remain present but literal and parameter values are absent. Missing log events do not demonstrate obfuscation. See [query and security logging](https://neo4j.com/docs/operations-manual/current/monitoring/logging/) and [Cypher Shell parameters](https://neo4j.com/docs/operations-manual/current/cypher-shell/). On versions supporting error obfuscation, test it separately with: ```cypher RETURN date('SECURECONFIG_ERROR_CANARY'); ``` This deliberately invalid date should produce a query error. Establish whether the error canary appears in the unprotected log, then enable error obfuscation and require the failure event to remain without that value. If the baseline does not expose the canary, the comparison is inconclusive. See [date parsing](https://neo4j.com/docs/cypher-manual/current/functions/temporal/) and [error obfuscation](https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/). **REASONED, Community HTTP logging: requires a running HTTPS connector and log access, unavailable here.** Compare the authenticated request below with HTTP request logging disabled and enabled. The query must succeed in both states, with the corresponding request recorded when logging is enabled. This tests request logging only; it does not demonstrate Bolt or security auditing. ### HTTPS authentication (REASONED: documented Query API and legacy API comparisons need unavailable HTTPS deployment, trusted certificates and protected credentials) **REASONED: requires HTTPS, trusted certificates, a protected credential file, and the Query API, unavailable here.** The transactional HTTP API endpoint `/db/neo4j/tx/commit` was deprecated in 5.26; deprecation does not mean every deployment has removed it. Prefer the Query API when enabled. It arrived in 5.19 and became enabled by default on self-managed installations in 5.25. See the [HTTP API deprecation](https://neo4j.com/docs/http-api/current/) and [Query API availability](https://neo4j.com/docs/query-api/current/). Provision `neo4j-auth.header` through your secret-management mechanism as an owner-readable-only file containing the complete Authorization header for a valid database account. Do not construct it with a secret-bearing command-line argument. `ca.pem` contains the signing CA. For an interactive authenticated positive control, replacing `--header @neo4j-auth.header` with `--user neo4j` prompts for the password; remove the header-file check for that variant. Never append a password to the username. For publicly trusted certificates, the explicit CA option and file check may be omitted only when curl's default trust store suffices. Skip the HTTPS pairs when HTTPS is deliberately unavailable. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NEO4J_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "expected exactly one hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the hostname inside the quotes; not probing" exit 2 ;; esac [ -r ca.pem ] || { echo "provide the signing CA in ca.pem"; exit 2; } [ -r neo4j-auth.header ] || { echo "provide the protected header file"; exit 2; } command -v python3 >/dev/null 2>&1 || { echo "python3 is not installed here; not probing"; exit 2; } set +e # errexit off here, so a request that ends nonzero is classified below instead of ending the block set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert ca.pem -H 'Content-Type: application/json' \ --data '{"statement":"RETURN 1 AS ok"}' \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:7473/db/neo4j/query/v2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the status, the response with its write-out line, then curl's exit status printf '%s' "$3" # python3 parses the whole body as JSON, so text after a result fails the parse, and prints e for an errors member and r for exactly one ok=1 value; "if 1:" lets its lines be indented set -- "$1" "$2" "$(if printf '%s' "${3%$'\n'http=*}" | python3 -I -c 'if 1: import json, sys try: d = json.loads(sys.stdin.buffer.read()) except (ValueError, RecursionError): d = None d = d if isinstance(d, dict) else {} t = d.get("data") if isinstance(d.get("data"), dict) else {} print("e" * ("errors" in d) + "r" * (json.dumps((t.get("fields"), t.get("values"))) == json.dumps((("ok",), ((1,),)))), end="")' then printf '\n0'; else printf '\n%s' "$?"; fi)" "$4" "${3##*http=}" # the parsed body's marks and python3's exit status, curl's exit status, then the HTTP code set -- "$1" "$2" "${3%$'\n'*}" "$4" "${5%%[!0123456789]*}" "${3##*$'\n'}" # the marks, curl's exit status, the HTTP code, then python3's exit status case "$4:$6:$5:$3" in 0:0:401:*) echo "the anonymous query was refused with 401" ;; 0:0:2??:r) echo "FINDING: the anonymous query was accepted with HTTP $5, so authentication is not enforced"; set -- "$1" 1 ;; 0:0:2??:*) echo "HTTP $5 for the anonymous query without the ok=1 result alone (an errors member, or an empty or malformed body) is neither the 401 pass nor an accepted query, since HTTP success alone is not enough; inconclusive"; set -- "$1" 3 ;; 0:0:*) echo "HTTP $5 for the anonymous query is neither the 401 pass nor an accepted query, and a 404 does not prove authentication enforcement; inconclusive"; set -- "$1" 3 ;; 0:*) echo "python3 exit $6: the anonymous response could not be processed; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $4: the anonymous request failed, and a connection or TLS failure does not prove authentication enforcement; inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert ca.pem --header @neo4j-auth.header \ -H 'Content-Type: application/json' \ --data '{"statement":"RETURN 1 AS ok"}' \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:7473/db/neo4j/query/v2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the status, the response with its write-out line, then curl's exit status printf '%s' "$3" set -- "$1" "$2" "$(if printf '%s' "${3%$'\n'http=*}" | python3 -I -c 'if 1: import json, sys try: d = json.loads(sys.stdin.buffer.read()) except (ValueError, RecursionError): d = None d = d if isinstance(d, dict) else {} t = d.get("data") if isinstance(d.get("data"), dict) else {} print("e" * ("errors" in d) + "r" * (json.dumps((t.get("fields"), t.get("values"))) == json.dumps((("ok",), ((1,),)))), end="")' then printf '\n0'; else printf '\n%s' "$?"; fi)" "$4" "${3##*http=}" # the parsed body's marks and python3's exit status, curl's exit status, then the HTTP code set -- "$1" "$2" "${3%$'\n'*}" "$4" "${5%%[!0123456789]*}" "${3##*$'\n'}" # the marks, curl's exit status, the HTTP code, then python3's exit status case "$4:$6:$5:$3" in 0:0:2??:e*) echo "HTTP $5 with an errors member: the authenticated query did not return ok=1, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; 0:0:2??:r) echo "the authenticated query returned ok=1" ;; 0:0:*) echo "HTTP $5 without the expected ok=1 result, and a Query API 202 alone does not prove query success, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; 0:*) echo "python3 exit $6: the authenticated response could not be processed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $4: the authenticated request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the response headers and bodies are not classified beyond the checks above, so judge them yourself" exit "$2" ) ``` **REASONED outcomes:** an exposed authentication-disabled test service returns the query result anonymously. With authentication enabled, the first request returns 401; the authenticated request must contain the expected `ok=1` result. A Query API 202 response alone does not prove query success: inspect the body for errors and the expected data. A 404 or connection/TLS failure does not prove authentication enforcement. See [authorization responses](https://neo4j.com/docs/query-api/current/authentication-authorization/) and [query response semantics](https://neo4j.com/docs/query-api/current/query/). For deployments exposing the legacy transactional HTTP API, including Neo4j 5 versions predating the Query API, run this pair as well. When both APIs are enabled, test both. Its JSON uses `statements`, unlike the Query API's singular `statement`. See the [legacy query endpoint](https://neo4j.com/docs/http-api/current/query/) and [legacy authentication](https://neo4j.com/docs/http-api/current/authentication-authorization/). ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NEO4J_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "expected exactly one hostname; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the hostname inside the quotes; not probing" exit 2 ;; esac [ -r ca.pem ] || { echo "provide the signing CA in ca.pem"; exit 2; } [ -r neo4j-auth.header ] || { echo "provide the protected header file"; exit 2; } command -v python3 >/dev/null 2>&1 || { echo "python3 is not installed here; not probing"; exit 2; } set +e # errexit off here, so a request that ends nonzero is classified below instead of ending the block set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert ca.pem -H 'Content-Type: application/json' \ --data '{"statements":[{"statement":"RETURN 1 AS ok"}]}' \ -w '\nlegacy-no-auth=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:7473/db/neo4j/tx/commit"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the status, the response with its write-out line, then curl's exit status printf '%s' "$3" # python3 parses the whole body as JSON, so text after a result fails the parse, and prints e for an empty errors array and r for exactly one ok=1 row; "if 1:" lets its lines be indented set -- "$1" "$2" "$(if printf '%s' "${3%$'\n'legacy-no-auth=*}" | python3 -I -c 'if 1: import json, sys try: d = json.loads(sys.stdin.buffer.read()) except (ValueError, RecursionError): d = None d = d if isinstance(d, dict) else {} r = d.get("results") if isinstance(d.get("results"), list) else () r = tuple((x.get("columns"), tuple(y.get("row") if isinstance(y, dict) else None for y in x.get("data")) if isinstance(x.get("data"), list) else None) if isinstance(x, dict) else None for x in r) print("e" * (d.get("errors") == list()) + "r" * (json.dumps(r) == json.dumps(((("ok",), ((1,),)),))), end="")' then printf '\n0'; else printf '\n%s' "$?"; fi)" "$4" "${3##*legacy-no-auth=}" # the parsed body's marks and python3's exit status, curl's exit status, then the HTTP code set -- "$1" "$2" "${3%$'\n'*}" "$4" "${5%%[!0123456789]*}" "${3##*$'\n'}" # the marks, curl's exit status, the HTTP code, then python3's exit status case "$4:$6:$5:$3" in 0:0:401:*) echo "the anonymous legacy query was refused with 401" ;; 0:0:2??:er) echo "FINDING: the anonymous legacy query was accepted with HTTP $5, so authentication is not enforced"; set -- "$1" 1 ;; 0:0:2??:*) echo "HTTP $5 for the anonymous legacy query without ok=1 and an empty errors array (an error-only, empty or malformed body) is neither the 401 pass nor an accepted query, since HTTP success alone is not enough; inconclusive"; set -- "$1" 3 ;; 0:0:*) echo "HTTP $5 for the anonymous legacy query is neither the 401 pass nor an accepted query (a missing endpoint proves nothing); inconclusive"; set -- "$1" 3 ;; 0:*) echo "python3 exit $6: the anonymous legacy response could not be processed; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $4: the anonymous legacy request failed (a TLS error or connection failure proves nothing); inconclusive"; set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert ca.pem --header @neo4j-auth.header \ -H 'Content-Type: application/json' \ --data '{"statements":[{"statement":"RETURN 1 AS ok"}]}' \ -w '\nlegacy-auth=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "https://$1:7473/db/neo4j/tx/commit"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the status, the response with its write-out line, then curl's exit status printf '%s' "$3" set -- "$1" "$2" "$(if printf '%s' "${3%$'\n'legacy-auth=*}" | python3 -I -c 'if 1: import json, sys try: d = json.loads(sys.stdin.buffer.read()) except (ValueError, RecursionError): d = None d = d if isinstance(d, dict) else {} r = d.get("results") if isinstance(d.get("results"), list) else () r = tuple((x.get("columns"), tuple(y.get("row") if isinstance(y, dict) else None for y in x.get("data")) if isinstance(x.get("data"), list) else None) if isinstance(x, dict) else None for x in r) print("e" * (d.get("errors") == list()) + "r" * (json.dumps(r) == json.dumps(((("ok",), ((1,),)),))), end="")' then printf '\n0'; else printf '\n%s' "$?"; fi)" "$4" "${3##*legacy-auth=}" # the parsed body's marks and python3's exit status, curl's exit status, then the HTTP code set -- "$1" "$2" "${3%$'\n'*}" "$4" "${5%%[!0123456789]*}" "${3##*$'\n'}" # the marks, curl's exit status, the HTTP code, then python3's exit status case "$4:$6:$5:$3" in 0:0:2??:er) echo "the authenticated legacy query returned ok=1 with an empty errors array" ;; 0:0:*) echo "HTTP $5 without ok=1 and an empty errors array, and HTTP 200 alone does not demonstrate query success, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; 0:*) echo "python3 exit $6: the authenticated legacy response could not be processed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $4: the authenticated legacy request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac echo "block exit $2; the response headers and bodies are not classified beyond the checks above, so judge them yourself" exit "$2" ) ``` **Requires a deployment with the legacy HTTP API, trusted certificates, and protected credentials, unavailable here.** With authentication disabled in a disposable baseline, the anonymous request returns the query result. With authentication enabled, it must return 401; the authenticated request must return `ok=1` with an empty `errors` array. HTTP 200 alone does not demonstrate query success. A missing endpoint, TLS error, connection failure, or an authentication failure without a working positive control is inconclusive. See [legacy authorization responses](https://neo4j.com/docs/http-api/current/authentication-authorization/) and [legacy query results](https://neo4j.com/docs/http-api/current/query/). ### Verification scope REASONED: a scope summary for this Verify section, per the guide's cited Configure network connectors (Neo4j 5), Cypher Shell prompts, environment credentials, history, parameters, and timeouts, OpenSSL 3 TLS probe options, TLS 1.3 client-certificate validation and alerts, Transactional HTTP API deprecation and Query API availability entries and the other entries listed under Sources. No Neo4j service ran in the authoring environment, and the local checks that did run are recorded at the start of the Verify section. The comparisons below are REASONED from the cited Neo4j documentation and pinned sources; no service behaviour is claimed as observed. | Check scope | Status, deployment comparison and prerequisites | |---|---| | Service comparisons | REASONED: the service comparisons listed in this cell, per the guide's cited Configure network connectors (Neo4j 5), OpenSSL 3 TLS probe options, TLS 1.3 client-certificate validation and alerts, Cypher Shell prompts, environment credentials, history, parameters, and timeouts, Transactional HTTP API deprecation and Query API availability entries and the other entries listed under Sources. The REASONED service comparisons listed in this cell: none ran, because the prerequisites this cell lists are absent from the authoring environment. REASONED, not demonstrated. Reproduce all Verify comparisons in disposable Community and Enterprise deployments (part 1 of 4): prompted bootstrap and process arguments; listeners, TLS, plaintext refusal, and default-password rotation. The verification step is to reproduce all Verify comparisons in disposable Community and Enterprise deployments (part 2 of 4): Enterprise standalone and cluster binds, Docker inheritance, discovery v1/v2, opt-in Prometheus, and cluster TLS rejection of untrusted peers with healthy authorized discovery, replication, and routing; scoped graph reads and denied writes; effective privileges. The verification step is to reproduce all Verify comparisons in disposable Community and Enterprise deployments (part 3 of 4): procedure/UDF/LOAD denials and the separate existing local-file setting; each memory budget and default/client timeout. The verification step is to reproduce all Verify comparisons in disposable Community and Enterprise deployments (part 4 of 4): security events, query literal/parameter/error obfuscation, Community HTTP request logging, and the Query API and legacy transactional HTTP API anonymous/authenticated pairs; mutual TLS where configured. Requires server and client runtimes, an Enterprise cluster as well as a standalone server, fixtures, compatible APOC, trusted certificates, protected credentials, concurrent workloads, log/process access, and an external network vantage. Record versions, exposed/fixed outcomes, matched positive controls, and errors. | ## Common mistakes - `server.default_listen_address=0.0.0.0` set during installation "to test", with `neo4j`/`neo4j` still in place. - Bolt TLS configured but left at `server.bolt.tls_level=OPTIONAL`, so `neo4j://` clients keep connecting in plaintext. - HTTPS enabled while `server.http.enabled` stays `true`, leaving 7474 open beside 7473. - Treating a separate Community username as a restricted account, or Enterprise's built-in `reader` as a database-specific role. - Adding a narrow role without removing broader privileges inherited by the same account. - Assuming read-only graph access also blocks procedures, UDFs, or data loading. - Treating a default transaction timeout as an unoverrideable ceiling. - Treating missing logs, empty query results, or connection failures as successful security checks. ## Sources (checked September 2026) - Configure network connectors (Neo4j 5): https://neo4j.com/docs/operations-manual/5/configuration/connectors/ - Ports: https://neo4j.com/docs/operations-manual/current/configuration/ports/ - Set an initial password: https://neo4j.com/docs/operations-manual/current/configuration/set-initial-password/ - Exact configuration settings, defaults, editions, and error-obfuscation version (current-series error obfuscation since 2026.01.3): https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/ - Neo4j 5.26 (LTS), fetched 2026-09-26, Enterprise cluster and monitoring ports: https://neo4j.com/docs/operations-manual/5/configuration/ports/#_cluster - Neo4j 5.26 (LTS) cluster deployment and discovery-version conditions (discovery v2 deployment from 5.23): https://neo4j.com/docs/operations-manual/5/clustering/setup/deploy/#cluster-example-configure-a-three-primary-cluster - OpenSSL 3 TLS probe options: https://docs.openssl.org/3.0/man1/openssl-s_client/ - TLS 1.3 client-certificate validation and alerts: https://www.rfc-editor.org/rfc/rfc8446.html - Probe time limit and exit status: https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html - Neo4j 5.26 (LTS) cluster SSL policy, certificates, and mutual authentication: https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-cluster-config - Pinned Dockerfile EXPOSE declarations, Debian: https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-debian#L53 - Pinned Dockerfile EXPOSE declarations, UBI 8: https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-ubi8#L90 - Pinned Dockerfile EXPOSE declarations, UBI 9: https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-ubi9#L89 - Neo4j 5.26 (LTS) configuration reference, cluster settings and advertised addresses, listener defaults, and error obfuscation since 5.26.21 (discovery selector/endpoints labelled 5.22): https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/ - Configuration changes in Neo4j 5 (minimum password length setting since 5.3): https://neo4j.com/docs/upgrade-migration-guide/current/version-5/changelogs/configuration-settings/ - SSL framework and certificate-verified connection schemes: https://neo4j.com/docs/operations-manual/current/security/ssl-framework/ - Authentication and native lockout configuration: https://neo4j.com/docs/operations-manual/current/authentication-authorization/ - Manage users and Community's implied administrator privileges: https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-users/ - Manage roles: https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-roles/ - Built-in roles and PUBLIC privileges: https://neo4j.com/docs/operations-manual/current/authentication-authorization/built-in-roles/ - Database access privileges: https://neo4j.com/docs/operations-manual/current/authentication-authorization/database-administration/ - MATCH and property-read privileges: https://neo4j.com/docs/operations-manual/current/authentication-authorization/privileges-reads/ - GRANT, DENY, and showing user privileges: https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-privileges/ - Access-control limitations and hidden properties: https://neo4j.com/docs/operations-manual/current/authentication-authorization/limitations/ - Procedure and user-defined function execution privileges: https://neo4j.com/docs/operations-manual/current/authentication-authorization/dbms-administration/dbms-execute-privileges/ - LOAD privileges (rolling documentation, checked September 2026): https://neo4j.com/docs/operations-manual/current/authentication-authorization/load-privileges/ - LOAD privilege introduction versions (LOAD ON ALL DATA since 5.13; CIDR privileges since 5.16): https://neo4j.com/docs/cypher-manual/5/deprecations-additions-removals-compatibility/ - Cypher Shell prompts, environment credentials, history, parameters, and timeouts (history disable since 2025.08; transaction-timeout since 2025.12): https://neo4j.com/docs/operations-manual/current/cypher-shell/ - Docker introduction and initial authentication: https://neo4j.com/docs/operations-manual/current/docker/introduction/ - Docker configuration and container listen addresses: https://neo4j.com/docs/operations-manual/current/docker/configuration/ - Official Neo4j 5 Docker entrypoint and default listen address: https://raw.githubusercontent.com/neo4j/docker-neo4j/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/docker-entrypoint.sh - Single sign-on integration: https://neo4j.com/docs/operations-manual/current/authentication-authorization/sso-integration/ - LDAP integration: https://neo4j.com/docs/operations-manual/current/authentication-authorization/ldap-integration/ - Securing extensions and plugin loading: https://neo4j.com/docs/operations-manual/current/security/securing-extensions/ - APOC configuration and exact import-setting spelling: https://neo4j.com/docs/apoc/current/config/ - Protecting against SSRF, redirected from the original developer KB URL: https://support.neo4j.com/s/article/8584271681427-Protecting-against-Server-Side-Request-Forgery-SSRF - APOC Extended JDBC outbound loading: https://neo4j.com/labs/apoc/5/overview/apoc.load/apoc.load.jdbc/ - APOC security, boosted execution, and SSRF: https://neo4j.com/docs/apoc/current/security-guidelines/ - Transaction memory limits and accounting: https://neo4j.com/docs/operations-manual/current/performance/memory-configuration/ - Transaction timeout behavior: https://neo4j.com/docs/operations-manual/current/database-internals/transaction-management/ - Driver transaction timeout overrides and older-server exceptions (server exception: 4.2 through 5.2 inclusive): https://neo4j.com/docs/api/python-driver/current/api.html - Logging, edition availability, security events, and rotation: https://neo4j.com/docs/operations-manual/current/monitoring/logging/ - Enterprise online backup: https://neo4j.com/docs/operations-manual/current/backup-restore/online-backup/ - Transactional HTTP API deprecation (deprecated in 5.26): https://neo4j.com/docs/http-api/current/ - Legacy transactional HTTP API query payload and results: https://neo4j.com/docs/http-api/current/query/ - Legacy transactional HTTP API authentication: https://neo4j.com/docs/http-api/current/authentication-authorization/ - Query API availability (introduced in 5.19; enabled by default in 5.25): https://neo4j.com/docs/query-api/current/ - Query API authentication and authorization: https://neo4j.com/docs/query-api/current/authentication-authorization/ - Query API endpoint and response semantics: https://neo4j.com/docs/query-api/current/query/ - Built-in procedures, including db.labels and db.clearQueryCaches: https://neo4j.com/docs/operations-manual/current/procedures/built-in-procedures/ - APOC UDF test and Cypher 25 deprecation: https://neo4j.com/docs/apoc/current/overview/apoc.text/apoc.text.join/ - CREATE fixture syntax: https://neo4j.com/docs/cypher-manual/current/clauses/create/ - LOAD CSV syntax: https://neo4j.com/docs/cypher-manual/current/clauses/load-csv/ - UNWIND workload syntax: https://neo4j.com/docs/cypher-manual/current/clauses/unwind/ - Aggregating functions for fixture counts and workloads: https://neo4j.com/docs/cypher-manual/current/functions/aggregating/ - List functions, including range: https://neo4j.com/docs/cypher-manual/current/functions/list/ - Scalar functions, including size: https://neo4j.com/docs/cypher-manual/current/functions/scalar/ - Temporal date parsing for the error canary: https://neo4j.com/docs/cypher-manual/current/functions/temporal/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - cluster: Neo4j Enterprise (5.26) - neo5: Packaged Neo4j (5) - password-min: Neo4j password setting minimum (5.3) - load-min: Neo4j LOAD minimum (5.13) - cidr-min: Neo4j CIDR LOAD minimum (5.16) - shell-history: Cypher Shell history minimum (2025.08) - shell-timeout: Cypher Shell timeout minimum (2025.12) - driver-history: Neo4j server timeout exception (4.2 through 5.2) - errors5: Neo4j 5 error-obfuscation minimum (5.26.21) - errors-current: Neo4j current error-obfuscation minimum (2026.01.3) - http-deprecated: Neo4j HTTP API deprecation (5.26) - query-min: Neo4j Query API minimum (5.19) - query-default: Neo4j Query API default minimum (5.25) - cypher: Cypher UDF deprecation (25) - docs: Neo4j documentation (unknown) - docker: Neo4j Docker entrypoint (5359427c4f51d0d51cce5b048757a2c21e6f377e) - ssrf: Neo4j SSRF guidance (unknown) - apoc: APOC documentation (unknown) - openssl: OpenSSL (3) - tls: TLS RFC 8446 (unknown) - timeout: timeout (unknown) Claims (full source mapping in the JSON block below): - packaged-bind (REASONED; neo5): Packaged Neo4j 5 defaults to localhost; a widened shared bind exposes enabled connectors. - auth-default (REASONED; docs): Authentication defaults on with neo4j/neo4j; rotate before remote access and reserve auth disabling for isolated recovery. - bolt-default (REASONED; docs): Bolt defaults to :7687 with TLS DISABLED; an omitted connector host inherits the shared listen address. - http-default (REASONED; docs): Plaintext HTTP defaults enabled on :7474; HTTPS is disabled by default on :7473. - docker-bind (REASONED; docker, docs): The pinned Docker entrypoint defaults the shared listen host to 0.0.0.0; publication and firewall determine host exposure. - docker-auth (REASONED; docs): NEO4J_AUTH initializes a new database, does not rotate an existing password, and none disables authentication; deliver secrets outside argv. - bootstrap (REASONED; docs): Use isolated loopback Cypher Shell --change-password prompts; set-initial-password is first-start-only and takes an exposed positional password. - credential-input (REASONED; docs): Unset ambient NEO4J_PASSWORD for prompts; automation may supply it through the environment, never expanded into -p, with local-inspection limits. - password-min (REASONED; docs, password-min): Default minimum password length is 8; auth_minimum_password_length was introduced in 5.3 and does not make eight characters a recommendation. - backup (REASONED; docs): Enterprise online backup defaults explicitly to 127.0.0.1:6362 and does not inherit a widened shared host; Community has no online-backup listener. - discovery (REASONED; cluster): Enterprise discovery v1 listens on hostless :5000 when active and is deprecated since 5.23; inventory standalone servers too. - shipping (REASONED; cluster): Enterprise transaction shipping/catch-up uses :6000, also serving backups and discovery v2; default replication is unauthenticated. - raft (REASONED; cluster): Enterprise Raft listens on hostless :7000, including on standalone Enterprise deployments. - routing (REASONED; cluster): Server-side routing uses additional Bolt :7688 when enabled; dbms.routing.enabled defaults true. - advertising (REASONED; cluster): Cluster listen hosts inherit the shared localhost default; advertised addresses separately inherit localhost and must be peer-reachable with matching ports. - discovery-version (REASONED; cluster): In 5.26 discovery defaults V1_ONLY; V2_ONLY shares 6000, mixed modes run both. Deployment docs say v2 from 5.23, while selector/endpoints are labelled 5.22. - cluster-isolation (REASONED; cluster): Restrict cluster listeners to members with firewall allowlists; single-server overrides retain explicit loopback on 5000/6000/7000/7688. - cluster-tls (REASONED; cluster): The disabled-by-default cluster SSL policy covers discovery, shipping, Raft and routing; client_auth=REQUIRE acts only after enabling it. - cluster-certificates (REASONED; cluster): Use distinct protected PKCS#8 PEM keys, leaf-first certificate chains, trusted/revoked directories and both server/client EKUs; retain trust_all=false. - backup-tls (REASONED; cluster, docs): Cluster TLS does not enable the separate backup SSL scope on 6362 or replace Bolt/HTTPS policies. - prometheus (REASONED; cluster): Enterprise Prometheus defaults disabled and explicitly localhost:2004; hostless :2004 inherits the shared host. Cluster SSL does not establish metrics protection. - graphite (REASONED; cluster): Graphite :2003 is a disabled-by-default outbound destination, not a Neo4j listener. - jvm-listeners (REASONED; cluster): JMX 3637 and debugging 5005 require explicit JVM options; leave remote management/debugging out of the baseline. - docker-cluster (REASONED; docker, cluster): Docker widens hostless cluster listeners but not explicit backup/Prometheus hosts; Enterprise defaults cluster advertisements to container hostname and maps legacy variables. - docker-overrides (REASONED; docker, docs): Explicit settings override Docker defaults, then environment overrides files; NEO4J_ maps underscores to __ and dots to _ for the four loopback overrides. - docker-expose (REASONED; docker): All three pinned Dockerfiles EXPOSE only 7474/7473/7687; EXPOSE is neither the full listener inventory nor a firewall. - client-certificates (REASONED; docs): Use policy-specific PKCS#8 PEM keys and certificates, service ownership and modes 0400/0644; legacy PKCS#1 is deprecated. - bolt-required (REASONED; docs): Enable the Bolt SSL policy and set tls_level=REQUIRED; OPTIONAL still accepts plaintext. - https-policy (REASONED; docs): Enable HTTPS with its own certificate policy and disable server.http.enabled to remove plaintext HTTP. - bolt-mtls (REASONED; docs): Client_auth=NONE uses server TLS plus database credentials; REQUIRE adds client certificates and needs trusted certificates and certificate-capable clients. - community-users (REASONED; docs): Separate Community users all have implied administrator privileges; application or fronting controls must enforce end-user authorization. - lockout (REASONED; docs): Native authentication defaults to three failed attempts and a 5s lock; these settings do not establish an external IdP lockout policy. - enterprise-role (REASONED; docs): Built-in reader is broad across databases; custom catalog_reader grants database ACCESS and MATCH only for Product sku/name to a fresh catalog_app. - effective-privileges (REASONED; docs): A narrow role does not remove broader roles; inspect effective grants including PUBLIC before switching the application. - mfa (REASONED; docs): Native passwords lack a second factor; Enterprise OIDC can enforce IdP MFA, LDAP simple alone cannot, and alternate paths must not bypass MFA. - public-execute (REASONED; docs): Default Enterprise PUBLIC grants procedure, UDF and data-loading access; read-only graph privileges alone do not restrict these capabilities. - execute-denials (REASONED; docs): DENY EXECUTE PROCEDURES/FUNCTIONS blocks those capabilities for catalog_reader; built-in functions remain available and narrower grants cannot override matching denials. - load-denial (REASONED; docs, load-min, cidr-min): DENY LOAD ON ALL DATA needs Enterprise 5.13 or later; CIDR-specific privileges arrived in 5.16. - boosted (REASONED; apoc): Do not grant applications boosted execution; APOC boosted procedures can bypass graph and loading restrictions. - plugin-loading (REASONED; docs): Keep procedures.unrestricted empty unless reviewed and narrow procedures.allowlist from its * default; loading controls do not create Community per-user privileges. - apoc-file (REASONED; apoc): For Neo4j 5+, APOC settings belong in apoc.conf; defaults import.file.enabled=false and import.file.use_neo4j_config=true retain file/import-directory checks. - csv-file (REASONED; docs): allow_csv_import_from_file_urls defaults true; disable unnecessary local LOAD CSV. Configuration references take precedence over conflicting APOC overview wording. - outbound (REASONED; docs, apoc, ssrf): LOAD CSV and APOC HTTP/JDBC can cause SSRF; JDBC needs APOC Extended and a compatible driver. Restrict egress and use compatible cypher_ip_blocklist support. - memory-transaction (REASONED; docs): db.memory.transaction.max=64m is an example per-transaction tracked-memory budget, not a default or exact process-memory limit. - memory-database (REASONED; docs): db.memory.transaction.total.max=256m is an example aggregate per-database transaction budget; size against heap and workload. - memory-server (REASONED; docs): dbms.memory.transaction.total.max=512m is an example server-wide transaction budget; tracked-limit violations terminate transactions. - timeout (REASONED; docs, driver-history): db.transaction.timeout=30s is an example default, not a hard client ceiling; servers 4.2 through 5.2 ignore larger overrides per the driver reference. - security-log (REASONED; docs): Enterprise security.log with authentication records logins, administration and authorization failures; retain successful logins with log_successful_authentication=true. - query-log (REASONED; docs): Enterprise query logging VERBOSE is the documented current default and records starts/finishes; protect logs and review server-logs.xml rotation. - query-secrets (REASONED; docs): Disable parameter and early raw logging and enable literal obfuscation; clearQueryCaches makes dynamic literal changes affect cached queries. - error-obfuscation (REASONED; errors5, errors-current): obfuscate_errors arrived in 5.26.21 and current-series 2026.01.3; do not add it on older versions, and treat obfuscated logs as sensitive. - http-log (REASONED; docs): Community optional HTTP request logging applies only with HTTP/HTTPS and does not supply equivalent Bolt, query or security auditing. - local-guards (DEMONSTRATED; docs): Historical guard-only checks rejected placeholders and malformed marker/count inputs in six guarded blocks; no Neo4j service behavior was demonstrated. Those checks covered an earlier form of the guarded blocks, whose refusals exited with status 1; the current form is covered by gate-suite bash -n and ShellCheck passes and drafting stub runs. - tls-schemes (REASONED; docs): neo4j+s and bolt+s verify certificates; +ssc skips verification. Cypher Shell trust configuration is independent of OpenSSL/curl CA options. - verify-listeners (REASONED; docs, cluster, docker): Confirm working Bolt/HTTPS, absent HTTP, Enterprise loopback backup, conditional cluster/Prometheus ports and Docker namespace/publication controls; an empty inventory is insufficient. - verify-cluster-tls (REASONED; cluster, openssl, tls, timeout): Compare cluster TLS client_auth NONE with REQUIRE: no certificate and untrusted issuer must be rejected while a trusted member completes a verified handshake. - cluster-probe-limits (REASONED; openssl, tls, timeout): Inspect fatal alerts and member logs; server Verification OK, timeout 124 or transport failure alone does not prove client acceptance or rejection. - verify-cluster-health (REASONED; cluster, docs): SHOW SERVERS must retain expected Enabled/Available members in both states; protocol-level discovery, replication and routing comparisons remain unobserved. - verify-bolt (REASONED; docs): Verified handshake and authenticated TLS query must succeed; plaintext must fail at transport under REQUIRED, not merely at authentication. - verify-http-off (REASONED; docs): An exposed 7474 endpoint answers HTTP; fixed HTTP is absent, with target/path confirmation and working TLS controls needed to interpret connection failures. - verify-password (REASONED; docs): Default password acceptance before rotation must become authentication refusal while the replacement succeeds; account for lockout and inspect prompted-secret argv. - verify-session (REASONED; docs, shell-history): The guarded session prompts for the application password; history disable needs Cypher Shell 2025.08+, while earlier documented shells use in-memory. - verify-query-control (REASONED; docs): RETURN 1 must succeed before interpreting later denials; administrative work uses system and fixture queries use neo4j. - verify-graph (REASONED; docs): Known fixtures distinguish broad access from sku/name-only reads: hidden property null, unrelated-node count zero and write refusal, with administrator fixture controls. - verify-procedure (REASONED; docs): An available db.labels call must succeed before the isolated EXECUTE PROCEDURES denial and fail authorization afterward while RETURN 1 succeeds. - verify-udf (REASONED; docs, apoc, cypher): A loaded permitted apoc.text.join must return SECURECONFIG_UDF before denial and fail authorization afterward; it is deprecated in Cypher 25. - verify-load (REASONED; docs): Use a known readable CSV with file loading enabled to isolate LOAD denial; separately test the file-setting toggle with an account allowed to LOAD. - verify-memory (REASONED; docs): Calibrate a workload that completes at larger budgets; isolate each memory limit with concurrent transactions and retain a small-query control. OOM or timeout is inconclusive. - verify-timeout (REASONED; docs, shell-timeout, driver-history): Calibrated work must time out under the 30s default but complete with a 60s override on current servers; Cypher Shell transaction-timeout needs 2025.12+. - verify-security-log (REASONED; docs): Correlate failed/successful logins, privilege changes and denied writes; compare successful-login logging off/on while authentication succeeds in both states. - verify-query-log (REASONED; docs): Literal/parameter canaries disappear under the policy while query events and results remain; missing log events do not prove obfuscation. - verify-error-log (REASONED; docs): The invalid date must expose its error canary before obfuscation and retain the failure event without it afterward; a non-exposing baseline is inconclusive. - verify-http-log (REASONED; docs): Community HTTP logging off/on must preserve the successful authenticated HTTPS request and record it when enabled; this says nothing about Bolt auditing. - query-api-version (REASONED; query-min, query-default, http-deprecated): Query API arrived in 5.19 and became enabled by default in 5.25; the legacy transactional API was deprecated in 5.26, not universally removed. - verify-query-api (REASONED; docs): On HTTPS 7473 /db/neo4j/query/v2, anonymous queries work with auth disabled and return 401 when enabled; valid credentials must return ok=1, not merely HTTP 202. - verify-legacy (REASONED; docs): When /db/neo4j/tx/commit is enabled, test its statements payload too: anonymous 401 with auth enabled and authenticated ok=1 plus empty errors; HTTP 200 alone is insufficient. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "a5bae35dce475f51d51a0897f48af22dc879fa254260e5b604de23c48c7051ce", "components": { "cluster": { "name": "Neo4j Enterprise", "basis": "5.26", "sources": { "s0a9b520e7dd9": "https://neo4j.com/docs/operations-manual/5/configuration/ports/#_cluster", "scb82a1e6034e": "https://neo4j.com/docs/operations-manual/5/clustering/setup/deploy/#cluster-example-configure-a-three-primary-cluster", "s3b91bd83988a": "https://neo4j.com/docs/operations-manual/5/security/ssl-framework/#ssl-cluster-config", "s213150a93a9b": "https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/" } }, "neo5": { "name": "Packaged Neo4j", "basis": "5", "sources": { "s45afc735a153": "https://neo4j.com/docs/operations-manual/5/configuration/connectors/" } }, "password-min": { "name": "Neo4j password setting minimum", "basis": "5.3", "sources": { "s39c40121233d": "https://neo4j.com/docs/upgrade-migration-guide/current/version-5/changelogs/configuration-settings/" } }, "load-min": { "name": "Neo4j LOAD minimum", "basis": "5.13", "sources": { "se48a10ba1e9a": "https://neo4j.com/docs/cypher-manual/5/deprecations-additions-removals-compatibility/" } }, "cidr-min": { "name": "Neo4j CIDR LOAD minimum", "basis": "5.16", "sources": { "se48a10ba1e9a": "https://neo4j.com/docs/cypher-manual/5/deprecations-additions-removals-compatibility/" } }, "shell-history": { "name": "Cypher Shell history minimum", "basis": "2025.08", "sources": { "s27aabf32542c": "https://neo4j.com/docs/operations-manual/current/cypher-shell/" } }, "shell-timeout": { "name": "Cypher Shell timeout minimum", "basis": "2025.12", "sources": { "s27aabf32542c": "https://neo4j.com/docs/operations-manual/current/cypher-shell/" } }, "driver-history": { "name": "Neo4j server timeout exception", "basis": "4.2 through 5.2", "sources": { "s2508ef9d0d44": "https://neo4j.com/docs/api/python-driver/current/api.html" } }, "errors5": { "name": "Neo4j 5 error-obfuscation minimum", "basis": "5.26.21", "sources": { "s213150a93a9b": "https://neo4j.com/docs/operations-manual/5/configuration/configuration-settings/" } }, "errors-current": { "name": "Neo4j current error-obfuscation minimum", "basis": "2026.01.3", "sources": { "sf356a53bcdde": "https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/" } }, "http-deprecated": { "name": "Neo4j HTTP API deprecation", "basis": "5.26", "sources": { "s0ac4757c97b1": "https://neo4j.com/docs/http-api/current/" } }, "query-min": { "name": "Neo4j Query API minimum", "basis": "5.19", "sources": { "sa27cfcb0609b": "https://neo4j.com/docs/query-api/current/" } }, "query-default": { "name": "Neo4j Query API default minimum", "basis": "5.25", "sources": { "sa27cfcb0609b": "https://neo4j.com/docs/query-api/current/" } }, "cypher": { "name": "Cypher UDF deprecation", "basis": "25", "sources": { "s656e3faa465c": "https://neo4j.com/docs/apoc/current/overview/apoc.text/apoc.text.join/" } }, "docs": { "name": "Neo4j documentation", "basis": "unknown", "sources": { "s45afc735a153": "https://neo4j.com/docs/operations-manual/5/configuration/connectors/", "s6e25ca2b57e6": "https://neo4j.com/docs/operations-manual/current/configuration/ports/", "s08d51abe6a73": "https://neo4j.com/docs/operations-manual/current/configuration/set-initial-password/", "sf356a53bcdde": "https://neo4j.com/docs/operations-manual/current/configuration/configuration-settings/", "s9114febb4116": "https://neo4j.com/docs/operations-manual/current/security/ssl-framework/", "sa684352a8ce2": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/", "s7dcb6ce12fb7": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-users/", "scc9ead4d865a": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-roles/", "sdf6a150be16d": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/built-in-roles/", "s39011db1efd2": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/database-administration/", "s349a76dad225": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/privileges-reads/", "s49e57b8da0e8": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/manage-privileges/", "s41d90cbaf5bd": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/limitations/", "s216b761855ea": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/dbms-administration/dbms-execute-privileges/", "sd1cbea2cf003": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/load-privileges/", "s27aabf32542c": "https://neo4j.com/docs/operations-manual/current/cypher-shell/", "sa88393321e84": "https://neo4j.com/docs/operations-manual/current/docker/introduction/", "s189dd64eedf0": "https://neo4j.com/docs/operations-manual/current/docker/configuration/", "s87f690765c5e": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/sso-integration/", "s9868bfc264b4": "https://neo4j.com/docs/operations-manual/current/authentication-authorization/ldap-integration/", "sa3a72e7400b3": "https://neo4j.com/docs/operations-manual/current/security/securing-extensions/", "s8ae91f812f5b": "https://neo4j.com/docs/operations-manual/current/performance/memory-configuration/", "s85f3aab61748": "https://neo4j.com/docs/operations-manual/current/database-internals/transaction-management/", "s849558d7ac94": "https://neo4j.com/docs/operations-manual/current/monitoring/logging/", "sb0ddc8887396": "https://neo4j.com/docs/operations-manual/current/backup-restore/online-backup/", "s4f9d1acd790a": "https://neo4j.com/docs/http-api/current/query/", "s527f81176145": "https://neo4j.com/docs/http-api/current/authentication-authorization/", "s95e2a6b71cc7": "https://neo4j.com/docs/query-api/current/authentication-authorization/", "seb0695092581": "https://neo4j.com/docs/query-api/current/query/", "s9f0fd7367ed5": "https://neo4j.com/docs/operations-manual/current/procedures/built-in-procedures/", "s45b3067e825e": "https://neo4j.com/docs/cypher-manual/current/clauses/create/", "s915f2ec50a7d": "https://neo4j.com/docs/cypher-manual/current/clauses/load-csv/", "s244dc312864d": "https://neo4j.com/docs/cypher-manual/current/clauses/unwind/", "s30abf38e206e": "https://neo4j.com/docs/cypher-manual/current/functions/aggregating/", "s6afe6e03243e": "https://neo4j.com/docs/cypher-manual/current/functions/list/", "s8df4293b9d1d": "https://neo4j.com/docs/cypher-manual/current/functions/scalar/", "sbc262b364399": "https://neo4j.com/docs/cypher-manual/current/functions/temporal/" } }, "docker": { "name": "Neo4j Docker entrypoint", "basis": "5359427c4f51d0d51cce5b048757a2c21e6f377e", "sources": { "sc85aeddf6446": "https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-debian#L53", "sf120b320c41f": "https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-ubi8#L90", "s313b08920171": "https://github.com/neo4j/docker-neo4j/blob/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/Dockerfile-ubi9#L89", "s67d863435d75": "https://raw.githubusercontent.com/neo4j/docker-neo4j/5359427c4f51d0d51cce5b048757a2c21e6f377e/docker-image-src/5/coredb/docker-entrypoint.sh" } }, "ssrf": { "name": "Neo4j SSRF guidance", "basis": "unknown", "sources": { "sfc06984e6e27": "https://support.neo4j.com/s/article/8584271681427-Protecting-against-Server-Side-Request-Forgery-SSRF" } }, "apoc": { "name": "APOC documentation", "basis": "unknown", "sources": { "s41842145484e": "https://neo4j.com/docs/apoc/current/config/", "s5667c070a781": "https://neo4j.com/labs/apoc/5/overview/apoc.load/apoc.load.jdbc/", "s3d201e2d8cd7": "https://neo4j.com/docs/apoc/current/security-guidelines/", "s656e3faa465c": "https://neo4j.com/docs/apoc/current/overview/apoc.text/apoc.text.join/" } }, "openssl": { "name": "OpenSSL", "basis": "3", "sources": { "s00aaf106164f": "https://docs.openssl.org/3.0/man1/openssl-s_client/" } }, "tls": { "name": "TLS RFC 8446", "basis": "unknown", "sources": { "se28608fb71a7": "https://www.rfc-editor.org/rfc/rfc8446.html" } }, "timeout": { "name": "timeout", "basis": "unknown", "sources": { "s8844a42d0596": "https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html" } } }, "claims": { "packaged-bind": {"text": "Packaged Neo4j 5 defaults to localhost; a widened shared bind exposes enabled connectors.", "components": ["neo5"], "sources": ["neo5:s45afc735a153"], "status": "REASONED"}, "auth-default": {"text": "Authentication defaults on with neo4j/neo4j; rotate before remote access and reserve auth disabling for isolated recovery.", "components": ["docs"], "sources": ["docs:s08d51abe6a73", "docs:sa684352a8ce2"], "status": "REASONED"}, "bolt-default": {"text": "Bolt defaults to :7687 with TLS DISABLED; an omitted connector host inherits the shared listen address.", "components": ["docs"], "sources": ["docs:s45afc735a153"], "status": "REASONED"}, "http-default": {"text": "Plaintext HTTP defaults enabled on :7474; HTTPS is disabled by default on :7473.", "components": ["docs"], "sources": ["docs:s45afc735a153"], "status": "REASONED"}, "docker-bind": {"text": "The pinned Docker entrypoint defaults the shared listen host to 0.0.0.0; publication and firewall determine host exposure.", "components": ["docker", "docs"], "sources": ["docker:s67d863435d75", "docs:s189dd64eedf0"], "status": "REASONED"}, "docker-auth": {"text": "NEO4J_AUTH initializes a new database, does not rotate an existing password, and none disables authentication; deliver secrets outside argv.", "components": ["docs"], "sources": ["docs:sa88393321e84"], "status": "REASONED"}, "bootstrap": {"text": "Use isolated loopback Cypher Shell --change-password prompts; set-initial-password is first-start-only and takes an exposed positional password.", "components": ["docs"], "sources": ["docs:s08d51abe6a73", "docs:s27aabf32542c"], "status": "REASONED"}, "credential-input": {"text": "Unset ambient NEO4J_PASSWORD for prompts; automation may supply it through the environment, never expanded into -p, with local-inspection limits.", "components": ["docs"], "sources": ["docs:s27aabf32542c"], "status": "REASONED"}, "password-min": {"text": "Default minimum password length is 8; auth_minimum_password_length was introduced in 5.3 and does not make eight characters a recommendation.", "components": ["docs", "password-min"], "sources": ["docs:sa684352a8ce2", "password-min:s39c40121233d"], "status": "REASONED"}, "backup": {"text": "Enterprise online backup defaults explicitly to 127.0.0.1:6362 and does not inherit a widened shared host; Community has no online-backup listener.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:sb0ddc8887396"], "status": "REASONED"}, "discovery": {"text": "Enterprise discovery v1 listens on hostless :5000 when active and is deprecated since 5.23; inventory standalone servers too.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:s213150a93a9b"], "status": "REASONED"}, "shipping": {"text": "Enterprise transaction shipping/catch-up uses :6000, also serving backups and discovery v2; default replication is unauthenticated.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:scb82a1e6034e", "cluster:s213150a93a9b"], "status": "REASONED"}, "raft": {"text": "Enterprise Raft listens on hostless :7000, including on standalone Enterprise deployments.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:s213150a93a9b"], "status": "REASONED"}, "routing": {"text": "Server-side routing uses additional Bolt :7688 when enabled; dbms.routing.enabled defaults true.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:s213150a93a9b"], "status": "REASONED"}, "advertising": {"text": "Cluster listen hosts inherit the shared localhost default; advertised addresses separately inherit localhost and must be peer-reachable with matching ports.", "components": ["cluster"], "sources": ["cluster:s213150a93a9b"], "status": "REASONED"}, "discovery-version": {"text": "In 5.26 discovery defaults V1_ONLY; V2_ONLY shares 6000, mixed modes run both. Deployment docs say v2 from 5.23, while selector/endpoints are labelled 5.22.", "components": ["cluster"], "sources": ["cluster:scb82a1e6034e", "cluster:s213150a93a9b"], "status": "REASONED"}, "cluster-isolation": {"text": "Restrict cluster listeners to members with firewall allowlists; single-server overrides retain explicit loopback on 5000/6000/7000/7688.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:s213150a93a9b"], "status": "REASONED"}, "cluster-tls": {"text": "The disabled-by-default cluster SSL policy covers discovery, shipping, Raft and routing; client_auth=REQUIRE acts only after enabling it.", "components": ["cluster"], "sources": ["cluster:s3b91bd83988a", "cluster:s213150a93a9b"], "status": "REASONED"}, "cluster-certificates": {"text": "Use distinct protected PKCS#8 PEM keys, leaf-first certificate chains, trusted/revoked directories and both server/client EKUs; retain trust_all=false.", "components": ["cluster"], "sources": ["cluster:s3b91bd83988a"], "status": "REASONED"}, "backup-tls": {"text": "Cluster TLS does not enable the separate backup SSL scope on 6362 or replace Bolt/HTTPS policies.", "components": ["cluster", "docs"], "sources": ["cluster:s3b91bd83988a", "cluster:s213150a93a9b", "docs:s9114febb4116"], "status": "REASONED"}, "prometheus": {"text": "Enterprise Prometheus defaults disabled and explicitly localhost:2004; hostless :2004 inherits the shared host. Cluster SSL does not establish metrics protection.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:s213150a93a9b"], "status": "REASONED"}, "graphite": {"text": "Graphite :2003 is a disabled-by-default outbound destination, not a Neo4j listener.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9", "cluster:s213150a93a9b"], "status": "REASONED"}, "jvm-listeners": {"text": "JMX 3637 and debugging 5005 require explicit JVM options; leave remote management/debugging out of the baseline.", "components": ["cluster"], "sources": ["cluster:s0a9b520e7dd9"], "status": "REASONED"}, "docker-cluster": {"text": "Docker widens hostless cluster listeners but not explicit backup/Prometheus hosts; Enterprise defaults cluster advertisements to container hostname and maps legacy variables.", "components": ["docker", "cluster"], "sources": ["docker:s67d863435d75", "cluster:s213150a93a9b"], "status": "REASONED"}, "docker-overrides": {"text": "Explicit settings override Docker defaults, then environment overrides files; NEO4J_ maps underscores to __ and dots to _ for the four loopback overrides.", "components": ["docker", "docs"], "sources": ["docker:s67d863435d75", "docs:s189dd64eedf0"], "status": "REASONED"}, "docker-expose": {"text": "All three pinned Dockerfiles EXPOSE only 7474/7473/7687; EXPOSE is neither the full listener inventory nor a firewall.", "components": ["docker"], "sources": ["docker:sc85aeddf6446", "docker:sf120b320c41f", "docker:s313b08920171"], "status": "REASONED"}, "client-certificates": {"text": "Use policy-specific PKCS#8 PEM keys and certificates, service ownership and modes 0400/0644; legacy PKCS#1 is deprecated.", "components": ["docs"], "sources": ["docs:s9114febb4116"], "status": "REASONED"}, "bolt-required": {"text": "Enable the Bolt SSL policy and set tls_level=REQUIRED; OPTIONAL still accepts plaintext.", "components": ["docs"], "sources": ["docs:s45afc735a153", "docs:s9114febb4116"], "status": "REASONED"}, "https-policy": {"text": "Enable HTTPS with its own certificate policy and disable server.http.enabled to remove plaintext HTTP.", "components": ["docs"], "sources": ["docs:s45afc735a153", "docs:s9114febb4116"], "status": "REASONED"}, "bolt-mtls": {"text": "Client_auth=NONE uses server TLS plus database credentials; REQUIRE adds client certificates and needs trusted certificates and certificate-capable clients.", "components": ["docs"], "sources": ["docs:s9114febb4116"], "status": "REASONED"}, "community-users": {"text": "Separate Community users all have implied administrator privileges; application or fronting controls must enforce end-user authorization.", "components": ["docs"], "sources": ["docs:s7dcb6ce12fb7"], "status": "REASONED"}, "lockout": {"text": "Native authentication defaults to three failed attempts and a 5s lock; these settings do not establish an external IdP lockout policy.", "components": ["docs"], "sources": ["docs:sa684352a8ce2"], "status": "REASONED"}, "enterprise-role": {"text": "Built-in reader is broad across databases; custom catalog_reader grants database ACCESS and MATCH only for Product sku/name to a fresh catalog_app.", "components": ["docs"], "sources": ["docs:s7dcb6ce12fb7", "docs:scc9ead4d865a", "docs:sdf6a150be16d", "docs:s39011db1efd2", "docs:s349a76dad225"], "status": "REASONED"}, "effective-privileges": {"text": "A narrow role does not remove broader roles; inspect effective grants including PUBLIC before switching the application.", "components": ["docs"], "sources": ["docs:sdf6a150be16d", "docs:s49e57b8da0e8"], "status": "REASONED"}, "mfa": {"text": "Native passwords lack a second factor; Enterprise OIDC can enforce IdP MFA, LDAP simple alone cannot, and alternate paths must not bypass MFA.", "components": ["docs"], "sources": ["docs:s87f690765c5e", "docs:s9868bfc264b4"], "status": "REASONED"}, "public-execute": {"text": "Default Enterprise PUBLIC grants procedure, UDF and data-loading access; read-only graph privileges alone do not restrict these capabilities.", "components": ["docs"], "sources": ["docs:sdf6a150be16d", "docs:s216b761855ea", "docs:sd1cbea2cf003"], "status": "REASONED"}, "execute-denials": {"text": "DENY EXECUTE PROCEDURES/FUNCTIONS blocks those capabilities for catalog_reader; built-in functions remain available and narrower grants cannot override matching denials.", "components": ["docs"], "sources": ["docs:s49e57b8da0e8", "docs:s216b761855ea"], "status": "REASONED"}, "load-denial": {"text": "DENY LOAD ON ALL DATA needs Enterprise 5.13 or later; CIDR-specific privileges arrived in 5.16.", "components": ["docs", "load-min", "cidr-min"], "sources": ["docs:sd1cbea2cf003", "load-min:se48a10ba1e9a", "cidr-min:se48a10ba1e9a"], "status": "REASONED"}, "boosted": {"text": "Do not grant applications boosted execution; APOC boosted procedures can bypass graph and loading restrictions.", "components": ["apoc"], "sources": ["apoc:s3d201e2d8cd7"], "status": "REASONED"}, "plugin-loading": {"text": "Keep procedures.unrestricted empty unless reviewed and narrow procedures.allowlist from its * default; loading controls do not create Community per-user privileges.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:sa3a72e7400b3"], "status": "REASONED"}, "apoc-file": {"text": "For Neo4j 5+, APOC settings belong in apoc.conf; defaults import.file.enabled=false and import.file.use_neo4j_config=true retain file/import-directory checks.", "components": ["apoc"], "sources": ["apoc:s41842145484e"], "status": "REASONED"}, "csv-file": {"text": "allow_csv_import_from_file_urls defaults true; disable unnecessary local LOAD CSV. Configuration references take precedence over conflicting APOC overview wording.", "components": ["docs"], "sources": ["docs:sf356a53bcdde"], "status": "REASONED"}, "outbound": {"text": "LOAD CSV and APOC HTTP/JDBC can cause SSRF; JDBC needs APOC Extended and a compatible driver. Restrict egress and use compatible cypher_ip_blocklist support.", "components": ["docs", "apoc", "ssrf"], "sources": ["docs:s915f2ec50a7d", "apoc:s5667c070a781", "apoc:s3d201e2d8cd7", "ssrf:sfc06984e6e27"], "status": "REASONED"}, "memory-transaction": {"text": "db.memory.transaction.max=64m is an example per-transaction tracked-memory budget, not a default or exact process-memory limit.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:s8ae91f812f5b"], "status": "REASONED"}, "memory-database": {"text": "db.memory.transaction.total.max=256m is an example aggregate per-database transaction budget; size against heap and workload.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:s8ae91f812f5b"], "status": "REASONED"}, "memory-server": {"text": "dbms.memory.transaction.total.max=512m is an example server-wide transaction budget; tracked-limit violations terminate transactions.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:s8ae91f812f5b"], "status": "REASONED"}, "timeout": {"text": "db.transaction.timeout=30s is an example default, not a hard client ceiling; servers 4.2 through 5.2 ignore larger overrides per the driver reference.", "components": ["docs", "driver-history"], "sources": ["docs:sf356a53bcdde", "docs:s85f3aab61748", "driver-history:s2508ef9d0d44"], "status": "REASONED"}, "security-log": {"text": "Enterprise security.log with authentication records logins, administration and authorization failures; retain successful logins with log_successful_authentication=true.", "components": ["docs"], "sources": ["docs:s849558d7ac94"], "status": "REASONED"}, "query-log": {"text": "Enterprise query logging VERBOSE is the documented current default and records starts/finishes; protect logs and review server-logs.xml rotation.", "components": ["docs"], "sources": ["docs:s849558d7ac94"], "status": "REASONED"}, "query-secrets": {"text": "Disable parameter and early raw logging and enable literal obfuscation; clearQueryCaches makes dynamic literal changes affect cached queries.", "components": ["docs"], "sources": ["docs:s849558d7ac94", "docs:s9f0fd7367ed5"], "status": "REASONED"}, "error-obfuscation": {"text": "obfuscate_errors arrived in 5.26.21 and current-series 2026.01.3; do not add it on older versions, and treat obfuscated logs as sensitive.", "components": ["errors5", "errors-current"], "sources": ["errors5:s213150a93a9b", "errors-current:sf356a53bcdde"], "status": "REASONED"}, "http-log": {"text": "Community optional HTTP request logging applies only with HTTP/HTTPS and does not supply equivalent Bolt, query or security auditing.", "components": ["docs"], "sources": ["docs:s849558d7ac94"], "status": "REASONED"}, "local-guards": {"text": "Historical guard-only checks rejected placeholders and malformed marker/count inputs in six guarded blocks; no Neo4j service behavior was demonstrated. Those checks covered an earlier form of the guarded blocks, whose refusals exited with status 1; the current form is covered by gate-suite bash -n and ShellCheck passes and drafting stub runs.", "components": ["docs"], "sources": ["docs:s27aabf32542c"], "status": "DEMONSTRATED", "evidence": "Guard-only checks exercised placeholder and malformed marker/count rejection in all six guarded blocks."}, "tls-schemes": {"text": "neo4j+s and bolt+s verify certificates; +ssc skips verification. Cypher Shell trust configuration is independent of OpenSSL/curl CA options.", "components": ["docs"], "sources": ["docs:s9114febb4116", "docs:s27aabf32542c"], "status": "REASONED"}, "verify-listeners": {"text": "Confirm working Bolt/HTTPS, absent HTTP, Enterprise loopback backup, conditional cluster/Prometheus ports and Docker namespace/publication controls; an empty inventory is insufficient.", "components": ["docs", "cluster", "docker"], "sources": ["docs:s6e25ca2b57e6", "docs:sb0ddc8887396", "cluster:s0a9b520e7dd9", "cluster:s213150a93a9b", "docker:s67d863435d75"], "status": "REASONED", "verify": [1]}, "verify-cluster-tls": {"text": "Compare cluster TLS client_auth NONE with REQUIRE: no certificate and untrusted issuer must be rejected while a trusted member completes a verified handshake.", "components": ["cluster", "openssl", "tls", "timeout"], "sources": ["cluster:s3b91bd83988a", "cluster:s213150a93a9b", "openssl:s00aaf106164f", "tls:se28608fb71a7", "timeout:s8844a42d0596"], "status": "REASONED", "verify": [2]}, "cluster-probe-limits": {"text": "Inspect fatal alerts and member logs; server Verification OK, timeout 124 or transport failure alone does not prove client acceptance or rejection.", "components": ["openssl", "tls", "timeout"], "sources": ["openssl:s00aaf106164f", "tls:se28608fb71a7", "timeout:s8844a42d0596"], "status": "REASONED", "verify": [2]}, "verify-cluster-health": {"text": "SHOW SERVERS must retain expected Enabled/Available members in both states; protocol-level discovery, replication and routing comparisons remain unobserved.", "components": ["cluster", "docs"], "sources": ["cluster:scb82a1e6034e", "docs:s27aabf32542c"], "status": "REASONED", "verify": [2]}, "verify-bolt": {"text": "Verified handshake and authenticated TLS query must succeed; plaintext must fail at transport under REQUIRED, not merely at authentication.", "components": ["docs"], "sources": ["docs:s45afc735a153", "docs:s9114febb4116"], "status": "REASONED", "verify": [3]}, "verify-http-off": {"text": "An exposed 7474 endpoint answers HTTP; fixed HTTP is absent, with target/path confirmation and working TLS controls needed to interpret connection failures.", "components": ["docs"], "sources": ["docs:s45afc735a153", "docs:s6e25ca2b57e6"], "status": "REASONED", "verify": [3]}, "verify-password": {"text": "Default password acceptance before rotation must become authentication refusal while the replacement succeeds; account for lockout and inspect prompted-secret argv.", "components": ["docs"], "sources": ["docs:s08d51abe6a73", "docs:sa684352a8ce2", "docs:s27aabf32542c"], "status": "REASONED", "verify": [3]}, "verify-session": {"text": "The guarded session prompts for the application password; history disable needs Cypher Shell 2025.08+, while earlier documented shells use in-memory.", "components": ["docs", "shell-history"], "sources": ["docs:s27aabf32542c", "shell-history:s27aabf32542c"], "status": "REASONED", "verify": [4]}, "verify-query-control": {"text": "RETURN 1 must succeed before interpreting later denials; administrative work uses system and fixture queries use neo4j.", "components": ["docs"], "sources": ["docs:s27aabf32542c"], "status": "REASONED"}, "verify-graph": {"text": "Known fixtures distinguish broad access from sku/name-only reads: hidden property null, unrelated-node count zero and write refusal, with administrator fixture controls.", "components": ["docs"], "sources": ["docs:s349a76dad225", "docs:s49e57b8da0e8", "docs:s41d90cbaf5bd", "docs:s45b3067e825e"], "status": "REASONED"}, "verify-procedure": {"text": "An available db.labels call must succeed before the isolated EXECUTE PROCEDURES denial and fail authorization afterward while RETURN 1 succeeds.", "components": ["docs"], "sources": ["docs:s216b761855ea", "docs:s9f0fd7367ed5"], "status": "REASONED"}, "verify-udf": {"text": "A loaded permitted apoc.text.join must return SECURECONFIG_UDF before denial and fail authorization afterward; it is deprecated in Cypher 25.", "components": ["docs", "apoc", "cypher"], "sources": ["docs:s216b761855ea", "apoc:s656e3faa465c", "cypher:s656e3faa465c"], "status": "REASONED"}, "verify-load": {"text": "Use a known readable CSV with file loading enabled to isolate LOAD denial; separately test the file-setting toggle with an account allowed to LOAD.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:sd1cbea2cf003", "docs:s915f2ec50a7d"], "status": "REASONED"}, "verify-memory": {"text": "Calibrate a workload that completes at larger budgets; isolate each memory limit with concurrent transactions and retain a small-query control. OOM or timeout is inconclusive.", "components": ["docs"], "sources": ["docs:s8ae91f812f5b", "docs:s244dc312864d", "docs:s30abf38e206e", "docs:s6afe6e03243e", "docs:s8df4293b9d1d"], "status": "REASONED"}, "verify-timeout": {"text": "Calibrated work must time out under the 30s default but complete with a 60s override on current servers; Cypher Shell transaction-timeout needs 2025.12+.", "components": ["docs", "shell-timeout", "driver-history"], "sources": ["docs:s85f3aab61748", "docs:s244dc312864d", "docs:s30abf38e206e", "docs:s6afe6e03243e", "shell-timeout:s27aabf32542c", "driver-history:s2508ef9d0d44"], "status": "REASONED"}, "verify-security-log": {"text": "Correlate failed/successful logins, privilege changes and denied writes; compare successful-login logging off/on while authentication succeeds in both states.", "components": ["docs"], "sources": ["docs:s849558d7ac94"], "status": "REASONED"}, "verify-query-log": {"text": "Literal/parameter canaries disappear under the policy while query events and results remain; missing log events do not prove obfuscation.", "components": ["docs"], "sources": ["docs:s27aabf32542c", "docs:s849558d7ac94", "docs:s9f0fd7367ed5"], "status": "REASONED"}, "verify-error-log": {"text": "The invalid date must expose its error canary before obfuscation and retain the failure event without it afterward; a non-exposing baseline is inconclusive.", "components": ["docs"], "sources": ["docs:sf356a53bcdde", "docs:sbc262b364399"], "status": "REASONED"}, "verify-http-log": {"text": "Community HTTP logging off/on must preserve the successful authenticated HTTPS request and record it when enabled; this says nothing about Bolt auditing.", "components": ["docs"], "sources": ["docs:s849558d7ac94"], "status": "REASONED", "verify": [5]}, "query-api-version": {"text": "Query API arrived in 5.19 and became enabled by default in 5.25; the legacy transactional API was deprecated in 5.26, not universally removed.", "components": ["query-min", "query-default", "http-deprecated"], "sources": ["query-min:sa27cfcb0609b", "query-default:sa27cfcb0609b", "http-deprecated:s0ac4757c97b1"], "status": "REASONED"}, "verify-query-api": {"text": "On HTTPS 7473 /db/neo4j/query/v2, anonymous queries work with auth disabled and return 401 when enabled; valid credentials must return ok=1, not merely HTTP 202.", "components": ["docs"], "sources": ["docs:s95e2a6b71cc7", "docs:seb0695092581"], "status": "REASONED", "verify": [5]}, "verify-legacy": {"text": "When /db/neo4j/tx/commit is enabled, test its statements payload too: anonymous 401 with auth enabled and authenticated ok=1 plus empty errors; HTTP 200 alone is insufficient.", "components": ["docs"], "sources": ["docs:s4f9d1acd790a", "docs:s527f81176145"], "status": "REASONED", "verify": [6]} } } ```` ====================================================================== ==> memcached.md ====================================================================== # Memcached: bind privately; authentication and TLS are optional builds Memcached is a cache that keeps data in memory so applications can read it quickly. This guide helps your AI assistant configure it securely, including a loopback or private bind with UDP off, an unprivileged account, and TLS and binary-protocol SASL login only on builds and clients that support them. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/memcached.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against memcached source 1.6.45 and OpenSSL CLI 3.0; 3 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-10-15; documentation checked September 2026. The guide records 37 claims: 1 demonstrated by a check that ran and 36 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Memcached has no authentication by default, and its `-l` option defaults to `INADDR_ANY`, so a stock start listens on every interface on TCP 11211 and serves any client that connects. The project's own wording: memcached "does not spend much, if any, effort in ensuring its defensibility from random internet connections", so it "must not" be exposed to the internet or to untrusted users. The practical control is network isolation; SASL and TLS exist, but each needs a build compiled with that feature. SASL adds no encryption; SASL PLAIN credentials need TLS. See the [server configuration documentation](https://docs.memcached.org/serverguide/configuring/) and [SASL documentation](https://docs.memcached.org/protocols/binarysasl/). Use a maintained package carrying current security fixes. Feature introduction dates below describe availability, not a recommended deployment version. At the time of writing, [upstream lists 1.6.45 as current stable](https://memcached.org/). [1.6.42](https://github.com/memcached/memcached/wiki/ReleaseNotes1642) was explicitly security-focused and included SASL, authentication, and protocol fixes; [1.6.45](https://github.com/memcached/memcached/wiki/ReleaseNotes1645) contains further security and crash fixes. Review distribution backports and test upgrades on a canary before rollout. The startup examples build on one another; apply the chosen arguments to one service instance, rather than starting every example concurrently. Distribution service accounts and configuration files vary. ## 1. Bind to loopback or a private interface, UDP off ```bash memcached -l 127.0.0.1 -p 11211 -U 0 ``` `-l` accepts an address or `host:port`; the man page calls it "an important option to consider as there is no other way to secure the installation". Put the same flags in your distribution's service configuration, and firewall 11211 per [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) or [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md). For clients on other hosts, prefer a private network or a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)) over a public listener. Use `-l 127.0.0.1` for local clients, or the actual private interface address for remote clients. Avoid wildcard listeners such as `0.0.0.0` and `::`. Permit only intended clients through the firewall. Repeated `-l` options and address-specific ports can create additional listeners; inspect every listener. See the [network configuration documentation](https://github.com/memcached/memcached/wiki/ConfiguringServer#networking). Set both transport ports explicitly: `-p 11211 -U 0`. Changing the TCP port does not provide access control. `-p 0` disables TCP; `-U 0` disables UDP. These meanings are documented in the [1.6.45 man page](https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1). UDP defaults to disabled since 1.5.6. Retain explicit `-U 0` to override inherited package settings: public UDP enables reflection and amplification attacks. The older configuration wiki's illustrative `udpport 11211` output is not the desired state. See the [vendor DDoS advisory](https://docs.memcached.org/advisories/ddos/). ## 2. Run under a dedicated unprivileged account Limit the host privileges available to a compromised cache process. Run the service as its dedicated account. If the launcher starts as root, add `-u memcache`, substituting the existing service account: ```bash memcached -l 127.0.0.1 -p 11211 -U 0 -u memcache ``` `-u` changes identity only when started as root; it does not create the account. If the service manager already starts memcached as its dedicated unprivileged account, configure that identity there. Distribution account names vary. See the [1.6.45 man page](https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1). Supported builds also offer `-o drop_privileges` for additional syscall restrictions. This is separate from `-u`, and the [1.6.45 CLI help](https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c) documents it as disabled by default. Treat it as a follow-up control until tested with the deployed platform, build, and required operations. ## 3. Bound connections and item memory Set a connection budget and an item-storage budget: ```bash memcached -l 127.0.0.1 -p 11211 -U 0 -u memcache -c 256 -m 256 ``` These are example capacities. Calculate the connection limit from application pools and leave operational headroom. Allow memory for connections, threads, and other overhead beyond `-m`. `-m` is not a total process-memory limit, and `-c` is not a request-rate limit. Network isolation remains necessary. The documented upstream defaults are 1024 connections and 64 MB of item storage. Verify the service's effective configuration because package overrides can differ. See the [man page](https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1) and [memory and connection guidance](https://github.com/memcached/memcached/wiki/ConfiguringServer). Optionally, `-o idle_timeout=60` retires idle connections. Choose a timeout compatible with connection pools. The 1.6.45 default is zero, meaning no timeout; binary-protocol handling was fixed in 1.5.15. See the [CLI implementation](https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c) and [1.5.15 release notes](https://github.com/memcached/memcached/wiki/ReleaseNotes1515). ## 4. SASL authentication (binary protocol only) For clients that require binary SASL, use a build configured with `--enable-sasl`; add `--enable-tls` when building the encrypted deployment: ```bash ./configure --enable-sasl --enable-tls ``` SASL support dates to 1.4.3 and requires a compatible client and build. Confirm the installed binary advertises `-S`, then retain `-S` in the service arguments. Install the Cyrus SASL mechanism required by the client. See the [SASL how-to](https://github.com/memcached/memcached/wiki/SASLHowto) and [TLS build documentation](https://docs.memcached.org/features/tls/). Credentials come from the Cyrus SASL password database. The password command prompts interactively: ```bash saslpasswd2 -a memcached -c cacheuser memcached -l 10.0.0.5 -p 11211 -U 0 -u memcache -c 256 -m 256 -S ``` This demonstrates SASL enablement; add TLS as in step 5 before sending PLAIN credentials. The password database must be owned by, and readable only by, the account running memcached. SASL provides authentication without encrypting cache traffic and is intended to protect against neighbours and accidents inside a mostly trusted network, not to justify internet exposure. See the [SASL documentation](https://docs.memcached.org/protocols/binarysasl/). `-S` enables SASL commands and requires the binary protocol. Protected operations such as GET require successful authentication. Binary VERSION and SASL negotiation commands are permitted before authentication, so a VERSION response does not demonstrate an authentication bypass. See the [1.6.45 binary dispatcher](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c). Because SASL protects only the binary protocol, keep every listener on the binary protocol. memcached supports per-listener protocol overrides, and an ASCII connection, including one that selects ASCII on a negotiating listener, bypasses SASL even with `-S`, so such a listener would accept commands without credentials; a negotiated binary connection still undergoes the SASL check. Probe each listener with an unauthenticated ASCII GET as well as the binary no-credentials check, so an ASCII bypass on any listener is caught. The binary protocol is [deprecated upstream](https://docs.memcached.org/protocols/). For new text/meta clients, use private networking and an appropriate authenticated TLS deployment. ASCII has no SASL authentication, but optional ASCII token authentication exists: `-Y /etc/memcached/authfile`. Provision that file with one `username:password` pair per line, readable only by the service account. Configure a client that implements the token exchange: a fake `set` command carries `username password` as its value. Carry that exchange over TLS and keep passwords out of startup arguments. See the [authentication wire format](https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt). `-Y` arrived in 1.5.15, is built in without SASL dependencies, and provides no per-user authorization after login. Choose it as an alternative to binary SASL. See the [1.5.15 release notes](https://github.com/memcached/memcached/wiki/ReleaseNotes1515). It remains experimental in 1.6.45. Binary operation is excluded; a nonzero UDP configuration is rejected at startup, rather than silently overridden, so retain `-U 0`. See the [1.6.45 option handling](https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c). ## 5. TLS (1.5.13 and later, build with `--enable-tls`) The TLS documentation specifies a build configured with `--enable-tls` against OpenSSL 1.1.1 or later, and a client library that speaks TLS. TLS is off by default. This example combines the preceding controls with the command restrictions in steps 6 and 7: ```bash memcached -l 10.0.0.5 -p 11211 -U 0 -u memcache -c 256 -m 256 -S -Z -F -X -W \ -o ssl_chain_cert=/etc/memcached/tls/fullchain.pem,ssl_key=/etc/memcached/tls/privkey.pem ``` `--enable-tls` is the build option; `-Z` (`--enable-ssl`) enables TLS at runtime. `ssl_chain_cert` and `ssl_key` point at the PEM certificate chain and key ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)). `-o` accepts comma-separated extended options. `-o ssl_verify_mode=2` with `-o ssl_ca_cert=/path/ca.pem`, substituting the actual CA file, requires client certificates. This provides mutual TLS and a possession factor for machine clients ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)). The default verification mode is `0`, which does not require client certificates. See the [TLS documentation](https://docs.memcached.org/features/tls/). A `-l notls:127.0.0.1:11211` listener explicitly bypasses TLS for local tooling. It does not remove `-S`'s binary-protocol requirement. Keep this exception restricted to loopback if an existing deployment requires it; do not add a plaintext listener merely to obtain text administration commands. On a text-capable listener, `refresh_certs` reloads certificates without a restart, and `stats settings` shows active `ssl_` values. Those text requests cannot simply be sent to the demonstrated `-S` listener. In 1.6.45, use the service manager to send SIGHUP to the verified memcached process for certificate reload, and use an authenticated binary client's STAT request with key `settings` for settings inspection. Retain the configured certificate paths; check reload errors and inspect the certificate on a new connection. Existing connections retain their established sessions. See the [TLS reload design](https://raw.githubusercontent.com/memcached/memcached/4b9e6198fc44c9eb3ae80802a1b0dcbaf9602969/doc/tls.txt), [1.6.45 signal handling](https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c), and [binary statistics implementation](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c). MFA: there is no login for a person, so no second factor applies; human access to the host goes behind MFA per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 6. Disable unnecessary cache-wide invalidation and keep shutdown disabled If the application does not require cache-wide invalidation, add `-F`. This disables `flush_all`, reducing the risk of accidental or malicious cache eviction followed by a backend load spike. It does not prevent ordinary writes or individual deletions. Leave `-A` absent so the ASCII `shutdown` command remains disabled. Check inherited service arguments as well as the configuration you edited. Both controls are documented in the [1.6.45 man page](https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1). A rejected flush still increments `cmd_flush`; that counter alone does not establish that flushing succeeded. Verify the command response and a previously readable test item. The [protocol reference](https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt) describes invalidation and shutdown; its general flush success description must be read with the `-F` restriction. ## 7. Disable key enumeration and live watch access Cache keys and activity logs can disclose application identifiers and usage. Add `-X -W` when operations do not require key dumps or live watchers. In 1.6.45, `-X` blocks `stats cachedump`, `stats detail`, `lru_crawler metadump`, and `lru_crawler mgdump`. The man page lists fewer commands than the implementation; do not generalize this coverage to older releases. `-W` blocks `watch`. These controls leave ordinary `stats`, `stats settings`, and reads of known keys available within the applicable protocol and authentication rules. Keep those capabilities behind the same network and authentication boundary. See the [1.6.45 command enforcement](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_text.c). `-W` was introduced in [1.5.21](https://github.com/memcached/memcached/wiki/ReleaseNotes1521). The flags are listed in the [1.6.45 CLI help](https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c). Do not substitute `-o no_lru_crawler` for `-X`. It disables the background crawler, and protocol commands can enable the crawler again. It is a maintenance setting, not an authorization boundary. See the [crawler protocol](https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt). ## Verify **REASONED:** the service-level checks V1 to V6, per the 15 Sources entries that this paragraph names. The first five of the 15 Sources entries are memcached documentation, binary protocol SASL authentication; memcached documentation, TLS; memcached documentation, configuring the server (`-l`, `-U`, exposure warning); memcached 1.6.42 security release notes; and memcached 1.6.45 security and crash fixes. The sixth to ninth of the 15 Sources entries are memcached UDP reflection and amplification advisory; memcached 1.6.45 man page, network, account, resource, flush, and shutdown options; memcached protocol reference (`-Y` text protocol authentication); and memcached 1.5.15 release notes, ASCII authentication and binary idle timeout fix. The tenth to fifteenth of the 15 Sources entries are memcached 1.6.45 CLI help, defaults, effective settings, and signal handling; memcached 1.6.45 text command enforcement; memcached 1.6.45 binary authentication, statistics, and flush enforcement; memcached TLS design and certificate reload; memcached basic text command semantics; and memcached binary requests and responses. The authoring environment has no memcached binary, no container runtime and no network access to obtain either, and no available authorized service environment for reproducing these states, so no exposed or fixed service state was observed; each step records its local runs and its expected exposed and fixed outcomes. Throwaway certificates were generated for the local OpenSSL checks recorded below; a TLS-enabled memcached service, configured authentication service and separate network observers remain unavailable. Expected outcomes come from the linked vendor sources; no live outcome is claimed. **REASONED:** the syntax, lint and guard-validation record for the guide's 11 shell blocks only, not memcached; syntax and lint passes have no exposed-state comparison. The following local runs were recorded during marking review (October 2026) under bash 5.3.9 with logging stubs for memcached, ps, ss, nc and openssl first on PATH and no network. All 11 blocks passed `bash -n` and ShellCheck 0.11.0 with no findings, and `check_guard_conventions.py --strict-guards` on this guide printed PASS for 11 fences, 5 of them guard fences. Each of the five guarded blocks rejected six invalid values and two incomplete-assignment cases under `bash -u`; valid inputs reached instrumented probes. The rejected cases ran no stubs; valid cases reached the stubs with the block's arguments. This tests the guards, not memcached. The bash -n, ShellCheck, scanner and stub runs recorded here covered an earlier form of the five guarded blocks, whose refusals exited with status 1; the current form, whose refusals exit with status 2 and whose results set each block's exit status, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Memcached startup/configuration parsing remains undemonstrated. **REASONED:** the paste, quoting and builtin assumptions for every guarded block; only the V2 cases below were recorded during marking review (October 2026), and additional V2 interactive paste runs during marking review (October 2026) under bash 5.3.9 piped the block followed by `echo SESSION_SURVIVED` into `bash --norc --noprofile -i` with a stub nc first on PATH and no network. With the parentheses and unchanged placeholder, stdout printed `replace the address; not probing` then `SESSION_SURVIVED`; without the parentheses, it printed the refusal line and the guard's exit closed the shell before `SESSION_SURVIVED`. With 10.0.0.5 and the parentheses, stdout printed `STUB nc -vz -w 5 10.0.0.5 11211`, `TCP connection failed; inspect the error and matched positive control`, then `SESSION_SURVIVED`. The previously recorded V2 runs were under bash 5.3.9 with a stub nc and no network; not memcached. All V2 runs recorded here covered an earlier form of the V2 block, whose refusals exited with status 1 and whose `set --` line took one value; the current form, which also takes the observer and exits with a status for each outcome, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. A literal apostrophe inside the quotes on the `set --` line made `bash -n` fail with unexpected EOF, and the same value with the apostrophe shell-escaped parsed. With the builtin `[`, two values on the `set --` line were refused with `supply exactly one address; not probing`; with `[` shadowed by a shell function, the same block probed 10.0.0.5. The probe lines pasted without the guards ran nc against an empty target, or against `REPLACE_WITH_REAL_TARGET_IP` held by the calling shell, both of which the whole block refuses. Paste each complete block, including its parentheses, marker, and count checks. Substitute inside the single quotes on the `set --` line. Do not insert a literal apostrophe there without shell escaping. The guards assume normal shell builtins; fragments pasted below the guards are not protected. ### V1. Inspect the release, process identity, arguments, and every listener ```bash # On the service host, first ensure memcached resolves to the same installed executable used by the service. # Supply its actual PID. Run listener inspection with enough privilege to identify the process and in its # network namespace; also inspect host-side published ports for container deployments. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MEMCACHED_PID' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly one PID; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace the PID; not probing"; exit 2 ;; *) case "$1" in *[!0123456789]*|0) echo "supply a positive numeric PID; not probing"; exit 2 ;; *) command -v memcached > /dev/null || { echo "memcached is not on PATH: put the executable the service runs first on PATH; not probing"; exit 2; } set -- "$1" 0 # the 2nd value is the block's status so far: 3 (a command failed) outranks 2 (ss is not installed), which outranks 0; this block classifies no finding memcached -V || { echo "memcached -V exit $?; inconclusive"; set -- "$1" 3; } memcached -h || { echo "memcached -h exit $?; inconclusive"; set -- "$1" 3; } ps -o pid=,user=,group=,args= -p "$1" || { echo "ps exit $?: no process $1 was listed; inconclusive"; set -- "$1" 3; } if command -v ss >/dev/null; then ss -tlnup || { echo "ss -tlnup exit $?: the listener list failed; inconclusive"; set -- "$1" 3; } set -- "$1" "$2" listed # the 3rd value says whether ss ran, so the last line names every listener only when it did else echo "ss is not installed here; not listing sockets" case "$2" in 0) set -- "$1" 2 ;; esac set -- "$1" "$2" unlisted fi case "$3" in listed) echo "block exit $2; the release, the options memcached -h lists, the process account and every listener are not classified, so judge them yourself" ;; *) echo "block exit $2; the release, the options memcached -h lists and the process account are not classified, so judge them yourself" ;; esac exit "$2" ;; esac ;; esac ) ``` **REASONED:** preceding block; the `memcached -V`, `memcached -h`, `ps` and `ss -tlnup` inventory in the preceding block and the release, option and account checks in this paragraph, per the guide's cited memcached 1.6.42 security release notes, memcached 1.6.45 security and crash fixes, memcached 1.6.45 CLI help and memcached 1.6.45 man page entries. No memcached executable or service process exists in the authoring environment, and no host listener was read. During marking review (October 2026) the block ran under `bash -u` with stub memcached, ps and ss: PID 1234 reached `memcached -V`, `memcached -h`, `ps -o pid=,user=,group=,args= -p 1234` and `ss -tlnup` in that order, and invalid PIDs were refused before any stub ran. Those stub runs covered an earlier form of the V1 block, whose refusals exited with status 1; the current form, which refuses a memcached missing from PATH, exits 3 when any of the four commands fails, otherwise 2 when `ss` is not installed, and otherwise 0, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Expected exposed outcome: a release without the cited fixes or their backports, `-S` or `-Z` missing for the SASL/TLS deployment, or another process account; fixed: the fixes present, both options advertised and the dedicated account. Confirm the package's security-fix status, including distribution backports; a version string alone is insufficient. Confirm `-S` and `-Z` are advertised for the SASL/TLS deployment. The running process must use the intended dedicated account. **REASONED:** the listener comparison in this paragraph, per the guide's cited memcached 1.6.45 man page and memcached UDP reflection and amplification advisory entries. No memcached service, published container port or second network position exists in the authoring environment, so no listener was read; the stub run of the V1 block is recorded in the previous paragraph. The expected exposed and fixed outcomes are those stated here. Read every listener: TCP 11211 on loopback or the intended private address only, no memcached UDP socket, and no unexpected address-specific port or published listener. An exposed comparison has a wildcard/unintended listener or UDP enabled; a fixed comparison has only the intended listeners while an allowed client's protected operation still succeeds. Use V2 and V6 as the positive controls. The [man page](https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1) and [DDoS advisory](https://docs.memcached.org/advisories/ddos/) define the expected configuration. **REASONED:** the effective-settings check in this paragraph, per the guide's cited memcached 1.6.45 CLI help, defaults, effective settings, and signal handling entry. No memcached service exists in the authoring environment, so no `stats settings` or binary STAT response was read. During marking review (October 2026) Python printed 268435456 and 67108864 for 256 and 64 multiplied by 1048576, matching the byte values given here for the example and upstream item budgets. Expected exposed outcome: upstream or package budgets, a nonzero `udpport`, or shutdown, flush or dumping enabled; fixed: the selected budgets with UDP `0`, shutdown `no`, flush `no` and dumping `no`. For capacity, request `stats settings` through V3's applicable text configuration, or authenticated binary STAT with key `settings` for `-S`. For the example budgets, expect `maxconns` of `256` and `maxbytes` of `268435456`; unchanged upstream defaults would report `1024` and `67108864`. Compare the actual selected budgets, not these numbers if you chose different capacities. Also inspect `tcpport`, `udpport`, `inter`, `shutdown_command`, `flush_enabled`, and `dump_enabled`. Expected restrictions are UDP `0`, shutdown `no`, flush `no`, and dumping `no`. See the [effective-settings implementation](https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c). **REASONED:** the matched SET/GET positive control, per the guide's cited memcached basic text command semantics entry. No memcached service or application client exists in the authoring environment, so this has no local part. Expected in both comparisons: the application's GET returns the value its SET stored; without that, the settings observations above are unpaired. A successful application SET/GET after applying the budgets is the matched positive control. These observations establish effective settings and basic operation, not workload capacity or a total process-memory ceiling. ### V2. Pair outside denial with allowed-client success **REASONED:** separate allowed and disallowed observers and a deployed cache are unavailable. Use the real address from the deployment inventory, not a documentation address. Run this block from both observers, with `disallowed` or `allowed` as its second value, and record their locations and targets. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_REAL_TARGET_IP' 'REPLACE_WITH_disallowed_OR_allowed' # the target address, then disallowed if this observer must be refused or allowed if it must connect [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "supply exactly one address and one observer; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|-*|*[[:space:]]*) echo "replace the address; not probing"; exit 2 ;; *) case "$2" in disallowed|allowed) ;; *) echo "substitute disallowed (an observer the bind and firewall must refuse) or allowed (an observer that must connect) on the set -- line above; not probing"; exit 2 ;; esac command -v nc > /dev/null || { echo "nc is not on PATH; not probing"; exit 2; } if nc -vz -w 5 "$1" 11211; then echo "TCP connection succeeded; compare the observer and expected policy" case "$2" in disallowed) echo "FINDING: the disallowed observer connected to $1 port 11211"; exit 1 ;; *) echo "block exit 0; the allowed observer connected, and V6's application request from it is still yours to complete"; exit 0 ;; esac else set -- "$1" "$2" "$?" echo "TCP connection failed; inspect the error and matched positive control" case "$2" in disallowed) echo "block exit 0; nc exit $3 is a refusal, a timeout or a wrong target, which this block cannot tell apart, so it is an unclassified observation: pair it with the allowed observer's success and V1's listener and firewall evidence"; exit 0 ;; *) echo "nc exit $3: the allowed observer could not connect, so the positive control failed; inconclusive"; exit 3 ;; esac fi ;; esac ) ``` **REASONED:** preceding block; the `nc -vz -w 5` TCP probe in the preceding block, run from both observers, and the comparison in this paragraph, per the guide's cited memcached documentation, configuring the server entry. No deployed cache and no separate allowed and disallowed network observers exist in the authoring environment. During marking review (October 2026) the block ran under `bash -u` with a stub nc: 10.0.0.5 reached `nc -vz -w 5 10.0.0.5 11211`, a stub exit of 0 printed the succeeded line and 1 the failed line, and the placeholder, an empty value, an angle-bracketed value, an example.com name, an option-like value and a value containing a space were refused before nc ran. Those stub runs covered an earlier form of the V2 block, whose refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The current form exits 1 when a `disallowed` observer connects and 3 when an `allowed` observer cannot, and it exits 0 for the other two outcomes, a failed `disallowed` connection being an unclassified observation that it says to pair with the allowed run and V1. The expected exposed and fixed outcomes are those stated here. On an isolated test network, the exposed comparison allows the otherwise disallowed observer to connect. After fixing the bind/firewall, that observer must receive refusal or timeout while the allowed observer can still connect and complete V6's application request. Do not create public exposure to obtain a baseline. **REASONED:** the observer positions and targets in this paragraph, per the guide's cited memcached documentation, configuring the server entry. No deployed cache or second network position exists in the authoring environment, so this has no local part beyond the V2 block runs recorded two paragraphs above. Expected: the loopback or private positive control connects, and once the bind and firewall are fixed the outside probe of each non-loopback or public address does not. For loopback-only service, the positive control runs on the service host against `127.0.0.1`; the outside probe targets the host's actual non-loopback address. For a private remote-client deployment, test the actual private endpoint from both relevant network positions, and any public address or port mapping separately. **REASONED:** the timeout caveat in this paragraph, per the guide's cited memcached documentation, configuring the server entry. No deployed cache, firewall or second network position exists in the authoring environment. During marking review (October 2026) the V2 block with a stub nc exiting 1 printed `TCP connection failed; inspect the error and matched positive control`; the block branches only on the exit status of nc, so its own output does not separate a timeout from a refusal or a wrong target. That stub run covered an earlier form of the V2 block, whose refusals exited with status 1; the current form, which still branches only on the exit status of nc, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Expected: a timeout is inconclusive in either comparison until paired with V1 listener and firewall evidence. A timeout alone proves nothing about isolation: a wrong target, unavailable service, or broken route can produce it. Correlate the pair with V1's process/listener evidence and the effective firewall rules. This follows the vendor's [network isolation guidance](https://docs.memcached.org/serverguide/configuring/). ### V3. Retain the loopback text probe for its applicable configuration **REASONED:** no loopback memcached service is available. This probe is only for the no-authentication, no-TLS loopback configuration in step 1, or an isolated test instance with that transport. ```bash ( set -- PASTE_WHOLE_BLOCK '127.0.0.1' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly one address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "replace the address; not probing"; exit 2 ;; *) [ "$1" = 127.0.0.1 ] || { echo "this probe is only for loopback; not probing"; exit 2; } command -v nc > /dev/null || { echo "nc is not on PATH; not probing"; exit 2; } set -- "$1" 0 "$(if printf 'stats\r\nquit\r\n' | nc -w 5 "$1" 11211; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (3, inconclusive, outranks 0; this block classifies no finding), then the reply and nc's exit status set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" printf '%s' "$3" if [ "$4" != 0 ]; then echo "nc exit $4: the stats request failed; inconclusive"; set -- "$1" 3 else case "$3" in END$'\r'*|*$'\n'END$'\r'*) set -- "$1" "$2" ;; *) echo "the stats reply has no END line, so this is not the applicable text listener; inconclusive"; set -- "$1" 3 ;; esac fi set -- "$1" "$2" "$(if printf 'stats settings\r\nquit\r\n' | nc -w 5 "$1" 11211; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" printf '%s' "$3" if [ "$4" != 0 ]; then echo "nc exit $4: the stats settings request failed; inconclusive"; set -- "$1" 3 else case "$3" in END$'\r'*|*$'\n'END$'\r'*) set -- "$1" "$2" ;; *) echo "the stats settings reply has no END line, so this is not the applicable text listener; inconclusive"; set -- "$1" 3 ;; esac fi echo "block exit $2; the statistics and settings values are not classified, so judge them yourself" exit "$2" ;; esac ) ``` **REASONED:** preceding block; the two text requests in the preceding block and the outcomes in this paragraph, per the guide's cited memcached protocol reference, memcached basic text command semantics, memcached 1.6.45 binary authentication, statistics, and flush enforcement, and memcached documentation, TLS entries. No loopback memcached service exists in the authoring environment. During marking review (October 2026) the block ran under `bash -u` with a stub nc: the shipped 127.0.0.1 value sent `stats\r\nquit\r\n` and then `stats settings\r\nquit\r\n` to port 11211, and 10.0.0.5 and 127.0.0.2 printed `this probe is only for loopback; not probing` without running nc. Those stub runs covered an earlier form of the V3 block, whose refusals exited with status 1; the current form, which exits 3 when either reply lacks an `END` line or nc fails and otherwise 0, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The expected outcomes on the applicable listener and on the combined deployment are those stated here. Expect statistics terminated by `END` on the applicable text listener. On the combined private-address SASL/TLS deployment, this probe fails for independent reasons: nothing need listen on loopback, `-S` rejects text `stats`, and `-Z` requires TLS. That failure proves none of those controls individually. Pair it with V1's listener evidence and V6's successful authenticated request. See the [text protocol](https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt) and [binary dispatcher](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c). ### V4. Verify TLS, certificate identity, and the existing mutual-TLS option **REASONED:** following block; no TLS-enabled memcached service is available. The local V4 stub run recorded during marking review (October 2026) under `bash -u` refused a missing `ca.pem` and, with a readable `ca.pem`, reached the stub `openssl s_client -connect 10.0.0.5:11211 -CAfile ca.pem -verify_ip 10.0.0.5 -verify_return_error` command. This records argument construction and the file guard, not a TLS handshake. That stub run covered an earlier form of the V4 block, whose refusals exited with status 1; the current form, which also refuses a missing timeout or openssl and exits 3 when the command fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Provide the trusted CA as `ca.pem` and the actual server IPv4 address: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SERVER_IPV4' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly one address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|-*|*[[:space:]]*) echo "replace the address; not probing"; exit 2 ;; *) [ -r ca.pem ] || { echo "provide the trusted CA file ca.pem; not probing"; exit 2; } command -v timeout > /dev/null || { echo "timeout is not on PATH; not probing"; exit 2; } command -v openssl > /dev/null || { echo "openssl is not on PATH; not probing"; exit 2; } if timeout 10 openssl s_client -connect "$1:11211" -CAfile ca.pem -verify_ip "$1" \ -verify_return_error '*|*example.com*|"") echo "replace the disposable cache address; not probing"; exit 2 ;; *) [ "$1" = 127.0.0.1 ] || { echo "use the disposable loopback cache; not probing"; exit 2; } command -v nc > /dev/null || { echo "nc is not on PATH; not probing"; exit 2; } command -v timeout > /dev/null || { echo "timeout is not on PATH; not probing"; exit 2; } set -- "$1" 0 "$(if printf 'set sc:probe 0 300 2\r\nok\r\nget sc:probe\r\nquit\r\n' | nc -w 5 "$1" 11211; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (3, inconclusive, outranks 0; this block classifies no finding), then the control's reply and nc's exit status set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" printf '%s' "$3" case "$4:$3" in 0:*$'STORED\r\nVALUE sc:probe 0 2\r\nok\r\nEND\r'*) ;; 0:*) echo "the initial SET/GET did not store and return ok, so the results below are inconclusive"; set -- "$1" 3 "$3" "$4" ;; *) echo "nc exit $4: the initial SET/GET request failed, so the results below are inconclusive"; set -- "$1" 3 "$3" "$4" ;; esac set -- "$1" "$2" sleep 2 printf 'flush_all\r\nget sc:probe\r\nquit\r\n' | nc -w 5 "$1" 11211 || { echo "nc exit $?: the flush request failed; inconclusive"; set -- "$1" 3; } printf 'set sc:probe 0 300 2\r\nok\r\nstats items\r\nquit\r\n' | nc -w 5 "$1" 11211 || { echo "nc exit $?: the SET and item statistics request failed; inconclusive"; set -- "$1" 3; } set -- "$1" "$2" 'stats cachedump 1 10' 'stats detail dump' \ 'lru_crawler metadump all' 'lru_crawler mgdump all' 'watch' # the requests sent below, one per pass, as positional parameters while [ "$#" -gt 2 ]; do printf 'request: %s\n' "$3" printf '%s\r\n' "$3" | timeout 5 nc -w 3 "$1" 11211 || { echo "timeout or nc exit $?: the $3 request failed or timed out; inconclusive"; set -- "$1" 3 "${@:3}"; } set -- "$1" "$2" "${@:4}" done printf 'get sc:probe\r\ndelete sc:probe\r\nstats\r\nstats settings\r\nquit\r\n' | nc -w 5 "$1" 11211 || { echo "nc exit $?: the GET, DELETE and statistics request failed; inconclusive"; set -- "$1" 3; } printf 'shutdown\r\n' | nc -w 5 "$1" 11211 || { echo "nc exit $?: the shutdown request failed; inconclusive"; set -- "$1" 3; } printf 'stats\r\nquit\r\n' | nc -w 5 "$1" 11211 || { echo "nc exit $?: the statistics request after shutdown failed, as when shutdown stopped the process; read it against the shutdown row; inconclusive"; set -- "$1" 3; } echo "block exit $2; every response after the initial SET/GET is not classified, so read it against the table below" exit "$2" ;; esac ) ``` Read the responses, not just the block's final exit status: | Request | Exposed comparison | Fixed comparison and positive control | | --- | --- | --- | | Initial SET/GET | Stores and returns `ok` | Also stores and returns `ok`; otherwise subsequent results are inconclusive | | `flush_all`, then GET | Flush succeeds; the previously readable item is absent | Explicit flush refusal; GET still returns `ok` | | `stats cachedump 1 10` | Command accepted, with a normal dump terminator; an empty dump alone is not denial | Explicit dumping refusal; known-key GET still works | | `stats detail dump` | Detail dump accepted, possibly empty | Explicit detail refusal | | `lru_crawler metadump all` and `lru_crawler mgdump all` | Successful dumps from the populated fixture | Explicit dump refusal | | `watch` | Watcher accepted | Explicit watch refusal | | GET, DELETE, ordinary statistics | Ordinary operations succeed | Ordinary operations still succeed | | `shutdown`, then fresh statistics request | Process stops; subsequent connection fails | Explicit shutdown refusal; the same process still answers statistics | The short item uses slab class 1 with the stated default geometry; inspect `stats items` before interpreting that dump. A disabled or busy crawler, malformed request, connection failure, or protocol mismatch does not prove `-X`. A watcher timeout does not prove `-W`; inspect whether the watcher was accepted or explicitly refused. The two-second pause separates item creation from immediate flush timing. These comparisons follow the [basic command semantics](https://docs.memcached.org/protocols/basic/), [1.6.45 restrictions](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_text.c), and [shutdown protocol](https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt). The disposable text fixture isolates these flags; it does not demonstrate the production authentication or TLS configuration. For a binary SASL deployment, also repeat SET, GET, FLUSH, and GET through the authenticated TLS client on a disposable equivalent. Without `-F`, FLUSH invalidates the item; with `-F`, FLUSH is refused and the item remains readable. See the [binary flush implementation](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c). ### V6. Test authentication with a protected operation **REASONED:** no memcached service, configured authentication database, or compatible client is available. Use the deployed application's client, with credentials obtained interactively or from its protected secret configuration, never password arguments. **REASONED:** seeding the probe item and using fresh connections, per the guide's cited memcached documentation, binary protocol SASL authentication and memcached protocol reference entries. No memcached service, authentication database or compatible client exists in the authoring environment, so this has no local part. Expected in both comparisons: the authorized client reads back the seeded value. Seed `sc:auth-probe` with a short-lived known value through an authorized client. Use fresh connections for the following requests: | Mode | Concrete request and exposed comparison | Fixed outcome and matched positive control | | --- | --- | --- | | Binary SASL | Binary GET, opcode `0x00`, key `sc:auth-probe`, without SASL authentication. A service without `-S` returns the item | **REASONED:** the binary SASL comparison in this row, per the guide's cited memcached documentation, binary protocol SASL authentication, memcached 1.6.45 binary authentication, statistics, and flush enforcement, and memcached binary requests and responses entries; no SASL-enabled memcached build or binary client exists in the authoring environment, so this row has no local part. With `-S`, the unauthenticated GET receives authentication-required/error status `0x20`. On a fresh connection, successful SASL authentication followed by the same GET returns the known value | | ASCII token authentication | Text `get sc:auth-probe` without token exchange. A service without `-Y` returns the item | **REASONED:** the ASCII token comparison in this row, per the guide's cited memcached protocol reference and memcached 1.5.15 release notes entries; no memcached service with `-Y` or token-exchange client exists in the authoring environment, so this row has no local part. With `-Y`, it cannot retrieve the item before authentication. On a fresh connection, the client's fake SET token exchange succeeds, then the same GET returns the known value | **REASONED:** the incorrect-password comparison and matched correct-credential request, per the guide's cited memcached documentation, binary protocol SASL authentication and memcached protocol reference entries. No memcached service, authentication database or compatible client exists in the authoring environment, so this has no local part. The expected exposed and fixed outcomes are those stated here and in the table above. Also test an intentionally incorrect password on a fresh connection and confirm it cannot retrieve the item; retain the successful correct-credential request against the same endpoint. Keep the same TLS and client-certificate conditions across each authentication comparison so a TLS failure cannot masquerade as authentication enforcement. **REASONED:** the inconclusive-result rules in this paragraph, per the guide's cited memcached binary requests and responses, memcached documentation, binary protocol SASL authentication, memcached 1.6.45 binary authentication, statistics, and flush enforcement, and memcached protocol reference entries. No memcached service or client exists in the authoring environment, so this has no local part. Expected: binary VERSION and SASL negotiation can succeed in both comparisons, so only the protected GET distinguishes them. Binary VERSION and SASL negotiation success are not substitutes for GET. A missing key, closed connection, or unavailable server without the matched successful request is inconclusive. See the [binary protocol](https://docs.memcached.org/protocols/binary/), [SASL status codes](https://docs.memcached.org/protocols/binarysasl/), [binary enforcement](https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c), and [ASCII token exchange](https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt). | Check scope | Status | Deployment comparison and prerequisites | | --- | --- | --- | | V1-V6 | REASONED from the cited memcached documentation and pinned protocol sources; not demonstrated | **REASONED:** the V1 to V6 summary in this row, per the Sources entries, which this cell lists in three parts. The Sources entries (1 of 3): memcached documentation, binary protocol SASL authentication; memcached documentation, TLS; memcached documentation, configuring the server (`-l`, `-U`, exposure warning); memcached 1.6.42 security release notes; memcached 1.6.45 security and crash fixes; memcached UDP reflection and amplification advisory. The Sources entries (2 of 3): memcached 1.6.45 man page, network, account, resource, flush, and shutdown options; memcached protocol reference (`-Y` text protocol authentication); memcached 1.5.15 release notes, ASCII authentication and binary idle timeout fix; memcached 1.6.45 CLI help, defaults, effective settings, and signal handling; memcached 1.6.45 text command enforcement. The Sources entries (3 of 3): memcached 1.6.45 binary authentication, statistics, and flush enforcement; memcached TLS design and certificate reload; memcached basic text command semantics; and memcached binary requests and responses. No memcached runtime, configured SASL or TLS service, or network observers exist in the authoring environment, and the local runs are recorded at each step and at the top of this section. Obtain an authorized memcached runtime, SASL/TLS client and certificate fixtures, and allowed/disallowed network observers. Run V1-V6 against matched exposed and fixed states; record package/build identity, service arguments, process ownership, listeners, effective budgets, requests, responses, and positive controls. Validate startup parsing and certificate reload. If optional idle timeout or privilege dropping is adopted, include its platform and application checks before claiming it effective. | ## Common mistakes - A container or package that starts memcached without `-l`, so it listens on `INADDR_ANY` while a firewall rule is assumed but absent. - `-S` on a distribution package built without SASL: the option is documented as meaningful only with SASL compiled in, so confirm it took effect rather than assuming. - SASL without TLS across a shared network; the documentation is explicit that SASL adds no encryption, and PLAIN credentials need TLS. - Changing `-p` and treating the new port as access control, or inheriting a nonzero UDP setting. - Treating `-m` as a total process-memory limit or `-c` as a request-rate limit. - Assuming a rejected text request proves authentication on a binary/TLS listener, or treating VERSION as a protected operation. - Reading `cmd_flush` as proof that invalidation succeeded. - Substituting `-o no_lru_crawler` for `-X`, or assuming `-X -W` disables ordinary statistics and known-key access. - Adding a plaintext listener just to send text administration commands to a service configured for binary SASL. - Calling an outside timeout a pass without confirming the real target and a successful allowed-client request. ## Sources (checked September 2026) - memcached documentation, binary protocol SASL authentication: https://docs.memcached.org/protocols/binarysasl/ - memcached documentation, TLS: https://docs.memcached.org/features/tls/ - OpenSSL 3.0 `s_client` verification options and `-verify_return_error` (checked October 2026): https://docs.openssl.org/3.0/man1/openssl-s_client/ - OpenSSL 3.0 verification options: `-verify_hostname`, `-verify_ip` and the trusted-certificate file options (checked October 2026): https://docs.openssl.org/3.0/man1/openssl-verification-options/ - memcached documentation, configuring the server (`-l`, `-U`, exposure warning): https://docs.memcached.org/serverguide/configuring/ - memcached man page (`-l` default `INADDR_ANY`, `-U` default 0, `-S`): https://raw.githubusercontent.com/memcached/memcached/5d17f8f4bb068a0bdd4809e80e3ed5d378ef2fad/doc/memcached.1 - memcached protocol reference (`-Y` text protocol authentication): https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt - memcached current stable release: https://memcached.org/ - memcached 1.6.42 security release notes: https://github.com/memcached/memcached/wiki/ReleaseNotes1642 - memcached 1.6.45 security and crash fixes: https://github.com/memcached/memcached/wiki/ReleaseNotes1645/780e280d1a7f98f2981ffeb357ef0c9b182a9844 - memcached 1.6.45 man page, network, account, resource, flush, and shutdown options: https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1 - memcached configuration wiki, networking and resource budgets: https://github.com/memcached/memcached/wiki/ConfiguringServer - memcached UDP reflection and amplification advisory: https://docs.memcached.org/advisories/ddos/ - memcached SASL build and setup how-to: https://github.com/memcached/memcached/wiki/SASLHowto - memcached protocol status and binary deprecation: https://docs.memcached.org/protocols/ - memcached 1.5.15 release notes, ASCII authentication and binary idle timeout fix: https://docs.memcached.org/releasenotes/releasenotes1515/ - memcached 1.5.21 release notes, watch disabling: https://github.com/memcached/memcached/wiki/ReleaseNotes1521 - memcached 1.6.45 CLI help, defaults, effective settings, and signal handling: https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c - memcached 1.6.45 text command enforcement: https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_text.c - memcached 1.6.45 binary authentication, statistics, and flush enforcement: https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c - memcached TLS design and certificate reload: https://raw.githubusercontent.com/memcached/memcached/4b9e6198fc44c9eb3ae80802a1b0dcbaf9602969/doc/tls.txt - memcached basic text command semantics: https://docs.memcached.org/protocols/basic/ - memcached binary requests and responses: https://docs.memcached.org/protocols/binary/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - stable: memcached source (1.6.45) - docs: memcached documentation (unknown) - protocol: memcached protocol source (7278bdee96329915bbc87731ba005095453f5c2f) - reload: memcached TLS design (4b9e6198fc44c9eb3ae80802a1b0dcbaf9602969) - ascii: memcached ASCII authentication release notes (unknown) - watch: memcached watch-control release notes (unknown) - security: memcached security release notes (unknown) - release-notes-source: memcached release notes revision (780e280d1a7f98f2981ffeb357ef0c9b182a9844) - openssl: OpenSSL CLI (3.0) Claims (full source mapping in the JSON block below): - default (REASONED; docs, stable): Stock memcached has no authentication and binds INADDR_ANY on TCP11211; isolate it from the internet and untrusted users. - releases (REASONED; security, docs, release-notes-source): Recorded current stable is 1.6.45 with further fixes after security-focused 1.6.42; check backports and canary upgrades, not feature-minimum releases. - bind (REASONED; stable, docs): Use loopback or an intended private address with firewall restrictions; repeated -l/address-specific ports can add listeners that require inspection. - ports (REASONED; stable): Set -p 11211 -U 0 explicitly; zero disables the corresponding transport and changing TCP port provides no access control. - udp-default (REASONED; docs): UDP defaults disabled since 1.5.6; keep -U 0 against inherited settings and reflection/amplification exposure. - account (REASONED; stable): Run a dedicated unprivileged account; -u changes identity only when launched as root and does not create the account. - syscalls (REASONED; stable): Optional drop_privileges adds build/platform-dependent syscall restrictions, defaults disabled and needs deployment testing. - connections (REASONED; stable, docs): -c bounds connections, not request rate; upstream default 1024, example 256 with operational headroom. - memory (REASONED; stable, docs): -m budgets item memory, not total process memory; upstream default 64 MB, example 256 with extra process overhead. - idle (REASONED; stable, ascii): Optional idle_timeout defaults zero/no timeout in 1.6.45; choose pool-compatible values; binary handling was fixed in 1.5.15. - sasl-build (REASONED; docs): SASL support dates from 1.4.3 and requires --enable-sasl, advertised -S and compatible Cyrus mechanism/client; SASL PLAIN needs TLS. - sasl-secrets (REASONED; docs): saslpasswd2 prompts; protect the Cyrus password database for the service account. SASL authenticates without encryption and does not justify public exposure. - sasl-protocol (REASONED; stable): -S gates binary protected operations but permits VERSION/negotiation; ASCII per-listener overrides or negotiation bypass SASL, so test every listener. - ascii-auth (REASONED; docs, protocol, ascii): Deprecated binary protocol has an alternative: -Y service-only username:password file and a client fake-SET token exchange over TLS, without per-user authorization. - ascii-limits (REASONED; ascii, stable): -Y remains experimental in 1.6.45, needs no SASL dependency, excludes binary operation and rejects nonzero UDP at startup. - tls (REASONED; docs): TLS dates from 1.5.13, needs --enable-tls/OpenSSL1.1.1+ and compatible clients; default off, runtime -Z uses PEM chain/key extended options. - mtls (REASONED; docs): ssl_verify_mode defaults 0/no required client cert; mode 2 with ssl_ca_cert requires certificates, adding machine possession, not human MFA. - notls (REASONED; docs, stable): A notls loopback listener bypasses TLS but does not remove -S binary requirements; do not add plaintext merely for text administration. - reload (REASONED; reload, stable): Text refresh_certs/settings do not apply directly to -S; 1.6.45 SIGHUP reloads configured paths and authenticated binary STAT inspects settings; existing sessions persist. - flush (REASONED; stable, protocol): -F blocks cache-wide invalidation but preserves ordinary writes/deletes; rejected flush increments cmd_flush, so inspect response and retained item. - shutdown (REASONED; stable, protocol): Leave -A absent to keep ASCII shutdown disabled; inspect inherited arguments. - dump (REASONED; stable): 1.6.45 -X blocks cachedump, stats detail and crawler meta/mgdump; ordinary statistics and known-key reads remain available. - watch (REASONED; watch, stable): -W blocks watch and dates from 1.5.21; preserve network/authentication protection for other remaining capabilities. - crawler (REASONED; protocol): no_lru_crawler is not an authorization boundary; protocol commands can re-enable the background crawler. - verify-inventory (REASONED; stable, docs): Check executable/build capabilities, process account and every TCP/UDP/publication listener; pair intended listeners with allowed-client protected operations. - verify-budgets (REASONED; stable): Effective example maxconns/maxbytes are 256/268435456 versus upstream 1024/67108864; verify disabled UDP/shutdown/flush/dump and matched SET/GET, not workload capacity. - verify-isolation (REASONED; docs): Disallowed observer must lose TCP11211 while allowed observer still completes a protected request; timeout alone is inconclusive without target/listener/firewall evidence. - verify-text (REASONED; protocol, stable): Loopback plaintext stats/settings end with END only in the applicable text configuration; SASL/TLS/private-bind failures prove none of those controls independently. - verify-tls (REASONED; docs, openssl): Plaintext cannot complete TLS; fixed endpoint must verify CA and IP/DNS identity and support application operations; chain-only trust is insufficient. The check must fail closed on certificate verification errors; s_client continues the handshake after such errors unless -verify_return_error is given. - verify-mtls (REASONED; docs): Under verify mode 2, only a valid client certificate completes the application request; server Verification: OK can precede client rejection. - verify-reload (REASONED; reload, stable): After replacing material and SIGHUP, new connections must present the new certificate and complete an application request; errors or old certificates fail. - verify-flush (REASONED; stable, docs): Disposable text/binary comparisons change flush success and lost item to explicit refusal and retained item; initial SET/GET and ordinary operations stay usable. - verify-dump (REASONED; stable, protocol): Populated disposable fixtures must change dump acceptance to explicit refusal; empty dumps, crawler problems and protocol failures do not prove -X. - verify-watch (REASONED; stable): Exposed watch acceptance must become explicit refusal; watcher timeout alone does not demonstrate -W. - verify-shutdown (REASONED; protocol, stable): Disposable -A baseline stops; fixed state refuses shutdown while the same process answers statistics; disable restart masking and never use a live cache. - verify-auth (REASONED; docs, stable, protocol): Fresh no-auth/wrong-password protected GETs must fail while correct SASL or ASCII token login returns a known item; binary SASL auth-required status is 0x20. - local-guards (DEMONSTRATED; stable, docs): Historical shell/scanner checks and invalid-input rejection exercised guards and instrumented dispatch, not memcached startup or service behavior. Those checks covered an earlier form of the five guarded blocks, whose refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck passes. ````json { "schema": 1, "checked": "2026-10-15", "documentation_checked": "2026-09", "body_sha256": "57ef35d716df1dc97e6a33ed6fc2d7cf7c4a2b6d0a932215eb44a778f579836e", "components": { "stable": { "name": "memcached source", "basis": "1.6.45", "sources": { "s05c72b35637d": "https://raw.githubusercontent.com/memcached/memcached/1.6.45/doc/memcached.1", "s1310cfc0315d": "https://raw.githubusercontent.com/memcached/memcached/1.6.45/memcached.c", "sceaaea90ec60": "https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_bin.c", "sd40dab94f516": "https://raw.githubusercontent.com/memcached/memcached/1.6.45/proto_text.c" } }, "docs": { "name": "memcached documentation", "basis": "unknown", "sources": { "sb2bb8731440a": "https://docs.memcached.org/serverguide/configuring/", "s75df2663dea3": "https://memcached.org/", "sf2153ed1541e": "https://github.com/memcached/memcached/wiki/ConfiguringServer", "s9b5a9f424414": "https://docs.memcached.org/advisories/ddos/", "sed14f4319d19": "https://github.com/memcached/memcached/wiki/SASLHowto", "s3b05fd21e910": "https://docs.memcached.org/protocols/binarysasl/", "s31b3834d23a2": "https://docs.memcached.org/protocols/", "s57f57201fc31": "https://docs.memcached.org/features/tls/", "sc0c6532a1ef5": "https://docs.memcached.org/protocols/basic/" } }, "protocol": { "name": "memcached protocol source", "basis": "7278bdee96329915bbc87731ba005095453f5c2f", "sources": { "s625682583be8": "https://raw.githubusercontent.com/memcached/memcached/7278bdee96329915bbc87731ba005095453f5c2f/doc/protocol.txt" } }, "reload": { "name": "memcached TLS design", "basis": "4b9e6198fc44c9eb3ae80802a1b0dcbaf9602969", "sources": { "s839f028f2a46": "https://raw.githubusercontent.com/memcached/memcached/4b9e6198fc44c9eb3ae80802a1b0dcbaf9602969/doc/tls.txt" } }, "ascii": { "name": "memcached ASCII authentication release notes", "basis": "unknown", "sources": { "sdcc562ed68f7": "https://docs.memcached.org/releasenotes/releasenotes1515/" } }, "watch": { "name": "memcached watch-control release notes", "basis": "unknown", "sources": { "sa3593ecebdce": "https://github.com/memcached/memcached/wiki/ReleaseNotes1521" } }, "security": { "name": "memcached security release notes", "basis": "unknown", "sources": { "se456e52d21c6": "https://github.com/memcached/memcached/wiki/ReleaseNotes1642" } }, "release-notes-source": { "name": "memcached release notes revision", "basis": "780e280d1a7f98f2981ffeb357ef0c9b182a9844", "sources": { "s0a46fce08585": "https://github.com/memcached/memcached/wiki/ReleaseNotes1645/780e280d1a7f98f2981ffeb357ef0c9b182a9844" } }, "openssl": { "name": "OpenSSL CLI", "basis": "3.0", "sources": { "s00aaf106164f": "https://docs.openssl.org/3.0/man1/openssl-s_client/", "s8a7380c7371d": "https://docs.openssl.org/3.0/man1/openssl-verification-options/" } } }, "claims": { "default": {"text": "Stock memcached has no authentication and binds INADDR_ANY on TCP11211; isolate it from the internet and untrusted users.", "components": ["docs", "stable"], "sources": ["docs:sb2bb8731440a", "stable:s05c72b35637d"], "status": "REASONED"}, "releases": {"text": "Recorded current stable is 1.6.45 with further fixes after security-focused 1.6.42; check backports and canary upgrades, not feature-minimum releases.", "components": ["security", "docs", "release-notes-source"], "sources": ["release-notes-source:s0a46fce08585", "security:se456e52d21c6", "docs:s75df2663dea3"], "status": "REASONED"}, "bind": {"text": "Use loopback or an intended private address with firewall restrictions; repeated -l/address-specific ports can add listeners that require inspection.", "components": ["stable", "docs"], "sources": ["stable:s05c72b35637d", "docs:sf2153ed1541e"], "status": "REASONED"}, "ports": {"text": "Set -p 11211 -U 0 explicitly; zero disables the corresponding transport and changing TCP port provides no access control.", "components": ["stable"], "sources": ["stable:s05c72b35637d"], "status": "REASONED"}, "udp-default": {"text": "UDP defaults disabled since 1.5.6; keep -U 0 against inherited settings and reflection/amplification exposure.", "components": ["docs"], "sources": ["docs:s9b5a9f424414"], "status": "REASONED"}, "account": {"text": "Run a dedicated unprivileged account; -u changes identity only when launched as root and does not create the account.", "components": ["stable"], "sources": ["stable:s05c72b35637d"], "status": "REASONED"}, "syscalls": {"text": "Optional drop_privileges adds build/platform-dependent syscall restrictions, defaults disabled and needs deployment testing.", "components": ["stable"], "sources": ["stable:s1310cfc0315d"], "status": "REASONED"}, "connections": {"text": "-c bounds connections, not request rate; upstream default 1024, example 256 with operational headroom.", "components": ["stable", "docs"], "sources": ["stable:s05c72b35637d", "docs:sf2153ed1541e"], "status": "REASONED"}, "memory": {"text": "-m budgets item memory, not total process memory; upstream default 64 MB, example 256 with extra process overhead.", "components": ["stable", "docs"], "sources": ["stable:s05c72b35637d", "docs:sf2153ed1541e"], "status": "REASONED"}, "idle": {"text": "Optional idle_timeout defaults zero/no timeout in 1.6.45; choose pool-compatible values; binary handling was fixed in 1.5.15.", "components": ["stable", "ascii"], "sources": ["stable:s1310cfc0315d", "ascii:sdcc562ed68f7"], "status": "REASONED"}, "sasl-build": {"text": "SASL support dates from 1.4.3 and requires --enable-sasl, advertised -S and compatible Cyrus mechanism/client; SASL PLAIN needs TLS.", "components": ["docs"], "sources": ["docs:sed14f4319d19", "docs:s3b05fd21e910"], "status": "REASONED"}, "sasl-secrets": {"text": "saslpasswd2 prompts; protect the Cyrus password database for the service account. SASL authenticates without encryption and does not justify public exposure.", "components": ["docs"], "sources": ["docs:s3b05fd21e910"], "status": "REASONED"}, "sasl-protocol": {"text": "-S gates binary protected operations but permits VERSION/negotiation; ASCII per-listener overrides or negotiation bypass SASL, so test every listener.", "components": ["stable"], "sources": ["stable:sceaaea90ec60"], "status": "REASONED"}, "ascii-auth": {"text": "Deprecated binary protocol has an alternative: -Y service-only username:password file and a client fake-SET token exchange over TLS, without per-user authorization.", "components": ["docs", "protocol", "ascii"], "sources": ["docs:s31b3834d23a2", "protocol:s625682583be8", "ascii:sdcc562ed68f7"], "status": "REASONED"}, "ascii-limits": {"text": "-Y remains experimental in 1.6.45, needs no SASL dependency, excludes binary operation and rejects nonzero UDP at startup.", "components": ["ascii", "stable"], "sources": ["ascii:sdcc562ed68f7", "stable:s1310cfc0315d"], "status": "REASONED"}, "tls": {"text": "TLS dates from 1.5.13, needs --enable-tls/OpenSSL1.1.1+ and compatible clients; default off, runtime -Z uses PEM chain/key extended options.", "components": ["docs"], "sources": ["docs:s57f57201fc31"], "status": "REASONED"}, "mtls": {"text": "ssl_verify_mode defaults 0/no required client cert; mode 2 with ssl_ca_cert requires certificates, adding machine possession, not human MFA.", "components": ["docs"], "sources": ["docs:s57f57201fc31"], "status": "REASONED"}, "notls": {"text": "A notls loopback listener bypasses TLS but does not remove -S binary requirements; do not add plaintext merely for text administration.", "components": ["docs", "stable"], "sources": ["docs:s57f57201fc31", "stable:sceaaea90ec60"], "status": "REASONED"}, "reload": {"text": "Text refresh_certs/settings do not apply directly to -S; 1.6.45 SIGHUP reloads configured paths and authenticated binary STAT inspects settings; existing sessions persist.", "components": ["reload", "stable"], "sources": ["reload:s839f028f2a46", "stable:s1310cfc0315d", "stable:sceaaea90ec60"], "status": "REASONED"}, "flush": {"text": "-F blocks cache-wide invalidation but preserves ordinary writes/deletes; rejected flush increments cmd_flush, so inspect response and retained item.", "components": ["stable", "protocol"], "sources": ["stable:s05c72b35637d", "stable:sceaaea90ec60", "protocol:s625682583be8"], "status": "REASONED"}, "shutdown": {"text": "Leave -A absent to keep ASCII shutdown disabled; inspect inherited arguments.", "components": ["stable", "protocol"], "sources": ["stable:s05c72b35637d", "protocol:s625682583be8"], "status": "REASONED"}, "dump": {"text": "1.6.45 -X blocks cachedump, stats detail and crawler meta/mgdump; ordinary statistics and known-key reads remain available.", "components": ["stable"], "sources": ["stable:sd40dab94f516"], "status": "REASONED"}, "watch": {"text": "-W blocks watch and dates from 1.5.21; preserve network/authentication protection for other remaining capabilities.", "components": ["watch", "stable"], "sources": ["watch:sa3593ecebdce", "stable:sd40dab94f516"], "status": "REASONED"}, "crawler": {"text": "no_lru_crawler is not an authorization boundary; protocol commands can re-enable the background crawler.", "components": ["protocol"], "sources": ["protocol:s625682583be8"], "status": "REASONED"}, "verify-inventory": {"text": "Check executable/build capabilities, process account and every TCP/UDP/publication listener; pair intended listeners with allowed-client protected operations.", "components": ["stable", "docs"], "sources": ["stable:s05c72b35637d", "docs:s9b5a9f424414"], "status": "REASONED", "verify": [1]}, "verify-budgets": {"text": "Effective example maxconns/maxbytes are 256/268435456 versus upstream 1024/67108864; verify disabled UDP/shutdown/flush/dump and matched SET/GET, not workload capacity.", "components": ["stable"], "sources": ["stable:s1310cfc0315d"], "status": "REASONED", "verify": [1, 3]}, "verify-isolation": {"text": "Disallowed observer must lose TCP11211 while allowed observer still completes a protected request; timeout alone is inconclusive without target/listener/firewall evidence.", "components": ["docs"], "sources": ["docs:sb2bb8731440a"], "status": "REASONED", "verify": [2]}, "verify-text": {"text": "Loopback plaintext stats/settings end with END only in the applicable text configuration; SASL/TLS/private-bind failures prove none of those controls independently.", "components": ["protocol", "stable"], "sources": ["protocol:s625682583be8", "stable:sceaaea90ec60"], "status": "REASONED", "verify": [3]}, "verify-tls": {"text": "Plaintext cannot complete TLS; fixed endpoint must verify CA and IP/DNS identity and support application operations; chain-only trust is insufficient. The check must fail closed on certificate verification errors; s_client continues the handshake after such errors unless -verify_return_error is given.", "components": ["docs", "openssl"], "sources": ["docs:s57f57201fc31", "openssl:s8a7380c7371d", "openssl:s00aaf106164f"], "status": "REASONED", "verify": [4]}, "verify-mtls": {"text": "Under verify mode 2, only a valid client certificate completes the application request; server Verification: OK can precede client rejection.", "components": ["docs"], "sources": ["docs:s57f57201fc31"], "status": "REASONED", "verify": [4]}, "verify-reload": {"text": "After replacing material and SIGHUP, new connections must present the new certificate and complete an application request; errors or old certificates fail.", "components": ["reload", "stable"], "sources": ["reload:s839f028f2a46", "stable:s1310cfc0315d"], "status": "REASONED", "verify": [4]}, "verify-flush": {"text": "Disposable text/binary comparisons change flush success and lost item to explicit refusal and retained item; initial SET/GET and ordinary operations stay usable.", "components": ["stable", "docs"], "sources": ["stable:sd40dab94f516", "stable:sceaaea90ec60", "docs:sc0c6532a1ef5"], "status": "REASONED", "verify": [5]}, "verify-dump": {"text": "Populated disposable fixtures must change dump acceptance to explicit refusal; empty dumps, crawler problems and protocol failures do not prove -X.", "components": ["stable", "protocol"], "sources": ["stable:sd40dab94f516", "protocol:s625682583be8"], "status": "REASONED", "verify": [5]}, "verify-watch": {"text": "Exposed watch acceptance must become explicit refusal; watcher timeout alone does not demonstrate -W.", "components": ["stable"], "sources": ["stable:sd40dab94f516"], "status": "REASONED", "verify": [5]}, "verify-shutdown": {"text": "Disposable -A baseline stops; fixed state refuses shutdown while the same process answers statistics; disable restart masking and never use a live cache.", "components": ["protocol", "stable"], "sources": ["protocol:s625682583be8", "stable:sd40dab94f516"], "status": "REASONED", "verify": [5]}, "verify-auth": {"text": "Fresh no-auth/wrong-password protected GETs must fail while correct SASL or ASCII token login returns a known item; binary SASL auth-required status is 0x20.", "components": ["docs", "stable", "protocol"], "sources": ["docs:s3b05fd21e910", "stable:sceaaea90ec60", "protocol:s625682583be8"], "status": "REASONED"}, "local-guards": {"text": "Historical shell/scanner checks and invalid-input rejection exercised guards and instrumented dispatch, not memcached startup or service behavior. Those checks covered an earlier form of the five guarded blocks, whose refusals exited with status 1; the current form is covered by the gate suite's bash -n and ShellCheck passes.", "components": ["stable", "docs"], "sources": ["stable:s05c72b35637d", "docs:s57f57201fc31"], "status": "DEMONSTRATED", "evidence": "Each of the five guarded blocks rejected six invalid values and two incomplete-assignment cases under `bash -u`; valid inputs reached instrumented probes."} } } ```` ====================================================================== ==> object-storage.md ====================================================================== # Object storage: S3, Cloudflare R2, Google Cloud Storage, Azure Blob, Supabase Storage Amazon S3, Cloudflare R2, Google Cloud Storage, Azure Blob Storage and Supabase Storage store files in buckets or, on Azure, containers. This guide helps your AI assistant configure them securely, including public access blocked for private data, per-application permissions, HTTPS enforced where supported, and short-lived signed links for sharing. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/object-storage.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Amazon S3 documentation (no recorded version); 4 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 63 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. AI projects put user uploads, datasets, and model files in buckets, and one public bucket or one over-broad policy leaks every object in it, silently, to anyone who guesses or scrapes a URL. Every provider below now defaults new buckets to private; the work is keeping them that way, granting access per principal, and sharing objects through short-lived signed URLs rather than by making anything public. Self-hosted MinIO is covered in [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md). Documentation was checked in September 2026. Defaults below refer to the documented behavior at the time of writing; inspect existing resources and inherited policies rather than assuming they match new-resource defaults. These controls address separate questions: who can read an object, which network routes reach it, whether transport is encrypted, and whether an object can be deleted. Live behavior has not been demonstrated in this authoring environment: no authorized cloud accounts, buckets, application identities, or network fixtures were supplied. The Verify section marks the live comparisons **REASONED** from the cited vendor documentation. Paste shell blocks whole, including the parentheses, marker, and argument-count checks. Substitute inside the single quotes on each `set --` line. These examples assume genuine shell builtins; a value containing a literal apostrophe needs correct shell quoting rather than direct substitution. Load cloud credentials through the provider's credential mechanism, never as literal keys in command arguments. ## Amazon S3 ### 1. Block public access at the account and bucket New buckets and objects allow no public access, and Object Ownership defaults to "Bucket owner enforced", which disables ACLs; keep it that way and grant access only through bucket policies and IAM. These examples target general purpose buckets. All four bucket Block Public Access settings default to enabled for new buckets. This does not establish an account-level default: inspect the account configuration and any AWS Organizations policy. See [bucket defaults](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingBucket.html) and [Object Ownership](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html). Turn on all four Block Public Access settings (`BlockPublicAcls`, `IgnorePublicAcls`, `BlockPublicPolicy`, `RestrictPublicBuckets`) at the account level as well as per bucket. S3 applies the most restrictive combination across applicable access point, bucket, and effective account settings, including organization enforcement. An account restriction therefore still applies if someone loosens a bucket setting later. Block Public Access neither encrypts objects nor removes stored public grants; disabling it can reactivate those grants. Remove unnecessary public policies and ACL grants as well. See [Block Public Access](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html). Before changing an existing bucket to `ObjectOwnership=BucketOwnerEnforced`, migrate ACL-dependent clients and access grants to policies. Uploads that request unsupported ACLs fail; uploads without an ACL, or with `bucket-owner-full-control`, remain supported. ACLs cease participating in authorization, but their stored configuration is not erased. See [the ownership migration behavior](https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html). The following applies the baseline and reads it back. Account changes affect other buckets in that account, so first inventory intentional public workloads and migrate them to the intended delivery architecture. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ACCOUNT_ID' 'REPLACE_WITH_BUCKET' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not configuring"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not configuring"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the account ID; not configuring"; exit 2 ;; *) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the bucket; not configuring"; exit 2 ;; *) aws s3control put-public-access-block --account-id "$1" \ --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true || exit aws s3api put-public-access-block --bucket "$2" \ --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true || exit aws s3api put-bucket-ownership-controls --bucket "$2" \ --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]' || exit aws s3control get-public-access-block --account-id "$1" || exit aws s3api get-public-access-block --bucket "$2" || exit aws s3api get-bucket-ownership-controls --bucket "$2" || exit aws s3api get-bucket-policy-status --bucket "$2" ;; esac ;; esac ) ``` Expect all four flags to be `true` in both readbacks and ownership to be `BucketOwnerEnforced`. For an attached bucket policy, expect `"IsPublic": false`. A missing policy is a separate state to inspect; an access-denied or failed readback is not evidence of privacy. The command syntax is documented for [account configuration](https://docs.aws.amazon.com/cli/latest/reference/s3control/put-public-access-block.html), [bucket configuration](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-public-access-block.html), and [ownership controls](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-ownership-controls.html). An Allow in a bucket policy is "public" if it grants to `"Principal": "*"` without a qualifying fixed restriction, such as a specific principal, `aws:SourceVpc`, `aws:SourceArn`, or a sufficiently narrow `aws:SourceIp`. A broad Deny is not a public grant. See [the meaning of public](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html). IAM Access Analyzer for S3 lists buckets whose ACL, bucket policy, or access point policy grants public or cross-account access, but its findings come only from an account-level external-access analyzer in the same Region; an organization-level analyzer has a different zone of trust and does not supply this S3 external-access view. Findings update asynchronously, and archived findings drop out of the active view; create an account-level analyzer in every Region that holds buckets, review archived as well as active findings, and confirm the Block Public Access settings directly rather than treating an empty list as proof. See [IAM Access Analyzer for S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-analyzer.html). ### 2. Give applications and signers only their object permissions Give each application its own IAM role with only the actions and prefixes it uses ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)). For an application that reads and uploads under `uploads/`, this identity policy is a starting point: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "ReadAndUploadOnly", "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": "arn:aws:s3:::REPLACE_WITH_BUCKET/uploads/*" }, { "Sid": "ListUploadsOnly", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::REPLACE_WITH_BUCKET", "Condition": { "StringLike": { "s3:prefix": ["uploads/", "uploads/*"] } } } ] } ``` Remove `s3:PutObject` from a download-only signer. Remove the listing statement if the application already knows object keys. Add deletion, multipart, or KMS permissions only when the actual operation needs them. Avoid `s3:*` and `"Resource": "*"` in application Allow statements. Other attached policies can add permissions, so review the role's effective access. See [identity policy examples](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-policies-s3.html) and [prefix conditions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/amazon-s3-policy-keys.html). ### 3. Require encrypted transport and choose network restrictions separately Merge this statement into the bucket policy, preserving existing required statements. It denies insecure requests from principals other than AWS services: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "DenyInsecureTransport", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::REPLACE_WITH_BUCKET", "arn:aws:s3:::REPLACE_WITH_BUCKET/*" ], "Condition": { "Bool": { "aws:SecureTransport": "false", "aws:PrincipalIsAWSService": "false" } } } ] } ``` The broad action in this Deny is intentional. The `aws:PrincipalIsAWSService=false` condition avoids breaking service-to-service requests whose network context can be redacted. It does not grant those services access; they still need authorization. See [transport policy conditions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/amazon-s3-policy-keys.html). If every consumer must use a particular VPC endpoint, optionally add this separate statement: ```json { "Sid": "DenyOutsideChosenEndpoint", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::REPLACE_WITH_BUCKET", "arn:aws:s3:::REPLACE_WITH_BUCKET/*" ], "Condition": { "StringNotEquals": { "aws:SourceVpce": "REPLACE_WITH_VPC_ENDPOINT_ID" } } } ``` This is an opt-in network boundary, not a universal private-bucket baseline. It can deny console access, external presigned-URL consumers, and integrations that do not traverse that endpoint. Establish the endpoint path and recovery procedure before applying it. A valid signature does not override the Deny. See [VPC endpoint bucket policies](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies-vpc-endpoint.html). ### 4. Keep signed access short and plan revocation Share objects with presigned URLs and a short expiry. The original example becomes `aws s3 presign s3://REPLACE_WITH_BUCKET/model.safetensors --expires-in 600`; the guarded Verify block demonstrates the same operation with a 60-second lifetime. The CLI default is 3600 seconds and maximum is 604800 seconds. The signing principal needs the underlying permission, such as `s3:GetObject`; signing a URL is not proof that the request will be authorized. Temporary credentials can expire before the requested URL lifetime. See [CLI presigning](https://docs.aws.amazon.com/cli/latest/reference/s3/presign.html) and [presigned URL permissions and expiry](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). For an additional age limit, optionally merge this statement into the bucket policy: ```json { "Sid": "DenyOldSignatures", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": "arn:aws:s3:::REPLACE_WITH_BUCKET/*", "Condition": { "NumericGreaterThan": { "s3:signatureAge": "600000" } } } ``` `600000` is ten minutes in milliseconds. This example limits signature age; it does not change the URL's encoded expiry, and its effect must be checked against other signed workloads. Revoking or deactivating the signing credentials invalidates dependent URLs; an effective IAM or bucket-policy denial can also stop access. S3 does not have Azure's container stored-access-policy mechanism. See [presigned URL restrictions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). ### 5. Keep encryption enabled; use KMS when key control is required All new object uploads already receive SSE-S3 encryption unless another supported encryption configuration applies. SSE-KMS is optional when you need KMS key control. Changing the bucket default does not retroactively encrypt or re-encrypt existing objects. See [default bucket encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-encryption.html). For SSE-KMS, the `put-bucket-encryption` configuration has this shape after substituting an appropriate key ARN: ```json { "Rules": [ { "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "REPLACE_WITH_KMS_KEY_ARN" }, "BucketKeyEnabled": true } ] } ``` Apply it through the documented [bucket-encryption operation](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-encryption.html), with the required key policy and application permissions. `BucketKeyEnabled` is disabled by default for general purpose buckets; enabling it reduces KMS request costs. It is not a public-access barrier. Encryption does not repair public permissions. See [the encryption rule reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ServerSideEncryptionRule.html) and [S3 Bucket Keys](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html). ### 6. Add recovery, retention, and audit after fixing exposure S3 Versioning is disabled by default. Enable it when recovery from overwrites or deletion is required, and account for retained versions. Once enabled, versioning can be suspended but the bucket cannot return to its original unversioned state. See [S3 Versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html). Object Lock requires versioning. Once Object Lock is enabled, it cannot be disabled and versioning cannot be suspended. `GOVERNANCE` retention can be bypassed by a principal with `s3:BypassGovernanceRetention` that explicitly requests the bypass. `COMPLIANCE` retention cannot be shortened or bypassed by an ordinary administrator or the account root user. Choose retention periods deliberately; test governance behavior before committing to compliance retention. Locks protect object versions, not confidentiality, and do not prevent a new version or delete marker being added. See [Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html) and [its irreversible configuration](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-configure.html). MFA Delete adds MFA requirements to permanent version deletion and changes to versioning state. Only the bucket owner's root account can enable it. It is configured through the CLI or API, not the console; it is not MFA for object reads. MFA Delete is incompatible with lifecycle configurations. Do not place root access keys or a real MFA code into a copied command. See [MFA Delete](https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html). Enable server access logging to a separate destination bucket and configure CloudTrail S3 data events for the object operations you need to audit. Both are off by default; CloudTrail management-event history is not an object-access log. See [server logging setup](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html) and [CloudTrail S3 data events](https://docs.aws.amazon.com/AmazonS3/latest/userguide/cloudtrail-logging-s3-info.html). With ACLs disabled on the log destination, grant `logging.s3.amazonaws.com` `s3:PutObject` on the intended log prefix through a destination bucket policy, constrained by the source bucket ARN and source account. Do not use ACL target grants. For delivery to an S3 bucket, the destination must be in the same account and Region; do not select an Object Lock destination or create recursive logging. Delivery is delayed, so issue a known fixture request and look for its record rather than treating an empty log as proof that nothing happened. See [log-delivery permissions and prerequisites](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html). ## Cloudflare R2 ### 1. Close every unintended public route Buckets are never publicly accessible by default; public access is an explicit step, either a custom domain you control or a Cloudflare-managed `r2.dev` subdomain, which is rate-limited and for development only. Under the bucket's Settings, keep the Public Development URL disabled. To disable an enabled URL, select Disable, type `disallow`, and confirm. Disabling one route leaves other enabled routes unchanged. See [public bucket settings](https://developers.cloudflare.com/r2/buckets/public-buckets/). Attaching no custom domain is a valid baseline for a bucket accessed only through authenticated APIs or presigned URLs. A custom domain can also be protected with Cloudflare Access or WAF token authentication; it need not be anonymously public. Keep `r2.dev` disabled when using those protections, or callers can bypass the protected hostname. Check every attached domain and any application route that serves the bucket. See [custom-domain access controls](https://developers.cloudflare.com/r2/buckets/public-buckets/) and [protecting R2 with Cloudflare Access](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/). ### 2. Scope application tokens by permission and bucket Create R2 API tokens with the least permission: `Object Read only` or `Object Read & Write` scoped to explicit buckets for applications. `Admin Read & Write` can create and delete buckets and change their configuration and belongs with operators; `Admin Read only` can inspect buckets and read objects but not create or delete buckets. The secret access key is shown once, so store it in a secret manager ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). See [R2 token permissions](https://developers.cloudflare.com/r2/api/tokens/). An Account API token can still be bucket-scoped when given object permissions. Account versus User API token describes the token's ownership and lifecycle; it does not by itself establish resource scope. Inspect the resource permissions and selected buckets. Give a download signer read-only object access rather than administrative permissions. See [token creation and resource scope](https://developers.cloudflare.com/r2/api/tokens/). ### 3. Use short presigned URLs and explicit browser CORS R2 supports S3 presigned URLs, generated with your R2 token and SigV4, valid from 1 second to 7 days (604800 seconds); keep uploads and downloads on these rather than on an anonymously public bucket. In the JavaScript signing example, `expiresIn` is the lifetime in seconds; choose it explicitly, for example `600`. Presigned URLs work on the R2 S3 API domain, not custom domains. Do not replace the signed hostname with your custom hostname. A protected custom domain uses its own Access or WAF authentication flow. See [R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/). For browser clients, add a bucket CORS policy with the application's exact origins, operations, and request headers. In the dashboard's CORS Policy JSON editor, this example permits reads and uploads from one origin: ```json [ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["GET", "PUT", "HEAD"], "AllowedHeaders": ["Content-Type"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 600 } ] ``` Replace the origin with the application's real HTTPS origin, remove unused methods, and add only headers the client actually sends. This is the dashboard format; Wrangler's configuration format differs. CORS is a browser control, not authorization: a non-browser client can use an otherwise valid presigned URL regardless of the allowed origin. See [R2 CORS configuration](https://developers.cloudflare.com/r2/buckets/cors/). ### 4. Treat lifecycle deletion as housekeeping The default lifecycle rule expires incomplete multipart uploads seven days after initiation; it does not expire completed objects. Add explicit object-expiration rules when appropriate. Expiration is asynchronous: objects are typically removed within roughly 24 hours of their expiration time, and changes can take longer to affect existing objects. Lifecycle deletion is not immediate credential revocation. Close an exposed route or revoke the relevant token when access must stop. See [object lifecycles](https://developers.cloudflare.com/r2/buckets/object-lifecycles/) and [API token lifecycle](https://developers.cloudflare.com/r2/api/tokens/). ## Google Cloud Storage ### 1. Disable ACL authorization and enforce public access prevention Enable uniform bucket-level access so ACLs are disabled and only IAM grants access; after 90 consecutive days it cannot be turned off. Migrate ACL-only grants before enabling it. The `gcloud storage buckets create` CLI flag defaults to `False`, so private-by-default does not mean uniform access is already enabled. See [uniform access](https://docs.cloud.google.com/storage/docs/uniform-bucket-level-access) and [the CLI creation default](https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/create). Enforce public access prevention on the bucket, and enforce the organization-policy constraint `constraints/storage.publicAccessPrevention` at the appropriate organization, folder, or project. With prevention active, attempts to grant `allUsers` or `allAuthenticatedUsers` fail with `412 Precondition Failed`, and anonymous requests to data get `401` or `403`. A bucket shows `enforced` or `inherited`; the API default is `inherited`, which protects the bucket only when an effective enforcing organization policy applies. See [public access prevention](https://docs.cloud.google.com/storage/docs/public-access-prevention), [the bucket API defaults](https://docs.cloud.google.com/storage/docs/json_api/v1/buckets), and [organization-policy setup](https://docs.cloud.google.com/storage/docs/using-public-access-prevention). ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BUCKET' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not configuring"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not configuring"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the bucket; not configuring"; exit 2 ;; *) gcloud storage buckets update "gs://$1" \ --uniform-bucket-level-access --public-access-prevention || exit gcloud storage buckets describe "gs://$1" ;; esac ) ``` These are boolean CLI flags. The description should show uniform access enabled and public access prevention enforced; the corresponding API fields are `iamConfiguration.uniformBucketLevelAccess.enabled=true` and `iamConfiguration.publicAccessPrevention="enforced"`. See [bucket update](https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/update), [bucket description](https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/describe), and [uniform-access setup](https://docs.cloud.google.com/storage/docs/using-uniform-bucket-level-access). ### 2. Limit IAM grants by object prefix and time Bucket IAM conditions require uniform bucket-level access. Use `resource.name.startsWith` for an object prefix and `request.time` for a grant deadline. An example conditional read binding is: ```json { "version": 3, "bindings": [ { "role": "roles/storage.objectViewer", "members": [ "serviceAccount:REPLACE_WITH_SERVICE_ACCOUNT_EMAIL" ], "condition": { "title": "ReadUploadsUntilExpiry", "expression": "resource.name.startsWith('projects/_/buckets/REPLACE_WITH_BUCKET/objects/uploads/') && request.time < timestamp('REPLACE_WITH_UTC_EXPIRY')" } } ], "etag": "REPLACE_WITH_CURRENT_POLICY_ETAG" } ``` Replace the expiry with an RFC 3339 UTC timestamp. Fetch the current policy with requested policy version 3, preserve its `etag` and required bindings, and merge the conditional binding into it. Write policy version 3; do not replace an existing policy blindly with this example. See [conditional policy management](https://docs.cloud.google.com/storage/docs/access-control/using-iam-permissions). A prefix condition cannot restrict `storage.objects.list` to matching objects: listing is authorized against the bucket. The object-prefix condition above does not grant a filtered listing capability. Avoid separately granting unrestricted listing if names must remain private, and inspect project-level grants that can independently authorize access. See [IAM conditions and their limitations](https://docs.cloud.google.com/storage/docs/access-control/iam). ### 3. Use an authorized signer and a short duration Signed URLs (V4) expire after at most 604800 seconds (7 days). `gcloud storage sign-url --duration=1h` signs with a service account, through `--impersonate-service-account`, an activated service account, or a key file, rather than ordinary `gcloud auth login` user credentials alone. The default duration is one hour. System-managed signing permits at most 12 hours because its signing key might not remain valid longer. Seven-day signing works with either `--private-key-file` or an account authorized through `gcloud auth activate-service-account`. See [the sign-url reference](https://docs.cloud.google.com/sdk/gcloud/reference/storage/sign-url). Grant the signing service account the required object permission and grant the caller only the signing or impersonation permissions it needs. Public access prevention does not apply to signed URLs, so keep their durations short. Do not create a long-lived service-account key merely to obtain a longer link lifetime. See [signing prerequisites](https://docs.cloud.google.com/storage/docs/access-control/signing-urls-with-helpers) and [signed URLs](https://docs.cloud.google.com/storage/docs/access-control/signed-urls). ### 4. Set transport policy without inventing a bucket HTTPS switch Use HTTPS for application requests and signed URLs. Where organization policy is available, configure `gcp.restrictTLSVersion` with denied values `TLS_VERSION_1` and `TLS_VERSION_1_1` to reject those older TLS versions for covered requests. This guide has no source-verified bucket setting equivalent to S3's `aws:SecureTransport` Deny for rejecting plaintext HTTP. A minimum TLS-version policy is not proof that plaintext HTTP is blocked. The documented TLS constraint also does not apply to public Cloud Storage objects served from the Google Front End cache. See [TLS-version restrictions and their scope](https://docs.cloud.google.com/docs/security/compliance/restrict-tls-versions). ### 5. Add CMEK only when you need customer-controlled keys Google-managed encryption is the default. For CMEK, select a Cloud KMS key in a compatible location and first grant the bucket project's Cloud Storage service agent `roles/cloudkms.cryptoKeyEncrypterDecrypter` on that key. Then set the bucket default through the Encryption configuration or the `gcloud storage buckets update` flag `--default-encryption-key=projects/REPLACE_WITH_PROJECT/locations/REPLACE_WITH_LOCATION/keyRings/REPLACE_WITH_KEY_RING/cryptoKeys/REPLACE_WITH_KEY`. See [standard encryption](https://docs.cloud.google.com/storage/docs/encryption/default-keys) and [CMEK setup](https://docs.cloud.google.com/storage/docs/encryption/using-customer-managed-keys). The default key applies to subsequent writes that do not select another encryption method. It does not re-encrypt existing objects; those require a rewrite. Encryption does not repair public permissions: authorized reads are decrypted by the service. See [CMEK behavior and key replacement](https://docs.cloud.google.com/storage/docs/encryption/customer-managed-keys). ## Azure Blob Storage ### 1. Deny anonymous access and migrate away from Shared Key Anonymous access is prohibited by default for Resource Manager storage accounts. Keep the account property `allowBlobPublicAccess` at `false` ("Allow Blob anonymous access: Disabled" under Settings > Configuration); it overrides any container set to Container or Blob access, so a per-container mistake cannot open data. Containers are private by default. The documented default interpretation of `allowBlobPublicAccess` is false; inspect existing accounts explicitly. See [anonymous-access remediation](https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent) and [the property reference](https://learn.microsoft.com/en-us/javascript/api/%40azure/arm-storage/storageaccountpropertiesupdateparameters?view=azure-node-latest). Before setting `allowSharedKeyAccess=false`, grant the application a Microsoft Entra data role at the narrowest useful scope, such as Storage Blob Data Reader on its container, and demonstrate an authorized read using that identity. A management-plane role alone is not a substitute for a Blob data role. See [assigning Blob data roles](https://learn.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access). An unset or null `allowSharedKeyAccess` permits Shared Key authorization, just like `true`. Setting it to `false` rejects account-key-authorized requests, including service SAS and account SAS, while permitting properly authorized user delegation SAS. It does not prevent someone holding an account key from computing a SAS; it prevents using that SAS for authorization. See [preventing Shared Key authorization](https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent). Azure Policy with the `Microsoft.Storage/storageAccounts/allowBlobPublicAccess` field audits or denies accounts that allow anonymous access. See [governing anonymous access](https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent). ### 2. Require HTTPS and restrict the network route Set `supportsHttpsTrafficOnly=true` in the storage-account resource and `minimumTlsVersion="TLS1_2"`. The CLI uses `--https-only true` and reports `enableHttpsTrafficOnly`; these are representations of the secure-transfer control. HTTPS-only rejects HTTP requests independently of authorization. See [secure transfer](https://learn.microsoft.com/en-us/azure/storage/common/storage-require-secure-transfer) and [storage-account resource properties](https://learn.microsoft.com/en-us/azure/templates/microsoft.storage/storageaccounts). Azure documents TLS 1.2 as the service minimum from February 3, 2026. Keep the explicit TLS 1.2 setting and check clients rather than relying on an old unset-property interpretation. See [the TLS migration notice](https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-migrate-to-tls2) and [minimum TLS configuration](https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-minimum-version). After granting and testing the Entra data role, apply the authorization and transport baseline: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_STORAGE_ACCOUNT' 'REPLACE_WITH_RESOURCE_GROUP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not configuring"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not configuring"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the storage account; not configuring"; exit 2 ;; *) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the resource group; not configuring"; exit 2 ;; *) az storage account update --name "$1" --resource-group "$2" \ --allow-blob-public-access false --allow-shared-key-access false \ --https-only true --min-tls-version TLS1_2 || exit az storage account show --name "$1" --resource-group "$2" \ --query allowBlobPublicAccess --output tsv || exit az storage account show --name "$1" --resource-group "$2" \ --query '{sharedKey:allowSharedKeyAccess,httpsOnly:enableHttpsTrafficOnly,tls:minimumTlsVersion,publicNetwork:publicNetworkAccess,networkRules:networkRuleSet,sasPolicy:sasPolicy}' ;; esac ;; esac ) ``` The first readback must be `false`; the remaining authorization and transport values must show Shared Key disabled, HTTPS required, and TLS 1.2. See [the account CLI reference](https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest). Configure network access separately. Set `networkAcls.defaultAction=Deny` with only the required network rules, or set `publicNetworkAccess=Disabled` for a private-endpoint deployment. The corresponding account-update CLI options are `--default-action Deny` and `--public-network-access Disabled`. Review firewall exceptions as well as the default action. These restrictions can also deny legitimate external SAS consumers. See [storage network access](https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security). For private access, create and approve a private endpoint for the `blob` subresource and configure private DNS, normally `privatelink.blob.core.windows.net`, for the client's network. Continue using the normal account Blob hostname; DNS should resolve it to the private endpoint inside that network. A private endpoint alone leaves the public route available, so also restrict or disable public network access. See [private endpoints and DNS](https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints). ### 3. Use user delegation SAS and enforce the intended lifetime Prefer a user delegation SAS secured by Microsoft Entra credentials over service or account SAS signed with the account key. Use HTTPS only, grant the least permission, such as read-only access to one blob, and use near-term expiry. See [the SAS overview](https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview). The CLI operation is `az storage blob generate-sas` with `--auth-mode login --as-user`. Supply the intended `--account-name`, `--container-name`, and `--name`, use `--permissions r --https-only`, and provide explicit UTC `--start` and `--expiry` values. A user delegation SAS cannot exceed seven days or outlive its delegation key. See [user delegation SAS creation](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-user-delegation-sas-create-cli) and [blob CLI parameters](https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest). The signer needs data permissions on the target and the `Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey/action` permission at account scope or above (the built-in Storage Blob Delegator role grants it). For a container-scoped reader, use a separate account-scoped Storage Blob Delegator assignment when needed rather than broadening all its data access. See [delegation-key permissions](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas) and [the Get User Delegation Key permission reference](https://learn.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key#permissions). Configure the account's SAS expiration policy with a short upper interval and expiration action `Block`. The default action, `Log`, permits out-of-policy use and records it only when Azure Monitor diagnostics are configured; it does not enforce the interval. `Block` denies supported requests with an excessive validity interval or missing signed start. The CLI options are `--sas-exp` and `--sas-expiration-action Block`. Accounts with missing access-key creation times can require key rotation before policy configuration succeeds. See [SAS expiration policies](https://learn.microsoft.com/en-us/azure/storage/common/sas-expiration-policy). The expiration action is not supported for service SAS associated with a stored access policy or user delegation SAS through the HDFS endpoint. It does apply to supported Blob user delegation SAS requests. The delegation key's own lifetime is separate, and a longer account policy cannot extend the seven-day user delegation limit. See [policy exceptions](https://learn.microsoft.com/en-us/azure/storage/common/sas-expiration-policy). Plan revocation by SAS type: - For a retained service-SAS integration, a container stored access policy can revoke associated SAS by removing the policy, changing its identifier, or expiring it. Preserve unrelated policies when updating the container ACL. Account SAS and user delegation SAS do not use that mechanism. See [stored access policies](https://learn.microsoft.com/en-us/rest/api/storageservices/define-stored-access-policy) and [SAS types](https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview). - For user delegation SAS, `az storage account revoke-delegation-keys` revokes all delegation keys for the account, affecting every dependent SAS. Revocation and RBAC changes can be delayed by caching; test the saved URL again rather than declaring immediate success. See [the revoke command](https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest) and [user delegation revocation](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas). - For account-key SAS without a stored policy, plan signing-key rotation and its wider application impact. Keeping `allowSharedKeyAccess=false` rejects account-key-authorized requests regardless of whether a SAS can still be generated. See [Shared Key prevention](https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent). ### 4. Apply retention deliberately Container immutability provides write-once, read-many retention. A newly created time-based retention policy starts unlocked, allowing testing and adjustment. Locking is irreversible: the locked policy cannot be deleted or shortened, although permitted extensions remain possible. Test with disposable data before locking a production policy. Retention does not prevent authorized or anonymous reads and therefore follows the exposure fixes above. See [immutable storage](https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-storage-overview) and [container retention configuration](https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-policy-configure-container-scope). ## Supabase Storage ### 1. Keep sensitive buckets private Buckets are private by default; a public bucket means anyone with the URL can read the file, so use one only for assets that are meant to be public. Make the intent explicit when creating a bucket: ```javascript const { data, error } = await supabase.storage.createBucket( 'REPLACE_WITH_PRIVATE_BUCKET', { public: false } ); if (error) throw error; ``` `false` is the private default. Public buckets bypass access controls for retrieving and serving files; uploads, deletes, moves, and copies still undergo access checks. A restrictive SELECT policy cannot make a public asset URL private. See [bucket access models](https://supabase.com/docs/guides/storage/buckets/fundamentals) and [createBucket](https://supabase.com/docs/reference/javascript/file-buckets-createbucket). ### 2. Scope every operation policy to the bucket and caller Access to a private bucket is governed by row level security policies on `storage.objects`, and without policies Storage allows no uploads at all. Application-table RLS does not secure Storage. Write policies per operation and scope them to the intended bucket and owner. The existing ownership example needs the bucket predicate: ```sql create policy "Individual user Access" on storage.objects for select to authenticated using ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (select auth.jwt()->>'sub') = owner_id ); ``` If this policy already exists, amend its predicate rather than adding another permissive policy. Audit other policies that could independently allow cross-bucket access. `owner_id` records the creating user's JWT subject; ownership alone does not enforce authorization. Objects created through a service credential or the dashboard might not have the end-user ownership this policy expects. See [Storage ownership](https://supabase.com/docs/guides/storage/security/ownership) and [RLS](https://supabase.com/docs/guides/database/postgres/row-level-security). SELECT is not upload permission. For objects named under the authenticated user's UUID, add a separate INSERT policy: ```sql create policy "Upload own files" on storage.objects for insert to authenticated with check ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (storage.foldername(name))[1] = (select auth.uid())::text and (select auth.jwt()->>'sub') = owner_id ); ``` Use an authenticated user's Storage request for this ownership model. The path condition matches the companion [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md) guide; the owner predicate also keeps the original ownership-based read model consistent. See [Storage access control](https://supabase.com/docs/guides/storage/security/access-control) and [path helpers](https://supabase.com/docs/guides/storage/schema/helper-functions). Only if overwrites and deletion are intended, add their separate policies: ```sql create policy "Update own files" on storage.objects for update to authenticated using ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (select auth.jwt()->>'sub') = owner_id ) with check ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (storage.foldername(name))[1] = (select auth.uid())::text and (select auth.jwt()->>'sub') = owner_id ); create policy "Delete own files" on storage.objects for delete to authenticated using ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (select auth.jwt()->>'sub') = owner_id ); ``` Upsert additionally needs SELECT and UPDATE. UPDATE checks both the existing row through `USING` and its proposed state through `WITH CHECK`; keep the bucket boundary in both. Storage operations can require more than one SQL permission: moving needs SELECT and UPDATE, copying needs SELECT and INSERT, and deletion needs SELECT and DELETE. See [upload and upsert permissions](https://supabase.com/docs/guides/storage/security/access-control), [copy and move permissions](https://supabase.com/docs/guides/storage/management/copy-move-objects#permissions), [update](https://supabase.com/docs/reference/javascript/file-buckets-update), [move](https://supabase.com/docs/reference/javascript/file-buckets-move), [copy](https://supabase.com/docs/reference/javascript/file-buckets-copy), and [remove](https://supabase.com/docs/reference/javascript/file-buckets-remove). ### 3. Issue short signed URLs and protect service credentials Share private objects with `supabase.storage.from('bucket').createSignedUrl('path.pdf', 3600)` from server code after authorizing the caller. The expiry argument is seconds; `3600` is an example, not a default: ```javascript const { data, error } = await supabase.storage .from('REPLACE_WITH_PRIVATE_BUCKET') .createSignedUrl('path.pdf', 3600); if (error) throw error; ``` `getPublicUrl` works for public buckets; constructing a public URL does not authorize a private download. Storage signed URLs use a dedicated internal signing key and stay valid until expiry even if you rotate or revoke Auth keys, disable legacy Auth keys, or migrate Auth signing algorithms. If signed URLs must be revoked, contact Supabase support. See [downloads and signing-key independence](https://supabase.com/docs/guides/storage/serving/downloads) and [createSignedUrl](https://supabase.com/docs/reference/javascript/file-buckets-createsignedurl). The service-role key bypasses RLS and never reaches a browser. Server secret keys also carry privileged access; neither is an end-user credential. A privileged signing endpoint must enforce its own bucket, object, and caller authorization before issuing a URL. Follow [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md) and the privileged-credential guidance in [pocketbase.md](https://github.com/jposluns/secureconfig/blob/main/pocketbase.md). See [Supabase API keys](https://supabase.com/docs/guides/getting-started/api-keys). ## Verify REASONED: Cloud comparison scope; no authorized cloud fixtures. A local Supabase stand-up opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Per the guide's cited sources listed in the next two sentences, exposed anonymous requests return fixture bytes and fixed private routes deny them while authorized controls succeed. The AWS, Cloudflare and Google Cloud sources are S3 Block Public Access (four settings, defaults, meaning of "public", IAM Access Analyzer); Cloudflare R2 public buckets, API tokens, presigned URLs; Google Cloud Storage uniform bucket-level access, public access prevention, signed URLs, `gcloud storage sign-url`. The Azure and Supabase sources are Azure Blob anonymous access remediation and SAS overview; and Supabase Storage buckets, access control, and downloads. Locally, all five Bash blocks passed bash -n and ShellCheck 0.11.0 with no diagnostics; 50 stubbed guard cases printed refusal messages without invoking curl. Those bash -n, ShellCheck and stubbed guard runs covered the five Bash blocks in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2 and whose S3 block exits with status 3 when signing fails or returns an unsafe URL or the authorized control fails. These comparisons require controlled cloud resources and harmless fixture objects. No live cloud comparison below was run during authoring. Demonstrate exposed and fixed behavior on disposable fixtures without exposing production data. A transport failure, missing object, failed signing operation, or absent permission to inspect configuration is inconclusive. ### 1. Check signed-link lifetimes **REASONED:** No authorized signing identities or fixture buckets were supplied. For each provider, issue a short-lived GET link for a known object, require successful access before expiry, then repeat the same saved URL after expiry and require denial. A newly generated URL is not an expiry test. The documented limits distinguish the intended behavior: | Provider | Expiry control | Documented default and limit | | --- | --- | --- | | S3 | `aws s3 presign --expires-in` | REASONED: Signed-link lifetime scope; no authorized provider signing identity or known cloud object is available. Per the guide's cited AWS CLI `s3 presign` (`--expires-in` default and maximum), the same saved URL should read the fixture before expiry and be denied afterward; an exposed public route must be tested separately. Local stub signing recorded --expires-in 60 and the identical saved URL on both signed requests; it did not validate AWS signing or expiry. That stub run covered the S3 Verify block in an earlier form, whose guard refusals ended with status 0; the current form, whose guard refusals exit with status 2 and whose signing-failure and failed-control arms exit with status 3, has not been run. Default 3600 seconds; maximum 604800. Signing credentials can expire sooner. [CLI](https://docs.aws.amazon.com/cli/latest/reference/s3/presign.html). | | R2 | JavaScript signer `expiresIn` | REASONED: Signed-link lifetime scope; no authorized provider signing identity or known cloud object is available. Per the guide's cited Cloudflare R2 public buckets, API tokens, presigned URLs, the same saved URL should read the fixture before expiry and be denied afterward; an exposed public route must be tested separately. The generic GET block in step 3 passed bash -n and ShellCheck 0.11.0 with no diagnostics, and stub execution printed http=200 exit=0 err=; no provider signer ran. The bash -n, ShellCheck and stub runs covered the step 3 block in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2. Set explicitly; supported lifetime 1-604800 seconds. Use the S3 API domain. [Presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/). | | GCS | `gcloud storage sign-url --duration` | REASONED: Signed-link lifetime scope; no authorized provider signing identity or known cloud object is available. Per the guide's cited Google Cloud Storage uniform bucket-level access, public access prevention, signed URLs, `gcloud storage sign-url`, the same saved URL should read the fixture before expiry and be denied afterward; an exposed public route must be tested separately. The generic GET block in step 3 passed bash -n and ShellCheck 0.11.0 with no diagnostics, and stub execution printed http=200 exit=0 err=; no provider signer ran. The bash -n, ShellCheck and stub runs covered the step 3 block in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2. Default 1h; maximum 12h with system-managed signing, or 7d with `--private-key-file` or an activated service account. [CLI](https://docs.cloud.google.com/sdk/gcloud/reference/storage/sign-url). | | Azure | `generate-sas --auth-mode login --as-user`, with explicit start and expiry | REASONED: Signed-link lifetime scope; no authorized provider signing identity or known cloud object is available. Per the guide's cited Azure user delegation SAS CLI, the same saved URL should read the fixture before expiry and be denied afterward; an exposed public route must be tested separately. The generic GET block in step 3 passed bash -n and ShellCheck 0.11.0 with no diagnostics, and stub execution printed http=200 exit=0 err=; no provider signer ran. The bash -n, ShellCheck and stub runs covered the step 3 block in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2. User delegation maximum 7d, bounded by the delegation key and any applicable stricter policy. [Creation](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-user-delegation-sas-create-cli). | | Supabase | `createSignedUrl(path, seconds)` | REASONED: Signed-link lifetime scope; no authorized provider signing identity or known cloud object is available. A local Supabase stand-up opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Per the guide's cited Supabase createBucket and createSignedUrl, the same saved URL should read the fixture before expiry and be denied afterward; an exposed public route must be tested separately. The local JavaScript syntax check passed with no diagnostics; it did not execute createSignedUrl. Supply seconds explicitly; 3600 is an example, not a default. Auth-key rotation does not revoke the link. [Reference](https://supabase.com/docs/reference/javascript/file-buckets-createsignedurl), [revocation caveat](https://supabase.com/docs/guides/storage/serving/downloads). | ### 2. Prove authorized S3 access, anonymous denial, and expiry **REASONED:** No S3 account, signing role, or known object was supplied. On a deliberately exposed disposable fixture, an unsigned GET returns its bytes; after the public-access baseline, that same unsigned GET must be denied while an authorized HTTPS GET succeeds. The saved signed URL must subsequently fail after expiry. See [public-access behavior](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html) and [presigned URL behavior](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). REASONED: S3 known-object and GET control scope; no authorized S3 signing identity or fixture bucket is available. Per the guide's cited S3 presigned URL permissions, expiry, revocation, and signature age, the authorized GET must succeed before anonymous denial can establish the fix. Locally, Bash syntax and ShellCheck 0.11.0 passed; injected responses printed 200/403/403, while a failed first control printed 'authorized control failed; no access-control conclusion' and made no later requests. These are stub observations, not S3 outcomes. The Bash syntax, ShellCheck and stub runs covered the S3 Verify block in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2 and whose signing-failure and failed-control arms exit with status 3. Use the same bucket and object in both arguments, with the correct regional endpoint. First prove the object exists and is readable WITH authorization, so a later anonymous denial is about access, not a missing object. S3 also returns `403` for an object you cannot list. Presigning signs a GET, so test with GET. REASONED: following block; S3 public-access and presigned-URL documentation supply the expected outcomes; no authorized S3 account, signing role or known object was supplied. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set -- PASTE_WHOLE_BLOCK 's3://REPLACE_WITH_BUCKET/model.safetensors' 'https://REPLACE_WITH_BUCKET.s3.REPLACE_WITH_REGION.amazonaws.com/model.safetensors' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the S3 URI; not probing"; exit 2 ;; *) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute the same object's unsigned HTTPS URL; not probing"; exit 2 ;; https://*) # First prove the object exists and is readable WITH authorization. # S3 also returns 403 for an object you cannot list. presign signs a GET. set -- "$2" "$(aws s3 presign "$1" --expires-in 60)" case "$2" in *'"'*) echo "signing failed or returned an unsafe URL; not probing"; exit 3 ;; *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*|*\\*) echo "signing failed or returned an unsafe URL; not probing"; exit 3 ;; https://*) # The URL is a credential. The builtin printf feeds curl on stdin, never argv. # AWS percent-encodes the URL; quotes, backslashes and controls were rejected. set -- "$1" "$2" "$(printf 'url = "%s"\n' "$2" | curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --config -)" printf '%s\n' "$3" case "$3" in "http=200 exit=0 "*) # Then the SAME object's unsigned URL must be denied; expected S3 403. curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" # And the saved signed URL must stop working once it expires. sleep 61 printf 'url = "%s"\n' "$2" | curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --config - ;; *) echo "authorized control failed; no access-control conclusion"; exit 3 ;; esac ;; *) echo "signing did not return HTTPS; not probing"; exit 3 ;; esac ;; *) echo "use the canonical unsigned HTTPS object URL; not probing"; exit 2 ;; esac ;; esac ) ``` REASONED: preceding block; S3 signing, authorized control, anonymous denial, expiry sequence, and local guards. No authorized S3 account or fixture object is available. Per the guide's cited S3 Block Public Access (four settings, defaults, meaning of "public", IAM Access Analyzer) and S3 presigned URL permissions, expiry, revocation, and signature age, exposed unsigned reads return bytes and fixed reads follow the sequence below. Bash syntax and ShellCheck 0.11.0 passed without diagnostics. Injected response sequences printed 200/200/200 and 200/403/403; a failed authorized control stopped later requests. Six unsafe signing-output cases printed refusal messages without invoking curl. These local stubs establish shell control flow only. The Bash syntax, ShellCheck and stub runs covered the preceding block in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2 and whose signing-failure and failed-control arms exit with status 3. Expected sequence: authorized `200`, anonymous `403`, then denial of the expired signed URL. A DNS, TLS, proxy, or local curl error is inconclusive. Client proxies are disabled so a proxy cannot answer for S3. One object is not the whole bucket: repeat for other keys, prefixes, and enabled access paths. The signed URL stays out of curl's process arguments because the shell's builtin `printf` supplies it through stdin. The block generates it by command substitution and it is never typed, so it is not in this block's shell history. This does not protect it from shell tracing, copied terminal output, or the account owner's process inspection. Keep `set -x` off. The curl status fields require curl 7.75.0 or later. ### 3. Check known objects on every other enabled route **REASONED:** No R2, GCS, Azure, or Supabase fixture accounts or URLs were supplied. Use the following GET probe first with an authorized signed URL, then with the same object's unsigned URL. It prints headers and the response body, so use only harmless fixture data. Preserve the successful authorized control and the denial response. For expiry or revocation checks, repeat the saved signed URL. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # A signed URL substituted on the set -- line is a credential and enters shell # history. Use a short-lived signed URL or clear that history line afterward. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_FIXTURE_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute a complete fixture URL without control characters; not probing"; exit 2 ;; https://*) set -- "${1//\\/\\\\}" set -- "${1//\"/\\\"}" printf 'url = "%s"\n' "$1" | curl -q -g -sS -i --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --config - ;; *) echo "use an HTTPS fixture URL; not probing"; exit 2 ;; esac ) ``` REASONED: preceding block; generic HTTPS fixture GET, URL guards and escaping, credential handling, and provider comparisons. No R2, GCS, Azure, or Supabase fixture accounts or URLs are available. A local Supabase stand-up opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Per the guide's cited sources listed in the next sentence, exposed anonymous routes serve bytes and fixed private routes deny them while authorized controls succeed. Those cited sources are Cloudflare R2 public buckets, API tokens, presigned URLs; Google Cloud Storage uniform bucket-level access, public access prevention, signed URLs, `gcloud storage sign-url`; Azure Blob anonymous access remediation and SAS overview; and Supabase Storage buckets, access control, and downloads. Bash syntax and ShellCheck 0.11.0 passed without diagnostics; a stub printed http=200 exit=0 err= and recorded the escaped URL on stdin, absent from argv. HTTP and control-character inputs printed refusal messages without invoking curl. The Bash syntax, ShellCheck and stub runs covered the preceding block in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2. This does not establish remote access control or credential secrecy. A pasted signed URL can enter shell history even though it is absent from curl's arguments, and stdin does not protect it from shell tracing or the account owner's process inspection. Use a shell session configured not to retain credentials, and do not record signed URLs in test reports. | Provider | Required comparison | | --- | --- | | R2 | **REASONED:** No bucket, Access application, or domain fixtures were supplied. GET the same known key through the S3 API signed URL, `r2.dev`, and every custom domain. An exposed route serves the fixture without authorization. Fixed `r2.dev` must not serve it; protected custom domains must deny or challenge an unauthorized caller while the authorized Access or WAF flow succeeds. A login page with status 200 is not the object: inspect bytes and redirects. [Public routes](https://developers.cloudflare.com/r2/buckets/public-buckets/), [Access protection](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/). | | GCS | **REASONED:** No bucket or IAM fixture was supplied. GET the known object's unsigned URL before and after prevention: an exposed public object returns bytes; enforced prevention denies anonymous access with `401` or `403`. Its valid signed URL can still succeed, so signed success does not mean prevention failed. [Prevention behavior](https://docs.cloud.google.com/storage/docs/public-access-prevention). | | Azure | **REASONED:** No storage account or request-version fixtures were supplied. GET a known blob anonymously and with authorization before and after account-level denial. Require anonymous denial and a successful authorized control. The remediation page documents `401` or `409` depending on request version; the configuration page describes `403`. Do not declare one universal code: inspect the error, request/service version, and returned bytes. [Remediation](https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent), [configuration](https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-configure). | | Supabase | **REASONED:** No project or user sessions were supplied. Request the known file through its public asset URL before and after making the fixture bucket private. Exposed returns the file; fixed returns an error, not the file. An authorized private download or deliberately issued signed URL must still succeed. [Bucket access](https://supabase.com/docs/guides/storage/buckets/fundamentals), [downloads](https://supabase.com/docs/guides/storage/serving/downloads). | ### 4. Inspect effective settings, permissions, and independent controls **REASONED:** No cloud inventories or deployed client bundles were supplied. The provider's public-access view must show no unintended public access: IAM Access Analyzer for S3, with an account-level analyzer in every bucket Region, has no unexplained active or archived public findings; and GCS descriptions show prevention `enforced`, or `inherited` with an effective enforcing `storage.publicAccessPrevention` policy. The provider's public-access view must also show no unintended public access for Azure and R2: the Azure Resource Graph inventory of `allowBlobPublicAccess` shows `false` on every account, and R2 has no Public Development URL and no unprotected custom domain. `inherited` alone is not a pass. Compare against deliberately exposed fixtures whose settings and requests reveal the exposure. See [S3 analysis](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-analyzer.html), [GCS policy inspection](https://docs.cloud.google.com/storage/docs/using-public-access-prevention), [Azure inventory](https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent), and [R2 routes](https://developers.cloudflare.com/r2/buckets/public-buckets/). | Control | Required exposed/fixed comparison | | --- | --- | | S3 policy scope and transport | **REASONED:** No IAM roles or controlled HTTP/VPC fixtures were supplied. As the application role, request `GetObject` inside and outside `uploads/`, and `ListObjectsV2` with allowed and unrelated prefixes. The broad-policy fixture permits excess access; the scoped role permits only intended requests. On an isolated harmless fixture, compare authenticated HTTP and HTTPS GETs before and after the transport Deny. Use disposable credentials confined to that fixture, never production credentials over HTTP. If the endpoint Deny is selected, repeat the authorized HTTPS GET inside and outside that endpoint. [IAM policies](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-policies-s3.html), [transport conditions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/amazon-s3-policy-keys.html), [endpoint restrictions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies-vpc-endpoint.html). | | R2 token scope and CORS | **REASONED:** No application token or browser fixture was supplied. Use the token to GET a known object in its permitted bucket and another bucket, and attempt an upload with a read-only token. Broad credentials permit excess operations; scoped credentials deny them. In a browser, request a presigned upload from allowed and unrelated origins with the intended method and headers. Correct CORS permits the intended browser flow; it does not prevent a non-browser caller using a valid signature. [Tokens](https://developers.cloudflare.com/r2/api/tokens/), [CORS](https://developers.cloudflare.com/r2/buckets/cors/). | | GCS conditions and TLS | **REASONED:** No conditional service account or organization-policy fixture was supplied. GET known objects inside and outside the prefix before and after the grant deadline; only the intended object and time window should work after broad grants are removed. Separately attempt object listing; do not accept an assumed prefix-filtered result. Compare a controlled GET to `https://storage.googleapis.com/REPLACE_WITH_BUCKET/REPLACE_WITH_OBJECT` using TLS 1.1 and TLS 1.2 under the documented TLS test procedure. Require the provider's policy denial for the restricted version and a successful supported-version control; a local TLS-library failure proves nothing about the policy. This does not test plaintext HTTP rejection. [Conditions](https://docs.cloud.google.com/storage/docs/access-control/iam), [TLS testing](https://docs.cloud.google.com/docs/security/compliance/restrict-tls-versions). | | Azure authorization and network | **REASONED:** No Entra identity, SAS fixtures, private endpoint, or external host was supplied. GET the same known blob using Entra authorization, user delegation SAS, and account/service SAS before and after Shared Key denial. The first two remain usable with sufficient permissions; account-key authorization is rejected. Check account readbacks, then repeat an authorized HTTPS GET inside and outside the selected network boundary. The private client must resolve the normal Blob hostname to the private endpoint and succeed; an excluded public client must be denied. A private endpoint without public-route restriction is the exposed comparison. [Shared Key behavior](https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent), [network access](https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security), [private DNS](https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints). | | Supabase operation and bucket scope | **REASONED:** No project with two buckets and two user sessions was supplied. As each user, call `download`, `upload`, `update`, `move`, `copy`, and `remove` on disposable objects owned by each user in both buckets. The original owner-only policy admits owned rows across buckets; corrected policies admit only the intended bucket and operations. Confirm that SELECT alone does not authorize upload, and that optional UPDATE/DELETE policies are absent when those operations are unwanted. Run a separate privileged-server control; its RLS bypass is not evidence that end-user policies work. [Access control](https://supabase.com/docs/guides/storage/security/access-control), [ownership](https://supabase.com/docs/guides/storage/security/ownership), [API keys](https://supabase.com/docs/guides/getting-started/api-keys). | | Revocation and Azure SAS policy | **REASONED:** No disposable signing credentials or diagnostic workspace was supplied. Save a working fixture URL, revoke its applicable signer permission/key or Azure stored policy/delegation key, and repeat the same GET after documented propagation. For Azure, use a still-unexpired SAS whose interval exceeds the configured limit: `Log` permits it and diagnostics record out-of-policy use; `Block` denies supported requests. Include the documented HDFS and stored-policy exceptions. For Supabase, Auth-key rotation must not be reported as Storage URL revocation; compare expiry or support-assisted revocation instead. [S3 URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html), [Azure policy](https://learn.microsoft.com/en-us/azure/storage/common/sas-expiration-policy), [delegation revocation](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas), [Supabase signing](https://supabase.com/docs/guides/storage/serving/downloads). | | Encryption, retention, and audit | **REASONED:** No KMS keys, retention fixtures, or logging destinations were supplied. Inspect a new object's encryption metadata after selecting the intended default; compare an existing object's unchanged key before a deliberate rewrite. With S3 versioning enabled, overwrite a harmless fixture and retrieve its prior version. Compare permanent deletion of an unlocked version with a retained version, including authorized governance bypass versus compliance denial. For MFA Delete, attempt a permanent version deletion and a versioning-state change with no MFA token and require denial, then repeat with a valid root MFA token (root account, CLI or API only) and require success. For the `s3:signatureAge` limit, present a signed URL whose signature is older than the configured age but still within its own expiry window and require the policy to deny it, distinguishing signature-age denial from ordinary URL expiry, while a freshly signed URL to the same object succeeds. For Azure, on an unlocked disposable time-based retention policy, shorten it using its current ETag and require success (unlocked policies remain editable); then lock that policy, confirm the `Locked` state, and require that both shortening and deleting it are refused by immutability. Use current ETags so a stale-precondition failure cannot masquerade as enforcement. Issue a known S3 object GET/PUT and locate the data-event and access-log records after delivery. Inspect R2 lifecycle rules and observe a disposable expired object's eventual deletion; do not count the lifecycle deadline as immediate revocation. [S3 encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-encryption.html), [GCS CMEK](https://docs.cloud.google.com/storage/docs/encryption/customer-managed-keys), [Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html), [MFA Delete](https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html), [signature age](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html), [Azure retention](https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-policy-configure-container-scope), [CloudTrail](https://docs.aws.amazon.com/AmazonS3/latest/userguide/cloudtrail-logging-s3-info.html), [access logging](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html), [R2 lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). | **REASONED:** No actual application credential inventory, repository deployment secrets, or client bundle was supplied. Application credentials must be scoped to one bucket or prefix, and no root, account-key, or service-role credential may appear in client code or the repository. Inspect the deployed bundle and its source maps, and compare with a disposable positive-control build containing a known dummy credential so the inspection can detect the condition it claims to exclude. See [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md), [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), and [Supabase credential privileges](https://supabase.com/docs/guides/getting-started/api-keys). ### Verification scope and local observations REASONED: Local observation provenance; the original authoring evidence is unavailable for authentication, so the following report of the original authoring runs is retained unauthenticated. This marking run independently passed bash -n and ShellCheck 0.11.0 on five blocks with no diagnostics, parsed seven JSON examples, passed two JavaScript syntax checks with no diagnostics, and observed 50 stubbed guard refusals without invoking curl. These runs establish local syntax and selected guards, not the provider comparisons. This marking run covered the five Bash blocks in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2 and whose S3 block exits with status 3 when signing fails or returns an unsafe URL or the authorized control fails. The provider behavior rests on the following Sources entries together, listed across this sentence and the next: S3 Block Public Access (four settings, defaults, meaning of "public", IAM Access Analyzer); S3 presigned URL permissions, expiry, revocation, and signature age; Cloudflare R2 public buckets, API tokens, presigned URLs. The Sources entries continuing that list are Google Cloud Storage uniform bucket-level access, public access prevention, signed URLs, `gcloud storage sign-url`; Azure Blob anonymous access remediation and SAS overview; Supabase Storage buckets, access control, and downloads; Supabase createBucket; and createSignedUrl. Historical report: All five bash blocks passed ShellCheck 0.11.0 and `bash -n` during authoring. Local guard tests refused embedded `REPLACE_WITH_` placeholders, `example.com`, angle brackets, empty arguments, and omitted or shortened `set --` lines in 50 cases. The repository's guard-convention scanner reported no findings on these blocks. All seven JSON examples parsed, and the two JavaScript examples passed syntax checking. The historical bash -n, ShellCheck and guard runs also covered the five Bash blocks in an earlier form, whose guard refusals ended with status 0. REASONED: Historical evidence boundary; no original authoring receipts, authorized cloud fixtures, or deployed bundle are available. A local Supabase stand-up opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. The preceding local reruns do not establish SQL policy enforcement or cloud outcomes. Per the guide's cited Supabase Storage buckets, access control, and downloads, exposed public retrieval serves bytes while fixed private retrieval requires authorization; this remains unobserved here. The following statement describes the original authoring report, not subsequent gate runs: These are local syntax and guard results only. SQL policies were source-traced but not executed against Supabase. No live cloud behavior, deployed-bundle inspection, or whole-corpus gate result is claimed. | Check scope | Deployment comparison and prerequisites | Status | | --- | --- | --- | | Cloud and application checks | REASONED: Full cloud and application comparison inventory; the accounts, identities, objects, policies, networks, keys, logs, sessions, and client build listed here are unavailable. A local Supabase stand-up opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Per the guide's cited S3 presigned URL permissions, expiry, revocation, and signature age; Cloudflare R2 public buckets, API tokens, presigned URLs; GCS IAM conditions and listing limitations; Azure network restrictions; and Supabase Storage buckets, access control, and downloads, these comparisons require the exposed/fixed outcomes specified above, including successful authorized controls. Local Bash and JavaScript syntax checks passed without diagnostics, seven JSON examples parsed, and 50 stubbed guard cases refused inputs without invoking curl; provider enforcement remains unobserved. The Bash syntax checks and stubbed guard cases covered the five Bash blocks in an earlier form, whose guard refusals ended with status 0; none was re-run on the current form, whose guard refusals exit with status 2 and whose S3 block exits with status 3 when signing fails or returns an unsafe URL or the authorized control fails. Demonstrate every REASONED comparison above with controlled S3, R2, GCS, Azure, and Supabase accounts; harmless objects; narrowly scoped application and signing identities; organization-policy access; TLS and private-network fixtures; KMS keys; retention and logging fixtures; Azure diagnostics; two Supabase users and buckets; and the deployed client build. Record effective settings, CLI/SDK versions, operations, identities, response/error codes and request versions, object-byte controls, expiry/revocation timing, logs, and disposable-data cleanup. Include S3 account and bucket readbacks, ACL migration, transport and endpoint denials, signature-age policy, versioning/Object Lock/MFA Delete behavior, Azure SAS-policy exceptions and delegation revocation, GCS conditional IAM and signing modes, every R2 route and CORS flow, and Supabase cross-bucket and per-operation denials. Never retain usable signed URLs or credentials in the record. | REASONED from the cited vendor documentation; live behavior is not demonstrated. | ## Common mistakes - Making a bucket public to fix a broken download link, when the fix was a signed URL. - A presigned URL or SAS with a multi-day expiry pasted into a chat or ticket; it is a credential until it expires. - Treating Block Public Access, encryption, HTTPS, a private endpoint, and retention as interchangeable controls. - Assuming a new-bucket default proves the state of an existing bucket or account. - Removing public grants only from the visible policy while leaving another route, ACL, inherited grant, or permissive RLS policy effective. - Protecting an R2 custom domain while leaving `r2.dev` enabled. - Calling GCS `inherited` public access prevention a pass without inspecting the effective organization policy. - Expecting a GCS object-prefix condition to filter bucket listing. - Disabling Azure Shared Key before granting and testing the replacement Entra data role. - Treating Azure SAS `Log` as enforcement, or assuming Shared Key denial prevents offline SAS generation. - Using an error for a nonexistent object as proof that anonymous access is blocked. - Rotating Supabase Auth keys and assuming existing Storage signed URLs were revoked. - Enabling irreversible retention before testing recovery, deletion, and operational requirements. ## Sources (checked September 2026) - S3 Block Public Access (four settings, defaults, meaning of "public", IAM Access Analyzer): https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html and https://docs.aws.amazon.com/AmazonS3/latest/userguide/configuring-block-public-access-bucket.html - S3 Object Ownership (Bucket owner enforced default, ACLs disabled): https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html - AWS CLI `s3 presign` (`--expires-in` default and maximum): https://docs.aws.amazon.com/cli/latest/reference/s3/presign.html - Cloudflare R2 public buckets, API tokens, presigned URLs: https://developers.cloudflare.com/r2/buckets/public-buckets/ , https://developers.cloudflare.com/r2/api/tokens/ , https://developers.cloudflare.com/r2/api/s3/presigned-urls/ - Google Cloud Storage uniform bucket-level access, public access prevention, signed URLs, `gcloud storage sign-url`. Google Cloud documentation sources: https://docs.cloud.google.com/storage/docs/uniform-bucket-level-access , https://docs.cloud.google.com/storage/docs/using-uniform-bucket-level-access , https://docs.cloud.google.com/storage/docs/public-access-prevention , https://docs.cloud.google.com/storage/docs/access-control/signed-urls , https://docs.cloud.google.com/sdk/gcloud/reference/storage/sign-url - Azure Blob anonymous access remediation and SAS overview: https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent and https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview - Supabase Storage buckets, access control, and downloads: https://supabase.com/docs/guides/storage/buckets/fundamentals , https://supabase.com/docs/guides/storage/security/access-control , https://supabase.com/docs/guides/storage/serving/downloads - [S3 general purpose bucket defaults](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingBucket.html). - [Account Block Public Access update](https://docs.aws.amazon.com/cli/latest/reference/s3control/put-public-access-block.html) and [readback](https://docs.aws.amazon.com/cli/latest/reference/s3control/get-public-access-block.html). - [Bucket Block Public Access update](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-public-access-block.html), [readback](https://docs.aws.amazon.com/cli/latest/reference/s3api/get-public-access-block.html), and [policy status](https://docs.aws.amazon.com/cli/latest/reference/s3api/get-bucket-policy-status.html). - [Ownership update](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-ownership-controls.html) and [readback](https://docs.aws.amazon.com/cli/latest/reference/s3api/get-bucket-ownership-controls.html). - [IAM Access Analyzer for S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-analyzer.html). - [S3 application IAM policies](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-policies-s3.html). - [S3 transport, service-principal exceptions, and prefix conditions](https://docs.aws.amazon.com/AmazonS3/latest/userguide/amazon-s3-policy-keys.html). - [S3 VPC endpoint policies](https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies-vpc-endpoint.html). - [S3 presigned URL permissions, expiry, revocation, and signature age](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). - [S3 default encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-encryption.html), [encryption CLI](https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-encryption.html), [encryption-rule defaults](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ServerSideEncryptionRule.html), and [Bucket Keys](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html). - [S3 Versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html), [Object Lock modes](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html), [irreversible Object Lock configuration](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-configure.html), and [MFA Delete](https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html). - [S3 server access logging](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerLogs.html), [destination policy and setup](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html), and [CloudTrail S3 data events](https://docs.aws.amazon.com/AmazonS3/latest/userguide/cloudtrail-logging-s3-info.html). - [R2 with Cloudflare Access](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/), [CORS](https://developers.cloudflare.com/r2/buckets/cors/), and [object lifecycles](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). - [GCS CLI creation defaults](https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/create), [bucket update](https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/update), [bucket description](https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/describe), and [bucket API fields](https://docs.cloud.google.com/storage/docs/json_api/v1/buckets). - [GCS public access prevention and organization-policy setup](https://docs.cloud.google.com/storage/docs/using-public-access-prevention). - [GCS IAM conditions and listing limitations](https://docs.cloud.google.com/storage/docs/access-control/iam) and [version-3 policy management](https://docs.cloud.google.com/storage/docs/access-control/using-iam-permissions). - [GCS signed-URL authorization prerequisites](https://docs.cloud.google.com/storage/docs/access-control/signing-urls-with-helpers). - [Google Cloud TLS-version organization policy and limitations](https://docs.cloud.google.com/docs/security/compliance/restrict-tls-versions). - [GCS standard encryption](https://docs.cloud.google.com/storage/docs/encryption/default-keys), [CMEK behavior](https://docs.cloud.google.com/storage/docs/encryption/customer-managed-keys), and [service-agent and default-key setup](https://docs.cloud.google.com/storage/docs/encryption/using-customer-managed-keys). - [Azure anonymous-access configuration and response-code caveat](https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-configure). - [Azure account property defaults](https://learn.microsoft.com/en-us/javascript/api/%40azure/arm-storage/storageaccountpropertiesupdateparameters?view=azure-node-latest) and [storage-account resource properties](https://learn.microsoft.com/en-us/azure/templates/microsoft.storage/storageaccounts). - [Azure Shared Key prevention](https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent) and [scoped Blob data roles](https://learn.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access). - [Azure secure transfer](https://learn.microsoft.com/en-us/azure/storage/common/storage-require-secure-transfer), [minimum TLS configuration](https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-minimum-version), and [February 2026 TLS migration](https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-migrate-to-tls2). - [Azure network restrictions](https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security) and [private endpoints and DNS](https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints). - [Azure account CLI, SAS-policy flags, and delegation-key revocation](https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest) and [Blob CLI SAS parameters](https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest). - [Azure user delegation SAS CLI](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-user-delegation-sas-create-cli), [permissions and revocation semantics](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas), [SAS expiration policy](https://learn.microsoft.com/en-us/azure/storage/common/sas-expiration-policy), and [stored access policy revocation](https://learn.microsoft.com/en-us/rest/api/storageservices/define-stored-access-policy). - [Azure immutable storage](https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-storage-overview) and [container retention configuration](https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-policy-configure-container-scope). - [Supabase createBucket](https://supabase.com/docs/reference/javascript/file-buckets-createbucket) and [createSignedUrl](https://supabase.com/docs/reference/javascript/file-buckets-createsignedurl). - [Supabase ownership](https://supabase.com/docs/guides/storage/security/ownership), [Storage path helpers](https://supabase.com/docs/guides/storage/schema/helper-functions), [RLS policy behavior](https://supabase.com/docs/guides/database/postgres/row-level-security), and [privileged API keys](https://supabase.com/docs/guides/getting-started/api-keys). - [Supabase update](https://supabase.com/docs/reference/javascript/file-buckets-update), [move](https://supabase.com/docs/reference/javascript/file-buckets-move), [copy](https://supabase.com/docs/reference/javascript/file-buckets-copy), and [remove](https://supabase.com/docs/reference/javascript/file-buckets-remove). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - s3: Amazon S3 documentation (unknown) - r2: Cloudflare R2 documentation (unknown) - gcs: Google Cloud Storage documentation (unknown) - azure: Azure Blob documentation (unknown) - supabase: Supabase Storage documentation (unknown) Claims (full source mapping in the JSON block below): - s3-default (REASONED; s3): New general purpose buckets/objects are private, Bucket owner enforced disables ACLs and all four bucket Block Public Access flags default enabled; inspect existing/account/organization settings. - s3-public-block (REASONED; s3): Enable all four public-access flags at account and bucket; most restrictive access-point/bucket/account/organization combination applies, but stored grants survive and can reactivate. - s3-acl-migration (REASONED; s3): Migrate ACL grants/clients before BucketOwnerEnforced; no-ACL and bucket-owner-full-control uploads work, unsupported ACLs fail and stored ACLs are not erased. - s3-readback (REASONED; s3): Read back account/bucket flags, ownership and attached policy IsPublic=false; missing policy differs from denied or failed inspection. - s3-public-meaning (REASONED; s3): Wildcard-principal Allow is public without a qualifying fixed restriction; broad Deny is not a public grant. - s3-analyzer (REASONED; s3): S3 external-access view needs account-level analyzers in each bucket Region; findings are asynchronous and archived findings need review, so an empty view proves no privacy. - s3-app (REASONED; s3): Scope GetObject/PutObject to uploads/* and ListBucket through prefix conditions; omit unused write/list permissions and inspect additive effective grants. - s3-transport (REASONED; s3): SecureTransport=false Deny covers bucket/objects; PrincipalIsAWSService=false avoids redacted-context service breakage without granting access. - s3-endpoint (REASONED; s3): Optional SourceVpce Deny restricts all consumers to the chosen endpoint, including signed URLs, and can block console/integrations; establish recovery first. - s3-presign (REASONED; s3): CLI GET presigning defaults 3600 seconds, maximum 604800; signer needs object permission and temporary credentials can expire sooner; use short explicit expiry. - s3-signature-age (REASONED; s3): signatureAge above 600000 ms can deny a still-unexpired URL; credential revocation or effective policy denial stops access, without Azure-style stored access policies. - s3-encryption (REASONED; s3): New writes receive SSE-S3 unless another supported mode applies; optional SSE-KMS needs key policy/permissions and default changes do not rewrite historical objects. - s3-bucket-key (REASONED; s3): BucketKeyEnabled defaults disabled for general purpose buckets; enabling reduces KMS request costs and does not restrict public access. - s3-versioning (REASONED; s3): Versioning defaults disabled; once enabled it can be suspended but cannot return to unversioned state. - s3-lock (REASONED; s3): Object Lock requires versioning and cannot be disabled; versioning cannot then be suspended. Locks protect versions, not reads, new versions or delete markers. - s3-retention (REASONED; s3): GOVERNANCE permits explicit authorized bypass; COMPLIANCE cannot be shortened/bypassed by ordinary admin or root; test before irreversible retention. - s3-mfa-delete (REASONED; s3): Only bucket-owner root enables MFA Delete through CLI/API; it gates permanent version deletion and versioning changes, not reads, and conflicts with lifecycle configurations. - s3-audit (REASONED; s3): Server access logging and CloudTrail object data events default off; management history is not an object log. Enable independent log destinations and selected data events. - s3-log-delivery (REASONED; s3): With ACLs disabled grant logging service PutObject on a constrained prefix; destination must share account/Region, lack Object Lock and avoid recursive logging; delivery is delayed. - r2-routes (REASONED; r2): R2 defaults private; disable development r2.dev and review every custom/application route independently. Access/WAF on a custom domain does not protect an enabled r2.dev bypass. - r2-tokens (REASONED; r2): Use bucket-scoped Object Read only/Read & Write tokens; admin permissions are broader. Account/User ownership is separate from resource scope and secret key is shown once. - r2-presign (REASONED; r2): SigV4 URLs support 1-604800 seconds with explicit expiresIn and work only on the S3 API domain, not custom domains. - r2-cors (REASONED; r2): Dashboard CORS specifies exact origins, methods and headers; Wrangler format differs and CORS cannot stop a non-browser with a valid signature. - r2-lifecycle (REASONED; r2): Default rule expires incomplete multipart uploads after seven days, not complete objects; object expiry is asynchronous, typically about 24 hours, and is not immediate revocation. - gcs-uniform (REASONED; gcs): Uniform access disables ACLs and becomes irreversible after 90 consecutive days; CLI bucket creation defaults its flag false, so migrate ACL grants explicitly. - gcs-public (REASONED; gcs): Enforce bucket/organization public-access prevention; inherited API default needs effective organization enforcement. Public grants fail 412 and anonymous reads get 401/403; signed URLs remain usable. - gcs-readback (REASONED; gcs): Boolean update flags enable uniform access and prevention; verify enabled=true and prevention=enforced in bucket description. - gcs-conditions (REASONED; gcs): Prefix/time IAM conditions require uniform access; merge policy version 3 with current etag and required bindings, checking independent project grants. - gcs-list (REASONED; gcs): Object-prefix conditions do not filter bucket-level objects.list; avoid an independent unrestricted listing grant when names are private. - gcs-sign (REASONED; gcs): sign-url requires service-account signing and object permission, not ordinary user login alone; default 1h, system-managed maximum 12h, private-key/activated-account maximum 7d. - gcs-tls (REASONED; gcs): Use HTTPS and optionally deny TLS 1/1.1 through organization policy; this establishes no bucket HTTP-deny switch and excludes public objects served from Google Front End cache. - gcs-encryption (REASONED; gcs): Google-managed encryption is default; CMEK needs compatible location and Storage service-agent encrypter/decrypter role before setting default key; only subsequent writes change absent rewrite. - azure-anonymous (REASONED; azure): Resource Manager accounts and containers default private; account allowBlobPublicAccess=false overrides public containers, with Azure Policy available to audit/deny broader settings. - azure-identity (REASONED; azure): Before denying Shared Key, grant and test narrow Entra Blob data roles; management-plane roles do not replace data permissions. - azure-shared-key (REASONED; azure): Unset/null Shared Key permits use; false rejects account-key, service SAS and account SAS authorization but permits authorized user delegation SAS and does not prevent offline signing. - azure-transport (REASONED; azure): Require supportsHttpsTrafficOnly and TLS1_2; CLI uses https-only/enableHttpsTrafficOnly. TLS 1.2 service minimum is documented from February 3, 2026. - azure-network (REASONED; azure): Use defaultAction=Deny with narrow rules or publicNetworkAccess=Disabled for private endpoints; review exceptions and external SAS consumers. - azure-private-endpoint (REASONED; azure): Approve blob private endpoint and private DNS while retaining normal Blob hostname; private endpoint alone leaves the public route available. - azure-sas (REASONED; azure): Prefer user delegation SAS via login/as-user with least permissions, HTTPS, explicit start/expiry; maximum seven days and delegation-key lifetime both apply. - azure-signer (REASONED; azure): Signer needs object data permission plus generateUserDelegationKey at account scope or above; a separate Blob Delegator role avoids broadening container data access. - azure-sas-policy (REASONED; azure): Expiration Log default does not enforce and needs diagnostics; Block denies supported excess-duration/missing-start requests. Key creation times may require rotation first. - azure-sas-exceptions (REASONED; azure): SAS expiration action excludes stored-policy service SAS and HDFS user-delegation requests; account policy cannot extend user-delegation seven-day maximum. - azure-revoke (REASONED; azure): Stored policy revokes only associated service SAS; revoke-delegation-keys affects all account delegation keys with caching delays; account-key SAS needs rotation or Shared Key denial. - azure-retention (REASONED; azure): Time-based container retention starts unlocked; locking prevents deletion/shortening irreversibly but permits extensions and does not restrict reads. - supabase-private (REASONED; supabase): Buckets default public=false; public reads bypass access control while mutations still check it. A restrictive SELECT policy cannot protect public asset URLs. - supabase-select (REASONED; supabase): Private Storage uses storage.objects RLS, not application-table policies; scope SELECT to bucket and owner JWT subject and review additive grants and service-created ownership. - supabase-insert (REASONED; supabase): Uploads need separate INSERT checks for bucket, user UUID path and owner; without policies Storage grants no uploads. - supabase-mutations (REASONED; supabase): Optional UPDATE checks existing and proposed bucket/owner state; upsert/move need SELECT+UPDATE, copy SELECT+INSERT and removal SELECT+DELETE. - supabase-sign (REASONED; supabase): Authorize caller before createSignedUrl with explicit seconds; 3600 is an example. Public URL construction cannot authorize private reads; Auth-key rotation does not revoke Storage URLs. - supabase-privileged (REASONED; supabase): Service-role/server secret keys bypass end-user restrictions and remain server-side; privileged signing endpoints must enforce bucket/object/caller access themselves. - verify-s3 (REASONED; s3): Known-object authorized GET must return 200, same unsigned URL 403 after fixing public grants, then the saved 60-second URL must expire; errors or missing objects are inconclusive. - verify-r2 (REASONED; r2): Compare the same fixture through signed S3 API, r2.dev and all custom routes; fixed development route cannot serve it and protected domain denies/challenges while authorized flow succeeds. - verify-gcs (REASONED; gcs): Compare exposed anonymous fixture with enforced prevention 401/403 and a successful valid signed control; signed success does not mean prevention failed. - verify-azure (REASONED; azure): Require anonymous denial and authorized success on the same blob; documented 401/409/403 depends on request/service version, so inspect errors and bytes. - verify-supabase (REASONED; supabase): Public fixture URL serves bytes before privacy and returns an error afterwards while authorized private/signed download works; test actual user operation/bucket boundaries separately. - verify-expiry (REASONED; s3, r2, gcs, azure, supabase): For every provider repeat the same saved short-lived GET URL before and after expiry, retaining a working object/identity control; do not substitute a fresh URL for the expiry test. - verify-effective (REASONED; s3, r2, gcs, azure, supabase): Inspect effective public-access settings and inherited grants with exposed fixtures; inventory and deployed-bundle inspection remain unobserved. - verify-scope-transport (REASONED; s3, r2, gcs, azure, supabase): Compare allowed/forbidden prefixes, buckets, operations and time windows; test real HTTP/TLS/endpoint denials with positive controls, not local TLS-library failures. - verify-revocation (REASONED; s3, azure, supabase): Repeat saved URLs after applicable revocation propagation; Azure Log/Block needs an unexpired excessive-duration fixture and exceptions, while Supabase Auth rotation is no Storage revocation. - verify-encryption-retention (REASONED; s3, gcs, azure): Inspect new versus historical object encryption; compare unlocked deletion with retention and explicit governance bypass, and Azure editable versus locked policy with current ETags. - verify-mfa-delete (REASONED; s3): Compare permanent deletion and versioning changes without MFA versus valid root MFA through CLI/API; unrelated failures do not establish enforcement. - verify-signature-age (REASONED; s3): An older signature still within URL expiry must fail the age policy while a fresh URL to the same object succeeds. - verify-audit-lifecycle (REASONED; s3, r2): Locate S3 fixture GET/PUT data events and access logs after delivery; observe R2 eventual expiry without equating its deadline to revocation. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "139bbd5d2de5d6cc80a68540c09848087e17cb4f633455a6f0c0d41641bdd015", "components": { "s3": { "name": "Amazon S3 documentation", "basis": "unknown", "sources": { "se628d1970eb5": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingBucket.html", "s403772d7da93": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html", "s3402cc49aabf": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html", "sa1216f9e4a19": "https://docs.aws.amazon.com/cli/latest/reference/s3control/get-public-access-block.html", "sd883ca72fcc3": "https://docs.aws.amazon.com/cli/latest/reference/s3api/get-public-access-block.html", "s742ac34f928a": "https://docs.aws.amazon.com/cli/latest/reference/s3api/get-bucket-ownership-controls.html", "s42f66d943112": "https://docs.aws.amazon.com/cli/latest/reference/s3api/get-bucket-policy-status.html", "s1b0317b11178": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-analyzer.html", "sa45a1ad70632": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-policies-s3.html", "s15a0fd0f91bc": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/amazon-s3-policy-keys.html", "s75d763bbc87d": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies-vpc-endpoint.html", "s492da65d00aa": "https://docs.aws.amazon.com/cli/latest/reference/s3/presign.html", "s3a070b6e701f": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html", "s2c093f4a6ecc": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-encryption.html", "s13bc14c030aa": "https://docs.aws.amazon.com/cli/latest/reference/s3api/put-bucket-encryption.html", "s1fd64086bfb0": "https://docs.aws.amazon.com/AmazonS3/latest/API/API_ServerSideEncryptionRule.html", "s4e5f400aef98": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-key.html", "sd1400721c9b8": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html", "sb105a845c1ae": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html", "se3a6a0837037": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-configure.html", "seb4472df7637": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html", "saebd5e9383a5": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerLogs.html", "s3629ed4a70bb": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/cloudtrail-logging-s3-info.html", "sa284fcc5d680": "https://docs.aws.amazon.com/AmazonS3/latest/userguide/enable-server-access-logging.html" } }, "r2": { "name": "Cloudflare R2 documentation", "basis": "unknown", "sources": { "sb7a759b2ebe8": "https://developers.cloudflare.com/r2/buckets/public-buckets/", "s8d6c268180c2": "https://developers.cloudflare.com/r2/tutorials/cloudflare-access/", "sa2c9f0cbf3e8": "https://developers.cloudflare.com/r2/api/tokens/", "sb94531314aa1": "https://developers.cloudflare.com/r2/api/s3/presigned-urls/", "s148e84e6f9c5": "https://developers.cloudflare.com/r2/buckets/cors/", "se07bc6a61135": "https://developers.cloudflare.com/r2/buckets/object-lifecycles/" } }, "gcs": { "name": "Google Cloud Storage documentation", "basis": "unknown", "sources": { "s42c2205b0738": "https://docs.cloud.google.com/storage/docs/uniform-bucket-level-access", "scda488e40bbb": "https://docs.cloud.google.com/storage/docs/using-uniform-bucket-level-access", "sd33bc897974f": "https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/create", "sb9d61cebffa0": "https://docs.cloud.google.com/storage/docs/public-access-prevention", "sc3765b42a898": "https://docs.cloud.google.com/storage/docs/using-public-access-prevention", "s457c739204db": "https://docs.cloud.google.com/storage/docs/json_api/v1/buckets", "sbcc8696a4e01": "https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/update", "sd970d5152bda": "https://docs.cloud.google.com/sdk/gcloud/reference/storage/buckets/describe", "sdb7089445e31": "https://docs.cloud.google.com/storage/docs/access-control/iam", "sf76bc0245839": "https://docs.cloud.google.com/storage/docs/access-control/using-iam-permissions", "s685c316007d5": "https://docs.cloud.google.com/sdk/gcloud/reference/storage/sign-url", "sd1c119f40d43": "https://docs.cloud.google.com/storage/docs/access-control/signing-urls-with-helpers", "sfb023872a25b": "https://docs.cloud.google.com/docs/security/compliance/restrict-tls-versions", "s4f89c95012ff": "https://docs.cloud.google.com/storage/docs/encryption/default-keys", "s86e4b23db573": "https://docs.cloud.google.com/storage/docs/encryption/using-customer-managed-keys", "sabd7f559ae65": "https://docs.cloud.google.com/storage/docs/encryption/customer-managed-keys" } }, "azure": { "name": "Azure Blob documentation", "basis": "unknown", "sources": { "sfd92831c28db": "https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent", "s4717f9763b79": "https://learn.microsoft.com/en-us/javascript/api/%40azure/arm-storage/storageaccountpropertiesupdateparameters?view=azure-node-latest", "scda1e91df2fe": "https://learn.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access", "seb224abe947c": "https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent", "s484800a6e4ba": "https://learn.microsoft.com/en-us/azure/storage/common/storage-require-secure-transfer", "se38a28ad1bae": "https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-migrate-to-tls2", "sc38fd7e9cefd": "https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security", "sbd8fe80e3ff8": "https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints", "s67193cefbdea": "https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-user-delegation-sas-create-cli", "s3648b9b93183": "https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest", "s6c70f60b7985": "https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas", "s353dcd86e363": "https://learn.microsoft.com/en-us/azure/storage/common/sas-expiration-policy", "s2ac1ebafc5e6": "https://learn.microsoft.com/en-us/rest/api/storageservices/define-stored-access-policy", "s4b9e1d3892dc": "https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest", "sb8cabf5ab8ba": "https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-storage-overview", "s7dcf19862de9": "https://learn.microsoft.com/en-us/azure/storage/blobs/immutable-policy-configure-container-scope", "s853e642c5356": "https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-configure" } }, "supabase": { "name": "Supabase Storage documentation", "basis": "unknown", "sources": { "s1567fb838f20": "https://supabase.com/docs/guides/storage/buckets/fundamentals", "sd6febfd9d2f6": "https://supabase.com/docs/reference/javascript/file-buckets-createbucket", "s60e4fb6418a0": "https://supabase.com/docs/guides/storage/security/ownership", "s46cff2590323": "https://supabase.com/docs/guides/database/postgres/row-level-security", "sdba7d7b6dcf8": "https://supabase.com/docs/guides/storage/security/access-control", "s9e1484f46eff": "https://supabase.com/docs/guides/storage/schema/helper-functions", "s4473d833575f": "https://supabase.com/docs/reference/javascript/file-buckets-copy", "s759f4328cacd": "https://supabase.com/docs/reference/javascript/file-buckets-move", "s93f79ed28789": "https://supabase.com/docs/reference/javascript/file-buckets-remove", "s702e2dd69bbf": "https://supabase.com/docs/reference/javascript/file-buckets-createsignedurl", "sc29febbb230e": "https://supabase.com/docs/guides/storage/serving/downloads", "s65d5b8043bad": "https://supabase.com/docs/guides/getting-started/api-keys" } } }, "claims": { "s3-default": {"text": "New general purpose buckets/objects are private, Bucket owner enforced disables ACLs and all four bucket Block Public Access flags default enabled; inspect existing/account/organization settings.", "components": ["s3"], "sources": ["s3:se628d1970eb5", "s3:s403772d7da93", "s3:s3402cc49aabf"], "status": "REASONED"}, "s3-public-block": {"text": "Enable all four public-access flags at account and bucket; most restrictive access-point/bucket/account/organization combination applies, but stored grants survive and can reactivate.", "components": ["s3"], "sources": ["s3:s3402cc49aabf"], "status": "REASONED"}, "s3-acl-migration": {"text": "Migrate ACL grants/clients before BucketOwnerEnforced; no-ACL and bucket-owner-full-control uploads work, unsupported ACLs fail and stored ACLs are not erased.", "components": ["s3"], "sources": ["s3:s403772d7da93"], "status": "REASONED"}, "s3-readback": {"text": "Read back account/bucket flags, ownership and attached policy IsPublic=false; missing policy differs from denied or failed inspection.", "components": ["s3"], "sources": ["s3:sa1216f9e4a19", "s3:sd883ca72fcc3", "s3:s742ac34f928a", "s3:s42f66d943112"], "status": "REASONED"}, "s3-public-meaning": {"text": "Wildcard-principal Allow is public without a qualifying fixed restriction; broad Deny is not a public grant.", "components": ["s3"], "sources": ["s3:s3402cc49aabf"], "status": "REASONED"}, "s3-analyzer": {"text": "S3 external-access view needs account-level analyzers in each bucket Region; findings are asynchronous and archived findings need review, so an empty view proves no privacy.", "components": ["s3"], "sources": ["s3:s1b0317b11178"], "status": "REASONED"}, "s3-app": {"text": "Scope GetObject/PutObject to uploads/* and ListBucket through prefix conditions; omit unused write/list permissions and inspect additive effective grants.", "components": ["s3"], "sources": ["s3:sa45a1ad70632", "s3:s15a0fd0f91bc"], "status": "REASONED"}, "s3-transport": {"text": "SecureTransport=false Deny covers bucket/objects; PrincipalIsAWSService=false avoids redacted-context service breakage without granting access.", "components": ["s3"], "sources": ["s3:s15a0fd0f91bc"], "status": "REASONED"}, "s3-endpoint": {"text": "Optional SourceVpce Deny restricts all consumers to the chosen endpoint, including signed URLs, and can block console/integrations; establish recovery first.", "components": ["s3"], "sources": ["s3:s75d763bbc87d"], "status": "REASONED"}, "s3-presign": {"text": "CLI GET presigning defaults 3600 seconds, maximum 604800; signer needs object permission and temporary credentials can expire sooner; use short explicit expiry.", "components": ["s3"], "sources": ["s3:s492da65d00aa", "s3:s3a070b6e701f"], "status": "REASONED"}, "s3-signature-age": {"text": "signatureAge above 600000 ms can deny a still-unexpired URL; credential revocation or effective policy denial stops access, without Azure-style stored access policies.", "components": ["s3"], "sources": ["s3:s3a070b6e701f"], "status": "REASONED"}, "s3-encryption": {"text": "New writes receive SSE-S3 unless another supported mode applies; optional SSE-KMS needs key policy/permissions and default changes do not rewrite historical objects.", "components": ["s3"], "sources": ["s3:s2c093f4a6ecc", "s3:s13bc14c030aa"], "status": "REASONED"}, "s3-bucket-key": {"text": "BucketKeyEnabled defaults disabled for general purpose buckets; enabling reduces KMS request costs and does not restrict public access.", "components": ["s3"], "sources": ["s3:s1fd64086bfb0", "s3:s4e5f400aef98"], "status": "REASONED"}, "s3-versioning": {"text": "Versioning defaults disabled; once enabled it can be suspended but cannot return to unversioned state.", "components": ["s3"], "sources": ["s3:sd1400721c9b8"], "status": "REASONED"}, "s3-lock": {"text": "Object Lock requires versioning and cannot be disabled; versioning cannot then be suspended. Locks protect versions, not reads, new versions or delete markers.", "components": ["s3"], "sources": ["s3:sb105a845c1ae", "s3:se3a6a0837037"], "status": "REASONED"}, "s3-retention": {"text": "GOVERNANCE permits explicit authorized bypass; COMPLIANCE cannot be shortened/bypassed by ordinary admin or root; test before irreversible retention.", "components": ["s3"], "sources": ["s3:sb105a845c1ae"], "status": "REASONED"}, "s3-mfa-delete": {"text": "Only bucket-owner root enables MFA Delete through CLI/API; it gates permanent version deletion and versioning changes, not reads, and conflicts with lifecycle configurations.", "components": ["s3"], "sources": ["s3:seb4472df7637"], "status": "REASONED"}, "s3-audit": {"text": "Server access logging and CloudTrail object data events default off; management history is not an object log. Enable independent log destinations and selected data events.", "components": ["s3"], "sources": ["s3:saebd5e9383a5", "s3:s3629ed4a70bb"], "status": "REASONED"}, "s3-log-delivery": {"text": "With ACLs disabled grant logging service PutObject on a constrained prefix; destination must share account/Region, lack Object Lock and avoid recursive logging; delivery is delayed.", "components": ["s3"], "sources": ["s3:sa284fcc5d680"], "status": "REASONED"}, "r2-routes": {"text": "R2 defaults private; disable development r2.dev and review every custom/application route independently. Access/WAF on a custom domain does not protect an enabled r2.dev bypass.", "components": ["r2"], "sources": ["r2:sb7a759b2ebe8", "r2:s8d6c268180c2"], "status": "REASONED"}, "r2-tokens": {"text": "Use bucket-scoped Object Read only/Read & Write tokens; admin permissions are broader. Account/User ownership is separate from resource scope and secret key is shown once.", "components": ["r2"], "sources": ["r2:sa2c9f0cbf3e8"], "status": "REASONED"}, "r2-presign": {"text": "SigV4 URLs support 1-604800 seconds with explicit expiresIn and work only on the S3 API domain, not custom domains.", "components": ["r2"], "sources": ["r2:sb94531314aa1"], "status": "REASONED"}, "r2-cors": {"text": "Dashboard CORS specifies exact origins, methods and headers; Wrangler format differs and CORS cannot stop a non-browser with a valid signature.", "components": ["r2"], "sources": ["r2:s148e84e6f9c5"], "status": "REASONED"}, "r2-lifecycle": {"text": "Default rule expires incomplete multipart uploads after seven days, not complete objects; object expiry is asynchronous, typically about 24 hours, and is not immediate revocation.", "components": ["r2"], "sources": ["r2:se07bc6a61135"], "status": "REASONED"}, "gcs-uniform": {"text": "Uniform access disables ACLs and becomes irreversible after 90 consecutive days; CLI bucket creation defaults its flag false, so migrate ACL grants explicitly.", "components": ["gcs"], "sources": ["gcs:s42c2205b0738", "gcs:scda488e40bbb", "gcs:sd33bc897974f"], "status": "REASONED"}, "gcs-public": {"text": "Enforce bucket/organization public-access prevention; inherited API default needs effective organization enforcement. Public grants fail 412 and anonymous reads get 401/403; signed URLs remain usable.", "components": ["gcs"], "sources": ["gcs:sb9d61cebffa0", "gcs:sc3765b42a898", "gcs:s457c739204db"], "status": "REASONED"}, "gcs-readback": {"text": "Boolean update flags enable uniform access and prevention; verify enabled=true and prevention=enforced in bucket description.", "components": ["gcs"], "sources": ["gcs:sbcc8696a4e01", "gcs:sd970d5152bda", "gcs:scda488e40bbb"], "status": "REASONED"}, "gcs-conditions": {"text": "Prefix/time IAM conditions require uniform access; merge policy version 3 with current etag and required bindings, checking independent project grants.", "components": ["gcs"], "sources": ["gcs:sdb7089445e31", "gcs:sf76bc0245839"], "status": "REASONED"}, "gcs-list": {"text": "Object-prefix conditions do not filter bucket-level objects.list; avoid an independent unrestricted listing grant when names are private.", "components": ["gcs"], "sources": ["gcs:sdb7089445e31"], "status": "REASONED"}, "gcs-sign": {"text": "sign-url requires service-account signing and object permission, not ordinary user login alone; default 1h, system-managed maximum 12h, private-key/activated-account maximum 7d.", "components": ["gcs"], "sources": ["gcs:s685c316007d5", "gcs:sd1c119f40d43"], "status": "REASONED"}, "gcs-tls": {"text": "Use HTTPS and optionally deny TLS 1/1.1 through organization policy; this establishes no bucket HTTP-deny switch and excludes public objects served from Google Front End cache.", "components": ["gcs"], "sources": ["gcs:sfb023872a25b"], "status": "REASONED"}, "gcs-encryption": {"text": "Google-managed encryption is default; CMEK needs compatible location and Storage service-agent encrypter/decrypter role before setting default key; only subsequent writes change absent rewrite.", "components": ["gcs"], "sources": ["gcs:s4f89c95012ff", "gcs:s86e4b23db573", "gcs:sabd7f559ae65"], "status": "REASONED"}, "azure-anonymous": {"text": "Resource Manager accounts and containers default private; account allowBlobPublicAccess=false overrides public containers, with Azure Policy available to audit/deny broader settings.", "components": ["azure"], "sources": ["azure:sfd92831c28db", "azure:s4717f9763b79"], "status": "REASONED"}, "azure-identity": {"text": "Before denying Shared Key, grant and test narrow Entra Blob data roles; management-plane roles do not replace data permissions.", "components": ["azure"], "sources": ["azure:scda1e91df2fe"], "status": "REASONED"}, "azure-shared-key": {"text": "Unset/null Shared Key permits use; false rejects account-key, service SAS and account SAS authorization but permits authorized user delegation SAS and does not prevent offline signing.", "components": ["azure"], "sources": ["azure:seb224abe947c"], "status": "REASONED"}, "azure-transport": {"text": "Require supportsHttpsTrafficOnly and TLS1_2; CLI uses https-only/enableHttpsTrafficOnly. TLS 1.2 service minimum is documented from February 3, 2026.", "components": ["azure"], "sources": ["azure:s484800a6e4ba", "azure:se38a28ad1bae"], "status": "REASONED"}, "azure-network": {"text": "Use defaultAction=Deny with narrow rules or publicNetworkAccess=Disabled for private endpoints; review exceptions and external SAS consumers.", "components": ["azure"], "sources": ["azure:sc38fd7e9cefd"], "status": "REASONED"}, "azure-private-endpoint": {"text": "Approve blob private endpoint and private DNS while retaining normal Blob hostname; private endpoint alone leaves the public route available.", "components": ["azure"], "sources": ["azure:sbd8fe80e3ff8"], "status": "REASONED"}, "azure-sas": {"text": "Prefer user delegation SAS via login/as-user with least permissions, HTTPS, explicit start/expiry; maximum seven days and delegation-key lifetime both apply.", "components": ["azure"], "sources": ["azure:s67193cefbdea", "azure:s3648b9b93183"], "status": "REASONED"}, "azure-signer": {"text": "Signer needs object data permission plus generateUserDelegationKey at account scope or above; a separate Blob Delegator role avoids broadening container data access.", "components": ["azure"], "sources": ["azure:s6c70f60b7985"], "status": "REASONED"}, "azure-sas-policy": {"text": "Expiration Log default does not enforce and needs diagnostics; Block denies supported excess-duration/missing-start requests. Key creation times may require rotation first.", "components": ["azure"], "sources": ["azure:s353dcd86e363"], "status": "REASONED"}, "azure-sas-exceptions": {"text": "SAS expiration action excludes stored-policy service SAS and HDFS user-delegation requests; account policy cannot extend user-delegation seven-day maximum.", "components": ["azure"], "sources": ["azure:s353dcd86e363"], "status": "REASONED"}, "azure-revoke": {"text": "Stored policy revokes only associated service SAS; revoke-delegation-keys affects all account delegation keys with caching delays; account-key SAS needs rotation or Shared Key denial.", "components": ["azure"], "sources": ["azure:s2ac1ebafc5e6", "azure:s6c70f60b7985", "azure:s4b9e1d3892dc", "azure:seb224abe947c"], "status": "REASONED"}, "azure-retention": {"text": "Time-based container retention starts unlocked; locking prevents deletion/shortening irreversibly but permits extensions and does not restrict reads.", "components": ["azure"], "sources": ["azure:sb8cabf5ab8ba", "azure:s7dcf19862de9"], "status": "REASONED"}, "supabase-private": {"text": "Buckets default public=false; public reads bypass access control while mutations still check it. A restrictive SELECT policy cannot protect public asset URLs.", "components": ["supabase"], "sources": ["supabase:s1567fb838f20", "supabase:sd6febfd9d2f6"], "status": "REASONED"}, "supabase-select": {"text": "Private Storage uses storage.objects RLS, not application-table policies; scope SELECT to bucket and owner JWT subject and review additive grants and service-created ownership.", "components": ["supabase"], "sources": ["supabase:s60e4fb6418a0", "supabase:s46cff2590323"], "status": "REASONED"}, "supabase-insert": {"text": "Uploads need separate INSERT checks for bucket, user UUID path and owner; without policies Storage grants no uploads.", "components": ["supabase"], "sources": ["supabase:sdba7d7b6dcf8", "supabase:s9e1484f46eff"], "status": "REASONED"}, "supabase-mutations": {"text": "Optional UPDATE checks existing and proposed bucket/owner state; upsert/move need SELECT+UPDATE, copy SELECT+INSERT and removal SELECT+DELETE.", "components": ["supabase"], "sources": ["supabase:sdba7d7b6dcf8", "supabase:s4473d833575f", "supabase:s759f4328cacd", "supabase:s93f79ed28789"], "status": "REASONED"}, "supabase-sign": {"text": "Authorize caller before createSignedUrl with explicit seconds; 3600 is an example. Public URL construction cannot authorize private reads; Auth-key rotation does not revoke Storage URLs.", "components": ["supabase"], "sources": ["supabase:s702e2dd69bbf", "supabase:sc29febbb230e"], "status": "REASONED"}, "supabase-privileged": {"text": "Service-role/server secret keys bypass end-user restrictions and remain server-side; privileged signing endpoints must enforce bucket/object/caller access themselves.", "components": ["supabase"], "sources": ["supabase:s65d5b8043bad"], "status": "REASONED"}, "verify-s3": {"text": "Known-object authorized GET must return 200, same unsigned URL 403 after fixing public grants, then the saved 60-second URL must expire; errors or missing objects are inconclusive.", "components": ["s3"], "sources": ["s3:s3a070b6e701f", "s3:s3402cc49aabf"], "status": "REASONED", "verify": [1]}, "verify-r2": {"text": "Compare the same fixture through signed S3 API, r2.dev and all custom routes; fixed development route cannot serve it and protected domain denies/challenges while authorized flow succeeds.", "components": ["r2"], "sources": ["r2:sb7a759b2ebe8", "r2:s8d6c268180c2"], "status": "REASONED", "verify": [2]}, "verify-gcs": {"text": "Compare exposed anonymous fixture with enforced prevention 401/403 and a successful valid signed control; signed success does not mean prevention failed.", "components": ["gcs"], "sources": ["gcs:sb9d61cebffa0", "gcs:sc3765b42a898"], "status": "REASONED", "verify": [2]}, "verify-azure": {"text": "Require anonymous denial and authorized success on the same blob; documented 401/409/403 depends on request/service version, so inspect errors and bytes.", "components": ["azure"], "sources": ["azure:sfd92831c28db", "azure:s853e642c5356"], "status": "REASONED", "verify": [2]}, "verify-supabase": {"text": "Public fixture URL serves bytes before privacy and returns an error afterwards while authorized private/signed download works; test actual user operation/bucket boundaries separately.", "components": ["supabase"], "sources": ["supabase:s1567fb838f20", "supabase:sdba7d7b6dcf8"], "status": "REASONED", "verify": [2]}, "verify-expiry": {"text": "For every provider repeat the same saved short-lived GET URL before and after expiry, retaining a working object/identity control; do not substitute a fresh URL for the expiry test.", "components": ["s3", "r2", "gcs", "azure", "supabase"], "sources": ["s3:s3a070b6e701f", "r2:sb94531314aa1", "gcs:s685c316007d5", "azure:s67193cefbdea", "supabase:sc29febbb230e"], "status": "REASONED", "verify": [2]}, "verify-effective": {"text": "Inspect effective public-access settings and inherited grants with exposed fixtures; inventory and deployed-bundle inspection remain unobserved.", "components": ["s3", "r2", "gcs", "azure", "supabase"], "sources": ["s3:s1b0317b11178", "r2:sb7a759b2ebe8", "gcs:sc3765b42a898", "azure:sfd92831c28db", "supabase:s65d5b8043bad"], "status": "REASONED"}, "verify-scope-transport": {"text": "Compare allowed/forbidden prefixes, buckets, operations and time windows; test real HTTP/TLS/endpoint denials with positive controls, not local TLS-library failures.", "components": ["s3", "r2", "gcs", "azure", "supabase"], "sources": ["s3:s15a0fd0f91bc", "r2:s148e84e6f9c5", "gcs:sfb023872a25b", "azure:sbd8fe80e3ff8", "supabase:sdba7d7b6dcf8"], "status": "REASONED"}, "verify-revocation": {"text": "Repeat saved URLs after applicable revocation propagation; Azure Log/Block needs an unexpired excessive-duration fixture and exceptions, while Supabase Auth rotation is no Storage revocation.", "components": ["s3", "azure", "supabase"], "sources": ["s3:s3a070b6e701f", "azure:s353dcd86e363", "supabase:sc29febbb230e"], "status": "REASONED", "verify": [2]}, "verify-encryption-retention": {"text": "Inspect new versus historical object encryption; compare unlocked deletion with retention and explicit governance bypass, and Azure editable versus locked policy with current ETags.", "components": ["s3", "gcs", "azure"], "sources": ["s3:s2c093f4a6ecc", "s3:s13bc14c030aa", "s3:sb105a845c1ae", "gcs:sabd7f559ae65", "azure:s7dcf19862de9"], "status": "REASONED"}, "verify-mfa-delete": {"text": "Compare permanent deletion and versioning changes without MFA versus valid root MFA through CLI/API; unrelated failures do not establish enforcement.", "components": ["s3"], "sources": ["s3:seb4472df7637"], "status": "REASONED"}, "verify-signature-age": {"text": "An older signature still within URL expiry must fail the age policy while a fresh URL to the same object succeeds.", "components": ["s3"], "sources": ["s3:s3a070b6e701f"], "status": "REASONED"}, "verify-audit-lifecycle": {"text": "Locate S3 fixture GET/PUT data events and access logs after delivery; observe R2 eventual expiry without equating its deadline to revocation.", "components": ["s3", "r2"], "sources": ["s3:s3629ed4a70bb", "s3:sa284fcc5d680", "r2:se07bc6a61135"], "status": "REASONED"} } } ```` ====================================================================== ==> nats.md ====================================================================== # NATS and JetStream: authentication, TLS, and the monitoring port NATS is a messaging server that passes messages between applications by subject name, and JetStream is its built-in feature for storing those messages. This guide helps your AI assistant configure a deployment securely, including required client authentication, scoped subjects, TLS, a private monitoring port, and bounded JetStream storage. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/nats.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against NATS Server v2.15.2, NATS image listener source v2.15.2, natscli v0.4.0, and Go socket source go1.26.8; 3 further sources are pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 70 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. NATS accepts client connections on 4222 with no authentication configured by default. A separately enabled HTTP monitoring endpoint, conventionally 8222, reveals connection metadata, subscription subjects, and message/byte counters without a login of its own. Both need explicit configuration. JetStream, which the vendor's JetStream concepts page says "adds a persistence layer on top" of Core NATS, stores streams and consumers, and the monitoring endpoint reports its state at `/jsz`. The cited pages do not say whether JetStream opens a listening port of its own, so this guide does not establish it. The source of the official Docker images opens more than these defaults. At the pinned image source commit, which carries NATS Server 2.15.2 and 2.14.9, every 2.14 and 2.15 Dockerfile (scratch, Alpine, Nano Server and Windows Server Core) copies a bundled `nats-server.conf` into the image and names it in its default command. An image built from one of them, run with its default command, its default working directory and no configuration mounted over the bundled one, loads that file. What the published `nats` images contain is not established, since the official library file that maps the published tags to image source commits was not re-read for this commit. The scratch and Windows variants name the file by a relative path, so the file loads only when the working directory is where the image copies it: `/` on scratch, `C:\` on Windows. The bundled configuration's path is `/nats-server.conf` on scratch, `/etc/nats/nats-server.conf` on Alpine and `C:\nats-server.conf` on Windows. The bundled `nats-server.conf` sets `port: 4222` with no client authorization, `monitor_port: 8222`, and a `cluster` block on port 6222 whose route authorization is user `ruser` with password `T0pS3cr3t`, which the image source publishes along with the cluster name `my_cluster`. None of the three sets a host. At NATS Server v2.15.2, which the 2.15 Dockerfiles name, a listener without a host listens on `0.0.0.0`, which Go's go1.26.8 source opens as a dual-stack socket where the host supports IPv4-mapped IPv6 addresses. The listener defaults of v2.14.9, which the 2.14 Dockerfiles name, are not established, and neither is that socket behaviour at the Go releases the v2.15.2 and v2.14.9 release notes name (1.27.3 and 1.26.10), since that source was not read. Anything that can reach such a container on those ports (another container on its network, a published port, or host networking) therefore gets unauthenticated client access, unauthenticated monitoring, and route connections authorized by public credentials. Passing arguments replaces the default command, so the bundled configuration is not loaded unless the arguments name it again with `--config` or `-c`. Mount your own configuration and pass it with `--config`: set client authentication, bind monitoring to `127.0.0.1` or remove it, and drop the `cluster` block unless its route credentials are private. Publish only 4222, and only to host loopback or a private network. The review baseline is **NATS Server v2.15.2**, tag commit `5a815f11a911efa2c88fd1dbfb18ed89a449bfc1`, and **natscli v0.4.0**. This is a pinned review baseline, not a claim that these are the latest releases. Stable documentation can describe newer versions; version-sensitive conclusions below also use the pinned implementation. The vendor's [2.15 upgrade guide](https://docs.nats.io/release-notes/upgrade-to-2.15) recommends "upgrading from at least v2.14.7 to v2.15.x" because of its metalayer changes. It also lists a default limit of 1000 consumers per stream (section 8) and the `js_raft_delete_range` feature flag, now on by default, which it says "may cause problems when upgrading to 2.15 directly from 2.12 or earlier". The fragments explain individual controls and conditional alternatives. Section 14 assembles one selected static-account configuration for the Verify identities. Replace every placeholder before deployment. All example capacities and time budgets are workload-dependent. ## 1. Require authentication and separate credential ownership Tier-1 rationale: separate identities and signing authority limit the consequences of a leaked application credential. Static authentication supports a shared token, password users, and public NKey identities. Prefer individually scoped identities over a shared token. Separate password and NKey users may coexist in a `users` list; an individual NKey entry replaces that identity's `user`/`password` pair. Static NKey authentication does not require JWT infrastructure. For example, these are alternative application entries to place inside the intended account's `users` list: ```text { user: app password: "REPLACE_WITH_LONG_RANDOM_PASSWORD" permissions { publish: { allow: ["orders.created"] } subscribe: { deny: [">"] } } } { nkey: "REPLACE_WITH_GENERATED_PUBLIC_USER_NKEY" permissions { publish: { allow: ["orders.created"] } subscribe: { deny: [">"] } } } ``` Generate your own public user NKey and substitute it. An illustrative public key is not a credential-generation procedure. The vendor documents `nats auth nkey gen user --output user.nk` and `nats auth nkey show user.nk`; generation and inspection are offline operations, but require the pinned CLI and a private writable directory. The seed file belongs to the client, while the server stores the public key. For password users, store a bcrypt hash instead of a plaintext password where possible. `nats server passwd` prompts interactively; do not pass a password through its command-line arguments. Substitute the resulting hash in the server's `password` field, and give the original password to the client through its protected secret configuration. Bcrypt protects stored passwords, not their transmission, so retain TLS. Do not set `no_auth_user`, which admits unauthenticated connections as a named user, unless an anonymous path is deliberate. It is easy to leave this setting behind after testing. **Conditional: operator-mode JWT authentication.** Use this separate configuration approach when delegated account administration is needed. Replace static account/user declarations with generated JWT configuration, retaining the listener, TLS, monitoring, and resource controls: ```text operator: "/etc/nats/operator.jwt" system_account: "REPLACE_WITH_GENERATED_SYSTEM_ACCOUNT_PUBLIC_KEY" resolver { type: full dir: "/var/lib/nats/resolver" } resolver_preload { REPLACE_WITH_GENERATED_SYSTEM_ACCOUNT_PUBLIC_KEY: "REPLACE_WITH_SIGNED_SYSTEM_ACCOUNT_JWT" } ``` The two system-account public-key placeholders must identify the same generated account, whose signed JWT is preloaded. The resolver also needs the appropriate application account JWTs before their users can authenticate. The trust hierarchy has two signing levels: the operator, or an operator signing key, signs account JWTs; each account, or its signing keys, signs user JWTs. Accounts and users are not both signed by the operator. Ordinary non-bearer users prove possession of their seed by signing the server's nonce. Bearer JWTs are an explicit exception: possession of the JWT is sufficient. Clients can use a protected credentials file through `--creds /protected/app.creds`. The file contains a user seed and remains secret. The Verify probes use environment bindings so credential values and credentials-file paths need not appear in command arguments. Keep operator/account signing seeds on the provisioning system, not the broker. JWT authentication does not inherently restrict subjects: encode and review user permissions separately. Exposed versus fixed: one shared secret identifies every application; individually scoped credentials and, where needed, delegated signing authority separate their access and revocation. See [authentication basics](https://docs.nats.io/learn/security/authentication-basics), [operator mode](https://docs.nats.io/learn/security/operator-mode), and [decentralized authentication](https://docs.nats.io/learn/security/decentralized-auth). ## 2. Scope subjects, queue groups, and replies Tier-1 rationale: compromised application credentials should reach only their assigned messages and necessary replies. A user with no `permissions` block and no applicable `default_permissions` is unrestricted within its account. Explicit user permissions replace defaults rather than merging with them. A top-level user list can use `authorization.default_permissions`; account-local defaults belong to the corresponding account. The selected `order-svc` identity publishes order subjects and subscribes only to its own reply inbox: ```text { user: order-svc password: "REPLACE_WITH_ORDER_SVC_BCRYPT_HASH" permissions { publish: { allow: ["orders.>"] deny: ["$SYS.>", "$JS.API.>", "$JS.*.API.>"] } # Set the client's inbox prefix to _INBOX.order-svc. # Grant only this identity's prefix; see the note below on _INBOX.>. subscribe: { allow: ["_INBOX.order-svc.>"] } } } ``` The vendor's authorization page grants `_INBOX.>` in its own example and says "Allowing the prefix lets `order-svc` receive replies and nothing else". This guide infers that `_INBOX.>` also matches other clients' replies under the default `_INBOX.` prefix, so it grants an identity-specific prefix instead. The publisher-only `app` example in section 1 remains separate: it cannot subscribe, including to replies. Do not give it request/reply work without reviewing its inbox permissions. `publish.allow` and `subscribe.allow` are independent. Restrict both operations explicitly. For ordinary static permissions, a **nonempty** allow list denies subjects outside that list; an empty allow list imposes no restriction. Use an explicit deny, such as `subscribe: { deny: [">"] }`, to prohibit an operation. For a request-handling worker, require the assigned queue group and bound reply permissions: ```text { user: order-worker password: "REPLACE_WITH_ORDER_WORKER_BCRYPT_HASH" permissions { publish: { deny: [">"] } subscribe: { allow: ["orders.lookup order-workers"] } allow_responses: { max: 1, expires: "2s" } } } ``` Do not add a bare `"orders.lookup"` subscription grant: it defeats the queue-only restriction. A matching static deny overrides a static allow. **`allow_responses` is an exception to treating that rule as absolute:** a tracked response can be permitted after the static publish check denies it. The worker above can send one timely reply despite its static publish deny. Requesters influence reply subjects, so this feature is not an absolute reply-subject allow list. The response count and expiry are workload-dependent policy choices. Exposed versus fixed: broad subscriptions and reply publishing become queue-specific delivery and bounded replies. See [authorization](https://docs.nats.io/learn/security/authorization), [subscription permissions](https://docs.nats.io/reference/config/authorization/users/permissions/subscribe/), [response permissions](https://docs.nats.io/reference/config/authorization/users/permissions/allow_responses/), and [v2.15.2 permission enforcement](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/client.go). ## 3. Enable TLS Tier-1 rationale: authenticate the endpoint and protect credentials and messages in transit. ```text tls { cert_file: "/etc/nats/certs/server-cert.pem" key_file: "/etc/nats/certs/server-key.pem" ca_file: "/etc/nats/certs/ca.pem" verify: true timeout: "2s" } ``` Certificates per [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md). Clients must validate both the CA chain and the intended server identity. `verify: true` requires and verifies a client certificate against `ca_file`. It does not itself require a NATS password: that additional requirement comes from the selected authentication configuration. The password-user configuration in section 14 requires both a trusted client certificate and the corresponding password. `verify_and_map: true` also verifies the certificate and derives a configured user's identity from certificate attributes, including supported email, DNS, or URI SANs, or the distinguished name. Choose and document the intended recipe. `verify_and_map` enables verification itself; setting both options true is not inherently invalid. Exposed versus fixed: plaintext or an unverified endpoint becomes an authenticated TLS connection, with client certificates required where configured. See [encryption and TLS](https://docs.nats.io/learn/security/encryption), the [TLS reference](https://docs.nats.io/reference/config/tls/), and the [v2.15.2 TLS parser](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/opts.go). ## 4. Keep the monitoring port private Tier-1 rationale: monitoring discloses deployment-wide metadata outside application account permissions. The HTTP monitoring endpoint is off unless configured (the bundled configuration in the official image source configures it; see the introduction), for example with `http_port: 8222` or the server's `-m 8222` option. `https_port` serves the same data over TLS. It answers `/varz`, `/connz`, `/routez`, and, with JetStream enabled, `/jsz`. For same-host collection: ```text http: "127.0.0.1:8222" ``` Otherwise bind a deliberately restricted private interface. Avoid a bare `http_port` that leaves the binding implicit. For remote HTTP access, use an authenticating proxy and prevent direct access to its backend from unauthorized networks, including through container port publication. The cited monitoring and hardening pages do not mention a proxy: they say the port "is unauthenticated by default" and to bind it to `127.0.0.1`, so the proxy and its bypass prevention are this guide's recommendation. `/connz` exposes connection metadata and counters, not captured message payloads. Query parameters request additional details: `subs=true` requests subscription subjects and `auth=true` requests identity information. **`auth=true` does not enable authentication.** The native monitoring endpoint has no NATS user/password or JWT login. HTTPS encrypts transport but does not add that login, and client `verify`/`verify_and_map` settings do not protect monitoring with mTLS. Authenticated system-account monitoring over NATS is a separate path; enabling it does not secure an exposed HTTP port. Exposed versus fixed: anonymous external JSON retrieval becomes access confined to the intended collector or authenticated front end, with direct backend bypass denied. See [monitoring endpoints](https://docs.nats.io/learn/monitoring/monitoring-endpoints), [deployment hardening](https://docs.nats.io/learn/deployment/hardening), and the [v2.15.2 monitoring implementation](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/monitor.go). ## 5. Secure cluster, leafnode, and gateway connections separately Tier-1 rationale: an unauthorized peer can create an additional route into trusted message traffic. Enable only the links the deployment needs. An inbound cluster, leafnode, or gateway listener has its own authentication and TLS, independent of client `authorization` and top-level `tls`. Their default host is `0.0.0.0`; explicitly bind enabled listeners to intended private interfaces and restrict network peers. All addresses and secrets below are placeholders or illustrative private addresses. Store credential-bearing URLs in protected configuration, never command arguments. **Conditional: cluster routes, conventionally 6222.** ```text cluster { name: ORDERS_CLUSTER listen: "10.0.0.10:6222" authorization { user: route password: "REPLACE_WITH_ROUTE_PASSWORD" } tls { cert_file: "/etc/nats/certs/route-cert.pem" key_file: "/etc/nats/certs/route-key.pem" ca_file: "/etc/nats/certs/peer-ca.pem" } routes: [ "nats-route://route:REPLACE_WITH_URL_ENCODED_ROUTE_PASSWORD@route-b.example.com:6222" ] } ``` Cluster TLS always requires peer certificate verification. Explicit `routes` URLs do not automatically obtain credentials from `cluster.authorization`; supply the remote credentials in each configured URL. Cluster authorization supports a username/password, not client-style `users` or `token`. **Conditional: gateways, conventionally 7222.** ```text gateway { name: EAST listen: "10.0.0.10:7222" reject_unknown_cluster: true authorization { user: gateway password: "REPLACE_WITH_GATEWAY_PASSWORD" } tls { cert_file: "/etc/nats/certs/gateway-cert.pem" key_file: "/etc/nats/certs/gateway-key.pem" ca_file: "/etc/nats/certs/peer-ca.pem" } gateways: [ { name: WEST urls: [ "nats://gateway:REPLACE_WITH_URL_ENCODED_REMOTE_GATEWAY_PASSWORD@gateway-west.example.com:7222" ] } ] } ``` Gateway TLS also verifies peer certificates. `reject_unknown_cluster: true` restricts accepted cluster names; it does not replace credentials or TLS. Gateway authorization likewise does not support client-style `users` or `token`. For both links, certificate SANs, advertised addresses, and explicit URLs must agree. Keep outgoing TLS `insecure` disabled. If the CA trusts more machines than should join, assess `verify_cert_and_check_known_urls: true` inside the link's TLS block. This additional restriction requires a reviewed set of known URLs and constrains dynamic growth. **Conditional: accepted leafnodes, conventionally 7422.** A static-authentication hub can bind its leaf identity to an account: ```text leafnodes { listen: "10.0.0.10:7422" authorization { user: leaf-orders password: "REPLACE_WITH_LEAF_PASSWORD" account: ORDERS } tls { cert_file: "/etc/nats/certs/leaf-server-cert.pem" key_file: "/etc/nats/certs/leaf-server-key.pem" ca_file: "/etc/nats/certs/peer-ca.pem" verify: true } } ``` The hub must define `ORDERS`. Use matching username/password credentials in a protected remote URL when connecting to this static hub. For a separate **operator-mode hub** that accepts a generated leaf user credential, the outbound leaf instead uses: ```text leafnodes { remotes: [ { url: "nats-leaf://hub.example.com:7422" account: ORDERS credentials: "/etc/nats/creds/orders-leaf.creds" tls { cert_file: "/etc/nats/certs/leaf-client-cert.pem" key_file: "/etc/nats/certs/leaf-client-key.pem" ca_file: "/etc/nats/certs/peer-ca.pem" } } ] } ``` These static-hub and operator-hub authentication recipes are alternatives. A `.creds` file does not authenticate against the static password entry above. The leaf remotes page says "the URL scheme should be `nats-leaf` or `ws`", so the example uses `nats-leaf`. At v2.15.2 the parser checks only that a remote URL parses (`opts.go` lines 2270 to 2279), and a solicited leaf connection uses TLS when the remote has a `tls {}` block or its TLS flag is set: `tlsRequired := remote.TLS || remote.TLSConfig != nil` (`leafnode.go` line 3668). A `wss://` URL sets that flag: "If the url(s) have the "wss://" scheme, and we don't have a TLS config, mark that we should be using TLS anyway." (lines 490 to 494). A hub that requires TLS sets it too. When the hub's first INFO says TLS is required, `leafnode.go` sets `remote.TLS = true` (lines 1647 to 1655), and again under `if info.TLSRequired && didSolicit {` (lines 1770 to 1771). Before that, a remote that uses a proxy and has no TLS configuration is closed with "TLS configuration mismatch: Hub requires TLS but leafnode remote is not configured for TLS." (lines 1649 to 1654). This guide found no code in the v2.15.2 files it read that turns TLS on for a `tls://` scheme, and infers that with a `tls://` URL, TLS comes only from a `tls {}` block or a hub that requires it. The remote's `account` selects the **local** account on the leaf. The remotes page does not say which hub account the remote's credentials select; this guide infers that the hub's authentication of those credentials determines it. An outbound-only leaf needs no inbound leaf listener: at v2.15.2 the server starts the leaf accept loop only when a leafnode port is set, and solicits `remotes` separately. Exposed versus fixed: reachable plaintext or insufficiently authenticated peer links require permitted networks, the link's credentials, and its TLS policy. A successful test on client port 4222 proves nothing about these links. See the [cluster](https://docs.nats.io/reference/config/cluster/), [gateway](https://docs.nats.io/reference/config/gateway/), [leafnode](https://docs.nats.io/reference/config/leafnodes/), [leaf authorization](https://docs.nats.io/reference/config/leafnodes/authorization/), and [leaf remotes](https://docs.nats.io/reference/config/leafnodes/remotes/) references, the v2.15.2 [remote URL parser](https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L2270-L2279), [remote TLS requirement](https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L3668), [`wss://` TLS flag](https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L490-L494), hub TLS requirement [on first INFO](https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L1647-L1655) and [in INFO processing](https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L1770-L1771), and [leaf listener start](https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L2552-L2562). ## 6. Bound resources and process privilege Tier-1 rationale: stalled connections and compromised authenticated clients should have finite resource budgets. Set resource limits deliberately. At the September 2026 review baseline, the vendor reference documents defaults of **65,536 client connections (`64K`)** and **1 MiB payload (`1MB`)**. These are capacity limits, not authentication. Package configuration and service arguments can override them; inspect the effective deployment. The following are workload-dependent examples, not recommended values for every deployment: ```text max_connections: 1024 max_subscriptions: 256 max_control_line: 4KB max_payload: 1MB max_pending: 8MB write_deadline: "2s" authorization { timeout: "2s" } ``` The selected TLS block also uses `timeout: "2s"`. For multi-tenancy, assess this account-local budget: ```text limits { max_connections: 128 } ``` `max_subscriptions` bounds subscriptions per client connection. `max_payload` must not exceed `max_pending`. A connection count is not a connection-attempt rate limit. Slow-consumer handling may disconnect clients or lose Core NATS delivery; overly short deadlines can harm healthy clients. Run `nats-server` as a dedicated non-root service identity. For same-host clients, use: ```text host: "127.0.0.1" port: 4222 ``` For remote clients, choose a specific private interface instead of the default `0.0.0.0`, and apply network restrictions. Section 13 separates readable secrets from writable broker state. Exposed versus fixed: excessive connections, subscriptions, queued output, and stalled handshakes encounter explicit budgets while healthy clients retain service. See [runtime configuration](https://docs.nats.io/reference/config), [authorization timeout](https://docs.nats.io/reference/config/authorization/timeout), [account limits](https://docs.nats.io/reference/config/accounts/limits/), and [deployment hardening](https://docs.nats.io/learn/deployment/hardening). ## 7. Isolate accounts and reserve the system account for administration Tier-1 rationale: a compromised tenant must not reach another tenant's messages or server administration. Different usernames alone are not tenant isolation. A top-level user list places its users in the shared default account, `$G`. Define distinct accounts, each with its own subject space, and share only reviewed subjects. This boundary fragment is incorporated with actual users in section 14: ```text accounts { ORDERS { exports: [ { stream: "orders.shipped", accounts: [ANALYTICS] } ] } ANALYTICS { imports: [ { stream: { account: ORDERS, subject: "orders.shipped" } } ] } SYS {} } system_account: SYS ``` An export without an `accounts` restriction is public to other accounts that import it. The explicit restriction above permits only `ANALYTICS`. A Core NATS `stream` export shares messages; it is not automatically a JetStream stream export. Keep application identities out of the system account. The default system-account name is `$SYS`; the selected configuration explicitly chooses the separately declared `SYS` account. Its credentials reach server monitoring and management and must be treated as administrative credentials. A `$SYS.>` deny in an application account is useful defence in depth, but does not replace correct account membership and export/import boundaries. Exposed versus fixed: unrelated users in `$G`, or publicly importable exports, become separate subject spaces with one reviewed sharing path and separate administration. See [accounts and multitenancy](https://docs.nats.io/learn/security/accounts-and-multitenancy) and [cross-account configuration](https://docs.nats.io/learn/security/cross-account). ## 8. Bound JetStream storage and separate its administration Tier-1 rationale: application credentials must not consume deployment-wide persistence capacity or implicitly authorize deletion, replay, and reconfiguration. **Conditional: when JetStream is required**, set server storage budgets: ```text jetstream { store_dir: "/var/lib/nats/jetstream" max_memory_store: 256MB max_file_store: 2GB request_queue_limit: 1000 } ``` Inside each account that needs persistence: ```text jetstream { max_memory: 64MB max_file: 512MB max_streams: 10 max_consumers: 20 max_bytes_required: true memory_max_stream_bytes: 32MB disk_max_stream_bytes: 128MB max_ack_pending: 1000 } ``` Every capacity here is workload-dependent. Server storage budgets are not total process-memory or filesystem quotas; reserve operational headroom. `request_queue_limit` bounds pending JetStream API work, not client connection attempts. **`max_consumers` is not a total account-wide consumer count.** In v2.15.2, the selected account limit is enforced against a stream's consumer count, both on a standalone server and in a cluster, where the check counts only that stream's consumers in the selected tier. The standalone check runs only when `!s.JetStreamIsClustered() && s.standAloneMode()` and the server is not recovering (`consumer.go` lines 1152 to 1157), and `standAloneMode()` requires that no cluster or gateway port is set (`server.go` lines 1603 to 1606). From those two files, this guide infers that a server whose JetStream is not clustered but which sets a cluster or gateway port runs neither check; whether that configuration occurs is not established. Creating a Direct or sourcing consumer skips the check, and the clustered check does not count them. Test the deployed stream types and effective limits. A successful reload alone does not prove a reduced account budget took effect: inspect the effective account limits and logs, especially when existing reservations exceed the proposed budget. **From 2.15, streams have a default consumer limit.** The upgrade guide says "a default limit of 1000 consumers per stream is applied". The server JetStream limits reference documents `default_max_consumers`, default `1000`, as the limit for "a stream that does not set its own `max_consumers`, and whose account has no consumer limit either", and says "Set to `-1` to disable this default". At v2.15.2 the server sets `default_max_consumers` to 1000 when it is 0, `if opts.JetStreamLimits.DefaultMaxConsumers == 0 {` (`opts.go` lines 6255 to 6256), and both checks apply it only when it is above 0. The source applies the default only when neither the stream nor the account sets a limit, and its comment says an unlimited (`-1`) stream or account limit "counts as not set", so the default "can't be opted out of per stream or account". Set `default_max_consumers` inside the server's `jetstream { limits { } }` block, as the upgrade guide's example does, to change it server-wide. The selected account sets `max_consumers: 20`, so this guide infers that the server default does not apply to its streams. Count the consumers, including watchers, that each stream needs before upgrading an account that sets no consumer limit. Retain narrow application publish/subscribe lists. Give administrative API subjects only to provisioning identities. A Core-only identity can explicitly deny: ```text publish { allow: ["orders.>"] deny: ["$JS.API.>", "$JS.*.API.>"] } ``` A JetStream application instead needs individually reviewed API grants for named resources, appropriate reply subscriptions, and acknowledgement permissions. A blanket API deny breaks legitimate JetStream clients. Broad `$JS.API.>` access can authorize operations such as stream deletion and stored-message retrieval. **Conditional: independent JetStream systems across leaf and hub deployments.** Add distinct domains to their existing server JetStream blocks, for example `domain: EDGE` and `domain: HUB`, and select the intended system with CLI `--js-domain EDGE` or `--js-domain HUB`. The leaf nodes page says a domain "isolates one JetStream system from another across a leaf link". Neither it nor the domain reference calls a domain an authorization boundary, and this guide infers that account isolation and subject permissions still apply across domains. Exposed versus fixed: insufficiently bounded persistence gains account budgets, required stream sizes, object limits, and separate provisioning credentials. See [server JetStream configuration](https://docs.nats.io/reference/config/jetstream/), [account JetStream limits](https://docs.nats.io/reference/config/accounts/jetstream/), [request queue limits](https://docs.nats.io/reference/config/jetstream/request_queue_limit), [v2.15.2 consumer enforcement](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/consumer.go), its [standalone consumer-limit check](https://github.com/nats-io/nats-server/blob/v2.15.2/server/consumer.go#L1157-L1176), [its conditions](https://github.com/nats-io/nats-server/blob/v2.15.2/server/consumer.go#L1152-L1157) and [standalone mode](https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L1603-L1606), the [clustered check](https://github.com/nats-io/nats-server/blob/v2.15.2/server/jetstream_cluster.go#L12474-L12514), the [default constant](https://github.com/nats-io/nats-server/blob/v2.15.2/server/jetstream_api.go#L426-L427), [its parser](https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L2555-L2556) and [where it applies](https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6255-L6256), [API subjects](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/jetstream_api.go), the [server JetStream limits reference](https://docs.nats.io/reference/config/jetstream/limits/), the [2.15 upgrade guide](https://docs.nats.io/release-notes/upgrade-to-2.15), and [JetStream across leaf nodes](https://docs.nats.io/learn/topologies/leaf-nodes). ## 9. Encrypt sensitive JetStream storage when required Tier-1 rationale: copying persistence files should not directly disclose stored message content. **Conditional: for sensitive persisted messages**, add these fields to the existing server JetStream block: ```text encryption_key: $JS_ENCRYPTION_KEY cipher: aes ``` Provision `JS_ENCRYPTION_KEY` through a protected service secret. The vendor recommends at least 32 bytes of key material. `cipher: aes` selects AES-GCM when that is the deployment policy. This is server-wide file-store data and metadata encryption, not per-account encryption or protection from an authorized running broker. Protect recovery keys separately from data, and independently protect backups. Rotation requires the documented previous-key transition and restart. During that transition the existing JetStream block includes: ```text encryption_key: $JS_ENCRYPTION_KEY prev_encryption_key: $JS_PREVIOUS_ENCRYPTION_KEY cipher: aes ``` Follow the vendor's full transition and recovery procedure before retiring the old key. Do not infer successful rotation or recoverability from startup alone. Exposed versus fixed: unencrypted persistence becomes encrypted file-store data and metadata, with tested recovery and key rotation. See the [encryption-key reference](https://docs.nats.io/reference/config/jetstream/encryption_key), [previous-key reference](https://docs.nats.io/reference/config/jetstream/prev_encryption_key), and [encryption and rotation guidance](https://docs.nats.io/learn/security/encryption). ## 10. Keep MQTT conditional and scoped Tier-1 rationale: an optional protocol listener must not bypass identity and subject boundaries. Leave MQTT unconfigured unless required. When enabled, give it a private listener and its own TLS configuration: ```text mqtt { listen: "10.0.0.10:8883" tls { cert_file: "/etc/nats/certs/mqtt-cert.pem" key_file: "/etc/nats/certs/mqtt-key.pem" ca_file: "/etc/nats/certs/ca.pem" } } ``` Add individually scoped device users inside the intended account: ```text { user: device-01 password: "REPLACE_WITH_DEVICE_01_BCRYPT_HASH" allowed_connection_types: ["MQTT"] permissions { publish: { allow: ["devices.device-01.telemetry"] } subscribe: { allow: ["devices.device-01.command"] } } } ``` Review the translated NATS subjects for the MQTT topics your clients use. Remove unintended MQTT-specific and top-level `no_auth_user` settings. Restrict existing Core users to `allowed_connection_types: ["STANDARD"]` if they must not use additional protocol listeners. The MQTT authentication page says a QoS 1 or 2 subscription "is backed by a JetStream durable", and that a leaf node "can serve MQTT too, and it doesn't have to run JetStream itself": "the leaf can use JetStream reachable through its connection to the hub". This guide infers that MQTT state counts against the persistence budgets of the JetStream that holds it, which on such a leaf can be the hub's; whether MQTT works with no JetStream reachable is not established. MQTT cannot perform the normal NKey nonce-signature exchange. Operator-mode MQTT uses an explicitly permitted bearer user JWT as the password; possession is sufficient, making TLS and secret handling essential. Exposed versus fixed: an additional anonymous or overly broad device listener becomes authenticated, protocol-restricted access to reviewed subjects. See [MQTT configuration](https://docs.nats.io/reference/config/mqtt/), [MQTT authentication](https://docs.nats.io/learn/mqtt/auth-and-clustering), and [connection-type restrictions](https://docs.nats.io/reference/config/authorization/users/allowed_connection_types). ## 11. Keep WebSockets conditional and check browser origins Tier-1 rationale: browser-accessible messaging needs authenticated transport and an explicit browser-origin policy. Leave WebSockets unconfigured unless required. An example listener is: ```text websocket { listen: "10.0.0.10:8443" tls { cert_file: "/etc/nats/certs/websocket-cert.pem" key_file: "/etc/nats/certs/websocket-key.pem" } handshake_timeout: "2s" allowed_origins: ["https://app.example.com"] } ``` Replace the example origin with the deployed application origin. Alternatively, choose `same_origin: true` when the application and WebSocket endpoint have the required same-origin relationship. Scope browser users inside their account: ```text { user: browser-orders password: "REPLACE_WITH_BROWSER_USER_BCRYPT_HASH" allowed_connection_types: ["WEBSOCKET"] permissions { publish: { allow: ["orders.lookup"] } subscribe: { allow: ["_INBOX.browser-orders.>"] } } } ``` The client must use the matching inbox prefix. Provision browser credentials according to the application's identity model; do not distribute one permanent shared credential to every browser. Origin checks are not client authentication. Non-browser clients can omit or construct `Origin`. TLS is required unless explicitly disabled with `no_tls`; if a proxy terminates TLS, its backend path must be inaccessible to unauthorized clients. Review listener-specific authorization and `no_auth_user` overrides. Exposed versus fixed: unintended websites or a directly reachable plaintext backend become an authenticated WSS endpoint with an explicit browser-origin policy and protected backend. See [WebSocket configuration](https://docs.nats.io/reference/config/websocket/) and [connection-type restrictions](https://docs.nats.io/reference/config/authorization/users/allowed_connection_types). ## 12. Isolate auth callout when external authentication is required Tier-1 rationale: an external authenticator must not expose submitted credentials or become an unrestricted impersonation interface. **Conditional: only for deployments that need external identity integration**, configure a dedicated authentication-service account and narrowly listed bypass identities. This static-server fragment replaces the corresponding authentication configuration; it is not an extra checkbox to append to section 14: ```text authorization { timeout: "2s" auth_callout { issuer: "REPLACE_WITH_GENERATED_CALLOUT_ISSUER_PUBLIC_ACCOUNT_NKEY" account: AUTH auth_users: [auth-svc] xkey: "REPLACE_WITH_GENERATED_CALLOUT_PUBLIC_XKEY" } } accounts { AUTH { users: [ { user: auth-svc password: "REPLACE_WITH_AUTH_SERVICE_BCRYPT_HASH" permissions { publish: { deny: [">"] } subscribe: { allow: ["$SYS.REQ.USER.AUTH"] } allow_responses: { max: 1, expires: "2s" } } } ] } ORDERS {} ANALYTICS {} } ``` Keep application users out of `AUTH`. The issuer is a public key; the authentication service holds the corresponding signing material and the private XKey in its protected runtime configuration. XKey encryption protects callout payloads. The service must validate requests and return correctly signed responses assigning intended accounts and permissions. This requires a functioning authentication service. Test service outage as rejected admission. Static-server configuration and operator-mode account-JWT configuration are different workflows. `allowed_accounts` is not a universal target-account restriction: its meaning depends on the mode. Review the appropriate workflow before using it. At v2.15.2 the server skips the callout when the connecting user's name or NKey is listed in `auth_users`, or, for a user JWT, when its account lists the JWT's subject ([`auth.go` lines 740 to 763](https://github.com/nats-io/nats-server/blob/v2.15.2/server/auth.go#L740-L763)). The v2.15.1 and v2.14.8 release notes list "Fixed issues with auth callout `auth_users` bypass"; whether that fix is in this code is not established, since no earlier `auth.go` was read. Exposed versus fixed: ordinary users cannot observe authentication exchanges, excessive identities cannot bypass delegation, and the exchange is isolated and encrypted. See [auth callout](https://docs.nats.io/learn/security/auth-callout), [configuration fields](https://docs.nats.io/reference/config/authorization/auth_callout), [v2.15.2 parsing](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/opts.go), and [response validation](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/auth_callout.go). ## 13. Give each process only its required files Tier-1 rationale: local file access must not turn broker compromise into credential theft across applications or tenants. Use private directories and owner-readable secret files, typically `0700` and `0600`, with ownership appropriate to the process that needs them. These are deployment policies, not NATS directives. | Material | Required access | | --- | --- | | Server configuration, TLS private keys, and link credentials | Readable by the dedicated broker service identity; writable only by the deployment authority where practical | | JetStream storage and operator resolver state | Writable by the broker in designated state directories | | Application `.creds` and user-seed files | Readable by their owning application, without granting the broker access to every application's seed | | Operator/account signing seeds and provisioning store | Restricted to the provisioning system | | Outbound leaf `.creds` | A legitimate broker-readable exception because the broker itself authenticates the outbound link | Check parent-directory permissions, backup copies, secret delivery, and the actual service identity. Systemd sandboxing must leave required state paths writable. Do not assume a packaged service unit has the intended identity and restrictions without inspecting the running deployment. Exposed versus fixed: broadly readable credentials and unnecessary signing keys on the broker become access limited to each process's required material. See [deployment hardening](https://docs.nats.io/learn/deployment/hardening) and [credential and signing-store handling](https://docs.nats.io/learn/security/operator-mode). ## 14. Assemble one selected configuration This selected example uses static password users, separate accounts, client mTLS, loopback client access, loopback monitoring, and JetStream for `ORDERS`. It supplies every identity used by the primary Verify commands. For remote clients, replace the loopback client binding with the intended private interface and enforce the corresponding network policy. All capacities and timeouts are workload-dependent examples. Replace every password placeholder with a separately generated bcrypt hash. Clients supply the corresponding original password. Optional operator mode, encryption at rest, peer links, MQTT, WebSockets, and auth callout are not enabled here. Apply their separate recipes only when required. A Core-only deployment can omit both the server and account JetStream blocks. ```text host: "127.0.0.1" port: 4222 http: "127.0.0.1:8222" max_connections: 1024 max_subscriptions: 256 max_control_line: 4KB max_payload: 1MB max_pending: 8MB write_deadline: "2s" authorization { timeout: "2s" } tls { cert_file: "/etc/nats/certs/server-cert.pem" key_file: "/etc/nats/certs/server-key.pem" ca_file: "/etc/nats/certs/ca.pem" verify: true timeout: "2s" } jetstream { store_dir: "/var/lib/nats/jetstream" max_memory_store: 256MB max_file_store: 2GB request_queue_limit: 1000 } accounts { ORDERS { limits { max_connections: 128 } jetstream { max_memory: 64MB max_file: 512MB max_streams: 10 max_consumers: 20 max_bytes_required: true memory_max_stream_bytes: 32MB disk_max_stream_bytes: 128MB max_ack_pending: 1000 } users: [ { user: order-svc password: "REPLACE_WITH_ORDER_SVC_BCRYPT_HASH" allowed_connection_types: ["STANDARD"] permissions { publish: { allow: ["orders.>"] deny: ["$SYS.>", "$JS.API.>", "$JS.*.API.>"] } subscribe: { allow: ["_INBOX.order-svc.>"] } } } { user: order-consumer password: "REPLACE_WITH_ORDER_CONSUMER_BCRYPT_HASH" allowed_connection_types: ["STANDARD"] permissions { publish: { deny: [">"] } subscribe: { allow: ["orders.>"] } } } { user: order-worker password: "REPLACE_WITH_ORDER_WORKER_BCRYPT_HASH" allowed_connection_types: ["STANDARD"] permissions { publish: { deny: [">"] } subscribe: { allow: ["orders.lookup order-workers"] } allow_responses: { max: 1, expires: "2s" } } } { user: orders-provisioner password: "REPLACE_WITH_ORDERS_PROVISIONER_BCRYPT_HASH" allowed_connection_types: ["STANDARD"] permissions { publish: { allow: ["$JS.API.>"] } subscribe: { allow: ["_INBOX.orders-provisioner.>"] } } } ] exports: [ { stream: "orders.shipped", accounts: [ANALYTICS] } ] } ANALYTICS { limits { max_connections: 128 } users: [ { user: analytics-reader password: "REPLACE_WITH_ANALYTICS_READER_BCRYPT_HASH" allowed_connection_types: ["STANDARD"] permissions { publish: { deny: [">"] } subscribe: { allow: ["orders.>"] } } } ] imports: [ { stream: { account: ORDERS, subject: "orders.shipped" } } ] } SYS { limits { max_connections: 128 } users: [ { user: sys-admin password: "REPLACE_WITH_SYS_ADMIN_BCRYPT_HASH" allowed_connection_types: ["STANDARD"] permissions { publish: { allow: ["$SYS.REQ.>"] } subscribe: { allow: ["_INBOX.sys-admin.>"] } } } ] } } system_account: SYS ``` `orders-provisioner` deliberately holds account-level JetStream administration; it is not an application credential or a system-account identity. `sys-admin` has the system request permissions used below, rather than unrestricted application access. `analytics-reader` can subscribe to its account's `orders.>` space, but the only imported ORDERS subject is `orders.shipped`. Its wildcard does not import other ORDERS subjects. ## Verify REASONED: verification scope from the recorded authoring limitations and cited sources. Service behaviour has **not** been demonstrated here. `nats-server`, `nats`, `nk`, `nsc`, Docker, and Podman are unavailable on the authoring environment's `PATH`; no live broker, certificate fixtures, authentication service, or external peer environment was supplied. The filesystem restrictions prohibit provisioning binaries and writable credential fixtures. The live comparisons below are **REASONED** from the cited documentation and pinned sources. REASONED: comparison procedure from the paired controls below; no live broker or external test fixture is available. Run exposed-state comparisons only in an authorized isolated fixture. Keep the target, identity, payload, and observation window matched while changing the control under test. Record diagnostics and positive controls without secrets. DNS failures, generic timeouts, and local fixture errors are inconclusive. ### V0. Offline checks and their limits DEMONSTRATED: the local shell-syntax and guard checks recorded in this paragraph, re-run at marking time: the five fragments passed bash -n (Bash 5.3.9) and ShellCheck 0.11.0 with no diagnostics. The marking-time guard-only runs rejected 30 invalid cases (unsubstituted placeholders, removed set markers, wrong argument counts, empty values, angle brackets and example.com hosts) with their not-probing messages, and all five substituted controls reached a local stub marker. No network command ran in that marking-time re-run. **Demonstrated here:** the five fenced Bash fragments below passed `bash -n` with Bash 5.3.9 and ShellCheck 0.11.0. Guard-only execution rejected 145 invalid input cases and allowed five substituted controls to reach a local marker. Those cases included empty arguments, original and embedded placeholders, angle brackets, `example.com`, wrong argument counts, and missing setup with unrelated caller arguments. No network command ran. This establishes bounded shell and guard evidence, not CLI semantics or general bypass resistance. The marking-time and original runs recorded in this paragraph covered an earlier form of the V2 authentication block, whose failed positive control exited with status 1; none was re-run on the current form, which exits with status 3 after a failed positive control. REASONED: provenance caveat only, from the revision record of this guide; it states what the earlier runs do not prove, and no new check is claimed. The prior guide's reported 14 invalid guard cases and two positive controls concerned its earlier fragments; they are not evidence that the replacement service checks ran. REASONED: native-parsing procedure from the linked v2.15.2 configuration-test source; the pinned server and certificate, JWT and include fixtures are unavailable. **Offline-capable, not demonstrated here: native parsing.** With the pinned server and readable certificate, JWT, and include fixtures, run `timeout 10s nats-server -t -c selected.conf`. Repeat for every complete conditional configuration actually selected. Pair each valid fixture with a deliberately malformed configuration or unknown-field negative control. Successful parsing does not establish reachability, authorization, or secure placeholder replacement. See [v2.15.2 configuration-test handling](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/opts.go). REASONED: generation procedure from the cited NATS key and JWT documentation; pinned tools and credential fixtures are unavailable. **Offline-capable, not demonstrated here: credential generation.** The NKey commands in section 1 and operator/account/user generation require installed pinned tools and a private writable store. Generation and local inspection need no broker; publication to a resolver and broker acceptance do. REASONED: deployment-file and credential review from the configuration and credential recipes above; Python 3.14.4 text scanning detected placeholders in a copied section 14 example, found none in a substituted control and detected injected REPLACE_WITH_, documentation-address and example-domain controls. The Python text scanning demonstrates only those text checks, not native parsing or credential validity, and no selected deployment files or generated credentials were supplied. **Separate deployment-file check required:** scan the selected configuration, every included file, and referenced secret inputs for unresolved `REPLACE_WITH_` values and documentation addresses. Inspect generated key/JWT types and references with the appropriate tooling. A password such as `REPLACE_WITH_LONG_RANDOM_PASSWORD` can be syntactically valid, so `-t` is not a placeholder validator. An absence of placeholder text does not establish password strength or key ownership. ### V1. Running identity, effective configuration, and listeners (REASONED: inventory and isolation expectations from the linked hardening and configuration references; no running service or deployment namespace) **REASONED: no running NATS service, service manager fixture, or deployment network namespace is available.** On the deployment host, run bounded inventory commands such as `timeout 10s ss -tlnp`, with sufficient privilege to identify owners. Read the whole listener inventory, including IPv6. For containers, inspect both the broker's network namespace and host-side published ports. Inspect the actual process/service identity, loaded configuration path, startup arguments, startup logs, and effective file access. Compare them with sections 6 and 13. Inspect effective limits through the allowed monitoring path and JetStream account information, rather than assuming package defaults. Exposed versus fixed: root execution, broadly readable secrets, unintended writable paths, and public listeners become the dedicated service identity, necessary file access, and intended bindings. Offline file inspection cannot establish what the running process actually experiences. Inventory 4222, 8222, and every enabled peer or protocol listener, including 6222, 7422, 7222, MQTT, and WebSockets. Use the actual configured ports. See [hardening](https://docs.nats.io/learn/deployment/hardening) and [configuration](https://docs.nats.io/reference/config). ### V2. Authentication, TLS, and out-of-scope subjects (REASONED: authentication, TLS and subject-permission comparisons from the cited NATS and pinned CLI sources; no broker, CLI or certificate fixtures) **REASONED: no pinned CLI/server, trusted client/server certificates, or broker logs are available.** The CLI reads saved contexts and environment settings before defaults. Each block clears inherited `NATS_*` settings and uses `--no-context`. This prevents a supposedly anonymous check from silently inheriting a token, user, seed, credentials file, or proxy setting. A fully clean `env -i` launch is another way to isolate ambient settings, but required credentials must still be supplied deliberately. Paste the whole subshell after substituting all four values. These probes accept a DNS hostname or IPv4 address, without a scheme, port, or embedded credentials. TLS paths and the username are exported inside the guarded subshell. The password is read from the terminal and never exported or passed as a command argument: natscli v0.4.0 offers no stdin input for it and binds `--password` to `NATS_PASSWORD`, so each `nats` command that needs it receives it as a one-command `NATS_PASSWORD="$pw"` prefix assignment. That moves the password out of argv, not out of reach: it can remain readable through `/proc//environ` by the same user and by root while that `nats` command, or the `timeout` that runs it, is running. Environment secrets still require a trusted local execution account. Paste the password alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. The first publish is the allowed control. The subsequent operations deliberately test missing credentials, a wrong password, a missing client certificate, and forbidden publish/subscribe subjects. Inspect each command's diagnostics; the block's final exit status is not a combined verdict. ```bash # Authentication, TLS and subject permissions; no pinned server/CLI, broker or certificate fixtures. Expected outcomes and vendor sources are recorded in this section. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NATS_HOST' 'REPLACE_WITH_CLIENT_CERT_FILE' 'REPLACE_WITH_CLIENT_KEY_FILE' 'REPLACE_WITH_CA_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 4 ] || { echo 'provide exactly 4 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the host'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the certificate path'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the key path'; exit 2 ;; esac case "$4" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the CA path'; exit 2 ;; esac case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*|-*) echo 'use a DNS hostname or IPv4 address only'; exit 2 ;; esac { unset -n v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA && unset -v v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA; } 2>/dev/null || { echo 'cannot clear the variables this block uses; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } for v in "${!NATS_@}"; do { unset -n "$v" && unset -v "$v"; } 2>/dev/null || { echo "cannot clear ambient $v; not probing"; exit 2; } done for f in "$2" "$3" "$4"; do [ -r "$f" ] || { echo 'a TLS fixture is unreadable; not probing'; exit 2; } done export NATS_CERT="$2" NATS_KEY="$3" NATS_CA="$4" srv="nats://$1:4222" IFS= read -r -s -t 60 -p 'order-svc password: ' pw < /dev/tty || { echo 'password input failed'; exit 2; } printf '\n' case "$pw" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'supply a real password'; exit 2 ;; esac export NATS_USER=order-svc || { echo 'credential export failed'; exit 2; } [ "$NATS_USER" = order-svc ] || { echo 'username mismatch'; exit 2; } opts=(--no-context --server "$srv" --timeout 3s --inbox-prefix _INBOX.order-svc) NATS_PASSWORD="$pw" timeout 10s nats "${opts[@]}" pub orders.created hi || { echo 'positive control failed; stop'; exit 3; } unset NATS_USER timeout 10s nats "${opts[@]}" pub orders.created hi export NATS_USER=order-svc NATS_PASSWORD="wrong-$pw" timeout 10s nats "${opts[@]}" pub orders.created hi unset NATS_CERT NATS_KEY NATS_PASSWORD="$pw" timeout 10s nats "${opts[@]}" pub orders.created hi export NATS_CERT="$2" NATS_KEY="$3" NATS_PASSWORD="$pw" timeout 10s nats "${opts[@]}" pub billing.charge hi NATS_PASSWORD="$pw" timeout 6s nats "${opts[@]}" sub 'billing.>' --count 1 ) ``` Exposed versus fixed: - With password authentication absent, omitted/wrong credentials can be admitted; the fixed password-user recipe must report an authentication rejection. - Without required client verification, a client lacking a trusted certificate can be admitted; the fixed recipe must reject the missing certificate. Repeat the complete block with an untrusted but readable client certificate/key pair: its initial positive-control command must instead fail with the corresponding TLS diagnostic. - A client that fails to check server identity can accept the wrong endpoint identity. Repeat against an authorized DNS alias that reaches the same test listener but is absent from the server certificate SANs. Require a hostname-validation diagnostic, while the correct hostname succeeds. - Broad permissions permit `billing.charge` publication and `billing.>` subscription; the fixed `order-svc` must produce the corresponding permission violations. If using `verify_and_map` instead, the positive control is a correctly mapped certificate; absent, untrusted, and unmapped certificates are distinct negative cases. Do not describe a password as mandatory for that alternative. See [authentication](https://docs.nats.io/learn/security/authentication-basics), [TLS](https://docs.nats.io/reference/config/tls/), [authorization](https://docs.nats.io/learn/security/authorization), and [pinned CLI credential handling](https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/cli/util.go). ### V3. Actual delivery, queue restrictions, and bounded replies (REASONED: delivery, queue, reply and request-helper procedures from the cited permissions and pinned CLI sources; no broker or client fixtures; Python 3.14.4 json.load accepted the request-helper JSON body and rejected 1 truncated control; JSON syntax alone does not establish delivery, queue restrictions or reply permissions) **REASONED: no broker, pinned CLI, two authenticated clients, or response-test client is available.** A successful publish message or a quiet subscriber is insufficient proof of delivery. In a quiet fixture, start terminal 1, wait for subscription establishment, then run terminal 2 while terminal 1 is still active. The consumer must print the exact marker from terminal 2. Terminal 1 is self-contained and reads its password inside the guarded subshell. Paste the password alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. Piping a password to `nats sub` does not configure authentication; in CLI v0.4.0, a username without a password is treated as a token. ```bash # Subscriber delivery control; no pinned server/CLI, broker or certificate fixtures. Expected outcomes and vendor sources are recorded in this section. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NATS_HOST' 'REPLACE_WITH_CONSUMER_CERT_FILE' 'REPLACE_WITH_CONSUMER_KEY_FILE' 'REPLACE_WITH_CA_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 4 ] || { echo 'provide exactly 4 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the host'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the certificate path'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the key path'; exit 2 ;; esac case "$4" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the CA path'; exit 2 ;; esac case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*|-*) echo 'use a DNS hostname or IPv4 address only'; exit 2 ;; esac { unset -n v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA && unset -v v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA; } 2>/dev/null || { echo 'cannot clear the variables this block uses; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } for v in "${!NATS_@}"; do { unset -n "$v" && unset -v "$v"; } 2>/dev/null || { echo "cannot clear ambient $v; not probing"; exit 2; } done for f in "$2" "$3" "$4"; do [ -r "$f" ] || { echo 'a TLS fixture is unreadable; not probing'; exit 2; } done export NATS_CERT="$2" NATS_KEY="$3" NATS_CA="$4" srv="nats://$1:4222" IFS= read -r -s -t 60 -p 'order-consumer password: ' pw < /dev/tty || { echo 'password input failed'; exit 2; } printf '\n' case "$pw" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'supply a real password'; exit 2 ;; esac export NATS_USER=order-consumer || { echo 'credential export failed'; exit 2; } NATS_PASSWORD="$pw" timeout 45s nats --no-context --server "$srv" --timeout 3s sub 'orders.>' --count 1 ) ``` Terminal 2 independently establishes its target, TLS configuration, and credentials; it cannot inherit variables from terminal 1's subshell. Paste its password alone at the hidden prompt too: a multi-line paste leaves every line after the first for your shell to run. ```bash # Publisher delivery control; no pinned server/CLI, broker or certificate fixtures. Expected outcomes and vendor sources are recorded in this section. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NATS_HOST' 'REPLACE_WITH_CLIENT_CERT_FILE' 'REPLACE_WITH_CLIENT_KEY_FILE' 'REPLACE_WITH_CA_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 4 ] || { echo 'provide exactly 4 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the host'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the certificate path'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the key path'; exit 2 ;; esac case "$4" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the CA path'; exit 2 ;; esac case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*|-*) echo 'use a DNS hostname or IPv4 address only'; exit 2 ;; esac { unset -n v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA && unset -v v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA; } 2>/dev/null || { echo 'cannot clear the variables this block uses; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } for v in "${!NATS_@}"; do { unset -n "$v" && unset -v "$v"; } 2>/dev/null || { echo "cannot clear ambient $v; not probing"; exit 2; } done for f in "$2" "$3" "$4"; do [ -r "$f" ] || { echo 'a TLS fixture is unreadable; not probing'; exit 2; } done export NATS_CERT="$2" NATS_KEY="$3" NATS_CA="$4" srv="nats://$1:4222" IFS= read -r -s -t 60 -p 'order-svc password: ' pw < /dev/tty || { echo 'password input failed'; exit 2; } printf '\n' case "$pw" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'supply a real password'; exit 2 ;; esac export NATS_USER=order-svc || { echo 'credential export failed'; exit 2; } marker=$(date +%s%N) || { echo 'marker generation failed; not publishing'; exit 2; } [ -n "$marker" ] || { echo 'marker generation failed; not publishing'; exit 2; } marker="marker-$marker" printf 'Expected delivery: %s\n' "$marker" NATS_PASSWORD="$pw" timeout 10s nats --no-context --server "$srv" --timeout 3s \ --inbox-prefix _INBOX.order-svc pub orders.created "$marker" ) ``` For queue comparisons, rerun the complete consumer block with the `order-worker` identity, its password and certificate fixtures, and replace the final subscription arguments with `sub orders.lookup --queue order-workers --count 1`. Publish a unique marker to `orders.lookup` using the complete publisher block. Then repeat with `--queue other-workers` and with no queue option. The exposed broad subscription grant accepts these forms; the fixed worker must receive through `order-workers` and reject the other forms. Test both acceptance and actual marker delivery. Do not grant the worker a bare `orders.lookup` permission to make a failing test pass. The following self-contained request block is also used by V4 and V5. Its last three inputs are identity, subject, and JSON body. Keep subjects such as `$SYS.REQ.SERVER.PING` and `$JS.API.INFO` single-quoted on the `set --` line. The inbox prefix matches the selected identity. Paste the password alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash # Request/reply, account and JetStream checks; no pinned server/CLI, broker or certificate fixtures. Expected outcomes and vendor sources are recorded in this section. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NATS_HOST' 'REPLACE_WITH_CLIENT_CERT_FILE' 'REPLACE_WITH_CLIENT_KEY_FILE' 'REPLACE_WITH_CA_FILE' 'order-svc' 'orders.lookup' '{}' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 7 ] || { echo 'provide exactly 7 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the host'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the certificate path'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the key path'; exit 2 ;; esac case "$4" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the CA path'; exit 2 ;; esac case "$5" in order-svc|orders-provisioner|sys-admin) ;; *) echo 'select a listed test identity'; exit 2 ;; esac case "$6" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the request subject'; exit 2 ;; esac case "$7" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the request body'; exit 2 ;; esac case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*|-*) echo 'use a DNS hostname or IPv4 address only'; exit 2 ;; esac { unset -n v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA && unset -v v f pw srv opts marker NATS_USER NATS_PASSWORD NATS_CERT NATS_KEY NATS_CA; } 2>/dev/null || { echo 'cannot clear the variables this block uses; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } for v in "${!NATS_@}"; do { unset -n "$v" && unset -v "$v"; } 2>/dev/null || { echo "cannot clear ambient $v; not probing"; exit 2; } done for f in "$2" "$3" "$4"; do [ -r "$f" ] || { echo 'a TLS fixture is unreadable; not probing'; exit 2; } done export NATS_CERT="$2" NATS_KEY="$3" NATS_CA="$4" srv="nats://$1:4222" IFS= read -r -s -t 60 -p "$5 password: " pw < /dev/tty || { echo 'password input failed'; exit 2; } printf '\n' case "$pw" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'supply a real password'; exit 2 ;; esac export NATS_USER="$5" || { echo 'credential export failed'; exit 2; } NATS_PASSWORD="$pw" timeout 10s nats --no-context --server "$srv" --timeout 3s \ --inbox-prefix "_INBOX.$5" request "$6" "$7" ) ``` **Response-permission comparison, REASONED: a client capable of retaining the worker connection and inspecting permission errors is unavailable.** Use a protocol/client fixture with a 20-second total bound and 3-second operation deadlines: 1. Authenticate as `order-worker`, subscribe to `orders.lookup` in queue `order-workers`, and flush the subscription. 2. Issue the request above. On the same worker connection that receives it, publish `ok` to its received reply subject within two seconds. The requester must receive it. 3. Publish a second reply on that same subject and require a permission violation. 4. Receive a fresh request, wait beyond its two-second grant, and require rejection of the late reply. 5. Attempt an unrelated publication to `billing.charge` on the worker connection and require a permission violation. The wire operations are a queue `SUB orders.lookup order-workers 1`, followed by `PUB` to the reply subject delivered in the received `MSG`; the two-byte reply frame is `PUB 2\r\nok\r\n`. Substitute the actual reply subject inside the protocol client. A new CLI connection cannot reuse the first connection's response grant. In an exposed broad-publish configuration, extra, expired, and unrelated publications can succeed. In the fixed configuration only the first timely tracked reply succeeds. Recall that a dynamic response grant can authorize a subject otherwise denied statically. See [subscription permissions](https://docs.nats.io/reference/config/authorization/users/permissions/subscribe/), [response permissions](https://docs.nats.io/reference/config/authorization/users/permissions/allow_responses/), [pinned CLI subscription flags](https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/cli/sub_command.go), and [server enforcement](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/client.go). ### V4. Accounts, approved sharing, and system requests (REASONED: account isolation, sharing and system-request comparisons from the cited account and CLI sources; no multi-account broker or client fixtures; Python 3.14.4 json.load accepted the system-request JSON body and rejected 1 truncated control; JSON syntax alone does not establish account isolation, sharing or system authorization) **REASONED: no multi-account broker, system credential, or concurrent client fixture is available.** Use the complete consumer block separately as `order-consumer` and `analytics-reader`, with their own credentials and TLS fixtures, but raise the ORDERS consumer's final subscription to `--count 2` so it stays subscribed across both markers below. A natscli v0.4.0 subscriber unsubscribes and exits as soon as it reaches its `--count`, so a `--count 1` ORDERS consumer would exit after the first marker and never observe the second. Keep `analytics-reader` at `--count 1`, since it should receive only the shipped marker. Publish an `orders.created` marker from `order-svc`. Exposed versus fixed: identities placed together in `$G` can receive the same allowed subject. In the selected fixed configuration, the ORDERS consumer receives `orders.created`, while the ANALYTICS subscriber does not. Then publish a fresh `orders.shipped` marker: both must receive it through the approved export/import. The shipped-message positive control must use the same ANALYTICS subscription setup as the isolation check; silence alone is inconclusive. For an administrative comparison, use the request block with `sys-admin`, subject `$SYS.REQ.SERVER.PING`, and body `{}`. Require a system response. Repeat with `order-svc` and its own credentials and inbox prefix: require the corresponding publish-permission rejection. An exposed application identity with administrative account membership and grants can receive the administrative response; the fixed tenant identity cannot. If validating private-export restrictions, add an isolated third test account with a matching import. An unrestricted export permits that sharing; `accounts: [ANALYTICS]` must prevent it. Confirm successful sharing to ANALYTICS in the same run. See [accounts](https://docs.nats.io/learn/security/accounts-and-multitenancy) and [cross-account sharing](https://docs.nats.io/learn/security/cross-account). ### V5. JetStream authority and effective limits (REASONED: JetStream authority and limit comparisons from the cited account, consumer and API sources; no server, CLI or disposable store; Python 3.14.4 json.load accepted all eight literal JSON request bodies in the table and rejected 8 truncated controls; JSON syntax alone does not establish API acceptance, authority or limit enforcement) **REASONED: no JetStream server, pinned CLI, or disposable persistence fixture is available. Conditional on JetStream being enabled.** Use the guarded request block with the following concrete subjects and bodies. Start from an isolated empty test account/store; `SC_TEST` and the other names below are disposable test resources. Record API response bodies, not only CLI exit status. | Identity and request | Exposed versus fixed comparison | | --- | --- | | `orders-provisioner`: `$JS.API.INFO`, `{}` | Obtain effective account limits and usage. Repeat after reload and correlate logs; a successful reload without changed effective limits is insufficient. | | `orders-provisioner`: `$JS.API.STREAM.CREATE.SC_TEST`, `{"name":"SC_TEST","subjects":["orders.test"],"storage":"file","num_replicas":1,"max_bytes":1048576,"discard":"new"}` | A below-limit stream must be created successfully. These are workload-dependent test capacities. | | `order-svc`: the same create request, or `$JS.API.STREAM.INFO.SC_TEST`, `{}` | An exposed broadly authorized application can administer/inspect persistence; the fixed Core-only identity must receive an API-subject permission violation. | | `orders-provisioner`: `$JS.API.STREAM.MSG.GET.SC_TEST`, `{"seq":1}` | After publishing a marker to `orders.test` with the complete publisher block, the provisioner can retrieve it. Repeat as `order-svc`; the fixed Core-only identity must be denied. | | `orders-provisioner`: `$JS.API.STREAM.CREATE.SC_UNBOUNDED`, `{"name":"SC_UNBOUNDED","subjects":["orders.unbounded"],"storage":"file","num_replicas":1}` | An insufficiently bounded account accepts a stream without a byte cap; `max_bytes_required: true` must reject it. | | `orders-provisioner`: `$JS.API.STREAM.CREATE.SC_BIG`, `{"name":"SC_BIG","subjects":["orders.big"],"storage":"file","num_replicas":1,"max_bytes":268435456}` | An exposed account can reserve this larger stream; the selected 128MB file-stream ceiling must reject it. | | `orders-provisioner`: `$JS.API.CONSUMER.DURABLE.CREATE.SC_TEST.C01`, `{"stream_name":"SC_TEST","config":{"durable_name":"C01","ack_policy":"explicit","max_ack_pending":1000}}` | A below-limit consumer must succeed. Repeat with unique matching names through `C20`; the next must be rejected by the selected per-stream account consumer limit. | | On a fresh disposable stream, or after deleting a consumer so the per-stream consumer count is not the cause, create one consumer with `max_ack_pending: 1000`, then repeat the same operation with `max_ack_pending: 1001` | The below-limit consumer must be created successfully; the `1001` request must be rejected specifically by the selected consumer acknowledgement ceiling, not by the per-stream consumer count reached above. Confirm the same `1001` request is accepted once only the acknowledgement ceiling is relaxed. | | `order-svc`, then `orders-provisioner`: `$JS.API.STREAM.DELETE.SC_TEST`, `{}` | The fixed application must be denied; the provisioner must delete the disposable stream successfully. Recreate the fixture if an exposed application successfully deleted it. | Also create small, uniquely named streams with distinct subjects until reaching the configured stream count, then require rejection of the next. Keep their aggregate storage reservations below the account budget so the test measures stream count. Test memory and file account budgets separately. Reserve below-limit streams first, then request one more reservation exceeding `max_memory` or `max_file` while keeping individual stream sizes and stream count valid. Require the corresponding resource error. Exercise server budgets across enough independently budgeted accounts; a single account hitting its own limit does not prove the server-wide limit. For stored-byte enforcement, publish bounded messages into the disposable `SC_TEST` stream until its byte limit is reached. Its `discard: "new"` test policy makes further storage rejection distinguishable from automatic eviction. Observe JetStream publish acknowledgements through a fixture identity with the necessary inbox grant. Keep each test batch bounded, and record successful storage before the rejection. Use a 30-second bound per isolated load-test batch and finite message/object counts. Clean up all test streams and consumers, inspect for leftovers after interruptions, and record cleanup. If domains are configured, repeat against the intended explicit API domain or the corresponding CLI `--js-domain`; verify that switching domains selects the intended system without changing tenant authorization. See [account limits and reload caveats](https://docs.nats.io/reference/config/accounts/jetstream/), [consumer enforcement](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/consumer.go), and [API subjects](https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/jetstream_api.go). ### V6. Encryption and recovery (REASONED: encrypted-store recovery comparisons from the linked encryption and rotation references; no runtime, stores or recovery keys; Python 3.14.4 json.load accepted the retrieval JSON body and rejected 1 truncated control; JSON syntax alone does not establish encryption, key rotation or recovery) **REASONED: no JetStream runtime, disposable encrypted stores, recovery keys, or writable fixtures are available. Conditional on encryption being selected.** Use separate disposable unencrypted and encrypted stores. Create `SC_TEST`, publish a unique marker to `orders.test`, and retrieve it through `$JS.API.STREAM.MSG.GET.SC_TEST` with `{"seq":1}` using the guarded request block and provisioning identity. Stop the fixture before inspecting copied persistence files. Compare the unencrypted and encrypted stores, then restart the encrypted copy with the correct key and retrieve the same marker. Repeat recovery from copies with a missing and a wrong key; require that the original stored data cannot be recovered under those conditions, with matching diagnostics. Do not mistake an empty newly initialized store for recovery. Test the documented `prev_encryption_key` transition, restart, and subsequent recovery with the new key, including the documented removal of the previous key after migration. Bound each startup/recovery attempt to 30 seconds and use copies so failed tests do not destroy the sole recoverable data. Exposed versus fixed: a copied plaintext store discloses data; the encrypted store requires the appropriate key and remains recoverable through the tested procedure. Absence of a plaintext marker alone proves neither complete encryption coverage nor recoverability. See [encryption and rotation](https://docs.nats.io/learn/security/encryption) and [previous-key configuration](https://docs.nats.io/reference/config/jetstream/prev_encryption_key). ### V7. Actual cluster, gateway, and leaf peers (REASONED: peer admission and delivery comparisons from the linked cluster, gateway and leaf references; no peer servers, certificates or test networks) **REASONED: no peer servers, peer certificate fixtures, or allowed/disallowed peer networks are available. Conditional on each enabled link.** Use actual pinned servers and complete peer configurations, with bounded fixture runs such as `timeout 30s nats-server -c peer-test.conf`. Credentials belong in protected configuration files. Change one peer control at a time: | Link | Positive control and exposed versus fixed comparison | | --- | --- | | Cluster | Establish the intended route with matching credentials and trusted certificates; confirm `/routez`, logs, and cross-server marker delivery. Repeat with wrong explicit-route credentials, an untrusted certificate, and a disallowed source network. An insufficiently protected route admits the peer; the fixed route rejects it with the matching diagnostic. | | Gateway | Establish the intended named cluster link; confirm `/gatewayz`, logs, and marker delivery. Repeat with wrong credentials, an untrusted certificate, a disallowed network, and an unknown cluster name. Unknown-name rejection supplements authentication. | | Leaf | Establish the intended local-account to hub-account link; confirm `/leafz`, logs, and marker delivery. Repeat with wrong credentials, an untrusted certificate, and a disallowed network. Verify that an unintended account does not receive the marker. For an outbound-only leaf, inventory the absence of an inbound leaf listener. | If known-URL certificate checking is selected, compare a permitted peer certificate/URL with a certificate trusted by the CA but outside the approved URL set. Record the healthy link before and after negative tests. A client-port test, TCP connection alone, or successful TLS handshake without message routing cannot substitute for these comparisons. See the [cluster](https://docs.nats.io/reference/config/cluster/), [gateway](https://docs.nats.io/reference/config/gateway/), and [leaf](https://docs.nats.io/reference/config/leafnodes/) references. ### V8. Monitoring reachability and proxy bypass (REASONED: collector, observer and proxy-bypass comparisons from the linked monitoring and hardening references; no service, observer or proxy fixtures) **REASONED: no monitoring service, intended collector, external observer, or authenticating proxy fixture is available.** First complete the listener inventory in V1. Run this whole block from the intended collector with its permitted target, then from the unauthorized observer with the inventoried externally reachable address. For the selected loopback binding, the collector runs on the broker host and targets `127.0.0.1`. ```bash # Monitoring reachability; no pinned server/CLI, broker or certificate fixtures. Expected outcomes and vendor sources are recorded in this section. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MONITOR_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'provide exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo 'substitute the monitor host'; exit 2 ;; esac case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-]*|-*) echo 'use a DNS hostname or IPv4 address only'; exit 2 ;; esac for endpoint in varz 'connz?subs=true&auth=true' routez jsz; do curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "http://$1:8222/$endpoint" done ) ``` Exposed versus fixed: an anonymous external observer retrieves JSON in the exposed state; the fixed collector still retrieves the expected JSON, while the external observer cannot directly reach the backend. Any actual HTTP response from the direct backend proves reachability, even if it is an error. For the selected loopback listener, connection refusal, commonly curl exit 7, is the expected external result, but it counts only alongside the matched collector success, correct target, and listener inventory. A stopped service or wrong target also refuses. DNS errors and post-connect timeouts are inconclusive. A firewall-drop design needs matching firewall evidence and the same positive controls; a timeout alone is insufficient. **Conditional proxy comparison, REASONED: no proxy or identity-provider fixture is available.** Send `GET /connz?subs=true&auth=true` to the intended HTTPS proxy with a 5-second connection timeout and 20-second total bound. Require unauthenticated rejection and authenticated JSON success, using the proxy's protected credential mechanism. Then run the direct-backend block from the unauthorized network and require bypass denial. An intentionally reachable authenticating proxy is not an exposed anonymous backend. See [monitoring endpoints](https://docs.nats.io/learn/monitoring/monitoring-endpoints) and [hardening](https://docs.nats.io/learn/deployment/hardening). ### V9. Enabled MQTT, WebSockets, and auth callout (REASONED: optional-protocol comparisons from the linked MQTT, WebSocket and auth-callout references; no clients, listeners or authentication fixture) **REASONED: no optional-protocol clients, listeners, browser fixture, or authentication service are available. Run only the applicable comparisons.** Use clients with protected credential inputs, a 3-second operation deadline, and a 20-second overall bound per case. | Conditional feature | Concrete comparison | | --- | --- | | MQTT | Send an MQTT `CONNECT` using the scoped device credentials, then `PUBLISH` a unique marker to `devices/device-01/telemetry` and subscribe to `devices/device-01/command`. Confirm translated-subject delivery with authorized peers. Repeat without credentials, with wrong credentials, and with another device's subjects. An exposed listener admits anonymous/broad access; the fixed listener rejects it. Attempt the same device identity through the Core listener and require the connection-type restriction. | | Operator-mode MQTT | Use the explicitly permitted bearer user JWT as the MQTT password through protected client configuration. Compare valid and invalid JWTs, account placement, and subject permissions. This tests bearer admission, not nonce-signature proof. | | WebSockets | Perform a WSS upgrade with the approved browser `Origin`, authenticate as the scoped browser user, and request `orders.lookup` using its matching inbox prefix. Repeat with an unapproved origin while keeping credentials valid, and with invalid credentials while keeping the origin approved. An exposed origin policy permits the unwanted browser connection; the fixed policy rejects it. Separately test a non-browser client omitting `Origin`: it must still authenticate. Test direct backend access if TLS terminates at a proxy. | | Auth callout | Connect with valid external credentials, then invalid credentials, and verify the returned account and subject grants through marker delivery and forbidden-subject attempts. Stop the authentication service in the isolated fixture and repeat admission: the fixed deployment must reject the new connection. Attempt an ordinary-user subscription to `$SYS.REQ.USER.AUTH`; it must not expose the exchange. Verify that only the narrowly listed service identity bypasses delegation. | For callout encryption, verify that the service can process exchanges with the matching private XKey, while a wrong key fails to produce accepted authentication responses. Do not log submitted credentials or decrypted payloads. See [MQTT authentication](https://docs.nats.io/learn/mqtt/auth-and-clustering), [WebSocket configuration](https://docs.nats.io/reference/config/websocket/), and [auth callout](https://docs.nats.io/learn/security/auth-callout). ### V10. Connection pressure and slow consumers (REASONED: bounded capacity comparisons from the linked runtime, account and request-queue references; no load client, isolated broker or diagnostics) **REASONED: no load client, isolated broker, or matching runtime diagnostics are available.** Use a fixture client that holds and counts actual authenticated connections, with secrets supplied through protected inputs. Bound each test run to 30 seconds and close every connection on completion. - Hold the selected account's 128 connections, require rejection of the next, release one, and admit a replacement. Compare with an exposed higher/unbounded account budget. - Test the server's 1024 connection limit across enough test accounts whose combined account budgets permit reaching it. The three account budgets in section 14 cannot by themselves exercise that server ceiling. Alternatively, select smaller recorded limits in an isolated fixture. Require the next-connection rejection, release/replacement success, and matching diagnostics. - On one authenticated connection, establish 256 permitted subscriptions with distinct subscription IDs, then attempt the next. Compare subscription-limit rejection with the exposed higher/unbounded state. - Compare a valid below-limit `PUB` with a payload over the selected 1MB limit, and a valid control line with one over 4KB. Require the relevant rejection, keeping a healthy client active. - Open a connection and stall the required TLS handshake; separately complete TLS and stall NATS authentication. Require the configured timeout diagnostics while a normal client connects successfully. - Establish an authorized subscriber, stop reading, and publish a finite workload sufficient to exercise pending-output or write-deadline enforcement. Compare the exposed larger/unbounded backlog with fixed slow-consumer handling, and retain a healthy subscriber that receives its marker. - If JetStream is enabled, exercise API pressure above and below the selected request-queue budget with bounded concurrent requests, matching server diagnostics, and a healthy-client control. These are capacity tests, not proof of a connection-attempt rate limit. A client disconnect alone is not evidence of the intended limit without matching diagnostics and a successful below-limit control. See [runtime limits](https://docs.nats.io/reference/config), [account limits](https://docs.nats.io/reference/config/accounts/limits/), and [JetStream request queues](https://docs.nats.io/reference/config/jetstream/request_queue_limit). ### Verification scope and local work REASONED: scope of the original authoring record and its shell-only evidence. The whole-corpus gate suite was not run for this drop-in generation. Shell checks do not demonstrate NATS configuration parsing or service behaviour. | Check scope | Status | Procedure and prerequisites | | --- | --- | --- | | REASONED: service-comparison scope from the cited NATS sources and V0-V10 prerequisites; no broker fixtures. Service behaviour | REASONED from the cited NATS Server v2.15.2 and natscli v0.4.0 sources and vendor documentation; not demonstrated | On an authorized deployment pinned to NATS Server v2.15.2 and natscli v0.4.0, run V0-V10 and every applicable conditional comparison against isolated exposed and fixed states. Record server/client/tool versions, complete substituted configurations, commands or protocol requests, responses, matching server logs, effective limits, positive controls, and cleanup without secrets. Configuration inspection or successful `-t` alone does not demonstrate service behaviour. | | REASONED: local-work scope from V0 and its cited sources; pinned tools and credential fixtures are unavailable. Native parsing and key generation | Outstanding in this environment; retained local work | Run the offline-capable checks above with the pinned tools and readable certificate, JWT, include, and credential fixtures. | ## Common mistakes - Leaving `no_auth_user` set after testing, which quietly readmits anonymous clients. - Exposing 8222 or `https_port` on a public interface because it "is just monitoring." - A user with no `permissions` block and no applicable `default_permissions`, which is unrestricted within its account rather than denied. - Treating separate usernames in `$G` as tenant isolation, or omitting `accounts` from an export that should be private. - Assuming an empty allow list denies everything, or that a static publish deny always defeats `allow_responses`. - Adding a plain subject grant beside a queue-specific grant, thereby admitting non-queue subscriptions. - Reusing `_INBOX.>` across applications or forgetting the client's matching inbox prefix. - Giving Core-only applications JetStream administrative API access, or treating a JetStream domain as an account boundary. - Treating `max_consumers` as a total account-wide count, or reload success as proof that reduced limits became effective. - Assuming client TLS protects peer listeners or monitoring, or that an outbound-only leaf must expose an inbound listener. - Securing a monitoring proxy while leaving its backend directly reachable. - Enabling MQTT, WebSockets, or auth callout without reviewing their separate identity paths and overrides. - Leaving operator/account signing seeds or unrelated application credentials readable by the broker. - Treating a timeout, absent plaintext marker, successful parse, or quiet subscriber as a demonstrated security control. ## Sources (checked September 2026) - Official `nats` Docker images: the tag-to-directory map for 2.14 and 2.15 (library file pinned commit dcd3db677d8919c3a42d369bd48a6f1c234b3ae8). Official `nats` Docker images at image source pinned commit 2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6: the bundled `nats-server.conf` (the same in every 2.14 and 2.15 directory), the scratch and Alpine configuration copy, `ENTRYPOINT` and `CMD`, the Alpine entrypoint, and the Nano Server and Windows Server Core `ENTRYPOINT` and `CMD` (cited for both 2.15 and 2.14). These lines are unchanged from commit 0e72748d (checked November 2026). Official `nats` Docker images at image source pinned commit 2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6 ("Releases v2.15.2, v2.14.9"): the server release each image carries, 2.15.2 and 2.14.9 (checked November 2026). Each Alpine and Windows Server Core build sets `NATS_SERVER` and downloads that release: `ENV NATS_SERVER 2.15.2` in the 2.15 and `ENV NATS_SERVER 2.14.9` in the 2.14 Dockerfiles. Each scratch build copies the binary from the matching Alpine image, `COPY --from=nats:2.15.2-alpine3.22 /usr/local/bin/nats-server /nats-server` and `COPY --from=nats:2.14.9-alpine3.22 /usr/local/bin/nats-server /nats-server`. Each Nano Server build copies it from the matching Windows Server Core image, `COPY --from=nats:2.15.2-windowsservercore-ltsc2022 C:\\nats-server.exe C:\\nats-server.exe` and `COPY --from=nats:2.14.9-windowsservercore-ltsc2022 C:\\nats-server.exe C:\\nats-server.exe`; each variant's lines are cited for both versions. Item sources: https://github.com/docker-library/official-images/blob/dcd3db677d8919c3a42d369bd48a6f1c234b3ae8/library/nats#L6-L50, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/scratch/nats-server.conf and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/nats-server.conf. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/nanoserver-ltsc2022/nats-server.conf and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/windowsservercore-ltsc2022/nats-server.conf. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/scratch/nats-server.conf, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/nats-server.conf and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/nanoserver-ltsc2022/nats-server.conf. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/windowsservercore-ltsc2022/nats-server.conf, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/scratch/Dockerfile#L5-L9 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/Dockerfile#L38-L43. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/docker-entrypoint.sh#L7-L9 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/docker-entrypoint.sh#L7-L9. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/nanoserver-ltsc2022/Dockerfile#L5-L9 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/windowsservercore-ltsc2022/Dockerfile#L44-L48. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/windowsservercore-ltsc2022/Dockerfile#L44-L48 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/nanoserver-ltsc2022/Dockerfile#L5-L9. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/scratch/Dockerfile#L5-L9, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/Dockerfile#L38-L43 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/Dockerfile#L3. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/Dockerfile#L28 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/windowsservercore-ltsc2022/Dockerfile#L7. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/windowsservercore-ltsc2022/Dockerfile#L18, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/scratch/Dockerfile#L4 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/nanoserver-ltsc2022/Dockerfile#L4. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/Dockerfile#L3, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/Dockerfile#L28 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/windowsservercore-ltsc2022/Dockerfile#L7. More item sources: https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/windowsservercore-ltsc2022/Dockerfile#L18, https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/scratch/Dockerfile#L4 and https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/nanoserver-ltsc2022/Dockerfile#L4 - NATS Server listen defaults (pinned tag v2.15.2, checked November 2026): `DEFAULT_HOST` of `0.0.0.0`, `monitor_port` read as `http_port`, the `-c` and `--config` flags, the client and monitoring host defaults, the cluster host default, and the client, monitoring and route listeners opened with Go's `tcp` listen. The cited lines have the same text at v2.15.0, the earlier pin; v2.14.9, which the 2.14 images carry, was not read. Item sources: https://github.com/nats-io/nats-server/blob/v2.15.2/server/const.go#L85-L86, https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L1296, https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6316-L6317 and https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6063-L6070. More item sources: https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6093-L6096, https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L2901-L2907, https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L3159-L3165 and https://github.com/nats-io/nats-server/blob/v2.15.2/server/route.go#L2751-L2752. More item sources: https://github.com/nats-io/nats-server/blob/v2.15.2/server/util.go#L275-L283 - Go's wildcard `tcp` listen opening one dual-stack socket where IPv4-mapped addresses are supported (pinned tag go1.26.8, the `toolchain` line of go.mod at v2.14.7 and v2.15.0; go.mod at v2.15.2 declares `go 1.26.0` and `toolchain go1.26.10`, and the v2.15.2 release notes give Go 1.27.3; this file was not read at those releases): https://github.com/golang/go/blob/go1.26.8/src/net/ipsock_posix.go#L134-L147 - NATS Server `go.mod` lines 3 to 5, `go 1.26.0` and `toolchain go1.26.10` (pinned tag v2.15.2, checked November 2026): https://github.com/nats-io/nats-server/blob/v2.15.2/go.mod#L3-L5 - NATS Server v2.15.2 release (checked November 2026): https://github.com/nats-io/nats-server/releases/tag/v2.15.2 - Exact server tag commit: https://github.com/nats-io/nats-server/commit/5a815f11a911efa2c88fd1dbfb18ed89a449bfc1 - Server v2.15.2 consumer limits (checked November 2026): the standalone check, the clustered check, the default constant and its parser. Item sources: https://github.com/nats-io/nats-server/blob/v2.15.2/server/consumer.go#L1157-L1176, https://github.com/nats-io/nats-server/blob/v2.15.2/server/jetstream_cluster.go#L12474-L12514, https://github.com/nats-io/nats-server/blob/v2.15.2/server/jetstream_api.go#L426-L427 and https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L2555-L2556 - Server v2.15.2 standalone consumer-limit check conditions and where the default applies (checked November 2026): https://github.com/nats-io/nats-server/blob/v2.15.2/server/consumer.go#L1152-L1157, https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L1603-L1606 and https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6255-L6256 - Server v2.15.2 auth callout `auth_users` bypass check (checked November 2026): https://github.com/nats-io/nats-server/blob/v2.15.2/server/auth.go#L740-L763 - Server v2.15.2 leaf remote URL parsing, remote TLS requirement and leaf listener start (checked November 2026): https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L2270-L2279, https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L3668 and https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L2552-L2562 - Server v2.15.2 leaf remote TLS flag set by a `wss://` URL and by a hub that requires TLS (checked November 2026): https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L490-L494, https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L1647-L1655 and https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L1770-L1771 - 2.15 upgrade guide (rolling documentation, checked November 2026): https://docs.nats.io/release-notes/upgrade-to-2.15 - Server JetStream limits and `default_max_consumers` (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/limits/ - Securing NATS overview (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/ - Authentication basics, password hashes, NKeys, tokens, and anonymous admission (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/authentication-basics - Authorization and subject permissions (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/authorization - Operator mode, resolver setup, credentials, and signing-store handling (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/operator-mode - Decentralized authentication and signing hierarchy (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/decentralized-auth - Accounts and multitenancy (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/accounts-and-multitenancy - Cross-account exports and imports (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/cross-account - Subscription and queue permissions (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/authorization/users/permissions/subscribe/ - Bounded response permissions (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/authorization/users/permissions/allow_responses/ - Connection-type restrictions (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/authorization/users/allowed_connection_types - Authorization timeout (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/authorization/timeout - Encryption, TLS authentication, and JetStream key rotation (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/encryption - TLS reference and listener applicability (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/tls/ - Monitoring endpoints and query parameters (rolling documentation, checked November 2026): https://docs.nats.io/learn/monitoring/monitoring-endpoints - JetStream concepts (rolling documentation, checked November 2026): https://docs.nats.io/concepts/jetstream - Runtime configuration, defaults, bindings, and system account (rolling documentation, checked November 2026): https://docs.nats.io/reference/config - Account connection limits (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/accounts/limits/ - Server JetStream configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/ - Account JetStream limits and reload caveats (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/accounts/jetstream/ - JetStream request queue limit (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/request_queue_limit - JetStream domain (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/domain - JetStream encryption key (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/encryption_key - JetStream cipher selection (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/cipher - JetStream previous encryption key (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/jetstream/prev_encryption_key - Cluster configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/cluster/ - Gateway configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/gateway/ - Leafnode configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/leafnodes/ - Leafnode authorization and account binding (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/leafnodes/authorization/ - Leafnode remotes and local account selection (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/leafnodes/remotes/ - JetStream across leaf nodes (rolling documentation, checked November 2026): https://docs.nats.io/learn/topologies/leaf-nodes - MQTT configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/mqtt/ - MQTT authentication and clustering (rolling documentation, checked November 2026): https://docs.nats.io/learn/mqtt/auth-and-clustering - WebSocket configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/websocket/ - Auth callout workflows (rolling documentation, checked November 2026): https://docs.nats.io/learn/security/auth-callout - Auth callout configuration (rolling documentation, checked November 2026): https://docs.nats.io/reference/config/authorization/auth_callout - Deployment hardening, non-root service identity, and sandboxing (rolling documentation, checked November 2026): https://docs.nats.io/learn/deployment/hardening - Server v2.15.2 configuration parser and configuration-test handling (checked November 2026): https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/opts.go - Server v2.15.2 client and response-permission enforcement (checked November 2026): https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/client.go - Server v2.15.2 consumer-limit enforcement (checked November 2026): https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/consumer.go - Server v2.15.2 JetStream API subjects and domain mappings (checked November 2026): https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/jetstream_api.go - Server v2.15.2 monitoring fields and handlers (checked November 2026): https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/monitor.go - Server v2.15.2 auth-callout response validation (checked November 2026): https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/auth_callout.go - natscli v0.4.0 flags, contexts, inbox prefixes, domains, and environment bindings: https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/nats/main.go - natscli v0.4.0 credential-option handling: https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/cli/util.go - natscli v0.4.0 subscription and queue flags: https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/cli/sub_command.go ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - server: NATS Server (v2.15.2) - image-server: NATS image listener source (v2.15.2) - cli: natscli (v0.4.0) - commit: NATS baseline commit (5a815f11a911efa2c88fd1dbfb18ed89a449bfc1) - library: NATS image catalogue (dcd3db677d8919c3a42d369bd48a6f1c234b3ae8) - image: NATS image source (2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6) - server-docs: NATS Server documentation (rolling) (unknown) - go: Go socket source (go1.26.8) Claims (full source mapping in the JSON block below): - baseline (REASONED; server, commit): v2.15.2 and its recorded commit are the review baseline, not a latest-release claim. - upgrade-notes (REASONED; server-docs): The 2.15 upgrade guide recommends upgrading from at least v2.14.7 and lists a per-stream consumer default and the js_raft_delete_range flag, now on by default. - client-default (REASONED; server-docs): Client port 4222 defaults to no authentication and wildcard host; select loopback or a private interface. - image-config (REASONED; library, image): 2.14/2.15 variants at image commit 2027d0ac load bundled configuration only with the recorded default command, working directory and mounts; whether published tags use them is not established. - image-client (REASONED; image): Bundled images enable unauthenticated client 4222; mount and explicitly select protected configuration. - image-monitor (REASONED; image): Bundled images enable unauthenticated monitoring 8222; remove or bind privately. - image-route (REASONED; image): Bundled route 6222 uses published ruser/T0pS3cr3t and my_cluster; remove or replace with private credentials. - image-bind (REASONED; image-server, go): At v2.15.2, bundled listeners without hosts use 0.0.0.0, dual-stack where IPv4-mapped IPv6 is supported (Go go1.26.8 source); restrict publication. - static-auth (REASONED; server-docs): Separate password/NKey users may coexist; a user's NKey replaces its password pair, without requiring JWT mode. - passwords (REASONED; cli, server-docs): Generate bcrypt interactively; protect seeds and password inputs; hashes do not replace TLS. - anonymous (REASONED; server, server-docs): no_auth_user admits anonymous clients as a named identity; omit unless deliberate. - operator (REASONED; server-docs): Operator signs account JWTs, accounts sign users; preload resolver accounts and protect signing seeds off the broker. - jwt-proof (REASONED; server-docs): Non-bearer users prove seed possession by nonce signature; bearer JWT possession is sufficient. - permissions-default (REASONED; server, server-docs): Absent user/default permissions means unrestricted account access; explicit permissions replace defaults. - allow-lists (REASONED; server, server-docs): Publish/subscribe lists are independent; nonempty allows restrict, empty allows do not; matching static deny wins. - inboxes (REASONED; server-docs): Use identity-specific reply inboxes; the authorization page's example grants _INBOX.>, and this guide infers that grant also matches other clients' replies. - queues (REASONED; server-docs): Require the assigned queue in subscription permissions; a bare subject grant defeats queue-only restriction. - responses (REASONED; server, server-docs): allow_responses can override static publish denial for tracked replies; bound count/expiry, not an absolute subject allow-list. - tls (REASONED; server, server-docs): Client TLS validates endpoint identity; verify requires client certificates but password requirements come from authentication. - tls-map (REASONED; server, server-docs): verify_and_map derives configured identity from certificate attributes and enables verification itself. - monitor-default (REASONED; server, server-docs): Native monitoring is off unless enabled, conventionally 8222; HTTPS adds transport security, not NATS login or client mTLS. - monitor-data (REASONED; server, server-docs): /varz, /connz, /routez and JetStream /jsz disclose metadata; subs/auth query options add details, not authentication. - monitor-boundary (REASONED; server-docs): Bind monitoring privately; the cited pages do not mention a proxy, so proxy bypass prevention is this guide's recommendation. - jetstream-port (REASONED; server-docs): JetStream adds a persistence layer reported at /jsz; whether it opens a listener of its own is not established from the cited pages. - cluster (REASONED; server-docs): Conditional private 6222 routes have separate credentials/TLS; explicit URLs need credentials; cluster TLS verifies peers. - gateway (REASONED; server-docs): Conditional private 7222 gateways verify peers; reject_unknown_cluster supplements credentials, not replaces them. - peer-urls (REASONED; server-docs): Match SANs, advertisements and URLs; keep insecure disabled; known-URL certificate checks constrain dynamic growth. - leaf (REASONED; server-docs): Conditional 7422 leaf auth/TLS is independent; static password and operator credentials recipes are alternatives. - leaf-accounts (REASONED; server-docs, server): Outbound account is local; this guide infers the hub's authentication of remote credentials selects the hub account; v2.15.2 starts no leaf listener without a leafnode port. - leaf-url (REASONED; server, server-docs): Use a nats-leaf or ws remote URL scheme as documented; at v2.15.2 the parser checks only that the URL parses, and a remote uses TLS with a tls block, a wss:// URL or a hub requiring TLS; this guide found no code doing so for tls://. - connection-default (REASONED; server-docs): Documented client-connection default is 65,536; configured limits are capacities, not attempt-rate limits. - payload-default (REASONED; server-docs): Documented payload default is 1 MiB; max_payload must not exceed max_pending. - runtime-budgets (REASONED; server-docs): Set per-server/account connections, per-client subscriptions, control-line/pending limits and TLS/auth/write deadlines for workload. - accounts (REASONED; server-docs): Top-level users share $G; separate account spaces and restrict exports to intended importing accounts. - system-account (REASONED; server, server-docs): Default system name is $SYS; selected SYS contains administrative identities, not application users. - jetstream-server (REASONED; server-docs): Conditional store_dir and memory/file budgets bound storage, not all process memory or filesystem use. - jetstream-queue (REASONED; server-docs): request_queue_limit bounds pending JetStream API work, not connection attempts. - jetstream-account (REASONED; server-docs): Set account memory/file, stream count, required max_bytes, per-stream byte and acknowledgement ceilings; inspect effective reload results. - consumer-limit (REASONED; server, server-docs): v2.15.2 account max_consumers is enforced per stream, standalone and clustered, not as a total account consumer count; the standalone check skips recovery and servers with a cluster or gateway port; Direct and sourcing consumers are exempt. - consumer-default (REASONED; server, server-docs): From 2.15, a stream with no consumer limit of its own or its account's gets default_max_consumers, 1000 unless set; -1 there disables it, but -1 on a stream or account does not. - jetstream-auth (REASONED; server, server-docs): Core-only users deny API subjects; legitimate JetStream apps need reviewed resource, reply and acknowledgement permissions. - domains (REASONED; server-docs): EDGE/HUB domains isolate JetStream systems across leaf links; this guide infers they are not tenant authorization boundaries. - encryption (REASONED; server-docs): Conditional server-wide file-store encryption uses protected key material, recommended at least 32 bytes; aes selects AES-GCM. - rotation (REASONED; server-docs): Previous-key transition needs restart and recovery tests; protect keys and backups separately. - mqtt (REASONED; server-docs): Optional private MQTT TLS stores state in JetStream, on a leaf possibly the hub's, and needs scoped translated subjects, protocol-restricted users and reviewed anonymous overrides. - mqtt-jwt (REASONED; server-docs): Operator-mode MQTT uses explicitly permitted bearer JWT passwords, not normal NKey nonce proof. - websocket (REASONED; server-docs): Optional 8443 WSS needs scoped users/inboxes and origin policy; non-browser Origin is not authentication. - websocket-tls (REASONED; server-docs): WebSocket TLS is required unless no_tls disables it; protect proxy backends and review listener auth overrides. - callout (REASONED; server, server-docs): Conditional auth callout needs dedicated AUTH account, narrow bypass users, signed responses and encrypted XKey exchanges. - callout-mode (REASONED; server, server-docs): Static and operator workflows differ; allowed_accounts is mode-dependent; unavailable authenticator must reject admission. - callout-bypass (REASONED; server): At v2.15.2 a listed auth_users name, NKey or account-listed JWT subject skips the callout; whether the auth_users bypass fix is in that code is not established. - files (REASONED; server-docs): Dedicated non-root broker reads necessary secrets and writes state; application/signing seeds stay with their owners. - verify-offline (REASONED; server, cli): Native parsing/key generation remain unobserved; pair valid parsing fixtures with malformed controls and separately scan placeholders. - verify-identity (REASONED; server-docs): Inspect running identity, effective config/limits, every listener namespace and host publication; file inspection alone is insufficient. - cli-input (REASONED; cli): Clear inherited NATS_* and saved contexts; password uses guarded one-command environment input, still locally readable. - verify-auth (REASONED; server, cli, server-docs): Require allowed publish, missing/wrong-password rejection, certificate/hostname discrimination and forbidden subject errors. - verify-delivery (REASONED; cli, server-docs): Require exact live markers; quiet subscribers and publish success alone prove no delivery. - verify-queue (REASONED; server, cli, server-docs): Assigned queue receives marker; wrong/no queue rejects with matched positive delivery. - verify-response (REASONED; server, cli, server-docs): Same worker connection sends one timely reply; extra, expired and unrelated replies must fail. - verify-accounts (REASONED; cli, server-docs): Created marker stays in ORDERS; shipped marker reaches ANALYTICS; keep count controls live and reject third-account imports. - verify-system (REASONED; cli, server-docs): sys-admin gets system response; tenant gets corresponding publish-permission rejection. - verify-js-auth (REASONED; server, cli, server-docs): Provisioner creates, inspects, retrieves and deletes fixtures; Core-only identity must receive API-subject denials. - verify-js-limits (REASONED; server, server-docs): Test byte reservations, stored bytes, stream/consumer counts and ack ceiling independently with below-limit controls. - verify-recovery (REASONED; server, server-docs): Copied encrypted store must recover with correct key and fail with missing/wrong key; test rotation, not just marker absence. - verify-peers (REASONED; server-docs): Actual links need cross-server delivery plus credential/certificate/network denials; client-port/TCP/TLS checks do not substitute. - verify-monitor (REASONED; server-docs): Collector retrieves expected JSON; unauthorized direct backend cannot answer; pair refusal with identity/inventory and live control. - verify-proxy (REASONED; server-docs): Conditional proxy needs anonymous refusal, authenticated JSON success and direct-backend bypass denial. - verify-optional (REASONED; server-docs): Test MQTT translated subjects/bearer identity, WSS origins versus auth, and callout outage, grants and XKey failures separately. - verify-pressure (REASONED; server-docs): Bounded workloads distinguish connection/subscription/payload/control-line/timeouts/slow-consumer/API limits with healthy controls. - open-questions (REASONED; server-docs, library, go, server): Not established: a JetStream listener of its own, v2.14.9 listener source, the library tag map for image commit 2027d0ac, Go 1.27.3 and 1.26.10 socket source, where the auth_users fix landed, MQTT with no JetStream reachable, and consumer-limit checks on unclustered JetStream with a cluster or gateway port. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "d0b82e96fb0e4d373b237715c2bc615b665605bc46f0dfb3e9a15625a4788bb1", "components": { "server": { "name": "NATS Server", "basis": "v2.15.2", "sources": { "s86e9f60d3aa9": "https://github.com/nats-io/nats-server/releases/tag/v2.15.2", "s7c8496104a83": "https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/opts.go", "sa43897703061": "https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/client.go", "s4bfc2341b0d2": "https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/monitor.go", "sa4f0492f75dd": "https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/consumer.go", "sbdcf97845757": "https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/jetstream_api.go", "sb54914c9712d": "https://raw.githubusercontent.com/nats-io/nats-server/v2.15.2/server/auth_callout.go", "sd1d3820fdf9e": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/consumer.go#L1157-L1176", "s68c7ffb8083f": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/jetstream_cluster.go#L12474-L12514", "s846564cde729": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/jetstream_api.go#L426-L427", "s86a9ce5c7e2c": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L2555-L2556", "s6d5ebd71fef0": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/auth.go#L740-L763", "se56a05213c0f": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L2270-L2279", "s03dd959b42c6": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L3668", "sb431eca8fd9e": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L2552-L2562", "sd70de4e4ef21": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L490-L494", "sbc4a2a7061bf": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L1647-L1655", "s0e681c3d165c": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/leafnode.go#L1770-L1771", "sa38d47f333f4": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/consumer.go#L1152-L1157", "s6e26c3c48215": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L1603-L1606", "s443feefd4dd3": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6255-L6256", "sf0ed50db3d92": "https://github.com/nats-io/nats-server/blob/v2.15.2/go.mod#L3-L5" } }, "image-server": { "name": "NATS image listener source", "basis": "v2.15.2", "sources": { "s407797bc3f61": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/const.go#L85-L86", "s86671012cc3b": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/opts.go#L6316-L6317", "s990eae4b21b3": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L2901-L2907", "sa1595211a165": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/server.go#L3159-L3165", "scaad1e9e2032": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/route.go#L2751-L2752", "s315f5b80f23d": "https://github.com/nats-io/nats-server/blob/v2.15.2/server/util.go#L275-L283" } }, "cli": { "name": "natscli", "basis": "v0.4.0", "sources": { "s068dbbd154ce": "https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/nats/main.go", "sd263b3e2aeb4": "https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/cli/util.go", "sc6b88ab2d1f8": "https://raw.githubusercontent.com/nats-io/natscli/v0.4.0/cli/sub_command.go" } }, "commit": { "name": "NATS baseline commit", "basis": "5a815f11a911efa2c88fd1dbfb18ed89a449bfc1", "sources": { "sc495d1acf88f": "https://github.com/nats-io/nats-server/commit/5a815f11a911efa2c88fd1dbfb18ed89a449bfc1" } }, "library": { "name": "NATS image catalogue", "basis": "dcd3db677d8919c3a42d369bd48a6f1c234b3ae8", "sources": { "s5820f80dc5e2": "https://github.com/docker-library/official-images/blob/dcd3db677d8919c3a42d369bd48a6f1c234b3ae8/library/nats#L6-L50" } }, "image": { "name": "NATS image source", "basis": "2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6", "sources": { "s219e3e7946d2": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/scratch/nats-server.conf", "s7713a2e2a6ac": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/nats-server.conf", "s2427bd65ce05": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/nanoserver-ltsc2022/nats-server.conf", "s547b9abc99d6": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/windowsservercore-ltsc2022/nats-server.conf", "s4049c60c5d9a": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/scratch/nats-server.conf", "s42754ed256e6": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/nats-server.conf", "sc1b71f56dc7f": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/nanoserver-ltsc2022/nats-server.conf", "s5f776fd275e1": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/windowsservercore-ltsc2022/nats-server.conf", "s5cac78302289": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/scratch/Dockerfile#L5-L9", "s0fdb814cfa7c": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/Dockerfile#L38-L43", "sb22edc248e1e": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/alpine3.22/docker-entrypoint.sh#L7-L9", "s452533e98a7a": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/docker-entrypoint.sh#L7-L9", "s1cf8cd1de71f": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/nanoserver-ltsc2022/Dockerfile#L5-L9", "s93fe876c7599": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.15.x/windowsservercore-ltsc2022/Dockerfile#L44-L48", "s31b7293b3c0d": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/windowsservercore-ltsc2022/Dockerfile#L44-L48", "s774d15b44aea": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/nanoserver-ltsc2022/Dockerfile#L5-L9", "sdb6263071b6c": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/scratch/Dockerfile#L5-L9", "s39988894321c": "https://github.com/nats-io/nats-docker/blob/2027d0acef4a2c603c0324dde7d4f3c4a8bf27c6/2.14.x/alpine3.22/Dockerfile#L38-L43" } }, "server-docs": { "name": "NATS Server documentation (rolling)", "basis": "unknown", "sources": { "se640dd73f45d": "https://docs.nats.io/learn/security/authentication-basics", "s7a81108db86e": "https://docs.nats.io/learn/security/authorization", "s468e604fd22d": "https://docs.nats.io/learn/security/operator-mode", "s5f9b8e5012ec": "https://docs.nats.io/learn/security/decentralized-auth", "s8cba9edd33b5": "https://docs.nats.io/learn/security/accounts-and-multitenancy", "sb07c45120b77": "https://docs.nats.io/learn/security/cross-account", "see8fdd7cdcf7": "https://docs.nats.io/reference/config/authorization/users/permissions/subscribe/", "s3eced14a9c7a": "https://docs.nats.io/reference/config/authorization/users/permissions/allow_responses/", "sce86f77690da": "https://docs.nats.io/reference/config/authorization/users/allowed_connection_types", "se5f31c6269c9": "https://docs.nats.io/reference/config/authorization/timeout", "s54ebf5db9564": "https://docs.nats.io/learn/security/encryption", "se9d8f0116aa0": "https://docs.nats.io/reference/config/tls/", "s524e33e8c24d": "https://docs.nats.io/learn/monitoring/monitoring-endpoints", "scd743849d62e": "https://docs.nats.io/concepts/jetstream", "s4532e40c78ec": "https://docs.nats.io/reference/config", "s077fb5dcad29": "https://docs.nats.io/reference/config/accounts/limits/", "s2aa6be0b95f3": "https://docs.nats.io/reference/config/jetstream/", "sac96fc2fd82c": "https://docs.nats.io/reference/config/accounts/jetstream/", "sb0a95cb44e8c": "https://docs.nats.io/reference/config/jetstream/request_queue_limit", "sccd84f1a4406": "https://docs.nats.io/reference/config/jetstream/domain", "s5c67c71c763a": "https://docs.nats.io/reference/config/jetstream/encryption_key", "s21378b23fccd": "https://docs.nats.io/reference/config/jetstream/cipher", "sebfa61302021": "https://docs.nats.io/reference/config/jetstream/prev_encryption_key", "s2e1ad079a3bf": "https://docs.nats.io/reference/config/cluster/", "s9371709dd080": "https://docs.nats.io/reference/config/gateway/", "se29432096847": "https://docs.nats.io/reference/config/leafnodes/", "s812e43517c7c": "https://docs.nats.io/reference/config/leafnodes/authorization/", "s65f0d3357c65": "https://docs.nats.io/reference/config/leafnodes/remotes/", "s739889828b7d": "https://docs.nats.io/learn/topologies/leaf-nodes", "s7255e9ec521c": "https://docs.nats.io/reference/config/mqtt/", "sb21fa571a215": "https://docs.nats.io/learn/mqtt/auth-and-clustering", "s32c27329e016": "https://docs.nats.io/reference/config/websocket/", "s6ce4e35cae81": "https://docs.nats.io/learn/security/auth-callout", "sc7f484b67ca6": "https://docs.nats.io/reference/config/authorization/auth_callout", "sf02d263ec6e8": "https://docs.nats.io/learn/deployment/hardening", "s662c31203437": "https://docs.nats.io/release-notes/upgrade-to-2.15", "s85083ec03e6c": "https://docs.nats.io/reference/config/jetstream/limits/" } }, "go": { "name": "Go socket source", "basis": "go1.26.8", "sources": { "s172a98468ce6": "https://github.com/golang/go/blob/go1.26.8/src/net/ipsock_posix.go#L134-L147" } } }, "claims": { "baseline": {"text": "v2.15.2 and its recorded commit are the review baseline, not a latest-release claim.", "components": ["server", "commit"], "sources": ["server:s86e9f60d3aa9", "commit:sc495d1acf88f"], "status": "REASONED"}, "upgrade-notes": {"text": "The 2.15 upgrade guide recommends upgrading from at least v2.14.7 and lists a per-stream consumer default and the js_raft_delete_range flag, now on by default.", "components": ["server-docs"], "sources": ["server-docs:s662c31203437"], "status": "REASONED"}, "client-default": {"text": "Client port 4222 defaults to no authentication and wildcard host; select loopback or a private interface.", "components": ["server-docs"], "sources": ["server-docs:se640dd73f45d", "server-docs:s4532e40c78ec"], "status": "REASONED"}, "image-config": {"text": "2.14/2.15 variants at image commit 2027d0ac load bundled configuration only with the recorded default command, working directory and mounts; whether published tags use them is not established.", "components": ["library", "image"], "sources": ["library:s5820f80dc5e2", "image:s219e3e7946d2", "image:s7713a2e2a6ac", "image:s2427bd65ce05", "image:s547b9abc99d6", "image:s4049c60c5d9a", "image:s42754ed256e6", "image:sc1b71f56dc7f", "image:s5f776fd275e1", "image:s5cac78302289", "image:s0fdb814cfa7c", "image:sb22edc248e1e", "image:s452533e98a7a", "image:s1cf8cd1de71f", "image:s93fe876c7599", "image:s31b7293b3c0d", "image:s774d15b44aea", "image:sdb6263071b6c", "image:s39988894321c"], "status": "REASONED"}, "image-client": {"text": "Bundled images enable unauthenticated client 4222; mount and explicitly select protected configuration.", "components": ["image"], "sources": ["image:s219e3e7946d2", "image:s4049c60c5d9a"], "status": "REASONED"}, "image-monitor": {"text": "Bundled images enable unauthenticated monitoring 8222; remove or bind privately.", "components": ["image"], "sources": ["image:s219e3e7946d2", "image:s4049c60c5d9a"], "status": "REASONED"}, "image-route": {"text": "Bundled route 6222 uses published ruser/T0pS3cr3t and my_cluster; remove or replace with private credentials.", "components": ["image"], "sources": ["image:s219e3e7946d2", "image:s4049c60c5d9a"], "status": "REASONED"}, "image-bind": {"text": "At v2.15.2, bundled listeners without hosts use 0.0.0.0, dual-stack where IPv4-mapped IPv6 is supported (Go go1.26.8 source); restrict publication.", "components": ["image-server", "go"], "sources": ["image-server:s407797bc3f61", "image-server:s86671012cc3b", "image-server:s990eae4b21b3", "image-server:sa1595211a165", "image-server:scaad1e9e2032", "image-server:s315f5b80f23d", "go:s172a98468ce6"], "status": "REASONED"}, "static-auth": {"text": "Separate password/NKey users may coexist; a user's NKey replaces its password pair, without requiring JWT mode.", "components": ["server-docs"], "sources": ["server-docs:se640dd73f45d"], "status": "REASONED"}, "passwords": {"text": "Generate bcrypt interactively; protect seeds and password inputs; hashes do not replace TLS.", "components": ["cli", "server-docs"], "sources": ["server-docs:se640dd73f45d", "cli:s068dbbd154ce"], "status": "REASONED"}, "anonymous": {"text": "no_auth_user admits anonymous clients as a named identity; omit unless deliberate.", "components": ["server", "server-docs"], "sources": ["server-docs:se640dd73f45d", "server:s7c8496104a83"], "status": "REASONED"}, "operator": {"text": "Operator signs account JWTs, accounts sign users; preload resolver accounts and protect signing seeds off the broker.", "components": ["server-docs"], "sources": ["server-docs:s468e604fd22d", "server-docs:s5f9b8e5012ec"], "status": "REASONED"}, "jwt-proof": {"text": "Non-bearer users prove seed possession by nonce signature; bearer JWT possession is sufficient.", "components": ["server-docs"], "sources": ["server-docs:s5f9b8e5012ec"], "status": "REASONED"}, "permissions-default": {"text": "Absent user/default permissions means unrestricted account access; explicit permissions replace defaults.", "components": ["server", "server-docs"], "sources": ["server-docs:s7a81108db86e", "server:s7c8496104a83"], "status": "REASONED"}, "allow-lists": {"text": "Publish/subscribe lists are independent; nonempty allows restrict, empty allows do not; matching static deny wins.", "components": ["server", "server-docs"], "sources": ["server-docs:s7a81108db86e", "server:sa43897703061"], "status": "REASONED"}, "inboxes": {"text": "Use identity-specific reply inboxes; the authorization page's example grants _INBOX.>, and this guide infers that grant also matches other clients' replies.", "components": ["server-docs"], "sources": ["server-docs:s7a81108db86e"], "status": "REASONED"}, "queues": {"text": "Require the assigned queue in subscription permissions; a bare subject grant defeats queue-only restriction.", "components": ["server-docs"], "sources": ["server-docs:see8fdd7cdcf7"], "status": "REASONED"}, "responses": {"text": "allow_responses can override static publish denial for tracked replies; bound count/expiry, not an absolute subject allow-list.", "components": ["server", "server-docs"], "sources": ["server-docs:s3eced14a9c7a", "server:sa43897703061"], "status": "REASONED"}, "tls": {"text": "Client TLS validates endpoint identity; verify requires client certificates but password requirements come from authentication.", "components": ["server", "server-docs"], "sources": ["server-docs:s54ebf5db9564", "server-docs:se9d8f0116aa0", "server:s7c8496104a83"], "status": "REASONED"}, "tls-map": {"text": "verify_and_map derives configured identity from certificate attributes and enables verification itself.", "components": ["server", "server-docs"], "sources": ["server-docs:se9d8f0116aa0", "server:s7c8496104a83"], "status": "REASONED"}, "monitor-default": {"text": "Native monitoring is off unless enabled, conventionally 8222; HTTPS adds transport security, not NATS login or client mTLS.", "components": ["server", "server-docs"], "sources": ["server-docs:s524e33e8c24d", "server:s4bfc2341b0d2"], "status": "REASONED"}, "monitor-data": {"text": "/varz, /connz, /routez and JetStream /jsz disclose metadata; subs/auth query options add details, not authentication.", "components": ["server", "server-docs"], "sources": ["server-docs:s524e33e8c24d", "server:s4bfc2341b0d2"], "status": "REASONED"}, "monitor-boundary": {"text": "Bind monitoring privately; the cited pages do not mention a proxy, so proxy bypass prevention is this guide's recommendation.", "components": ["server-docs"], "sources": ["server-docs:s524e33e8c24d", "server-docs:sf02d263ec6e8"], "status": "REASONED"}, "jetstream-port": {"text": "JetStream adds a persistence layer reported at /jsz; whether it opens a listener of its own is not established from the cited pages.", "components": ["server-docs"], "sources": ["server-docs:scd743849d62e", "server-docs:s524e33e8c24d"], "status": "REASONED"}, "cluster": {"text": "Conditional private 6222 routes have separate credentials/TLS; explicit URLs need credentials; cluster TLS verifies peers.", "components": ["server-docs"], "sources": ["server-docs:s2e1ad079a3bf"], "status": "REASONED"}, "gateway": {"text": "Conditional private 7222 gateways verify peers; reject_unknown_cluster supplements credentials, not replaces them.", "components": ["server-docs"], "sources": ["server-docs:s9371709dd080"], "status": "REASONED"}, "peer-urls": {"text": "Match SANs, advertisements and URLs; keep insecure disabled; known-URL certificate checks constrain dynamic growth.", "components": ["server-docs"], "sources": ["server-docs:s2e1ad079a3bf", "server-docs:s9371709dd080"], "status": "REASONED"}, "leaf": {"text": "Conditional 7422 leaf auth/TLS is independent; static password and operator credentials recipes are alternatives.", "components": ["server-docs"], "sources": ["server-docs:se29432096847", "server-docs:s812e43517c7c", "server-docs:s65f0d3357c65"], "status": "REASONED"}, "leaf-accounts": {"text": "Outbound account is local; this guide infers the hub's authentication of remote credentials selects the hub account; v2.15.2 starts no leaf listener without a leafnode port.", "components": ["server-docs", "server"], "sources": ["server-docs:s65f0d3357c65", "server:sb431eca8fd9e"], "status": "REASONED"}, "leaf-url": {"text": "Use a nats-leaf or ws remote URL scheme as documented; at v2.15.2 the parser checks only that the URL parses, and a remote uses TLS with a tls block, a wss:// URL or a hub requiring TLS; this guide found no code doing so for tls://.", "components": ["server", "server-docs"], "sources": ["server-docs:s65f0d3357c65", "server:se56a05213c0f", "server:s03dd959b42c6", "server:sd70de4e4ef21", "server:sbc4a2a7061bf", "server:s0e681c3d165c"], "status": "REASONED"}, "connection-default": {"text": "Documented client-connection default is 65,536; configured limits are capacities, not attempt-rate limits.", "components": ["server-docs"], "sources": ["server-docs:s4532e40c78ec"], "status": "REASONED"}, "payload-default": {"text": "Documented payload default is 1 MiB; max_payload must not exceed max_pending.", "components": ["server-docs"], "sources": ["server-docs:s4532e40c78ec"], "status": "REASONED"}, "runtime-budgets": {"text": "Set per-server/account connections, per-client subscriptions, control-line/pending limits and TLS/auth/write deadlines for workload.", "components": ["server-docs"], "sources": ["server-docs:se5f31c6269c9", "server-docs:s4532e40c78ec", "server-docs:s077fb5dcad29"], "status": "REASONED"}, "accounts": {"text": "Top-level users share $G; separate account spaces and restrict exports to intended importing accounts.", "components": ["server-docs"], "sources": ["server-docs:s8cba9edd33b5", "server-docs:sb07c45120b77"], "status": "REASONED"}, "system-account": {"text": "Default system name is $SYS; selected SYS contains administrative identities, not application users.", "components": ["server", "server-docs"], "sources": ["server-docs:s8cba9edd33b5", "server:s7c8496104a83"], "status": "REASONED"}, "jetstream-server": {"text": "Conditional store_dir and memory/file budgets bound storage, not all process memory or filesystem use.", "components": ["server-docs"], "sources": ["server-docs:s2aa6be0b95f3"], "status": "REASONED"}, "jetstream-queue": {"text": "request_queue_limit bounds pending JetStream API work, not connection attempts.", "components": ["server-docs"], "sources": ["server-docs:sb0a95cb44e8c"], "status": "REASONED"}, "jetstream-account": {"text": "Set account memory/file, stream count, required max_bytes, per-stream byte and acknowledgement ceilings; inspect effective reload results.", "components": ["server-docs"], "sources": ["server-docs:sac96fc2fd82c"], "status": "REASONED"}, "consumer-limit": {"text": "v2.15.2 account max_consumers is enforced per stream, standalone and clustered, not as a total account consumer count; the standalone check skips recovery and servers with a cluster or gateway port; Direct and sourcing consumers are exempt.", "components": ["server", "server-docs"], "sources": ["server-docs:sac96fc2fd82c", "server:sa4f0492f75dd", "server:sd1d3820fdf9e", "server:s68c7ffb8083f", "server:sa38d47f333f4", "server:s6e26c3c48215"], "status": "REASONED"}, "consumer-default": {"text": "From 2.15, a stream with no consumer limit of its own or its account's gets default_max_consumers, 1000 unless set; -1 there disables it, but -1 on a stream or account does not.", "components": ["server", "server-docs"], "sources": ["server-docs:s662c31203437", "server-docs:s85083ec03e6c", "server:sd1d3820fdf9e", "server:s68c7ffb8083f", "server:s846564cde729", "server:s86a9ce5c7e2c", "server:s443feefd4dd3"], "status": "REASONED"}, "jetstream-auth": {"text": "Core-only users deny API subjects; legitimate JetStream apps need reviewed resource, reply and acknowledgement permissions.", "components": ["server", "server-docs"], "sources": ["server-docs:s7a81108db86e", "server:sbdcf97845757"], "status": "REASONED"}, "domains": {"text": "EDGE/HUB domains isolate JetStream systems across leaf links; this guide infers they are not tenant authorization boundaries.", "components": ["server-docs"], "sources": ["server-docs:sccd84f1a4406", "server-docs:s739889828b7d"], "status": "REASONED"}, "encryption": {"text": "Conditional server-wide file-store encryption uses protected key material, recommended at least 32 bytes; aes selects AES-GCM.", "components": ["server-docs"], "sources": ["server-docs:s54ebf5db9564", "server-docs:s5c67c71c763a", "server-docs:s21378b23fccd"], "status": "REASONED"}, "rotation": {"text": "Previous-key transition needs restart and recovery tests; protect keys and backups separately.", "components": ["server-docs"], "sources": ["server-docs:s54ebf5db9564", "server-docs:sebfa61302021"], "status": "REASONED"}, "mqtt": {"text": "Optional private MQTT TLS stores state in JetStream, on a leaf possibly the hub's, and needs scoped translated subjects, protocol-restricted users and reviewed anonymous overrides.", "components": ["server-docs"], "sources": ["server-docs:sce86f77690da", "server-docs:s7255e9ec521c", "server-docs:sb21fa571a215"], "status": "REASONED"}, "mqtt-jwt": {"text": "Operator-mode MQTT uses explicitly permitted bearer JWT passwords, not normal NKey nonce proof.", "components": ["server-docs"], "sources": ["server-docs:sb21fa571a215"], "status": "REASONED"}, "websocket": {"text": "Optional 8443 WSS needs scoped users/inboxes and origin policy; non-browser Origin is not authentication.", "components": ["server-docs"], "sources": ["server-docs:sce86f77690da", "server-docs:s32c27329e016"], "status": "REASONED"}, "websocket-tls": {"text": "WebSocket TLS is required unless no_tls disables it; protect proxy backends and review listener auth overrides.", "components": ["server-docs"], "sources": ["server-docs:s32c27329e016"], "status": "REASONED"}, "callout": {"text": "Conditional auth callout needs dedicated AUTH account, narrow bypass users, signed responses and encrypted XKey exchanges.", "components": ["server", "server-docs"], "sources": ["server-docs:s6ce4e35cae81", "server-docs:sc7f484b67ca6", "server:sb54914c9712d"], "status": "REASONED"}, "callout-mode": {"text": "Static and operator workflows differ; allowed_accounts is mode-dependent; unavailable authenticator must reject admission.", "components": ["server", "server-docs"], "sources": ["server-docs:s6ce4e35cae81", "server:s7c8496104a83"], "status": "REASONED"}, "callout-bypass": {"text": "At v2.15.2 a listed auth_users name, NKey or account-listed JWT subject skips the callout; whether the auth_users bypass fix is in that code is not established.", "components": ["server"], "sources": ["server:s6d5ebd71fef0"], "status": "REASONED"}, "files": {"text": "Dedicated non-root broker reads necessary secrets and writes state; application/signing seeds stay with their owners.", "components": ["server-docs"], "sources": ["server-docs:s468e604fd22d", "server-docs:sf02d263ec6e8"], "status": "REASONED"}, "verify-offline": {"text": "Native parsing/key generation remain unobserved; pair valid parsing fixtures with malformed controls and separately scan placeholders.", "components": ["server", "cli"], "sources": ["server:s7c8496104a83", "cli:s068dbbd154ce"], "status": "REASONED"}, "verify-identity": {"text": "Inspect running identity, effective config/limits, every listener namespace and host publication; file inspection alone is insufficient.", "components": ["server-docs"], "sources": ["server-docs:s4532e40c78ec", "server-docs:sf02d263ec6e8"], "status": "REASONED"}, "cli-input": {"text": "Clear inherited NATS_* and saved contexts; password uses guarded one-command environment input, still locally readable.", "components": ["cli"], "sources": ["cli:s068dbbd154ce", "cli:sd263b3e2aeb4"], "status": "REASONED", "verify": [1, 2, 3, 4]}, "verify-auth": {"text": "Require allowed publish, missing/wrong-password rejection, certificate/hostname discrimination and forbidden subject errors.", "components": ["server", "cli", "server-docs"], "sources": ["server-docs:se640dd73f45d", "server-docs:se9d8f0116aa0", "server:sa43897703061", "cli:sd263b3e2aeb4"], "status": "REASONED", "verify": [1]}, "verify-delivery": {"text": "Require exact live markers; quiet subscribers and publish success alone prove no delivery.", "components": ["cli", "server-docs"], "sources": ["server-docs:s7a81108db86e", "cli:sc6b88ab2d1f8"], "status": "REASONED", "verify": [2, 3]}, "verify-queue": {"text": "Assigned queue receives marker; wrong/no queue rejects with matched positive delivery.", "components": ["server", "cli", "server-docs"], "sources": ["server-docs:see8fdd7cdcf7", "server:sa43897703061", "cli:sc6b88ab2d1f8"], "status": "REASONED", "verify": [2, 3]}, "verify-response": {"text": "Same worker connection sends one timely reply; extra, expired and unrelated replies must fail.", "components": ["server", "cli", "server-docs"], "sources": ["server-docs:s3eced14a9c7a", "server:sa43897703061", "cli:sd263b3e2aeb4"], "status": "REASONED", "verify": [4]}, "verify-accounts": {"text": "Created marker stays in ORDERS; shipped marker reaches ANALYTICS; keep count controls live and reject third-account imports.", "components": ["cli", "server-docs"], "sources": ["server-docs:s8cba9edd33b5", "server-docs:sb07c45120b77", "cli:sc6b88ab2d1f8"], "status": "REASONED", "verify": [2, 3]}, "verify-system": {"text": "sys-admin gets system response; tenant gets corresponding publish-permission rejection.", "components": ["cli", "server-docs"], "sources": ["server-docs:s8cba9edd33b5", "cli:sd263b3e2aeb4"], "status": "REASONED", "verify": [4]}, "verify-js-auth": {"text": "Provisioner creates, inspects, retrieves and deletes fixtures; Core-only identity must receive API-subject denials.", "components": ["server", "cli", "server-docs"], "sources": ["server-docs:sac96fc2fd82c", "server:sbdcf97845757", "cli:sd263b3e2aeb4"], "status": "REASONED", "verify": [4]}, "verify-js-limits": {"text": "Test byte reservations, stored bytes, stream/consumer counts and ack ceiling independently with below-limit controls.", "components": ["server", "server-docs"], "sources": ["server-docs:s2aa6be0b95f3", "server-docs:sac96fc2fd82c", "server:sa4f0492f75dd", "server:sbdcf97845757"], "status": "REASONED", "verify": [4]}, "verify-recovery": {"text": "Copied encrypted store must recover with correct key and fail with missing/wrong key; test rotation, not just marker absence.", "components": ["server", "server-docs"], "sources": ["server-docs:s54ebf5db9564", "server-docs:sebfa61302021", "server:sbdcf97845757"], "status": "REASONED", "verify": [4]}, "verify-peers": {"text": "Actual links need cross-server delivery plus credential/certificate/network denials; client-port/TCP/TLS checks do not substitute.", "components": ["server-docs"], "sources": ["server-docs:s2e1ad079a3bf", "server-docs:s9371709dd080", "server-docs:se29432096847"], "status": "REASONED"}, "verify-monitor": {"text": "Collector retrieves expected JSON; unauthorized direct backend cannot answer; pair refusal with identity/inventory and live control.", "components": ["server-docs"], "sources": ["server-docs:s524e33e8c24d", "server-docs:sf02d263ec6e8"], "status": "REASONED", "verify": [5]}, "verify-proxy": {"text": "Conditional proxy needs anonymous refusal, authenticated JSON success and direct-backend bypass denial.", "components": ["server-docs"], "sources": ["server-docs:s524e33e8c24d", "server-docs:sf02d263ec6e8"], "status": "REASONED", "verify": [5]}, "verify-optional": {"text": "Test MQTT translated subjects/bearer identity, WSS origins versus auth, and callout outage, grants and XKey failures separately.", "components": ["server-docs"], "sources": ["server-docs:sce86f77690da", "server-docs:s7255e9ec521c", "server-docs:sb21fa571a215", "server-docs:s32c27329e016", "server-docs:s6ce4e35cae81"], "status": "REASONED"}, "verify-pressure": {"text": "Bounded workloads distinguish connection/subscription/payload/control-line/timeouts/slow-consumer/API limits with healthy controls.", "components": ["server-docs"], "sources": ["server-docs:se5f31c6269c9", "server-docs:s4532e40c78ec", "server-docs:s077fb5dcad29", "server-docs:sb0a95cb44e8c"], "status": "REASONED"}, "open-questions": {"text": "Not established: a JetStream listener of its own, v2.14.9 listener source, the library tag map for image commit 2027d0ac, Go 1.27.3 and 1.26.10 socket source, where the auth_users fix landed, MQTT with no JetStream reachable, and consumer-limit checks on unclustered JetStream with a cluster or gateway port.", "components": ["server-docs", "library", "go", "server"], "sources": ["server-docs:scd743849d62e", "server-docs:sb21fa571a215", "library:s5820f80dc5e2", "go:s172a98468ce6", "server:sf0ed50db3d92", "server:s6d5ebd71fef0", "server:s6e26c3c48215"], "status": "REASONED"} } } ```` ====================================================================== ==> search-engines.md ====================================================================== # Search engines for RAG: Meilisearch and Typesense Meilisearch and Typesense are search engines that index your documents and answer search queries over HTTP. This guide helps your AI assistant configure each securely, including a master or bootstrap key that stays on the server, scoped keys for search clients, private listeners, and TLS from a fronting proxy. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/search-engines.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Meilisearch release reference v1.53.2, Typesense 30.2, Caddy v2.11.4, curl minimum 7.75.0, Git grep manual 2.55.0, Git rev-list manual 2.56.0, and Git manual 2.56.0; 4 further sources carry no recorded version. Metadata reviewed 2026-10-12; documentation checked October 2026. The guide records 30 claims: 10 demonstrated by a check that ran and 20 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Both back RAG pipelines and site search. Meilisearch ships a keyless development mode meant for a laptop and answers unauthenticated until you set a master key; Typesense requires an operator-supplied bootstrap key from the moment it starts, with no keyless mode. Meilisearch's default admin key is full access except key management, and Typesense's bootstrap key is admin over all endpoints and data; ship an unprotected dev-mode instance, or leak either engine's privileged key, and the whole corpus, every document your RAG pipeline embedded, is readable and writable by whoever has it. ## Meilisearch Meilisearch runs in two modes. In development mode it answers without a key by default, but development mode can still be protected by launching with `MEILI_MASTER_KEY` set; production mode requires it, together with `--env production`. Either way the master key (at least 16 bytes) is the credential everything else derives from. For a binary running beside its reverse proxy, set `MEILI_HTTP_ADDR=127.0.0.1:7700`; the binary's default is `localhost:7700` (as of v1.53.2, unless a `config.toml` in its working directory, which it reads without being asked, sets `http_addr`). The official Docker image, however, sets `MEILI_HTTP_ADDR=0.0.0.0:7700`, so with a host-side proxy publish the container port only on host loopback (`127.0.0.1:7700:7700`), and with a containerized proxy use a private container network without publishing the engine port. At the time of writing, Meilisearch's master-key documentation describes four default API keys (a Default Search API Key, a Default Admin API Key with full access except key management, a Default Read-Only Admin API Key, and a Default Chat API Key); the inventory and permissions are version-dependent, so check `GET /keys` on your deployed release. It also supports scoped API keys you create yourself and tenant tokens: server-generated JWTs derived from an API key for per-end-user search restrictions, so set a short `exp` explicitly (no later than the parent key's expiry) rather than assuming they are short-lived. Expose the Default Search API Key only when every searchable index and document it permits is public; for confidential data, issue an index-restricted search key or a server-generated tenant token with enforced search rules, and keep the token's signing key server-side. Never put an admin key, the read-only admin key, or the master key in front-end code. Meilisearch supports native HTTPS with `--ssl-cert-path` and `--ssl-key-path`; this guide uses a same-host TLS reverse proxy ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)) and keeps the plaintext backend connection on loopback or an isolated local container network. If the proxy connects across machines, protect that hop with verified TLS as well, and restrict engine ingress to the proxy ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md)). ## Typesense Typesense requires a bootstrap key at startup, set with the `--api-key` server parameter (a required parameter; the server will not start without it); that key has "admin permissions on all endpoints and data." Use the bootstrap key to create a separately revocable operational key through the `/keys` API, then use that operational key for routine administration; rotate the operational key through the API, while the bootstrap key remains a startup credential supplied through `TYPESENSE_API_KEY` or a protected configuration file rather than command-line arguments. Typesense listens on `0.0.0.0:8108` by default (as of v30.2; `--api-address`/`--api-port`), so for a same-host proxy set `--api-address=127.0.0.1 --api-port=8108`; its separate peering service defaults to port `8107`, so select a private peering address and restrict it to cluster members ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md)). Create a parent search-only key through the same `/keys` endpoint: ```json { "description": "Search products", "actions": ["documents:search"], "collections": ["products"] } ``` For document or field restrictions, keep this parent key server-side and use a Typesense SDK there to derive a Scoped Search API Key containing the required `filter_by`, `include_fields`, or `exclude_fields`; send only the derived key to the browser, with an explicit `expires_at` no later than the parent key's expiry. Narrow `collections` to a name or regex to limit a key to specific collections, embed a `filter_by` clause in a scoped key to restrict it to specific documents (Typesense: "Users will not be able to override the filter embedded inside the scoped API Key"), and use `include_fields`/`exclude_fields` to hide sensitive fields such as billing data from a given key. Typesense's own guidance is direct: "Never expose your Admin API Key or Bootstrap API Key to your frontend application as anyone with access to it will be able to write data into your collection." Set `expires_at` on browser-facing keys so a leaked one has a shelf life. Collection scoping does not by itself isolate data reachable through JOINs: review referenced collections and joined fields, enforce the intended restrictions in the key, and test joined queries with the browser credential. Typesense's cloud offering terminates TLS for you. A self-managed cluster can also terminate TLS natively with the `--ssl-certificate` and `--ssl-certificate-key` server parameters; Typesense's production guidance still calls for restricting the public port and the private peering listener. This guide defaults to the same reverse-proxy or platform TLS pattern as Meilisearch above because a proxy already handles certificate renewal, though native termination is a documented, supported alternative. ## The pattern, either engine The credential that goes into a browser must be search-only, and for confidential or multi-tenant data must enforce the caller's index or collection and document restrictions in the credential itself (a tenant token in Meilisearch, a scoped key with `filter_by` in Typesense). Browser-supplied filters are not authorization, and the parent key used to generate tenant tokens or scoped search keys must never be exposed. The admin or bootstrap key stays server-side, in the platform's secret store, never in client bundles or repository history ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Treat administration and backup as separate surfaces. Keep any dashboard or management UI private or behind an identity-aware proxy that enforces MFA, since an engine API key is a bearer credential and not a second factor; Meilisearch's development search preview is disabled in production mode. Protect dumps, snapshots, data volumes, and off-host backups independently of search authorization: restrict filesystem and object-store access, prohibit public downloads, and encrypt them, because a Meilisearch dump or a Typesense snapshot contains documents across every index. For Typesense, back up the directory its snapshot API produces rather than copying the live data directory, and restore into an isolated instance with authentication configured before exposing a listener. Review outbound requests too. Meilisearch v1.8 through v1.34.0 need upgrading for an authenticated blind SSRF fixed in v1.34.1; on current releases do not set `MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS=any`, restrict webhook and remote-service configuration to trusted administrators, protect webhook credentials, and constrain egress to approved destinations ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). Typesense's remote embedding configurations also make outbound requests carrying provider credentials, so apply the same destination and secret controls. Leave Meilisearch's experimental document-editing functions disabled unless needed; they run Rhai transformations that belong to trusted server-side workloads, not arbitrary operating-system command execution. ## Verify REASONED: from the shell behaviour of the hidden prompt (`read -s`) and of `set +x`, not from the loopback runs described next, which did not test it. What was run: on 2026-10-13 UTC a QA review ran the paired-request block under `bash -x` with its external commands stubbed and a dummy secret fed to the prompts on a pseudo-terminal; no trace line carried the dummy secret, and the same block with its `set +x` removed traced it. A second run the same day pasted the block into Bash 5.3.9 (`bash --noprofile --norc -i`) on a pseudo-terminal with history enabled, with stub commands that read stdin and slept four seconds, a dummy secret entered at the prompts, and the observing and inspected processes under the same account. The second run's history file held the pasted block, with its literal `$audit_key` references, but no prompt response, and in that run neither `/proc//cmdline` nor `/proc//environ` of the stubs, the block's subshell or its command-substitution shells contained the dummy secret. In that run the observer was an ancestor of the inspected processes. A third run the same day, on the QA review's evaluation host, repeated the observation with same-account observers that were siblings of the block's shells rather than ancestors: neither `/proc//cmdline` nor `/proc//environ` of any sampled process, including the recording stubs, the command-substitution shells, the block's subshell and the outer shell, contained the dummy secret. What was not run: the contents of process memory are unknown, because every attempt by those observers to open `/proc//mem` was refused with EACCES (Permission denied) on the evaluation host, where `/proc/sys/kernel/yama/ptrace_scope` read `1`. Root was also not tested as the observer, because the evaluation host refused privilege elevation (`sudo -n id` failed because the no-new-privileges flag was set). No real curl request to a live Meilisearch or Typesense service ran either, because `meilisearch` and `typesense-server` were not on the evaluation host's PATH (every run used a recording curl stub). In the paired-request block below, the hidden prompts keep the keys out of shell history and the block's `set +x` keeps them out of `set -x` tracing, but stdin closes the argv channel only, so the keys stay visible to the account owner and root. Paste each key alone at its hidden prompt: a multi-line paste feeds every line after the first to the next prompt or to your shell, which runs it. The QA review runs recorded here covered an earlier form of the paired-request block, whose transport failure exited with status 2 and whose unexpected positive-request status exited with status 1; none was re-run on the current form, which exits with status 3 for both. DEMONSTRATED: following block; the checks below were demonstrated on loopback against the Meilisearch v1.53.2 release binary (its SHA-256 matched the digest GitHub publishes for the asset) and the Typesense 30.2 server (its MD5 matched the one in the release tarball). Each engine ran on loopback with native TLS on 127.0.0.1, or behind a Caddy TLS proxy for the proxy checks, with certificates from a private test CA that curl trusted through `CURL_CA_BUNDLE`, and with every address set before start. The blocks ran as printed with only their `set --` values substituted, and keys were fed to the prompts on stdin. Those loopback runs covered an earlier form of both blocks: the paired-request block's transport failure exited with status 2 and its unexpected positive-request status with status 1, and the TCP block ended with status 2 when no port connected. Neither block was re-run in its current form, which exits with status 3 in each of those cases. What those runs do not show is marked **REASONED** where it occurs, with its reason. Backlog row 1.117 retains the real-client-build positive and negative controls for the credential scan below; its hand-built fixtures do not supply a real client bundle. Use Bash and curl 7.75.0 or later; never add `-k`. Substitute your actual HTTPS origin inside the single quotes, without a trailing slash. Run each row of the matrix below through this paired-request block, changing the method, path, JSON body, header prefix, and expected statuses on its `set --` line, and enter credentials at the prompts rather than in the command. Both requests use exactly the same origin, method, path, and body; inspect the engine JSON as well as the status, since a proxy login page, redirect, missing resource, or transport failure does not establish engine authorization. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -o pipefail { unset -n audit_role audit_key audit_expected audit_reply && unset -v audit_role audit_key audit_expected audit_reply; } 2>/dev/null || { echo 'A readonly audit_* variable is set in this shell'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'A readonly IFS is set in this shell'; exit 2; } set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_ORIGIN' \ 'POST' '/indexes/movies/search' '{"q":"ninja"}' \ 'Authorization: Bearer ' '401' '200' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'Paste the whole block'; exit 2; } shift [ "$#" -eq 7 ] || { echo 'Expected seven values'; exit 2; } case "$*" in *REPLACE_WITH_*) echo 'Substitute inside the single quotes'; exit 2 ;; esac case "$1" in https://?*) ;; *) echo 'Use your HTTPS origin, without a trailing slash'; exit 2 ;; esac case "$5" in 'Authorization: Bearer '|'X-TYPESENSE-API-KEY: ') ;; *) echo 'Unknown authentication header'; exit 2 ;; esac for audit_role in negative positive; do printf '%s key (empty only for an anonymous negative): ' "$audit_role" IFS= read -r -s audit_key || exit 2 printf '\n' case "$audit_key" in *REPLACE_WITH_*|*$'\r'*|*$'\n'*) echo 'Invalid key input'; exit 2 ;; esac if [ "$audit_role" = positive ]; then [ -n "$audit_key" ] || { echo 'Positive key required'; exit 2; } audit_expected=$7 else audit_expected=$6 fi if audit_reply=$( { if [ -n "$audit_key" ]; then printf '%s%s\n' "$5" "$audit_key" fi } | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --request "$2" --header @- --header 'Content-Type: application/json' \ --data-raw "$4" --write-out \ '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{http_code}' \ "$1$3" ); then printf '%s\n' "$audit_reply" else printf '%s\n' "${audit_reply-}" 'INCONCLUSIVE: transport failed' exit 3 fi case "$audit_reply" in *$'\n'"$audit_expected") ;; *) echo 'FAIL: unexpected HTTP status' if [ "$audit_role" = positive ]; then exit 3; fi exit 1 ;; esac done echo 'Statuses matched; inspect the response bodies before accepting the result.' ) ``` | Test | Method and path | JSON body | Negative credential/status | Positive credential/status | Marking | |---|---|---|---|---|---| | Meilisearch authentication | `POST /indexes/movies/search` | `{"q":"ninja"}` | Empty / `401` | Valid search key / `200`, containing the known fixture | REASONED: this row; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Anonymous keyless search is expected to return the fixture with 200; a protected search rejects anonymous access with 401 and permits the search key with 200, per the guide's cited Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys; rolling documentation, checked September 2026) entries. During marking review, the paired-request block passed bash -n and ShellCheck 0.11.0 without output. The marking review under bash -u with a recording curl stub gave these results: this row's status pair printed 'Statuses matched; inspect the response bodies before accepting the result.' (exit 0), a synthetic 999 printed 'FAIL: unexpected HTTP status' (exit 1), and a simulated transport failure printed 'INCONCLUSIVE: transport failed' (exit 2). Python json.loads accepted this row's JSON body. Stub responses establish only local control flow. These stub runs covered an earlier form of the paired-request block, whose transport failure exited with status 2 and whose unexpected positive-request status exited with status 1; the current form, which exits with status 3 for both, was not re-run. | | Typesense authentication | `GET /collections/products/documents/search?q=stark&query_by=company_name` | empty string | Empty / `401` | Valid search key / `200`, containing the known fixture | REASONED: this row; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. A disclosed operational key is expected to return the fixture with 200 before rotation; after rotation it is refused with 401 while its replacement returns 200, and anonymous access is refused with 401. The expectations for the disclosed key before and after rotation, the replacement key and anonymous access are per the guide's cited Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at; rolling documentation, checked September 2026), Typesense 30.2 authorization failure (401) entries. During marking review, the paired-request block passed bash -n and ShellCheck 0.11.0 without output. The marking review under bash -u with a recording curl stub gave these results: this row's status pair printed 'Statuses matched; inspect the response bodies before accepting the result.' (exit 0), a synthetic 999 printed 'FAIL: unexpected HTTP status' (exit 1), and a simulated transport failure printed 'INCONCLUSIVE: transport failed' (exit 2). This row has an empty request body. Stub responses establish only local control flow. These stub runs covered an earlier form of the paired-request block, whose transport failure exited with status 2 and whose unexpected positive-request status exited with status 1; the current form, which exits with status 3 for both, was not re-run. | | Meilisearch write restriction | `POST /indexes` | `{"uid":"acl_probe"}` | Search-only key / `403` | Authorized admin key / `202` | REASONED: this row; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. An overprivileged browser key is expected to create an index with 202; the fixed search-only key is refused with 403 while an authorized admin key receives 202, which requires task-success confirmation, per the guide's cited Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys; rolling documentation, checked September 2026) entries. During marking review, the paired-request block passed bash -n and ShellCheck 0.11.0 without output. The marking review under bash -u with a recording curl stub gave these results: this row's status pair printed 'Statuses matched; inspect the response bodies before accepting the result.' (exit 0), a synthetic 999 printed 'FAIL: unexpected HTTP status' (exit 1), and a simulated transport failure printed 'INCONCLUSIVE: transport failed' (exit 2). Python json.loads accepted this row's JSON body. Stub responses establish only local control flow. These stub runs covered an earlier form of the paired-request block, whose transport failure exited with status 2 and whose unexpected positive-request status exited with status 1; the current form, which exits with status 3 for both, was not re-run. | | Typesense write restriction | `POST /collections` | `{"name":"acl_probe","fields":[{"name":"title","type":"string"}]}` | Search-only key / `401` | Authorized admin key / `201` | REASONED: this row; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. An overprivileged browser key is expected to create a collection with 201; the fixed search-only key is refused with 401 while the authorized admin key receives 201. The expectations for the overprivileged browser key, the fixed search-only key and the authorized admin key are per the guide's cited entries (1 of 2): Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at; rolling documentation, checked September 2026), Typesense 30.2 authorization failure (401). The guide's cited entries for those expectations (2 of 2): Typesense 30.2 collection creation success (201), Typesense 30.2 collections (collection creation and schema fields). During marking review, the paired-request block passed bash -n and ShellCheck 0.11.0 without output. The marking review under bash -u with a recording curl stub gave these results: this row's status pair printed 'Statuses matched; inspect the response bodies before accepting the result.' (exit 0), a synthetic 999 printed 'FAIL: unexpected HTTP status' (exit 1), and a simulated transport failure printed 'INCONCLUSIVE: transport failed' (exit 2). Python json.loads accepted this row's JSON body. Stub responses establish only local control flow. These stub runs covered an earlier form of the paired-request block, whose transport failure exited with status 2 and whose unexpected positive-request status exited with status 1; the current form, which exits with status 3 for both, was not re-run. | REASONED: fixture preparation, task completion, rotation and tenant/JOIN comparisons; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Keyless Meilisearch is expected to expose the fixture, while master-key protection refuses anonymous search; a disclosed Typesense key succeeds before rotation and fails afterwards while its replacement succeeds. An unrestricted parent is expected to return the forbidden fixture and joined field, while the restricted credential returns the allowed fixture and omits the forbidden document and joined field even with filter and field-selection overrides. The keyless, master-key, rotation, tenant and JOIN expectations in the two preceding sentences, for both engines, rest on three of the guide's cited entries taken together, and no single entry supports every expectation. The first two of those entries are the guide's cited Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys; rolling documentation, checked September 2026) and Meilisearch tenant-token payload (exp is optional; rolling documentation, checked September 2026) entries. The third entry is the guide's cited Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at; rolling documentation, checked September 2026) entry. The guide's own record of the loopback JOIN comparison, in the next paragraph, shows that a collection-scoped key returned the joined field under an `include_fields` override and that an embedded `exclude_fields` restriction removed it; that record is cited here for those two results only, not for any other JOIN expectation. During marking review, bash -n and ShellCheck 0.11.0 accepted the paired-request block without output and Python json.loads accepted all three matrix JSON bodies. Under bash -u with a curl stub, placeholder, HTTP or empty origin, unknown header, wrong marker, wrong value count and omitted assignment printed their guard refusals and exited 2 without calling curl; placeholder or carriage-return keys printed 'Invalid key input' (exit 2), and an empty positive key printed 'Positive key required' (exit 2) after the negative request. Each matrix row's synthetic status pair exited 0 with 'Statuses matched; inspect the response bodies before accepting the result.'; synthetic status 999 and transport failure printed 'FAIL: unexpected HTTP status' (exit 1) and 'INCONCLUSIVE: transport failed' (exit 2). These runs do not observe task success, rotation or tenant/JOIN enforcement. These runs covered an earlier form of the paired-request block, whose transport failure exited with status 2 and whose unexpected positive-request status exited with status 1; the current form, which exits with status 3 for both, was not re-run. Use `Authorization: Bearer ` for Meilisearch and `X-TYPESENSE-API-KEY: ` for Typesense. Prepare the search fixtures first, and for the creation tests confirm `acl_probe` does not already exist, using a disposable resource and removing it afterward. A Meilisearch `202` only acknowledges a queued task, so confirm task success before crediting the positive control. In an isolated exposed-state test, keyless Meilisearch must return the fixture anonymously and the protected instance must reject that request; Typesense's exposed-state discriminator is a known disclosed bootstrap or operational key (it has no keyless mode), which after rotation must fail while its replacement succeeds against the same request. Do not invent a universal default password. Test tenant restrictions with known allowed and forbidden documents: the restricted credential must find the allowed fixture and omit the forbidden one, while an authorized control finds the forbidden fixture on the same query and origin; attempt to override filters and field selection, and test referenced collections where JOINs are used, since an empty result alone proves nothing. The paired-request block turns off `allexport` and removes its own variables, namerefs included, before it runs, because an earlier version printed `Statuses matched` and exited `0` without sending any request when the calling shell held a readonly `audit_role`. On loopback, with a curl stand-in that reported its environment, the block exited `2` with `A readonly audit_* variable is set in this shell` for a readonly `audit_role` or `audit_expected`; kept both key roles for a nameref named `audit_role`; and kept the prompted key out of curl's environment for an inherited `set -a`, an exported `audit_key`, a nameref `audit_key` and `declare -i audit_key`. It still assumes an ordinary interactive shell: no alias on any word the block uses, reserved words such as `if` included (an alias on `if` brought back the false pass with no request sent, while the stock `ls`, `grep` and `ll` aliases left the result unchanged; `alias` lists what is defined). The ordinary interactive shell that the paired-request block assumes has no functions shadowing any builtin or command the block uses (including `unset`, `read`, `printf` and `curl`), curl on `PATH` (with curl missing, a `command_not_found_handle` function that printed a newline and the expected status brought back the false pass), no builtins disabled with `enable -n`, and no `hash -p` entries. The same ordinary interactive shell has no hostile inherited DEBUG trap (the block clears DEBUG, RETURN and ERR traps with `trap - DEBUG RETURN ERR`, but an inherited DEBUG trap under `set -T` still runs once before that line, and under `shopt -s extdebug` can skip it), and the paired-request block assumes a paste at the prompt rather than inside a function. On the loopback runs, keyless Meilisearch returned the fixture to an anonymous search with `200`, so the authentication row printed `FAIL: unexpected HTTP status`: the exposed state. With a master key set, the same row got `401` `missing_authorization_header` anonymously and `200` with the fixture on the Default Search API Key, and `GET /keys` listed the four default keys named above; the write row got `403` `invalid_api_key` for the search key and `202` for the Default Admin API Key, whose task then reported `succeeded`. Started with `--env production` and no master key, Meilisearch refused to start: "You must provide a master key to secure your instance in a production environment". Typesense, with its bootstrap key from `TYPESENSE_API_KEY`, answered the authentication row with `401` anonymously and `200` on a collection-scoped search key, and the write row with `401` for that key and `201` for the bootstrap key. A disclosed operational key got `200` before rotation, `401` after it was deleted, and its replacement `200`. A Meilisearch tenant token filtered to one tenant returned only that tenant's fixture, and a request adding a filter for the other tenant returned nothing, while the parent key returned both; a Typesense scoped key embedding `filter_by` behaved the same way under an overriding `filter_by`. For JOINs, a Typesense key scoped to collection `products` was refused a direct search of `companies` with `401`, but a `products` search with `include_fields=$companies(billing)` returned the joined company's billing field: collection scoping alone did not isolate joined data. A scoped key embedding `exclude_fields=$companies(billing)` returned the same query without it. REASONED: listener inventory, container publications, IPv4/IPv6 rules and external TCP reachability; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No deployed container mappings, effective firewall rules or external disallowed vantage were available to this marking review. An exposed public API or peering listener is expected to accept an external TCP connection, while a fixed private backend gives no external answer and still answers its intended private client; refusal or timeout alone remains inconclusive. The listener expectations and the inconclusive reading of refusal or timeout follow the guide's cited Meilisearch v1.53.2 Dockerfile (image sets MEILI_HTTP_ADDR=0.0.0.0:7700), Typesense 30.2 server configuration (api-key required, api-address default 0.0.0.0, api-port 8108, peering-port 8107, ssl-certificate), OpenBSD netcat reference entries. During marking review, bash -n and ShellCheck 0.11.0 accepted the TCP block without output. Under bash -u with an nc stub, placeholder and empty addresses printed 'Substitute your address inside the quotes', a wrong marker printed 'Paste the whole block', and a missing address printed 'Expected one address'; each exited 2 without calling nc. Stub success printed 'FAIL: TCP 7700 is reachable' (exit 1); stub failure printed 'INCONCLUSIVE: inspect the connection error and firewall evidence' for all three ports (exit 2). These TCP block runs covered an earlier form, which ended with status 2 when no port connected; the current form, which ends with status 3 there, was not re-run. A separate ss stub printed 'ss <-ltnp>'; this checks argument delivery, not listener state. For the retained same-host TLS comparison, exposed keyless Meilisearch or a disclosed Typesense key is expected to permit search through the proxy; fixed routes refuse anonymous or revoked credentials and permit an authorized key. The expected same-host TLS outcomes in the preceding sentence, for the proxy and both engines, rest on three of the guide's cited entries taken together, and no single entry supports every outcome. The first of those entries is the guide's cited Caddy bind, certificate files, automatic HTTPS and admin options, and reverse-proxy configuration (pinned tag v2.11.4) entry. The second and third entries are the guide's cited Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys; rolling documentation, checked September 2026) and Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at; rolling documentation, checked September 2026) entries. The following historical authoring limitations remain unchanged. **The same-host TLS proxy demonstrated; container mappings, IPv6, firewalls and a wildcard bind REASONED (one host with no container runtime, no second network, and no root or `sudo` to read the rulesets, and the host forbids binding every interface):** proxy authentication does not prove the engine or peering ports are private. On the engine host inspect `ss -ltnp`, container port mappings, and the effective IPv4 and IPv6 firewall rules, and confirm the engine answers from its intended private client while running. From an external disallowed source, test every public address and actual published port; the block below covers the default ports with OpenBSD-compatible netcat and numeric addresses: REASONED: following block; external isolation follows the cited server configuration and OpenBSD netcat documentation; no second network, container runtime, or firewall-rule access was available. Recorded loopback outcomes do not establish external isolation. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PUBLIC_IP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'Paste the whole block'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'Expected one address'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'Substitute your address inside the quotes'; exit 2 ;; esac for audit_port in 7700 8108 8107; do if nc -nvz -w 5 "$1" "$audit_port"; then echo "FAIL: TCP $audit_port is reachable" exit 1 else echo "INCONCLUSIVE: inspect the connection error and firewall evidence" fi done exit 3 ) ``` REASONED: preceding block; external TCP interpretation and intentionally public native-HTTPS authorization; a live Meilisearch or Typesense stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No external disallowed vantage, deployed listener inventory, effective firewall rules or certificate-valid public engine endpoint was available to this marking review. An exposed private-backend deployment permits external TCP connections; the fixed backend gives no external answer while its private positive control answers. The exposed and fixed private-backend outcomes follow the guide's cited Meilisearch v1.53.2 Dockerfile (image sets MEILI_HTTP_ADDR=0.0.0.0:7700), Typesense 30.2 server configuration (api-key required, api-address default 0.0.0.0, api-port 8108, peering-port 8107, ssl-certificate), OpenBSD netcat reference entries. For intentionally public HTTPS, exposed keyless Meilisearch or a disclosed Typesense key returns data; fixed authorization denies anonymous or revoked credentials and permits the authorized control. The public-HTTPS outcomes in the preceding sentence, for both engines, rest on four of the guide's cited entries taken together, and no single entry supports every outcome. The first two of those entries are the guide's cited Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys; rolling documentation, checked September 2026) and Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at; rolling documentation, checked September 2026) entries. The third and fourth entries are the guide's cited Typesense 30.2 authorization failure (401) and curl options (write-out variables require 7.75.0+) entries. During marking review, bash -n and ShellCheck 0.11.0 accepted the TCP block without output. Under bash -u with an nc stub, placeholder and empty addresses printed 'Substitute your address inside the quotes', a wrong marker printed 'Paste the whole block', and a missing address printed 'Expected one address'; each exited 2 without calling nc. Stub success printed 'FAIL: TCP 7700 is reachable' (exit 1); stub failure printed 'INCONCLUSIVE: inspect the connection error and firewall evidence' for all three ports (exit 2). The paired-request block also passed bash -n and ShellCheck 0.11.0 without output; under bash -u with a curl stub, both authentication rows printed 'Statuses matched; inspect the response bodies before accepting the result.' (exit 0), synthetic 999 printed 'FAIL: unexpected HTTP status' (exit 1), and simulated transport failure printed 'INCONCLUSIVE: transport failed' (exit 2). Stubs do not establish TLS, authorization or network isolation. These stub runs covered earlier forms of both blocks: the TCP block ended with status 2 when no port connected, and the paired-request block's transport failure exited with status 2 and its unexpected positive-request status with status 1. The loopback runs recorded in the next paragraph also covered that earlier TCP block, whose refused shape ended with exit 2; the current form ends that shape with status 3, and neither block was re-run in its current form. Under the private-backend pattern, any successful external TCP connection is a finding, even if HTTP authentication would reject the caller; a timeout is inconclusive, and a refusal shows only no connection from that source at that moment, so corroborate it with the live private positive control, the listener bindings, and the effective firewall policy. If native HTTPS is intentionally public, run the paired authorization tests against that engine endpoint too, using its certificate-valid hostname and actual port (preserve the hostname with curl's `--resolve` when testing an IP, keep the substituted address guarded, and retain certificate verification); engine API TLS does not establish protection of Typesense's separate peering listener. On loopback, the harness's `ss -tulnp` listener check, run at every engine start, showed Typesense only on `127.0.0.1:8108` and `127.0.0.1:8107`; the exposed half, a wildcard bind such as Typesense's default `0.0.0.0:8108`, was not observed. The block against 127.0.0.1, where the engines listened, printed `FAIL: TCP 7700 is reachable` (exit `1`), the reachable shape; against 127.0.0.2, where nothing listens, each port's `Connection refused` printed `INCONCLUSIVE` and the block ended with exit `2`, the refused shape. A real external vantage is REASONED for the same reason. Behind Caddy v2.11.4 (the release tarball, its SHA-256 equal to GitHub's published digest) on `127.0.0.1:8443` with `bind 127.0.0.1`, `auto_https off`, the admin API off and TLS files from the test CA, terminating TLS in front of Meilisearch's plaintext backend on `127.0.0.1:7700`, the paired block's authentication row got `401` anonymously and `200` on the search key through the proxy. Behind the same proxy, keyless Meilisearch made the block print `FAIL: unexpected HTTP status` on an anonymous `200`, the exposed state, and Typesense with its plaintext backend on `127.0.0.1:8108` gave `401` anonymously then `200` on a collection-scoped search key. REASONED: search each admin, master or bootstrap key before deploying a client build, using the repository's `tools/scan_credential.sh` credential scanner. The cited Git manuals define the literal file and commit-tree searches; the engine references require privileged keys to stay server-side. The scanner is part of the [secureconfig repository](https://github.com/jposluns/secureconfig) itself, at `tools/scan_credential.sh`, from the merge of [PR #450](https://github.com/jposluns/secureconfig/pull/450) onward; it is not included in the published site or the plugin. Use a full clone of the repository, record the commit you ran, and read the script before giving it a real key. DEMONSTRATED: `tools/test_scan_credential.py` is the regression evidence for the repository tool, run with Bash 5.3.9 and Git 2.55.0 on disposable repositories: literal output and historical matches returned FOUND/1; absence and the named omission fixtures returned NOT FOUND/0; invalid inputs, unsafe output entries, Git diagnostics and incomplete searches returned INCONCLUSIVE/2. These are fixture observations, not real application builds; bounded sampling of the scanner's and its children's argv and environment during live scans found no key, and such sampling is no proof against exposure between samples or through process memory. REASONED: following block; no real application build is available in the authoring environment. From a trusted fresh shell with BASH_ENV unset, substitute the two checkout paths and the relative build directory, then enter one key alone on a line at the silent read; repeat for each key. Bash 4.3+, trusted Bash/Git executables and Git configuration, a finished build and a quiet repository are prerequisites. In a disposable real build, seed a dummy key and require FOUND/1, remove it and rebuild and require NOT FOUND/0; retain a separate old-commit positive control. Those real-build controls remain unobserved under row 1.117. ```bash (cd /path/to/project && bash /path/to/secureconfig/tools/scan_credential.sh ./dist) ``` REASONED: the tool's three outcomes are FOUND/1 (remove and rotate the credential), NOT FOUND/0 (only the scope below) and INCONCLUSIVE/2 (a refusal or incomplete search, taking precedence over earlier matches). It prints matching names, which can themselves contain a key, but never matching content or Git diagnostics. To identify the first failed prerequisite without entering a key, add `--explain` before the output argument; its categories disclose no paths. A READY result checks prerequisites only, not content. Interrupted runs without a final outcome remain inconclusive. - REASONED: Git grep searches raw output bytes and regular-file blobs in commits from `git rev-list --all --reflog`; it neither decodes gzip/Brotli nor reconstructs encoded, escaped, split or runtime-injected keys. - REASONED: Git tree searches omit committed symlink targets, submodule contents and LFS payloads; the output walk refuses symlinks, special/unreadable entries, empty output and any ASCII-case `.git` component. - REASONED: Git's commit enumeration omits unreachable objects, expired reflogs, omitted shallow/unfetched history, other-worktree private refs and objects present in no commit tree, including tags directly pointing at blobs; keys present only in commit or tag messages, author, committer or tagger identities, reflog messages, ref names or path names are not searched. - REASONED: Git replacement refs and grafts can hide original content or ancestry; only the resulting Git view is searched. - REASONED: a concurrent file change can evade the walk; commit lists consume memory and each commit needs a separate Git search, so resource exhaustion can interrupt completion. - REASONED: stdin avoids key-bearing child arguments and files; it does not protect process memory or pipes from the same account/root, or compensate for hostile shell startup files or executables. ## Common mistakes - Shipping a Meilisearch instance without `MEILI_MASTER_KEY` and `--env production` because dev mode "worked fine" in testing. - Putting the Typesense bootstrap key or Meilisearch admin key straight into front-end JavaScript instead of minting a scoped search key. - A scoped key with no `filter_by` or `collections` restriction, which searches everything the admin key can see. ## Sources (checked October 2026) Version scope: Typesense 30.2; Meilisearch current unversioned documentation checked 2026-09-18, with v1.53.2 as the release reference. The first release containing all four default keys and their exact permissions remains to be confirmed against the deployed release, so inspect `GET /keys`. The Verify commands require Bash, curl 7.75.0 or later, OpenBSD-compatible netcat and Git; the credential scan was exercised with Bash 5.3.9 and Git 2.55.0. - Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys; rolling documentation, checked September 2026): https://www.meilisearch.com/docs/resources/self_hosting/security/master_api_keys - Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at; rolling documentation, checked September 2026): https://typesense.org/docs/guide/data-access-control.html - Typesense 30.2 server configuration (api-key required, api-address default 0.0.0.0, api-port 8108, peering-port 8107, ssl-certificate): https://typesense.org/docs/30.2/api/server-configuration.html - Typesense `api-address` default `0.0.0.0`, `api-port` default 8108 and `peering-port` default 8107 (pinned tag v30.2): https://github.com/typesense/typesense/blob/v30.2/src/typesense_server_utils.cpp#L81-L85 - Typesense 30.2 API keys (parent search-only key, scoped-key derivation, description): https://typesense.org/docs/30.2/api/api-keys.html - Typesense 30.2 collections (collection creation and schema fields): https://typesense.org/docs/30.2/api/collections.html - Typesense 30.2 authorization failure (401): https://raw.githubusercontent.com/typesense/typesense/v30.2/src/http_server.cpp - Typesense 30.2 collection creation success (201): https://raw.githubusercontent.com/typesense/typesense/v30.2/src/core_api.cpp - Typesense backups (snapshot API, not the live data directory; rolling documentation, checked September 2026): https://typesense.org/docs/guide/backups.html - Typesense 30.2 remote embeddings (outbound requests with provider credentials): https://typesense.org/docs/30.2/api/vector-search.html - Meilisearch configuration reference (MEILI_HTTP_ADDR default localhost:7700; MEILI_EXPERIMENTAL_ALLOWED_IP_NETWORKS; rolling documentation, checked September 2026): https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference - Meilisearch `DEFAULT_HTTP_ADDR` "localhost:7700" and the `--http-addr` / `MEILI_HTTP_ADDR` option that defaults to it, and the implicit `./config.toml` read whose values yield to the environment and the command line (pinned tag v1.53.2). Meilisearch v1.53.2 sources: https://github.com/meilisearch/meilisearch/blob/v1.53.2/crates/meilisearch/src/option.rs#L95-L97, https://github.com/meilisearch/meilisearch/blob/v1.53.2/crates/meilisearch/src/option.rs#L221-L223 and https://github.com/meilisearch/meilisearch/blob/v1.53.2/crates/meilisearch/src/option.rs#L534-L560 - Meilisearch v1.53.2 Dockerfile (image sets MEILI_HTTP_ADDR=0.0.0.0:7700): https://raw.githubusercontent.com/meilisearch/meilisearch/v1.53.2/Dockerfile - Meilisearch native TLS (--ssl-cert-path, --ssl-key-path; rolling documentation, checked September 2026): https://www.meilisearch.com/docs/resources/self_hosting/security/http2_ssl - Meilisearch tenant-token payload (exp is optional; rolling documentation, checked September 2026): https://www.meilisearch.com/docs/capabilities/security/advanced/tenant_token_payload - Meilisearch backups and dumps (documents across every index; rolling documentation, checked September 2026): https://www.meilisearch.com/docs/resources/self_hosting/data_backup/overview - Meilisearch SSRF advisory (authenticated blind SSRF fixed in v1.34.1): https://www.meilisearch.com/blog/CVE-update-Jan-2026 - Meilisearch document-editing functions (Rhai; disabled unless enabled; rolling documentation, checked September 2026): https://www.meilisearch.com/docs/capabilities/indexing/how_to/edit_documents_with_functions - curl options (write-out variables require 7.75.0+): https://curl.se/docs/manpage.html - OpenBSD netcat reference: https://man.openbsd.org/nc - Caddy bind, certificate files, automatic HTTPS and admin options, and reverse-proxy configuration (pinned tag v2.11.4). Caddy v2.11.4 sources (1 of 2): https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/builtins.go#L58-L88, https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/options.go#L347-L381. Caddy v2.11.4 sources (2 of 2): https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/options.go#L459-L476, https://raw.githubusercontent.com/caddyserver/caddy/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go - Git grep manual (page last updated in 2.55.0; fixed strings, pattern files, binary text, names only, no-index and tree operands; supplied excerpts checked October 2026): https://git-scm.com/docs/git-grep - Git rev-list manual (page last updated in 2.56.0; all refs, HEAD and reflogs; supplied excerpts checked October 2026): https://git-scm.com/docs/git-rev-list - Git manual (page last updated in 2.56.0; --no-pager disables paging; supplied excerpt checked October 2026): https://git-scm.com/docs/git ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - meili: Meilisearch release reference (v1.53.2) - typesense: Typesense (30.2) - meili-docs: Meilisearch documentation (rolling) (unknown) - meili-advisory: Meilisearch SSRF advisory (unknown) - typesense-docs: Typesense documentation (rolling) (unknown) - caddy: Caddy (v2.11.4) - curl: curl minimum (7.75.0) - nc: OpenBSD-compatible netcat (unknown) - git-grep: Git grep manual (2.55.0) - git-rev-list: Git rev-list manual (2.56.0) - git: Git manual (2.56.0) Claims (full source mapping in the JSON block below): - meili-mode (REASONED; meili-docs): Development is keyless unless MEILI_MASTER_KEY is set; production requires a master key of at least 16 bytes. - meili-bind (REASONED; meili): Binary defaults localhost:7700 unless implicit config.toml overrides it; environment and CLI override the file. - meili-image (REASONED; meili): Official image sets MEILI_HTTP_ADDR=0.0.0.0:7700; publish only host loopback or use an unpublished private proxy network. - meili-keys (REASONED; meili-docs): Four default keys and their permissions are version-dependent; inspect GET /keys and keep admin, read-only admin and master keys server-side. - meili-tenant (REASONED; meili-docs): For confidential search, restrict indexes or issue tenant JWTs with enforced rules; keep the signing key server-side and set exp no later than parent expiry. - meili-tls (REASONED; meili-docs): Native HTTPS uses ssl-cert-path and ssl-key-path; the proxy pattern confines plaintext locally and verifies TLS across machines. - typesense-bootstrap (REASONED; typesense, typesense-docs): Bootstrap api-key is required and controls all endpoints/data; supply it through TYPESENSE_API_KEY or a protected file and use a revocable operational key routinely. - typesense-api (REASONED; typesense): API defaults 0.0.0.0:8108; set api-address=127.0.0.1 and api-port=8108 for a same-host proxy. - typesense-peer (REASONED; typesense): Separate peering defaults to 8107; select a private address and admit only cluster members. - typesense-scope (REASONED; typesense, typesense-docs): Parent search key uses documents:search and named/regex collections; derive browser keys with filter_by and field restrictions, keeping the parent server-side. - typesense-expiry (REASONED; typesense-docs): Set expires_at on browser keys no later than parent expiry; collection scope alone does not isolate JOIN data. - typesense-tls (REASONED; typesense): Self-managed native TLS uses ssl-certificate and ssl-certificate-key; cloud terminates TLS; restrict API and peering access independently. - admin-surface (REASONED; meili-docs, typesense-docs): Keep management UIs private or behind MFA; engine bearer keys are not a second factor and Meilisearch production disables the development preview. - backup (REASONED; meili-docs, typesense-docs): Protect and encrypt dumps, snapshots, volumes and backups; Typesense backs up snapshot output rather than the live directory and restores privately with auth. - meili-egress (REASONED; meili-docs, meili-advisory): Upgrade Meilisearch v1.8 through v1.34.0 for SSRF fixed in v1.34.1; avoid allowed-IP-networks=any and restrict remote/webhook administration and egress. - typesense-egress (REASONED; typesense): Remote embeddings send provider credentials outbound; restrict destinations and protect those secrets. - meili-functions (REASONED; meili-docs): Leave experimental document-editing functions disabled unless needed; Rhai transformations belong to trusted workloads, not arbitrary OS execution. - verify-meili-auth (DEMONSTRATED; meili-docs): Loopback keyless search returned 200; protected search returned anonymous 401 and keyed 200 with the fixture. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run. - verify-meili-write (DEMONSTRATED; meili-docs): Search key was refused index creation with 403; Default Admin received 202 and the task succeeded. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run. - verify-meili-production (DEMONSTRATED; meili-docs): Production startup without a master key was refused in the recorded run. - verify-typesense-auth (DEMONSTRATED; typesense, typesense-docs): Loopback Typesense refused anonymous search with 401 and returned the fixture with a collection-scoped key and 200. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run. - verify-typesense-write (DEMONSTRATED; typesense): Search-key collection creation returned 401; bootstrap creation returned 201. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run. - verify-rotation (DEMONSTRATED; typesense): Disclosed Typesense operational key succeeded before deletion, failed after deletion, and its replacement succeeded. The record does not confirm that these runs went through the paired-request block; if they did, they used its earlier form, whose transport failure exited 2 and unexpected positive-request status exited 1, and the current form, exiting 3 for both, has not been run. - verify-tenant (DEMONSTRATED; meili-docs, typesense-docs): Tenant token and scoped-key filters resisted other-tenant overrides while parent controls returned both fixtures. The record does not confirm that these runs went through the paired-request block; if they did, they used its earlier form, whose transport failure exited 2 and unexpected positive-request status exited 1, and the current form, exiting 3 for both, has not been run. - verify-join (DEMONSTRATED; typesense-docs): Collection-scoped key leaked a joined billing field; an embedded exclude_fields restriction removed it. The record does not confirm that these runs went through the paired-request block; if they did, they used its earlier form, whose transport failure exited 2 and unexpected positive-request status exited 1, and the current form, exiting 3 for both, has not been run. - verify-proxy (DEMONSTRATED; meili-docs, typesense-docs, caddy): Recorded same-host Caddy TLS proxy preserved anonymous/keyed auth distinctions for both engines; no external isolation is established. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run. - verify-request (REASONED; curl, meili-docs, typesense-docs): Paired requests require Bash, curl 7.75.0+, verified HTTPS and identical origin/method/path/body; inspect engine JSON, not status alone. - verify-external (REASONED; meili, typesense, nc): Inventory actual listeners, publications and IPv4/IPv6 rules; external TCP success fails, while refusal/timeout needs live private controls and policy evidence. - verify-local-tcp (DEMONSTRATED; nc): Recorded netcat loopback reachable/refused shapes returned FAIL/exit 1 and INCONCLUSIVE/exit 2 respectively, on an earlier form of the block; the current form, which ends the refused shape with status 3, was not re-run. External reachability remains unobserved. - verify-bundle (REASONED; meili-docs, typesense-docs, git-grep, git-rev-list, git): Repository credential scanner searches literal output and local commit-tree content; regression fixtures ran, while real-client-build controls remain unobserved. ````json { "schema": 1, "checked": "2026-10-12", "documentation_checked": "2026-10", "body_sha256": "d3682f5717a14f304d8522a79b508ee4a1a0782f0d77d24ccbe27394d0f8c23b", "components": { "meili": { "name": "Meilisearch release reference", "basis": "v1.53.2", "sources": { "sbed426856353": "https://github.com/meilisearch/meilisearch/blob/v1.53.2/crates/meilisearch/src/option.rs#L95-L97", "sa5636634c0e5": "https://github.com/meilisearch/meilisearch/blob/v1.53.2/crates/meilisearch/src/option.rs#L534-L560", "sb9cb7736ecb6": "https://raw.githubusercontent.com/meilisearch/meilisearch/v1.53.2/Dockerfile" } }, "typesense": { "name": "Typesense", "basis": "30.2", "sources": { "s9671c6e07c7f": "https://typesense.org/docs/30.2/api/server-configuration.html", "s2c4b6bcfaf53": "https://typesense.org/docs/30.2/api/api-keys.html", "sa9d1feeb6147": "https://typesense.org/docs/30.2/api/vector-search.html", "s4a43ef5aa4f9": "https://raw.githubusercontent.com/typesense/typesense/v30.2/src/http_server.cpp", "s29b60ad510db": "https://raw.githubusercontent.com/typesense/typesense/v30.2/src/core_api.cpp" } }, "meili-docs": { "name": "Meilisearch documentation (rolling)", "basis": "unknown", "sources": { "sb0775887509a": "https://www.meilisearch.com/docs/resources/self_hosting/security/master_api_keys", "s9ea7561d005d": "https://www.meilisearch.com/docs/capabilities/security/advanced/tenant_token_payload", "s26bf028aa235": "https://www.meilisearch.com/docs/resources/self_hosting/security/http2_ssl", "s5a14ae3a4b54": "https://www.meilisearch.com/docs/resources/self_hosting/configuration/reference", "s733eae6c1299": "https://www.meilisearch.com/docs/resources/self_hosting/data_backup/overview", "s034b9614262f": "https://www.meilisearch.com/docs/capabilities/indexing/how_to/edit_documents_with_functions" } }, "meili-advisory": { "name": "Meilisearch SSRF advisory", "basis": "unknown", "sources": { "sb8b81d3f76ff": "https://www.meilisearch.com/blog/CVE-update-Jan-2026" } }, "typesense-docs": { "name": "Typesense documentation (rolling)", "basis": "unknown", "sources": { "s6fb918104d58": "https://typesense.org/docs/guide/data-access-control.html", "s02ce5458d664": "https://typesense.org/docs/guide/backups.html" } }, "caddy": { "name": "Caddy", "basis": "v2.11.4", "sources": { "sb0b62262df7b": "https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/builtins.go#L58-L88", "sb4be8e0cfe75": "https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/options.go#L347-L381", "sd11645c28457": "https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/options.go#L459-L476", "s9702adeb8c11": "https://raw.githubusercontent.com/caddyserver/caddy/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go" } }, "curl": { "name": "curl minimum", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "nc": { "name": "OpenBSD-compatible netcat", "basis": "unknown", "sources": { "s096a08149c92": "https://man.openbsd.org/nc" } }, "git-grep": { "name": "Git grep manual", "basis": "2.55.0", "sources": { "se138ed3614b1": "https://git-scm.com/docs/git-grep" } }, "git-rev-list": { "name": "Git rev-list manual", "basis": "2.56.0", "sources": { "sa551f03eb541": "https://git-scm.com/docs/git-rev-list" } }, "git": { "name": "Git manual", "basis": "2.56.0", "sources": { "s1d8fe6ad85ac": "https://git-scm.com/docs/git" } } }, "claims": { "meili-mode": {"text": "Development is keyless unless MEILI_MASTER_KEY is set; production requires a master key of at least 16 bytes.", "components": ["meili-docs"], "sources": ["meili-docs:sb0775887509a"], "status": "REASONED"}, "meili-bind": {"text": "Binary defaults localhost:7700 unless implicit config.toml overrides it; environment and CLI override the file.", "components": ["meili"], "sources": ["meili:sbed426856353", "meili:sa5636634c0e5"], "status": "REASONED"}, "meili-image": {"text": "Official image sets MEILI_HTTP_ADDR=0.0.0.0:7700; publish only host loopback or use an unpublished private proxy network.", "components": ["meili"], "sources": ["meili:sb9cb7736ecb6"], "status": "REASONED"}, "meili-keys": {"text": "Four default keys and their permissions are version-dependent; inspect GET /keys and keep admin, read-only admin and master keys server-side.", "components": ["meili-docs"], "sources": ["meili-docs:sb0775887509a"], "status": "REASONED"}, "meili-tenant": {"text": "For confidential search, restrict indexes or issue tenant JWTs with enforced rules; keep the signing key server-side and set exp no later than parent expiry.", "components": ["meili-docs"], "sources": ["meili-docs:s9ea7561d005d", "meili-docs:sb0775887509a"], "status": "REASONED"}, "meili-tls": {"text": "Native HTTPS uses ssl-cert-path and ssl-key-path; the proxy pattern confines plaintext locally and verifies TLS across machines.", "components": ["meili-docs"], "sources": ["meili-docs:s26bf028aa235"], "status": "REASONED"}, "typesense-bootstrap": {"text": "Bootstrap api-key is required and controls all endpoints/data; supply it through TYPESENSE_API_KEY or a protected file and use a revocable operational key routinely.", "components": ["typesense", "typesense-docs"], "sources": ["typesense:s9671c6e07c7f", "typesense-docs:s6fb918104d58"], "status": "REASONED"}, "typesense-api": {"text": "API defaults 0.0.0.0:8108; set api-address=127.0.0.1 and api-port=8108 for a same-host proxy.", "components": ["typesense"], "sources": ["typesense:s9671c6e07c7f"], "status": "REASONED"}, "typesense-peer": {"text": "Separate peering defaults to 8107; select a private address and admit only cluster members.", "components": ["typesense"], "sources": ["typesense:s9671c6e07c7f"], "status": "REASONED"}, "typesense-scope": {"text": "Parent search key uses documents:search and named/regex collections; derive browser keys with filter_by and field restrictions, keeping the parent server-side.", "components": ["typesense", "typesense-docs"], "sources": ["typesense:s2c4b6bcfaf53", "typesense-docs:s6fb918104d58"], "status": "REASONED"}, "typesense-expiry": {"text": "Set expires_at on browser keys no later than parent expiry; collection scope alone does not isolate JOIN data.", "components": ["typesense-docs"], "sources": ["typesense-docs:s6fb918104d58"], "status": "REASONED"}, "typesense-tls": {"text": "Self-managed native TLS uses ssl-certificate and ssl-certificate-key; cloud terminates TLS; restrict API and peering access independently.", "components": ["typesense"], "sources": ["typesense:s9671c6e07c7f"], "status": "REASONED"}, "admin-surface": {"text": "Keep management UIs private or behind MFA; engine bearer keys are not a second factor and Meilisearch production disables the development preview.", "components": ["meili-docs", "typesense-docs"], "sources": ["meili-docs:s5a14ae3a4b54", "typesense-docs:s6fb918104d58"], "status": "REASONED"}, "backup": {"text": "Protect and encrypt dumps, snapshots, volumes and backups; Typesense backs up snapshot output rather than the live directory and restores privately with auth.", "components": ["meili-docs", "typesense-docs"], "sources": ["meili-docs:s733eae6c1299", "typesense-docs:s02ce5458d664"], "status": "REASONED"}, "meili-egress": {"text": "Upgrade Meilisearch v1.8 through v1.34.0 for SSRF fixed in v1.34.1; avoid allowed-IP-networks=any and restrict remote/webhook administration and egress.", "components": ["meili-docs", "meili-advisory"], "sources": ["meili-advisory:sb8b81d3f76ff", "meili-docs:s5a14ae3a4b54"], "status": "REASONED"}, "typesense-egress": {"text": "Remote embeddings send provider credentials outbound; restrict destinations and protect those secrets.", "components": ["typesense"], "sources": ["typesense:sa9d1feeb6147"], "status": "REASONED"}, "meili-functions": {"text": "Leave experimental document-editing functions disabled unless needed; Rhai transformations belong to trusted workloads, not arbitrary OS execution.", "components": ["meili-docs"], "sources": ["meili-docs:s034b9614262f"], "status": "REASONED"}, "verify-meili-auth": {"text": "Loopback keyless search returned 200; protected search returned anonymous 401 and keyed 200 with the fixture. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run.", "components": ["meili-docs"], "sources": ["meili-docs:sb0775887509a"], "status": "DEMONSTRATED", "evidence": "keyless Meilisearch returned the fixture to an anonymous search with `200`, so the authentication row printed `FAIL: unexpected HTTP status`: the exposed state.", "verify": [1]}, "verify-meili-write": {"text": "Search key was refused index creation with 403; Default Admin received 202 and the task succeeded. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run.", "components": ["meili-docs"], "sources": ["meili-docs:sb0775887509a"], "status": "DEMONSTRATED", "evidence": "the write row got `403` `invalid_api_key` for the search key and `202` for the Default Admin API Key, whose task then reported `succeeded`. Started with", "verify": [1]}, "verify-meili-production": {"text": "Production startup without a master key was refused in the recorded run.", "components": ["meili-docs"], "sources": ["meili-docs:sb0775887509a"], "status": "DEMONSTRATED", "evidence": "Meilisearch refused to start: \"You must provide a master key to secure your instance in a production environment\"."}, "verify-typesense-auth": {"text": "Loopback Typesense refused anonymous search with 401 and returned the fixture with a collection-scoped key and 200. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run.", "components": ["typesense", "typesense-docs"], "sources": ["typesense-docs:s6fb918104d58", "typesense:s4a43ef5aa4f9"], "status": "DEMONSTRATED", "evidence": "answered the authentication row with `401` anonymously and `200` on a collection-scoped search key", "verify": [1]}, "verify-typesense-write": {"text": "Search-key collection creation returned 401; bootstrap creation returned 201. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run.", "components": ["typesense"], "sources": ["typesense:s4a43ef5aa4f9", "typesense:s29b60ad510db"], "status": "DEMONSTRATED", "evidence": "the write row with `401` for that key and `201` for the bootstrap key.", "verify": [1]}, "verify-rotation": {"text": "Disclosed Typesense operational key succeeded before deletion, failed after deletion, and its replacement succeeded. The record does not confirm that these runs went through the paired-request block; if they did, they used its earlier form, whose transport failure exited 2 and unexpected positive-request status exited 1, and the current form, exiting 3 for both, has not been run.", "components": ["typesense"], "sources": ["typesense:s2c4b6bcfaf53"], "status": "DEMONSTRATED", "evidence": "A disclosed operational key got `200` before rotation, `401` after it was deleted, and its replacement `200`. A Meilisearch tenant token", "verify": [1]}, "verify-tenant": {"text": "Tenant token and scoped-key filters resisted other-tenant overrides while parent controls returned both fixtures. The record does not confirm that these runs went through the paired-request block; if they did, they used its earlier form, whose transport failure exited 2 and unexpected positive-request status exited 1, and the current form, exiting 3 for both, has not been run.", "components": ["meili-docs", "typesense-docs"], "sources": ["meili-docs:s9ea7561d005d", "typesense-docs:s6fb918104d58"], "status": "DEMONSTRATED", "evidence": "A Meilisearch tenant token filtered to one tenant returned only that tenant's fixture, and a request adding a filter for the other tenant returned nothing, while the parent key returned both; a Typesense scoped key embedding `filter_by` behaved the same way under an overriding `filter_by`. For JOINs,", "verify": [1]}, "verify-join": {"text": "Collection-scoped key leaked a joined billing field; an embedded exclude_fields restriction removed it. The record does not confirm that these runs went through the paired-request block; if they did, they used its earlier form, whose transport failure exited 2 and unexpected positive-request status exited 1, and the current form, exiting 3 for both, has not been run.", "components": ["typesense-docs"], "sources": ["typesense-docs:s6fb918104d58"], "status": "DEMONSTRATED", "evidence": "For JOINs, a Typesense key scoped to collection `products` was refused a direct search of `companies` with `401`, but a `products` search with `include_fields=$companies(billing)` returned the joined company's billing field: collection scoping alone did not isolate joined data. A scoped key embedding `exclude_fields=$companies(billing)` returned the same query without it.", "verify": [1]}, "verify-proxy": {"text": "Recorded same-host Caddy TLS proxy preserved anonymous/keyed auth distinctions for both engines; no external isolation is established. The recorded runs used an earlier form of the paired-request block, whose transport failure exited 2 and unexpected positive-request status exited 1; the current form, exiting 3 for both, has not been run.", "components": ["meili-docs", "typesense-docs", "caddy"], "sources": ["meili-docs:sb0775887509a", "typesense-docs:s6fb918104d58", "caddy:sb0b62262df7b", "caddy:sb4be8e0cfe75", "caddy:sd11645c28457", "caddy:s9702adeb8c11"], "status": "DEMONSTRATED", "evidence": "Behind the same proxy, keyless Meilisearch made the block print `FAIL: unexpected HTTP status` on an anonymous `200`, the exposed state, and Typesense with its plaintext backend on `127.0.0.1:8108` gave `401` anonymously then `200` on a collection-scoped search key.", "verify": [1]}, "verify-request": {"text": "Paired requests require Bash, curl 7.75.0+, verified HTTPS and identical origin/method/path/body; inspect engine JSON, not status alone.", "components": ["curl", "meili-docs", "typesense-docs"], "sources": ["curl:s2b2686afaf41", "meili-docs:sb0775887509a", "typesense-docs:s6fb918104d58"], "status": "REASONED"}, "verify-external": {"text": "Inventory actual listeners, publications and IPv4/IPv6 rules; external TCP success fails, while refusal/timeout needs live private controls and policy evidence.", "components": ["meili", "typesense", "nc"], "sources": ["meili:sb9cb7736ecb6", "typesense:s9671c6e07c7f", "nc:s096a08149c92"], "status": "REASONED", "verify": [2]}, "verify-local-tcp": {"text": "Recorded netcat loopback reachable/refused shapes returned FAIL/exit 1 and INCONCLUSIVE/exit 2 respectively, on an earlier form of the block; the current form, which ends the refused shape with status 3, was not re-run. External reachability remains unobserved.", "components": ["nc"], "sources": ["nc:s096a08149c92"], "status": "DEMONSTRATED", "evidence": "The block against 127.0.0.1, where the engines listened, printed `FAIL: TCP 7700 is reachable` (exit `1`), the reachable shape; against 127.0.0.2, where nothing listens, each port's `Connection refused` printed `INCONCLUSIVE` and the block ended with exit `2`, the refused shape."}, "verify-bundle": {"text": "Repository credential scanner searches literal output and local commit-tree content; regression fixtures ran, while real-client-build controls remain unobserved.", "components": ["meili-docs", "typesense-docs", "git-grep", "git-rev-list", "git"], "sources": ["meili-docs:sb0775887509a", "typesense-docs:s6fb918104d58", "git-grep:se138ed3614b1", "git-rev-list:sa551f03eb541", "git:s1d8fe6ad85ac"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> sqlite.md ====================================================================== # SQLite in deployment: the file is the exposure (plus Turso and Litestream) SQLite is a database engine built into your application that keeps its data in an ordinary file. This guide helps your AI assistant protect that file, including no copies in web roots or git, tight permissions, separate encryption at rest, and the network exposure libSQL, Turso, Litestream and LiteFS add. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/sqlite.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against SQLite documentation (rolling) (no recorded version), Litestream MCP minimum v0.5.0, LiteFS source v0.5.14, Git documentation v2.56.0, GNU Coreutils stat manual v9.7, GNU Bash reference manual bash-5.3, curl curl-8_14_1, and GNU grep documentation v3.12; 7 further sources carry no recorded version. Metadata reviewed 2026-10-17; documentation checked September 2026. The guide records 40 claims: 2 demonstrated by a check that ran and 38 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. SQLite has no server process and no network listener: "there are no other processes, threads, machines, or other mechanisms (apart from host computer OS and filesystem) to help provide database services" ([SQLite: serverless](https://www.sqlite.org/serverless.html)), so there is no database-side authentication and the only access control is the filesystem's. Its security page warns that "any database file which might have ever been writable by an agent in a different security domain should be treated as suspect." So the deployment exposure is rarely a flaw in SQLite itself; it is wherever the database file ends up: under a web root, inside a git repository, readable by another local account, or replicated to a public bucket. Run the application with access only to the files it needs, since `ATTACH` opens further databases under the process's filesystem authority; keep extension loading disabled unless required (core SQLite disables it by default, though the SQLite CLI enables it) and load only trusted extension libraries. Application-level SQL-injection prevention is outside this guide's scope. ## 1. Keep the file out of anything that serves it Keep the database, its sidecars, temporary files, exports, and backups outside every web-served directory, for example under `/var/lib/myapp/app.db` rather than `public/app.db` or `static/app.db`; a file under the web root is downloadable by URL like any other. Confirm that aliases, symlinks, application download routes, static-host uploads, and backup publishing do not expose them, and disable directory listing where appropriate rather than relying on hidden filenames. [web-exposure.md](https://github.com/jposluns/secureconfig/blob/main/web-exposure.md) explains the general pattern; its example deny rules must be extended for the actual SQLite filenames if you use them as a backstop. ## 2. Keep the file out of git A committed `.db` file ships every row to anyone who clones the repository, permanently, even after a later commit deletes it. This warning is this guide's assessment based on Git's documented removal from the index alone or from the index and working tree and inspectable commit logs. Add `*.db`, `*.sqlite`, `*.sqlite3`, and their sidecar files to `.gitignore` before the first commit. The WAL, shared-memory, and rollback-journal files take the full database filename plus `-wal`, `-shm`, or `-journal`, so each extension needs its own trio (`*.db-wal`, `*.db-shm`, `*.db-journal`, `*.sqlite-wal`, `*.sqlite-shm`, `*.sqlite-journal`, `*.sqlite3-wal`, `*.sqlite3-shm`, `*.sqlite3-journal`). Ignore rules do not remove already-tracked files, so scan the index and history and handle a copy that already leaked per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). ## 3. File permissions Use a dedicated application account. Restrict its database directory to that account (`700`) and the database and any existing journal, WAL, and SHM sidecar files to that account (`600`), and set `umask 077` in the launch environment before those files are created; any other local account or process can otherwise open the file directly, since there is no SQLite-side access control. Verify existing sidecars separately rather than deleting live ones to repair exposure, and protect temporary files and backups too: with the Unix VFS, `SQLITE_TMPDIR` can point at an existing private temporary directory (SQLite may fall back to other locations, so confirm it exists). These permissions do not isolate processes that share the account or privileged host users. ## 4. Encryption at rest is not built in Public-domain SQLite does not encrypt database files; whoever obtains the file reads every row, which is what makes steps 1 to 3 load-bearing. The [SQLite Encryption Extension (SEE)](https://www.sqlite.org/see/doc/trunk/www/readme.wiki) is licensed software ("the public version of SQLite will not be able to read or write an encrypted database file"), and [SQLCipher](https://www.zetetic.net/sqlcipher/) is a third-party build with the same goal; unless the deployment has adopted one of those, rely on filesystem or volume encryption. Keep encryption keys outside the database and its backups and load them through the application's secret mechanism, and note that SEE documents unencrypted TEMP tables, so do not assume temporary files are encrypted. Rows that hold credentials make every database, journal, export, and backup copy a secret-bearing artefact; if one leaks, rotate those credentials rather than assuming deletion contains it ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ## 5. libSQL and Turso: the file becomes an HTTP endpoint Turso serves libSQL databases over HTTP, replacing the local file with a network service authenticated by a bearer token against a URL of the form `https://[databaseName]-[organizationSlug].turso.io`. Applications read `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` from the environment; the token is a secret exactly like an API key, never in the client bundle, never committed ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ```bash turso db tokens create example-db --read-only --expiration 7d ``` `turso db tokens create` supports `-r`/`--read-only` to scope a token away from writes, and `-e`/`--expiration` to give it a lifetime (`never`, or a duration such as `7d3h`); issue a scoped, expiring token for anything that does not need full write access rather than reusing one long-lived full-access token everywhere. ## 6. Litestream and LiteFS: the replica destination is now part of the exposure Litestream continuously replicates the SQLite file to S3, Google Cloud Storage, Azure Blob Storage, and other supported destinations, authenticating to S3 the same way any AWS client does, with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in the environment. Litestream's own S3 guide scopes the IAM policy to the one bucket and prefix it needs rather than granting broad S3 access, which limits the damage if the credential leaks. The replica bucket needs the same private-by-default posture as any other bucket: see [object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md) for keeping it non-public and restoring through scoped credentials rather than a public URL. Litestream's metrics listener (`addr`) and, in v0.5.0 and later, its MCP listener (`mcp-addr`) are disabled by default; leave them off unless needed, and when local access is required bind explicitly to loopback (for example `addr: "127.0.0.1:9090"`, `mcp-addr: "127.0.0.1:3001"`). The MCP server has no built-in authentication and exposes database information and restore capabilities, so reach it only through an authenticated tunnel or protected proxy, never a directly published port. LiteFS replicates a SQLite file across a cluster's nodes rather than to object storage directly; its docs note the project is pre-1.0 and recommend regular off-site backups as a separate measure, and that backup destination should get the same bucket-privacy treatment as a Litestream replica. LiteFS also runs an HTTP API for replication and administration. At v0.5.14 no route on it checks any credential: the server accepts cleartext HTTP/1 and h2c (HTTP/2 without TLS), and LiteFS's own client uses h2c only. Network reachability therefore exposes unauthenticated operations, subject to the role and database conditions below. The default `http.addr` is `:20202`, which names no host and so listens on every interface, and the example configuration in the LiteFS repository ships the same host-less value. Set the address in the configuration file; no command-line flag sets it directly. The default search order is `litefs.yml` in the working directory, then the current user's home directory when available, then `/etc/litefs.yml`; `-config PATH` instead reads the specified path. `${VAR}` environment expansion applies unless `-no-expand-env` is passed. An explicitly empty `addr` still listens on every interface, on a system-chosen port; it does not disable the API. | Route | Role and database conditions at v0.5.14 | | --- | --- | | POST `/import` | Primary only: creates a named database if absent or replaces its contents from the request body. A named request on a replica returns 503 before database creation; it is not forwarded. `DB.Import` also rejects a non-primary. | | GET `/export` | Primary or replica: exports an existing named local database in full, including applicable WAL pages. A missing database returns 404. | | POST `/stream` | Primary only, over HTTP/2 with a valid position-map body. An otherwise valid request on a replica returns 503. Without a `filter`, it can stream every database on the primary; a caller-supplied filter narrows the set, and a fresh position receives a snapshot. | | POST `/tx` | Primary or replica: applies a valid LTX file to an existing named local database, without forwarding or checking primary status or the caller's halt-lock ownership. Non-snapshot input must match the next transaction id and current checksum, and the file undergoes LTX validation. A missing database returns 404. | | POST `/halt`, DELETE `/halt` | Primary or replica: POST creates the named local database if absent and acquires its write locks with a nonzero lock id. On the primary this stalls that database's writes; on a replica it can block replication applying that database's updates. The default lock TTL is 30 seconds, with expiration checked every 5 seconds. DELETE releases the existing database's lock when its id matches. | | POST `/promote` | Requires an eligible candidate. An already-primary candidate returns success without a change; a candidate replica with a known primary requests handoff from it, subject to the handoff conditions below. No database name is required. | | POST `/handoff` | Primary only, with a connected target subscriber and a lease that supports handoff; static leases refuse it. No database name is required. | | GET `/info`, `/events`, `/metrics` | Primary or replica, without a named database: disclose node, cluster, and database state through information, events, and metrics. | The debug routes are also unconditional on either role and need no named database: Go's `/debug/pprof/` exposes profiling (`cmdline` returns the process command line; `profile` and `trace` run live profiles), and `/debug/vars` exposes the command line, memory statistics, and a `store` variable carrying primary status and each database's name, transaction id, and lock states. The third route, `/debug/rand`, streams deterministic pseudo-random data and checks a one-minute timeout only between writes, so a slow or non-reading client can hold a request open longer, since the server sets no write timeout. The optional `Litefs-Id` comparisons in the API reject self-connections; they do not authenticate callers. Keep each node's API reachable only by the cluster's own nodes over a private network: set `http.addr` to that node's private address and never publish port 20202 through an internet-facing port mapping or proxy. Under Consul leasing, set each node's `lease.advertise-url` to its own private API URL; if unset, it is derived as `http://` plus `lease.hostname` (falling back to the OS hostname) and the actual listening port. Under static leasing, the configured value is used as given: the primary advertises its own private API URL, while every replica's `lease.advertise-url` must point to the primary's private API URL. The upstream image bakes in no configuration file and declares no `EXPOSE`. With the default bind, LiteFS listens on every interface of its network namespace. Container reachability depends on the configured bind, network mode, connected peers, routing, firewall, and port publication: same-network peers can reach the listener without a published port, host networking uses the host's network namespace, and containers sharing a network namespace can reach its listeners. The optional application proxy (`proxy.addr`, `:8080` in the example configuration) is a separate listener: startup is attempted only when `proxy.target` is set, and `proxy.db` and `proxy.addr` must also be nonempty. It forwards requests to the application and needs the application's own TLS and authentication controls. ## Verify REASONED: the shell and HTTP mechanics of the download, bundle, listener and Turso checks rest on the GNU Bash bash-5.3, GNU grep v3.12 and curl curl-8_14_1 entries in Sources; this is source reasoning, not a new run. Bash documents `set --` and `shift`, quoted positional arguments and counts, unset-only expansion, prefix/suffix removal, conditional lists, loops, tests, patterns and subshells; these are the primitives used by this guide's marker, count, URL and directory checks. Bash documents `trap -`, `unset -n`/`-v`, readonly failure, `set +x +a +e`, `read -r -s -p`, `printf`, redirections and pipelines; the block's `< /dev/tty` is this guide's procedure and requires a usable controlling terminal at that path, whose operating-system availability these manuals do not establish. The token-handling conclusions below are this guide's assessment under these assumptions; hidden input is not itself history suppression. The clean-shell assumption in CONTRIBUTING rule 7 still applies; paste whole blocks, never a token as a shell command. REASONED: following block; this overview and the download probe below. The probe needs a served application, and none was available during marking review (October 2026), which ran only local commands; per the guide's cited SQLite serverless architecture and SQLite temporary and sidecar file naming entries, the expected exposed outcome is `200` or `206` for `app.db` or a sidecar once the control answers, and the fixed outcome `404` or the application's catch-all. The probe block passed `bash -n` and ShellCheck 0.11.0 with no findings in that review. The probe block's guards were exercised during marking review (October 2026) under bash 5.3.9 with a Bash curl stub. The written placeholder, non-HTTPS scheme, missing host, empty or nondigit port, multiple port colons, origin path, query, fragment, userinfo or space, empty or placeholder prefix, relative prefix, double slash, prefix query, fragment or space, wrong value count and omitted initial `set --` each printed the corresponding refusal and exited 2 without calling curl. Valid root and `/static/` fixtures exited 0 and called the stub five and nine times respectively; its synthetic HTTP 200 output does not establish live-service behavior. Those runs covered an earlier form whose requests set no exit status. The current form exits 1 when `app.db` or a sidecar answers 200 or 206, otherwise 3 when the control, `app.db` or a sidecar request gets no HTTP response, and otherwise 0, printing any other status for you to judge against the application's catch-all. It is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026 under bash 5.3.9 with a curl stand-in. The bundle scan, git inventory and `stat` re-runs from that review are recorded at their steps. The bundle scan and the git inventory were demonstrated on the authoring host in exposed and fixed states. The `stat` check is REASONED for ownership: the earlier plain-file marking review did not exercise a SQLite connection, but the 15 October 2026 local marking review re-observed WAL and rollback-journal modes on open connections with Python SQLite 3.46.1; ownership by a separate account remains unexercised. The paragraph after the `stat` step records these observations. The download probe, the LiteFS/Litestream listener check and the Turso check are REASONED, each marked at its step with the capability that was missing. Their expected outcomes are this guide's assessment using the cited service facts and shell, curl and grep primitives; the manuals do not establish deployed outcomes or exhaustive guard coverage. Use Bash and curl with the behaviour documented here at bash-5.3 and curl-8_14_1; the cited curl page dates `exitcode` and `errormsg` to 7.75.0 but does not establish compatibility of these complete checks with every release from that version onward; never add `-k`. Substitute inside the single quotes and paste each complete subshell. ```bash # Needs a served application, which opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. # The database and its sidecars must not be downloadable. The positive control must answer first, or the 404s # are inconclusive (a timeout, NXDOMAIN, or an environment proxy otherwise reads like a false pass). ( set +e # errexit off here, so a request that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_APP_ORIGIN' '/' # the second value is a static path prefix to probe as well, such as '/static'; '/' probes the root only [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "exactly one origin and one path prefix required; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*) echo "substitute the HTTPS application origin; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "HTTPS origin required; not probing"; exit 2 ;; esac case "${1#https://}" in ""|/*|:*) echo "the origin needs a host after https://; not probing"; exit 2 ;; esac set -- "${1%/}" "$2" # one trailing / is tolerated and dropped case "${1#https://}" in */*|*'?'*|*'#'*|*@*|*[[:space:][:cntrl:]]*) echo "the origin must be https://host or https://host:port, with no path, query, fragment, userinfo or whitespace; not probing"; exit 2 ;; esac case "${1#https://}" in *:|*:*[!0123456789]*) echo "the origin port must be digits after a single colon; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*) echo "substitute the static path prefix, or '/' for the root only; not probing"; exit 2 ;; esac case "$2" in /*) ;; *) echo "the path prefix must start with /; not probing"; exit 2 ;; esac case "$2" in *//*) echo "the path prefix must not contain //; not probing"; exit 2 ;; esac case "$2" in *'?'*|*'#'*|*[[:space:][:cntrl:]]*) echo "the path prefix must not contain ?, # or whitespace; not probing"; exit 2 ;; esac case "${2%/}" in "") set -- "$1" ;; *) set -- "$1" "$1${2%/}" ;; esac set -- 0 "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -o /dev/null \ -w 'control http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" printf '%s' "${2%$'\n'*}" # the control's result line case "${2##*$'\n'}" in 0) ;; *) echo "the positive control got no HTTP response, so the 404s below are inconclusive"; set -- 3 "${@:2}" ;; esac set -- "$1" "${@:3}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then each base to probe while [ "$#" -gt 1 ]; do printf 'under %s/\n' "$2" ( set -- 0 "$2" app.db "$2" app.db-wal "$2" app.db-shm "$2" app.db-journal # this base's status, then base and file in pairs, so no named loop variable is read while [ "$#" -gt 1 ]; do set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -o /dev/null \ -w "$3 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n" "$2/$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:2}" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" "${@:3}" # status, curl's exit status, its result line, then the base, the file and the remaining pairs printf '%s' "$3" set -- "$1" "$2" "${3#* http=}" "${@:4}" # the result line from its HTTP code on case "$2:$3" in 0:'200 '*|0:'206 '*) echo "FINDING: $5 answered ${3%% *}, so it is downloadable"; set -- 1 "${@:2}" ;; 0:'404 '*) ;; 0:*) echo "unclassified observation: $5 answered ${3%% *}; that is the pass only if it is the application's catch-all" ;; *) echo "curl exit $2 for $5; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" ;; esac set -- "$1" "${@:6}" done # 404 or the app's catch-all, never 200/206; repeat for any further prefix your app serves exit "$1" ) case "$?:$1" in 0:*|*:1) ;; 1:*) set -- 1 "${@:2}" ;; *) set -- 3 "${@:2}" ;; esac # fold that base's status into the block's, ranked as above set -- "$1" "${@:3}" done echo "block exit $1; any status other than 200, 206 or 404 above is left for you to judge against the application's catch-all" exit "$1" ) ``` REASONED: preceding block; the download probe above, which needs a served application that was not available, with its expected outcomes per the guide's cited SQLite serverless architecture and SQLite temporary and sidecar file naming entries and its local runs recorded in the opening paragraph of this section. The declaration's scope also covers this introduction to the bundle scan below, whose runs during marking review (October 2026) are recorded after that block. The download guards were exercised during marking review (October 2026) under bash 5.3.9 with a Bash curl stub, and the original block passed ShellCheck 0.11.0 with no findings. The placeholder, scheme, host, port, origin path and prefix refusal fixtures, wrong value count and omitted initial `set --` each exited 2 without calling curl; valid root and static-prefix fixtures exited 0 with synthetic HTTP 200 output, leaving live-service behavior REASONED. Those runs covered an earlier form of the probe; the opening paragraph of this section records how its current form was checked. Keeping the token off argv and shell history rests on the block's hidden `read` prompt and the `printf` builtin feeding `grep -f -`, per CONTRIBUTING rule 7's `read` exception; no record of the arguments grep received was captured in that review, so the argv property was not observed there. Expected exposed outcome: `token-literal exit: 0`; fixed: `1`. Scan built client bundles for a leaked Turso/libSQL token, keeping the token off argv and shell history, though not out of your own account's view of the process: REASONED: the paste-alone instruction for the block below, per CONTRIBUTING rule 7's `read` exception. Paste the token alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. DEMONSTRATED: following block; the recorded authoring-host placeholder-token runs distinguish exposed and clean bundles and exercise the listed shell guards; no real token was used. On 2026-10-13 (UTC), after both scans were changed to print file names only (`-l`), a bundle carrying a JWT-shaped dummy printed `dist/app.js` before `token-literal exit: 0` and again before `jwt-shape exit: 0`, with no token in the output, and a clean bundle printed `1` for both; before the change each scan printed the line holding the token. These runs covered an earlier form, whose result arms ended with status 0. The current form exits 1 when either printed status is 0, 0 when both are 1, and 3 otherwise or when the hidden token read fails, and it is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes. It was re-run on 31 October 2026 under bash 5.3.9 and GNU grep 3.12 on scratch fixtures through a pseudo-terminal with a generated JWT-shaped placeholder: the placeholder bundle printed `0` for both lines and exited 1, and a clean bundle printed `1` for both and exited 0. ```bash ( # a subshell, so your own script arguments are untouched trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e # never trace or export the token read below { unset -n tok && unset -v tok; } 2>/dev/null || { echo 'a readonly tok is set in this shell; not scanning'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not scanning'; exit 2; } set -- PASTE_WHOLE_BLOCK build dist .next/static [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not scanning'; exit 2; } shift set -- "$@" '' # an empty value ends the list, so no named loop variable is read while [ -n "$1" ]; do if [ -d "$1" ]; then set -- "$@" "$1"; fi # keep only the directories that exist shift done shift # drop the empty value that ended the list [ "$#" -ge 1 ] || { echo 'none of the bundle directories exist here; not scanning'; exit 2; } if ! IFS= read -r -s -p 'paste the token value from the secret store (input hidden): ' tok < /dev/tty; then echo 'token input failed; not scanning' exit 3 fi echo [ -n "$tok" ] || { echo 'no token supplied; not scanning'; exit 2; } printf '%s\n' "$tok" | grep -rlF -f - -- "$@"; set -- "$?" "$@"; echo "token-literal exit: $1 (1 is the goal: not found; 0 means the token is in the bundle)" grep -rlE 'eyJ[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-]{10,}[.][ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-]{10,}' -- "${@:2}"; set -- "$?" "$@"; echo "jwt-shape exit: $1 (1 is the goal; 0 means a JWT-shaped string needs explaining)" case "$2:$1" in # token-literal:jwt-shape; a 0 (finding) outranks any other status (inconclusive) 0:*|*:0) set -- 1 ;; 1:1) set -- 0 ;; *) set -- 3 ;; esac echo "block exit $1; the file names and any grep errors above, and whether these directories hold your built bundle, are left for you to judge" exit "$1" ) ``` *** REASONED: the scan limits and token-format assertion are only partly demonstrated; no Sources entry as worded (Turso HTTP API quickstart (`TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`), Turso CLI `db tokens create` (`--read-only`, `--expiration`), Turso SQL over HTTP (`/v2/pipeline`, bearer authentication)) states that Turso tokens are JWTs, and no real token was available. The bundle scan was re-run during marking review (October 2026) under bash 5.3.9 and GNU grep 3.12 on scratch fixtures, with a generated JWT-shaped placeholder, not a real token, written to the hidden prompt through a pseudo-terminal. A bundle carrying the placeholder printed `dist/app.js` before `token-literal exit: 0` and again before `jwt-shape exit: 0`, and a clean bundle printed `1` for both. With the placeholder stored base64-encoded, or only in an unnamed `out/` directory beside a clean `dist/`, both lines printed `1`, the same as the clean scan. The block refused, exiting 2, with no named directory present, an empty token, its `set --` line omitted, a readonly `tok` or `IFS` in the calling shell, or no controlling terminal; under an inherited `set -x` only `trap - DEBUG RETURN ERR` and `set +x +a +e` themselves were traced and no token was printed, and under an inherited `set -e` a clean scan still printed both lines. These re-runs covered an earlier form, whose result arms ended with status 0; in the current form, whose 31 October 2026 re-run is recorded before the block, a failed hidden-prompt read, including one with no controlling terminal, exits 3 instead of 2. The nameref and lowercase cases are recorded in the list after the `stat` step. That real Turso and libSQL tokens are JWTs was not observed here. A clean result is evidence, not proof: neither pattern matched in the paths searched, not that the token cannot be present in some other form. A bundler can split, encode, or transform it, and a missing directory produces the same silence as a genuinely clean scan, so read the exit codes above and confirm the directories exist. The JWT-shape scan assumes, without a cited source, that Turso and libSQL tokens are JWTs; a JWT-shaped hit needs explaining either way. REASONED: interpretation of the unchanged bundle commands, from the GNU grep v3.12 and Bash bash-5.3 entries in Sources. `grep -rlF -f - --` recursively searches the selected directories for fixed patterns read one per stdin line and prints matching file names; `grep -rlE` uses an extended regular expression instead, with the listed characters and repetitions chosen by this guide as a heuristic. Recursion follows command-line symlinks but skips symlinks encountered below them. The default binary-input heuristic can suppress output and change whether patterns match; the manual does not establish a complete binary-secret scan. GNU grep reports 0 for a match, 1 for no selected lines and 2 for an error in these invocations, which do not use `-q`. This guide requires both printed statuses to be 1, no diagnostic errors, and confirmation of the intended directories before treating the scan as clean within its stated limits; 2 or any other unexpected status is inconclusive, not clean. Bash makes the first printed status a pipeline status: normally grep's status, but with inherited `pipefail` a failed `printf` can affect it too. The block then exits 1 when either printed status is 0, 0 when both are 1, and 3 otherwise; that summary status is not a substitute for reading both printed scan statuses. DEMONSTRATED: re-run during marking review (October 2026) with git 2.53.0 in throwaway repositories outside any checkout. With `app.db` committed before an `app.db*` rule, `git check-ignore -v` listed only the three sidecars while `git ls-files` printed `app.db` and `git log` printed its commit; with the rule in place and `app.db` never committed, `git check-ignore -v` listed all four paths and the other two commands printed nothing. These runs covered an earlier form of three top-level commands with no status of their own, and a first 31 October 2026 re-run with git 2.53.0 covered a later form that named `app.db` and its sidecars in the block itself. The current form takes the database path on its `set --` line, refuses with status 2 when that line is missing, has the wrong number of values or is unsubstituted, and derives the `-wal`, `-shm` and `-journal` paths from it. It is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026 under bash 5.3.9, and it was re-run that day with git 2.53.0 in three further throwaway repositories with `app.db` on its `set --` line, exiting 1 for the tracked and the history-only repository and 0 for the never-committed one. Substitute the database's path in the repository inside the quotes. Confirm the database is not tracked in git, not only that a rule exists (ignore rules do not apply to already-tracked files): DEMONSTRATED: following block; the recorded three throwaway repositories distinguish tracked, historical and never-committed database files. The current form's re-run, with `app.db` on its `set --` line, is recorded in the paragraph above. ```bash ( # a subshell, so the exits below end only the subshell set +e # errexit off here, so a git command that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DATABASE_PATH' # the database's path in this repository, such as app.db or data/app.db; its -wal, -shm and -journal sidecars are derived from it [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not checking"; exit 2; } shift [ "$#" -eq 1 ] || { echo "exactly one database path required; not checking"; exit 2; } case "$1" in ""|*REPLACE_WITH_*) echo "substitute the database path; not checking"; exit 2 ;; esac set -- 0 "$1" "$1-wal" "$1-shm" "$1-journal" # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0; then the database and its sidecars if git check-ignore -v -- "${@:2}"; then :; else # exit 1 only means no path is ignored set -- "$1" "$?" "${@:2}" case "$2" in 1) ;; *) echo "git check-ignore exit $2; inconclusive"; set -- 3 "${@:2}" ;; esac set -- "$1" "${@:3}" fi set -- "$1" "$(if git ls-files -- "${@:2}"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:2}" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" "${@:3}" # status, git's exit status, its output, then the database and its sidecars printf '%s' "$3" # any output is a tracked database or sidecar if [ "$2" != 0 ]; then echo "git ls-files exit $2; inconclusive"; [ "$1" = 1 ] || set -- 3 "${@:2}" elif [ -n "$3" ]; then echo "FINDING: git tracks the database or sidecar listed above"; set -- 1 "${@:2}" fi set -- "$1" "${@:4}" set -- "$1" "$(if git log --all --oneline -- "${@:2}"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # status, git's exit status, then its output if printf '%s' "$3" | head; then :; else # any output is a copy in history; handle per secrets.md echo "showing the git log output failed with exit $?; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" fi if [ "$2" != 0 ]; then echo "git log exit $2; inconclusive"; [ "$1" = 1 ] || set -- 3 elif [ -n "$3" ]; then echo "FINDING: git history holds a copy of the database or a sidecar"; set -- 1 fi echo "block exit $1; the check-ignore lines above are left for you to judge: each database and sidecar path should match a rule" exit "$1" ) ``` REASONED: following block; the live-file and separate-account ownership check was only partly demonstrated; no application or second account was available. Reading the output rests on the guide's cited GNU Coreutils v9.7 `stat` manual entry (`-c` format, `%a`, `%U` and `%n`), and the expected private modes rest on the recorded umask runs in this paragraph and the `stat` item below, not on a source. The `stat` check this paragraph introduces was re-run during marking review (October 2026) with uutils coreutils 0.8.0 `stat`, not GNU stat. For a directory holding `app.db` and `-wal`, `-shm` and `-journal` files created as plain files under umask `022`, it printed `755` for the directory and `644` for each file; under umask `077` it printed `700` and `600`, and every path showed the one account that ran it. No application or SQLite connection ran in that earlier plain-file review, so which sidecars exist while the application runs was not exercised there. The 15 October 2026 local marking review re-observed WAL and rollback-journal modes on open connections with Python SQLite 3.46.1; ownership by a separate app user remains unexercised. Those runs covered an earlier form, a bare `stat` whose failure exited 1; the current form exits 3 when `stat` fails and otherwise 0, leaving the modes and owners to read, and is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026. Check the live file, its directory, and every existing sidecar while the application is running: REASONED: following block; reading the mode and owner fields rests on the guide's cited GNU Coreutils v9.7 `stat` manual entry, and the expected modes rest on the recorded umask runs, not on a source. The recorded local WAL and rollback-journal runs compare umask 022 with 077, but no second account was available to exercise ownership by a separate application account. Expected exposed modes are 755/644 and fixed modes 700/600, with ownership by the app user; the recorded tests used one account and ran an earlier form of the block, as the paragraph above records. ```bash ( # a subshell, so the exits below end only the subshell if stat -c '%a %U %n' /var/lib/myapp /var/lib/myapp/app.db*; then # 700 on the directory, 600 on app.db and any -wal/-shm/-journal sidecars, each owned by the app user; an absent sidecar says nothing about its future mode echo "unclassified observation: read the modes and owners above: 700 on the directory and 600 on each file, each owned by the app user" exit 0 else echo "stat exit $?: a path above could not be read (run this as root or the app user when the directory is private); inconclusive, so read the error above" exit 3 fi ) ``` REASONED: preceding block; the `stat` check above and the combined account of the three checks below were only partly demonstrated in the earlier plain-file marking review: no SQLite connection or second account was available then. Reading the output rests on the guide's cited GNU Coreutils v9.7 `stat` manual entry; the expected exposed modes 755/644 and fixed modes 700/600 rest on the recorded umask runs in the `stat` item below rather than on a source, and the fixed state also expects ownership by the app user. The three checks were re-run during the earlier plain-file marking review (October 2026) using only bash, grep, git, `stat` and a pseudo-terminal for the bundle scan's hidden prompt, on local fixtures, as recorded in the paragraphs around those blocks above; each separated its exposed and fixed states. The runs of all three covered earlier forms; the bundle scan and the git inventory were re-run on their current forms on 31 October 2026, and the `stat` check's current form is covered by gate passes and stub runs, as recorded at each step. The earlier `stat` re-run read plain files created under each umask because the `sqlite3` command was refused in that marking environment. The 15 October 2026 local marking review re-observed WAL and rollback-journal modes on open connections with Python SQLite 3.46.1; ownership by a separate app user was not exercised in either review. On the authoring host, without opening any socket, these three checks ran in exposed and fixed states. - DEMONSTRATED: re-run during marking review (October 2026) under bash 5.3.9 and GNU grep 3.12 with a generated JWT-shaped placeholder, not a real token, written to the hidden prompt through a pseudo-terminal; the exposed, clean and guard observations are in the items below. These runs, and those in the items below, covered an earlier form, whose result arms ended with status 0; the current form's 31 October 2026 re-run is recorded before the block. **Bundle scan.** It ran with a generated JWT-shaped placeholder, not a real token, typed at its hidden prompt. - DEMONSTRATED: exposed state, re-run during marking review (October 2026) with the placeholder in a fixture `dist/app.js`: it printed `dist/app.js` before `token-literal exit: 0` and again before `jwt-shape exit: 0`; the clean comparison is the next item. A bundle carrying the placeholder printed `token-literal exit: 0` and `jwt-shape exit: 0`. - DEMONSTRATED: fixed state, re-run during marking review (October 2026) with a fixture `dist/app.js` that lacks the placeholder: it printed `token-literal exit: 1` and `jwt-shape exit: 1`. A clean bundle printed `1` for both. - DEMONSTRATED: re-run during marking review (October 2026): with none of `build`, `dist` or `.next/static` present the block printed `none of the bundle directories exist here; not scanning` and exited 2 without prompting, while with `dist` present it prompted and scanned. With none of the named directories present, the block refused to scan. - DEMONSTRATED: re-run during marking review (October 2026) with `tok` readonly in the calling shell: the block then current printed the refusal quoted here, without prompting, and exited 2, while the block before this change, taken from the guide's git history, prompted, reported `tok: readonly variable` and printed `token input failed; not scanning`. With `tok` readonly in the calling shell, the block refused before prompting: `a readonly tok is set in this shell; not scanning` (exit 2). The block before this change failed at the prompt instead ("token input failed"). - DEMONSTRATED: re-run during marking review (October 2026) with `tok` a nameref to `PATH` in the calling shell: the block then current found `grep` and printed `0` for both lines, while the block before this change, taken from the guide's git history, printed `grep: command not found` and `127` for both. With `tok` a nameref to `PATH` in the calling shell, the block scanned as in the placeholder run, printed `0` for both lines, and left `PATH` intact. The block before this change wrote the token into `PATH`, so `grep` was not found and both lines printed `127`. - DEMONSTRATED: re-run during marking review (October 2026) with `declare -l tok` in the calling shell and a mixed-case placeholder: the block then current printed `0` for both lines, while the block before this change, taken from the guide's git history, printed `token-literal exit: 1` and `jwt-shape exit: 0`. With `declare -l tok` inherited, the block printed `0` for both lines. The block before this change lower-cased the token and printed `token-literal exit: 1`, a false clean result with the token in the bundle. - DEMONSTRATED: re-run during marking review (October 2026) with `IFS` readonly in the calling shell: the block then current printed the refusal quoted here, without prompting, and exited 2, while the block before this change, taken from the guide's git history, reported `IFS: readonly variable`, prompted, and printed `0` for both lines. With `IFS` readonly in the calling shell, the block refused before prompting: `a readonly IFS is set in this shell; not scanning` (exit 2). The block before this change prompted and scanned with the readonly `IFS` in force. - DEMONSTRATED: re-run during marking review (October 2026) with git 2.53.0 in three throwaway repositories outside any checkout, each holding `app.db` and its three sidecars as plain files; the exposed and fixed observations are in the items below. **Git inventory.** It was run in three throwaway repositories. These runs, and those in the items below, covered an earlier form of three top-level commands that named `app.db` and its sidecars directly; the current form takes the database path on its `set --` line, and its 31 October 2026 re-run is recorded before the block. - DEMONSTRATED: exposed state, re-run during marking review (October 2026): `git check-ignore -v` listed `app.db-wal`, `app.db-shm` and `app.db-journal` against `.gitignore:1:app.db*` but not `app.db`, `git ls-files` printed `app.db`, and `git log` printed its commit; the fixed comparison is the last item. With `app.db` committed before an `app.db*` ignore rule was added, `git check-ignore -v` listed the sidecars but not the tracked `app.db`. `git ls-files` printed `app.db`, and `git log` printed its commit. - DEMONSTRATED: exposed state, re-run during marking review (October 2026): after a commit of `git rm --cached app.db`, `git ls-files` printed nothing and `git log` printed both commits touching `app.db`; the fixed comparison is the last item. After `git rm --cached`, `git log` still printed both commits touching it. - DEMONSTRATED: fixed state, re-run during marking review (October 2026): `git check-ignore -v` listed all four paths against `.gitignore:1:app.db*`, and `git ls-files` and `git log` printed nothing. With the rule in place and the file never committed, only the rule matches printed. - REASONED: this stat checklist as a whole; the first three child items' mode comparisons are DEMONSTRATED and ownership in the last is REASONED, per the guide's cited GNU Coreutils v9.7 `stat` manual (checked October 2026; GNU syntax) and SQLite temporary and sidecar file naming, -wal, -shm and -journal (rolling documentation, checked September 2026) entries. Local marking review (15 October 2026, Python SQLite 3.46.1, uutils coreutils 0.8.0 `stat`): `stat -c` on an open WAL connection and an open rollback-journal write transaction printed 755/644 under umask 022 and 700/600 under 077, with the one test account as owner and fixture paths in `%n`; the stat block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`, both with no output. Expected exposed modes are 755/644; fixed modes are 700/600 with ownership by the application account. A separately owned live application file inventory was not exercised and its availability is unknown; no second authorized account was available, as the checklist records, so application ownership and future sidecar modes remain unobserved. **`stat` check.** A connection was held open while `stat` ran: in WAL mode, and separately with a write transaction in rollback-journal mode. These runs, and those in the items below, covered an earlier form of the block; how its current form was checked is recorded before the block. - DEMONSTRATED: the WAL-mode exposed and fixed mode comparison, per the guide's cited GNU Coreutils v9.7 `stat` manual (checked October 2026; GNU syntax) and SQLite temporary and sidecar file naming, -wal, -shm and -journal (rolling documentation, checked September 2026) entries. Local marking review (15 October 2026, Python SQLite 3.46.1, uutils coreutils 0.8.0 `stat`): `stat -c` on an open WAL connection and an open rollback-journal write transaction printed 755/644 under umask 022 and 700/600 under 077, with the one test account as owner and fixture paths in `%n`; the stat block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`, both with no output. Under umask 022 the directory printed 755 and app.db, -wal and -shm printed 644; under 077 they printed 700 and 600 respectively. Under umask `022` the directory was `755`, and `app.db`, `app.db-wal` and `app.db-shm` were `644`. - DEMONSTRATED: the WAL-mode exposed and fixed mode comparison, per the guide's cited GNU Coreutils v9.7 `stat` manual (checked October 2026; GNU syntax) and SQLite temporary and sidecar file naming, -wal, -shm and -journal (rolling documentation, checked September 2026) entries. Local marking review (15 October 2026, Python SQLite 3.46.1, uutils coreutils 0.8.0 `stat`): `stat -c` on an open WAL connection and an open rollback-journal write transaction printed 755/644 under umask 022 and 700/600 under 077, with the one test account as owner and fixture paths in `%n`; the stat block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`, both with no output. Under umask 022 the directory printed 755 and app.db, -wal and -shm printed 644; under 077 they printed 700 and 600 respectively. Under umask `077` they were `700` and `600`. - DEMONSTRATED: the rollback-journal exposed and fixed mode comparison, per the guide's cited GNU Coreutils v9.7 `stat` manual (checked October 2026; GNU syntax) and SQLite temporary and sidecar file naming, -wal, -shm and -journal (rolling documentation, checked September 2026) entries. Local marking review (15 October 2026, Python SQLite 3.46.1, uutils coreutils 0.8.0 `stat`): `stat -c` on an open WAL connection and an open rollback-journal write transaction printed 755/644 under umask 022 and 700/600 under 077, with the one test account as owner and fixture paths in `%n`; the stat block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`, both with no output. With the write transaction open, app.db-journal printed 644 under umask 022 and 600 under 077. With a write transaction held open in rollback-journal mode, `app.db-journal` was `644` under umask `022` and `600` under `077`, the same as `app.db`. - REASONED: ownership across security domains, per the guide's cited GNU Coreutils v9.7 `stat` manual (checked October 2026; GNU syntax) and SQLite temporary and sidecar file naming, -wal, -shm and -journal (rolling documentation, checked September 2026) entries. Local marking review (15 October 2026, Python SQLite 3.46.1, uutils coreutils 0.8.0 `stat`): `stat -c` on an open WAL connection and an open rollback-journal write transaction printed 755/644 under umask 022 and 700/600 under 077, with the one test account as owner and fixture paths in `%n`; the stat block passed `bash -n` and ShellCheck 0.11.0 with `-s bash`, both with no output. The one-account fixture printed the test account for every path; expected exposed ownership is an unintended account and fixed ownership is the application account, but a separately owned live application file was not exercised (availability unknown) and no second authorized account was available, as this item records. Each was owned by the one account that ran the test. Ownership by a separate app user was not exercised, because no second account was available. REASONED: needs a running LiteFS, or Litestream with metrics or MCP enabled, plus an authorized peer and an untrusted network vantage, and none was available during marking review (October 2026). The declaration rests on the guide's cited LiteFS configuration (http.addr, lease.advertise-url, default port 20202), LiteFS listener and configuration (pinned tag v0.5.14) and Litestream configuration (metrics addr, MCP mcp-addr) (Litestream MCP listener in v0.5.0 and later) entries. Expected exposed outcome: an HTTP response at the untrusted vantage; fixed: the authorized peer's control answers and the untrusted vantage gets none, corroborated with the bind address, routing, proxy configuration and firewall. The probe block passed `bash -n` and ShellCheck 0.11.0 with no findings in that review. The probe block's guards were exercised during marking review (October 2026) under bash 5.3.9 with a Bash curl stub. The written placeholder printed `substitute the listener URL; not probing`, `ftp://example.test` and `https://` printed `HTTP or HTTPS URL required; not probing`, an extra value printed `exactly one URL required; not probing`, and omission of the initial `set --` printed `paste the whole block; not probing`, each exiting 2 without calling curl. A valid dummy HTTP URL reached the stub, printed its synthetic `http=200 exit=0 err=` line and exited 0; no listener or network isolation was exercised. Those runs covered an earlier form whose request set no exit status. The current form takes `authorized` or `untrusted` on its `set --` line and exits 1 when the untrusted vantage gets an HTTP response, 0 when the authorized control answers or curl cannot connect from the untrusted vantage, and 3 otherwise. It is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026. For LiteFS (`20202`) or an enabled Litestream metrics/MCP listener (REASONED: needs a running LiteFS or Litestream, which opens listeners; the authoring host forbids that without an isolated network namespace, and none was available), prove network isolation from an untrusted vantage. Run it first from an authorized peer against the private URL (a response is the positive control), then from an untrusted network against the same public address and port: REASONED: following block; LiteFS and Litestream listener isolation follows the cited API and configuration sources; no isolated network namespace was available to run listeners. ```bash ( set +e # errexit off here, so a request that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_LISTENER_PROBE_URL' 'REPLACE_WITH_authorized_OR_untrusted' # the 2nd value is where you run this from: authorized (the positive control, against the private URL) or untrusted (against the public address and port) [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "exactly one URL and one vantage required; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*) echo "substitute the listener URL; not probing"; exit 2 ;; esac case "$1" in http://?*|https://?*) ;; *) echo "HTTP or HTTPS URL required; not probing"; exit 2 ;; esac case "$2" in authorized|untrusted) ;; *) echo "substitute authorized or untrusted, where you run this from; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # URL, vantage, curl's exit status, then its result line printf '%s' "$4" case "$2:$3" in authorized:0) echo "the authorized control answered"; set -- 0 ;; authorized:*) echo "the authorized control got no HTTP response; inconclusive"; set -- 3 ;; untrusted:0) echo "FINDING: an HTTP listener answered at the untrusted vantage"; set -- 1 ;; untrusted:7) echo "curl could not connect from the untrusted vantage: the pass only alongside an authorized control that answered"; set -- 0 ;; *) echo "curl exit $3 at the untrusted vantage: a timeout, DNS or TLS error is inconclusive"; set -- 3 ;; esac echo "block exit $1; the bind address, routing, proxy configuration and firewall that corroborate this result are left for you to judge" exit "$1" ) ``` REASONED: the listener probe above, this paragraph's reading of its result, and the Turso introduction, per the guide's cited LiteFS route dispatch and reads (pinned tag v0.5.14), Litestream configuration (metrics addr, MCP mcp-addr) (Litestream MCP listener in v0.5.0 and later) and Turso SQL over HTTP (`/v2/pipeline`, bearer authentication) entries. The declaration of the listener probe above is the paragraph before its block, and that of the Turso check below is the paragraph after its block. No LiteFS or Litestream listener, untrusted vantage, Turso database or token was available during marking review (October 2026). Expected exposed outcome: any HTTP response at the untrusted vantage; fixed: none there while the authorized control answers. The listener and Turso blocks passed `bash -n` and ShellCheck 0.11.0 with no findings in that review; their guards were exercised during marking review (October 2026) under bash 5.3.9 with a Bash curl stub: written placeholders, `ftp://example.test`, bare `https://`, extra values and omitted initial `set --` lines each produced the corresponding refusal and exited 2 without calling curl. The Turso block also refused readonly `turso_tok`, readonly `IFS`, an empty token, a placeholder token and a token containing a tab, each exiting 2 without calling curl. Through a pseudo-terminal its hidden prompt did not echo the supplied token; the valid dummy token reached the authorized curl stub on stdin as `Authorization: Bearer dummy.token.review` followed by a newline and was absent from both recorded curl argument lists. The valid listener and Turso cases exited 0 using synthetic HTTP 200 output; no live-service behavior, shell-history behavior or inherited tracing behavior was exercised by these runs. Those runs covered earlier forms of both blocks, whose requests set no exit status; the current forms are covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026, as recorded beside each block. Any HTTP response from the untrusted vantage shows that an HTTP listener answered. Confirmed direct reachability to LiteFS exposes its unauthenticated endpoints, subject to their role and state conditions above; a fronting proxy can return its own denial or expose only selected paths, so its response alone does not establish reachability to the whole LiteFS API. A timeout, DNS, or TLS error, or a failed authorized control, is inconclusive, so corroborate with the bind address, routing, proxy configuration, and firewall. For Turso (REASONED: this outbound check needs a Turso database and token, and none was available to the authoring environment), prove the HTTP database rejects anonymous queries. The cited Turso SQL-over-HTTP reference supplies the anonymous/authorized discriminator; no Turso database and token were available. Have a valid database token from the secret store ready to paste. The block uses Bash's terminal-only hidden input and tests the resulting value for control characters before constructing the header; Bash `read` ignores NUL and reads one line, so this is not a claim that every malformed paste is rejected. Paste the token alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. This guide's assessment, assuming the clean shell required by CONTRIBUTING rule 7 and a token pasted only at the prompt, is that clearing the token's attributes, disabling allexport and tracing, and feeding the builtin `printf` output to curl's `--header @-` keep that value out of curl's argv, the exported environment, shell-parser history and subsequent `set -x` output. These documented primitives do not prove that no secret can leak, and the running account remains outside this protection: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n turso_tok && unset -v turso_tok; } 2>/dev/null || { echo "a readonly turso_tok is set in this shell; not probing"; exit 2; } { unset -n IFS; } 2>/dev/null || { echo "a readonly IFS is set in this shell; not probing"; exit 2; } set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_DATABASE_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "exactly one URL required; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*) echo "substitute the HTTPS database URL; not probing"; exit 2 ;; esac case "$1" in https://?*) ;; *) echo "HTTPS required; not probing"; exit 2 ;; esac IFS= read -r -s -p 'Turso database token (input hidden): ' turso_tok < /dev/tty || { echo "token input failed; not probing"; exit 3; } printf '\n' case "$turso_tok" in ""|*REPLACE_WITH_*|*[[:cntrl:]]*) echo "paste a valid database token, with no control characters; not probing"; exit 2 ;; esac set -- 0 "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -H 'Content-Type: application/json' \ --data '{"requests":[{"type":"execute","stmt":{"sql":"SELECT 1"}},{"type":"close"}]}' \ -w '\nanonymous http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${1%/}/v2/pipeline"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # the block's status so far (3, inconclusive, outranks 0), the URL, curl's exit status, then the body and result line printf '%s' "$4" set -- "$1" "$2" "$3" "${4##*anonymous http=}" # the result line from its HTTP code on case "$3:$4" in 0:'401 '*|0:'403 '*) echo "the anonymous request was rejected with HTTP ${4%% *}" ;; 0:2*) echo "the anonymous request answered HTTP ${4%% *}, so its body decides: a successful SELECT 1 result is the exposure and a statement error is not; inconclusive until you read it"; set -- 3 "$2" ;; 0:*) echo "the anonymous request answered HTTP ${4%% *}, neither a 401 or 403 rejection nor a 2xx; inconclusive"; set -- 3 "$2" ;; *) echo "curl exit $3 for the anonymous request: a transport failure is inconclusive"; set -- 3 "$2" ;; esac set -- "$1" "$2" "$(if printf 'Authorization: Bearer %s\n' "$turso_tok" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --header @- \ -H 'Content-Type: application/json' \ --data '{"requests":[{"type":"execute","stmt":{"sql":"SELECT 1"}},{"type":"close"}]}' \ -w '\nauthorized http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${2%/}/v2/pipeline"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # status, URL, curl's exit status, then the body and result line printf '%s' "$4" set -- "$1" "$2" "$3" "${4##*authorized http=}" # the result line from its HTTP code on case "$3:$4" in 0:2*) echo "the authorized request answered HTTP ${4%% *}; read its body for the SELECT 1 result" ;; 0:*) echo "the authorized request answered HTTP ${4%% *}, so the positive control failed; inconclusive"; set -- 3 "$2" ;; *) echo "curl exit $3 for the authorized request: a transport failure is inconclusive"; set -- 3 "$2" ;; esac echo "block exit $1; both response bodies above are left for you to judge: the authorized one must carry a result containing 1, and a successful anonymous result is the exposure" exit "$1" ) ``` REASONED: preceding block; the Turso check above and the outcomes stated here, per the guide's cited Turso SQL over HTTP (`/v2/pipeline`, bearer authentication) entry. No Turso database and token was available during marking review (October 2026). Expected exposed outcome: the anonymous request returns a successful `SELECT 1` result; fixed: it is rejected and returns no successful result while the authorized request returns `1`. The block passed `bash -n` and ShellCheck 0.11.0 with no findings in that review. The block's guards were exercised during marking review (October 2026) under bash 5.3.9 with a Bash curl stub. The written URL placeholder, `ftp://example.test`, bare `https://`, an extra value, omission of the initial `set --`, readonly `turso_tok`, readonly `IFS`, an empty token, a placeholder token and a token containing a tab each printed the corresponding refusal and exited 2 without calling curl. A valid dummy token supplied through the hidden pseudo-terminal prompt was not echoed, reached the authorized stub on stdin as `Authorization: Bearer dummy.token.review` followed by a newline, and was absent from both recorded curl argument lists. That case printed synthetic `anonymous http=200 exit=0 err=` and `authorized http=200 exit=0 err=` lines and exited 0; the stub emitted no query result, so anonymous rejection and authorized query success remain REASONED. Those runs covered an earlier form whose requests set no exit status and whose failed hidden-prompt read exited 2. The current form exits 3 when the hidden-prompt read fails, when either request gets no HTTP response, when the authorized request answers other than 2xx, or when the anonymous request answers other than 401 or 403, and otherwise 0, leaving both bodies for you to read. It is covered by the gate suite's `bash -n` and ShellCheck 0.11.0 passes and by stub runs on 31 October 2026. The authorized request must return a result containing `1`; the identical anonymous request must be rejected and return no successful query result. Anonymous query success is exposure, and an HTTP 200 alone is insufficient because the body can carry a statement error; transport failures are inconclusive. ## Sources (checked September 2026) Applicability checked on 2026-09-18: SQLite 3.x documentation (local file tests on SQLite 3.46.1); LiteFS HTTP behavior traced to v0.5.14; Litestream MCP documented for v0.5.0 and later; Turso rolling CLI documentation and the SQL-over-HTTP `/v2/pipeline` protocol. Installed Turso, Litestream, and LiteFS versions were not available for runtime confirmation. The Verify commands require Bash, curl, GNU-compatible grep and stat, and Git; the shell, HTTP-client and scan behaviour is cited at bash-5.3, curl-8_14_1 and v3.12 respectively (checked October 2026), without asserting a minimum version for the complete checks. - SQLite security (rolling documentation, checked September 2026): https://www.sqlite.org/security.html - Turso HTTP API quickstart (`TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`): https://docs.turso.tech/sdk/http/quickstart - Turso CLI `db tokens create` (`--read-only`, `--expiration`): https://docs.turso.tech/cli/db/tokens/create - Litestream guides (supported replica destinations): https://litestream.io/guides/ - Litestream S3 guide (credentials, scoped IAM policy): https://litestream.io/guides/s3/ - LiteFS overview (cluster replication, pre-1.0 status, backup recommendation): https://docs.fly.io/litefs - SQLite serverless architecture, no server process; OS and filesystem only (rolling documentation, checked September 2026): https://www.sqlite.org/serverless.html - SQLite temporary and sidecar file naming, -wal, -shm and -journal (rolling documentation, checked September 2026): https://www.sqlite.org/tempfiles.html - SQLite Encryption Extension (licensed; the public build cannot read an encrypted file): https://www.sqlite.org/see/doc/trunk/www/readme.wiki - SQLCipher (third-party encrypted-SQLite build): https://www.zetetic.net/sqlcipher/ - SQLite extension loading, disabled by default and enabled in the CLI (rolling documentation, checked September 2026): https://sqlite.org/loadext.html - LiteFS configuration (http.addr, lease.advertise-url, default port 20202): https://docs.fly.io/litefs/config - LiteFS listener and configuration (pinned tag v0.5.14): default address, plain TCP and h2c server, h2c-only client, mount flags, environment expansion, default config search and explicit path, and example API bind. LiteFS listener and configuration sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L32-L35, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/config.go#L57, https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L76-L99, https://github.com/superfly/litefs/blob/v0.5.14/http/client.go#L32-L43. More LiteFS listener and configuration sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L76-L110, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/config.go#L219-L233, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/config.go#L288-L333, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/etc/litefs.yml#L54-L58 - LiteFS route dispatch and reads (pinned tag v0.5.14): no credential gate, export including WAL pages, primary-only HTTP/2 stream, database enumeration and filtering, snapshots, position-map decoding, info, events, and unconditional debug routes including rand. LiteFS route dispatch and reads sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L134-L268, https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L320-L346, https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2682-L2776, https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L495-L520. More LiteFS route dispatch and reads sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L526-L585, https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L686-L699, https://github.com/superfly/litefs/blob/v0.5.14/http/http.go#L15-L43, https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L271-L292. More LiteFS route dispatch and reads sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L779-L803, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L487-L488, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1661-L1713 - LiteFS mutations and role checks (pinned tag v0.5.14): import handler and database method, replica primary context, local tx application and validation, per-database halt acquisition and release, expiration defaults and enforcement, and replication write locks. LiteFS mutations and role checks sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L294-L318, https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2779-L2811, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L139-L141, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L444-L478. More LiteFS mutations and role checks sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1904-L1910, https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L461-L493, https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2387-L2448, https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2452-L2609. More LiteFS mutations and role checks sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L348-L407, https://github.com/superfly/litefs/blob/v0.5.14/db.go#L215-L325, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L39-L41, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1455-L1465. More LiteFS mutations and role checks sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1525-L1531 - LiteFS leasing and handoff (pinned tag v0.5.14): promote eligibility, primary and subscriber checks, Consul URL derivation, static configuration and primary URL on replicas, replication destination, and static handoff refusal. LiteFS leasing and handoff sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L409-L459, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L408-L432, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L329-L348, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L241-L244. More LiteFS leasing and handoff sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/lease.go#L99-L131, https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1384-L1387, https://github.com/superfly/litefs/blob/v0.5.14/lease.go#L168-L170 - LiteFS optional proxy and image (pinned tag v0.5.14): proxy startup condition and required fields, example proxy address, and Dockerfile without configuration or EXPOSE. LiteFS optional proxy and image sources (pinned tag v0.5.14): https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L519-L524, https://github.com/superfly/litefs/blob/v0.5.14/http/proxy_server.go#L131-L144, https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/etc/litefs.yml#L60-L68, https://github.com/superfly/litefs/blob/v0.5.14/Dockerfile - Go standard library defaults the LiteFS listener and debug routes inherit (a host-less or empty address listens on every unicast address, with the port chosen automatically when empty; expvar serves `cmdline` and `memstats`; pprof's `cmdline` responds with the running program's command line): https://pkg.go.dev/net#Listen, https://pkg.go.dev/expvar and https://pkg.go.dev/net/http/pprof - Go h2c handler (ordinary HTTP/1 requests pass to the underlying handler): [NewHandler documentation](https://pkg.go.dev/github.com/golang/net/http2/h2c#NewHandler). - Docker container reachability: [bridge peers and publication](https://docs.docker.com/engine/network/drivers/bridge/), [host networking](https://docs.docker.com/engine/network/drivers/host/), and [shared container networking stacks](https://docs.docker.com/engine/network/#container-networks). - Litestream configuration (metrics addr, MCP mcp-addr) (Litestream MCP listener in v0.5.0 and later): https://litestream.io/reference/config/ - Turso SQL over HTTP (`/v2/pipeline`, bearer authentication): https://docs.turso.tech/sdk/http/reference - Git v2.56.0 `gitignore` documentation (checked October 2026; tracked files are unaffected): https://github.com/git/git/blob/v2.56.0/Documentation/gitignore.adoc#L15-L18 - Git v2.56.0 `git-rm` documentation (checked October 2026; removal from the index alone or from the index and working tree): https://github.com/git/git/blob/v2.56.0/Documentation/git-rm.adoc#L17-L28 - Git v2.56.0 `git-log` documentation (checked October 2026; shows commit logs): https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L16-L16 - GNU Coreutils v9.7 `stat` manual (checked October 2026; GNU syntax): [`-c` format](https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L12989-L12995), [`%a` octal permissions](https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L13046), [`%n` file name](https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L13062), and [`%U` owner name](https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L13073). - GNU Bash reference manual bash-5.3 (checked October 2026): [single and double quotes (450-487)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n450); [pipelines, status negation, sequential and conditional lists, while, for, if and case (769-1084)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n769); [subshell and brace groups (1397-1440)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n1397); [positional parameters, quoted "$@", "$#" and "$?" (1866-1948)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n1866); [unset-only default expansion (2288-2300)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n2288); [word splitting and null IFS (2993-3015)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n2993). - GNU Bash reference manual bash-5.3 (checked October 2026): [literal, wildcard, bracket and character-class patterns (3131-3200)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3131); [exit status and EXIT traps (4350-4358)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4350); [shift and test arguments and status (4582-4616)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4582); [echo output (5216-5234)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5216); [set -- positional assignment (6182-6185)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6182); [directory, string and integer conditions (8232-8405)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n8232). - GNU Bash reference manual bash-5.3 (checked October 2026): [shortest prefix and suffix removal (2580-2617)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n2580); [printf formatting, string arguments and status (5466-5546)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5466); [input/output redirection and open failures (3280-3414)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3280); [command-prefix assignments (3872-3880)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3872); [trap reset and DEBUG/RETURN/ERR conditions (4737-4808)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4737); [unset -n/-v and readonly failure (4852-4876)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4852). - GNU Bash reference manual bash-5.3 (checked October 2026): [declare -l lowercase conversion (5156-5159)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5156); [nameref, readonly and export attributes (5161-5195)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5161); [read line assignment and IFS (5548-5569)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5548); [read -p terminal prompt (5625-5627)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5625); [read -r literal backslashes and -s terminal echo suppression (5629-5637)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5629); [read NUL handling and failure status (5659-5669)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5659). - GNU Bash reference manual bash-5.3 (checked October 2026): [set -a export attribute (5917-5921)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5917); [set -e and its exceptions (5929-5944)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5929); [set -x expanded-command tracing (6122-6128)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6122); [plus options disable settings (6194-6203)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6194); [history records shell-parser input (7274-7295)](https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n7274). - curl curl-8_14_1 (checked October 2026): [-q as the first parameter suppresses curlrc (4-20)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/disable.md#L4-L20); [-g disables curl URL globbing (4-22)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/globoff.md#L4-L22); [--noproxy and the all-hosts wildcard (18-28)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/noproxy.md#L18-L28); [connection timeout, including DNS and handshakes (19-28)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/connect-timeout.md#L19-L28); [per-transfer timeout (21-27)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/max-time.md#L21-L27); [-s suppresses progress and errors, not requested output (4-25)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/silent.md#L4-L25). - curl curl-8_14_1 (checked October 2026): [-S restores error messages with -s (4-19)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/show-error.md#L4-L19); [-w output and format substitutions (4-33)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L4-L33); [write-out is independent of transfer success (49-53)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L49-L53); [errormsg and exitcode added in 7.75.0 (75-79)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79); [http_code is the last retrieved response code (105-107)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L105-L107); [TLS verification by default; -k skips it (4-30)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/insecure.md#L4-L30). - curl curl-8_14_1 (checked October 2026): [HTTP response codes do not indicate failure by default (24-28)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/fail.md#L24-L28); [-o /dev/null suppresses response bodies (4-53)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/output.md#L4-L53); [--data sends POST data (4-49)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/data.md#L4-L49); [-H/--header overrides internal headers, passes control characters verbatim, and @- reads one header per stdin line (24-49)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md). - GNU grep documentation v3.12 (checked October 2026): [-f reads one pattern per line; - means stdin (197-207)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n197); [-l prints matching file names and stops at the first match per file (342-351)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n342); [default binary handling, suppression and matching limits (621-668)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n621). More GNU grep documentation v3.12 sources (checked October 2026): [-r recurses, follows command-line symlinks and skips encountered symlinks (757-769)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n757); [-- terminates options (788-794)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n788); [exit 0 for a match, 1 for none, 2 for error, with the -q exception (1110-1115)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1110). - GNU grep documentation v3.12 (checked October 2026): [-E selects extended regular expressions (1142-1148)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1142); [-F selects fixed strings (1150-1156)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1150); [ordinary regexp characters (1274-1277)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1274); [{n,} repeats at least n times (1316-1320)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1316); [bracket expressions match one listed character (1363-1373)](https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1363). - curl curl-8_14_1 behaviour used by this guide's probes: the cited `-q`, `-g` and `--noproxy '*'` controls suppress curlrc, URL globbing and proxies; `--connect-timeout 5` bounds connection setup and `--max-time 20` bounds each transfer. The cited `-sS` combination hides progress while retaining errors, `-o /dev/null` suppresses bodies, and `-w` prints the response code, transfer exit code and error text even on failure. HTTP status is separate from transfer success. For Turso, the cited `--data` selects POST and `-H 'Content-Type: application/json'` replaces curl's default content type; the request payload and the required anonymous/authorized outcomes remain this guide's procedure using the Turso reference, not curl's guarantee. The cited default TLS checks support the requirement never to add `-k`. ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - sqlite-rolling: SQLite documentation (rolling) (unknown) - mcp: Litestream MCP minimum (v0.5.0) - see: SQLite Encryption Extension (unknown) - turso: Turso documentation (unknown) - litestream: Litestream documentation (unknown) - cipher: SQLCipher (unknown) - litefs: LiteFS source (v0.5.14) - litefs-docs: LiteFS documentation (unknown) - go: Go library documentation (unknown) - docker: Docker documentation (unknown) - git: Git documentation (v2.56.0) - coreutils: GNU Coreutils stat manual (v9.7) - bash: GNU Bash reference manual (bash-5.3) - curl: curl (curl-8_14_1) - grep-docs: GNU grep documentation (v3.12) Claims (full source mapping in the JSON block below): - filesystem (REASONED; sqlite-rolling): No server, network listener or database-side authentication; filesystem authority controls access, including ATTACH. Treat files writable across security domains as suspect. - extensions (REASONED; sqlite-rolling): Core extension loading defaults off, while the CLI enables it; enable only when required and load trusted libraries. - web-files (REASONED; sqlite-rolling): Keep databases, sidecars, temporary files, exports and backups outside web-served paths; inspect aliases, symlinks, download routes and publishing, and do not rely on hidden names. - git-files (REASONED; sqlite-rolling, git): Ignore database extensions and each -wal, -shm and -journal suffix before committing; ignore rules do not remove tracked files or history. This guide assesses committed database rows as permanently available to anyone who clones, even after a later deletion commit. - permissions (REASONED; sqlite-rolling): Dedicated account, directory 700, database and existing sidecars 600, and launch umask 077; same-account processes and privileged users remain outside this isolation. - temporary (REASONED; sqlite-rolling): Protect temporary files and backups; Unix SQLITE_TMPDIR needs an existing private directory and may fall back elsewhere. Do not delete live sidecars to repair permissions. - encryption (REASONED; see, cipher): Public-domain SQLite has no file encryption; SEE is licensed and SQLCipher is a third-party alternative, otherwise use filesystem or volume encryption. - encryption-keys (REASONED; see, sqlite-rolling): Keep keys outside databases and backups; SEE TEMP tables can be unencrypted. Treat credential-bearing copies as secrets and rotate credentials after a leak. - turso-http (REASONED; turso): Turso serves libSQL over HTTPS at the database/organization turso.io URL using bearer authentication; TURSO_DATABASE_URL and TURSO_AUTH_TOKEN belong in application configuration, never a client bundle or git. - turso-scope (REASONED; turso): db tokens create --read-only scopes away writes; --expiration accepts never or durations such as 7d3h. Prefer scoped, expiring tokens. - replicas (REASONED; litestream): Litestream replicates to supported object stores; scope AWS credentials and IAM to the required bucket/prefix, keep replicas private, and restore with scoped credentials. - metrics (REASONED; litestream): Litestream addr metrics listener defaults disabled; leave off unless needed, otherwise bind loopback, for example 127.0.0.1:9090. - mcp (REASONED; mcp): Litestream MCP mcp-addr is documented for v0.5.0 and later, defaults disabled and has no built-in authentication; protect information and restore access with an authenticated tunnel/proxy, for example at loopback:3001. - litefs-backups (REASONED; litefs-docs): LiteFS replicates among cluster nodes; its pre-1.0 documentation recommends separate off-site backups with private destinations. - litefs-transport (REASONED; litefs, go): LiteFS v0.5.14 routes have no credential gate; the server accepts cleartext HTTP/1 and h2c, and its client uses h2c. Litefs-Id comparisons reject self-connections, not unauthenticated callers. - litefs-bind (REASONED; litefs, go): http.addr defaults to host-less :20202, including the example; an empty address still listens on all interfaces at a system-chosen port. - litefs-config (REASONED; litefs): Set API bind in the config file; no direct address flag. Search working directory, available home directory, then /etc/litefs.yml; -config overrides and -no-expand-env disables ${VAR} expansion. - import (REASONED; litefs): POST /import creates or replaces a named database on the primary only; replicas return 503 before creation without forwarding, and DB.Import also checks primary status. - export (REASONED; litefs): GET /export returns the full existing local database, including applicable WAL pages, on either role; missing databases return 404. - stream (REASONED; litefs): POST /stream requires primary, HTTP/2 and a valid position map; replicas return 503. Unfiltered streams cover every database, filters narrow them, and fresh positions receive snapshots. - tx (REASONED; litefs): POST /tx applies a validated LTX file to an existing local database on either role without forwarding, primary or halt-owner checks; non-snapshots must match next transaction/checksum, missing databases return 404. - halt (REASONED; litefs): POST /halt on either role creates the database if absent and acquires write locks with a nonzero id, stalling primary writes or replica updates; DELETE releases an existing lock for a matching id. - halt-ttl (REASONED; litefs): LiteFS halt-lock TTL defaults to 30 seconds. - promote (REASONED; litefs): POST /promote needs an eligible candidate, succeeds unchanged for an existing primary, and asks a known primary for handoff from a candidate replica; no database name is required. - handoff (REASONED; litefs): POST /handoff requires primary, a connected target subscriber and a handoff-capable lease; static leases refuse it and no database name is required. - information (REASONED; litefs): GET /info, /events and /metrics disclose node, cluster and database state on either role without a named database. - debug (REASONED; litefs, go): Unconditional /debug/pprof/ exposes command line and live profiles; /debug/vars exposes command line, memory and store database/lock state on either role without a database name. - debug-rand (REASONED; litefs): /debug/rand streams deterministic pseudo-random data; its minute timeout is checked between writes and no server write timeout bounds a slow or non-reading client. - cluster-private (REASONED; litefs, litefs-docs): Bind each LiteFS API to its private node address and restrict reachability to cluster nodes; never publish 20202 through an internet-facing mapping or proxy. - consul-url (REASONED; litefs): Consul lease.advertise-url names each node private API; when unset it derives http:// plus lease.hostname or OS hostname and the actual listening port. - static-url (REASONED; litefs): Static leasing uses lease.advertise-url as configured: the primary points at itself and each replica at the primary private API. - container (REASONED; litefs, docker): LiteFS image supplies no configuration or EXPOSE; default wildcard binding applies within its namespace. Peers, host/shared networking, routing, firewall and publication determine reachability. - proxy (REASONED; litefs): LiteFS application proxy is separate: proxy.target enables attempted startup, proxy.db and proxy.addr must be nonempty, example :8080; application TLS and authentication remain necessary. - verify-download (REASONED; sqlite-rolling, bash, curl): Probe actual database/sidecar paths only after a successful application control; timeouts, DNS and proxy failures do not establish non-publication. No served application was available. - verify-bundle (DEMONSTRATED; turso, bash, grep-docs): Local placeholder-token scan distinguished exposed and clean bundles and refused absent directories; readonly, nameref, lowercase and IFS cases are recorded. A clean scan covers only the searched forms and paths. The runs covered an earlier form, whose result arms ended with status 0; the current form was re-run on 31 October 2026. - verify-git (DEMONSTRATED; sqlite-rolling): Local throwaway repositories showed tracked/history copies survive ignore rules and index removal; a never-committed ignored file produced only rule matches. Those runs, and a first git 2.53.0 re-run, covered earlier forms naming app.db directly; gate passes, stub runs and a git 2.53.0 re-run on 31 October 2026 cover the current form, which takes the database path on its set -- line. - verify-modes (REASONED; sqlite-rolling, coreutils): SQLite 3.46.1 local WAL and rollback-journal runs showed umask 022 versus 077 modes; every file belonged to the test account, and separate application-account ownership was not exercised. Those runs covered an earlier form; gate passes and stub runs cover the current one. - verify-listeners (REASONED; litefs, litestream, mcp, bash, curl): Compare authorized private control with untrusted reachability for LiteFS or enabled Litestream metrics/MCP; any HTTP response shows a listener, not every backend route. Transport errors and failed controls are inconclusive; no isolated listener environment was available. - verify-turso (REASONED; turso, bash, curl): Turso /v2/pipeline must return SELECT 1 with a valid bearer token and no successful result anonymously; HTTP 200 alone is insufficient and transport failure inconclusive. No database/token was available. - halt-expiration (REASONED; litefs): LiteFS checks halt-lock expiration every 5 seconds. ````json { "schema": 1, "checked": "2026-10-17", "documentation_checked": "2026-09", "body_sha256": "6e44867fed7f8eceb07674a7a4da09f72418ec7cd1a7e85f394aaffda99c829d", "components": { "sqlite-rolling": { "name": "SQLite documentation (rolling)", "basis": "unknown", "sources": { "s2721da91f8d2": "https://www.sqlite.org/serverless.html", "sf3d7f8391994": "https://www.sqlite.org/tempfiles.html", "s742deb3338bb": "https://sqlite.org/loadext.html", "sadf0431c1271": "https://www.sqlite.org/security.html" } }, "mcp": { "name": "Litestream MCP minimum", "basis": "v0.5.0", "sources": { "sa5dddf04652c": "https://litestream.io/reference/config/" } }, "see": { "name": "SQLite Encryption Extension", "basis": "unknown", "sources": { "sda3d8876503d": "https://www.sqlite.org/see/doc/trunk/www/readme.wiki" } }, "turso": { "name": "Turso documentation", "basis": "unknown", "sources": { "sb32fd9c679e1": "https://docs.turso.tech/sdk/http/quickstart", "sfec4e734f423": "https://docs.turso.tech/cli/db/tokens/create", "se409838a474a": "https://docs.turso.tech/sdk/http/reference" } }, "litestream": { "name": "Litestream documentation", "basis": "unknown", "sources": { "sec3d72af1447": "https://litestream.io/guides/", "s87fa51accafc": "https://litestream.io/guides/s3/", "sa5dddf04652c": "https://litestream.io/reference/config/" } }, "cipher": { "name": "SQLCipher", "basis": "unknown", "sources": { "s57d83a42a74a": "https://www.zetetic.net/sqlcipher/" } }, "litefs": { "name": "LiteFS source", "basis": "v0.5.14", "sources": { "s694a6ba8eafa": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L134-L268", "s864cacb8e7cc": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L76-L99", "s16da1761d6cc": "https://github.com/superfly/litefs/blob/v0.5.14/http/client.go#L32-L43", "sf0e982d0e36a": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/config.go#L57", "s9389d639da6b": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/etc/litefs.yml#L54-L58", "s475c28fd949e": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L76-L110", "s055e15e84ba5": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/config.go#L219-L233", "s0b0bac964067": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/config.go#L288-L333", "s326a1007e5c2": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L294-L318", "s4547cc32557d": "https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2779-L2811", "s85ece0e83d43": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1904-L1910", "sa467eeb47f34": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L320-L346", "s5ffa5c20fc78": "https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2682-L2776", "s02908966756d": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L495-L520", "sd63fa776fbc1": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L526-L585", "s1c51b7efc42d": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L686-L699", "scd0168363c1a": "https://github.com/superfly/litefs/blob/v0.5.14/http/http.go#L15-L43", "s298808a10d50": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L461-L493", "s7269cca8b0ea": "https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2387-L2448", "sbd59a1af784e": "https://github.com/superfly/litefs/blob/v0.5.14/db.go#L2452-L2609", "sa199174f541b": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L348-L407", "s750403129022": "https://github.com/superfly/litefs/blob/v0.5.14/db.go#L215-L325", "s5009045b97e1": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1525-L1531", "se2add54cf935": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L39-L41", "s59a2dfccd853": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1455-L1465", "s246e6ad0d330": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L409-L459", "sae4ece3fbc87": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L408-L432", "s7bcb006d6884": "https://github.com/superfly/litefs/blob/v0.5.14/lease.go#L168-L170", "se1a41376a98d": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L271-L292", "s93c0ad91a567": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L779-L803", "s36792457dd64": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L487-L488", "s7234e34af9ef": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1661-L1713", "s4cebe21e711d": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L329-L348", "sb43f5d78723c": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L241-L244", "s8dfa3f10f3d3": "https://github.com/superfly/litefs/blob/v0.5.14/lease.go#L99-L131", "s83d8bd834347": "https://github.com/superfly/litefs/blob/v0.5.14/store.go#L1384-L1387", "s3284d1a3ecad": "https://github.com/superfly/litefs/blob/v0.5.14/Dockerfile", "s2b80fdf8adbe": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/mount_linux.go#L519-L524", "sc0568fe7e0ee": "https://github.com/superfly/litefs/blob/v0.5.14/http/proxy_server.go#L131-L144", "sf0d31f900082": "https://github.com/superfly/litefs/blob/v0.5.14/cmd/litefs/etc/litefs.yml#L60-L68", "s82103ee03d64": "https://github.com/superfly/litefs/blob/v0.5.14/http/server.go#L32-L35" } }, "litefs-docs": { "name": "LiteFS documentation", "basis": "unknown", "sources": { "s94287a8892d0": "https://docs.fly.io/litefs", "s294c6fd17eec": "https://docs.fly.io/litefs/config" } }, "go": { "name": "Go library documentation", "basis": "unknown", "sources": { "s6c5be8454523": "https://pkg.go.dev/github.com/golang/net/http2/h2c#NewHandler", "s37204ff1b27c": "https://pkg.go.dev/net#Listen", "se49529035a0c": "https://pkg.go.dev/expvar", "s0246034dddd1": "https://pkg.go.dev/net/http/pprof" } }, "docker": { "name": "Docker documentation", "basis": "unknown", "sources": { "s881082f899b2": "https://docs.docker.com/engine/network/drivers/bridge/", "s6732393fd414": "https://docs.docker.com/engine/network/drivers/host/", "s00095bd02553": "https://docs.docker.com/engine/network/#container-networks" } }, "git": { "name": "Git documentation", "basis": "v2.56.0", "sources": { "s12aad6467212": "https://github.com/git/git/blob/v2.56.0/Documentation/gitignore.adoc#L15-L18", "s8054ab09dc74": "https://github.com/git/git/blob/v2.56.0/Documentation/git-rm.adoc#L17-L28", "s9a6802adec89": "https://github.com/git/git/blob/v2.56.0/Documentation/git-log.adoc#L16-L16" } }, "coreutils": { "name": "GNU Coreutils stat manual", "basis": "v9.7", "sources": { "s247df4545dc0": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L12989-L12995", "sfd49553ab6e5": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L13046", "sbd6f7cad7bf4": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L13062", "s3e55a7242c33": "https://github.com/coreutils/coreutils/blob/v9.7/doc/coreutils.texi#L13073" } }, "bash": { "name": "GNU Bash reference manual", "basis": "bash-5.3", "sources": { "s3711969be580": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n450", "s7fec9b923488": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n769", "s77b19e384d70": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n1397", "se4f37e01a249": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n1866", "s07f77f16f9f5": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n2288", "sbed8cd562a35": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n2993", "s7c9e44003867": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3131", "s223b5c4d9a28": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4350", "se02ece9163d4": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4582", "s6067b2b1f78a": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5216", "sa506f1abd6a1": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6182", "s7d29125b4005": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n8232", "s8962e63cfb20": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n2580", "s8a27e686ddfb": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5466", "s213f6d8d1f07": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3280", "sf9757e9fd6e8": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3872", "s8cbc1b4709e2": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4737", "s3255eca7e496": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n4852", "s252aa2e81179": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5156", "s8447036b2114": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5161", "s5cab49305f3d": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5548", "s6e8ae812c1c3": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5625", "sb38bf74a89b0": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5629", "s260459961fbc": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5659", "s8c59508bd4c8": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5917", "s8016d1aecfca": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n5929", "se62fd3ad4fc3": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6122", "s2df39d2c9c1c": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n6194", "s80e3246e96d8": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n7274" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s5b71321b79ea": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/disable.md#L4-L20", "sc405a4bffc27": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/globoff.md#L4-L22", "s345ec65025b0": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/noproxy.md#L18-L28", "sc9973c4ceec1": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/connect-timeout.md#L19-L28", "s7b6dca376156": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/max-time.md#L21-L27", "s156481f6b726": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/silent.md#L4-L25", "s07916104bf60": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/show-error.md#L4-L19", "sa937dbe5dd4a": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L4-L33", "s021603e35e68": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L49-L53", "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79", "s39402f9cb25c": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L105-L107", "sf2dcd83157a6": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/insecure.md#L4-L30", "s13f463154cea": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/fail.md#L24-L28", "s2f1ff2f6cacd": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/output.md#L4-L53", "sd3b039bafbf6": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/data.md#L4-L49", "s3db522258e44": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md" } }, "grep-docs": { "name": "GNU grep documentation", "basis": "v3.12", "sources": { "s1733b640052b": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n197", "s99f121f5426a": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n342", "sbc8d05ab659b": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n621", "sfdd7a73eab39": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n757", "s7f9dc35e81c5": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n788", "s5170317ddea8": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1110", "sc08d6af4d642": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1142", "scd528c854b46": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1150", "sb8be8b716426": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1274", "s5d5af9297df2": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1316", "se99a83dba3c5": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1363" } } }, "claims": { "filesystem": {"text": "No server, network listener or database-side authentication; filesystem authority controls access, including ATTACH. Treat files writable across security domains as suspect.", "components": ["sqlite-rolling"], "sources": ["sqlite-rolling:s2721da91f8d2", "sqlite-rolling:sadf0431c1271"], "status": "REASONED"}, "extensions": {"text": "Core extension loading defaults off, while the CLI enables it; enable only when required and load trusted libraries.", "components": ["sqlite-rolling"], "sources": ["sqlite-rolling:s742deb3338bb"], "status": "REASONED"}, "web-files": {"text": "Keep databases, sidecars, temporary files, exports and backups outside web-served paths; inspect aliases, symlinks, download routes and publishing, and do not rely on hidden names.", "components": ["sqlite-rolling"], "sources": ["sqlite-rolling:s2721da91f8d2", "sqlite-rolling:sf3d7f8391994"], "status": "REASONED"}, "git-files": {"text": "Ignore database extensions and each -wal, -shm and -journal suffix before committing; ignore rules do not remove tracked files or history. This guide assesses committed database rows as permanently available to anyone who clones, even after a later deletion commit.", "components": ["sqlite-rolling", "git"], "sources": ["sqlite-rolling:sf3d7f8391994", "git:s12aad6467212", "git:s8054ab09dc74", "git:s9a6802adec89"], "status": "REASONED"}, "permissions": {"text": "Dedicated account, directory 700, database and existing sidecars 600, and launch umask 077; same-account processes and privileged users remain outside this isolation.", "components": ["sqlite-rolling"], "sources": ["sqlite-rolling:s2721da91f8d2", "sqlite-rolling:sf3d7f8391994"], "status": "REASONED"}, "temporary": {"text": "Protect temporary files and backups; Unix SQLITE_TMPDIR needs an existing private directory and may fall back elsewhere. Do not delete live sidecars to repair permissions.", "components": ["sqlite-rolling"], "sources": ["sqlite-rolling:sf3d7f8391994"], "status": "REASONED"}, "encryption": {"text": "Public-domain SQLite has no file encryption; SEE is licensed and SQLCipher is a third-party alternative, otherwise use filesystem or volume encryption.", "components": ["see", "cipher"], "sources": ["see:sda3d8876503d", "cipher:s57d83a42a74a"], "status": "REASONED"}, "encryption-keys": {"text": "Keep keys outside databases and backups; SEE TEMP tables can be unencrypted. Treat credential-bearing copies as secrets and rotate credentials after a leak.", "components": ["see", "sqlite-rolling"], "sources": ["see:sda3d8876503d", "sqlite-rolling:sf3d7f8391994"], "status": "REASONED"}, "turso-http": {"text": "Turso serves libSQL over HTTPS at the database/organization turso.io URL using bearer authentication; TURSO_DATABASE_URL and TURSO_AUTH_TOKEN belong in application configuration, never a client bundle or git.", "components": ["turso"], "sources": ["turso:sb32fd9c679e1"], "status": "REASONED"}, "turso-scope": {"text": "db tokens create --read-only scopes away writes; --expiration accepts never or durations such as 7d3h. Prefer scoped, expiring tokens.", "components": ["turso"], "sources": ["turso:sfec4e734f423"], "status": "REASONED"}, "replicas": {"text": "Litestream replicates to supported object stores; scope AWS credentials and IAM to the required bucket/prefix, keep replicas private, and restore with scoped credentials.", "components": ["litestream"], "sources": ["litestream:sec3d72af1447", "litestream:s87fa51accafc"], "status": "REASONED"}, "metrics": {"text": "Litestream addr metrics listener defaults disabled; leave off unless needed, otherwise bind loopback, for example 127.0.0.1:9090.", "components": ["litestream"], "sources": ["litestream:sa5dddf04652c"], "status": "REASONED"}, "mcp": {"text": "Litestream MCP mcp-addr is documented for v0.5.0 and later, defaults disabled and has no built-in authentication; protect information and restore access with an authenticated tunnel/proxy, for example at loopback:3001.", "components": ["mcp"], "sources": ["mcp:sa5dddf04652c"], "status": "REASONED"}, "litefs-backups": {"text": "LiteFS replicates among cluster nodes; its pre-1.0 documentation recommends separate off-site backups with private destinations.", "components": ["litefs-docs"], "sources": ["litefs-docs:s94287a8892d0"], "status": "REASONED"}, "litefs-transport": {"text": "LiteFS v0.5.14 routes have no credential gate; the server accepts cleartext HTTP/1 and h2c, and its client uses h2c. Litefs-Id comparisons reject self-connections, not unauthenticated callers.", "components": ["litefs", "go"], "sources": ["litefs:s694a6ba8eafa", "litefs:s864cacb8e7cc", "litefs:s16da1761d6cc", "go:s6c5be8454523"], "status": "REASONED"}, "litefs-bind": {"text": "http.addr defaults to host-less :20202, including the example; an empty address still listens on all interfaces at a system-chosen port.", "components": ["litefs", "go"], "sources": ["litefs:sf0e982d0e36a", "litefs:s9389d639da6b", "go:s37204ff1b27c", "litefs:s82103ee03d64"], "status": "REASONED"}, "litefs-config": {"text": "Set API bind in the config file; no direct address flag. Search working directory, available home directory, then /etc/litefs.yml; -config overrides and -no-expand-env disables ${VAR} expansion.", "components": ["litefs"], "sources": ["litefs:s475c28fd949e", "litefs:s055e15e84ba5", "litefs:s0b0bac964067"], "status": "REASONED"}, "import": {"text": "POST /import creates or replaces a named database on the primary only; replicas return 503 before creation without forwarding, and DB.Import also checks primary status.", "components": ["litefs"], "sources": ["litefs:s326a1007e5c2", "litefs:s4547cc32557d", "litefs:s85ece0e83d43"], "status": "REASONED"}, "export": {"text": "GET /export returns the full existing local database, including applicable WAL pages, on either role; missing databases return 404.", "components": ["litefs"], "sources": ["litefs:sa467eeb47f34", "litefs:s5ffa5c20fc78"], "status": "REASONED"}, "stream": {"text": "POST /stream requires primary, HTTP/2 and a valid position map; replicas return 503. Unfiltered streams cover every database, filters narrow them, and fresh positions receive snapshots.", "components": ["litefs"], "sources": ["litefs:s02908966756d", "litefs:sd63fa776fbc1", "litefs:s1c51b7efc42d", "litefs:scd0168363c1a"], "status": "REASONED"}, "tx": {"text": "POST /tx applies a validated LTX file to an existing local database on either role without forwarding, primary or halt-owner checks; non-snapshots must match next transaction/checksum, missing databases return 404.", "components": ["litefs"], "sources": ["litefs:s298808a10d50", "litefs:s7269cca8b0ea", "litefs:sbd59a1af784e"], "status": "REASONED"}, "halt": {"text": "POST /halt on either role creates the database if absent and acquires write locks with a nonzero id, stalling primary writes or replica updates; DELETE releases an existing lock for a matching id.", "components": ["litefs"], "sources": ["litefs:sa199174f541b", "litefs:s750403129022", "litefs:s5009045b97e1"], "status": "REASONED"}, "halt-ttl": {"text": "LiteFS halt-lock TTL defaults to 30 seconds.", "components": ["litefs"], "sources": ["litefs:se2add54cf935", "litefs:s59a2dfccd853"], "status": "REASONED"}, "promote": {"text": "POST /promote needs an eligible candidate, succeeds unchanged for an existing primary, and asks a known primary for handoff from a candidate replica; no database name is required.", "components": ["litefs"], "sources": ["litefs:s246e6ad0d330", "litefs:sae4ece3fbc87"], "status": "REASONED"}, "handoff": {"text": "POST /handoff requires primary, a connected target subscriber and a handoff-capable lease; static leases refuse it and no database name is required.", "components": ["litefs"], "sources": ["litefs:s246e6ad0d330", "litefs:s7bcb006d6884"], "status": "REASONED"}, "information": {"text": "GET /info, /events and /metrics disclose node, cluster and database state on either role without a named database.", "components": ["litefs"], "sources": ["litefs:s694a6ba8eafa", "litefs:se1a41376a98d", "litefs:s93c0ad91a567"], "status": "REASONED"}, "debug": {"text": "Unconditional /debug/pprof/ exposes command line and live profiles; /debug/vars exposes command line, memory and store database/lock state on either role without a database name.", "components": ["litefs", "go"], "sources": ["litefs:s694a6ba8eafa", "litefs:s36792457dd64", "litefs:s7234e34af9ef", "go:se49529035a0c", "go:s0246034dddd1"], "status": "REASONED"}, "debug-rand": {"text": "/debug/rand streams deterministic pseudo-random data; its minute timeout is checked between writes and no server write timeout bounds a slow or non-reading client.", "components": ["litefs"], "sources": ["litefs:s694a6ba8eafa", "litefs:s864cacb8e7cc"], "status": "REASONED"}, "cluster-private": {"text": "Bind each LiteFS API to its private node address and restrict reachability to cluster nodes; never publish 20202 through an internet-facing mapping or proxy.", "components": ["litefs", "litefs-docs"], "sources": ["litefs:s694a6ba8eafa", "litefs-docs:s294c6fd17eec"], "status": "REASONED"}, "consul-url": {"text": "Consul lease.advertise-url names each node private API; when unset it derives http:// plus lease.hostname or OS hostname and the actual listening port.", "components": ["litefs"], "sources": ["litefs:s4cebe21e711d"], "status": "REASONED"}, "static-url": {"text": "Static leasing uses lease.advertise-url as configured: the primary points at itself and each replica at the primary private API.", "components": ["litefs"], "sources": ["litefs:s8dfa3f10f3d3", "litefs:s83d8bd834347", "litefs:sb43f5d78723c"], "status": "REASONED"}, "container": {"text": "LiteFS image supplies no configuration or EXPOSE; default wildcard binding applies within its namespace. Peers, host/shared networking, routing, firewall and publication determine reachability.", "components": ["litefs", "docker"], "sources": ["litefs:s3284d1a3ecad", "docker:s881082f899b2", "docker:s6732393fd414", "docker:s00095bd02553"], "status": "REASONED"}, "proxy": {"text": "LiteFS application proxy is separate: proxy.target enables attempted startup, proxy.db and proxy.addr must be nonempty, example :8080; application TLS and authentication remain necessary.", "components": ["litefs"], "sources": ["litefs:s2b80fdf8adbe", "litefs:sc0568fe7e0ee", "litefs:sf0d31f900082"], "status": "REASONED"}, "verify-download": {"text": "Probe actual database/sidecar paths only after a successful application control; timeouts, DNS and proxy failures do not establish non-publication. No served application was available.", "components": ["sqlite-rolling", "bash", "curl"], "sources": ["sqlite-rolling:s2721da91f8d2", "sqlite-rolling:sf3d7f8391994", "bash:s3711969be580", "bash:s7fec9b923488", "bash:s77b19e384d70", "bash:se4f37e01a249", "bash:s07f77f16f9f5", "bash:sbed8cd562a35", "bash:s7c9e44003867", "bash:s223b5c4d9a28", "bash:se02ece9163d4", "bash:s6067b2b1f78a", "bash:sa506f1abd6a1", "bash:s7d29125b4005", "bash:s8962e63cfb20", "bash:s8a27e686ddfb", "curl:s5b71321b79ea", "curl:sc405a4bffc27", "curl:s345ec65025b0", "curl:sc9973c4ceec1", "curl:s7b6dca376156", "curl:s156481f6b726", "curl:s07916104bf60", "curl:sa937dbe5dd4a", "curl:s021603e35e68", "curl:s5e1383feef7d", "curl:s39402f9cb25c", "curl:sf2dcd83157a6", "curl:s13f463154cea", "curl:s2f1ff2f6cacd"], "status": "REASONED", "verify": [1]}, "verify-bundle": {"text": "Local placeholder-token scan distinguished exposed and clean bundles and refused absent directories; readonly, nameref, lowercase and IFS cases are recorded. A clean scan covers only the searched forms and paths. The runs covered an earlier form, whose result arms ended with status 0; the current form was re-run on 31 October 2026.", "components": ["turso", "bash", "grep-docs"], "sources": ["turso:sb32fd9c679e1", "bash:s3711969be580", "bash:s7fec9b923488", "bash:s77b19e384d70", "bash:se4f37e01a249", "bash:s07f77f16f9f5", "bash:sbed8cd562a35", "bash:s7c9e44003867", "bash:s223b5c4d9a28", "bash:se02ece9163d4", "bash:s6067b2b1f78a", "bash:sa506f1abd6a1", "bash:s7d29125b4005", "bash:s8a27e686ddfb", "bash:s213f6d8d1f07", "bash:sf9757e9fd6e8", "bash:s8cbc1b4709e2", "bash:s3255eca7e496", "bash:s252aa2e81179", "bash:s8447036b2114", "bash:s5cab49305f3d", "bash:s6e8ae812c1c3", "bash:sb38bf74a89b0", "bash:s260459961fbc", "bash:s8c59508bd4c8", "bash:s8016d1aecfca", "bash:se62fd3ad4fc3", "bash:s2df39d2c9c1c", "bash:s80e3246e96d8", "grep-docs:s1733b640052b", "grep-docs:s99f121f5426a", "grep-docs:sbc8d05ab659b", "grep-docs:sfdd7a73eab39", "grep-docs:s7f9dc35e81c5", "grep-docs:s5170317ddea8", "grep-docs:sc08d6af4d642", "grep-docs:scd528c854b46", "grep-docs:sb8be8b716426", "grep-docs:s5d5af9297df2", "grep-docs:se99a83dba3c5"], "status": "DEMONSTRATED", "evidence": "A bundle carrying the placeholder printed `dist/app.js` before `token-literal exit: 0` and again before `jwt-shape exit: 0`, and a clean bundle printed `1` for both.", "verify": [2]}, "verify-git": {"text": "Local throwaway repositories showed tracked/history copies survive ignore rules and index removal; a never-committed ignored file produced only rule matches. Those runs, and a first git 2.53.0 re-run, covered earlier forms naming app.db directly; gate passes, stub runs and a git 2.53.0 re-run on 31 October 2026 cover the current form, which takes the database path on its set -- line.", "components": ["sqlite-rolling"], "sources": ["sqlite-rolling:sf3d7f8391994"], "status": "DEMONSTRATED", "evidence": "With `app.db` committed before an `app.db*` ignore rule was added, `git check-ignore -v` listed the sidecars but not the tracked `app.db`. `git ls-files` printed `app.db`, and `git log` printed its commit.", "verify": [3]}, "verify-modes": {"text": "SQLite 3.46.1 local WAL and rollback-journal runs showed umask 022 versus 077 modes; every file belonged to the test account, and separate application-account ownership was not exercised. Those runs covered an earlier form; gate passes and stub runs cover the current one.", "components": ["sqlite-rolling", "coreutils"], "sources": ["sqlite-rolling:sf3d7f8391994", "coreutils:s247df4545dc0", "coreutils:sfd49553ab6e5", "coreutils:sbd6f7cad7bf4", "coreutils:s3e55a7242c33"], "status": "REASONED", "verify": [4]}, "verify-listeners": {"text": "Compare authorized private control with untrusted reachability for LiteFS or enabled Litestream metrics/MCP; any HTTP response shows a listener, not every backend route. Transport errors and failed controls are inconclusive; no isolated listener environment was available.", "components": ["litefs", "litestream", "mcp", "bash", "curl"], "sources": ["litefs:s694a6ba8eafa", "litestream:sa5dddf04652c", "mcp:sa5dddf04652c", "bash:s3711969be580", "bash:s7fec9b923488", "bash:s77b19e384d70", "bash:se4f37e01a249", "bash:s07f77f16f9f5", "bash:sbed8cd562a35", "bash:s7c9e44003867", "bash:s223b5c4d9a28", "bash:se02ece9163d4", "bash:s6067b2b1f78a", "bash:sa506f1abd6a1", "bash:s7d29125b4005", "curl:s5b71321b79ea", "curl:sc405a4bffc27", "curl:s345ec65025b0", "curl:sc9973c4ceec1", "curl:s7b6dca376156", "curl:s156481f6b726", "curl:s07916104bf60", "curl:sa937dbe5dd4a", "curl:s021603e35e68", "curl:s5e1383feef7d", "curl:s39402f9cb25c", "curl:sf2dcd83157a6", "curl:s13f463154cea", "curl:s2f1ff2f6cacd"], "status": "REASONED", "verify": [5]}, "verify-turso": {"text": "Turso /v2/pipeline must return SELECT 1 with a valid bearer token and no successful result anonymously; HTTP 200 alone is insufficient and transport failure inconclusive. No database/token was available.", "components": ["turso", "bash", "curl"], "sources": ["turso:se409838a474a", "bash:s3711969be580", "bash:s7fec9b923488", "bash:s77b19e384d70", "bash:se4f37e01a249", "bash:s07f77f16f9f5", "bash:sbed8cd562a35", "bash:s7c9e44003867", "bash:s223b5c4d9a28", "bash:se02ece9163d4", "bash:s6067b2b1f78a", "bash:sa506f1abd6a1", "bash:s7d29125b4005", "bash:s8962e63cfb20", "bash:s8a27e686ddfb", "bash:s213f6d8d1f07", "bash:sf9757e9fd6e8", "bash:s8cbc1b4709e2", "bash:s3255eca7e496", "bash:s252aa2e81179", "bash:s8447036b2114", "bash:s5cab49305f3d", "bash:s6e8ae812c1c3", "bash:sb38bf74a89b0", "bash:s260459961fbc", "bash:s8c59508bd4c8", "bash:s8016d1aecfca", "bash:se62fd3ad4fc3", "bash:s2df39d2c9c1c", "bash:s80e3246e96d8", "curl:s5b71321b79ea", "curl:sc405a4bffc27", "curl:s345ec65025b0", "curl:sc9973c4ceec1", "curl:s7b6dca376156", "curl:s156481f6b726", "curl:s07916104bf60", "curl:sa937dbe5dd4a", "curl:s021603e35e68", "curl:s5e1383feef7d", "curl:s39402f9cb25c", "curl:sf2dcd83157a6", "curl:s13f463154cea", "curl:sd3b039bafbf6", "curl:s3db522258e44"], "status": "REASONED", "verify": [6]}, "halt-expiration": {"text": "LiteFS checks halt-lock expiration every 5 seconds.", "components": ["litefs"], "sources": ["litefs:se2add54cf935", "litefs:s59a2dfccd853"], "status": "REASONED"} } } ```` ====================================================================== ==> sqlite-http-frontends.md ====================================================================== # SQLite HTTP front-ends: Datasette and sqlite-web Datasette and sqlite-web are web apps for browsing and querying an SQLite database; sqlite-web can edit rows and drop tables. This guide helps your AI assistant configure them securely, including loopback binds, Datasette's permissions, sqlite-web's read-only mode where possible, TLS and login at a fronting layer, and an unprivileged account. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/sqlite-http-frontends.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Datasette container generator (pinned to a commit), sqlite-web package 0.8.1, sqlite-web tagged implementation 0.8.1, datasette-auth-passwords 1.1.1, datasette-write 0.4, Docker localhost-publication boundary 28.0.0, and curl minimum write-out version 7.75.0; 3 further sources are pinned to a commit and 11 carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 55 claims: 18 demonstrated by a check that ran and 37 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An SQLite HTTP front-end turns a local file into a network service: **the port is the exposure**. Datasette allows anonymous browsing and read-only SQL by default; sqlite-web provides queries, imports, exports, row changes, and schema deletion. An unprotected, reachable instance exposes those capabilities. See Datasette permissions and sqlite-web features. A reachable, unauthenticated, writable sqlite-web instance therefore permits compromise of the databases its process can modify. That is an inference from its documented capabilities, not a claim of automatic host compromise. Use [sqlite.md](https://github.com/jposluns/secureconfig/blob/main/sqlite.md) for the files at rest and [web-exposure.md](https://github.com/jposluns/secureconfig/blob/main/web-exposure.md) for accidental file publication. Application-level SQL injection is outside this guide's scope; see the OWASP SQL Injection Prevention Cheat Sheet. ## Datasette At the time of writing, the stable documentation lists 0.65.5, including a fix for a table-permission bypass. Use a supported release containing the security fixes; check the changelog when deploying. **Bind privately and inspect the deployment path.** Bare `datasette` invokes `serve`, whose defaults are `127.0.0.1:8001`; `-h/--host` and `-p/--port` override them. The following baseline serves an unchanging snapshot on loopback, disables arbitrary SQL by default, and disables whole-file downloads. The pathname is an example. See the CLI reference and settings reference. ```bash datasette serve \ --host 127.0.0.1 --port 8001 \ --immutable /var/lib/sqlite-http/published.db \ --setting default_allow_sql off \ --setting allow_download off ``` Use `--immutable` only when the file will remain unchanged while served. SQLite skips locking and change detection in immutable mode; changing the file can produce incorrect results or corruption errors. A live database written by another process does not meet that condition. See Datasette immutable mode and SQLite URI semantics. Packaging can widen exposure: - Datasette's documented Docker command binds `0.0.0.0:8001` inside the container and publishes `8001:8001`. Container binding and host publication are separate decisions. - At Datasette commit `caf238aac86ebe370959b25384d6e05f6a1e2359`, `make_dockerfile()` starts the command with `datasette serve --host 0.0.0.0`, adds `-i filename` for each supplied file (the CLI's immutable mode), then always adds `--cors --inspect-file inspect-data.json`. For truthy `metadata_file`, `template_dir`, `plugins_dir` and `version_note`, respectively, it adds `--metadata` with that filename, `--template-dir templates/`, `--plugins-dir plugins/` and `--version-note` with that value. For truthy `static`, each `(mount_point, path)` adds `--static mount_point:mount_point`; the path is not a command argument. Truthy `extra_options` is split on whitespace and appended token by token. The generator applies `shlex.quote()` to all tokens so far, then appends literal, unquoted `--port $PORT` and joins the tokens with spaces, in that order. See the pinned generator and immutable-option passages in Sources. The parameters `files`, `metadata_file`, `extra_options`, `branch`, `template_dir`, `plugins_dir`, `static`, `install`, `spatialite`, `version_note` and `secret` have no defaults in this function's signature; `environment_variables=None`, `port=8001` and `apt_get_extras=None` do. Falsy environment and apt inputs become empty containers, and `DATASETTE_SECRET` is set from `secret`. A truthy `branch` prepends its Datasette GitHub archive URL to `install`; otherwise the package name `datasette` is prepended. Truthy `spatialite` adds `python3-dev`, `gcc` and `libsqlite3-mod-spatialite` to the apt packages and sets `SQLITE_EXTENSIONS` to `/usr/lib/x86_64-linux-gnu/mod_spatialite.so`. The Dockerfile inserts an apt block only when the resulting list is nonempty, renders the environment entries, installs the selected packages and runs `datasette inspect` with the space-joined files and `--inspect-file inspect-data.json`. These branches affect the image or environment, not additional serve arguments. The `port` parameter supplies both `ENV PORT` and `EXPOSE`; it does not replace `$PORT` in the command. See the pinned generator passage. The Dockerfile uses shell-form `CMD`, with the command text following `CMD`, rather than a JSON argument array. In this guide's reasoning, the literal `$PORT` therefore needs shell expansion to become a numeric argument; the generator does not expand it. The pinned package tests assert the generated text with Docker calls mocked, including a changed `port` value; those assertions do not demonstrate container execution or runtime expansion. This guide's advice: review the generated command, not just its bind address. At this same commit, `datasette package` passes its port option (default 8001) to `temporary_docker_directory()`, which calls `make_dockerfile()` with file basenames and, only when the merged metadata is nonempty, `metadata.json`, and writes the Dockerfile. `datasette publish` registers publisher subcommands; the cited `cloudrun` publisher uses that wrapper without overriding its default port and adds `--setting force_https_urls on` unless `extra_options` already contains the substring `force_https_urls`. The cited `heroku` publisher instead uses `temporary_heroku_directory()` and writes a separate Procfile. See the pinned wrapper, package, publish-registration, Cloud Run caller and Heroku passages. These passages do not establish other publishing plugins' behaviour, or a relationship to the separately pinned Cloud Run implementation below. - The inspected Cloud Run publisher uses `--allow-unauthenticated`. Cloud Run requires its ingress container to listen on `0.0.0.0`, supplies `PORT` with an ingress default of 8080, and terminates external TLS. That platform requirement does not supply application authorization. - Review each `datasette-publish-*` plugin as another exposure path. For example, `datasette-publish-fly` documents an application hostname under `app.fly.dev`. No universal authentication default across publishing plugins was established. Docker publishes to all host addresses by default when the mapping omits an address. For a proxy on the Docker host, use a mapping such as `-p 127.0.0.1:8001:8001`. Check direct routing and Docker's documented pre-28.0.0 localhost-publication caveat too; verify the resulting reachability. **Configure authentication and authorization.** Requests have an actor; permissions decide what it may access. Metadata `allow` blocks cover instances, databases, tables, and canned queries. Authentication alone does not establish those restrictions. Authentication plugins include datasette-auth-passwords and datasette-auth-github. The latter explicitly documents that anonymous access remains possible until permissions restrict it. This illustrative metadata permits actor `analyst` and disables arbitrary SQL. It is authorization configuration, not a user database. Save it as `metadata.json` and add `--metadata metadata.json` to the launch command. ```json { "allow": {"id": ["analyst"]}, "allow_sql": false } ``` `datasette --root` prints a one-use login URL that establishes the root actor through a cookie. It does not close anonymous access. Treat that URL as a credential per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). **Review every write capability.** The ordinary SQL interface is read-only. Writable canned queries use `"write": true`; plugins can provide further writes, rather than a global write mode. For example, `datasette-write` adds `/db/-/write`, governed by the `datasette-write` permission and restricted to root by default. Installing it does not grant anonymous writes. Review other mutable plugins individually. **Restrict SQL and exports separately.** The `?sql=` interface and its `.json?sql=` form expose arbitrary read queries. Read-only access still discloses data. Table and view `allow` restrictions do not prevent arbitrary SQL from reading those objects; restrict SQL alongside table access. Metadata `allow_sql` can express actor-specific policy. `--setting default_allow_sql off` changes the default `execute-sql` decision. There is no generic `--setting permissions` option in the stable settings reference. Queries default to a one-second time limit and at most 1,000 returned rows at a time. Pagination can retrieve further rows, so the row limit is not a confidentiality boundary. Repeated expensive requests may still consume resources; that is an inference, not a demonstrated denial of service. `allow_download` defaults on for immutable, file-backed databases. Make an explicit download decision when using `--immutable`; `--setting allow_download off` disables whole-file downloads, not ordinary data exports. **Review browser-facing and auxiliary routes.** `--cors` adds `Access-Control-Allow-Origin: *` to JSON endpoints. Omit it unless cross-origin access is intended; its absence does not authenticate requests. Remember that the generated Dockerfile adds it. `/-/metadata`, `/-/plugins`, `/-/versions`, `/-/settings`, and `/-/databases`, including their `.json` forms, expose configuration or instance details. Review them explicitly. The permissions debugger is separately restricted to root or an actor granted its permission; "internal" is not one access class. Core `hash_urls` was removed in 0.61. Its replacement, `datasette-hashed-urls`, provides content-hash URLs for caching, not secrecy or authorization; omit it from this private-data baseline. Datasette uses `asgi-csrf`, comparing the `ds_csrftoken` cookie with a `csrftoken` form field or `x-csrftoken` header. Preserve that protection for write-capable routes and review plugins using `skip_csrf`. It does not restrict anonymous reads. Keep plugin secrets out of public metadata through the documented environment or file indirection. Handle root login URLs and credential files per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). ## sqlite-web At the time of writing, sqlite-web defaults to `127.0.0.1:8080`. Overrides are `-H/--host`, with uppercase `H`, and `-p/--port`. Its UI supports arbitrary queries, imports and exports, row insert/update/delete, and dropping schema objects. The project Dockerfile starts with `-H 0.0.0.0` and exposes 8080; the README publishes `8080:8080`. For a proxy on the Docker host, use an explicit loopback publication such as `-p 127.0.0.1:8080:8080`, then verify it externally. Authentication requires a nonempty password. In the tagged sqlite-web 0.8.1 implementation, `-P/--password` enables password setup: it uses `SQLITE_WEB_PASSWORD` when that variable is nonempty and otherwise prompts; the variable is read only inside that option's branch. In this guide's assessment of that release, the environment variable alone therefore does not enable authentication. The browser login uses one shared password, without per-table roles. For the shared proxy-authentication pattern below: ```bash sqlite_web \ --host 127.0.0.1 --port 8080 \ --read-only /var/lib/sqlite-http/published.db ``` `--read-only` opens the database using SQLite `mode=ro`. It reduces exposure to reads; it does not authenticate callers. The path is an example. The inspected query handler executes a read through GET, while writes and scripts require POST; the drop handler also deletes through POST. Use a harmless read GET to establish anonymous access. sqlite-web documents native TLS through `--ssl-cert` and `--ssl-key`; the recommended deployment here still uses the shared TLS proxy. sqlite-web 0.8.1 from PyPI showed no CSRF check on its insert route on loopback: an authenticated `POST` insert with no token and a foreign `Origin` header wrote a row, and the insert form contains no CSRF field. Its session cookie was set with `HttpOnly; Path=/` and no `SameSite` or `Secure` attribute, even over TLS, so whether a browser sends it on a cross-site request depends on that browser's default. Prefer `--read-only` behind the proxy. The flags and routes described here matched the installed 0.8.1 package for everything the loopback runs exercised. ## Fronting layer, TLS, and least privilege For a same-host deployment, keep both origins on loopback and put the complete application behind TLS and authentication. Datasette documents reverse-proxy deployment. Follow [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), applying Basic authentication to every route, including SQL, JSON, exports, downloads, and auxiliary paths. See Datasette proxy deployment, nginx HTTPS, nginx Basic authentication, and Caddy Basic authentication. For human access, use an identity-aware proxy: [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md) covers Google IAP and [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) covers Cloudflare Access. Both evaluate access before forwarding requests. Neither core tool supplies native MFA in the reviewed authentication interfaces. Enforce MFA at the identity provider per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), or through Cloudflare Access independent MFA. For private access, see [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md). Serve shares within the tailnet; Funnel publishes to the internet. Verify which is configured. A proxy login must not be bypassable through another published origin or provider hostname. Inventory and test those paths separately; IAP specifically documents direct-backend and Cloud Run hostname bypass precautions. Run the service under a dedicated unprivileged OS account with access only to the intended databases. Prefer a curated snapshot whose file and parent directory the serving account cannot modify. This recommendation follows from SQLite's direct filesystem access and its warning about files writable across security boundaries. Reuse [sqlite.md](https://github.com/jposluns/secureconfig/blob/main/sqlite.md) section 3 for permissions, sidecars, and temporary files, adapting ownership for a read-only serving account. ## Verify REASONED: historical service-run overview, not a new demonstration; a live Datasette, sqlite-web or Caddy stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. All five shell blocks passed bash -n and ShellCheck 0.11.0 without output, and local metadata JSON parsing printed metadata JSON parsed. Block A printed `STUB sudo <-tlnp>`; 42 block B/C guard runs rejected invalid fixtures with exit 2 and refusal messages, and valid fixtures printed curl argument records with exit 0. These local checks do not establish HTTP behavior. Exposed routes return protected data anonymously; fixed authentication denies or filters it, per the guide's cited Datasette authentication and permissions (rolling documentation, checked September 2026) and sqlite-web README, features, options, and Docker usage (sqlite-web 0.8.1 from PyPI, tested on loopback). The following is the historical authoring record. The service outcomes below were **demonstrated on loopback** against Datasette 0.65.5 (with datasette-auth-passwords 1.1.1 and datasette-write 0.4) and sqlite-web 0.8.1, installed from PyPI into a virtual environment, with every server on 127.0.0.1 and, for the HTTPS blocks, native TLS or a Caddy TLS proxy with certificates from a private test CA that curl trusted through `CURL_CA_BUNDLE`. Blocks B and C ran as printed with only their placeholders substituted. What those runs do not show is marked **REASONED** where it occurs, with its reason and basis in the cited vendor documentation. Those runs, and the loopback runs recorded below, used earlier forms of blocks A, B and C, which did not classify their results. Block A now refuses with status 2 when `ss` is not installed, exits 3 if `ss` fails and otherwise 0, block B classifies its response by the step named on its `set --` line, and block C compares the authorized and anonymous responses against an expected text. Their current forms are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026 with no network. DEMONSTRATED: local shell preparation on 2026-10-05 in private /dev/shm scratch with Bash 5.3.9. All five shell blocks passed bash -n and the metadata JSON parsed. An unescaped URL apostrophe failed bash -n with exit 2; proper escaping forwarded one intact URL argument to an explicitly substituted curl argument recorder. Blocks B and C forwarded -q -g, --noproxy and error write-out options, without -k or redirect-following. These are local argument checks, not HTTP observations, and they ran on earlier forms of blocks B and C, which did not classify their results; the current forms are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. Use Bash with real, unshadowed builtins and curl 7.75.0 or newer. Paste whole subshells and substitute inside the single quotes. A literal apostrophe requires proper shell escaping; do not simply paste it between those quotes. Use URLs without embedded credentials or signed tokens. Every curl begins with `-q -g`, disables environment proxies, and reports the error text. Do not add `-k` or redirect-following. ### A. Listener inventory REASONED: listener-inventory scope; a live Datasette/sqlite-web stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The Cloud Run ingress comparison also needs a provider deployment, and the root inventory needs sudo; neither was available. Block A passed bash -n and ShellCheck 0.11.0 without output and a sudo stub printed `STUB sudo <-tlnp>`, exiting 0; no privileged inventory ran. That run covered an earlier form of block A, which ran `sudo ss -tlnp` at top level; its current form, which refuses with status 2 when `ss` is not installed, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. Expect a wildcard origin in the exposed state and the intended loopback listeners in the fixed same-host state, per the guide's cited ss manual, Datasette CLI reference (rolling documentation, checked September 2026), and sqlite-web README, features, options, and Docker usage (sqlite-web 0.8.1 from PyPI, tested on loopback). Host publication is separate per Docker port publishing (Docker's pre-28.0.0 localhost-publication caveat) and Cloud Run container contract. The following capability statements describe the historical authoring environment. **The fixed bind demonstrated on loopback; a wildcard bind and container publications REASONED** (the host forbids binding every interface and has no container runtime). On the deployment host, inspect the complete listener table: REASONED: following block; the cited ss and deployment documentation supplies the full inventory check; sudo, wildcard binding and container publication were unavailable. Only the unprivileged loopback inventory is recorded as demonstrated below. Per the guide's cited sudo sudoers manual, secure_path entry, where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. ```bash ( # a subshell, so the exit below ends only the subshell command -v ss >/dev/null 2>&1 || { echo "ss is not installed here; not listing sockets"; exit 2; } if sudo ss -tlnp; then set -- 0 else echo "sudo ss -tlnp exit $?: the listener table could not be read, and an inspection error followed by an empty table is inconclusive"; set -- 3 fi echo "block exit $1; every listener is an unclassified observation, including IPv6 entries and unexpected ports, so judge it yourself: a wildcard origin bind is the finding" exit "$1" ) ``` For the same-host examples, expect `127.0.0.1:8001` and/or `127.0.0.1:8080`, owned by the intended processes. A wildcard origin bind is the finding. Read IPv6 entries and unexpected ports too. An inspection error followed by an empty table is inconclusive. These example ports match the documented CLI defaults; substitute the actual configured ports when testing. On loopback, bare `datasette serve` with no host or port flag listened on `127.0.0.1:8001` and bare `sqlite_web` on `127.0.0.1:8080`, as `ss -tlnp` run without `sudo` by the same account showed (the authoring host has no `sudo`). REASONED: publication boundaries from the cited Docker and Cloud Run documentation; no container runtime or provider deployment is available. Inspect container publications separately: Docker forwarding can expose a port without a corresponding host listening process. A wildcard bind inside a container is distinct from a public host publication; managed ingress such as Cloud Run also has its own binding contract. Confirm those boundaries through configuration and external probes. ### B. Direct reachability and anonymous access REASONED: historical direct, provider and proxy comparison scope; a live Datasette/sqlite-web stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. An external vantage, provider deployment, and IAP or Cloudflare Access tenancy remain prerequisites for those comparisons. Block B passed bash -n and ShellCheck 0.11.0 without output; across ordinary Bash, set -u, and set -u with IFS=0, placeholders, missing assignments, empty URLs, non-HTTP schemes and userinfo exited 2 with their refusal messages, while valid URLs exited 0 and printed one STUB curl argument record. Those runs covered an earlier form of block B; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. Exposed paths return protected data anonymously; fixed protected routes deny or filter it, and a loopback-only direct origin gives no answer from an external vantage after a successful local control. Those outcomes are per the guide's cited Datasette authentication and permissions (rolling documentation, checked September 2026), sqlite-web README, features, options, and Docker usage (sqlite-web 0.8.1 from PyPI, tested on loopback), Caddy Basic authentication (rolling documentation, checked September 2026), Google IAP overview and origin protection, and Cloudflare Access HTTP applications. The following is the historical authoring record, including its missing second network and provider deployment. **Demonstrated on loopback against both tools; an external vantage and provider hostnames REASONED** (the host has no second network and no provider deployment). The same-host reverse proxy was demonstrated with Caddy (section C); identity-aware-proxy routes are REASONED, since no IAP or Cloudflare Access tenancy was available. REASONED: deployment controls from the cited route and proxy documentation; no external vantage or provider deployment is available. First obtain a successful local control against the running backend. Then run the block from an authorized external testing host against each direct origin and provider hostname, followed by the published HTTPS routes. For a protected route, establish section C's authorized positive control first. Use disposable databases for any local exposed-state reproduction. REASONED: following block; the complete deployment scenario needs an external vantage and provider deployment, neither available. Its direct-origin and provider reachability and anonymous-access expectations follow the cited Datasette/sqlite-web routes, deployment and proxy documentation, because no running frontend, external test path or provider deployment is available for these service checks; the separate loopback outcomes and local guard observations are recorded below. The block's second value names the step: `control` for the local control, `direct` for a direct origin probed from the external testing host, and `route` for a provider hostname or published route. With `direct` it exits 1 for any HTTP response and 0 for a curl exit 7 or 28, whose err text you read; `control` and `route` exit 0 with an unclassified-observation line when a response arrives, and any other curl failure exits 3. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTP_OR_HTTPS_URL' 'REPLACE_WITH_control_OR_direct_OR_route' # the URL, then control (the local control against the running backend), direct (a direct origin, probed from the external testing host) or route (a provider hostname or published route) [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'exactly one URL and one of control, direct or route required; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*YOUR_PUBLIC_IP*) echo 'substitute the target URL; not probing'; exit 2 ;; esac case "$1" in http://?*|https://?*) ;; *) echo 'HTTP or HTTPS required; not probing'; exit 2 ;; esac case "$1" in *@*|*[[:cntrl:]]*) echo 'userinfo or control character rejected; not probing'; exit 2 ;; esac case "$2" in control|direct|route) ;; *) echo 'substitute control, direct or route as the second value; not probing'; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS -i --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --url "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the URL, the step, the response with its write-out line, then curl's exit status printf '%s' "$3" case "$2:$4" in direct:0) echo "FINDING: the direct origin answered, and any HTTP response from a direct origin, including 401, 403 or 404, proves that path answered"; set -- 1 ;; direct:7|direct:28) echo "unclassified observation: a refusal or timeout supports isolation only after confirming the target and a successful local control"; set -- 0 ;; control:0) echo "unclassified observation: the local control answered; confirm from the response that it came from the running backend"; set -- 0 ;; route:0) echo "unclassified observation: a database listing, schema result or protected rows without credentials is the finding, and a denial or the configured login redirect with no protected data is the fixed state"; set -- 0 ;; control:*) echo "curl exit $4: the local control failed; inconclusive"; set -- 3 ;; *) echo "curl exit $4: DNS, TLS and local socket errors prove nothing about exposure; inconclusive"; set -- 3 ;; esac echo "block exit $1; the response headers, its body and curl's err text are not classified, so judge them yourself" exit "$1" ) ``` REASONED: following block; the local request examples and query targets in the table below follow the cited Datasette/sqlite-web routes documentation, and no running frontend is available to answer them. For a disposable `published.db` containing at least one table, these are local control examples: | Tool | UI | Read query | |---|---|---| | REASONED: request targets from the cited Datasette query and JSON API documentation; no running Datasette fixture is available. Datasette | `http://127.0.0.1:8001/` | `http://127.0.0.1:8001/published.json?sql=SELECT+name+FROM+sqlite_master+LIMIT+1` | | REASONED: request targets from the cited sqlite-web query handler; no running sqlite-web fixture is available. sqlite-web | `http://127.0.0.1:8080/` | `http://127.0.0.1:8080/query/?sql=SELECT+name+FROM+sqlite_master+LIMIT+1` | REASONED: query-route provenance; a live Datasette/sqlite-web stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Block B passed bash -n and ShellCheck 0.11.0 without output; across ordinary Bash, set -u, and set -u with IFS=0, placeholders, missing assignments, empty URLs, non-HTTP schemes and userinfo exited 2 with their refusal messages, while valid URLs exited 0 and printed one STUB curl argument record. Those runs covered an earlier form of block B; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. An in-memory SQLite fixture with an items table ran SELECT name FROM sqlite_master LIMIT 1 and printed [('items',)]; this checks the query only. Exposed anonymous query routes return schema data and fixed protected routes deny or filter it. Those outcomes are per the guide's cited Datasette SQL queries and writable canned queries (rolling documentation, checked September 2026), Datasette JSON API and CORS (rolling documentation, checked September 2026), Datasette authentication and permissions (rolling documentation, checked September 2026), and sqlite-web implementation. The following source and package confirmation is the historical record. Datasette's query and JSON interfaces are documented; sqlite-web's GET route was traced to the inspected source and confirmed for the 0.8.1 package on loopback. REASONED: external test scope from the cited deployment and curl references; no external testing host or provider deployment is available. For external testing, replace loopback with the actual deployment address, port, and path. Loopback on the testing machine cannot test the server's external exposure. Interpret the results as follows: - REASONED: reachability interpretation from the cited curl and deployment references; no external vantage is available. **Reachability:** any HTTP response from a direct origin, including 401, 403, or 404, proves that path answered. A loopback-only origin should not answer externally. Refusal or timeout supports isolation only after confirming the target and a successful local control; DNS, TLS, and local socket errors are inconclusive. - REASONED: exposure criteria from the cited query and authentication references; no live frontend fixture is available. **Anonymous exposure:** a bare GET of the UI, a `?sql=` route, or a `.json` route returns a database listing, schema result, or protected rows without credentials. That is the finding. The schema query reveals a name; repeat with a known nonsensitive row endpoint to check row access. - REASONED: denial interpretation from the cited authentication references; no live protected frontend fixture is available. **Fixed:** a denial or the configured login redirect returns no protected data. A 200 login page is not query success, and an unexplained 404 does not prove authentication. - REASONED: SQL comparison from the cited Datasette permissions and settings; no authorized browsing fixture is available. **SQL disabled:** an authorized browsing account still reaches its permitted table page but receives no SQL result. Check that browsing positive control separately. - REASONED: proxy-only comparison; a live Caddy and Datasette stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Block C passed bash -n and ShellCheck 0.11.0 without output. Block C's guard runs across ordinary Bash, set -u, and set -u with IFS=0 showed that placeholders, missing assignments, empty URLs, non-HTTPS schemes, userinfo, missing header files and directories exited 2 with their refusal messages; valid URLs and a regular mode-0600 header file exited 0 and printed two STUB curl argument records, with only the header path forwarded. Those runs covered an earlier form of block C; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. An untrusted direct backend path exposes data; the fixed proxy path denies anonymous requests while allowing the authorized control, and requires backend isolation, per the guide's cited Caddy Basic authentication (rolling documentation, checked September 2026) and Datasette proxy deployment (rolling documentation, checked September 2026). The demonstration reference is historical. **Proxy-only authentication (demonstrated with Caddy; see section C):** a loopback backend may intentionally return data without credentials. Exposure exists when an untrusted caller reaches it or bypasses the proxy. DEMONSTRATED: historical loopback runs at authoring against Datasette 0.65.5 and sqlite-web 0.8.1, recorded as observed. On loopback, block B against bare `datasette serve` returned the database listing, and the SQL JSON route returned the known row (`widget-canary`), both with `200`, and `/-/versions.json` answered `200` anonymously: the exposed state. Against the baseline command above, the SQL JSON route returned `403` while the table's JSON page still returned `200` (SQL disabled, browsing permitted), and `/published.db` returned `403`; without `--setting allow_download off` it returned `200`. `--cors` added `Access-Control-Allow-Origin: *`, absent without it. Bare `sqlite_web` answered the query GET with `200` anonymously, and an anonymous `POST` insert wrote a row to a disposable database; with `--read-only` the read still returned `200` and the insert changed nothing. `SQLITE_WEB_PASSWORD` set without `-P` left sqlite-web open (`200` anonymously), as the source reading above says; with `-P` the anonymous query GET got `302` to `/login/`. Block B in these runs was an earlier form that printed the response without classifying it; the current form prints the same response before its classification line and is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. ### C. Authorized positive control versus anonymous access REASONED: historical native-TLS, same-host proxy and identity-aware-proxy comparison scope; a live Datasette/sqlite-web stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Caddy TLS proxy reproduction also needs a listener under that isolation requirement, and IAP or Cloudflare Access header comparisons need their respective tenancy. Block C passed bash -n and ShellCheck 0.11.0 without output. Block C's guard runs across ordinary Bash, set -u, and set -u with IFS=0 showed that placeholders, missing assignments, empty URLs, non-HTTPS schemes, userinfo, missing header files and directories exited 2 with their refusal messages; valid URLs and a regular mode-0600 header file exited 0 and printed two STUB curl argument records, with only the header path forwarded. Those runs covered an earlier form of block C; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. Exposed routes answer anonymously; fixed routes deny or filter anonymous data while returning it to the authorized control. Those outcomes are per the guide's cited Datasette authentication and permissions (rolling documentation, checked September 2026), sqlite-web README, features, options, and Docker usage (sqlite-web 0.8.1 from PyPI, tested on loopback), Caddy Basic authentication (rolling documentation, checked September 2026), Google IAP overview and origin protection, and Cloudflare Access HTTP applications. The following is the historical authoring record. **Demonstrated on loopback over native TLS; the same-host proxy demonstrated with Caddy; identity-aware-proxy headers REASONED, since no IAP or Cloudflare Access tenancy was available.** DEMONSTRATED: local file preparation and block C guards on 2026-10-05 in private /dev/shm scratch with Bash 5.3.9 and a disposable Authorization header. The real file tests accepted a regular mode-0600 file and an explicitly substituted curl argument recorder received only its @path, not its contents. Missing, directory and mode-000 files exited 2 before the recorder ran. A mode-0644 file was also accepted, confirming the guard does not establish privacy; chmod 600 restored the private fixture. No service authentication was tested, and these runs used an earlier form of block C, with two values on its `set --` line; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. Prepare a private mode-0600 header file per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), containing the required Authorization, session Cookie, or fronting-layer headers. Only its path enters argv through `-H "@file"`. Keep credentials out of the URL, shell history, tracing, and shared output. The guard checks that the file is readable and regular; preparation must establish its privacy. REASONED: positive-control selection from the cited query and permission references; no protected deployment or test identity is available. Choose a URL that returns known nonsensitive protected data. When arbitrary SQL is disabled, use a permitted table page or its JSON representation. REASONED: following block; the complete fronting-layer scenario includes IAP or Cloudflare Access, and no tenancy was available. The cited authentication documentation supplies expectations; native-TLS and Caddy loopback outcomes are recorded below. The block's third value is a nonsensitive text that appears only in that protected data. It exits 3 when the authorized response lacks that text or a request fails, 1 when the anonymous response contains it, 0 when the control contains it and the anonymous response withholds it, and 2 for a guard refusal. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_DATA_URL' 'REPLACE_WITH_AUTH_HEADER_FILE' 'REPLACE_WITH_EXPECTED_DATA_TEXT' # the data URL, the header file, then a nonsensitive text that appears only in the protected data [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'exactly one URL, one file and one expected text required; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the data URL; not probing'; exit 2 ;; esac case "$1" in https://?*) ;; *) echo 'HTTPS required; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*) echo 'substitute the header file; not probing'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*) echo 'substitute the expected data text; not probing'; exit 2 ;; esac case "$1" in *@*) echo 'userinfo rejected; not probing'; exit 2 ;; esac case "$1$2$3" in *[[:cntrl:]]*) echo 'control character rejected; not probing'; exit 2 ;; esac [ -f "$2" ] || { echo 'regular header file required; not probing'; exit 2; } [ -r "$2" ] || { echo 'readable header file required; not probing'; exit 2; } set -- "$1" "$2" "$3" "$(if curl -q -g -sS -i --noproxy '*' --connect-timeout 5 --max-time 20 \ -H "@$2" -w '\nauthorized http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --url "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # the authorized response with its write-out line, then curl's exit status printf '%s' "$4" set -- "$1" "$2" "$3" 0 "${4%$'\n'authorized http=*}" "$5" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), the response, then curl's exit status case "$6" in 0) case "$5" in *"$3"*) echo "positive control: the authorized response contains the expected data" ;; *) echo "the authorized response does not contain the expected data, and HTTP success alone is insufficient: the control failed, so the comparison is inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac ;; *) echo "curl exit $6: the authorized request failed, so the comparison is inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -i --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nanonymous http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --url "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # the status, the anonymous response with its write-out line, then curl's exit status printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "${5%$'\n'anonymous http=*}" "$6" case "$6" in 0) case "$5" in *"$3"*) echo "FINDING: the anonymous response contains the expected protected data"; set -- "$1" "$2" "$3" 1 ;; *) echo "the anonymous response withholds the expected data" ;; esac ;; *) echo "curl exit $6: the anonymous request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; the response headers and bodies, and the repeat with an authenticated but unauthorized account, are not classified, so judge them yourself" exit "$4" ) ``` - REASONED: authorization comparison from the cited authentication and permission references; no live deployment or test identities are available. The authorized response must contain the expected data; HTTP success alone is insufficient. Only then assess whether the anonymous response withholds that result. If the control fails, the comparison is inconclusive. Repeat with an authenticated but unauthorized account where actor permissions apply. DEMONSTRATED: historical loopback runs at authoring, recorded as observed. On loopback, with the metadata above plus datasette-auth-passwords, the block returned the table's rows to the `analyst` session cookie (`authorized http=200`) and `403` anonymously; an authenticated `guest` got `403`, the unauthorized outcome, and the analyst got `403` on SQL under `allow_sql: false`. For sqlite-web with `-P`, the session cookie got the row with `200` and the anonymous request `302` to `/login/`. With datasette-write on a disposable database, an anonymous write got `403`, a root write without the CSRF token `403`, and with the token `302`, and the row was written. Under the same metadata, `/-/versions.json`, `/-/metadata.json`, `/-/plugins.json`, `/-/settings.json` and `/-/databases.json` returned `403` anonymously and `200` to a freshly logged-in analyst. Under the same metadata, `/-/metadata.json` showed that analyst both users' password hashes when they were written inline in the plugin configuration, but only the `$env` references when they were supplied through environment variables. Block C in these runs was an earlier form that printed both responses without comparing them; the current form prints the same responses before its classification lines and is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. DEMONSTRATED: historical loopback run of a Caddy TLS proxy at authoring, recorded as observed. The Caddy proxy was Caddy v2.11.4 (the release tarball, its SHA-256 equal to GitHub's published digest) on `127.0.0.1:8443` with `bind 127.0.0.1`, `auto_https off`, the admin API off and TLS files from the test CA, with [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)'s `basic_auth` in front of a Datasette that had no authentication of its own. Requests through that Caddy proxy to `/`, the SQL JSON route, `/published.db`, `/-/versions.json` and `/published/items.csv` returned `401` anonymously and `200` with the Basic credential, while the loopback backend answered each of those routes directly with `200`: the proxy-only pattern, safe only while nothing untrusted reaches the backend. Block C through the proxy gave `authorized http=200` with the rows and `anonymous http=401` with `www-authenticate: Basic realm="restricted"`. That block C run used an earlier form, which printed both responses without comparing them; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. DEMONSTRATED: historical local authoring checks, recorded as run. Local authoring checks passed: `bash -n` on all five shell blocks, parsing of the metadata JSON, and 120 guard executions covering 40 cases under ordinary Bash, `set -u`, and `set -u` with `IFS=0`. The cases included missing assignments or markers, shortened and extra arguments, embedded placeholders, invalid schemes, URL userinfo, control characters, invalid header-file paths, and valid argument forwarding. An isolated curl argument recorder measured guard behavior; the HTTP outcomes above were demonstrated separately on loopback. Those checks covered earlier forms of blocks A, B and C; their current forms are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. DEMONSTRATED: local guard limits on 2026-10-05 in private /dev/shm scratch with Bash 5.3.9. Across 48 block B/C cases under ordinary Bash, set -u, and set -u with IFS=0, an explicitly substituted curl argument recorder ran with valid whole-block values, inherited matching marker/arguments, and pastes below the guards. Whole-block placeholders and omitted assignments without a matching marker exited 2 without calling it. These observations establish shell behavior only, and they covered earlier forms of blocks B and C, whose current forms are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026. Partial pastes beginning below the guards remain unguarded. An inherited marker with exactly the expected arguments is indistinguishable from a complete assignment. Paste whole blocks; these checks do not claim otherwise. **REASONED:** the cited vendor documentation is the basis for what the loopback runs could not show: a wildcard bind and container publications, an external vantage against direct origins and provider hostnames, the identity-aware-proxy pattern, and `ss` run as root. ## Sources (checked September 2026) Applicability checked on 2026-09-18: Datasette stable documentation lists 0.65.5; the container-generator discussion here is scoped to commit `caf238aac86ebe370959b25384d6e05f6a1e2359`, not a tagged release or the separately pinned Cloud Run implementation. The sqlite-web source was inspected on moving `master`, identifying itself as 0.8.1; the released 0.8.1 package matched it for the flags and routes the loopback runs exercised. Runtime confirmation used Datasette 0.65.5, datasette-auth-passwords 1.1.1, datasette-write 0.4 and sqlite-web 0.8.1 from PyPI. Every URL listed below was fetched during authoring, except the sqlite-web 0.8.1 tag entries, which were checked against the locally extracted tag snapshot in October 2026, and the sudo sudoers manual entry, which was checked against the file at its tag on 2026-10-31. This list also records the source-fetch review. - Datasette CLI reference (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/cli-reference.html - Datasette authentication and permissions (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/authentication.html - Datasette settings (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/settings.html - Datasette SQL queries and writable canned queries (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/sql_queries.html - Datasette JSON API and CORS (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/json_api.html - Datasette introspection routes (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/introspection.html - Datasette Docker installation (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/installation.html - Datasette proxy deployment (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/deploying.html - Datasette publishing (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/publish.html - Datasette container generator: - Datasette generator parameters, command construction and Dockerfile (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette temporary Docker directory wrapper (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette publish command registration (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette package command and port option (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette immutable option (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette Cloud Run generator caller (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette Heroku separate Procfile path (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette package text assertions with mocked Docker calls (caf238aac86ebe370959b25384d6e05f6a1e2359): - Datasette Cloud Run publisher: https://github.com/simonw/datasette/blob/e889403d3bbe143854262682161c98a57bdb6594/datasette/publish/cloudrun.py - Datasette Fly publishing plugin: https://github.com/simonw/datasette-publish-fly - Datasette immutable mode and hashed URLs (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/performance.html - Datasette CSRF protection (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/internals.html#csrf-protection - Datasette plugin secret configuration (rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/plugins.html#secret-configuration-values - Datasette changelog and security fixes (Datasette core hash_urls removed in 0.61; rolling documentation, checked September 2026): https://docs.datasette.io/en/stable/changelog.html - Datasette password authentication plugin (datasette-auth-passwords 1.1.1 used in the loopback demonstrations): https://github.com/simonw/datasette-auth-passwords/blob/1.1.1/README.md - Datasette GitHub authentication plugin: https://github.com/simonw/datasette-auth-github - Datasette write plugin (datasette-write 0.4 used in the loopback demonstrations): https://github.com/simonw/datasette-write/blob/0.4/README.md - sqlite-web README, features, options, and Docker usage (sqlite-web 0.8.1 from PyPI, tested on loopback): https://github.com/coleifer/sqlite-web/blob/0.8.1/README.md - sqlite-web implementation: https://raw.githubusercontent.com/coleifer/sqlite-web/825b4a050fe2a04e6c6cf4b8ee1f56eaea4feef2/sqlite_web/sqlite_web.py - sqlite-web 0.8.1 tag f86cfc7613a511114a804720448d8a145c17cc79 version line (checked October 2026): https://github.com/coleifer/sqlite-web/blob/0.8.1/sqlite_web/sqlite_web.py#L3 - sqlite-web 0.8.1 tag f86cfc7613a511114a804720448d8a145c17cc79 password option (checked October 2026): https://github.com/coleifer/sqlite-web/blob/0.8.1/sqlite_web/sqlite_web.py#L1735-L1740 - sqlite-web 0.8.1 tag f86cfc7613a511114a804720448d8a145c17cc79 password branch (checked October 2026): https://github.com/coleifer/sqlite-web/blob/0.8.1/sqlite_web/sqlite_web.py#L1944-L1956 - sqlite-web Dockerfile: https://raw.githubusercontent.com/coleifer/sqlite-web/7f6658b012a789f0b9418cad543e7798ce850130/docker/Dockerfile - SQLite security: https://www.sqlite.org/security.html - SQLite serverless architecture and filesystem access: https://www.sqlite.org/serverless.html - SQLite URI semantics: https://www.sqlite.org/uri.html - Docker port publishing (Docker's pre-28.0.0 localhost-publication caveat): https://docs.docker.com/engine/network/port-publishing/ - Cloud Run container contract: https://docs.cloud.google.com/run/docs/container-contract - nginx HTTPS configuration: https://nginx.org/en/docs/http/configuring_https_servers.html - nginx Basic authentication: https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html - Caddy Basic authentication (rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/basic_auth - Google IAP overview and origin protection: https://docs.cloud.google.com/iap/docs/concepts-overview - Cloudflare Access HTTP applications: https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/ - Cloudflare independent MFA: https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/ - Tailscale Serve and Funnel distinction: https://tailscale.com/docs/reference/tailscale-cli/serve - curl manual (write-out variables `exitcode` and `errormsg` added in curl 7.75.0): https://curl.se/docs/manpage.html - ss manual: https://man7.org/linux/man-pages/man8/ss.8.html - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 - OWASP SQL injection prevention: https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - ds-container: Datasette container generator (caf238aac86ebe370959b25384d6e05f6a1e2359) - web: sqlite-web package (0.8.1) - web-tag-source: sqlite-web tagged implementation (0.8.1) - ds-cloud: Datasette Cloud Run publisher (e889403d3bbe143854262682161c98a57bdb6594) - fly: Datasette Fly publishing plugin (unknown) - datasette-docs: Datasette documentation (rolling) (unknown) - web-source: sqlite-web implementation (825b4a050fe2a04e6c6cf4b8ee1f56eaea4feef2) - web-image: sqlite-web Dockerfile (7f6658b012a789f0b9418cad543e7798ce850130) - passwords: datasette-auth-passwords (1.1.1) - github-auth: datasette-auth-github (unknown) - write: datasette-write (0.4) - sqlite: SQLite documentation (unknown) - docker: Docker localhost-publication boundary (28.0.0) - cloudrun: Cloud Run documentation (unknown) - nginx: nginx documentation (unknown) - iap: Google IAP documentation (unknown) - access: Cloudflare Access documentation (unknown) - tailscale: Tailscale documentation (unknown) - caddy-docs: Caddy documentation (rolling) (unknown) - curl: curl minimum write-out version (7.75.0) - ss: ss documentation (unknown) Claims (full source mapping in the JSON block below): - release (REASONED; datasette-docs): The recorded stable Datasette documentation lists 0.65.5 with a table-permission bypass fix; deploy a supported release containing the security fixes. - datasette-bind (DEMONSTRATED; datasette-docs): Bare datasette serve without host or port flags listened on 127.0.0.1:8001 in the recorded unprivileged local ss inventory. - immutable (REASONED; sqlite, datasette-docs): Use --immutable only for an unchanged snapshot; SQLite skips locking/change detection, so changing it can yield incorrect results or corruption errors. - datasette-docker (REASONED; datasette-docs): Documented Docker launch binds 0.0.0.0:8001 and publishes 8001:8001; container bind and host publication are separate. - container-generator (REASONED; ds-container): At the pinned commit, make_dockerfile starts datasette serve --host 0.0.0.0, applies shlex.quote to all prior tokens, then appends literal unquoted --port $PORT in shell-form CMD; runtime expansion is undemonstrated. Package and publish cloudrun use the wrapper, Heroku a separate Procfile path (other publishers unestablished); guide advice: review the generated command, not just its bind address. - cloudrun (REASONED; ds-cloud, cloudrun): Inspected publisher uses --allow-unauthenticated; Cloud Run requires 0.0.0.0 and PORT, ingress default 8080, with external TLS termination but no implied application authorization. - publish-plugins (REASONED; fly, datasette-docs): Review each publishing plugin and provider hostname; datasette-publish-fly documents app.fly.dev and no universal plugin authentication default was established. - docker-publication (REASONED; docker): Address-less Docker publication binds all host addresses; use explicit host loopback mappings for 8001 or 8080, and check direct routing and the pre-28.0.0 caveat externally. - actor-policy (REASONED; github-auth, datasette-docs): Metadata allow blocks authorize actors at instance, database, table and canned-query scope; authentication alone does not restrict access. GitHub authentication explicitly leaves anonymous access until permissions restrict it. - root-url (REASONED; datasette-docs): datasette --root issues a one-use root-cookie login URL, not an anonymous-access restriction; treat the URL as a credential. - canned-writes (REASONED; datasette-docs): Ordinary SQL is read-only; canned queries enable writes with write:true and plugins may add further mutable routes, not a global write mode. - write-plugin (REASONED; write, datasette-docs): datasette-write adds /db/-/write governed by its own permission, root-only by default; installing it does not grant anonymous writes. - sql-policy (REASONED; datasette-docs): ?sql= and .json?sql= allow arbitrary reads; table/view allow rules do not prevent SQL reading those objects. Set allow_sql policy or default_allow_sql off; no generic --setting permissions exists. - query-limits (REASONED; datasette-docs): Queries default to a one-second time limit; repeated expensive requests may still consume resources, an inference rather than demonstrated denial of service. - downloads (DEMONSTRATED; datasette-docs): allow_download defaults on for immutable file-backed databases; disabling it controls whole-file downloads, not ordinary exports. Local /published.db changed from 200 to 403. - cors (DEMONSTRATED; datasette-docs): The local --cors run added Access-Control-Allow-Origin: *; the header was absent without the flag. - introspection (REASONED; datasette-docs): /-/metadata, /-/plugins, /-/versions, /-/settings and /-/databases, including JSON forms, expose instance details; permissions debugger separately requires root or its permission. - hash-urls (REASONED; datasette-docs): Core hash_urls was removed in 0.61; datasette-hashed-urls provides cache-oriented content hashes, not secrecy or authorization. - csrf (REASONED; datasette-docs): Datasette asgi-csrf compares ds_csrftoken cookie with csrftoken form field or x-csrftoken header; preserve it for writes and review skip_csrf plugins. It does not restrict anonymous reads. - plugin-secrets (DEMONSTRATED; passwords, datasette-docs): The local analyst saw inline password hashes in metadata, but only $env references after environment indirection. - web-bind (DEMONSTRATED; web, web-source): Bare sqlite_web listened on 127.0.0.1:8080 in the recorded unprivileged local ss inventory. - web-capabilities (REASONED; web): sqlite-web supports queries, imports/exports, row changes and dropping schema objects; reachable unauthenticated write access implies database compromise, not automatic host compromise. - web-container (REASONED; web-image, web): sqlite-web Dockerfile starts -H 0.0.0.0 and exposes 8080; README publishes 8080:8080. Use explicit loopback publication for a same-host proxy. - web-password (DEMONSTRATED; web, web-source): Local SQLITE_WEB_PASSWORD without -P left anonymous query access at 200; with -P the anonymous query redirected to /login/. - web-readonly (DEMONSTRATED; web, web-source, sqlite): In the local --read-only run the anonymous read still returned 200 while the insert changed nothing. - web-methods (REASONED; web-source): Inspected sqlite-web query handler permits read GETs; writes, scripts and drops require POST. A harmless query GET establishes anonymous read access. - web-tls (REASONED; web): sqlite-web documents native --ssl-cert and --ssl-key; this deployment baseline uses the shared TLS proxy. - web-csrf (DEMONSTRATED; web, web-source): sqlite-web 0.8.1 loopback insert accepted an authenticated POST without a token and with foreign Origin; the form has no CSRF field. Prefer read-only behind the proxy. - web-cookie (DEMONSTRATED; web, web-source): sqlite-web 0.8.1 set HttpOnly; Path=/ without SameSite or Secure even over TLS; cross-site cookie sending therefore depends on browser defaults. - fronting (REASONED; nginx, caddy-docs, datasette-docs): Keep same-host origins on loopback and apply TLS and authentication to every UI, SQL, JSON, export, download and auxiliary route. - identity-proxy (REASONED; iap, access): IAP and Cloudflare Access evaluate access before forwarding; no tenancy was available to demonstrate these routes. - mfa (REASONED; web, access, datasette-docs): Neither reviewed core authentication interface supplies native MFA; enforce it at the identity provider or through Cloudflare Access independent MFA. - tailnet (REASONED; tailscale): Tailscale Serve shares inside the tailnet; Funnel publishes publicly. Verify which path is configured. - origin-bypass (REASONED; iap): Inventory direct origins and provider hostnames so proxy login cannot be bypassed; IAP documents backend and Cloud Run hostname precautions. - os-account (REASONED; sqlite): Use a dedicated unprivileged account and curated snapshot whose file and parent it cannot modify; protect sidecars and temporary files under SQLite filesystem authority. - verify-inventory (REASONED; web, ss, docker, cloudrun, datasette-docs): Inspect complete listener table, IPv6 and unexpected ports; empty output after an error is inconclusive. Root ss, wildcard binds, Docker publication and managed ingress remain reasoned without sudo/container capability. - verify-external (REASONED; web, iap, curl, datasette-docs): External origin/provider tests require a successful local control and actual deployment targets; any HTTP response proves reachability, while transport failures alone prove no isolation. No second network/provider deployment was available. - verify-anonymous (REASONED; web, datasette-docs): UI, schema or row data without credentials is exposure; login pages, unexplained 404s and HTTP success alone do not establish protection. SQL-disabled tests also need an authorized browsing control. - datasette-exposed (DEMONSTRATED; datasette-docs): Local bare Datasette returned listing and known SQL row with 200 and /-/versions.json anonymously with 200; baseline SQL returned 403 while table JSON still returned 200. Block B was then in an earlier form; its current form has the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs. - web-exposed (DEMONSTRATED; web, web-source): Local bare sqlite-web returned query GET 200 anonymously and accepted an anonymous insert into a disposable database. - verify-authorized (REASONED; web, curl, iap, access, datasette-docs): Private mode-0600 header file supplies credentials by path, not argv values; compare known authorized data with anonymous denial and repeat with an unauthorized actor. File guards establish readability/type, not privacy; identity-proxy scenarios lack a tenancy. - actor-results (DEMONSTRATED; passwords, datasette-docs): Local password-plugin analyst cookie returned table rows with 200, anonymous and guest requests got 403, and analyst SQL got 403 under allow_sql:false. Block C was then in an earlier form; its current form has the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs. - web-auth-results (DEMONSTRATED; web, web-source): Local sqlite-web -P session cookie returned the row with 200 and the anonymous request redirected to /login/. - introspection-results (DEMONSTRATED; passwords, datasette-docs): Under the local metadata policy, all five auxiliary JSON routes returned 403 anonymously and 200 to a freshly logged-in analyst. - caddy-results (DEMONSTRATED; caddy-docs, datasette-docs): Caddy v2.11.4 loopback TLS/Basic proxy denied anonymous UI, SQL, database download, versions and CSV with 401 and allowed credentials with 200; direct backend remained anonymous. Protection depends on backend isolation. Block C was then in an earlier form; its current form has the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs. - local-checks (DEMONSTRATED; curl): Recorded bash parsing, metadata JSON parsing and 120 guard executions passed; curl argument recording tested guards separately from service outcomes. They covered earlier forms of blocks A, B and C; their current forms have the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs. - guard-limits (DEMONSTRATED; curl): Use Bash with real unshadowed builtins and curl 7.75.0 or newer; paste whole blocks, escape apostrophes, avoid credential URLs, and retain TLS verification. Partial pastes and inherited matching marker/arguments remain outside guard coverage. Those cases ran on earlier forms of blocks B and C; their current forms have the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs. - datasette-cli (REASONED; datasette-docs): Bare datasette invokes serve; -h/--host and -p/--port override its bind and port. - web-cli (REASONED; web, web-source): sqlite-web -H/--host uses uppercase H and -p/--port overrides the port; the documented default is 127.0.0.1:8080. - write-test (DEMONSTRATED; write, datasette-docs): Local anonymous write and root write without CSRF were denied; root write with the token returned 302 and wrote the row. - secret-indirection (REASONED; datasette-docs): Keep plugin secrets out of public metadata with documented environment or file indirection, and protect root login URLs and credential files. - web-auth-config (REASONED; web, web-source, web-tag-source): In tagged sqlite-web 0.8.1, a nonempty shared password requires -P/--password; SQLITE_WEB_PASSWORD is read only inside that option branch, and an unset or empty variable leads to a prompt. The shared login supplies no per-table roles. - web-readonly-config (REASONED; web, web-source, sqlite): --read-only opens SQLite with mode=ro and reduces exposure to reads; it does not authenticate callers. - cors-policy (REASONED; datasette-docs): --cors permits cross-origin JSON access and should be omitted unless intended; its absence does not authenticate requests. - query-pages (REASONED; datasette-docs): Queries return at most 1,000 rows at a time by default; pagination retrieves more, so that limit is not a confidentiality boundary. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "f32f2d8ab5237bf48e3a0dbb55bc212b9cf5e903bd7165f3324c1758aad3cc47", "components": { "ds-container": { "name": "Datasette container generator", "basis": "caf238aac86ebe370959b25384d6e05f6a1e2359", "sources": { "s3c9be24424e0": "https://raw.githubusercontent.com/simonw/datasette/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/utils/__init__.py", "sffebbc2c5528": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/utils/__init__.py#L576-L655", "seae12396d87a": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/utils/__init__.py#L658-L736", "s9e735f75bf4b": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/cli.py#L173-L179", "s224af1264bb1": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/cli.py#L203-L306", "sf0ea47721a46": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/cli.py#L352-L358", "sae5997e91b6b": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/publish/cloudrun.py#L17-L244", "s735b0ff17b61": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/datasette/publish/heroku.py#L21-L254", "s2484732fbf31": "https://github.com/simonw/datasette/blob/caf238aac86ebe370959b25384d6e05f6a1e2359/tests/test_package.py#L11-L61" } }, "web": { "name": "sqlite-web package", "basis": "0.8.1", "sources": { "s91f8591a5f84": "https://github.com/coleifer/sqlite-web/blob/0.8.1/README.md" } }, "web-tag-source": { "name": "sqlite-web tagged implementation", "basis": "0.8.1", "sources": { "s1626cd0d975f": "https://github.com/coleifer/sqlite-web/blob/0.8.1/sqlite_web/sqlite_web.py#L3", "s8c12f9aab57c": "https://github.com/coleifer/sqlite-web/blob/0.8.1/sqlite_web/sqlite_web.py#L1735-L1740", "s30bba3ce63c6": "https://github.com/coleifer/sqlite-web/blob/0.8.1/sqlite_web/sqlite_web.py#L1944-L1956" } }, "ds-cloud": { "name": "Datasette Cloud Run publisher", "basis": "e889403d3bbe143854262682161c98a57bdb6594", "sources": { "sf5859249ea9f": "https://github.com/simonw/datasette/blob/e889403d3bbe143854262682161c98a57bdb6594/datasette/publish/cloudrun.py" } }, "fly": { "name": "Datasette Fly publishing plugin", "basis": "unknown", "sources": { "s2a2db4192225": "https://github.com/simonw/datasette-publish-fly" } }, "datasette-docs": { "name": "Datasette documentation (rolling)", "basis": "unknown", "sources": { "s3e64933fe2e9": "https://docs.datasette.io/en/stable/changelog.html", "sb0aae2446988": "https://docs.datasette.io/en/stable/cli-reference.html", "sca59b58c5c84": "https://docs.datasette.io/en/stable/performance.html", "s8f2fe7a13b3f": "https://docs.datasette.io/en/stable/installation.html", "se7cc29f79be8": "https://docs.datasette.io/en/stable/publish.html", "s878851fd8eac": "https://docs.datasette.io/en/stable/authentication.html", "s824eb176fda7": "https://docs.datasette.io/en/stable/sql_queries.html", "s406cc998a5da": "https://docs.datasette.io/en/stable/internals.html#csrf-protection", "se154b0fa47dd": "https://docs.datasette.io/en/stable/settings.html", "s96c8e6562139": "https://docs.datasette.io/en/stable/json_api.html", "s1e93b280c5af": "https://docs.datasette.io/en/stable/introspection.html", "sb1b30f98f026": "https://docs.datasette.io/en/stable/plugins.html#secret-configuration-values", "s53e84082f6d1": "https://docs.datasette.io/en/stable/deploying.html" } }, "web-source": { "name": "sqlite-web implementation", "basis": "825b4a050fe2a04e6c6cf4b8ee1f56eaea4feef2", "sources": { "s13d71c0a8cb2": "https://raw.githubusercontent.com/coleifer/sqlite-web/825b4a050fe2a04e6c6cf4b8ee1f56eaea4feef2/sqlite_web/sqlite_web.py" } }, "web-image": { "name": "sqlite-web Dockerfile", "basis": "7f6658b012a789f0b9418cad543e7798ce850130", "sources": { "s4c81e6becc20": "https://raw.githubusercontent.com/coleifer/sqlite-web/7f6658b012a789f0b9418cad543e7798ce850130/docker/Dockerfile" } }, "passwords": { "name": "datasette-auth-passwords", "basis": "1.1.1", "sources": { "sb6597be5af67": "https://github.com/simonw/datasette-auth-passwords/blob/1.1.1/README.md" } }, "github-auth": { "name": "datasette-auth-github", "basis": "unknown", "sources": { "s90107b903580": "https://github.com/simonw/datasette-auth-github" } }, "write": { "name": "datasette-write", "basis": "0.4", "sources": { "s073e10d21091": "https://github.com/simonw/datasette-write/blob/0.4/README.md" } }, "sqlite": { "name": "SQLite documentation", "basis": "unknown", "sources": { "s4e4e320abf5b": "https://www.sqlite.org/uri.html", "s2721da91f8d2": "https://www.sqlite.org/serverless.html", "sadf0431c1271": "https://www.sqlite.org/security.html" } }, "docker": { "name": "Docker localhost-publication boundary", "basis": "28.0.0", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } }, "cloudrun": { "name": "Cloud Run documentation", "basis": "unknown", "sources": { "sb90a6c13388a": "https://docs.cloud.google.com/run/docs/container-contract" } }, "nginx": { "name": "nginx documentation", "basis": "unknown", "sources": { "s6a0728ca7609": "https://nginx.org/en/docs/http/configuring_https_servers.html", "sf32ce2956917": "https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html" } }, "iap": { "name": "Google IAP documentation", "basis": "unknown", "sources": { "saafdfc163fce": "https://docs.cloud.google.com/iap/docs/concepts-overview" } }, "access": { "name": "Cloudflare Access documentation", "basis": "unknown", "sources": { "sfe56578bedf4": "https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/", "s06747cf7388c": "https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/independent-mfa/" } }, "tailscale": { "name": "Tailscale documentation", "basis": "unknown", "sources": { "s5b9a9b15ae80": "https://tailscale.com/docs/reference/tailscale-cli/serve" } }, "caddy-docs": { "name": "Caddy documentation (rolling)", "basis": "unknown", "sources": { "sbc2bd9599bd1": "https://caddyserver.com/docs/caddyfile/directives/basic_auth" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "ss": { "name": "ss documentation", "basis": "unknown", "sources": { "sfc58f455acfa": "https://man7.org/linux/man-pages/man8/ss.8.html" } } }, "claims": { "release": {"text": "The recorded stable Datasette documentation lists 0.65.5 with a table-permission bypass fix; deploy a supported release containing the security fixes.", "components": ["datasette-docs"], "sources": ["datasette-docs:s3e64933fe2e9"], "status": "REASONED"}, "datasette-bind": {"text": "Bare datasette serve without host or port flags listened on 127.0.0.1:8001 in the recorded unprivileged local ss inventory.", "components": ["datasette-docs"], "sources": ["datasette-docs:sb0aae2446988"], "status": "DEMONSTRATED", "evidence": "bare `datasette serve` with no host or port flag listened on `127.0.0.1:8001`"}, "immutable": {"text": "Use --immutable only for an unchanged snapshot; SQLite skips locking/change detection, so changing it can yield incorrect results or corruption errors.", "components": ["sqlite", "datasette-docs"], "sources": ["datasette-docs:sca59b58c5c84", "sqlite:s4e4e320abf5b"], "status": "REASONED"}, "datasette-docker": {"text": "Documented Docker launch binds 0.0.0.0:8001 and publishes 8001:8001; container bind and host publication are separate.", "components": ["datasette-docs"], "sources": ["datasette-docs:s8f2fe7a13b3f"], "status": "REASONED"}, "container-generator": {"text": "At the pinned commit, make_dockerfile starts datasette serve --host 0.0.0.0, applies shlex.quote to all prior tokens, then appends literal unquoted --port $PORT in shell-form CMD; runtime expansion is undemonstrated. Package and publish cloudrun use the wrapper, Heroku a separate Procfile path (other publishers unestablished); guide advice: review the generated command, not just its bind address.", "components": ["ds-container"], "sources": ["ds-container:s3c9be24424e0", "ds-container:sffebbc2c5528", "ds-container:seae12396d87a", "ds-container:s9e735f75bf4b", "ds-container:s224af1264bb1", "ds-container:sf0ea47721a46", "ds-container:sae5997e91b6b", "ds-container:s735b0ff17b61", "ds-container:s2484732fbf31"], "status": "REASONED"}, "cloudrun": {"text": "Inspected publisher uses --allow-unauthenticated; Cloud Run requires 0.0.0.0 and PORT, ingress default 8080, with external TLS termination but no implied application authorization.", "components": ["ds-cloud", "cloudrun"], "sources": ["ds-cloud:sf5859249ea9f", "cloudrun:sb90a6c13388a"], "status": "REASONED"}, "publish-plugins": {"text": "Review each publishing plugin and provider hostname; datasette-publish-fly documents app.fly.dev and no universal plugin authentication default was established.", "components": ["fly", "datasette-docs"], "sources": ["datasette-docs:se7cc29f79be8", "fly:s2a2db4192225"], "status": "REASONED"}, "docker-publication": {"text": "Address-less Docker publication binds all host addresses; use explicit host loopback mappings for 8001 or 8080, and check direct routing and the pre-28.0.0 caveat externally.", "components": ["docker"], "sources": ["docker:s1e53417c513d"], "status": "REASONED"}, "actor-policy": {"text": "Metadata allow blocks authorize actors at instance, database, table and canned-query scope; authentication alone does not restrict access. GitHub authentication explicitly leaves anonymous access until permissions restrict it.", "components": ["github-auth", "datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "github-auth:s90107b903580"], "status": "REASONED"}, "root-url": {"text": "datasette --root issues a one-use root-cookie login URL, not an anonymous-access restriction; treat the URL as a credential.", "components": ["datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "datasette-docs:sb0aae2446988"], "status": "REASONED"}, "canned-writes": {"text": "Ordinary SQL is read-only; canned queries enable writes with write:true and plugins may add further mutable routes, not a global write mode.", "components": ["datasette-docs"], "sources": ["datasette-docs:s824eb176fda7"], "status": "REASONED"}, "write-plugin": {"text": "datasette-write adds /db/-/write governed by its own permission, root-only by default; installing it does not grant anonymous writes.", "components": ["write", "datasette-docs"], "sources": ["datasette-docs:s406cc998a5da", "write:s073e10d21091"], "status": "REASONED"}, "sql-policy": {"text": "?sql= and .json?sql= allow arbitrary reads; table/view allow rules do not prevent SQL reading those objects. Set allow_sql policy or default_allow_sql off; no generic --setting permissions exists.", "components": ["datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "datasette-docs:se154b0fa47dd", "datasette-docs:s824eb176fda7"], "status": "REASONED"}, "query-limits": {"text": "Queries default to a one-second time limit; repeated expensive requests may still consume resources, an inference rather than demonstrated denial of service.", "components": ["datasette-docs"], "sources": ["datasette-docs:se154b0fa47dd", "datasette-docs:s824eb176fda7"], "status": "REASONED"}, "downloads": {"text": "allow_download defaults on for immutable file-backed databases; disabling it controls whole-file downloads, not ordinary exports. Local /published.db changed from 200 to 403.", "components": ["datasette-docs"], "sources": ["datasette-docs:se154b0fa47dd"], "status": "DEMONSTRATED", "evidence": "`/published.db` returned `403`; without `--setting allow_download off` it returned `200`."}, "cors": {"text": "The local --cors run added Access-Control-Allow-Origin: *; the header was absent without the flag.", "components": ["datasette-docs"], "sources": ["datasette-docs:s96c8e6562139"], "status": "DEMONSTRATED", "evidence": "`--cors` added `Access-Control-Allow-Origin: *`, absent without it."}, "introspection": {"text": "/-/metadata, /-/plugins, /-/versions, /-/settings and /-/databases, including JSON forms, expose instance details; permissions debugger separately requires root or its permission.", "components": ["datasette-docs"], "sources": ["datasette-docs:s1e93b280c5af", "datasette-docs:s878851fd8eac"], "status": "REASONED"}, "hash-urls": {"text": "Core hash_urls was removed in 0.61; datasette-hashed-urls provides cache-oriented content hashes, not secrecy or authorization.", "components": ["datasette-docs"], "sources": ["datasette-docs:s3e64933fe2e9", "datasette-docs:sca59b58c5c84"], "status": "REASONED"}, "csrf": {"text": "Datasette asgi-csrf compares ds_csrftoken cookie with csrftoken form field or x-csrftoken header; preserve it for writes and review skip_csrf plugins. It does not restrict anonymous reads.", "components": ["datasette-docs"], "sources": ["datasette-docs:s406cc998a5da"], "status": "REASONED"}, "plugin-secrets": {"text": "The local analyst saw inline password hashes in metadata, but only $env references after environment indirection.", "components": ["passwords", "datasette-docs"], "sources": ["datasette-docs:sb1b30f98f026", "passwords:sb6597be5af67"], "status": "DEMONSTRATED", "evidence": "`/-/metadata.json` showed that analyst both users' password hashes when they were written inline in the plugin configuration, but only the `$env` references when they were supplied through environment variables."}, "web-bind": {"text": "Bare sqlite_web listened on 127.0.0.1:8080 in the recorded unprivileged local ss inventory.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "DEMONSTRATED", "evidence": "bare `sqlite_web` on `127.0.0.1:8080`, as `ss -tlnp` run without `sudo` by the same account showed"}, "web-capabilities": {"text": "sqlite-web supports queries, imports/exports, row changes and dropping schema objects; reachable unauthenticated write access implies database compromise, not automatic host compromise.", "components": ["web"], "sources": ["web:s91f8591a5f84"], "status": "REASONED"}, "web-container": {"text": "sqlite-web Dockerfile starts -H 0.0.0.0 and exposes 8080; README publishes 8080:8080. Use explicit loopback publication for a same-host proxy.", "components": ["web-image", "web"], "sources": ["web-image:s4c81e6becc20", "web:s91f8591a5f84"], "status": "REASONED"}, "web-password": {"text": "Local SQLITE_WEB_PASSWORD without -P left anonymous query access at 200; with -P the anonymous query redirected to /login/.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "DEMONSTRATED", "evidence": "`SQLITE_WEB_PASSWORD` set without `-P` left sqlite-web open (`200` anonymously), as the source reading above says; with `-P` the anonymous query GET got `302` to `/login/`."}, "web-readonly": {"text": "In the local --read-only run the anonymous read still returned 200 while the insert changed nothing.", "components": ["web", "web-source", "sqlite"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2", "sqlite:s4e4e320abf5b"], "status": "DEMONSTRATED", "evidence": "with `--read-only` the read still returned `200` and the insert changed nothing."}, "web-methods": {"text": "Inspected sqlite-web query handler permits read GETs; writes, scripts and drops require POST. A harmless query GET establishes anonymous read access.", "components": ["web-source"], "sources": ["web-source:s13d71c0a8cb2"], "status": "REASONED"}, "web-tls": {"text": "sqlite-web documents native --ssl-cert and --ssl-key; this deployment baseline uses the shared TLS proxy.", "components": ["web"], "sources": ["web:s91f8591a5f84"], "status": "REASONED"}, "web-csrf": {"text": "sqlite-web 0.8.1 loopback insert accepted an authenticated POST without a token and with foreign Origin; the form has no CSRF field. Prefer read-only behind the proxy.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "DEMONSTRATED", "evidence": "an authenticated `POST` insert with no token and a foreign `Origin` header wrote a row, and the insert form contains no CSRF field."}, "web-cookie": {"text": "sqlite-web 0.8.1 set HttpOnly; Path=/ without SameSite or Secure even over TLS; cross-site cookie sending therefore depends on browser defaults.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "DEMONSTRATED", "evidence": "Its session cookie was set with `HttpOnly; Path=/` and no `SameSite` or `Secure` attribute, even over TLS"}, "fronting": {"text": "Keep same-host origins on loopback and apply TLS and authentication to every UI, SQL, JSON, export, download and auxiliary route.", "components": ["nginx", "caddy-docs", "datasette-docs"], "sources": ["datasette-docs:s53e84082f6d1", "nginx:s6a0728ca7609", "nginx:sf32ce2956917", "caddy-docs:sbc2bd9599bd1"], "status": "REASONED"}, "identity-proxy": {"text": "IAP and Cloudflare Access evaluate access before forwarding; no tenancy was available to demonstrate these routes.", "components": ["iap", "access"], "sources": ["iap:saafdfc163fce", "access:sfe56578bedf4"], "status": "REASONED"}, "mfa": {"text": "Neither reviewed core authentication interface supplies native MFA; enforce it at the identity provider or through Cloudflare Access independent MFA.", "components": ["web", "access", "datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "web:s91f8591a5f84", "access:s06747cf7388c"], "status": "REASONED"}, "tailnet": {"text": "Tailscale Serve shares inside the tailnet; Funnel publishes publicly. Verify which path is configured.", "components": ["tailscale"], "sources": ["tailscale:s5b9a9b15ae80"], "status": "REASONED"}, "origin-bypass": {"text": "Inventory direct origins and provider hostnames so proxy login cannot be bypassed; IAP documents backend and Cloud Run hostname precautions.", "components": ["iap"], "sources": ["iap:saafdfc163fce"], "status": "REASONED"}, "os-account": {"text": "Use a dedicated unprivileged account and curated snapshot whose file and parent it cannot modify; protect sidecars and temporary files under SQLite filesystem authority.", "components": ["sqlite"], "sources": ["sqlite:s2721da91f8d2", "sqlite:sadf0431c1271"], "status": "REASONED"}, "verify-inventory": {"text": "Inspect complete listener table, IPv6 and unexpected ports; empty output after an error is inconclusive. Root ss, wildcard binds, Docker publication and managed ingress remain reasoned without sudo/container capability.", "components": ["web", "ss", "docker", "cloudrun", "datasette-docs"], "sources": ["datasette-docs:sb0aae2446988", "web:s91f8591a5f84", "ss:sfc58f455acfa", "docker:s1e53417c513d", "cloudrun:sb90a6c13388a"], "status": "REASONED", "verify": [1]}, "verify-external": {"text": "External origin/provider tests require a successful local control and actual deployment targets; any HTTP response proves reachability, while transport failures alone prove no isolation. No second network/provider deployment was available.", "components": ["web", "iap", "curl", "datasette-docs"], "sources": ["datasette-docs:s53e84082f6d1", "web:s91f8591a5f84", "iap:saafdfc163fce", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2]}, "verify-anonymous": {"text": "UI, schema or row data without credentials is exposure; login pages, unexplained 404s and HTTP success alone do not establish protection. SQL-disabled tests also need an authorized browsing control.", "components": ["web", "datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "datasette-docs:s824eb176fda7", "web:s91f8591a5f84"], "status": "REASONED", "verify": [2]}, "datasette-exposed": {"text": "Local bare Datasette returned listing and known SQL row with 200 and /-/versions.json anonymously with 200; baseline SQL returned 403 while table JSON still returned 200. Block B was then in an earlier form; its current form has the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs.", "components": ["datasette-docs"], "sources": ["datasette-docs:sb0aae2446988", "datasette-docs:se154b0fa47dd", "datasette-docs:s96c8e6562139", "datasette-docs:s1e93b280c5af"], "status": "DEMONSTRATED", "evidence": "Against the baseline command above, the SQL JSON route returned `403` while the table's JSON page still returned `200` (SQL disabled, browsing permitted)"}, "web-exposed": {"text": "Local bare sqlite-web returned query GET 200 anonymously and accepted an anonymous insert into a disposable database.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "DEMONSTRATED", "evidence": "Bare `sqlite_web` answered the query GET with `200` anonymously, and an anonymous `POST` insert wrote a row to a disposable database"}, "verify-authorized": {"text": "Private mode-0600 header file supplies credentials by path, not argv values; compare known authorized data with anonymous denial and repeat with an unauthorized actor. File guards establish readability/type, not privacy; identity-proxy scenarios lack a tenancy.", "components": ["web", "curl", "iap", "access", "datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "web:s91f8591a5f84", "curl:s2b2686afaf41", "iap:saafdfc163fce", "access:sfe56578bedf4"], "status": "REASONED", "verify": [3]}, "actor-results": {"text": "Local password-plugin analyst cookie returned table rows with 200, anonymous and guest requests got 403, and analyst SQL got 403 under allow_sql:false. Block C was then in an earlier form; its current form has the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs.", "components": ["passwords", "datasette-docs"], "sources": ["datasette-docs:s878851fd8eac", "passwords:sb6597be5af67"], "status": "DEMONSTRATED", "evidence": "the block returned the table's rows to the `analyst` session cookie (`authorized http=200`) and `403` anonymously; an authenticated `guest` got `403`, the unauthorized outcome, and the analyst got `403` on SQL under `allow_sql: false`."}, "web-auth-results": {"text": "Local sqlite-web -P session cookie returned the row with 200 and the anonymous request redirected to /login/.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "DEMONSTRATED", "evidence": "For sqlite-web with `-P`, the session cookie got the row with `200` and the anonymous request `302` to `/login/`."}, "introspection-results": {"text": "Under the local metadata policy, all five auxiliary JSON routes returned 403 anonymously and 200 to a freshly logged-in analyst.", "components": ["passwords", "datasette-docs"], "sources": ["datasette-docs:s1e93b280c5af", "datasette-docs:s878851fd8eac", "passwords:sb6597be5af67"], "status": "DEMONSTRATED", "evidence": "Under the same metadata, `/-/versions.json`, `/-/metadata.json`, `/-/plugins.json`, `/-/settings.json` and `/-/databases.json` returned `403` anonymously and `200` to a freshly logged-in analyst"}, "caddy-results": {"text": "Caddy v2.11.4 loopback TLS/Basic proxy denied anonymous UI, SQL, database download, versions and CSV with 401 and allowed credentials with 200; direct backend remained anonymous. Protection depends on backend isolation. Block C was then in an earlier form; its current form has the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs.", "components": ["caddy-docs", "datasette-docs"], "sources": ["caddy-docs:sbc2bd9599bd1", "datasette-docs:s53e84082f6d1"], "status": "DEMONSTRATED", "evidence": "Block C through the proxy gave `authorized http=200` with the rows and `anonymous http=401` with `www-authenticate: Basic realm=\"restricted\"`."}, "local-checks": {"text": "Recorded bash parsing, metadata JSON parsing and 120 guard executions passed; curl argument recording tested guards separately from service outcomes. They covered earlier forms of blocks A, B and C; their current forms have the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "DEMONSTRATED", "evidence": "Local authoring checks passed: `bash -n` on all five shell blocks, parsing of the metadata JSON, and 120 guard executions covering 40 cases under ordinary Bash, `set -u`, and `set -u` with `IFS=0`."}, "guard-limits": {"text": "Use Bash with real unshadowed builtins and curl 7.75.0 or newer; paste whole blocks, escape apostrophes, avoid credential URLs, and retain TLS verification. Partial pastes and inherited matching marker/arguments remain outside guard coverage. Those cases ran on earlier forms of blocks B and C; their current forms have the gate suite's bash -n and ShellCheck 0.11.0 passes and drafting stub runs.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "DEMONSTRATED", "evidence": "Across 48 block B/C cases under ordinary Bash, set -u, and set -u with IFS=0, an explicitly substituted curl argument recorder ran with valid whole-block values, inherited matching marker/arguments, and pastes below the guards. Whole-block placeholders and omitted assignments without a matching marker exited 2 without calling it."}, "datasette-cli": {"text": "Bare datasette invokes serve; -h/--host and -p/--port override its bind and port.", "components": ["datasette-docs"], "sources": ["datasette-docs:sb0aae2446988"], "status": "REASONED"}, "web-cli": {"text": "sqlite-web -H/--host uses uppercase H and -p/--port overrides the port; the documented default is 127.0.0.1:8080.", "components": ["web", "web-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2"], "status": "REASONED"}, "write-test": {"text": "Local anonymous write and root write without CSRF were denied; root write with the token returned 302 and wrote the row.", "components": ["write", "datasette-docs"], "sources": ["datasette-docs:s406cc998a5da", "write:s073e10d21091"], "status": "DEMONSTRATED", "evidence": "With datasette-write on a disposable database, an anonymous write got `403`, a root write without the CSRF token `403`, and with the token `302`, and the row was written."}, "secret-indirection": {"text": "Keep plugin secrets out of public metadata with documented environment or file indirection, and protect root login URLs and credential files.", "components": ["datasette-docs"], "sources": ["datasette-docs:sb1b30f98f026", "datasette-docs:s878851fd8eac"], "status": "REASONED"}, "web-auth-config": {"text": "In tagged sqlite-web 0.8.1, a nonempty shared password requires -P/--password; SQLITE_WEB_PASSWORD is read only inside that option branch, and an unset or empty variable leads to a prompt. The shared login supplies no per-table roles.", "components": ["web", "web-source", "web-tag-source"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2", "web-tag-source:s1626cd0d975f", "web-tag-source:s8c12f9aab57c", "web-tag-source:s30bba3ce63c6"], "status": "REASONED"}, "web-readonly-config": {"text": "--read-only opens SQLite with mode=ro and reduces exposure to reads; it does not authenticate callers.", "components": ["web", "web-source", "sqlite"], "sources": ["web:s91f8591a5f84", "web-source:s13d71c0a8cb2", "sqlite:s4e4e320abf5b"], "status": "REASONED"}, "cors-policy": {"text": "--cors permits cross-origin JSON access and should be omitted unless intended; its absence does not authenticate requests.", "components": ["datasette-docs"], "sources": ["datasette-docs:s96c8e6562139"], "status": "REASONED"}, "query-pages": {"text": "Queries return at most 1,000 rows at a time by default; pagination retrieves more, so that limit is not a confidentiality boundary.", "components": ["datasette-docs"], "sources": ["datasette-docs:se154b0fa47dd", "datasette-docs:s824eb176fda7"], "status": "REASONED"} } } ```` ====================================================================== ==> surrealdb.md ====================================================================== # SurrealDB: authentication and TLS SurrealDB is a database server where apps store and query data. This guide helps your AI assistant configure it securely, including release 2.0 or later with authentication on and a root user, a private bind (its Docker images listen on every interface), scoped users, limited query capabilities, and TLS. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/surrealdb.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against SurrealDB server and image v4.0.0, SurrealDB 3.x server v3.3.5, SurrealDB native AUDIENCE minimum 3.3, SurrealDB SCOPE deprecation 2.x, SurrealDB SCOPE removal 3.0, GNU Bash reference manual bash-5.3, Linux procfs manual man-pages-6.19, and Linux ptrace manual man-pages-6.19; 3 further sources carry no recorded version. Metadata reviewed 2026-11-05; documentation checked September 2026. The guide records 45 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Since SurrealDB 2.0.0, authentication is enabled by default and the CLI binds `127.0.0.1:8000` rather than `0.0.0.0` (2.0.0 removed the old `--auth` flag, added `--unauthenticated`, and changed the bind default); on older releases neither held, so pin your version. Even on a current version, a laptop demo becomes a real exposure the moment the same command runs on a routable server with `--unauthenticated` or a widened bind carried over. This guide covers SurrealDB v4.0.0 and, where the two differ, v3.3.5, the last 3.x release (commit 5c3383fe). Where a statement cites only v4.0.0 source, the v3.3.5 source read for this guide supports it too, unless the text says otherwise. Statements that rest only on the rolling documentation, which as served on 2026-11-04 describes 4.0, are not established at v3.3.5. The v3.3.5 release record's body is only "Release v3.3.5", so what v3.3.5 changed is not established either. ## 1. Root user and authenticated mode At v4.0.0, two different programs answer to `surreal`. The database executable defines a `start` command and no `db` command, so it takes `surreal start`. The Surreal CLI launcher, which the vendor's install script installs as `surreal` with, by default, the database as a pinned component, takes `surreal db start` and passes everything after the component name to the database unchanged. The launcher's pages document no bare `surreal start`, and say "A name that is not a component, such as `surreal foo`, runs a `surreal-foo` from your `PATH` if one exists, and reports an unknown component otherwise." As this guide's inference, under the launcher `surreal start` does not start the database, and could pass the block's environment to a `surreal-start` found on `PATH`. So the block below uses `surreal db start`, for the documented launcher install; where the standalone database executable is installed as `surreal`, drop `db`. The container example in section 2 keeps `start`, because in the `dev` and `prod` stages of the repository's `docker/Dockerfile` the `ENTRYPOINT` runs `/surreal`, by default copied from `target/release/surreal`, which as this guide reads it is the database executable. The `dev-ci` and `prod-ci` stages copy a prebuilt CI artifact instead, whose origin is not established at v4.0.0. Which program the vendor's install script put on `PATH` as `surreal` for 3.x releases is not established, so whether a v3.3.5 install takes `surreal start` or `surreal db start` is not established either. `surreal db start` takes `--user`/`-u` and `--pass`/`-p` (also `SURREAL_USER`/`SURREAL_PASS`) for, in the v4.0.0 help text, "the initial database root user. Only if no other root user exists": they initialize that user when none exists, and do not disable authentication or rotate an existing root password. At v4.0.0 both flags refuse an empty value (`value_parser = clap::builder::NonEmptyStringValueParser::new()`), and the server core refuses an empty or whitespace-only username or password before it creates the user (`!user.trim().is_empty() && !pass.trim().is_empty()`). Whether an empty `SURREAL_PASS` in the environment reaches those checks or counts as unset is not established at v4.0.0; the block refuses an empty password itself. At v3.3.5 neither check is in the cited code. Its `--user` and `--pass` arguments end with `requires = "password"` and `requires = "username"`, with no `value_parser`, and its core `initialise_credentials` goes straight to `Self::retry("Initialise credentials", || self.initialise_credentials_attempt(user, pass))` with no `ensure!`. Whether v3.3.5 refuses an empty value elsewhere, in the user definition it then computes or in clap's handling of an empty environment variable, is not established; the block's own check refuses an empty password on either release. A root user can also come from the SurrealQL file that `--import-file` (`SURREAL_IMPORT_FILE`) runs at start: the server runs that import first, "so that an import which supplies root users suppresses creation of the CLI bootstrap user", so treat such a file as a credential. At v4.0.0, `--help` no longer prints credential values: the password argument sets `hide_env_values = true`, and the executable applies `arg.hide_env_values(true)` to every argument of every command. Before 4.0.0 it could: GHSA-whfh-76h8-2r66, "CLI help output prints the values of SURREAL_PASS and SURREAL_TOKEN" (low; affected `< 4.0.0`, patched `4.0.0`), names `surreal start` among the commands and was reproduced on v3.2.4. At v3.3.5 the password argument sets no `hide_env_values` (its attributes are `env = "SURREAL_PASS"`, `short = 'p'`, `long = "password"`, `visible_alias = "pass"` and `requires = "username"`), and no v3.3.5 file read for this guide calls `hide_env_values`. v3.3.5 is inside that advisory's affected range, though the advisory's reproductions name images up to `v3.3.4` and not v3.3.5. As this guide's advice, on v3.3.5 never run `--help` in a shell where `SURREAL_PASS` or `SURREAL_TOKEN` is set. Pass the password through the environment, never argv (which reaches `ps` and shell history). `surreal db start` has no stdin input for it (none in the v4.0.0 server files read for this guide), so the block prompts for it and hands it to that one command as a prefix assignment. Bash 5.3 says that assignment affects only the command's environment; ensure the password is never exported from the shell: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n pw SURREAL_USER SURREAL_PASS && unset -v pw SURREAL_USER SURREAL_PASS; } 2>/dev/null || { echo 'cannot clear pw, SURREAL_USER or SURREAL_PASS in this shell; not starting'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not starting'; exit 2; } set -- "${!SURREAL_@}" [ "$#" -eq 0 ] || { printf 'SURREAL_* variables set in this shell:'; printf ' %s' "$@"; printf '; unset them first; not starting\n'; exit 2; } IFS= read -r -s -p 'Initial root password: ' pw < /dev/tty || exit 2 printf '\n' [ -n "$pw" ] || exit 2 SURREAL_USER=root SURREAL_PASS="$pw" surreal db start surrealkv:/data/mydb.db --bind 127.0.0.1:8000 ) ``` This moves the password out of argv, not out of reach: `/proc//environ` contains the initial environment set at `execve` and does not reflect later changes to the process environment. Reading it is governed by a `PTRACE_MODE_READ_FSCREDS` check that depends on factors such as the credentials and capabilities of the two processes, dumpability and Linux Security Modules. As this guide's assessment, the same account or root may read the initial value when that check permits, and a process inheriting the environment may receive it and expose it the same way for as long as that process runs. Treat it as exposed to that account and to root while the server runs. Under the launcher, Bash starts the launcher with that environment. Whether the launcher replaces itself with the database or stays running as its parent, and whether it passes `SURREAL_PASS` on unchanged, is not established at v4.0.0: its pages say variables that start with `SURREAL_` and not `SURREAL_CLI_` "belong to the database, and the launcher never reads them", without saying how they reach it. As this guide's assessment, treat a resident launcher as exposed the same way, and confirm that the root user was created by signing in with it. Paste the password alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. The block clears `SURREAL_USER` and `SURREAL_PASS` itself, then refuses to start while any other `SURREAL_*` variable is set in the calling shell, naming each. The database reads many of its options from such variables, including `SURREAL_UNAUTHENTICATED` and `SURREAL_BIND`, so an inherited one would start the server differently from what the block shows. The check also refuses the launcher's own `SURREAL_CLI_*` variables, which its pages separate from the database's. Unset them, or start from a clean shell. v4.0.0 removes the RocksDB engine. The start page as served says the `rocksdb://` and `indxdb://` schemes "are kept as deprecated spellings of `surrealkv://`", so the block names `surrealkv:/data/mydb.db`, which that page reads as an absolute path. The server's own path check is in a crate this guide has not read, so how v4.0.0 handles a `rocksdb:` path is not established here. v3.3.5 differs. Its core's datastore factory comment gives `"memory",` and then `"rocksdb:...", "tikv:..."` as examples of the connection strings an implementation may interpret, and the 3.3 release page says a 3.3 feature is "Supported on every storage backend: in-memory, RocksDB, SurrealKV, IndexedDB and TiKV". Its core also documents `Datastore::new("rocksdb://database.db")` as a usage example, and its server has a test, compiled only with the `storage-rocksdb` feature (`#[cfg(feature = "storage-rocksdb")]`), that builds a `rocksdb:` path (`format!("rocksdb:{}", dir.path().join("store").display())`). As this guide's inference from these, RocksDB is a working engine at v3.3.5; they show code for it, not that a v3.3.5 release build includes it. How v3.3.5 parses a path scheme, and whether it reads the block's `surrealkv:/data/mydb.db` as an absolute path, is not established: its path parser was not read, and the start page as served describes 4.0. At v4.0.0 a directory a 3.x release wrote is refused unless the operator opts in. The error in the source says the in-place upgrade "is experimental and is refused by default", that "a 3.x release cannot open them afterwards", and to "take a backup and set SURREAL_EXPERIMENTAL_UPGRADE_FROM_3X=true to opt in to the experimental upgrade". This guide gives only that `true` value; which other values the server accepts is not established at v4.0.0. The refusal is v4.0.0's: v3.3.5's `kvs-surrealkv/src/lib.rs` contains no `SURREAL_EXPERIMENTAL_UPGRADE_FROM_3X` text, so as this guide's inference the opt-in does not apply to a v3.3.5 server. The block cannot carry that opt-in: set in the calling shell, it is a `SURREAL_*` variable, and the block refuses to start. As this guide's advice, convert a 3.x directory as its own deliberate step after a backup, and run this block on a directory that v4.0.0 wrote or on a new one. The `--unauthenticated` flag (`SURREAL_UNAUTHENTICATED`) allows unauthenticated access instead; a guest connecting under it gets permissions equivalent to the `OWNER` role. Do not run it, or carry it from a local demo, on anything reachable beyond your own machine. ## 2. Bind privately `--bind`/`-b` (`SURREAL_BIND`) sets the listening address and defaults to `127.0.0.1:8000` (as of v4.0.0), loopback only. The images built from the repository's `docker/Dockerfile` do not keep that default: each of its runtime stages sets `SURREAL_BIND=0.0.0.0:8000` and none sets a `CMD`, so a container run with `start` and no `--bind` or `SURREAL_BIND` of its own listens on every IPv4 interface inside the container. That is safe only while the host side stays private: publish it on host loopback (`127.0.0.1:8000:8000`) or attach it to a private network only. Whether the published images are built from that file is not established at v4.0.0. On a host, widen the bind deliberately, and only to a private address, for example `--bind 10.0.0.5:8000`; never bind an unauthenticated or root-only instance to `0.0.0.0`. SurrealDB's own security guidance says that if the database should only be reachable by other internal services, "expose SurrealDB exclusively to the internal network instead of deploying the service with a publicly addressable network interface." ## 3. User levels and access control System users (`DEFINE USER`) exist at three levels: root, namespace, and database. The `DEFINE USER` page says "Note that even a root-level user can be given a limited role such as `VIEWER`. This can be useful for automated services that need to monitor each namespace and database, or for colleagues in non-technical roles." The JWT page says, of root tokens, "Root tokens can be used to select, create, update, and delete on all tables in all databases of all namespaces, as well as to define and remove namespaces and databases from the SurrealDB instance." As this guide's inference from those sentences, a root system user reaches every namespace and database, within what its role allows. The best-practices page says system users may be assigned "roles that will limit the actions they can perform inside the level where they are defined", with `ROLES VIEWER` and `ROLES EDITOR` examples, and the start page says the start credentials "define a new root user with the `OWNER` role". The `DEFINE USER` page says "Currently, only the built-in roles OWNER, EDITOR and VIEWER are available." Whether every system user holds one is not established by the pages read for this guide. The docs warn plainly that "a root system user is not restricted by permissions at all, so it is the wrong credential to put in an application"; give trusted backend administration a narrowly scoped system user. But system users at EVERY level are governed by RBAC and bypass table and field `PERMISSIONS` entirely, so a namespace- or database-level system user is not a substitute for record-level isolation: when application users need database-enforced row or field permissions, use record users (below), not a system user. Record users are different: they are rows in your own tables, authenticated through `DEFINE ACCESS ... TYPE RECORD` with custom `SIGNUP` and `SIGNIN` logic. Record users get no access beyond what a `PERMISSIONS` clause on a table or field grants them, the same rules-are-the-security model as Firebase and Supabase ([firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md)): the permissions page says "omitting `PERMISSIONS` results in a `PERMISSIONS NONE`", so a table with no `PERMISSIONS` clause grants a record user nothing by default. Fields default the other way: "A field without an explicit clause is stored as `PERMISSIONS FULL`", which follows the table's rules, so give a sensitive field, such as a password hash, its own clause. The `/signup` and `/signin` HTTP endpoints accept unauthenticated credentials by design. At v4.0.0 so do `/signup/cookie` and `/signin/cookie`, which `--cookie-auth` with an explicit `--allow-origin` list enables, and scoped paths such as `/ns/{ns}/db/{db}/ac/{ac}/signup`, which the server rewrites to the unscoped route before any layer sees them. At v3.3.5 the sign-in and sign-up routers register only `.route("/signin", options(|| async {}).post(handler))` and `.route("/signup", options(|| async {}).post(handler))`, and there is no `ntw/scope.rs`, so the cookie routes and scoped paths are not in the v3.3.5 files read. Whether another v3.3.5 file registers them is not established. A `SIGNUP` clause can permit public self-registration that root credentials do not restrict, so omit `SIGNUP` when self-registration is unwanted and test that it is refused while a provisioned account still signs in. At v4.0.0 every HTTP signup form calls the same core function, which refuses an access method with no `SIGNUP` (`bail!(AuthError::AccessRecordNoSignup)`). The v3.3.5 `/signup` handler calls the same core function (`surrealdb_core::iam::signup::signup(kvs, &mut session, vars.into())`), whose check is the same at both releases. Whether RPC `signup` reaches that check was not traced at v4.0.0 or v3.3.5, so where `/rpc` is allowed, test the refusal there too. At v4.0.0 the per-address limit on sign-in and sign-up requests, and on requests carrying Basic credentials, is off by default: `lazy_env_parse!("SURREAL_HTTP_AUTH_RATE_LIMIT_ENABLED", bool, false)`. The 4.0 release page says "Basic authentication is rate-limited", which as this guide reads the source holds only once the limiter is enabled. Its burst and per-minute rate then default to 60, and a request with no client address the server can work out is not limited. Which values enable it is not established at v4.0.0. At v3.3.5 the setting and its defaults are the same, but the limiter covers less: it admits any request that is not `POST` untouched (`if request.method() != Method::POST {`) and limits only `"/signin"` and `"/signup"`, whatever headers they carry, with no cookie, refresh or sign-out path. It lacks v4.0.0's header case, which also limits any other request that carries Basic credentials (`} else if has_basic_auth(request) {`). As this guide's reading of the v3.3.5 source, with the limiter enabled a `POST` to `/signin` or `/signup` with a client address the server can work out is limited whether or not it carries Basic credentials, and any other request that carries them is not. A request with no such address is not limited (`let Some(key) = request_key(request) else {`). v4.0.0 fixes these advisories on table, field and role controls, each affecting versions below 4.0.0 and patched in 4.0.0, so do not rely on those controls on an earlier release: - GHSA-5xcv-fm6g-4hf3 (high): "Quoted Computed-Field Formatter Bypasses Field-Level SELECT Permissions". - GHSA-fh2q-8f75-m4h6 (high): "REFERENCE ON DELETE CASCADE/UNSET/CUSTOM bypasses target table PERMISSIONS FOR delete/update". - GHSA-qh3v-rr48-x3cq (medium): "Authenticated Export Permission Bypass". - GHSA-ffvm-mqh3-7986 (medium): "SurrealDB 3.2.4 allows FOR loop variables named $access, $auth, and $token to shadow the authenticated session values used by table permission expressions." Its advisory record gives the severity as "medium", while the 4.0 release page lists it as "High". - GHSA-wjh3-m4pv-q6cp (medium): "Cross-field array-index drift in wildcard field-permission filtering". - GHSA-cff7-q66j-4hc8 (medium): "UNIQUE-Index Conflict Errors Disclose SELECT-Restricted Field Values and Record Ids in SurrealDB". - GHSA-3rcm-c653-wgmr (medium): "Re-RELATE on an Existing Edge Echoes Stored in/out Endpoints Despite SELECT Restrictions in SurrealDB". - GHSA-ff2q-h4x7-cpj4 (medium): "ON DELETE REJECT Errors Disclose SELECT-Hidden Referencing Record Ids in SurrealDB". - GHSA-pvv9-h693-m8r4 (medium): "SurrealDB LIVE query notification expressions allow write side effects that bypass the write-blocking guard"; its description says write side effects in the WHERE clause that reference document fields "can exfiltrate data even after the subscriber's SELECT permission is revoked". - GHSA-m887-2246-mgw3 (low): "VIEWER role can define tables implicitly via CREATE on a non-STRICT database". - GHSA-3287-hpm5-3f8r (medium): "SurrealDB DDL statements bypass namespace/database implicit-creation authorization added by GHSA-wp87-mgvq-5j93 fix". Use `DEFINE ACCESS ... TYPE RECORD`; the legacy `DEFINE SCOPE` is deprecated in 2.x and removed in 3.0. `DEFINE ACCESS` also supports `TYPE JWT` and `TYPE BEARER` (per-client keys). Mind the authorization boundary: a standalone `TYPE JWT` access grants SYSTEM-level sessions governed by its scope and roles, above table and field permissions, whereas application users who need those permissions want `DEFINE ACCESS ... ON DATABASE TYPE RECORD WITH JWT`. Trusting an IdP's tokens is more than a valid signature: enforce the expected issuer and audience (native `AUDIENCE` is available since 3.3.0, so set it at this guide's v4.0.0 basis; check the issuer in an `AUTHENTICATE` clause, as the JWT page's example does), set the verification algorithm and key or JWKS URL, and test expired, wrong-issuer, and wrong-audience tokens against a valid-token control. ## 4. Capabilities: what an authorized query may do Authentication decides who connects; capabilities decide what a query may then reach, and it is a separate control. `--allow-all` and `--deny-all` default off; network access is denied by default, functions are allowed by default, and scripting (`--allow-scripting`) and guest access (`--allow-guests`) default off. These defaults come from the capabilities page as served, which describes 4.0 ("Before 4.0, a target such as `*.example.com` was read as an exact host name"), so at v3.3.5 they are not established. The capabilities page says "the more specific capabilities prevail over the less specific" and that at the same level of specificity denies prevail. As this guide's reading of the v4.0.0 source, that holds between layers: a category's allow list overrides `--deny-all` or a bare category deny. It does not hold between list entries: a function is allowed only when an allow entry matches it and no deny entry does (`self.allow_funcs.matches(target) && !self.deny_funcs.matches(target)`), so allowing `http::get` while denying `http` refuses `http::get`. The capabilities page's own example agrees: under `--deny-funcs crypto --allow-funcs crypto::md5`, "all four are denied, because a specific deny wins over any allow." Prefer `--deny-all` with narrow explicit allowances for the routes, RPC methods, and functions you need rather than a bare `--allow-funcs` or `--allow-net`, keep embedded scripting off unless required, and confirm each default against your pinned version. As this guide's reading of the v4.0.0 source, `--deny-all` alone leaves every HTTP route, RPC method and MCP tool allowed by capabilities. `--allow-http`, `--allow-rpc` and `--allow-mcp` each default to an empty value (`#[arg(default_value_os = "")]`), their parsers read it as every target (`Ok(Targets::All)`), and under `--deny-all` each allow getter returns `Targets::All` for that list, while each deny getter denies nothing unless you name targets in its list. That runtime result is not established by a run. So pass explicit `--allow-http` and `--allow-rpc` lists of the routes and RPC methods you use, giving routes by name without a slash, such as `health`, `signin`, `signup` and `sql`. The HTTP protocol page says a denied `health` route answers `403`, which the Verify block's health check reports as inconclusive. So when the Verify block's health check should pass, include `health` in the `--allow-http` list. Under `--deny-all` the HTTP allow getter returns a named list as given (`return targets.clone();`), the route parser reads the name as `"health" => Ok(RouteTarget::Health),`, and `/health` refuses a route that is not allowed: `return Err(NetError::ForbiddenRoute(RouteTarget::Health.to_string()));`. GHSA-cr79-9p8r-p5wx (high; affected `< 4.0.0`, patched `4.0.0`), "MCP Tool Identifier Validation Permits Statement Injection via Escaped Backticks", bears on MCP tools staying allowed: its impact reads "Arbitrary SQL query execution through MCP tool invocation, bypassing tool argument constraints." Its patch note says "Version 4.0.0 and later are not affected by this issue." The v3.3.5 capability files read for this guide, `server/src/dbs/mod.rs` and `rpc/src/capabilities/mod.rs`, have no MCP-tool lists. v3.3.5's capability arguments define `allow_rpc` and `allow_http` with the same `#[arg(default_value_os = "")]` default, and neither file defines an `allow_mcp` or `deny_mcp` argument. Their only MCP capability target is the HTTP route, `"mcp" => Ok(RouteTarget::Mcp),`. v3.3.5 also defines a `surreal mcp` command, `Mcp(McpCommandArguments)`, to "Start the MCP server over stdio for AI assistant integration", behind `#[cfg(feature = "mcp")]`. Its arguments type, `McpCommandArguments`, is named through the `cli::mcp` module (`use mcp::McpCommandArguments;` at `cli/mod.rs` line 38), whose file, `cli/mcp.rs` or `cli/mcp/mod.rs`, was not read. Whether anything else gates MCP tools at v3.3.5 is not established, because neither that module nor its MCP crate was read. v4.0.0 also fixes these capability bypasses, each affecting versions below 4.0.0 and patched in 4.0.0: - GHSA-5w6j-qm42-4rc2 (high): "JavaScript Built-in Bridge Bypasses Function Capability Enforcement". - GHSA-fq69-wr4w-v3gm (high): "JavaScript Query Bridge Bypasses Arbitrary-Query Subject Gate". - GHSA-q83r-f38r-rwvj (high): "Surrealism WASI socket allowlist ignores deny_net, enabling SSRF via hostname resolving to a private IP". The 4.0 release page lists 23 advisories and says "Each affected `v3.3.5` and earlier, and each fix ships in `v4.0.0` and is enabled by default." Each of the 23 advisory records gives the affected range `< 4.0.0` and the patched version `4.0.0`, so v3.3.5 falls inside all 23 published affected ranges. Whether a given v3.3.5 server is exposed to each depends on that advisory's own prerequisites, such as a feature, platform, route or privilege it names. This guide names 16 above as bearing on its statements. The other 7 are GHSA-p3c2-wh5f-jwqm, GHSA-cph5-pm57-9w9c, GHSA-7pqw-x84x-frxc, GHSA-6c37-x4jm-529x, GHSA-7r2m-g5wf-pj9j, GHSA-623g-mhxq-5hmf and GHSA-m9qr-4rcq-3jhc, on GraphQL WebSockets, bucket storage paths, the model export route, events in interactive transactions and a dependency. GHSA-q83r-f38r-rwvj gives its prerequisites: "the experimental `surrealism` capability is enabled; the attacker is authenticated with enough privilege to upload a `.surli` module to a bucket and invoke it; and the operator relies on `deny_net` (with a broad or attacker-influenceable `allow_net`) for egress control". GHSA-m9qr-4rcq-3jhc says its reproduction exercises `reblessive`'s public API directly and "It is not remote/black-box triggerable." GHSA-q83r-f38r-rwvj and GHSA-m9qr-4rcq-3jhc are filed against the `surrealdb-core` crate and the other 21 against `surrealdb`; reading those crate versions as server releases is this guide's inference. Functions such as `http::*` and JWKS retrieval make outbound requests, so restrict destinations and apply the metadata and internal-network protections in [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md); authentication alone does not prevent SSRF. ## 5. TLS `--web-crt` and `--web-key` serve SurrealDB's own interfaces over HTTPS directly. SurrealDB's own guidance also endorses delegating TLS termination to a load balancer or reverse proxy, per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). Reaching the instance only over a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)) is this guide's own suggestion, not one on SurrealDB's pages. Whether SurrealDB v4.0.0 has a native second factor is not established here, because none of the pages read for this guide addresses one. Require MFA at the identity provider (for JWT access) or at an administrative access gateway per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), and make sure direct-origin access cannot bypass that gateway. ## Verify ```bash # REASONED: expected server outcomes follow the cited SurrealDB documentation; no SurrealDB runtime is # available in the authoring environment. A redirect, missing database, disabled route, proxy rejection, TLS or transport failure is # inconclusive, never a pass. # Run this block on the SurrealDB host: its listener inventory and health check read that host. It exits 2 if a # check refuses its input, 3 if the JWT prompt, ss, the health check or either query fails or the health check # does not answer 200, otherwise 2 if ss is not installed, and otherwise 0; the listener list, when ss ran, and # the two query responses stay for you to judge. # Auth discrimination: INFO FOR DB; with no credentials vs with a valid system-user JWT, same NS/DB and # endpoint. Substitute inside the quotes and paste the whole block; run first against the direct origin, then # the actual HTTPS endpoint. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -o pipefail { unset -n probe_jwt && unset -v probe_jwt; } 2>/dev/null || { echo 'a readonly probe_jwt is set in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- PASTE_WHOLE_BLOCK 'http://127.0.0.1:8000' 'REPLACE_WITH_NAMESPACE' 'REPLACE_WITH_DATABASE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'need endpoint, namespace and database; not probing'; exit 2; } case "$1|$2|$3" in *REPLACE_WITH_*) echo 'replace all placeholders; not probing'; exit 2 ;; esac { [ -n "$1" ] && [ -n "$2" ] && [ -n "$3" ]; } || { echo 'empty value; not probing'; exit 2; } case "$1" in http://127.0.0.1:8000|https://?*) ;; *) echo 'use local loopback HTTP or HTTPS; not probing'; exit 2 ;; esac if IFS= read -r -s -p 'Short-lived system-user JWT: ' probe_jwt; then printf '\n' else printf '\n'; echo 'secret input failed; not probing'; exit 3 # every check above passed, so the failed prompt is inconclusive fi [[ "$probe_jwt" =~ ^[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-]+$ ]] || { echo 'missing or malformed JWT; not probing'; exit 2; } set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 3 (inconclusive) outranks 2 (ss is not installed), which outranks 0 if command -v ss >/dev/null; then ss -tlnp || { echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; set -- "$1" "$2" "$3" 3; } # inventory, on the host: 8000 on loopback or a private address, never 0.0.0.0 set -- "$1" "$2" "$3" "$4" listed # the 5th value says whether ss ran, so the last line names the listener list only when it did else echo "ss is not installed here; not listing sockets" case "$4" in 0) set -- "$1" "$2" "$3" 2 ;; esac set -- "$1" "$2" "$3" "$4" unlisted fi # guard-conventions: allow fixed loopback health URL http://127.0.0.1:8000/health; no reader-substituted target set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null -w 'health=%{http_code}\n' http://127.0.0.1:8000/health; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # curl's exit status, then its health= line printf '%s' "$7" # /health answered (not an auth check) case "$6:$7" in 0:health=200*) ;; 0:*) echo "the health check did not answer 200; inconclusive"; set -- "$1" "$2" "$3" 3 "$5" ;; *) echo "curl exit $6: the health check failed; inconclusive"; set -- "$1" "$2" "$3" 3 "$5" ;; esac set -- "$4" "$5" -sS --noproxy '*' --connect-timeout 5 --max-time 20 --header 'Accept: application/json' \ --header "Surreal-NS: $2" --header "Surreal-DB: $3" --data-binary 'INFO FOR DB;' --url "${1%/}/sql" \ --write-out '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' # the block's status so far, whether ss ran, then curl's arguments echo UNAUTHENTICATED curl -q -g "${@:3}" ` passes everything after the component name on unchanged, and the install script installs `surreal` with the database as a pinned component (checked November 2026): https://surrealdb.com/docs/explore/cli - Surreal CLI components: a name that is not a component runs a `surreal-` from `PATH`, and `SURREAL_*` variables other than `SURREAL_CLI_*` belong to the database (checked November 2026): https://surrealdb.com/docs/explore/cli/components - SurrealDB 4.0 release notes (the `db` component, RocksDB removal, the 3.x upgrade opt-in, cookie endpoints, "Basic authentication is rate-limited" and 23 advisories, each affecting `v3.3.5` and earlier) (checked November 2026): https://surrealdb.com/releases/4.0 - SurrealDB 3.3 release notes (a 3.3 feature "Supported on every storage backend: in-memory, RocksDB, SurrealKV, IndexedDB and TiKV") (checked November 2026): https://surrealdb.com/releases/3.3 - GNU Bash reference manual, bash-5.3 (checked October 2026), prefix assignments 3872-3878: https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3872 - Linux man-pages `proc_pid_environ(5)`, tag man-pages-6.19 (file header says "unreleased"; version identity rests on the tag) (checked October 2026): initial environment 12-15 https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_environ.5?h=man-pages-6.19#n12. The same `proc_pid_environ(5)` page, tag man-pages-6.19 (file header says "unreleased"; version identity rests on the tag) (checked October 2026): later modifications 26-36 https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_environ.5?h=man-pages-6.19#n26. The same `proc_pid_environ(5)` page, tag man-pages-6.19 (file header says "unreleased"; version identity rests on the tag) (checked October 2026): procfs access check 43-47 https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_environ.5?h=man-pages-6.19#n43 - Linux man-pages `ptrace(2)`, tag man-pages-6.19 (file header says "unreleased"; version identity rests on the tag) (checked October 2026): ptrace access factors 2519-2533 https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man2/ptrace.2?h=man-pages-6.19#n2519 - SurrealDB v4.0.0 `start` root user and password: `--username`/`--user`/`-u` or `SURREAL_USER` and `--password`/`--pass`/`-p` or `SURREAL_PASS`, each refusing an empty value, the password's value hidden from `--help`, no stdin form in the files read (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/start.rs#L178-L205 - SurrealDB v4.0.0 `--unauthenticated` flag, bound to `SURREAL_UNAUTHENTICATED` (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L56-L59 - SurrealDB v4.0.0 `--bind`/`-b` (`SURREAL_BIND`) default `127.0.0.1:8000` (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/start.rs#L220-L223 - SurrealDB `docker/Dockerfile` sets `ENV SURREAL_BIND="0.0.0.0:8000"` in each runtime stage (L78, L106, L141, L169), each followed by `ENTRYPOINT ["/surreal"]` with no `CMD`; `dev` copies `/surreal` from `target/release/surreal` (L118-L120) and `dev-ci` from a prebuilt CI artifact (L59) (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/docker/Dockerfile#L47-L174 - SurrealDB v4.0.0 database executable: the whole `Commands` enum, with a `start` command and no `db` command (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/mod.rs#L144-L190 - SurrealDB v4.0.0 help: no argument's environment value shown, in any command (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/mod.rs#L427-L431 - SurrealDB v4.0.0 SurrealKV refusal of a directory a 3.x release wrote, unless `SURREAL_EXPERIMENTAL_UPGRADE_FROM_3X=true` (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/kvs-surrealkv/src/lib.rs#L336-L348 - SurrealDB v4.0.0 core root credentials: empty or whitespace-only values refused, and a root user created only when none exists (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/core/src/kvs/ds.rs#L3543-L3589 - SurrealDB v4.0.0 `--import-file` (`SURREAL_IMPORT_FILE`) (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L67-L70 and the import running before credentials: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L1326-L1332 - SurrealDB v4.0.0 capabilities under `--deny-all`: `--allow-rpc`, `--allow-http` and `--allow-mcp` defaulting to an empty value (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L200-L231 and their parsers reading it as every target: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/validator/mod.rs#L154-L185 and the RPC and HTTP allow getters: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L459-L535 and the RPC deny, MCP and HTTP deny getters: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L629-L696 and specific function denies applied on top of allows: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L597-L607 - SurrealDB v4.0.0 capability checks: function target matching (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L43-L55 and allow-and-not-deny: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L1158-L1160 and the same rule for RPC methods, MCP tools and HTTP routes: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L1226-L1236 - SurrealDB v4.0.0 `/health` with its memory-guard condition, and `/ready` with its heartbeat condition (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/health.rs#L10-L71 and https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/ready.rs#L15-L23 - SurrealDB v4.0.0 `/signup` and `/signup/cookie` handlers calling core signup (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/signup.rs#L43-L75 and https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/signup.rs#L133-L148 - SurrealDB v4.0.0 `/signin` and `/signin/cookie` routes (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/signin.rs#L45-L54 - SurrealDB v4.0.0 scoped `/ns/.../db/.../ac/...` paths rewritten before the router (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/scope.rs#L1-L35 - SurrealDB v4.0.0 core signup refusing an access method with no `SIGNUP` (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/core/src/iam/signup.rs#L213-L215 - SurrealDB v4.0.0 HTTP authentication rate limit, off by default, burst and per-minute rate 60 (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cnf/mod.rs#L290-L304 and the requests it covers: https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/rate_limit.rs#L244-L280 - SurrealDB v4.0.0 HTTP route names that `--allow-http` accepts, `health` among them (pinned tag v4.0.0) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L548-L573 - SurrealDB v3.3.5 release record, whose body is only "Release v3.3.5" (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/releases/tag/v3.3.5 - SurrealDB v3.3.5 `start` root user and password, with no non-empty value parser and no `hide_env_values` (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/cli/start.rs#L178-L201 - SurrealDB v3.3.5 core root credentials, retried with no empty-value check (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/core/src/kvs/ds.rs#L1246-L1250 - SurrealDB v3.3.5 core datastore factory, whose comment names `rocksdb:` and `tikv:` connection strings (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/core/src/kvs/ds.rs#L453-L461 - SurrealDB v3.3.5 core `Datastore::new("rocksdb://database.db")` usage example (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/core/src/kvs/ds.rs#L4970-L4981 - SurrealDB v3.3.5 server test, compiled only with the `storage-rocksdb` feature, that builds a `rocksdb:` path (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/dbs/mod.rs#L2134-L2142 - SurrealDB v3.3.5 SurrealKV crate, with no `SURREAL_EXPERIMENTAL_UPGRADE_FROM_3X` text (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/kvs-surrealkv/src/lib.rs - SurrealDB v3.3.5 `/signin` route and `/signup` route, whose handler calls core signup (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/ntw/signin.rs#L41-L49 and https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/ntw/signup.rs#L39-L75 - SurrealDB v3.3.5 HTTP authentication rate limit, covering only `POST` to `/signin` and `/signup` (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/ntw/rate_limit.rs#L243-L259 - SurrealDB v3.3.5 capabilities: `--allow-rpc` and `--allow-http` with no MCP-tool list (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/dbs/mod.rs#L105-L324 and the HTTP route names, with `mcp` as a route: https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/rpc/src/capabilities/mod.rs#L373-L397 - SurrealDB v3.3.5 `surreal mcp` command, behind `#[cfg(feature = "mcp")]`, whose arguments are named through the `cli::mcp` module (`cli/mcp.rs` or `cli/mcp/mod.rs`), which was not read (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/cli/mod.rs#L182-L184 - SurrealDB v3.3.5 `use mcp::McpCommandArguments;`, which names the `surreal mcp` arguments through the `cli::mcp` module (pinned tag v3.3.5, commit 5c3383fe) (checked November 2026): https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/cli/mod.rs#L37-L38 - SurrealDB CLI, `surreal db sql` (checked November 2026): https://surrealdb.com/docs/reference/cli/db/commands/sql - SurrealDB security overview (checked November 2026): https://surrealdb.com/docs/learn/security - SurrealDB authentication overview (`DEFINE USER` at root, namespace or database level) (checked November 2026): https://surrealdb.com/docs/learn/security/authentication/overview - SurrealDB DEFINE USER (a root-level user given a limited role such as `VIEWER`, for monitoring each namespace and database; only the built-in roles OWNER, EDITOR and VIEWER currently available) (checked November 2026): https://surrealdb.com/docs/surrealql/statements/define/user - SurrealDB security best practices (system-user roles, with `ROLES VIEWER` and `ROLES EDITOR` examples) (checked November 2026): https://surrealdb.com/docs/learn/security/best-practices/security-best-practices - SurrealDB capabilities (`--allow-all`/`--deny-all`, net denied, functions allowed, scripting/guests off, specificity + deny-wins, a specific deny winning over any allow) (checked November 2026): https://surrealdb.com/docs/learn/security/authorization/capabilities - SurrealDB permissions and row-level security (system users bypass table/field PERMISSIONS; on a table, omitting `PERMISSIONS` results in `PERMISSIONS NONE`, while a field without a clause is stored as `PERMISSIONS FULL`) (checked November 2026): https://surrealdb.com/docs/learn/security/authorization/permissions-and-row-level-security - SurrealDB DEFINE ACCESS (RECORD, JWT, BEARER) (checked November 2026): https://surrealdb.com/docs/reference/query-language/statements/define/access/record - SurrealDB 2.0.0 release (auth on by default, `--unauthenticated`, bind default 127.0.0.1) (checked November 2026): https://surrealdb.com/releases/2.0 - SurrealDB HTTP protocol (`/signup`, `/signin`, `POST /sql`, `/health`, `/status`; a denied `health` route answers `403`) (checked November 2026): https://surrealdb.com/docs/reference/rest-api/http-protocol - SurrealDB INFO statement (database information requires a system user) (checked November 2026): https://surrealdb.com/docs/reference/query-language/statements/info - SurrealDB DEFINE ACCESS TYPE JWT (native `AUDIENCE` since 3.3, issuer check via `AUTHENTICATE`) (rolling documentation, checked November 2026): https://surrealdb.com/docs/reference/query-language/statements/define/access/jwt - SurrealDB DEFINE SCOPE (deprecated in 2.x, removed in 3.0) (checked November 2026): https://surrealdb.com/docs/reference/query-language/statements/define/scope - SurrealDB advisory GHSA-whfh-76h8-2r66, "CLI help output prints the values of SURREAL_PASS and SURREAL_TOKEN" (low; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-whfh-76h8-2r66 - SurrealDB advisory GHSA-5w6j-qm42-4rc2, "JavaScript Built-in Bridge Bypasses Function Capability Enforcement" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-5w6j-qm42-4rc2 - SurrealDB advisory GHSA-fq69-wr4w-v3gm, "JavaScript Query Bridge Bypasses Arbitrary-Query Subject Gate" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-fq69-wr4w-v3gm - SurrealDB advisory GHSA-q83r-f38r-rwvj, "Surrealism WASI socket allowlist ignores deny_net, enabling SSRF via hostname resolving to a private IP" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-q83r-f38r-rwvj - SurrealDB advisory GHSA-5xcv-fm6g-4hf3, "Quoted Computed-Field Formatter Bypasses Field-Level SELECT Permissions" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-5xcv-fm6g-4hf3 - SurrealDB advisory GHSA-fh2q-8f75-m4h6, "REFERENCE ON DELETE CASCADE/UNSET/CUSTOM bypasses target table PERMISSIONS FOR delete/update" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-fh2q-8f75-m4h6 - SurrealDB advisory GHSA-qh3v-rr48-x3cq, "Authenticated Export Permission Bypass" (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-qh3v-rr48-x3cq - SurrealDB advisory GHSA-ffvm-mqh3-7986, FOR loop variables named `$access`, `$auth` and `$token` shadowing the session values that table permission expressions use ("medium" in the record, while the 4.0 release page lists it as "High"; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-ffvm-mqh3-7986 - SurrealDB advisory GHSA-wjh3-m4pv-q6cp, "Cross-field array-index drift in wildcard field-permission filtering" (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-wjh3-m4pv-q6cp - SurrealDB advisory GHSA-cff7-q66j-4hc8, "UNIQUE-Index Conflict Errors Disclose SELECT-Restricted Field Values and Record Ids in SurrealDB" (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-cff7-q66j-4hc8 - SurrealDB advisory GHSA-3rcm-c653-wgmr, "Re-RELATE on an Existing Edge Echoes Stored in/out Endpoints Despite SELECT Restrictions in SurrealDB" (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-3rcm-c653-wgmr - SurrealDB advisory GHSA-ff2q-h4x7-cpj4, "ON DELETE REJECT Errors Disclose SELECT-Hidden Referencing Record Ids in SurrealDB" (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-ff2q-h4x7-cpj4 - SurrealDB advisory GHSA-pvv9-h693-m8r4, "SurrealDB LIVE query notification expressions allow write side effects that bypass the write-blocking guard", including data exfiltration after a SELECT permission is revoked (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-pvv9-h693-m8r4 - SurrealDB advisory GHSA-m887-2246-mgw3, "VIEWER role can define tables implicitly via CREATE on a non-STRICT database" (low; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-m887-2246-mgw3 - SurrealDB advisory GHSA-3287-hpm5-3f8r, DDL statements bypassing namespace and database implicit-creation authorization (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-3287-hpm5-3f8r - SurrealDB advisory GHSA-cr79-9p8r-p5wx, "MCP Tool Identifier Validation Permits Statement Injection via Escaped Backticks", with arbitrary SQL query execution through MCP tool invocation (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-cr79-9p8r-p5wx - SurrealDB advisory GHSA-p3c2-wh5f-jwqm, "GraphQL WebSocket upgrade omits frame and message size limits enabling memory amplification" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-p3c2-wh5f-jwqm - SurrealDB advisory GHSA-cph5-pm57-9w9c, "Direct Filesystem Buckets Permit Path Traversal Across Key-Based ACL Boundaries" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-cph5-pm57-9w9c - SurrealDB advisory GHSA-7pqw-x84x-frxc, "Filesystem Bucket Containment Fails to Resolve Descendant Symlinks" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-7pqw-x84x-frxc - SurrealDB advisory GHSA-6c37-x4jm-529x, "Windows Backslashes Bypass Global Bucket Path Traversal Protection" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-6c37-x4jm-529x - SurrealDB advisory GHSA-7r2m-g5wf-pj9j, "Model Export Route joins Raw Name Permitting Authenticated Request Redirection" (high; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-7r2m-g5wf-pj9j - SurrealDB advisory GHSA-623g-mhxq-5hmf, "Interactive transactions keep the writes of a statement refused by a THROWing event, and commit persists them" (medium; affected `< 4.0.0`, patched `4.0.0`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-623g-mhxq-5hmf - SurrealDB advisory GHSA-m9qr-4rcq-3jhc, a safe-code data race and double-free in `surrealdb-core` through `reblessive 0.4.3` (high; affected `< 4.0.0`, patched `4.0.0`, filed against `surrealdb-core`) (checked November 2026): https://github.com/surrealdb/surrealdb/security/advisories/GHSA-m9qr-4rcq-3jhc ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - pinned: SurrealDB server and image (v4.0.0) - pinned-3x: SurrealDB 3.x server (v3.3.5) - audience: SurrealDB native AUDIENCE minimum (3.3) - scope-old: SurrealDB SCOPE deprecation (2.x) - scope-removed: SurrealDB SCOPE removal (3.0) - docs: SurrealDB documentation (rolling) (unknown) - defaults: SurrealDB 2.0 release announcement (unknown) - advisories: SurrealDB security advisories (unknown) - bash: GNU Bash reference manual (bash-5.3) - procfs: Linux procfs manual (man-pages-6.19) - ptrace: Linux ptrace manual (man-pages-6.19) Claims (full source mapping in the JSON block below): - auth-default (REASONED; defaults): Since 2.0.0 authentication defaults on, --auth was removed and --unauthenticated added; older releases do not share this default. - basis-3x (REASONED; pinned-3x, docs): The guide covers v4.0.0 and, where they differ, v3.3.5; the v3.3.5 source read supports each statement citing only v4.0.0 source unless stated. Not established at v3.3.5: statements resting only on rolling documentation, the capability defaults among them, what v3.3.5 changed, as its release record has no notes, and which command form a 3.x install takes. - bind-default (REASONED; defaults, pinned): Since 2.0.0 the CLI defaults to 127.0.0.1:8000; v4.0.0 --bind/-b and SURREAL_BIND retain that loopback default. - command-form (REASONED; pinned, docs): At v4.0.0 the database executable takes start and defines no db command; the Surreal CLI launcher runs it as surreal db start, passing the rest unchanged. As this guide's inference, under the launcher surreal start does not start the database, so the host block uses surreal db start and the image keeps start. - root-bootstrap (REASONED; docs, pinned): User/password flags or SURREAL_USER/PASS initialize a root only when none exists; they neither disable authentication nor rotate an existing password. At v4.0.0 the flags refuse empty values, core also refuses whitespace-only ones, and an import file that supplies root users suppresses the flag user. - root-bootstrap-3x (REASONED; pinned-3x): At v3.3.5 neither empty-credential check is in the cited code: the user and password flags have no non-empty value parser, and core credential setup has no empty-value check. Whether v3.3.5 refuses empty values elsewhere is not established; the block refuses an empty password itself. - root-input (REASONED; pinned, bash, procfs, ptrace): No v4.0.0 server file read has a stdin password form; prompt and use a Bash command-local environment assignment, not argv. As this guide's assessment, the initial server environment may be exposed to the same account and root when the procfs ptrace check permits; an inheriting process may expose it the same way while it runs; treat it as exposed to that account and root while the server runs. - help-values (REASONED; pinned, advisories): Before 4.0.0 --help could print SURREAL_PASS and SURREAL_TOKEN values (GHSA-whfh-76h8-2r66, low, affecting versions below 4.0.0, patched in 4.0.0); at v4.0.0 the start password sets hide_env_values and the executable hides every argument's environment value in help. - help-values-3x (REASONED; pinned-3x, advisories): At v3.3.5 the start password sets no hide_env_values and no v3.3.5 file read calls it, so v3.3.5 lies inside the affected range of GHSA-whfh-76h8-2r66, below 4.0.0, though its reproductions name images up to v3.3.4. As this guide's advice, never run --help on v3.3.5 with SURREAL_PASS or SURREAL_TOKEN set. - environment (REASONED; docs, pinned): The clean-Bash block clears user/password variables and rejects other inherited SURREAL_* options, including auth and bind overrides, the 3.x upgrade opt-in and the launcher's SURREAL_CLI_* variables. - launcher-unknowns (REASONED; docs): Whether the Surreal CLI launcher stays running as the server's parent, or passes SURREAL_PASS on unchanged, is not established at v4.0.0; as this guide's assessment, treat a resident launcher as exposed like the server and confirm the root user by signing in. - storage-upgrade (REASONED; pinned, docs): v4.0.0 removes the RocksDB engine; the docs read rocksdb:// as a deprecated spelling of surrealkv://, and the block names surrealkv:/data/mydb.db. A 3.x directory is refused unless SURREAL_EXPERIMENTAL_UPGRADE_FROM_3X=true, which the block cannot carry; convert it as a separate step after a backup. The server path check was not read. - storage-3x (REASONED; pinned-3x, docs): As this guide's inference from the v3.3.5 datastore factory comment, a rocksdb:// usage example in core, a rocksdb: test behind the storage-rocksdb feature and the 3.3 release page, RocksDB is a working engine at v3.3.5; the v4.0.0 refusal of a 3.x directory does not apply there, as its SurrealKV crate has no upgrade opt-in text. How v3.3.5 parses storage paths is not established. - unauthenticated (REASONED; docs, pinned): --unauthenticated/SURREAL_UNAUTHENTICATED gives guests OWNER-equivalent access; never carry it onto a remotely reachable deployment. - docker-bind (REASONED; pinned): v4.0.0 image stages set SURREAL_BIND=0.0.0.0:8000 without CMD; start without an override listens on all container IPv4 interfaces, requiring private network or loopback publication. Whether published images use that file is not established. - private-network (REASONED; docs): Widen host binds only deliberately to private addresses; internal-service databases should have no public interface. - system-users (REASONED; docs): System users exist at root, namespace and database level; the DEFINE USER page says only the built-in roles OWNER, EDITOR and VIEWER are currently available, but no page read says every user holds one. All bypass table/field PERMISSIONS, using roles instead, so scoped system users do not supply record isolation. - root-visibility (REASONED; docs): The DEFINE USER page says even a root-level user can be given a limited role such as VIEWER, useful for automated services that need to monitor each namespace and database; the JWT page says root tokens can act on all tables in all databases of all namespaces. As this guide's inference, a root system user reaches every namespace and database, within what its role allows. - record-users (REASONED; docs): Record users authenticate through DEFINE ACCESS TYPE RECORD SIGNUP/SIGNIN logic and are bound by table and field PERMISSIONS: omitting PERMISSIONS on a table results in PERMISSIONS NONE, while a field without a clause is stored as PERMISSIONS FULL and follows the table's rules. - signup (REASONED; docs, pinned): Unauthenticated /signup and /signin, their v4.0.0 cookie forms and scoped /ns/.../ac/... paths are intentional; every HTTP signup form reaches the core check that refuses an access method with no SIGNUP, so omit SIGNUP to prevent public registration and test refusal beside a provisioned sign-in. RPC signup was not traced. - signup-3x (REASONED; pinned-3x, pinned): At v3.3.5 the files read register only the /signin and /signup routes, with no cookie routes and no scoped-path rewrite, and the /signup handler calls the same core signup check as v4.0.0. Whether another v3.3.5 file registers cookie or scoped routes, and whether RPC signup reaches the check, is not established. - rate-limit (REASONED; pinned, docs): At v4.0.0 the per-address HTTP limit on sign-in, sign-up and Basic-auth requests is off by default (SURREAL_HTTP_AUTH_RATE_LIMIT_ENABLED false; burst and per-minute 60 once on), so the release page's Basic-auth rate limit applies only when enabled; a request with no derivable client address is not limited. - rate-limit-3x (REASONED; pinned-3x, pinned): At v3.3.5 the HTTP authentication limiter has the same setting and defaults but limits only POST requests to /signin and /signup with a derivable client address, whatever headers they carry; it has no cookie, refresh or sign-out path. It lacks v4.0.0's case for other requests carrying Basic credentials, so as this guide's reading of v3.3.5 source those are not rate-limited even when it is enabled. - permission-advisories (REASONED; advisories): v4.0.0 fixes advisories affecting versions below 4.0.0 on table, field and role controls: GHSA-5xcv-fm6g-4hf3, GHSA-fh2q-8f75-m4h6, GHSA-qh3v-rr48-x3cq, GHSA-ffvm-mqh3-7986, GHSA-wjh3-m4pv-q6cp, GHSA-cff7-q66j-4hc8, GHSA-3rcm-c653-wgmr, GHSA-ff2q-h4x7-cpj4, GHSA-pvv9-h693-m8r4, GHSA-m887-2246-mgw3 and GHSA-3287-hpm5-3f8r; do not rely on those controls on an earlier release. - scope-history (REASONED; scope-old, scope-removed): Use DEFINE ACCESS TYPE RECORD; legacy DEFINE SCOPE was deprecated in 2.x and removed in 3.0. - access-types (REASONED; docs): DEFINE ACCESS also supports JWT and per-client BEARER keys; standalone JWT creates system-level sessions, while RECORD WITH JWT retains record permissions. - jwt-checks (REASONED; docs, audience): Verify token algorithm/key or JWKS, issuer and audience; native AUDIENCE is available since 3.3.0, so set it at the v4.0.0 basis, and check the issuer in AUTHENTICATE as the JWT page's example does. Compare invalid tokens against a valid control. - capability-all (REASONED; docs, pinned): allow-all and deny-all default off; prefer deny-all with narrow route, RPC-method and function allowances. As this guide's reading of v4.0.0 source, deny-all alone leaves every HTTP route, RPC method and MCP tool allowed, as allow-http, allow-rpc and allow-mcp default to all targets; no run confirmed it, so pass explicit allow-http and allow-rpc lists. - capability-health (REASONED; pinned, docs): When the Verify block's health check should pass under deny-all, include health in the allow-http list: the HTTP allow getter returns a named list as given, the route parser reads health as the health route, and /health refuses a route that is not allowed, which the HTTP protocol page says answers 403. - mcp-advisory (REASONED; advisories): GHSA-cr79-9p8r-p5wx (high; affecting versions below 4.0.0, patched in 4.0.0) reports MCP tool identifier validation permitting statement injection, with arbitrary SQL query execution through MCP tool invocation; it bears on MCP tools staying allowed under deny-all. - capability-mcp-3x (REASONED; pinned-3x): The v3.3.5 capability files read define allow-rpc and allow-http with the same empty default but no allow-mcp or deny-mcp list; their only MCP target is the mcp HTTP route. surreal mcp is behind the mcp feature; its arguments are named through the cli::mcp module (use mcp::McpCommandArguments;), which was not read, nor was its MCP crate, so whether anything else gates MCP tools is not established. - capability-net (REASONED; docs): Network access is denied by default; restrict http::* and JWKS destinations and retain external egress protections against SSRF. - capability-functions (REASONED; docs): Functions are allowed by default; a bare allow-funcs broadens access beyond narrow named allowances. - capability-scripting (REASONED; docs): Embedded scripting defaults off; enable it only when required. - capability-guests (REASONED; docs): Guest access defaults off; capabilities govern what an authorized query may do independently of authentication. - capability-precedence (REASONED; docs, pinned): A category allow list overrides deny-all or a bare category deny, and deny wins at equal specificity; between list entries a deny entry wins over any allow entry, so a narrower allow does not override a broader deny, as the capabilities page's own example and this guide's reading of v4.0.0 source agree. - capability-advisories (REASONED; advisories, docs): v4.0.0 fixes capability bypasses affecting versions below 4.0.0: GHSA-5w6j-qm42-4rc2, GHSA-fq69-wr4w-v3gm and GHSA-q83r-f38r-rwvj; the 4.0 release page lists 23 advisories, of which this guide names 16 and lists 7 more. - advisories-3x (REASONED; advisories, docs): v3.3.5 is in all 23 affected ranges of the 4.0 advisories; exposure depends on prerequisites. GHSA-q83r-f38r-rwvj needs experimental Surrealism, authenticated module upload and invoke rights, and that the operator relies on deny_net (with a broad or attacker-influenceable allow_net) for egress control. GHSA-m9qr-4rcq-3jhc is "not remote/black-box triggerable." Crate-to-server mapping is inference. - tls (REASONED; docs): web-crt and web-key provide native HTTPS; SurrealDB documents TLS termination at a load balancer or reverse proxy, and reaching the instance only over a tailnet is this guide's own suggestion. - mfa (REASONED; docs): Whether SurrealDB v4.0.0 has a native second factor is not established, as no page read addresses one; require IdP or administrative-gateway MFA and prevent direct-origin bypass. - verify-inventory (REASONED; docs, pinned): Inventory 8000 on loopback/private addresses; at v4.0.0 a /health 200 means the route is allowed, the memory-health failure condition was not met (the check is disabled, or the guard has not stayed engaged for its configured period) and storage answers, not authentication or finished startup; /status is liveness and /ready follows startup and any configured heartbeat budget. - verify-auth (REASONED; docs): Compare INFO FOR DB without credentials and with a system-user JWT on the same NS/DB: exposed auth-off returns info; fixed denies anonymous access and returns authenticated OK data. - verify-auth-limits (REASONED; docs): Test direct origin and HTTPS; HTTP 200, missing database, disabled route, proxy rejection or transport failure alone proves no authorization control or record permissions. - verify-origin (REASONED; docs): Any HTTP answer from an untrusted-network origin probe proves reachability; a gateway result alone does not establish origin isolation. - verify-tls (REASONED; docs): Validate the certificate chain at every TLS termination using normal trust checks, never -k. ````json { "schema": 1, "checked": "2026-11-05", "documentation_checked": "2026-09", "body_sha256": "e955c8567ffc256dc05bbd2c57e3e72f1a758599118df2ac18ef43472a6ee4e5", "components": { "pinned": { "name": "SurrealDB server and image", "basis": "v4.0.0", "sources": { "s325ec1bdaeca": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/start.rs#L178-L205", "s025b59b3dab9": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L56-L59", "sd341f6f1f6cb": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/start.rs#L220-L223", "s439e9f7b6e75": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/docker/Dockerfile#L47-L174", "s02c05ba6cc1a": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/mod.rs#L144-L190", "sc48298a83d5b": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/mod.rs#L427-L431", "s315fd2b3211f": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/kvs-surrealkv/src/lib.rs#L336-L348", "s68c349d06271": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/core/src/kvs/ds.rs#L3543-L3589", "s85b6558fdf7d": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L67-L70", "se2b9738f37cc": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L1326-L1332", "scce5b37cc67e": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L200-L231", "sb983f262eea6": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cli/validator/mod.rs#L154-L185", "s41f93c8f1044": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L459-L535", "s10eff64ec263": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L629-L696", "sd1dcb148f996": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/dbs/mod.rs#L597-L607", "s6a13d5749f9a": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L43-L55", "s6f5b41199da6": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L1158-L1160", "sc2c677a2fedc": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L1226-L1236", "s538897c5bacc": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/health.rs#L10-L71", "s67ee52a23e57": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/ready.rs#L15-L23", "s6ddbe030cea1": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/signup.rs#L43-L75", "s1c21ede08712": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/signup.rs#L133-L148", "s0a950c71cc01": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/signin.rs#L45-L54", "saa6be90fd793": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/scope.rs#L1-L35", "se50982814d76": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/core/src/iam/signup.rs#L213-L215", "s57c8f0d7c045": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/cnf/mod.rs#L290-L304", "s0d453e52834e": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/server/src/ntw/rate_limit.rs#L244-L280", "s02c701807dae": "https://github.com/surrealdb/surrealdb/blob/v4.0.0/surrealdb/rpc/src/capabilities/mod.rs#L548-L573" } }, "pinned-3x": { "name": "SurrealDB 3.x server", "basis": "v3.3.5", "sources": { "s7374afb961a9": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/cli/start.rs#L178-L201", "s38f06f165ec4": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/core/src/kvs/ds.rs#L1246-L1250", "s77f37f03f1ee": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/core/src/kvs/ds.rs#L453-L461", "s12582dd057b1": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/core/src/kvs/ds.rs#L4970-L4981", "sfc310c15b039": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/dbs/mod.rs#L2134-L2142", "s73099ef5068f": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/kvs-surrealkv/src/lib.rs", "sc840084091cd": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/ntw/signin.rs#L41-L49", "s65828a4dd9ea": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/ntw/signup.rs#L39-L75", "s10ad47f37dcd": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/ntw/rate_limit.rs#L243-L259", "s7144f593a339": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/dbs/mod.rs#L105-L324", "sb555f9745ad7": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/rpc/src/capabilities/mod.rs#L373-L397", "sd82d608cc2e1": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/cli/mod.rs#L182-L184", "s5ef899492471": "https://github.com/surrealdb/surrealdb/blob/v3.3.5/surrealdb/server/src/cli/mod.rs#L37-L38", "s905871ed201e": "https://github.com/surrealdb/surrealdb/releases/tag/v3.3.5" } }, "audience": { "name": "SurrealDB native AUDIENCE minimum", "basis": "3.3", "sources": { "s048fd6bd1f3e": "https://surrealdb.com/docs/reference/query-language/statements/define/access/jwt" } }, "scope-old": { "name": "SurrealDB SCOPE deprecation", "basis": "2.x", "sources": { "s5a6f3aad8f24": "https://surrealdb.com/docs/reference/query-language/statements/define/scope" } }, "scope-removed": { "name": "SurrealDB SCOPE removal", "basis": "3.0", "sources": { "s5a6f3aad8f24": "https://surrealdb.com/docs/reference/query-language/statements/define/scope" } }, "docs": { "name": "SurrealDB documentation (rolling)", "basis": "unknown", "sources": { "s2eb33a763ea4": "https://surrealdb.com/docs/reference/cli/db/commands/start", "s38b5f5aad1d4": "https://surrealdb.com/docs/learn/security", "sdaa9a636537a": "https://surrealdb.com/docs/learn/security/authentication/overview", "s39d3475ac024": "https://surrealdb.com/docs/surrealql/statements/define/user", "sae73137661aa": "https://surrealdb.com/docs/learn/security/best-practices/security-best-practices", "sc798cc43c457": "https://surrealdb.com/docs/learn/security/authorization/capabilities", "sb9c3a17a2998": "https://surrealdb.com/docs/learn/security/authorization/permissions-and-row-level-security", "sfdfa4e63cdd5": "https://surrealdb.com/docs/reference/query-language/statements/define/access/record", "scc00d3d3618d": "https://surrealdb.com/docs/reference/rest-api/http-protocol", "s51100e115b41": "https://surrealdb.com/docs/reference/query-language/statements/info", "s048fd6bd1f3e": "https://surrealdb.com/docs/reference/query-language/statements/define/access/jwt", "se2dbccd34348": "https://surrealdb.com/docs/explore/cli", "s80d3b718d95d": "https://surrealdb.com/docs/explore/cli/components", "sac0752ff1726": "https://surrealdb.com/releases/4.0", "s23996eedc4c4": "https://surrealdb.com/releases/3.3" } }, "defaults": { "name": "SurrealDB 2.0 release announcement", "basis": "unknown", "sources": { "se73fa9c93548": "https://surrealdb.com/releases/2.0" } }, "advisories": { "name": "SurrealDB security advisories", "basis": "unknown", "sources": { "sd74a20d357b6": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-whfh-76h8-2r66", "s28cd26dc0103": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-5w6j-qm42-4rc2", "sad31e9ec665b": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-fq69-wr4w-v3gm", "s9c8e76544223": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-q83r-f38r-rwvj", "s1b98c24eb665": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-5xcv-fm6g-4hf3", "sb541fcd902da": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-fh2q-8f75-m4h6", "s2da32614f366": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-qh3v-rr48-x3cq", "s8064745de0e0": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-ffvm-mqh3-7986", "s2f3b208498de": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-wjh3-m4pv-q6cp", "se50ef588388b": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-cff7-q66j-4hc8", "se5f6e6699691": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-3rcm-c653-wgmr", "s29b6dab1b6dd": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-ff2q-h4x7-cpj4", "sb7209bfe440d": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-pvv9-h693-m8r4", "s6363f9220ffe": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-m887-2246-mgw3", "s1f98319f76ae": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-3287-hpm5-3f8r", "s390dd974a89c": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-cr79-9p8r-p5wx", "sd6a62a692fe6": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-p3c2-wh5f-jwqm", "se028de7b3f26": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-cph5-pm57-9w9c", "s4846cdc74b47": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-7pqw-x84x-frxc", "s7b253e6289d7": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-6c37-x4jm-529x", "s3a93dc5e0a90": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-7r2m-g5wf-pj9j", "s03e87b1aeb09": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-623g-mhxq-5hmf", "sc475ce490938": "https://github.com/surrealdb/surrealdb/security/advisories/GHSA-m9qr-4rcq-3jhc" } }, "bash": { "name": "GNU Bash reference manual", "basis": "bash-5.3", "sources": { "sf9757e9fd6e8": "https://git.savannah.gnu.org/cgit/bash.git/tree/doc/bashref.texi?h=bash-5.3#n3872" } }, "procfs": { "name": "Linux procfs manual", "basis": "man-pages-6.19", "sources": { "s56aa3e840419": "https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_environ.5?h=man-pages-6.19#n12", "s9aae2a8587f2": "https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_environ.5?h=man-pages-6.19#n26", "sd64e43d1515a": "https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man5/proc_pid_environ.5?h=man-pages-6.19#n43" } }, "ptrace": { "name": "Linux ptrace manual", "basis": "man-pages-6.19", "sources": { "sfe9fe6919d33": "https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man2/ptrace.2?h=man-pages-6.19#n2519" } } }, "claims": { "auth-default": {"text": "Since 2.0.0 authentication defaults on, --auth was removed and --unauthenticated added; older releases do not share this default.", "components": ["defaults"], "sources": ["defaults:se73fa9c93548"], "status": "REASONED"}, "basis-3x": {"text": "The guide covers v4.0.0 and, where they differ, v3.3.5; the v3.3.5 source read supports each statement citing only v4.0.0 source unless stated. Not established at v3.3.5: statements resting only on rolling documentation, the capability defaults among them, what v3.3.5 changed, as its release record has no notes, and which command form a 3.x install takes.", "components": ["pinned-3x", "docs"], "sources": ["pinned-3x:s905871ed201e", "docs:sc798cc43c457", "docs:se2dbccd34348"], "status": "REASONED"}, "bind-default": {"text": "Since 2.0.0 the CLI defaults to 127.0.0.1:8000; v4.0.0 --bind/-b and SURREAL_BIND retain that loopback default.", "components": ["defaults", "pinned"], "sources": ["defaults:se73fa9c93548", "pinned:sd341f6f1f6cb"], "status": "REASONED"}, "command-form": {"text": "At v4.0.0 the database executable takes start and defines no db command; the Surreal CLI launcher runs it as surreal db start, passing the rest unchanged. As this guide's inference, under the launcher surreal start does not start the database, so the host block uses surreal db start and the image keeps start.", "components": ["pinned", "docs"], "sources": ["pinned:s02c05ba6cc1a", "pinned:s439e9f7b6e75", "docs:se2dbccd34348", "docs:s80d3b718d95d", "docs:sac0752ff1726"], "status": "REASONED"}, "root-bootstrap": {"text": "User/password flags or SURREAL_USER/PASS initialize a root only when none exists; they neither disable authentication nor rotate an existing password. At v4.0.0 the flags refuse empty values, core also refuses whitespace-only ones, and an import file that supplies root users suppresses the flag user.", "components": ["docs", "pinned"], "sources": ["docs:s2eb33a763ea4", "pinned:s325ec1bdaeca", "pinned:s68c349d06271", "pinned:s85b6558fdf7d", "pinned:se2b9738f37cc"], "status": "REASONED"}, "root-bootstrap-3x": {"text": "At v3.3.5 neither empty-credential check is in the cited code: the user and password flags have no non-empty value parser, and core credential setup has no empty-value check. Whether v3.3.5 refuses empty values elsewhere is not established; the block refuses an empty password itself.", "components": ["pinned-3x"], "sources": ["pinned-3x:s7374afb961a9", "pinned-3x:s38f06f165ec4"], "status": "REASONED"}, "root-input": {"text": "No v4.0.0 server file read has a stdin password form; prompt and use a Bash command-local environment assignment, not argv. As this guide's assessment, the initial server environment may be exposed to the same account and root when the procfs ptrace check permits; an inheriting process may expose it the same way while it runs; treat it as exposed to that account and root while the server runs.", "components": ["pinned", "bash", "procfs", "ptrace"], "sources": ["pinned:s325ec1bdaeca", "bash:sf9757e9fd6e8", "procfs:s56aa3e840419", "procfs:s9aae2a8587f2", "procfs:sd64e43d1515a", "ptrace:sfe9fe6919d33"], "status": "REASONED"}, "help-values": {"text": "Before 4.0.0 --help could print SURREAL_PASS and SURREAL_TOKEN values (GHSA-whfh-76h8-2r66, low, affecting versions below 4.0.0, patched in 4.0.0); at v4.0.0 the start password sets hide_env_values and the executable hides every argument's environment value in help.", "components": ["pinned", "advisories"], "sources": ["pinned:s325ec1bdaeca", "pinned:sc48298a83d5b", "advisories:sd74a20d357b6"], "status": "REASONED"}, "help-values-3x": {"text": "At v3.3.5 the start password sets no hide_env_values and no v3.3.5 file read calls it, so v3.3.5 lies inside the affected range of GHSA-whfh-76h8-2r66, below 4.0.0, though its reproductions name images up to v3.3.4. As this guide's advice, never run --help on v3.3.5 with SURREAL_PASS or SURREAL_TOKEN set.", "components": ["pinned-3x", "advisories"], "sources": ["pinned-3x:s7374afb961a9", "advisories:sd74a20d357b6"], "status": "REASONED"}, "environment": {"text": "The clean-Bash block clears user/password variables and rejects other inherited SURREAL_* options, including auth and bind overrides, the 3.x upgrade opt-in and the launcher's SURREAL_CLI_* variables.", "components": ["docs", "pinned"], "sources": ["docs:s2eb33a763ea4", "docs:s80d3b718d95d", "pinned:s025b59b3dab9", "pinned:sd341f6f1f6cb", "pinned:s315fd2b3211f"], "status": "REASONED"}, "launcher-unknowns": {"text": "Whether the Surreal CLI launcher stays running as the server's parent, or passes SURREAL_PASS on unchanged, is not established at v4.0.0; as this guide's assessment, treat a resident launcher as exposed like the server and confirm the root user by signing in.", "components": ["docs"], "sources": ["docs:se2dbccd34348", "docs:s80d3b718d95d"], "status": "REASONED"}, "storage-upgrade": {"text": "v4.0.0 removes the RocksDB engine; the docs read rocksdb:// as a deprecated spelling of surrealkv://, and the block names surrealkv:/data/mydb.db. A 3.x directory is refused unless SURREAL_EXPERIMENTAL_UPGRADE_FROM_3X=true, which the block cannot carry; convert it as a separate step after a backup. The server path check was not read.", "components": ["pinned", "docs"], "sources": ["pinned:s315fd2b3211f", "docs:s2eb33a763ea4", "docs:sac0752ff1726"], "status": "REASONED"}, "storage-3x": {"text": "As this guide's inference from the v3.3.5 datastore factory comment, a rocksdb:// usage example in core, a rocksdb: test behind the storage-rocksdb feature and the 3.3 release page, RocksDB is a working engine at v3.3.5; the v4.0.0 refusal of a 3.x directory does not apply there, as its SurrealKV crate has no upgrade opt-in text. How v3.3.5 parses storage paths is not established.", "components": ["pinned-3x", "docs"], "sources": ["pinned-3x:s77f37f03f1ee", "pinned-3x:s12582dd057b1", "pinned-3x:sfc310c15b039", "pinned-3x:s73099ef5068f", "docs:s23996eedc4c4"], "status": "REASONED"}, "unauthenticated": {"text": "--unauthenticated/SURREAL_UNAUTHENTICATED gives guests OWNER-equivalent access; never carry it onto a remotely reachable deployment.", "components": ["docs", "pinned"], "sources": ["docs:s2eb33a763ea4", "pinned:s025b59b3dab9"], "status": "REASONED"}, "docker-bind": {"text": "v4.0.0 image stages set SURREAL_BIND=0.0.0.0:8000 without CMD; start without an override listens on all container IPv4 interfaces, requiring private network or loopback publication. Whether published images use that file is not established.", "components": ["pinned"], "sources": ["pinned:s439e9f7b6e75"], "status": "REASONED"}, "private-network": {"text": "Widen host binds only deliberately to private addresses; internal-service databases should have no public interface.", "components": ["docs"], "sources": ["docs:sae73137661aa"], "status": "REASONED"}, "system-users": {"text": "System users exist at root, namespace and database level; the DEFINE USER page says only the built-in roles OWNER, EDITOR and VIEWER are currently available, but no page read says every user holds one. All bypass table/field PERMISSIONS, using roles instead, so scoped system users do not supply record isolation.", "components": ["docs"], "sources": ["docs:sdaa9a636537a", "docs:s39d3475ac024", "docs:sae73137661aa", "docs:s2eb33a763ea4", "docs:sb9c3a17a2998"], "status": "REASONED"}, "root-visibility": {"text": "The DEFINE USER page says even a root-level user can be given a limited role such as VIEWER, useful for automated services that need to monitor each namespace and database; the JWT page says root tokens can act on all tables in all databases of all namespaces. As this guide's inference, a root system user reaches every namespace and database, within what its role allows.", "components": ["docs"], "sources": ["docs:s39d3475ac024", "docs:s048fd6bd1f3e", "docs:sdaa9a636537a"], "status": "REASONED"}, "record-users": {"text": "Record users authenticate through DEFINE ACCESS TYPE RECORD SIGNUP/SIGNIN logic and are bound by table and field PERMISSIONS: omitting PERMISSIONS on a table results in PERMISSIONS NONE, while a field without a clause is stored as PERMISSIONS FULL and follows the table's rules.", "components": ["docs"], "sources": ["docs:sb9c3a17a2998", "docs:sfdfa4e63cdd5"], "status": "REASONED"}, "signup": {"text": "Unauthenticated /signup and /signin, their v4.0.0 cookie forms and scoped /ns/.../ac/... paths are intentional; every HTTP signup form reaches the core check that refuses an access method with no SIGNUP, so omit SIGNUP to prevent public registration and test refusal beside a provisioned sign-in. RPC signup was not traced.", "components": ["docs", "pinned"], "sources": ["docs:sfdfa4e63cdd5", "docs:scc00d3d3618d", "docs:sac0752ff1726", "pinned:s6ddbe030cea1", "pinned:s1c21ede08712", "pinned:s0a950c71cc01", "pinned:saa6be90fd793", "pinned:se50982814d76"], "status": "REASONED"}, "signup-3x": {"text": "At v3.3.5 the files read register only the /signin and /signup routes, with no cookie routes and no scoped-path rewrite, and the /signup handler calls the same core signup check as v4.0.0. Whether another v3.3.5 file registers cookie or scoped routes, and whether RPC signup reaches the check, is not established.", "components": ["pinned-3x", "pinned"], "sources": ["pinned-3x:sc840084091cd", "pinned-3x:s65828a4dd9ea", "pinned:se50982814d76"], "status": "REASONED"}, "rate-limit": {"text": "At v4.0.0 the per-address HTTP limit on sign-in, sign-up and Basic-auth requests is off by default (SURREAL_HTTP_AUTH_RATE_LIMIT_ENABLED false; burst and per-minute 60 once on), so the release page's Basic-auth rate limit applies only when enabled; a request with no derivable client address is not limited.", "components": ["pinned", "docs"], "sources": ["pinned:s57c8f0d7c045", "pinned:s0d453e52834e", "docs:sac0752ff1726"], "status": "REASONED"}, "rate-limit-3x": {"text": "At v3.3.5 the HTTP authentication limiter has the same setting and defaults but limits only POST requests to /signin and /signup with a derivable client address, whatever headers they carry; it has no cookie, refresh or sign-out path. It lacks v4.0.0's case for other requests carrying Basic credentials, so as this guide's reading of v3.3.5 source those are not rate-limited even when it is enabled.", "components": ["pinned-3x", "pinned"], "sources": ["pinned-3x:s10ad47f37dcd", "pinned:s57c8f0d7c045", "pinned:s0d453e52834e"], "status": "REASONED"}, "permission-advisories": {"text": "v4.0.0 fixes advisories affecting versions below 4.0.0 on table, field and role controls: GHSA-5xcv-fm6g-4hf3, GHSA-fh2q-8f75-m4h6, GHSA-qh3v-rr48-x3cq, GHSA-ffvm-mqh3-7986, GHSA-wjh3-m4pv-q6cp, GHSA-cff7-q66j-4hc8, GHSA-3rcm-c653-wgmr, GHSA-ff2q-h4x7-cpj4, GHSA-pvv9-h693-m8r4, GHSA-m887-2246-mgw3 and GHSA-3287-hpm5-3f8r; do not rely on those controls on an earlier release.", "components": ["advisories"], "sources": ["advisories:s1b98c24eb665", "advisories:sb541fcd902da", "advisories:s2da32614f366", "advisories:s8064745de0e0", "advisories:s2f3b208498de", "advisories:se50ef588388b", "advisories:se5f6e6699691", "advisories:s29b6dab1b6dd", "advisories:sb7209bfe440d", "advisories:s6363f9220ffe", "advisories:s1f98319f76ae"], "status": "REASONED"}, "scope-history": {"text": "Use DEFINE ACCESS TYPE RECORD; legacy DEFINE SCOPE was deprecated in 2.x and removed in 3.0.", "components": ["scope-old", "scope-removed"], "sources": ["scope-old:s5a6f3aad8f24", "scope-removed:s5a6f3aad8f24"], "status": "REASONED"}, "access-types": {"text": "DEFINE ACCESS also supports JWT and per-client BEARER keys; standalone JWT creates system-level sessions, while RECORD WITH JWT retains record permissions.", "components": ["docs"], "sources": ["docs:sdaa9a636537a", "docs:sfdfa4e63cdd5", "docs:s048fd6bd1f3e"], "status": "REASONED"}, "jwt-checks": {"text": "Verify token algorithm/key or JWKS, issuer and audience; native AUDIENCE is available since 3.3.0, so set it at the v4.0.0 basis, and check the issuer in AUTHENTICATE as the JWT page's example does. Compare invalid tokens against a valid control.", "components": ["docs", "audience"], "sources": ["docs:s048fd6bd1f3e", "audience:s048fd6bd1f3e"], "status": "REASONED"}, "capability-all": {"text": "allow-all and deny-all default off; prefer deny-all with narrow route, RPC-method and function allowances. As this guide's reading of v4.0.0 source, deny-all alone leaves every HTTP route, RPC method and MCP tool allowed, as allow-http, allow-rpc and allow-mcp default to all targets; no run confirmed it, so pass explicit allow-http and allow-rpc lists.", "components": ["docs", "pinned"], "sources": ["docs:sc798cc43c457", "docs:scc00d3d3618d", "pinned:scce5b37cc67e", "pinned:sb983f262eea6", "pinned:s41f93c8f1044", "pinned:s10eff64ec263", "pinned:sc2c677a2fedc"], "status": "REASONED"}, "capability-health": {"text": "When the Verify block's health check should pass under deny-all, include health in the allow-http list: the HTTP allow getter returns a named list as given, the route parser reads health as the health route, and /health refuses a route that is not allowed, which the HTTP protocol page says answers 403.", "components": ["pinned", "docs"], "sources": ["pinned:s41f93c8f1044", "pinned:s02c701807dae", "pinned:s538897c5bacc", "docs:scc00d3d3618d"], "status": "REASONED"}, "mcp-advisory": {"text": "GHSA-cr79-9p8r-p5wx (high; affecting versions below 4.0.0, patched in 4.0.0) reports MCP tool identifier validation permitting statement injection, with arbitrary SQL query execution through MCP tool invocation; it bears on MCP tools staying allowed under deny-all.", "components": ["advisories"], "sources": ["advisories:s390dd974a89c"], "status": "REASONED"}, "capability-mcp-3x": {"text": "The v3.3.5 capability files read define allow-rpc and allow-http with the same empty default but no allow-mcp or deny-mcp list; their only MCP target is the mcp HTTP route. surreal mcp is behind the mcp feature; its arguments are named through the cli::mcp module (use mcp::McpCommandArguments;), which was not read, nor was its MCP crate, so whether anything else gates MCP tools is not established.", "components": ["pinned-3x"], "sources": ["pinned-3x:s7144f593a339", "pinned-3x:sb555f9745ad7", "pinned-3x:sd82d608cc2e1", "pinned-3x:s5ef899492471"], "status": "REASONED"}, "capability-net": {"text": "Network access is denied by default; restrict http::* and JWKS destinations and retain external egress protections against SSRF.", "components": ["docs"], "sources": ["docs:sc798cc43c457"], "status": "REASONED"}, "capability-functions": {"text": "Functions are allowed by default; a bare allow-funcs broadens access beyond narrow named allowances.", "components": ["docs"], "sources": ["docs:sc798cc43c457"], "status": "REASONED"}, "capability-scripting": {"text": "Embedded scripting defaults off; enable it only when required.", "components": ["docs"], "sources": ["docs:sc798cc43c457"], "status": "REASONED"}, "capability-guests": {"text": "Guest access defaults off; capabilities govern what an authorized query may do independently of authentication.", "components": ["docs"], "sources": ["docs:sc798cc43c457"], "status": "REASONED"}, "capability-precedence": {"text": "A category allow list overrides deny-all or a bare category deny, and deny wins at equal specificity; between list entries a deny entry wins over any allow entry, so a narrower allow does not override a broader deny, as the capabilities page's own example and this guide's reading of v4.0.0 source agree.", "components": ["docs", "pinned"], "sources": ["docs:sc798cc43c457", "pinned:sd1dcb148f996", "pinned:s6a13d5749f9a", "pinned:s6f5b41199da6"], "status": "REASONED"}, "capability-advisories": {"text": "v4.0.0 fixes capability bypasses affecting versions below 4.0.0: GHSA-5w6j-qm42-4rc2, GHSA-fq69-wr4w-v3gm and GHSA-q83r-f38r-rwvj; the 4.0 release page lists 23 advisories, of which this guide names 16 and lists 7 more.", "components": ["advisories", "docs"], "sources": ["advisories:s28cd26dc0103", "advisories:sad31e9ec665b", "advisories:s9c8e76544223", "docs:sac0752ff1726"], "status": "REASONED"}, "advisories-3x": {"text": "v3.3.5 is in all 23 affected ranges of the 4.0 advisories; exposure depends on prerequisites. GHSA-q83r-f38r-rwvj needs experimental Surrealism, authenticated module upload and invoke rights, and that the operator relies on deny_net (with a broad or attacker-influenceable allow_net) for egress control. GHSA-m9qr-4rcq-3jhc is \"not remote/black-box triggerable.\" Crate-to-server mapping is inference.", "components": ["advisories", "docs"], "sources": ["advisories:sd74a20d357b6", "advisories:s28cd26dc0103", "advisories:sad31e9ec665b", "advisories:s9c8e76544223", "advisories:s1b98c24eb665", "advisories:sb541fcd902da", "advisories:s2da32614f366", "advisories:s8064745de0e0", "advisories:s2f3b208498de", "advisories:se50ef588388b", "advisories:se5f6e6699691", "advisories:s29b6dab1b6dd", "advisories:sb7209bfe440d", "advisories:s6363f9220ffe", "advisories:s1f98319f76ae", "advisories:s390dd974a89c", "advisories:sd6a62a692fe6", "advisories:se028de7b3f26", "advisories:s4846cdc74b47", "advisories:s7b253e6289d7", "advisories:s3a93dc5e0a90", "advisories:s03e87b1aeb09", "advisories:sc475ce490938", "docs:sac0752ff1726"], "status": "REASONED"}, "tls": {"text": "web-crt and web-key provide native HTTPS; SurrealDB documents TLS termination at a load balancer or reverse proxy, and reaching the instance only over a tailnet is this guide's own suggestion.", "components": ["docs"], "sources": ["docs:s2eb33a763ea4", "docs:sae73137661aa"], "status": "REASONED"}, "mfa": {"text": "Whether SurrealDB v4.0.0 has a native second factor is not established, as no page read addresses one; require IdP or administrative-gateway MFA and prevent direct-origin bypass.", "components": ["docs"], "sources": ["docs:sdaa9a636537a", "docs:sae73137661aa"], "status": "REASONED"}, "verify-inventory": {"text": "Inventory 8000 on loopback/private addresses; at v4.0.0 a /health 200 means the route is allowed, the memory-health failure condition was not met (the check is disabled, or the guard has not stayed engaged for its configured period) and storage answers, not authentication or finished startup; /status is liveness and /ready follows startup and any configured heartbeat budget.", "components": ["docs", "pinned"], "sources": ["docs:s2eb33a763ea4", "docs:scc00d3d3618d", "pinned:s538897c5bacc", "pinned:s67ee52a23e57"], "status": "REASONED", "verify": [1]}, "verify-auth": {"text": "Compare INFO FOR DB without credentials and with a system-user JWT on the same NS/DB: exposed auth-off returns info; fixed denies anonymous access and returns authenticated OK data.", "components": ["docs"], "sources": ["docs:scc00d3d3618d", "docs:s51100e115b41"], "status": "REASONED", "verify": [1]}, "verify-auth-limits": {"text": "Test direct origin and HTTPS; HTTP 200, missing database, disabled route, proxy rejection or transport failure alone proves no authorization control or record permissions.", "components": ["docs"], "sources": ["docs:scc00d3d3618d", "docs:s51100e115b41"], "status": "REASONED", "verify": [1]}, "verify-origin": {"text": "Any HTTP answer from an untrusted-network origin probe proves reachability; a gateway result alone does not establish origin isolation.", "components": ["docs"], "sources": ["docs:sae73137661aa"], "status": "REASONED", "verify": [2]}, "verify-tls": {"text": "Validate the certificate chain at every TLS termination using normal trust checks, never -k.", "components": ["docs"], "sources": ["docs:sae73137661aa"], "status": "REASONED", "verify": [1, 2]} } } ```` ====================================================================== ==> time-series-metrics-stores.md ====================================================================== # Time-series and metrics stores: InfluxDB, VictoriaMetrics, and QuestDB InfluxDB, VictoriaMetrics and QuestDB are time-series databases: they store readings over time, like server metrics. This guide helps your AI assistant configure each securely, including InfluxDB 2.x setup finished privately and applications given scoped tokens, VictoriaMetrics cluster RPC ports limited to cluster members, and QuestDB's default database login changed. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/time-series-metrics-stores.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against InfluxDB OSS 2.x and 1.x, VictoriaMetrics single-node v1.155.0, VictoriaMetrics cluster v1.155.0-cluster, VictoriaMetrics shipped Compose image v1.154.1, and QuestDB Enterprise qualification 4.0.0; 2 further sources are pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 45 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A metrics store ingests over HTTP and answers queries over an HTTP API, and most ship a web console beside it. Several of them either authenticate nothing by default or ship a known default credential, so an instance that is reachable from off-host lets a stranger read your operational and business series, write false points that poison dashboards and alerts, or delete history. This guide covers three that AI-assisted projects commonly stand up. The controls are the same shape in each: bind the listeners privately, put authentication and TLS in front where the engine has none of its own, and change every default credential. [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) is the reverse-proxy pattern these lean on, [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) covers the tokens and passwords, and [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) is the account layer. All ports below bind all interfaces (`0.0.0.0`) unless noted, and native TLS is off unless you configure it. Replace every illustrative address and name. ## InfluxDB ### OSS 2.x The HTTP API, the UI, and the write endpoint all share port `8086`. You cannot turn authentication off or on with a flag: a fresh instance is instead *un-set-up*, and `GET /api/v2/setup` returns `allowed: true` until someone completes onboarding. Whoever reaches an exposed, un-initialized instance first can `POST /api/v2/setup` to create the initial user, organization, and an all-access operator token, which is a full takeover. Complete setup privately, then hand applications *scoped* tokens rather than the operator token. Bind the listener privately and enable TLS in the config file (located through `INFLUXD_CONFIG_PATH`): ```yaml http-bind-address: "127.0.0.1:8086" tls-cert: "/etc/ssl/influxdb.crt" tls-key: "/etc/ssl/influxdb.key" ``` ### OSS 1.x Port `8086` again carries query, write, and administrative statements, and the `[http]` setting `auth-enabled` is `false` by default. An unauthenticated caller can therefore read every database, write points, and run destructive statements such as `DROP DATABASE`. Create an administrator user first, then require authentication and turn on HTTPS: ```toml [http] bind-address = "127.0.0.1:8086" auth-enabled = true https-enabled = true https-certificate = "/etc/ssl/influxdb.crt" https-private-key = "/etc/ssl/influxdb.key" ``` Enabling `auth-enabled` does not by itself authenticate the Prometheus remote-read API; set `prom-read-auth-enabled = true` as well, since it defaults to `false` and has no effect until `auth-enabled` is on. The backup and restore RPC on `8088` binds `127.0.0.1` by default; keep it there. Leave the optional Graphite, collectd, OpenTSDB, and UDP inputs disabled unless you are separately securing them, since each is another unauthenticated write path. ## VictoriaMetrics ### Single-node At v1.155.0 single-node (commit `3fec2f72414d00d1acda807c8818d7af90cc966d`), the HTTP API, UI and HTTP ingest share the fallback address `-httpListenAddr=:8428` when no address is supplied. It listens on every IPv4 interface by default (`tcp4`); `-enableTCP6`, default `false`, enables IPv6 too. The shipped `deployment/docker/compose-vm-single.yml` publishes `8428:8428` without a host IP, so it requests publication on every host interface by default. That example selects image v1.154.1, despite being shipped at source tag v1.155.0; it also enables and publishes additional ingest listeners. HTTP Basic authentication uses `-httpAuth.username` and `-httpAuth.password`, both empty by default. It is disabled whenever the username is empty, even if a password is set. With a username set, one credential covers ordinary query, UI and HTTP ingest routes, including `/api/v1/import`, `/api/v1/import/csv`, `/api/v1/import/native`, the `/api/v1/import/prometheus` prefix and their supported `/prometheus` aliases, remote write, InfluxDB writes, OpenTelemetry, DataDog, New Relic and Zabbix ingestion. These ingest routes have no separate endpoint auth key. Basic auth does not cover `/health`, `/ping`, `/-/healthy`, `/-/ready`, `/robots.txt` or any path ending in `/favicon.ico`. `/ready` and `/influx/health` do pass through Basic auth. The wrapper also answers `OPTIONS` before authentication, and handles an optional `-http.pathPrefix` redirect or missing-prefix error before authentication. Paths here are relative to that configured prefix. These responses do not demonstrate protection of data or administrative operations. Bind the HTTP listener privately, and configure HTTPS with `-tls`, `-tlsCertFile` and `-tlsKeyFile`: ```text -httpListenAddr=127.0.0.1:8428 -httpAuth.username=REPLACE_WITH_A_NAME -httpAuth.password=REPLACE_WITH_A_SECRET -tls -tlsCertFile=/etc/ssl/vm.crt -tlsKeyFile=/etc/ssl/vm.key ``` The following handlers check their own keys. Every key defaults to empty: | Handler | Flag | | --- | --- | | `/snapshot/create`, `/snapshot/list`, `/snapshot/delete`, `/snapshot/delete_all`, and creation through `/api/v1/admin/tsdb/snapshot` | `-snapshotAuthKey` | | POST `/api/v1/admin/tsdb/delete_series` and `/prometheus/api/v1/admin/tsdb/delete_series`; POST `/tags/delSeries` and `/graphite/tags/delSeries` | `-deleteAuthKey` | | `/internal/force_merge` | `-forceMergeAuthKey` | | `/internal/force_flush` | `-forceFlushAuthKey` | | `/internal/resetRollupResultCache` | `-search.resetCacheAuthKey` | | `/internal/log_new_series` | `-logNewSeriesAuthKey` | | `/api/v1/admin/status/metric_names_stats/reset` | `-metricNamesStatsResetAuthKey` | | `/config`, `/api/v1/status/config`, and their `/prometheus` aliases | `-configAuthKey` | | `/-/reload` and `/prometheus/-/reload` | `-reloadAuthKey` | | `/metrics` | `-metricsAuthKey` | | `/flags` | `-flagsAuthKey` | | `/debug/pprof/*` (with the slash after `pprof`) | `-pprofAuthKey` | For these registered routes, an empty key falls back to Basic auth, leaving the operation unauthenticated if the username is also empty. A nonempty key requires a matching `authKey` request parameter and skips Basic auth; it replaces that check rather than adding a second credential. Missing or wrong keys return `401`, even with correct Basic credentials. The override is limited to the registered paths: another alias accepted by an application handler can still require Basic auth before its endpoint-key check. Setting a key does not enable the operation or protect other routes. Keep administrative, configuration and debug routes private, and use `vmauth` or a reverse proxy with TLS and per-route restrictions for client access. Other listener flags are `-graphiteListenAddr` (TCP and UDP), `-influxListenAddr` (TCP and UDP), `-opentsdbListenAddr` (TCP and UDP, with both telnet and HTTP put on TCP), `-opentsdbHTTPListenAddr` (HTTP over TCP), and `-vmselectAddr` (cluster-native query RPC). All default to empty and are disabled while empty. Leave them disabled unless needed; bind enabled listeners privately and restrict access to trusted clients separately from the main HTTP listener. None of these listeners is given a TLS configuration, so the HTTP `-tls` settings never apply to them. `-httpAuth.*` Basic auth is checked only on OpenTSDB HTTP put requests (`-opentsdbHTTPListenAddr`, and the HTTP branch of `-opentsdbListenAddr`); Graphite, InfluxDB line protocol over TCP and UDP, and OpenTSDB telnet and UDP accept data with no authentication at all. ### Cluster At v1.155.0-cluster, the open-source cluster splits HTTP across three components: `vminsert` takes ingestion on `-httpListenAddr=:8480`, `vmselect` takes queries and the UI on `:8481`, and `vmstorage` exposes maintenance and metrics on `:8482`. These are the fallback addresses when no `-httpListenAddr` is supplied. Each binds every IPv4 interface by default; `-enableTCP6` also enables IPv6. Each HTTP component accepts `-httpAuth.username` and `-httpAuth.password`, both empty by default; Basic authentication is disabled while the username is empty. On `vmstorage`, `-vminsertAddr=:8400` and `-vmselectAddr=:8401` also bind every IPv4 interface by default. These native RPC listeners have no authentication or native TLS in this open-source build. Their handshake checks a protocol identifier and negotiates compression, not a credential. A caller speaking the protocol can write arbitrary series through `8400`, or read and delete series through `8401`; the latter also exposes tenant enumeration, metric-name registration and usage-statistics reset. HTTP Basic auth, endpoint auth keys and `vmauth` do not protect these sockets. Bind `-vminsertAddr` and `-vmselectAddr` to each storage node's cluster-network IP and restrict access to trusted cluster members with firewall or network-policy rules. A private address alone is not an access control. The optional `-clusternativeListenAddr` on `vminsert` and `vmselect` is empty (disabled) by default; restrict it to the same trusted network if enabled for a multi-level cluster. The HTTP maintenance and disclosure handlers have their own keys: - On `vmstorage` (`8482`), `-snapshotAuthKey` gates `/snapshot/create`, `/snapshot/list`, `/snapshot/delete` and `/snapshot/delete_all`; `-forceMergeAuthKey` gates `/internal/force_merge`; `-forceFlushAuthKey` gates `/internal/force_flush`; and `-logNewSeriesAuthKey` gates `/internal/log_new_series`. - On `vmselect` (`8481`), `-deleteAuthKey` gates POSTs to `/delete/{accountID}/prometheus/api/v1/admin/tsdb/delete_series` and `/select/{accountID}/graphite/tags/delSeries`. `-search.resetCacheAuthKey` gates `/internal/resetRollupResultCache`, and `-metricNamesStatsResetAuthKey` gates `/admin/api/v1/admin/status/metric_names_stats/reset`. - On all three HTTP components, `-metricsAuthKey`, `-flagsAuthKey` and `-pprofAuthKey` gate `/metrics`, `/flags` and `/debug/pprof/*`, respectively. These keys default to empty. An empty endpoint key falls back to HTTP Basic auth, so the handler is unauthenticated when `-httpAuth.username` is also empty. A nonempty endpoint key requires a matching `authKey` request parameter and overrides Basic auth for that handler; it does not add a second authentication requirement or authenticate other routes. Bind every HTTP component privately and route external traffic through `vmauth` over HTTPS with per-route or per-tenant restrictions, allowing only the operations clients need. ## QuestDB QuestDB opens several protocols, each with its own default. The web console with its REST and SQL endpoints is on `9000`, and its Basic-auth keys `http.user` and `http.password` are unset by default, so the console and REST API answer unauthenticated in the open-source build. The PostgreSQL wire protocol on `8812` ships the default credential pair `admin` / `quest` in `pg.user` and `pg.password`. The InfluxDB line protocol on `9009` (TCP) has `line.tcp.auth.db.path` unset by default, so it accepts unauthenticated writes. Set each of these in the active `server.conf`: ```properties http.user=REPLACE_WITH_A_NAME http.password=REPLACE_WITH_A_SECRET pg.user=REPLACE_WITH_A_NAME pg.password=REPLACE_WITH_A_SECRET line.tcp.auth.db.path=conf/auth.txt ``` `line.tcp.auth.db.path` points at a file of P-256 public keys. The [minimal health server](https://questdb.com/docs/configuration/http-min-server/) on `9003` requires authentication when the HTTP server does by default (`http.health.check.authentication.required=true`); setting this option to `false` independently permits unauthenticated health checks. The open-source build has no native TLS, so terminate it at a reverse proxy; QuestDB Enterprise instead uses role-based access control (since Enterprise 4.0.0 it refuses to start if the `http.user` and `http.password` keys are set) and its own `tls.enabled` settings. ## The pattern, any of these Bind every listener to loopback or a private interface, and put authentication and TLS in a layer in front, because none of these engines authenticates by default: VictoriaMetrics ships its Basic auth empty, InfluxDB 1.x ships `auth-enabled` off, and QuestDB leaves its console keys and ILP auth unset while shipping a default database credential. Change every default credential (`admin` / `quest` first), and never place an ingest or query port on the public internet. A browser sign-in or MFA layer in front of the console does not authenticate the PostgreSQL wire or line-protocol ports, which are separate listeners: bind and front those too. ## Verify REASONED: following block; every probe in this section, including the TCP and UDP listener inventory below for each port it names. Every probe in this section, including the TCP and UDP listener inventory below for each port it names, rests on the guide's cited InfluxDB 2.x configuration options, InfluxDB 1.x configuration, VictoriaMetrics v1.155.0 single-node HTTP fallback, VictoriaMetrics v1.155.0-cluster HTTP fallback binds, and QuestDB HTTP server, PostgreSQL wire protocol, ingestion and minimal HTTP server entries. No InfluxDB, VictoriaMetrics or QuestDB listener ran. During marking review (October 2026) all three Verify blocks passed bash -n under bash 5.3.9, and the inventory's two commands, run without sudo on the review host as ss -tlnp and ss -ulnp (iproute2 6.19.0), accepted their flags and showed none of the named TCP or UDP ports listening, with an empty process column, so they tested no target; the sudo form was not run. Those runs covered all three blocks in earlier forms, whose exit statuses did not classify their results. The current forms of all three pass bash -n and ShellCheck 0.11.0 in the repository's gate suite, which runs both over every block. Stub runs of the current inventory with stub ss and sudo commands and no network, in a clean shell and under an inherited `set -e`, `readonly` and `declare -i`, exited 0 when both tables showed only loopback listeners, 1 when one showed a named port on a wildcard address, 3 when an ss command failed and 2 when ss was missing. Exposed, a listed port shows a public or wildcard bind; fixed, only its intended private address. The inventory block exits 1 when a port it names is bound to a wildcard address, 3 when either ss command fails, 2 when ss is not installed, so neither table is listed, and otherwise 0, leaving public and private addresses for you to judge. Per the guide's cited sudo sudoers manual, secure_path entry, where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. Every probe below is **REASONED, not demonstrated**: the authoring host forbids opening listeners without an isolated network namespace, and has none. Outcomes are derived from the cited vendor sources rather than observed. A transport failure, a redirect, a 404, a 405 or a TLS error is inconclusive, never the fixed state. ```bash # Listener inventory, REASONED as the paragraph above records: no isolated network namespace for listeners on the authoring host. Expected outcomes are recorded above and vendor sources below. ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } # read the whole table: 8086 and loopback 8088; 8428, 8480, 8481, 8482 and native # RPC 8400, 8401; 9000, 9003, 8812, 9009, each only on its intended private # address, and no metrics or ingest port bound to a public interface set -- 0 "$(if sudo ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0; a missing ss has already exited 2 above), then the TCP listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the TCP listener table, then ss's exit status printf '%s' "$2" [ "$3" = 0 ] || { echo "sudo ss -tlnp exit $3: the TCP listener inventory failed; inconclusive"; set -- 3 "$2" "$3"; } set -- "$1" "$2" "$(if sudo ss -ulnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # then the UDP listeners set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # status, both tables, then this ss's exit status printf '%s' "$3" [ "$4" = 0 ] || { echo "sudo ss -ulnp exit $4: the UDP listener inventory failed; inconclusive"; set -- 3 "$2" "$3" "$4"; } if printf '%s%s' "$2" "$3" | awk '$4 ~ /:(8086|8088|8428|8480|8481|8482|8400|8401|9000|9003|8812|9009)$/ && ($4 ~ /^0\.0\.0\.0:/ || index($4, "*:") == 1 || index($4, "[::]:") == 1 || index($4, ":::") == 1) { print; found = 1 } END { exit !found }'; then echo "FINDING: the rows just above bind a port this block names to a wildcard address, the exposed state"; set -- 1 else case "$?" in 1) ;; *) echo "awk could not read the listener tables; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac fi echo "block exit $1; public and private addresses are not classified, so judge every row against the comments yourself" exit "$1" ) ``` REASONED: the outside-host and trusted-host VictoriaMetrics cluster RPC reachability pair, per the guide's cited VictoriaMetrics v1.155.0-cluster HTTP fallback binds, RPC handshake and RPC capabilities entries and its VictoriaMetrics cluster security recommendations entry. During marking review (October 2026) the following block passed bash -n under bash 5.3.9 and ran with no network: unchanged, it printed the substitute-the-storage-node-IP refusal and exited 2; without its set -- line, the paste-the-whole-block refusal. The following block's no-network runs during marking review (October 2026) gave two further results: a host name was rejected by the block's Python ipaddress check with a ValueError; and 127.0.0.1, where nothing listened, printed connection failed with Connection refused and not proof of isolation for 8400 and 8401. Those runs covered an earlier form of the block, which took one value, let a host name end it with a Python traceback and exit status 1, and, once past its guards, otherwise exited 0 whatever it printed; none was repeated on the current form, which the repository's gate suite checks with bash -n and ShellCheck 0.11.0 like every block. **REASONED, not demonstrated:** the VictoriaMetrics cluster RPC check needs a running cluster and probe hosts inside and outside its protected network, unavailable because the authoring host forbids opening listeners without an isolated network namespace, and has none. The `ss` inventory above identifies local binds; it does not prove that `8400` and `8401` are unreachable from outside the cluster network. From an outside host, substitute each storage node's actual IP and `outside` below and run the whole block. Repeat from a trusted cluster host with `trusted` as the positive control. Use any configured replacement ports too, including enabled `-clusternativeListenAddr` listeners. The pinned RPC listener and handshake sources below explain why reachability matters. ```bash # REASONED: cluster RPC isolation and trusted-host control; no isolated network namespace for listeners on the authoring host. Expected outcomes and vendor sources are recorded below. ( set +e # errexit off here, so the probe's status is read on the line after it instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_STORAGE_NODE_IP' 'REPLACE_WITH_outside_OR_trusted' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the storage node IP; not probing"; exit 2 ;; esac case "$2" in outside|trusted) : ;; *) echo "substitute outside (a probe from an outside host) or trusted (the trusted cluster host control) as the second value on the set -- line above; not probing"; exit 2 ;; esac command -v python3 > /dev/null || { echo "python3 is required for this probe; not probing"; exit 2; } python3 - "$1" <<'PY' import ipaddress import socket import sys _, host = sys.argv try: ipaddress.ip_address(host) except ValueError: print(f"{host} is not an IP address: substitute the storage node's actual IP; not probing") sys.exit(20) reachable = 0 for port in (8400, 8401): try: with socket.create_connection((host, port), timeout=5): print(f"{host}:{port}: TCP REACHABLE") reachable += 1 except OSError as exc: print(f"{host}:{port}: connection failed: {exc}; not proof of isolation") sys.exit(21 + reachable) # 21: neither port connected, 22: one did, 23: both did PY set -- "$2" "$?" # outside or trusted, then the probe's exit status case "$1:$2" in *:20) set -- 2 ;; outside:22|outside:23) echo "FINDING: a storage RPC port accepted a TCP connection from outside, the exposed state"; set -- 1 ;; outside:21) echo "neither port connected from here: consistent with isolation, not proof; confirm the target, its running listeners and the firewall or network-policy rule responsible, and run the trusted-host control"; set -- 0 ;; trusted:23) echo "the trusted-host control connected to both listeners"; set -- 0 ;; trusted:21|trusted:22) echo "the trusted-host control did not connect to both listeners, so it failed; inconclusive"; set -- 3 ;; *) echo "python3 exit $2: the probe did not finish; inconclusive"; set -- 3 ;; esac echo "block exit $1; the per-port connection lines above, including any connection error, are not classified beyond this, so read them yourself" exit "$1" ) ``` REASONED: the expected exposed and fixed RPC outcomes, per the guide's cited VictoriaMetrics v1.155.0-cluster RPC handshake and RPC capabilities entries and vmauth authorization proxy entry; no cluster ran, so neither state was observed. An exposed listener is expected to report `TCP REACHABLE` from outside; this is an exposure failure even if an HTTP request to that port fails, since RPC is not HTTP. In the fixed state, outside connections must be blocked while the trusted-host control still connects to both running listeners. A timeout or refusal alone is inconclusive: confirm the target, running listener and firewall or network-policy rule responsible. With `outside`, the block exits 1 when either port reports `TCP REACHABLE` and 0 when neither connects, which it reports as consistent with isolation but not proof for that reason; with `trusted`, it exits 0 when both connect and 3 otherwise. It refuses an address that is not an IP literal with status 2, before any connection. A `401` from `vmauth` says nothing about RPC isolation. ```bash # REASONED: HTTP setup, authentication and endpoint-key probes; no isolated network namespace for listeners on the authoring host. Expected outcomes and vendor sources are recorded below. # Per-target discriminator: InfluxDB 2.x setup returns 200 in both states; read allowed # (true before setup, false after). The other named data endpoints should return 401 with auth. # Substitute a full URL on the set -- line # and paste the whole block so the guard runs; use `http://` for the exposed check and the hardened `https://` entrypoint for the re-run, and the body is kept because one target discriminates on it. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute a full URL on the set -- line above; not probing"; exit 2 ;; http://*|https://*) : ;; *) echo "expected an http:// or https:// URL; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${2##*$'\n'}" "${2%$'\n'*}" # curl's exit status, then its output printf '%s' "$2" set -- "$1" "$2" "${2%$'\n'}" set -- "$1" "$2" "${3##*$'\n'}" # curl's exit status, its output, then its http= exit= err= line case "$1:$3" in 0:"http=401 "*) echo "HTTP 401: this endpoint requires authentication"; set -- 0 ;; 0:"http=2"??" "*) case "$2" in *'"allowed":true'*|*'"allowed": true'*) echo "FINDING: the InfluxDB 2.x setup endpoint reports allowed true, so this instance has not been set up"; set -- 1 ;; *'"allowed":false'*|*'"allowed": false'*) echo "the InfluxDB 2.x setup endpoint reports allowed false: the instance is initialized"; set -- 0 ;; *) echo "unclassified observation: a URL alone does not tell this block which engine, or which proxy in front of one, answered, so it does not classify a data answer; read the body above against the expectations for this target; a 200 health response proves nothing on its own"; set -- 0 ;; esac ;; 0:"http=3"??" "*|0:"http=404 "*|0:"http=405 "*) echo "a redirect, a 404 or a 405 is inconclusive, never the fixed state"; set -- 3 ;; 0:*) echo "an HTTP status this block does not classify; inconclusive"; set -- 3 ;; *) echo "curl exit $1: a transport failure or a TLS error is inconclusive, never the fixed state"; set -- 3 ;; esac echo "block exit $1; a 2xx body other than the setup endpoint's is not classified, so judge it against the expectations below" exit "$1" ) ``` REASONED: preceding block; the guarded URL probe above for its setup, authentication and endpoint-key targets, and this paragraph's per-target InfluxDB, VictoriaMetrics and QuestDB expectations and PostgreSQL wire check. The declared scope rests on the guide's cited InfluxDB 2.x setup API, InfluxDB 1.x configuration, InfluxDB 1.x authentication and authorization, VictoriaMetrics v1.155.0 HTTP auth and TLS flags, QuestDB HTTP server and QuestDB PostgreSQL wire protocol entries. No InfluxDB, VictoriaMetrics or QuestDB instance ran; psql is installed in the authoring environment, but there was no QuestDB listener to refuse it. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ran with a stub curl and no network: unchanged, it printed the substitute-a-full-URL refusal and exited 2; a URL without a scheme drew the expected-http-or-https refusal; and the setup and snapshot-list URLs reached the stub unchanged. Those runs covered an earlier form of the block, which ended with curl's own status; none was repeated on the current form, which the repository's gate suite checks with bash -n and ShellCheck 0.11.0 like every block. The block exits 3 for a transport failure, a TLS error, a redirect, a `404`, a `405` or any other status it does not classify, 1 for a `2xx` body containing `"allowed":true`, and 0 for a `401`, for `"allowed":false`, or for any other `2xx`, whose body it leaves for you to read against these expectations. It does not classify a `2xx` data answer as exposed, because a URL alone does not tell it which engine, or which proxy in front of one, answered; a database list, series or a result set returned without credentials is the exposed state below. The expected exposed and fixed results are those stated here. Run that block once per target, over `http://` against the default plaintext listener. InfluxDB 2.x `http://tsdb.example.com:8086/api/v2/setup` returns a body containing `"allowed":true` on an un-set-up instance and `"allowed":false` once initialized; the HTTP status is `200` either way, so read the body, not the code. InfluxDB 1.x `http://tsdb.example.com:8086/query?q=SHOW+DATABASES` returns the database list when `auth-enabled` is off and `401` once it is on. VictoriaMetrics `http://tsdb.example.com:8428/api/v1/query?query=up` returns series until `-httpAuth.username` is set or it sits behind `vmauth`, then `401`. QuestDB `http://tsdb.example.com:9000/exec?query=SELECT+1` returns a result set with no `http.user` set and `401` once it is. For the PostgreSQL wire port, confirm separately that `admin` / `quest` is refused with a `psql` connection attempt. Once authentication and TLS are in front, re-run against the `https://` entrypoint to confirm the `401`, and separately confirm the plaintext port is closed from outside, since a `401` from the proxy does not prove the backend listener is unreachable. A `200` health response, a redirect, or a 404 proves none of this on its own. REASONED: the VictoriaMetrics v1.155.0 single-node endpoint-key and Basic-auth cases, per the guide's cited VictoriaMetrics v1.155.0 key precedence and Basic fallback, built-in exemptions and key routes, and empty password/key defaults entries. No VictoriaMetrics instance ran; the guarded URL block's local runs are recorded in the previous paragraph. The expected exposed and fixed results are those stated here. **REASONED, not demonstrated:** for v1.155.0 single-node, use the guarded URL block above against `/snapshot/list` and `/metrics` with no credentials. With empty endpoint keys and an empty Basic username, expect `200` and snapshot JSON or metrics text; with a Basic username set and the keys still empty, expect `401`. With nonempty endpoint keys, a request lacking `authKey` must return `401`. In an isolated deployment, also confirm that a correct endpoint key succeeds without Basic credentials, that Basic credentials alone cannot satisfy a nonempty endpoint key, and that ordinary query and ingest requests still require Basic auth. Do not use snapshot creation, deletion or ingestion as a read-only probe. `/health` and `/-/ready` remain unauthenticated under native Basic auth; their success is not a failed data-auth check. Include every enabled optional listener in the `ss` inventory and outside-network isolation checks. The authoring host forbids opening listeners without an isolated network namespace, and has none; these expectations are REASONED from the pinned sources cited for these controls. ## Common mistakes - Leaving an InfluxDB 2.x instance un-set-up on a reachable address, so the first stranger to `POST /api/v2/setup` becomes its operator. - Running InfluxDB 1.x with the default `auth-enabled = false`, which leaves `DROP DATABASE` open to anyone who can reach `8086`. - Leaving VictoriaMetrics reachable with its `-httpAuth.username` and `-httpAuth.password` empty (the default), or assuming `-deleteAuthKey` and its siblings authenticate the API when they gate only their own handlers. - Leaving QuestDB's `admin` / `quest` PostgreSQL credential in place, or fronting only the `9000` console while `8812` and `9009` stay open. - Terminating TLS and login at a proxy for the web console while the database and ingest protocols keep answering on their own ports. ## Sources (checked September 2026) - InfluxDB 2.x setup API (`GET /api/v2/setup`, the `allowed` field, `POST` onboarding): https://docs.influxdata.com/influxdb/v2/api/setup/ - InfluxDB 2.x configuration options (`http-bind-address`, `tls-cert`, `tls-key`, `INFLUXD_CONFIG_PATH`): https://docs.influxdata.com/influxdb/v2/reference/config-options/ - InfluxDB 1.x configuration (`[http] auth-enabled` default `false`, `bind-address` `:8086`, backup RPC `127.0.0.1:8088`): https://docs.influxdata.com/influxdb/v1/administration/config/ - InfluxDB 1.x authentication and authorization: https://docs.influxdata.com/influxdb/v1/administration/authentication_and_authorization/ - VictoriaMetrics v1.155.0 single-node (commit 3fec2f72414d00d1acda807c8818d7af90cc966d) [HTTP fallback and startup](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/victoria-metrics/main.go#L91-L115), [registered key routes and dispatch](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/victoria-metrics/main.go#L138-L202), [TCP listener](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/netutil/tcplistener.go#L16-L43) and [IPv4/IPv6 selection](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/netutil/tcplistener.go#L79-L85). - VictoriaMetrics v1.155.0 [HTTP auth and TLS flags](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L38-L57), [prefix and OPTIONS handling](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L385-L419), [built-in exemptions and key routes](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L425-L510), [key precedence and Basic fallback](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L534-L573), and [empty password/key defaults](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/flagutil/password.go#L16-L30). - VictoriaMetrics v1.155.0 [storage key flags and optional RPC address](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmstorage/main.go#L39-L47), [logging key](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmstorage/main.go#L112), [conditional RPC startup](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmstorage/main.go#L185-L198), and [maintenance and snapshot handlers](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmstorage/main.go#L251-L376). - VictoriaMetrics v1.155.0 [query-side key flags](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmselect/main.go#L33-L37), [alias normalization](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmselect/main.go#L99-L111), [rollup-cache reset](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmselect/main.go#L174-L179), and [deletion and metric-name reset](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmselect/main.go#L377-L435). - VictoriaMetrics v1.155.0 [optional ingest listeners and config/reload keys](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vminsert/main.go#L49-L70), [conditional listener startup](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vminsert/main.go#L90-L107), [HTTP ingest routes](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vminsert/main.go#L138-L309), and [configuration, reload and authenticated readiness handlers](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vminsert/main.go#L357-L393). - VictoriaMetrics v1.155.0 (shipped image v1.154.1) [shipped Compose image, publications and listener flags](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/deployment/docker/compose-vm-single.yml#L18-L37). - VictoriaMetrics v1.155.0 raw ingest listeners (no TLS configuration; Basic auth only on OpenTSDB HTTP): [Graphite](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/graphite/server.go#L46-L53), [InfluxDB](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/influx/server.go#L46-L52), [OpenTSDB mixed listener](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/opentsdb/server.go#L49-L60), [OpenTSDB HTTP listener](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/opentsdbhttp/server.go#L36-L36) and [its Basic-auth check](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/opentsdbhttp/server.go#L88-L92). - VictoriaMetrics v1.155.0-cluster HTTP fallback binds: [vminsert](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vminsert/main.go#L110-L113), [vmselect](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L107-L110), [vmstorage](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L171-L174), [RPC startup](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L243-L257); [RPC address defaults and storage auth-key flags](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L50-L72). - VictoriaMetrics v1.155.0-cluster TCP bind implementation and IPv4 default: [listener](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/netutil/tcplistener.go#L16-L43), [network selection](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/netutil/tcplistener.go#L79-L85); optional cluster-native listeners: [vminsert](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vminsert/main.go#L53-L54), [vmselect](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L77-L78). - VictoriaMetrics v1.155.0-cluster RPC handshake: [protocol identifiers](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/handshake/handshake.go#L17-L23), [insert hello check](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/handshake/handshake.go#L77-L109), [select hello check](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/handshake/handshake.go#L137-L158), [compression negotiation without credentials](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/handshake/handshake.go#L203-L233). - VictoriaMetrics v1.155.0-cluster RPC capabilities: [storage writes](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/vmstorage.go#L110-L131), [plaintext select listener](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/vmselectapi/server.go#L96-L103), [RPC dispatch including deletion, registration and reset](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/vmselectapi/server.go#L560-L590), [storage deletion and mutations](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/vmstorage.go#L325-L359). - VictoriaMetrics v1.155.0-cluster insert accept-to-write path: [listener and handshake setup](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/vminsertapi/server.go#L54-L75), [accept, handshake and connection processing](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/vminsertapi/server.go#L78-L144), [legacy and RPC dispatch](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/vminsertapi/server.go#L172-L226), [storage write callbacks](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/vminsertapi/server.go#L255-L267); [nil TLS config returns a plaintext connection](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/netutil/tcplistener.go#L107-L140) (vmstorage passes nil at the RPC startup cited above). - VictoriaMetrics v1.155.0-cluster HTTP auth: [flags and HTTP TLS scope](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/httpserver/httpserver.go#L38-L57), [metrics, flags and pprof routes](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/httpserver/httpserver.go#L461-L510), [endpoint-key precedence and Basic-auth fallback](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/httpserver/httpserver.go#L534-L573). - VictoriaMetrics v1.155.0-cluster password and auth-key defaults: [NewPassword stores an empty string](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/flagutil/password.go#L21-L30), [Get reads it unchanged when no source is configured](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/flagutil/password.go#L54-L68). - VictoriaMetrics v1.155.0-cluster maintenance: [storage handler auth routing](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L298-L307), [merge, flush, logging and snapshots](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L329-L442), [select cache resets](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L271-L298), [rollup-cache key](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/prometheus/prometheus.go#L58-L61), [Graphite deletion](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L582-L596), [Prometheus deletion](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L869-L886). - VictoriaMetrics cluster security recommendations (private network, HTTPS auth proxy and endpoint allowlists): https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#security - vmauth authorization proxy: https://docs.victoriametrics.com/victoriametrics/vmauth/ - QuestDB HTTP server (Enterprise 4.0.0 qualification recorded above; `9000`, `http.user` / `http.password` unset by default): https://questdb.com/docs/configuration/http-server/ - QuestDB PostgreSQL wire protocol (`8812`, default `admin` / `quest`): https://questdb.com/docs/configuration/postgres-wire-protocol/ - QuestDB ingestion / line protocol (`9009`, `line.tcp.auth.db.path` default none): https://questdb.com/docs/configuration/ingestion/ - QuestDB minimal HTTP server (`9003`, `http.health.check.authentication.required` defaults to `true`; `false` permits unauthenticated health checks): https://questdb.com/docs/configuration/http-min-server/ - QuestDB TLS encryption (Enterprise only; `tls.enabled`): https://questdb.com/docs/configuration/tls/ - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - influx2: InfluxDB OSS (2.x) - influx1: InfluxDB OSS (1.x) - single: VictoriaMetrics single-node (v1.155.0) - cluster: VictoriaMetrics cluster (v1.155.0-cluster) - image: VictoriaMetrics shipped Compose image (v1.154.1) - enterprise: QuestDB Enterprise qualification (4.0.0) - rpc: VictoriaMetrics cluster source (e4b1d55683f0da20410dc765787bd4e9b5afa0da) - proxy: VictoriaMetrics security/vmauth documentation (unknown) - single-commit: VictoriaMetrics single-node source commit (3fec2f72414d00d1acda807c8818d7af90cc966d) - quest: QuestDB documentation (unknown) Claims (full source mapping in the JSON block below): - influx2-setup (REASONED; influx2): 8086 shares API/UI/write; private onboarding creates operator user/org/token, setup allowed changes true to false, scoped app tokens. - influx2-tls (REASONED; influx2): INFLUXD_CONFIG_PATH selects config; http-bind-address, tls-cert and tls-key set private HTTPS. - influx1-auth (REASONED; influx1): 8086 auth defaults off; create admin then set bind-address, auth-enabled and HTTPS certificate/key fields. - influx1-other (REASONED; influx1): prom-read-auth-enabled=false by default; set it true with auth-enabled for remote-read auth; 8088 backup RPC defaults loopback; optional unauthenticated ingest stays disabled. - vm-bind (REASONED; single, single-commit): Single-node HTTP fallback :8428, tcp4 default, enableTCP6 false; explicit private bind and TLS flags. - vm-image (REASONED; single, image): Source tag ships Compose using image v1.154.1, wildcard 8428 publication and additional ingest listeners. - vm-basic (REASONED; single): Empty username disables Basic auth even with password; ordinary query/UI/HTTP ingest and aliases share the credential. - vm-exempt (REASONED; single): Health/ping/readiness/robots/favicon, OPTIONS and prefix handling exemptions differ from authenticated ready and influx/health. - vm-keys (REASONED; single): Registered endpoint keys default empty and fall back to Basic; nonempty authKey replaces Basic, wrong/missing gives 401; other aliases may require both. - vm-optional (REASONED; single): Graphite/Influx/OpenTSDB/native-query listeners default disabled; no TLS, and only OpenTSDB HTTP put checks Basic. - cluster-http (REASONED; cluster, rpc): vminsert/vmselect/vmstorage fall back to 8480/8481/8482 on IPv4 wildcard; optional IPv6, empty Basic credentials and HTTP-only TLS. - cluster-rpc (REASONED; cluster, rpc): 8400/8401 wildcard RPC has no credentials/TLS; handshake/compression permits writes, reads, deletion and metadata mutations, outside HTTP auth. - cluster-native (REASONED; cluster, proxy): Optional clusternativeListenAddr is disabled by default; restrict enabled native listeners to trusted cluster peers. - cluster-key-snapshot (REASONED; cluster): vmstorage: -snapshotAuthKey gates /snapshot/create, /snapshot/list, /snapshot/delete and /snapshot/delete_all. - cluster-key-merge (REASONED; cluster): vmstorage: -forceMergeAuthKey gates /internal/force_merge. - cluster-key-flush (REASONED; cluster): vmstorage: -forceFlushAuthKey gates /internal/force_flush. - cluster-key-logging (REASONED; cluster): vmstorage: -logNewSeriesAuthKey gates /internal/log_new_series. - cluster-key-delete (REASONED; cluster): vmselect: -deleteAuthKey gates POSTs to /delete/{accountID}/prometheus/api/v1/admin/tsdb/delete_series and /select/{accountID}/graphite/tags/delSeries. - cluster-key-cache (REASONED; cluster): vmselect: -search.resetCacheAuthKey gates /internal/resetRollupResultCache. - cluster-key-stats (REASONED; cluster): vmselect: -metricNamesStatsResetAuthKey gates /admin/api/v1/admin/status/metric_names_stats/reset. - cluster-key-metrics (REASONED; cluster): All three HTTP components: -metricsAuthKey gates /metrics. - cluster-key-flags (REASONED; cluster): All three HTTP components: -flagsAuthKey gates /flags. - cluster-key-pprof (REASONED; cluster): All three HTTP components: -pprofAuthKey gates /debug/pprof/*. - cluster-precedence (REASONED; cluster, rpc, proxy): Empty keys fall back to Basic; nonempty keys replace it, not add a factor or protect other routes; allowlist proxy routes. - quest-http (REASONED; quest): 9000 HTTP/console/SQL defaults unauthenticated; set http.user/password in server.conf. 9003 health requires auth when HTTP does by default; http.health.check.authentication.required=false disables that health check requirement. - quest-pg (REASONED; quest): 8812 PostgreSQL wire defaults admin/quest; replace pg.user/password and protect its separate listener. - quest-ilp (REASONED; quest): 9009 TCP writes default unauthenticated; line.tcp.auth.db.path selects P-256 public-key file. - quest-tls (REASONED; quest, enterprise): OSS requires proxy TLS; Enterprise uses RBAC/tls.enabled and rejects HTTP credential keys since stated Enterprise 4.0.0. - verify-inventory (REASONED; influx1, single, cluster, quest): Inventory TCP/UDP and every enabled listener; local binds do not prove external isolation. - verify-rpc (REASONED; cluster, rpc, proxy): Actual storage IPs must refuse outside TCP while permitted hosts connect; protocol is not HTTP and failure alone proves no isolation. - verify-http (REASONED; influx2, influx1, single, quest): Setup body, Influx1 database list/401, VM query/401 and Quest SQL/401 discriminate per target; redirects/errors are inconclusive. - verify-extra (REASONED; quest, proxy): Reject default PG credential separately; repeat HTTPS auth and plaintext isolation; console MFA does not protect native protocols. - verify-keys (REASONED; single): Read-only snapshot/list and metrics probes test empty/Basic/endpoint-key cases; health exemptions and optional listeners need separate checks. - key-snapshot (REASONED; single): snapshotAuthKey gates snapshot create/list/delete/delete_all and admin TSDB snapshot. - key-delete (REASONED; single): deleteAuthKey gates Prometheus delete_series and Graphite delSeries aliases. - key-merge (REASONED; single): forceMergeAuthKey gates internal/force_merge. - key-flush (REASONED; single): forceFlushAuthKey gates internal/force_flush. - key-cache (REASONED; single): search.resetCacheAuthKey gates internal/resetRollupResultCache. - key-logging (REASONED; single): logNewSeriesAuthKey gates internal/log_new_series. - key-stats (REASONED; single): metricNamesStatsResetAuthKey gates admin/status/metric_names_stats/reset. - key-config (REASONED; single): configAuthKey gates config and API status/config with Prometheus aliases. - key-reload (REASONED; single): reloadAuthKey gates -/reload and Prometheus alias. - key-metrics (REASONED; single): metricsAuthKey gates metrics. - key-flags (REASONED; single): flagsAuthKey gates flags. - key-pprof (REASONED; single): pprofAuthKey gates debug/pprof/* including trailing slash. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "c749c01383af1105c7ad079f301722bf9f7d8a683a24113641a18aa31aa647e3", "components": { "influx2": { "name": "InfluxDB OSS", "basis": "2.x", "sources": { "sc9e982673a2c": "https://docs.influxdata.com/influxdb/v2/api/setup/", "s51be52d52f2d": "https://docs.influxdata.com/influxdb/v2/reference/config-options/" } }, "influx1": { "name": "InfluxDB OSS", "basis": "1.x", "sources": { "s5b36c54676cf": "https://docs.influxdata.com/influxdb/v1/administration/config/", "s33366bcde097": "https://docs.influxdata.com/influxdb/v1/administration/authentication_and_authorization/" } }, "single": { "name": "VictoriaMetrics single-node", "basis": "v1.155.0", "sources": { "scc6aebe6cdc3": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/victoria-metrics/main.go#L91-L115", "sc82affc59de9": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/victoria-metrics/main.go#L138-L202", "s7a187accc5e7": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/netutil/tcplistener.go#L16-L43", "s4615dcd8fb22": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/netutil/tcplistener.go#L79-L85", "s984fca6a36b1": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L38-L57", "s97464fca7365": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L385-L419", "s68202bb6da1e": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L425-L510", "sa99901bcfdf0": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/httpserver/httpserver.go#L534-L573", "se099ff262b05": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/flagutil/password.go#L16-L30", "s80d416be2b69": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vminsert/main.go#L138-L309", "s11d0abb7ec48": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/deployment/docker/compose-vm-single.yml#L18-L37", "sf537ca3e8518": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vminsert/main.go#L49-L70", "s5ea63e7bf367": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/graphite/server.go#L46-L53", "sb59cd1494904": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/influx/server.go#L46-L52", "sa801df0e0cd9": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/opentsdb/server.go#L49-L60", "sb643edeb3afa": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/lib/ingestserver/opentsdbhttp/server.go#L88-L92", "s9e95e708aab5": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmstorage/main.go#L39-L47", "s5aa6d744b544": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/vmselect/main.go#L33-L37" } }, "cluster": { "name": "VictoriaMetrics cluster", "basis": "v1.155.0-cluster", "sources": { "sb9c05ee73a91": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vminsert/main.go#L110-L113", "s917acf99b191": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L107-L110", "s4d3500bd10df": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L171-L174", "s4e9fcbacb309": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L50-L72", "sae0e9a94ca95": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/netutil/tcplistener.go#L79-L85", "s7ab43405843d": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/handshake/handshake.go#L203-L233", "scf6372175d7b": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/vmselectapi/server.go#L560-L590", "sed011fe70075": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vminsert/main.go#L53-L54", "s84668f7f18ce": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L77-L78", "s22168276316c": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/httpserver/httpserver.go#L38-L57", "s9b7dc7d2a089": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/httpserver/httpserver.go#L534-L573", "s48dccc3608ac": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L329-L442", "s8db09299b6d9": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L271-L298", "s9470eaf64e21": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L582-L596", "s1c0dd8645aa8": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/main.go#L869-L886", "s4a4dcd49cd09": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/lib/httpserver/httpserver.go#L461-L510", "s0f2907547ee4": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmselect/prometheus/prometheus.go#L58-L61", "s0dbf8f9a75dd": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0-cluster/app/vmstorage/main.go#L243-L257" } }, "image": { "name": "VictoriaMetrics shipped Compose image", "basis": "v1.154.1", "sources": { "s11d0abb7ec48": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/deployment/docker/compose-vm-single.yml#L18-L37" } }, "enterprise": { "name": "QuestDB Enterprise qualification", "basis": "4.0.0", "sources": { "s3a0487771c1a": "https://questdb.com/docs/configuration/http-server/" } }, "rpc": { "name": "VictoriaMetrics cluster source", "basis": "e4b1d55683f0da20410dc765787bd4e9b5afa0da", "sources": { "sca91785d878c": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/vminsertapi/server.go#L78-L144", "sdba733140e0c": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/vminsertapi/server.go#L255-L267", "sb17dce5a1339": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/netutil/tcplistener.go#L107-L140", "sa18d26bb1269": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/e4b1d55683f0da20410dc765787bd4e9b5afa0da/lib/flagutil/password.go#L21-L30" } }, "proxy": { "name": "VictoriaMetrics security/vmauth documentation", "basis": "unknown", "sources": { "s185272dd320f": "https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#security", "s79ed3b00108a": "https://docs.victoriametrics.com/victoriametrics/vmauth/" } }, "single-commit": { "name": "VictoriaMetrics single-node source commit", "basis": "3fec2f72414d00d1acda807c8818d7af90cc966d", "sources": { "scc6aebe6cdc3": "https://github.com/VictoriaMetrics/VictoriaMetrics/blob/v1.155.0/app/victoria-metrics/main.go#L91-L115" } }, "quest": { "name": "QuestDB documentation", "basis": "unknown", "sources": { "s3a0487771c1a": "https://questdb.com/docs/configuration/http-server/", "s9c38998f3725": "https://questdb.com/docs/configuration/postgres-wire-protocol/", "se0fda155b33d": "https://questdb.com/docs/configuration/ingestion/", "s19f43451ff2b": "https://questdb.com/docs/configuration/http-min-server/", "s7ca922a2e0f5": "https://questdb.com/docs/configuration/tls/" } } }, "claims": { "influx2-setup": {"text": "8086 shares API/UI/write; private onboarding creates operator user/org/token, setup allowed changes true to false, scoped app tokens.", "components": ["influx2"], "sources": ["influx2:sc9e982673a2c"], "status": "REASONED"}, "influx2-tls": {"text": "INFLUXD_CONFIG_PATH selects config; http-bind-address, tls-cert and tls-key set private HTTPS.", "components": ["influx2"], "sources": ["influx2:s51be52d52f2d"], "status": "REASONED"}, "influx1-auth": {"text": "8086 auth defaults off; create admin then set bind-address, auth-enabled and HTTPS certificate/key fields.", "components": ["influx1"], "sources": ["influx1:s5b36c54676cf", "influx1:s33366bcde097"], "status": "REASONED"}, "influx1-other": {"text": "prom-read-auth-enabled=false by default; set it true with auth-enabled for remote-read auth; 8088 backup RPC defaults loopback; optional unauthenticated ingest stays disabled.", "components": ["influx1"], "sources": ["influx1:s5b36c54676cf"], "status": "REASONED"}, "vm-bind": {"text": "Single-node HTTP fallback :8428, tcp4 default, enableTCP6 false; explicit private bind and TLS flags.", "components": ["single", "single-commit"], "sources": ["single:scc6aebe6cdc3", "single:s7a187accc5e7", "single:s4615dcd8fb22", "single:s984fca6a36b1", "single-commit:scc6aebe6cdc3"], "status": "REASONED"}, "vm-image": {"text": "Source tag ships Compose using image v1.154.1, wildcard 8428 publication and additional ingest listeners.", "components": ["single", "image"], "sources": ["single:s11d0abb7ec48", "image:s11d0abb7ec48"], "status": "REASONED"}, "vm-basic": {"text": "Empty username disables Basic auth even with password; ordinary query/UI/HTTP ingest and aliases share the credential.", "components": ["single"], "sources": ["single:s984fca6a36b1", "single:sa99901bcfdf0", "single:se099ff262b05", "single:s80d416be2b69"], "status": "REASONED"}, "vm-exempt": {"text": "Health/ping/readiness/robots/favicon, OPTIONS and prefix handling exemptions differ from authenticated ready and influx/health.", "components": ["single"], "sources": ["single:s97464fca7365", "single:s68202bb6da1e", "single:s80d416be2b69"], "status": "REASONED"}, "vm-keys": {"text": "Registered endpoint keys default empty and fall back to Basic; nonempty authKey replaces Basic, wrong/missing gives 401; other aliases may require both.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s68202bb6da1e", "single:sa99901bcfdf0", "single:se099ff262b05", "single:sf537ca3e8518", "single:s9e95e708aab5", "single:s5aa6d744b544"], "status": "REASONED"}, "vm-optional": {"text": "Graphite/Influx/OpenTSDB/native-query listeners default disabled; no TLS, and only OpenTSDB HTTP put checks Basic.", "components": ["single"], "sources": ["single:sf537ca3e8518", "single:s5ea63e7bf367", "single:sb59cd1494904", "single:sa801df0e0cd9", "single:sb643edeb3afa", "single:s9e95e708aab5"], "status": "REASONED"}, "cluster-http": {"text": "vminsert/vmselect/vmstorage fall back to 8480/8481/8482 on IPv4 wildcard; optional IPv6, empty Basic credentials and HTTP-only TLS.", "components": ["cluster", "rpc"], "sources": ["cluster:sb9c05ee73a91", "cluster:s917acf99b191", "cluster:s4d3500bd10df", "cluster:s0dbf8f9a75dd", "cluster:sae0e9a94ca95", "cluster:s22168276316c", "rpc:sa18d26bb1269"], "status": "REASONED"}, "cluster-rpc": {"text": "8400/8401 wildcard RPC has no credentials/TLS; handshake/compression permits writes, reads, deletion and metadata mutations, outside HTTP auth.", "components": ["cluster", "rpc"], "sources": ["cluster:s4e9fcbacb309", "cluster:s7ab43405843d", "cluster:scf6372175d7b", "rpc:sca91785d878c", "rpc:sdba733140e0c", "rpc:sb17dce5a1339"], "status": "REASONED"}, "cluster-native": {"text": "Optional clusternativeListenAddr is disabled by default; restrict enabled native listeners to trusted cluster peers.", "components": ["cluster", "proxy"], "sources": ["cluster:sed011fe70075", "cluster:s84668f7f18ce", "proxy:s185272dd320f"], "status": "REASONED"}, "cluster-key-snapshot": {"text": "vmstorage: -snapshotAuthKey gates /snapshot/create, /snapshot/list, /snapshot/delete and /snapshot/delete_all.", "components": ["cluster"], "sources": ["cluster:s48dccc3608ac"], "status": "REASONED"}, "cluster-key-merge": {"text": "vmstorage: -forceMergeAuthKey gates /internal/force_merge.", "components": ["cluster"], "sources": ["cluster:s48dccc3608ac"], "status": "REASONED"}, "cluster-key-flush": {"text": "vmstorage: -forceFlushAuthKey gates /internal/force_flush.", "components": ["cluster"], "sources": ["cluster:s48dccc3608ac"], "status": "REASONED"}, "cluster-key-logging": {"text": "vmstorage: -logNewSeriesAuthKey gates /internal/log_new_series.", "components": ["cluster"], "sources": ["cluster:s48dccc3608ac"], "status": "REASONED"}, "cluster-key-delete": {"text": "vmselect: -deleteAuthKey gates POSTs to /delete/{accountID}/prometheus/api/v1/admin/tsdb/delete_series and /select/{accountID}/graphite/tags/delSeries.", "components": ["cluster"], "sources": ["cluster:s9470eaf64e21", "cluster:s1c0dd8645aa8"], "status": "REASONED"}, "cluster-key-cache": {"text": "vmselect: -search.resetCacheAuthKey gates /internal/resetRollupResultCache.", "components": ["cluster"], "sources": ["cluster:s8db09299b6d9", "cluster:s0f2907547ee4"], "status": "REASONED"}, "cluster-key-stats": {"text": "vmselect: -metricNamesStatsResetAuthKey gates /admin/api/v1/admin/status/metric_names_stats/reset.", "components": ["cluster"], "sources": ["cluster:s8db09299b6d9"], "status": "REASONED"}, "cluster-key-metrics": {"text": "All three HTTP components: -metricsAuthKey gates /metrics.", "components": ["cluster"], "sources": ["cluster:s4a4dcd49cd09"], "status": "REASONED"}, "cluster-key-flags": {"text": "All three HTTP components: -flagsAuthKey gates /flags.", "components": ["cluster"], "sources": ["cluster:s4a4dcd49cd09"], "status": "REASONED"}, "cluster-key-pprof": {"text": "All three HTTP components: -pprofAuthKey gates /debug/pprof/*.", "components": ["cluster"], "sources": ["cluster:s4a4dcd49cd09"], "status": "REASONED"}, "cluster-precedence": {"text": "Empty keys fall back to Basic; nonempty keys replace it, not add a factor or protect other routes; allowlist proxy routes.", "components": ["cluster", "rpc", "proxy"], "sources": ["cluster:s9b7dc7d2a089", "rpc:sa18d26bb1269", "proxy:s185272dd320f", "proxy:s79ed3b00108a"], "status": "REASONED"}, "quest-http": {"text": "9000 HTTP/console/SQL defaults unauthenticated; set http.user/password in server.conf. 9003 health requires auth when HTTP does by default; http.health.check.authentication.required=false disables that health check requirement.", "components": ["quest"], "sources": ["quest:s3a0487771c1a", "quest:s19f43451ff2b"], "status": "REASONED"}, "quest-pg": {"text": "8812 PostgreSQL wire defaults admin/quest; replace pg.user/password and protect its separate listener.", "components": ["quest"], "sources": ["quest:s9c38998f3725"], "status": "REASONED"}, "quest-ilp": {"text": "9009 TCP writes default unauthenticated; line.tcp.auth.db.path selects P-256 public-key file.", "components": ["quest"], "sources": ["quest:se0fda155b33d"], "status": "REASONED"}, "quest-tls": {"text": "OSS requires proxy TLS; Enterprise uses RBAC/tls.enabled and rejects HTTP credential keys since stated Enterprise 4.0.0.", "components": ["quest", "enterprise"], "sources": ["quest:s3a0487771c1a", "enterprise:s3a0487771c1a", "quest:s7ca922a2e0f5"], "status": "REASONED"}, "verify-inventory": {"text": "Inventory TCP/UDP and every enabled listener; local binds do not prove external isolation.", "components": ["influx1", "single", "cluster", "quest"], "sources": ["influx1:s5b36c54676cf", "single:sf537ca3e8518", "cluster:s4e9fcbacb309", "quest:s3a0487771c1a", "quest:s9c38998f3725", "quest:se0fda155b33d"], "status": "REASONED", "verify": [1]}, "verify-rpc": {"text": "Actual storage IPs must refuse outside TCP while permitted hosts connect; protocol is not HTTP and failure alone proves no isolation.", "components": ["cluster", "rpc", "proxy"], "sources": ["cluster:s4e9fcbacb309", "cluster:s7ab43405843d", "rpc:sca91785d878c", "proxy:s185272dd320f"], "status": "REASONED", "verify": [2]}, "verify-http": {"text": "Setup body, Influx1 database list/401, VM query/401 and Quest SQL/401 discriminate per target; redirects/errors are inconclusive.", "components": ["influx2", "influx1", "single", "quest"], "sources": ["influx2:sc9e982673a2c", "influx1:s33366bcde097", "single:sa99901bcfdf0", "quest:s3a0487771c1a"], "status": "REASONED", "verify": [3]}, "verify-extra": {"text": "Reject default PG credential separately; repeat HTTPS auth and plaintext isolation; console MFA does not protect native protocols.", "components": ["quest", "proxy"], "sources": ["quest:s9c38998f3725", "proxy:s185272dd320f"], "status": "REASONED", "verify": [3]}, "verify-keys": {"text": "Read-only snapshot/list and metrics probes test empty/Basic/endpoint-key cases; health exemptions and optional listeners need separate checks.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s68202bb6da1e", "single:sa99901bcfdf0"], "status": "REASONED", "verify": [3]}, "key-snapshot": {"text": "snapshotAuthKey gates snapshot create/list/delete/delete_all and admin TSDB snapshot.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s9e95e708aab5"], "status": "REASONED"}, "key-delete": {"text": "deleteAuthKey gates Prometheus delete_series and Graphite delSeries aliases.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s5aa6d744b544"], "status": "REASONED"}, "key-merge": {"text": "forceMergeAuthKey gates internal/force_merge.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s9e95e708aab5"], "status": "REASONED"}, "key-flush": {"text": "forceFlushAuthKey gates internal/force_flush.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s9e95e708aab5"], "status": "REASONED"}, "key-cache": {"text": "search.resetCacheAuthKey gates internal/resetRollupResultCache.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s5aa6d744b544"], "status": "REASONED"}, "key-logging": {"text": "logNewSeriesAuthKey gates internal/log_new_series.", "components": ["single"], "sources": ["single:sc82affc59de9"], "status": "REASONED"}, "key-stats": {"text": "metricNamesStatsResetAuthKey gates admin/status/metric_names_stats/reset.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:s5aa6d744b544"], "status": "REASONED"}, "key-config": {"text": "configAuthKey gates config and API status/config with Prometheus aliases.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:sf537ca3e8518"], "status": "REASONED"}, "key-reload": {"text": "reloadAuthKey gates -/reload and Prometheus alias.", "components": ["single"], "sources": ["single:sc82affc59de9", "single:sf537ca3e8518"], "status": "REASONED"}, "key-metrics": {"text": "metricsAuthKey gates metrics.", "components": ["single"], "sources": ["single:s68202bb6da1e"], "status": "REASONED"}, "key-flags": {"text": "flagsAuthKey gates flags.", "components": ["single"], "sources": ["single:s68202bb6da1e"], "status": "REASONED"}, "key-pprof": {"text": "pprofAuthKey gates debug/pprof/* including trailing slash.", "components": ["single"], "sources": ["single:s68202bb6da1e"], "status": "REASONED"} } } ```` ====================================================================== ==> jupyter.md ====================================================================== # Jupyter: password and TLS Jupyter lets people write and run code in browser-based notebooks, and JupyterHub hosts notebook servers for many users. This guide helps your AI assistant configure a deployment securely, including passwords on standalone servers, TLS, origin and Host checks kept on, and separate identities and system accounts for Hub users. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/jupyter.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Jupyter Server v2.21.1, JupyterHub 6.2.0 and 5.5.5, jupyter_client 8.6.3, configurable-http-proxy 5.4.0, and NativeAuthenticator 1.3.0; 7 further sources carry no recorded version. Metadata reviewed 2026-11-05; documentation checked September 2026. The guide records 70 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An unauthenticated Jupyter server gives anyone who can reach it arbitrary code execution with the permissions of its server and kernel processes. Running those processes with administrative privileges gives an authenticated attacker those privileges too. Jupyter Server ships with authentication on through a generated token; keep authentication enabled. Setting a password replaces the automatically generated token by default. A token and a password can coexist as alternatives, but neither is a second factor. [Jupyter Server security](https://jupyter-server.readthedocs.io/en/latest/operators/security.html). Keep the default loopback bind when changing other settings. The Server examples below target Jupyter Server 2.18+, including the backend used by JupyterLab and Notebook 7, and were checked against v2.21.1. The multi-user examples belong in `jupyterhub_config.py` and were checked against two JupyterHub releases: 6.2.0, the latest, and 5.5.5, the last 5.x release. Where the 5.5.5 documentation and source read for this guide say the same as 6.2.0, the guide cites 6.2.0 alone; where the two releases differ, it gives both, and a change it describes as new in 6.x does not apply to 5.5.5. Hub manages authentication for its single-user servers; the standalone password workflow below is for independently operated servers. [Server configuration](https://jupyter-server.readthedocs.io/en/latest/other/full-config.html), [Notebook configuration](https://jupyter-notebook.readthedocs.io/en/stable/configuring/config_overview.html), [Hub authenticators](https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html). Run JupyterHub 6.1.0 or later, or 5.5.3 or later on 5.x. GHSA-5jhc-4h3c-q97h, which section 5 quotes, lists every release up to 5.5.2, and 6.0.0 up to but not including 6.1.0, as affected. 5 further advisories name the same patched releases, 5.5.3 and 6.1.0, for all or part of those ranges. Section 11 quotes one of them, GHSA-93gc-h923-mp36. The 6.1.0 changelog says of another, GHSA-hx46-2cv3-mh6g, denial of service via invalid spawner options, "Only this one affects most deployments", and its advisory says "Denial of Service via certain invalid user input in spawner options could prevent subsequent JupyterHub launches and require manual database intervention to remove invalid data." That advisory lists 6.1.0 and 5.5.3 as patched, so this guide's floor includes its fix. As this guide's assessment, the other 3, GHSA-pvm9-48cg-w76j, GHSA-r764-xgvf-jrfj and GHSA-x7xh-7hc7-9436, which concern token expiry limits, SharedPasswordAuthenticator and the user rename API, bear on no setting this guide gives. The 6.1.1 and 5.5.4 changelog entries name no advisory: each fixes the regression in 6.1.0 and 5.5.3 that rejected 64-character tokens. The 6.2.0 and 5.5.5 entries name no advisory either: 6.2.0 "is a small release, fixing some regressions in 6.1", 5.5.5 "fixes a couple more regressions in 5.5.3", and both list "Print exception message thrown in LocalProcessSpawner preexec_fn" (#5584), which section 5 covers. 4 earlier advisories, GHSA-p43p-whwx-q52h, GHSA-c4gm-pwx9-9w8j, GHSA-69wv-m5fw-2fhp and GHSA-mj6q-mp44-53v8, fixed in 5.5.0 to 5.5.2, list no 6.x range as affected; that 6.0.0 and later contain those fixes is not established here from source. The 6.2.0 entry also lists "Truncate username in auth rejection log messages" (#5565) under maintenance; whether that change bears on GHSA-p43p-whwx-q52h is not established here. [JupyterHub 6.2.0 changelog](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md), [JupyterHub 5.5.5 changelog](https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/docs/source/reference/changelog.md), [GHSA-5jhc-4h3c-q97h](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-5jhc-4h3c-q97h), [GHSA-hx46-2cv3-mh6g](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-hx46-2cv3-mh6g), [GHSA-pvm9-48cg-w76j](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-pvm9-48cg-w76j), [GHSA-r764-xgvf-jrfj](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-r764-xgvf-jrfj), [GHSA-x7xh-7hc7-9436](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-x7xh-7hc7-9436). ## 1. Generate the config and set a password Run these commands under the dedicated account that will operate the standalone server: ```bash jupyter server --generate-config # writes ~/.jupyter/jupyter_server_config.py jupyter server password # prompts; stores the hash in jupyter_server_config.json ``` The password command prompts without putting the password in process arguments. [Password setup](https://jupyter-server.readthedocs.io/en/latest/operators/public-server.html). ### Require authentication for unclassified endpoints **Tier 1; Jupyter Server 2.18+ baseline for this guide.** An extension endpoint without an explicit authentication rule can expose functionality even when notebook and kernel routes require login. Add to `~/.jupyter/jupyter_server_config.py`: ```python c.ServerApp.allow_unauthenticated_access = False c.ServerApp.authenticate_prometheus = True ``` The first setting requires authentication for Jupyter handlers without an explicit public-access declaration. Explicitly public handlers remain public. Metrics have their own authentication setting. These controls do not establish that every third-party extension uses Jupyter's authentication machinery; review its routes separately. [Server API reference](https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.html#jupyter_server.serverapp.ServerApp.allow_unauthenticated_access). At the time of writing, the configuration reference still lists `allow_unauthenticated_access` as `True`. Configure it explicitly. The changelog lists the feature under **2.13.0**, as well as repeating it under 2.18.0. Therefore, 2.18+ is this guide's conservative baseline, not the feature's introduction version. The configuration help's reference to a default in 2.0 is not a reliable introduction date. [Configuration reference](https://jupyter-server.readthedocs.io/en/latest/other/full-config.html#ServerApp.allow_unauthenticated_access), [release history](https://jupyter-server.readthedocs.io/en/stable/other/changelog.html). ### Migration from older Notebook configurations Existing examples in this guide already use `ServerApp`. When importing an older configuration, migrate the server settings and then apply the additional Server 2 authentication changes: | Legacy setting | Current setting | |---|---| | `NotebookApp.ip`, `.certfile`, `.keyfile` | Corresponding `ServerApp.*` setting | | `NotebookApp.password` / `ServerApp.password` | `PasswordIdentityProvider.hashed_password` | | `NotebookApp.password_required` / `ServerApp.password_required` | `PasswordIdentityProvider.password_required` | | `NotebookApp.token` / `ServerApp.token` | `IdentityProvider.token` | Some vendor tutorials retain deprecated examples. Use the current reference for the final syntax. Supply credentials through the password prompt or protected configuration, never command-line arguments. [Notebook-server migration](https://jupyter-server.readthedocs.io/en/latest/operators/migrate-from-nbserver.html), [Server configuration reference](https://jupyter-server.readthedocs.io/en/latest/other/full-config.html), [authentication API](https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.auth.html). ## 2. Run the execution service without root privileges **Tier 1; Jupyter Server. The root check applies on Unix-like systems.** Run the standalone server and its local kernels under a dedicated, unprivileged operating-system account. In `~/.jupyter/jupyter_server_config.py`, keep root execution refused: ```python c.ServerApp.allow_root = False ``` Do not bypass this with `--allow-root`. The setting refuses root startup; it does not change the process's account or sandbox notebook code. Grant the account only the filesystem and service access its notebooks require. Apply this privilege requirement to Hub user workloads separately from the privileges required by the selected spawner. [Server startup implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py) (v2.21.1, lines 1000 to 1004 and 3116 to 3126). If terminals are unnecessary, disable them: ```python c.ServerApp.terminals_enabled = False ``` Notebook kernels can still execute shell commands. This removes an unused interface; it does not create an execution boundary. At the time of writing, the generated reference displays `False`, while the implementation dynamically defaults to `True`, subject to terminal availability. Configure it explicitly. [Terminal configuration](https://jupyter-server.readthedocs.io/en/latest/other/full-config.html#ServerApp.terminals_enabled), [implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py) (v2.21.1, lines 1984 to 2000). ## 3. Enable TLS Get a certificate per [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md), then in `~/.jupyter/jupyter_server_config.py`: ```python c.ServerApp.certfile = '/absolute/path/to/cert.pem' c.ServerApp.keyfile = '/absolute/path/to/key.pem' ``` Or per invocation: ```bash jupyter server --certfile=/absolute/path/to/cert.pem --keyfile=/absolute/path/to/key.pem ``` Replace the paths with the actual certificate and private key. Once native TLS is enabled, connect using `https://`; that listener no longer serves ordinary plain HTTP. [Public-server TLS setup](https://jupyter-server.readthedocs.io/en/latest/operators/public-server.html#using-ssl-for-encrypted-communication). ## 4. Exposure rules - Do not set `c.ServerApp.ip = '0.0.0.0'` or use `--ip 0.0.0.0` without the password from step 1 and HTTPS from step 3 in place. - Never leave the standalone server with neither a token nor a password. Disabling the token when a password is configured retains password authentication; disabling both removes native authentication. - An **HTTPS reverse proxy with authentication** ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)) or HTTPS access through [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), in front of a loopback-bound server, is an alternative to native TLS. Authentication alone does not encrypt transport. [Jupyter HTTPS proxy example](https://jupyterhub.readthedocs.io/en/6.2.0/howto/configuration/config-proxy.html). - MFA: the Jupyter password is single-factor. Fronting authentication adds a second factor only when its policy or identity provider requires one. JupyterHub can delegate authentication to an appropriate provider that enforces MFA. Options are in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ### Preserve origin, XSRF, and Host checks **Tier 1; Jupyter Server.** Permissive browser-origin settings or disabled cross-site request forgery checks can let another website misuse an authenticated session. For a same-origin deployment, configure: ```python c.ServerApp.allow_origin = '' c.ServerApp.allow_origin_pat = '' c.ServerApp.allow_credentials = False c.ServerApp.disable_check_xsrf = False ``` Do not address browser or WebSocket errors by allowing every origin, using a permissive origin expression, or disabling XSRF checks. Cross-origin clients need a deliberately reviewed exception. [Server API reference](https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.html). Jupyter Server 2.18.0 fixed GHSA-24qx-w28j-9m6p (CVE-2026-40110), "Jupyter Server has a CORS Origin Validation Bypass via `re.match()` in `allow_origin_pat`" (high; affected `<= 2.17.0`, patched `2.18.0`), so both this guide's 2.18+ target and v2.21.1 are outside its affected range. At v2.21.1, `origin_matches_pat` returns `False` for an empty pattern and otherwise accepts an origin only on `re.fullmatch`, warning when a pattern matched only as a prefix (`utils.py` lines 46 to 65). The empty `allow_origin_pat` above therefore matches no origin. [GHSA-24qx-w28j-9m6p](https://github.com/advisories/GHSA-24qx-w28j-9m6p), [origin pattern check](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/utils.py). Preserve DNS-rebinding protection while allowing the deployment's actual hostname: ```python c.ServerApp.allow_remote_access = False c.ServerApp.local_hostnames = ['localhost', 'jupyter.example.com'] ``` Replace `jupyter.example.com`. Setting `allow_remote_access = True` disables the Host check; adding a known hostname does not require it. This static hostname example is for a standalone server; Hub single-user servers need their actual generated hostnames accounted for. [Host-check configuration](https://jupyter-server.readthedocs.io/en/latest/other/full-config.html#ServerApp.allow_remote_access). Token-authenticated requests bypass origin and XSRF checks by design. Use a cookie-authenticated session, without an authorization token, to test these browser-session protections. [Identity-provider implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/auth/identity.py) (v2.21.1, lines 533 to 555), [request-handler implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/base/handlers.py) (v2.21.1, lines 403 to 408 and 530 to 554). ## 5. Give Hub users individual identities and execution accounts **Tier 1 for multi-user deployments; this example requires JupyterHub 6.1.0 or later, or 5.5.3 or later on 5.x, on Unix, and was checked against 6.2.0 and 5.5.5.** A shared standalone credential lets users execute code with the same account's authority. Use JupyterHub with individual authentication and a separate single-user server for each account. A standalone server password does not provide user isolation. [Public-server scope](https://jupyter-server.readthedocs.io/en/latest/operators/public-server.html). For existing local Unix accounts, put this admission policy in `jupyterhub_config.py`: ```python c.JupyterHub.authenticator_class = 'jupyterhub.auth.PAMAuthenticator' c.Authenticator.allow_all = False c.Authenticator.allow_existing_users = False c.Authenticator.allowed_users = { 'REPLACE_WITH_USER_1', 'REPLACE_WITH_USER_2', } c.PAMAuthenticator.allowed_groups = set() c.JupyterHub.spawner_class = 'jupyterhub.spawner.LocalProcessSpawner' ``` Replace the user placeholders with actual Unix account names. Disabling `allow_existing_users` prevents historical Hub database membership from independently admitting users. An empty `allowed_groups` avoids another admission path through Unix group membership. Other authenticators can extend these rules and require their own review. The 6.2.0 authenticator API page documents the `allow_all`, `allow_existing_users` and `allowed_groups` settings. [Authenticator API 6.2.0](https://jupyterhub.readthedocs.io/en/6.2.0/reference/api/auth.html), [Hub 5 admission changes](https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html#allowing-access). `LocalProcessSpawner` requires matching local Unix accounts. Do not use `SimpleLocalProcessSpawner` in production: it provides no isolation between users. [Spawner API](https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html), [Hub configuration reference](https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html). Run a release with the `LocalProcessSpawner` privilege-drop fix. GHSA-5jhc-4h3c-q97h, "Incomplete privilege drop in LocalProcessSpawner when setgroups() fails" (medium; affected `>=6.0.0, <6.1.0` and `<=5.5.2`, patched `6.1.0` and `5.5.3`), says that "When the `setgroups` syscall fails (rare), supplemental groups may be inherited from the Hub parent process." At 6.2.0, when setting the user's groups fails, the spawner's pre-exec function attempts to clear the supplemental groups with `os.setgroups([])`, a call its comment says "might fail", and only prints `Failed to clear groups` if it does. After `os.setuid(uid)`, if a group outside the user's remains, it prints `Failed to drop groups: {extra_groups}, aborting` to standard error and then aborts with `PermissionError(f"Failed to drop groups: {extra_groups}")` (`spawner.py` lines 1906 to 1935, the clearing attempt and check at lines 1915 to 1935). The print's comment says "preexec_fn swallows the raised exception message so print it", and both changelogs list it as "Print exception message thrown in LocalProcessSpawner preexec_fn" (#5584); 6.1.1 and 5.5.4 raised the same `PermissionError` without printing it. [GHSA-5jhc-4h3c-q97h](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-5jhc-4h3c-q97h), [6.2.0 spawner](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/spawner.py). Local processes are not containers, and `LocalProcessSpawner` does not enforce the generic CPU or memory limits. PAM does not automatically provide MFA; retain an explicitly configured MFA solution where required. [Spawner limitations](https://jupyterhub.readthedocs.io/en/6.2.0/reference/spawners.html#spawners-resource-limits-and-guarantees-optional), [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ### Moving from JupyterHub 5.5.5 to 6.2.0 These 6.x changes bear on this guide's configuration. JupyterHub 6 requires Python 3.10 and includes a database schema upgrade, and the upgrade notes say "you should backup your database and run `jupyterhub upgrade-db` after upgrading and before starting JupyterHub." 6.1 adds `Authenticator.auth_refresh_strict`, `False` by default; its documentation says "Prior to 6.1, the behavior was the same as Authenticator.auth_refresh_strict = True". With `True`, when a user's auth state is stale and cannot be refreshed, admin spawns for other users and API requests from that user's own servers fail until the user completes a fresh login. This guide configures no auth refresh, and the 5.5.5 authenticator API page has no such setting. [Upgrading to JupyterHub 6](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md), [Authenticator API 6.2.0](https://jupyterhub.readthedocs.io/en/6.2.0/reference/api/auth.html), [Authenticator API 5.5.5](https://jupyterhub.readthedocs.io/en/5.5.5/reference/api/auth.html). From 6.2.0 the Hub upgrades its database schema at startup by default. `JupyterHub.upgrade_db` now defaults to `True`, and its help text says "Set to False to require explicit upgrades via `jupyterhub upgrade-db`." and "Only SQLite databases will be backed up to a local file automatically." (6.2.0 `app.py` lines 1644 to 1656). The 6.2.0 changelog says the schema "is now upgraded at startup _by default_, equivalent to `jupyterhub --upgrade-db`", that setting `c.JupyterHub.upgrade_db = False` opts out, and "There is no database schema change in this release." (changelog lines 27 to 41). With the new `--no-upgrade-db` flag, "JupyterHub will abort if the current schema doesn't match.", and the `--upgrade-db` help now says "Usually safe, but always good to backup your database first" (`app.py` lines 149 to 171). 6.2.0 also adds `jupyterhub downgrade-db`, whose description says "Useful prior to rolling back a JupyterHub upgrade." (lines 263 to 311 and 349 to 352). 5.5.5 keeps `upgrade_db` at `False` (5.5.5 `app.py` lines 1501 to 1508). As this guide's reading of that help text and of the startup code, which calls `dbutil.upgrade_if_needed` when `upgrade_db` is set (6.2.0 `app.py` lines 2126 to 2129), a first 6.2.0 start on a 5.x database upgrades its schema unless `upgrade_db` is `False`. `jupyterhub/dbutil.py`, which performs the upgrade and any backup, was not read. Back up the database before that first start, as the upgrade notes say, whether or not `upgrade_db` keeps its default. [6.2.0 application](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py), [6.2.0 changelog](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md), [5.5.5 application](https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/app.py), [Upgrading to JupyterHub 6](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md). JupyterHub 6 adds PKCE to its internal OAuth and the `JupyterHub.oauth_require_pkce` setting, `False` by default for backward compatibility (6.2.0 `app.py` lines 523 to 535); the 5.5.5 `app.py` has no such setting. Its help text says `versionadded:: 6.0`, but the 6.1.0 changelog lists "Tag `oauth_require_pkce` with `config`" (#5533), so as this guide's reading the setting is configurable from 6.1.0, which is this guide's 6.x floor. The upgrade notes say that setting it to `True` "will prevent any OAuth client that does not send PKCE arguments (such as those using OAuth implementations from JupyterHub 5 or before) from completing OAuth." As this guide's inference, enable it only once every single-user server and OAuth service runs a client that sends PKCE. From 6.0, spawn options passed as GET query parameters should take an `opt-` prefix, which the 6.0.0 changelog lists as a breaking change. The upgrade notes say "Passing parameters without the `opt-` prefix will continue to work in most cases", with exceptions: `display_name` is now used by JupyterHub and removed from the spawner's options, so pass `opt-display_name`, and an existing `opt-a` parameter is converted to `a`, so pass `opt-opt-a`. They also say "If any parameter is passed using the `opt-` prefix all parameters must, others will be ignored." and "Other parameters may be used by JupyterHub in future releases. These will not be treated as a breaking change." For `POST /api/users/:username/servers/:servername`, the upgrade notes say the whole JSON body was passed as `user_options` before JupyterHub 6, and JupyterHub 6 moves this to a `user_options` key. They add: "For backward-compatibility, the full body will still be parsed as `user_options`, as long as neither the `user_options` nor `display_name` key is defined." (`upgrading-v6.md` line 130). This guide sets no spawn options. Section 8 covers the Unix-socket proxy support and section 10 the new internal HTTP client. [Upgrading to JupyterHub 6](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md), [6.2.0 application](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py), [6.2.0 changelog](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md). ## 6. Isolate Hub browser origins and protect server configuration **Tier 1 for mutually untrusted users; JupyterHub. The combined example targets Hub 6.1.0 or later, or 5.5.3 or later on 5.x, and host-prefixed cookies require Hub 4.1+.** Separate execution accounts do not separate browser origins. For mutually untrusted users, add to `jupyterhub_config.py`: ```python c.JupyterHub.subdomain_host = 'https://jupyter.example.com' c.JupyterHub.cookie_host_prefix_enabled = True c.Spawner.disable_user_config = True ``` Replace the hostname. Provide DNS and valid HTTPS certificates for the Hub and every generated user hostname, commonly through wildcard DNS and suitable certificate coverage. Use a domain separated from sensitive applications. Hub documents no cross-user protection guarantee without per-user domains. Enable host-prefixed cookies with HTTPS and per-user domain separation. A default Hub is intended for semi-trusted users. [Hub security overview](https://jupyterhub.readthedocs.io/en/stable/explanation/websecurity.html), [cookie setting and version](https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html). Keep the single-user server executable, Python environment, and launch path under administrator control. `disable_user_config` ignores user-home configuration, but users who can replace the server environment can circumvent it. Users may have separate writable kernel environments. These controls complement operating-system or container isolation. [Spawner API](https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html#jupyterhub.spawner.LocalProcessSpawner.disable_user_config), [server environment isolation](https://jupyterhub.readthedocs.io/en/stable/explanation/websecurity.html#isolate-packages-in-a-read-only-environment). ## 7. Verify REASONED: Verification preparation; a live Jupyter Server or JupyterHub stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran all five Bash blocks through bash -n and ShellCheck 0.11.0 with exit 0 and no output, and all fifteen Python configuration blocks passed ASCII, AST and traitlets.Config loading. The bash -n and ShellCheck runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and the listener inventory as a bare ss -tlnp, and those local runs were not repeated on the current forms. In the current forms the HTTP blocks' guard refusals exit with status 2, those blocks exit 1 for a finding and 3 for an inconclusive result, the inventory exits 2 when ss is not installed and 3 when ss fails, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three; stub runs of the inventory's current form are recorded at its heading. Exposed anonymous kernel access must contrast with fixed authentication and a working positive control, per the guide's cited Jupyter Server security and token/password authentication (rolling documentation, checked September 2026). **Service behaviour is REASONED, not demonstrated.** The authoring environment has no Jupyter Server, Notebook, or JupyterHub packages or executables, and no Docker or Podman. It has no available test deployment for the service checks below. These missing capabilities prevent exposed-versus-fixed demonstrations here; they do not waive local checks. Expected service outcomes rest on the cited vendor documentation and pinned implementations. REASONED: Isolated comparison discipline; no live test deployment was exercised, because a live Jupyter Server or JupyterHub stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local HTTP stub fixtures returned CURL_STUB for substituted inputs and refused written placeholders with exit 1 and "substitute every placeholder inside the quotes; not probing". Those stub runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and were not repeated on the current forms, whose guard refusals exit with status 2 and which exit 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover them. These are argument checks, not exposed/fixed service comparisons; per the guide's cited Jupyter Server Host, origin, XSRF, and default authentication implementation, exposed access must become fixed rejection while authenticated positive controls still succeed. Use an isolated test deployment for exposed configurations. Keep its versions, extensions, request paths, credentials, and test data consistent while changing the control under test. Never weaken a production deployment to create the exposed comparison. A rejection counts only when the positive control succeeds and the exposed comparison demonstrates that the request can reach the relevant functionality. REASONED: Complete-paste and substitution assumptions; a live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. bash -n and ShellCheck 0.11.0 returned 0 without output for both HTTP blocks. Stubbed bash -u runs rejected written placeholders with "substitute every placeholder inside the quotes; not probing", bad markers with "paste the whole block, including its set -- line; not probing", and short value lists with the corresponding exact-value-count refusal, all exit 1 before curl; valid browser and cookie inputs printed one and four CURL_STUB lines with exit 0. These bash -n, ShellCheck and stub runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and the stub runs were not repeated on the current forms, whose guard refusals exit with status 2 and which exit 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover them. These checks do not test a fragment starting below the guards or a caller already holding the expected marker and values; per the guide's cited curl options, stdin headers, cookie files, and diagnostic variables, the guarded requests still require a live deployment for exposed access versus fixed rejection. Paste complete Bash blocks. Substitute values **inside the single quotes** on each `set --` line; values containing a literal apostrophe require proper shell quoting. The guards assume genuine shell builtins. A fragment pasted below a guard is unguarded, and inherited arguments identical to the marker and expected values cannot be distinguished from a complete paste. ### TLS, anonymous access, and authenticated access (REASONED: TLS, anonymous and authenticated API access, token stdin handling, browser login and wrong Host. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran all five Bash blocks through bash -n and ShellCheck 0.11.0: exit 0 with no output; all fifteen Python configuration blocks passed ASCII, AST and traitlets.Config loading. With external commands stubbed, both written HTTP placeholders exited 1 with "substitute every placeholder inside the quotes; not probing"; valid browser and cookie fixtures exited 0 with one and four CURL_STUB lines respectively. Invalid marker, count, empty, placeholder, angle-bracket, example-host, control-character, scheme, URL and file fixtures exited 1 before curl. Seven token fixtures produced three CURL_STUB lines for the valid dummy token; empty, placeholder and angle-bracket tokens printed "a real token is required; not probing", and LF, CR and tab tokens printed "token contains a control character; not probing", after only the anonymous stub call. The valid token was absent from argv and present in stdin headers. These bash -n, ShellCheck and stub runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, the bash -n and ShellCheck runs also covered the listener inventory as a bare ss -tlnp, and the stub runs were not repeated on the current forms. In the current forms the HTTP blocks' guard refusals exit with status 2, those blocks exit 1 for a finding and 3 for an inconclusive result, the inventory exits 2 when ss is not installed and 3 when ss fails, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three; stub runs of the inventory's current form are recorded at its heading. Local openssl certificate verification printed OK for localhost and error 62, hostname mismatch, for wrong.internal; this does not test curl or Jupyter TLS. Browser sessions and a live fronting authentication route remain untested; expected exposed access and fixed TLS/authentication/Host outcomes are the comparisons in this subsection, per the guide's cited sources this declaration lists in two parts. The guide's cited sources this declaration lists (1 of 2): Jupyter Server public server guide and password/TLS setup (rolling documentation, checked September 2026); Jupyter Server security and token/password authentication (rolling documentation, checked September 2026); Jupyter Server Host, origin, XSRF, and default authentication implementation. The guide's cited sources this declaration lists (2 of 2): Jupyter Server REST API (rolling documentation, checked September 2026); curl TLS certificate and hostname verification; curl options, stdin headers, cookie files, and diagnostic variables.) **REASONED: no Jupyter runtime or test deployment in the authoring environment.** Use the actual external URL of `/api/kernels`, including any deployment prefix. The first request checks TLS and anonymous API access together. Use a URL without credentials or token query parameters. Supply the actual CA bundle path: the system CA bundle for a public certificate, the private CA bundle for a private certificate, or the trusted certificate from step 3 for a self-signed deployment. Request the hostname covered by the certificate. Certificate verification must remain enabled. These commands use curl 7.75.0+ for `exitcode` and `errormsg`. [curl certificate verification](https://curl.se/docs/sslcerts.html), [curl options](https://curl.se/docs/manpage.html). Leave the final value as `browser` for a password-only server. If a token is already configured, change it to `token`; Python prompts for it, and the shell feeds the authorization header to curl on stdin. Do not add a token merely to replace the password/browser positive control. The block disables shell tracing and avoids putting the token in shell history or curl arguments; it does not conceal credentials from the account owner or other session-recording mechanisms. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x set +e # errexit off here, so each result below is recorded instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PROTECTED_HTTPS_URL' 'REPLACE_WITH_CA_BUNDLE' 'browser' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } ( while [ "$#" -gt 0 ]; do case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; *[[:cntrl:]]*) echo "control character in input; not probing"; exit 2 ;; esac shift done ) || exit 2 case "$1" in https://*) ;; *) echo "HTTPS URL required; not probing"; exit 2 ;; esac case "$1" in *'@'*|*'?'*|*'#'*|*' '*) echo "use a URL without credentials, query, fragment or spaces; not probing"; exit 2 ;; esac case "$2" in /*) ;; *) echo "absolute CA bundle path required; not probing"; exit 2 ;; esac [ -f "$2" ] || { echo "CA bundle is not a readable file; not probing"; exit 2; } [ -r "$2" ] || { echo "CA bundle is not a readable file; not probing"; exit 2; } case "$3" in browser|token) ;; *) echo "choose browser or token; not probing"; exit 2 ;; esac case "$3" in token) command -v python3 > /dev/null || { echo "python3 is required for the token prompt; not probing"; exit 2; } ;; esac case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; *) set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a refused token), which outranks 0 # REASONED: no Jupyter runtime or test deployment in the authoring environment. # This request checks certificate validation AND anonymous API access. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" -w '\nanonymous=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then its output printf '%s' "$6" case "$5:$6" in 0:*'anonymous=403 '*) ;; 0:*'anonymous=2'??' '*) echo "FINDING: the anonymous request was answered with a 2xx, so this URL is reachable without credentials"; set -- "$1" "$2" "$3" 1 ;; 0:*'anonymous=3'??' '*|0:*'anonymous=401 '*) echo "unclassified observation: a redirect or 401 challenge, which is the fixed result only if it comes from your login layer; judge it" ;; 0:*) echo "the anonymous request was answered with neither the native 403 nor a login redirect; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the validated HTTPS request did not complete; a plaintext listener, a certificate that fails validation and a connection failure all end this way, so read err= above; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # Password-only deployments: perform the browser positive control described below. if [ "$3" = token ]; then ( set -- "$(if python3 -c 'import getpass; print(getpass.getpass("Jupyter token: "))'; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$1" "$2" set -- "${1%$'\n'*}" "$2" "$3" "${1##*$'\n'}" set -- "${1%$'\n'}" "$2" "$3" "$4" # the token without print's line ending, the URL, the CA bundle, then python3's exit status [ "$4" = 0 ] || { echo "the token prompt failed (python3 exit $4), so the token requests did not run; inconclusive"; exit 3; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|"") echo "a real token is required; not probing"; exit 2 ;; esac case "$1" in *[[:cntrl:]]*) echo "token contains a control character; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" 0 # the 4th value is now this part's status so far, ranked as above # REASONED: no Jupyter runtime or test deployment in the authoring environment. set -- "$1" "$2" "$3" "$4" "$(if printf 'Authorization: token %s\n' "$1" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$3" --header @- \ -w '\nauthenticated=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" printf '%s' "$6" case "$5:$6" in 0:*'authenticated=200 '*) ;; 0:*) echo "the token request did not return 200, so the authenticated positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the token request failed, so the authenticated positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac # REASONED: no Jupyter runtime or test deployment in the authoring environment. # The deliberately wrong Host below is a non-secret test value, not the destination. set -- "$1" "$2" "$3" "$4" "$(if printf 'Authorization: token %s\n' "$1" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$3" --header @- --header 'Host: rebinding.example.com' \ -w '\nwrong-host=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" printf '%s' "$6" case "$5:$6" in 0:*'wrong-host=403 '*) ;; 0:*'wrong-host=2'??' '*) echo "FINDING: the token request with a wrong Host reached the API"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "the wrong-Host request was answered with neither the native 403 nor a 2xx; a fronting layer's rejection shows only its own rejection; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the wrong-Host request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac exit "$4" ) case "$?:$4" in 0:*) ;; 1:*) set -- "$1" "$2" "$3" 1 ;; 2:0) set -- "$1" "$2" "$3" 2 ;; 2:*|*:1) ;; *) set -- "$1" "$2" "$3" 3 ;; esac # fold the token part's status into the block's, ranked as above fi echo "block exit $4; the response bodies, any login redirect or challenge and, without a token, the browser positive control described below are not classified, so judge them yourself" exit "$4" ;; esac ) ``` **No Jupyter runtime or browser session here.** In a clean browser profile, visit the same deployment without a token query or existing cookies. It must require authentication before loading a notebook. Log in with the configured password, then request the same `/api/kernels` URL: expect `200` with a JSON kernel list, including an empty list when no kernels are running. An existing session legitimately skips the login prompt. [Server authentication](https://jupyter-server.readthedocs.io/en/latest/operators/security.html), [kernels API](https://jupyter-server.readthedocs.io/en/stable/developers/rest-api.html). Use these exposed-versus-fixed comparisons: | Check | Exposed comparison | Fixed result and positive control | |---|---|---| | TLS | A known-running plaintext listener or a certificate failing trust/hostname validation cannot complete the validated HTTPS request. | HTTPS completes with `exit=0`. An HTTP error status can still accompany successful TLS. A connection failure alone proves no protection. | | Native API authentication | With both native credentials absent in the isolated fixture, anonymous `GET /api/kernels` returns kernel JSON. | Anonymous access returns native `403`; authenticated access returns `200` and kernel JSON. | | Fronting authentication | Demonstrate that the exposed external route returns kernel data anonymously. | Anonymous access yields no kernel data; a login redirect may replace native `403`. Complete all required login layers and obtain authenticated kernel JSON. | | Host check | With `allow_remote_access = True` in the isolated fixture, the token-authenticated wrong-Host request reaches the kernel API. | With the step 4 settings, wrong Host is refused with native `403`, while the normal-Host token request succeeds. | Native protected-API `403` and Host-check behaviour come from the [handler implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/base/handlers.py) (v2.21.1, lines 598 to 658 and 782 to 789). If a fronting layer rejects the wrong Host in both states, that demonstrates its rejection only; the Server Host-check comparison remains REASONED from that implementation, not demonstrated. ### Metrics and extension routes (REASONED: Metrics and unclassified extension routes. A live stand-up of Jupyter Server with representative extensions opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The reused HTTPS block passed bash -n and ShellCheck 0.11.0 without output; stub fixtures rejected placeholders with exit 1 and reached curl for substituted input. Those runs covered the TLS and API access block in an earlier form, whose guard refusals exited with status 1, and were not repeated on the current form, whose guard refusals exit with status 2 and which exits 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. No installed extension route or authenticated deployment was exercised. Expect anonymous metrics with authenticate_prometheus False, refusal with True and an authenticated metrics body; expect an otherwise unclassified route to require login only with allow_unauthenticated_access False, retaining its authenticated body. Explicitly public routes need separate review, per the guide's cited Jupyter Server API base handler, protected API responses, and metrics enforcement; Jupyter Server API reference for authentication, origin, Host, root, and terminal controls (rolling documentation, checked September 2026); curl options, stdin headers, cookie files, and diagnostic variables.) **REASONED: no Jupyter runtime, installed extension routes, or test deployment here.** Repeat the guarded HTTPS block with the actual Server `/metrics` URL, then each installed extension's documented, read-only protected URL. Complete the authenticated positive control for every route. Metrics should return their expected metrics body after authentication, rather than kernel JSON. An absent route, login page, or generic error is not that positive control. For the metrics comparison, set `authenticate_prometheus = False` only in the isolated exposed fixture: anonymous metrics should become available. Restore `True`: anonymous metrics should be refused and authenticated metrics should remain available. [Metrics handler](https://github.com/jupyter-server/jupyter_server/blob/v2.21.1/jupyter_server/base/handlers.py) (v2.21.1, lines 1202 to 1214). To demonstrate `allow_unauthenticated_access`, identify an extension route using Jupyter's handler machinery without an explicit authentication decorator or public-access declaration. With `True`, its anonymous GET is permitted; with `False`, login is required and its authenticated response remains available. Record the actual route and expected body. If no such route is installed, this feature check remains REASONED from the cited authentication-enforcement documentation; testing an already protected kernel route does not replace it. Explicitly public routes require a separate review of whether their exposure is intentional. [Default authentication enforcement](https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.html#jupyter_server.serverapp.ServerApp.allow_unauthenticated_access). ### Cookie-session XSRF and origin checks (REASONED: Cookie/header preparation, cookie GET, XSRF POST, origin/CORS comparisons and kernel cleanup. A live stand-up of Jupyter Server with a disposable default kernel opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No authenticated browser session was exercised. Local marking review ran all five Bash blocks through bash -n and ShellCheck 0.11.0: exit 0 with no output; all fifteen Python configuration blocks passed ASCII, AST and traitlets.Config loading. With external commands stubbed, both written HTTP placeholders exited 1 with "substitute every placeholder inside the quotes; not probing"; valid browser and cookie fixtures exited 0 with one and four CURL_STUB lines respectively. Invalid marker, count, empty, placeholder, angle-bracket, example-host, control-character, scheme, URL and file fixtures exited 1 before curl. These bash -n, ShellCheck and stub runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, the bash -n and ShellCheck runs also covered the listener inventory as a bare ss -tlnp, and the stub runs were not repeated on the current forms. In the current forms the HTTP blocks' guard refusals exit with status 2, those blocks exit 1 for a finding and 3 for an inconclusive result, the inventory exits 2 when ss is not installed and 3 when ss fails, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three; stub runs of the inventory's current form are recorded at its heading. Python json.loads parsed the POST body as an empty object; dummy Netscape cookie and X-XSRFToken files exercised file arguments only. Expected exposed POST creates a kernel without XSRF and wrong-origin GET returns data; fixed missing XSRF gives 403, valid XSRF gives 201, wrong-origin GET gives 404 and same-origin GET gives 200. Browser CORS and authenticated UI cleanup require the live session, per the guide's cited sources this declaration lists in two parts. The guide's cited sources this declaration lists (1 of 2): Jupyter Server identity provider and token-authenticated origin exception; Jupyter Server Host, origin, XSRF, and default authentication implementation; Jupyter Server kernel API implementation. The guide's cited sources this declaration lists (2 of 2): JupyterLab kernel and terminal management; Tornado XSRF cookies and request headers; curl options, stdin headers, cookie files, and diagnostic variables.) **REASONED: no Jupyter runtime, authenticated browser session, or test deployment here.** Use a disposable standalone test server with a working default kernel. Authenticate through its password login and prepare two account-private files: - A Netscape-format cookie jar containing that session's authentication and `_xsrf` cookies. - A header file containing only `X-XSRFToken: ` followed by the matching `_xsrf` value. Keep both files readable only by the testing account and remove them after testing. Do not put their contents on command lines. The header name is documented by [Tornado's XSRF protection](https://www.tornadoweb.org/en/stable/guide/security.html); curl supports cookie and header files. [curl options](https://curl.se/docs/manpage.html). Set the base URL to the actual server URL, including any deployment prefix. Set the same-origin value to its scheme and authority, including a non-default port, with no path or trailing slash. Do not supply an authorization token: that would bypass the protection being tested. ```bash ( set +x set +e # errexit off here, so each result below is recorded instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_SERVER_HTTPS_BASE_URL' 'REPLACE_WITH_CA_BUNDLE' \ 'REPLACE_WITH_COOKIE_JAR' 'REPLACE_WITH_XSRF_HEADER_FILE' 'REPLACE_WITH_SAME_ORIGIN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "the set -- line needs exactly 5 values; not probing"; exit 2; } ( while [ "$#" -gt 0 ]; do case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; *[[:cntrl:]]*) echo "control character in input; not probing"; exit 2 ;; esac shift done ) || exit 2 case "$1" in https://*) ;; *) echo "HTTPS base URL required; not probing"; exit 2 ;; esac case "$5" in https://*) ;; *) echo "HTTPS origin required; not probing"; exit 2 ;; esac case "$1$5" in *'@'*|*'?'*|*'#'*|*' '*) echo "use URLs without credentials, query, fragment or spaces; not probing"; exit 2 ;; esac case "$2" in /*) ;; *) echo "absolute CA bundle path required; not probing"; exit 2 ;; esac case "$3" in *'='*) echo "cookie file path must not contain =; not probing"; exit 2 ;; esac case "$3" in /*) ;; *) echo "absolute cookie file path required; not probing"; exit 2 ;; esac case "$4" in /*) ;; *) echo "absolute XSRF header file path required; not probing"; exit 2 ;; esac [ -f "$2" ] || { echo "a required file is unreadable; not probing"; exit 2; } [ -r "$2" ] || { echo "a required file is unreadable; not probing"; exit 2; } [ -f "$3" ] || { echo "a required file is unreadable; not probing"; exit 2; } [ -r "$3" ] || { echo "a required file is unreadable; not probing"; exit 2; } [ -f "$4" ] || { echo "a required file is unreadable; not probing"; exit 2; } [ -r "$4" ] || { echo "a required file is unreadable; not probing"; exit 2; } set -- "${1%/}" "$2" "$3" "$4" "$5" case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; *) set -- "$1" "$2" "$3" "$4" "$5" 0 # the 6th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # REASONED: no Jupyter runtime, browser session or test deployment here. # Positive control: a live cookie session must retrieve kernel JSON. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --cookie "$3" --header "Origin: $5" \ -w '\ncookie-get=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/api/kernels"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" # curl's exit status, then its output printf '%s' "$8" case "$7:$8" in 0:*'cookie-get=200 '*) ;; 0:*) echo "the cookie GET did not return 200, so the session positive control failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the cookie GET failed, so the session positive control failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # REASONED: no Jupyter runtime, browser session or test deployment here. # Negative XSRF case: same session and origin, but no X-XSRFToken header. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --cookie "$3" --header "Origin: $5" \ --header 'Content-Type: application/json' --data-binary '{}' \ -w '\nmissing-xsrf=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/api/kernels"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" printf '%s' "$8" case "$7:$8" in 0:*'missing-xsrf=403 '*) ;; 0:*'missing-xsrf=2'??' '*) echo "FINDING: the POST without an XSRF header was accepted; record and shut down any kernel it created"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:*) echo "the POST without an XSRF header was answered with neither 403 nor a 2xx; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the POST without an XSRF header failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # REASONED: no Jupyter runtime, browser session or test deployment here. # Positive XSRF control: creates a disposable kernel; record its returned id. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --cookie "$3" --header "Origin: $5" --header "@$4" \ --header 'Content-Type: application/json' --data-binary '{}' \ -w '\nvalid-xsrf=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/api/kernels"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" printf '%s' "$8" case "$7:$8" in 0:*'valid-xsrf=201 '*) ;; 0:*) echo "the POST with the matching XSRF header did not return 201, so the XSRF positive control failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the POST with the matching XSRF header failed, so the XSRF positive control failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # REASONED: no Jupyter runtime, browser session or test deployment here. # Wrong Origin, same authenticated session. This header is not a destination. set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --cacert "$2" --cookie "$3" --header 'Origin: https://cross-origin.example.com' \ -w '\nwrong-origin=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/api/kernels"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%$'\n'*}" printf '%s' "$8" case "$7:$8" in 0:*'wrong-origin=404 '*) ;; 0:*'wrong-origin=2'??' '*) echo "FINDING: the wrong-Origin cookie GET was answered with a 2xx"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:*) echo "the wrong-Origin cookie GET was answered with neither the native 404 nor a 2xx; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the wrong-Origin cookie GET failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac echo "block exit $6; the response bodies, the kernel ids to record and shut down, and the browser CORS headers are not classified, so judge them yourself" exit "$6" ;; esac ) ``` **The same missing runtime and session prevent these comparisons here.** | Check | Exposed comparison | Fixed result and positive control | |---|---|---| | XSRF | With `disable_check_xsrf = True` only in the isolated fixture, the same-origin POST without the XSRF header creates a kernel. | With `False`, missing XSRF produces `403`; the same session with the matching header creates a kernel with `201`. The cookie GET must also succeed. | | Origin | With `allow_origin = '*'` only in the isolated fixture, the wrong-Origin cookie GET returns kernel JSON. | With the step 4 origin settings, the wrong-Origin API request is rejected; current native `APIHandler` uses `404`. The same-origin cookie GET returns `200` and kernel JSON. | The POST body selects the default kernel, and successful creation returns `201`. [Kernel handler](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/services/kernels/handlers.py) (v2.21.1, lines 44 to 66). XSRF and origin outcomes follow the [request-handler checks](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/base/handlers.py) (v2.21.1, lines 530 to 554 and 751 to 758). A `403` or `404` in both states proves nothing about the intended control. Inspect the response and server logs, and require the positive controls above. Curl does not enforce browser CORS rules: also inspect a cookie-authenticated browser request and confirm that the fixed deployment does not grant cross-origin access through `Access-Control-Allow-Origin` or `Access-Control-Allow-Credentials`. [Origin configuration](https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.html). Record every kernel ID created in either state and shut down those disposable kernels through the authenticated UI. [JupyterLab kernel management](https://jupyterlab.readthedocs.io/en/stable/user/running.html). ### Execution privileges, terminals, and Hub separation (REASONED: Root refusal, terminal availability, Hub admission, distinct execution accounts, browser origins/cookies and protected user configuration. A live Jupyter Server or JupyterHub stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Separate Unix test accounts and browser sessions were not exercised. All fifteen Python configuration blocks passed ASCII, AST and traitlets.Config loading; this does not validate runtime traits. The reusable HTTPS block passed bash -n and ShellCheck 0.11.0 without output and its written placeholders exited 1 without calling curl. Those runs covered the TLS and API access block in an earlier form, whose guard refusals exited with status 1, and were not repeated on the current form, whose guard refusals exit with status 2 and which exits 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Expected permissive root startup, terminals, extra users, shared UIDs/origins and writable user overrides contrast with the fixed refusal, disabled terminals, admission allowlist, distinct UIDs/origins, host-prefixed cookies and protected configuration, retaining the positive controls in each row, per the guide's cited sources this declaration lists in two parts. The guide's cited sources this declaration lists (1 of 2): Jupyter Server startup, dynamic defaults, and root refusal implementation; JupyterHub 6.2.0 authenticator API and PAM admission settings; JupyterHub 6.2.0 configuration reference. The guide's cited sources this declaration lists (2 of 2): JupyterHub spawner API and user-configuration limitations (rolling documentation, checked September 2026); JupyterHub browser security, per-user domains, cookies, and server environments (rolling documentation, checked September 2026); curl options, stdin headers, cookie files, and diagnostic variables.) Each check below is **REASONED: Jupyter/JupyterHub runtimes and the required test accounts or browser sessions are unavailable here**. | Check | Concrete demonstration to perform | Exposed versus fixed outcome | |---|---|---| | Execution account and root refusal | On an isolated Unix fixture, start `jupyter server` as UID 0 with the step 2 configuration. Compare with root execution explicitly allowed in that fixture. Start the fixed server under its unprivileged account. Inspect server/kernel ownership using `ps -eo pid,ppid,euid,egid,comm`; in a notebook evaluate `import os; print(os.geteuid())`. | The exposed root process can start; the fixed root launch refuses startup. The fixed unprivileged server starts and its local kernel has the intended nonzero UID. | | Disabled terminals | With terminal support installed, request authenticated `/api/terminals` using the guarded HTTPS block before and after disabling terminals. Keep authenticated `/api/kernels` as the service positive control. | Enabled terminals provide their interface; disabled terminals do not. Kernel execution still works, including shell execution from notebook code. | | Hub admission | Through `/hub/login`, test an allowed Unix account and a valid Unix account outside the allowlist, including an ordinary historical Hub database user. Establish the latter's valid credentials in the isolated permissive fixture. | Permissive admission accepts the extra account; the fixed policy refuses it while an allowed account can log in and start a server. | | Hub execution accounts | Start servers for two admitted users. Compare their server process ownership and run `import os; print(os.geteuid())` in each local kernel. | A shared-account fixture uses the same authority; the fixed local-account deployment uses distinct intended Unix UIDs. | | Hub browser origins and cookies | In each user's own server tab, inspect `location.origin`. Inspect Hub and single-user authentication cookies in browser developer tools. | A single-domain fixture shares an origin; fixed user servers have distinct HTTPS origins. Host-prefixed authentication cookies use the `__Host-` prefix, `Secure`, path `/`, and no `Domain` attribute. | | Hub user configuration and environment | In a disposable user account, place `c.ServerApp.terminals_enabled = True` in its user configuration while the administrator disables terminals, then restart its server. Compare loading versus disabling user configuration. Check write permissions on the server executable, environment, and launch-path directories as that user. | The exposed fixture loads the user override or permits server replacement. The fixed deployment ignores the user configuration and denies writes to the administrator-controlled server environment; an ordinary notebook remains usable. | Root refusal and terminal behaviour are documented in the [Server implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py) (v2.21.1, lines 1984 to 2000 and 3116 to 3126). Hub comparisons follow its [admission rules](https://jupyterhub.readthedocs.io/en/6.2.0/reference/api/auth.html), [local spawner behaviour](https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html), [browser security requirements](https://jupyterhub.readthedocs.io/en/stable/explanation/websecurity.html), and [host-prefixed cookie configuration](https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html). ### Listener inventory and external reachability (REASONED: Listener inventory, port selection and external reachability. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 returned 0 without output for ss -tlnp; executing ss -tlnp returned 0 but printed "Cannot open netlink socket: Operation not permitted" and the inventory header. That denied inventory was not retried and proves no listener state. Those runs covered an earlier form of the block, a bare ss -tlnp that ended with ss's own status; the current form, which exits 2 when ss is not installed, 3 when ss fails and otherwise prints a closing line and exits 0, was run only with ss replaced by stubs, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Stub runs of the current form (October 2026, bash 5.3.9, under inherited set -e, a readonly variable and a declare -i variable) exited 0 with a stub ss that passed, 3 with one that failed, and 2 with no ss on PATH, printing "ss is not installed here; not listing sockets". A second host and working external endpoint were not exercised. Expect the exposed published backend to answer and the fixed loopback backend to be private while the public positive control answers; actual ports must be inventoried rather than assumed, per the guide's cited Jupyter Server startup, dynamic defaults, and root refusal implementation; Jupyter Server default port constant; curl options, stdin headers, cookie files, and diagnostic variables.) **REASONED for the deployed service: no Jupyter runtime is available here.** The locally feasible inventory command was attempted, but the authoring sandbox reported `Cannot open netlink socket: Operation not permitted`. Its otherwise empty output is not evidence that no listeners exist. ```bash # Deployed listener inventory; no Jupyter runtime here, and the recorded netlink attempt was denied. Expected binds and external controls follow the cited Server sources. ( # a subshell, so the exit below ends only the subshell; its status is this check's alone: 2 if ss is not installed, 3 if ss fails, otherwise 0 command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp || # read every listener; identify the actual Jupyter Server listener and its intended loopback bind { echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; exit 3; } echo "unclassified observation: read every listener in the table above, identify the actual Jupyter Server listener and confirm its intended loopback bind; this inventory tests no firewall or forwarding" exit 0 ) ``` Run this in the deployment's network namespace and identify the actual server listener. It inventories listeners; it does not test a firewall or forwarding. Confirm the intended loopback bind, then repeat the guarded HTTPS probe from another host against the actual external endpoint. In an exposed fixture, a listener published externally can answer; in the fixed private-backend arrangement, only the intended public entry point should be reachable. A backend timeout without a working public positive control is inconclusive. The standalone port normally starts at 8888 but can change through configuration or port retries. Do not assume it from an example. [Server port implementation](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py) (v2.21.1, lines 1062 to 1087 and 2721 to 2739), [default port constant](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/__init__.py) (v2.21.1, line 12). ### Verify the reverse-proxy path prefix (REASONED: Prefixed Server and Hub routes, unprefixed aliases and authenticated WebSocket positive controls. A live stand-up of Jupyter Server or JupyterHub with configurable-http-proxy opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The base_url and bind_url Python blocks parsed and loaded into traitlets.Config; output was PASS. No proxy or spawned user session was exercised. Expect the inconsistent fixture to expose an alias outside the prefix, and the fixed fixture to retain only intended prefixed application access with working authentication and WebSockets, per the guide's cited JupyterHub 6.2.0 proxy configuration and path prefix; configurable-http-proxy 5.4.0 command-line options; Jupyter Server v2.21.1 application definition and HTTP-server construction.) **REASONED:** no Jupyter Server, JupyterHub, or configurable-http-proxy is running in the authoring environment. In an isolated fixture, compare the consistent-prefix configuration from section 8 against a deployment where only part of the stack carries the prefix. Request the prefixed application routes (for a standalone Server, `GET /jupyter/api/kernels`; for JupyterHub, `GET /jupyter/hub/login` and a spawned user's actual prefixed URL) and, separately, probe any unprefixed alias (`GET /api/kernels`, `GET /hub/login`). The fixed configuration keeps every application route behind the intended authentication and routing boundary and leaves no unprefixed alias serving the application, while a bare `404` proves neither authentication nor a working deployment, so confirm authenticated access and a working kernel WebSocket on the prefixed path as the positive control. See the [JupyterHub proxy configuration](https://jupyterhub.readthedocs.io/en/6.2.0/howto/configuration/config-proxy.html). ### Verify forwarded-header trust (REASONED: Spoofed forwarded address/scheme/Forwarded headers, genuine Host and a spoofed X-Forwarded-Host, direct backend access and login/WebSocket controls. A live stand-up of Jupyter Server or JupyterHub behind a real ingress opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Python ingress-related configuration parsed and loaded into traitlets.Config; output was PASS. No real ingress, external network vantage or browser cookie session was exercised. Expect spoofed address/scheme to take effect with pass-through headers or reachable trusted backends, but not behind the private sanitizing ingress; host-prefixed cookies remain Secure unless cookie_options overrides them, except the single-user OAuth state cookie, whose Secure flag follows JUPYTERHUB_PUBLIC_URL or the detected scheme or, when neither sets it, a cookie_options entry. A spoofed X-Forwarded-Host needs its own check, because its handling is not established at 6.2.0 or 5.5.5, and genuine Host needs its own rejection check, per the guide's cited sources this declaration lists in two parts. The guide's cited sources this declaration lists (1 of 2): JupyterHub 6.2.0 configuration reference; JupyterHub 6.2.0 configuration reference (trusted_downstream_ips, public_url, internal_ssl); Jupyter Server v2.21.1 application definition and HTTP-server construction. The guide's cited sources this declaration lists (2 of 2): JupyterHub 6.2.0 application (internal_ssl and forwarded-header trust); JupyterHub 6.2.0 forwarded-scheme detection, Hub cookie attributes and single-user OAuth cookie.) **REASONED:** no Jupyter Server or JupyterHub behind a real ingress is available in the authoring environment. Send separate and conflicting spoofed `X-Forwarded-For`/`X-Real-IP` and `X-Scheme`/`X-Forwarded-Proto` headers through the real ingress, and separately attempt to reach the backend directly from an untrusted network. Compare the request-derived client address in the logs, the redirect targets, and the cookie `Secure` attribute. In the exposed fixture (header trust enabled while the backend is reachable or the proxy passes headers through unchanged) the attacker-controlled address or scheme takes effect; in the fixed fixture (section 9: a private bind plus a sanitizing ingress) it does not, while a legitimate login and WebSocket still succeed. Include a spoofed `Forwarded: proto=http` header and compare the detected browser-facing scheme (the redirect target and any scheme-derived decision) between the sanitizing and the pass-through ingress. Because section 6 sets `cookie_host_prefix_enabled`, the single-user OAuth cookie that records the login is set `Secure` whatever scheme is detected, so for that cookie a stripped-`Secure` result is demonstrable only in an isolated fixture with host-prefixed cookies disabled; with this guide's configuration, verify instead that the ingress removes or overwrites `Forwarded`. That code applies `cookie_options` after setting `Secure` (6.2.0 `services/auth.py` lines 1289 to 1298), so never set `secure` to false in a `cookie_options` override. The `cookie_options` help text suggests `secure=True` "if served on HTTPS and default HTTPS discovery fails (e.g. behind some proxies)" (6.2.0 `services/auth.py` lines 410 to 415). The single-user OAuth state cookie differs: under the same setting its name is the `__Host-` prefixed cookie name plus `-oauth-state` (6.2.0 `services/auth.py` lines 912 to 923), but the code that sets it does not take `Secure` from that prefix. When a state cookie of that name is already present, the code instead appends a hyphen and a random suffix of 8 ASCII letters to the name, under the comment "use a randomized cookie suffix to avoid collisions in case of concurrent logins" (lines 1189 to 1198). The cookie is set `Secure` when `JUPYTERHUB_PUBLIC_URL` has the `https` scheme or, with that variable empty or unset, when `get_browser_protocol` returns `https` (lines 1211 to 1217). When neither applies, a `cookie_options` entry that sets `secure` to true adds it, but no `cookie_options` entry can remove it once one of those conditions has set it (lines 1219 to 1226). The spawner passes `Spawner.public_url` as `JUPYTERHUB_PUBLIC_URL` (6.2.0 `spawner.py` line 1380), but no file read here sets `Spawner.public_url`, and `user.py` was not read, so whether that variable is set is not established at 6.2.0 or 5.5.5. As this guide's inference, without that variable or a `cookie_options` entry that sets `secure`, a spoofed `Forwarded: proto=http` that reaches the server can make the server send the state cookie without `Secure`, which browsers that enforce the `__Host-` prefix may reject, so inspect that cookie's `Set-Cookie` header in both fixtures too. The Hub's own cookie code forces `Secure` and path `/` on a `__Host-` cookie after applying `cookie_options` (6.2.0 `handlers/base.py` lines 711 to 717). The host is a separate case: the forwarded-header machinery rewrites only the client address and scheme, not the host. The standalone Server's origin check reads the genuine `Host` header. In the JupyterHub 6.2.0 files read here (`app.py`, `handlers/base.py`, `services/auth.py`, `utils.py` and `proxy.py`), `forwarded_host_header` appears only in its trait definition (6.2.0 `app.py` lines 1099 to 1107), and the Hub's Host checks read `self.request.host`. Whether another file, such as the `_xsrf_utils.py` module those handlers import, consumes `forwarded_host_header` or `X-Forwarded-Host` is not established at 6.2.0 or 5.5.5. Test, therefore, that a spoofed `X-Forwarded-Host` has no effect, and test an unintended genuine `Host` value at the ingress as well. See the [JupyterHub configuration reference](https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html), the [6.2.0 single-user OAuth cookie](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/services/auth.py), the [6.2.0 Hub cookie attributes](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/handlers/base.py), and the [Jupyter Server HTTP-server construction](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py). The spawner environment is in the [6.2.0 spawner](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/spawner.py). ### Verify internal Hub TLS (REASONED: Each internal Hub connection, client trust, hostname verification and the excluded kernel ZeroMQ transport. A live stand-up of JupyterHub with a spawner and single-user server opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Internal SSL configuration parsed and loaded into traitlets.Config; output was PASS. Local openssl generated a certificate, printed OK for trusted localhost verification and error 62, hostname mismatch, for wrong.internal. This tests local certificate verification only, not mutual TLS or Hub connections. Expect plaintext with internal TLS off, trusted client success and untrusted client/wrong-hostname rejection with it on at Hub 5.4+, at 6.2.0 through the aiohttp client context and at 5.5.5 through Tornado's client, retaining login, spawn, notebook and WebSocket controls; kernel ZeroMQ encryption remains a separate check, per the guide's cited sources this declaration lists in two parts. The guide's cited sources this declaration lists (1 of 2): JupyterHub 6.2.0 configuration reference (trusted_downstream_ips, public_url, internal_ssl); JupyterHub 6.2.0 changelog (internal hostname verification from 5.4); JupyterHub 6.2.0 application (internal_ssl and forwarded-header trust); JupyterHub 6.2.0 internal HTTP client. The guide's cited sources this declaration lists (2 of 2): JupyterHub 6.2.0 internal TLS context; JupyterHub 5.5.5 application and spawn-readiness check; Jupyter messaging protocol (jupyter_client 8.6.3); Jupyter kernel connection defaults (jupyter_client 8.6.3).) **REASONED:** no JupyterHub, spawner, or single-user server is available in the authoring environment. Keep the public HTTPS listener constant and compare internal TLS off against on (section 10). Inspect each actual internal connection (Hub to proxy API, proxy to Hub, proxy to single-user server, and Hub to single-user server): with internal TLS on, a request using the intended internal certificate authority and client certificate succeeds, while one presenting an untrusted certificate is rejected. A connection under an incorrect server hostname is likewise expected to be rejected on JupyterHub 5.4 or later (which enabled internal hostname verification by default), with internal TLS on; section 10 says which Hub connections that rests on at 6.2.0 and 5.5.5 and which are not established. Also present a certificate from a publicly trusted CA that is not the internal CA, since both releases' internal TLS context also loads the system's default CA certificates. Require a successful login, spawn, notebook execution, and kernel WebSocket exchange as positive controls. The single-user-server-to-kernel ZeroMQ connection must be observed separately and must not be counted as encrypted by this test, since `internal_ssl` does not cover it. See the [internal_ssl definition](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py) and the [Jupyter messaging protocol](https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/docs/messaging.rst). ### Verify NativeAuthenticator password and signup policy (REASONED: NativeAuthenticator password length/common-password checks, threshold/cooldown, pending-account oracle, approval/admission, signup disabling and bcrypt storage; PAM remains a host-policy case. A live stand-up of JupyterHub with NativeAuthenticator opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No browser signup session or Hub database was exercised. Python password and signup configuration parsed and loaded into traitlets.Config; output was PASS. Expected exposed empty/weak-password acceptance, disabled or zero-cooldown lockout and open/preauthorized signup contrast with the configured rejection, delayed login, approval hold and disabled-route 404; the pending-account oracle and first-signup allowlist exception remain exposed as described, and stored passwords must be bcrypt hashes, per the guide's cited sources this declaration lists. The guide's cited sources this declaration lists: NativeAuthenticator configuration traits and password, lockout, and signup enforcement; NativeAuthenticator quickstart and authenticator_class; NativeAuthenticator request handlers (signup 404 when signup is disabled, and the authorization panel).) **REASONED:** no JupyterHub with NativeAuthenticator installed, and no browser signup session, is available in the authoring environment. This check applies only to a Hub that uses NativeAuthenticator (section 11); a PAM deployment verifies its password policy on the host instead. On an authorized Hub configured with the section 11 settings, exercise the signup form at `/hub/signup` and the login form (these Hub paths sit under `JupyterHub.base_url`, so a section 8 prefix makes them `/jupyter/hub/...`), varying one control at a time. Test the length floor and the common-password check separately, because under the section 11 settings NativeAuthenticator accepts a signup password only when it clears both (its `is_password_strong` requires `len(password) >= minimum_password_length` and, when `check_common_password` is on, that the password is not in the list). A password that meets the length floor but appears in the common-password list is rejected only when `check_common_password` is on and accepted when it is off, which isolates that control (lower `minimum_password_length` for this case if no listed password is long enough), while a password below the floor is rejected on length alone. Even an empty password is accepted with the shipped defaults (`minimum_password_length` effectively `0` since its `.tag(default=1)` annotation is metadata rather than the trait default, and `check_common_password` `False`). Confirm each rejection is the server's signup response, not a client-side hint. For lockout, verify the failure threshold and the cooldown length separately, because a zero cooldown is not the same as a disabled lockout: with `allowed_failed_logins = 0` the account is never throttled, while with a positive threshold the account is blocked after that many consecutive failures. An immediate retry, within the same second as the last failure, is refused at both `0` and `1200`, so it does not distinguish them; instead submit the correct password about two seconds after the last failure, when it is accepted at the effective-`0` default but still refused at `seconds_before_next_try = 1200`. The lockout also does not stop password guessing against a not-yet-approved account: while such an account is blocked, the login handler still returns a distinct authorization-pending message for a correct password and the generic error for a wrong one, a password-confirmation oracle the lockout leaves open. To exercise the approval hold in isolation from Hub admission, set `allow_all = True` with an empty `allowed_users` and sign up a fresh non-admin username: with `open_signup` off it is created unauthorized and cannot log in until an administrator approves it on `/hub/authorize`, whereas `open_signup = True` admits it at signup. Then confirm the reverse exposure by adding a not-yet-registered username to `allowed_users` and observing that its first signup is authorized immediately without approval. With `enable_signup = False`, a GET or an otherwise valid POST to the signup route is refused (HTTP 404) and creates no account, while with signup enabled the same form renders and accepts a valid registration. Read the stored credential directly (the `password` column of NativeAuthenticator's `users_info` table in the Hub database) to confirm it is a bcrypt hash, not reversible text. A wrong username, an unmigrated database, or a stale browser session is inconclusive. See the [NativeAuthenticator configuration](https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/nativeauthenticator.py) and the [signup and authorization handlers](https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/handlers.py). ### Local authoring checks and REASONED service checks REASONED: Earlier local authoring inventory. Local marking review (October 2026, bash 5.3.9) ran five Bash blocks through bash -n and ShellCheck 0.11.0 (exit 0, no output) and fifteen Python blocks through ASCII, ast.parse and traitlets.Config checks (15 PASS). The bash -n and ShellCheck runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and the listener inventory as a bare ss -tlnp, and those local runs were not repeated on the current forms. In the current forms the HTTP blocks' guard refusals exit with status 2, those blocks exit 1 for a finding and 3 for an inconclusive result, the inventory exits 2 when ss is not installed and 3 when ss fails, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three; stub runs of the inventory's current form are recorded at its heading. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The following checks were run without editing files or running `tools/run_all_checks.sh`: - REASONED: Python configuration construction only. Local marking review (October 2026, bash 5.3.9) loaded all fifteen ASCII, ast.parse-valid blocks into traitlets.Config and printed 15 PASS; Jupyter trait recognition was not tested. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Trait recognition awaits an isolated Jupyter runtime, per the guide's cited Jupyter Server configuration reference and authentication migrations (rolling documentation, checked September 2026) and JupyterHub 6.2.0 configuration reference. All fifteen Python configuration blocks passed ASCII and Python syntax checks and loaded into `traitlets.Config`. This checks configuration-file construction, not Jupyter trait recognition or runtime enforcement. - REASONED: Bash syntax and lint only. Local marking review (October 2026, bash 5.3.9) ran bash -n and ShellCheck 0.11.0 with Bash source mode on all five blocks; each exited 0 with no output. Those runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and the listener inventory as a bare ss -tlnp, and they were not repeated on the current forms. In the current forms the HTTP blocks' guard refusals exit with status 2, those blocks exit 1 for a finding and 3 for an inconclusive result, the inventory exits 2 when ss is not installed and 3 when ss fails, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three; stub runs of the inventory's current form are recorded at its heading. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. All five Bash blocks passed `bash -n` and ShellCheck. - REASONED: Written-placeholder guard only. Local marking review (October 2026, bash 5.3.9) ran both HTTP blocks under bash -u with curl and python3 stubbed; each exited 1 with "substitute every placeholder inside the quotes; not probing" before any CURL_STUB call. Those runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and were not repeated on the current forms, whose guard refusals exit with status 2 and which exit 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover them. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The substituted browser and cookie fixtures printed one and four CURL_STUB lines, respectively; local argument handling follows the guide's cited curl options, stdin headers, cookie files, and diagnostic variables. Both HTTP blocks, in their earlier form, refused their placeholders locally under `bash -u`. - REASONED: Positional and input guards only. Local marking review (October 2026, bash 5.3.9) ran an 80-case bash -u matrix: 72 refusals from eight positions times nine inputs (empty, REPLACE_WITH_, <, >, example.com, LF, CR, tab and SOH), plus valid browser and cookie cases, two bad markers, two short counts and two IFS=0 count cases. The 72 refused before curl; valid cases printed one and four CURL_STUB lines; marker and count cases exited 1 with their explicit messages. With IFS=0, quoted guards refused 30 and 50 values with exact-count messages, while unquoted controls reached one and four CURL_STUB calls. Separate branches exited 1 with "choose browser or token; not probing", "cookie file path must not contain =; not probing" and "CA bundle is not a readable file; not probing". These runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, and were not repeated on the current forms, whose guard refusals exit with status 2 and which exit 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover them. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local curl argument handling follows the guide's cited curl options, stdin headers, cookie files, and diagnostic variables. Eighty positional-guard cases passed, covering valid inputs, empty values, embedded placeholders, angle brackets, the example hostname, control characters, missing markers, wrong argument counts, and an altered `IFS` count case. - REASONED: Token guard only. Local marking review (October 2026, bash 5.3.9) ran seven bash -u cases with python3 and curl stubbed: the valid dummy token printed three CURL_STUB lines and exited 0; empty, REPLACE_WITH_ and angle-bracket tokens printed "a real token is required; not probing"; LF, CR and tab printed "token contains a control character; not probing"; each refusal exited 1 after one anonymous CURL_STUB line. These runs covered the TLS and API access block in an earlier form, whose guard refusals exited with status 1, and were not repeated on the current form, whose guard refusals exit with status 2 and which exits 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Header input behaviour follows the guide's cited curl options, stdin headers, cookie files, and diagnostic variables. Seven token-guard cases passed, including rejection of LF, CR, and tab characters before header construction. - REASONED: Dummy-token transport only. Local marking review (October 2026, bash 5.3.9) ran the token path with python3 and curl stubbed; it exited 0 with three CURL_STUB lines. That run covered the TLS and API access block in an earlier form, whose guard refusals exited with status 1, and was not repeated on the current form, whose guard refusals exit with status 2 and which exits 1 for a finding and 3 for an inconclusive result; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Captured curl arguments began -q and -g and omitted DUMMY_TOKEN; stdin held two "Authorization: token DUMMY_TOKEN" headers. No HTTP request ran. A live Jupyter Server stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Header stdin handling follows the guide's cited curl options, stdin headers, cookie files, and diagnostic variables. A local dummy-token argument harness confirmed that the authenticated curl invocation starts with `-q -g` and receives the token on stdin, not in its arguments. This harness made no HTTP request and simulated no Jupyter behaviour. - DEMONSTRATED: Local guide-link resolution only. Local marking review (October 2026) checked a scratch copy with a free-certificates.md link replaced by tls.md: that target was absent; in the fixed guide, all six distinct local targets resolved and no tls.md link remained. The referenced local certificate, proxy, Cloudflare, and MFA guides exist. No `tls.md` link is used. | Scope | Status | Exposed-versus-fixed checks | |---|---|---| | Jupyter controls | REASONED: This table inventories the live comparisons, not observed results. A live Jupyter Server or JupyterHub stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran all five Bash blocks through bash -n and ShellCheck 0.11.0: exit 0 with no output; all fifteen Python configuration blocks passed ASCII, AST and traitlets.Config loading. With external commands stubbed, both written HTTP placeholders exited 1 with "substitute every placeholder inside the quotes; not probing"; valid browser and cookie fixtures exited 0 with one and four CURL_STUB lines respectively. Invalid marker, count, empty, placeholder, angle-bracket, example-host, control-character, scheme, URL and file fixtures exited 1 before curl. These bash -n, ShellCheck and stub runs covered the two guarded HTTP blocks in an earlier form, whose guard refusals exited with status 1, the bash -n and ShellCheck runs also covered the listener inventory as a bare ss -tlnp, and the stub runs were not repeated on the current forms. In the current forms the HTTP blocks' guard refusals exit with status 2, those blocks exit 1 for a finding and 3 for an inconclusive result, the inventory exits 2 when ss is not installed and 3 when ss fails, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three; stub runs of the inventory's current form are recorded at its heading. Local openssl verification printed OK for trusted localhost and error 62 for a wrong hostname. These runs do not establish the exposed-versus-fixed service outcomes listed in the table. The required versions, Unix accounts, TLS/DNS, browser sessions, terminal support, extension routes, second-host vantage, real ingress, spawner and NativeAuthenticator database remain prerequisites for those comparisons, per the guide's cited sources, which this cell lists in six parts. The guide's cited sources (1 of 6): Jupyter Server public server guide and password/TLS setup (rolling documentation, checked September 2026); Jupyter Server security and token/password authentication (rolling documentation, checked September 2026); Jupyter Server API base handler, protected API responses, and metrics enforcement. The guide's cited sources (2 of 6): Jupyter Server API reference for authentication, origin, Host, root, and terminal controls (rolling documentation, checked September 2026); Jupyter Server startup, dynamic defaults, and root refusal implementation; Jupyter Server default port constant; Jupyter Server identity provider and token-authenticated origin exception. The guide's cited sources (3 of 6): Jupyter Server Host, origin, XSRF, and default authentication implementation; Jupyter Server kernel API implementation; JupyterLab kernel and terminal management; JupyterHub 6.2.0 authenticator API and PAM admission settings; JupyterHub 6.2.0 configuration reference. The guide's cited sources (4 of 6): JupyterHub spawner API and user-configuration limitations (rolling documentation, checked September 2026); JupyterHub browser security, per-user domains, cookies, and server environments (rolling documentation, checked September 2026); curl options, stdin headers, cookie files, and diagnostic variables. The guide's cited sources (5 of 6): JupyterHub 6.2.0 proxy configuration and path prefix; configurable-http-proxy 5.4.0 command-line options; NativeAuthenticator configuration traits and password, lockout, and signup enforcement; NativeAuthenticator request handlers (signup 404 when signup is disabled, and the authorization panel). The guide's cited sources (6 of 6): JupyterHub 6.2.0 configuration reference (trusted_downstream_ips, public_url, internal_ssl); JupyterHub 6.2.0 changelog (internal hostname verification from 5.4); Jupyter Server v2.21.1 application definition and HTTP-server construction; JupyterHub 6.2.0 application (internal_ssl and forwarded-header trust); Jupyter messaging protocol (jupyter_client 8.6.3). REASONED from the cited vendor documentation and pinned implementations, not demonstrated. Requires Jupyter Server 2.18+, JupyterHub 6.1.0 or later or 5.5.3 or later, suitable Unix test accounts, TLS/DNS, browser sessions, terminal support, representative extension routes, and an authorized isolated deployment. | Check TLS and anonymous/authenticated API access; external reachability and listener inventory; metrics and unclassified extension authentication; cookie-session XSRF and origin checks; Host rejection with an authenticated positive control; root refusal and workload UIDs (items, part 1 of 2). The items to check (part 2 of 2): terminal disabling; Hub admission including historical users; separate execution accounts and browser origins; host-prefixed cookies; ignored user configuration and protected server environments. Record versions, requests, responses, logs, positive controls, and cleanup. For the sections 8 to 10 controls, check a route reaching the application outside the intended prefix versus a consistent prefix (items, part 1 of 3). The items to check for the sections 8 to 10 controls (part 2 of 3): a spoofed forwarded IP and scheme taking effect with header trust misconfigured versus rejected behind a sanitizing ingress, a spoofed Forwarded header not changing the detected browser scheme behind the ingress, and a spoofed X-Forwarded-Host having no effect (its handling is not established at 6.2.0 or 5.5.5). The items to check for the sections 8 to 10 controls (part 3 of 3): internal Hub traffic in plaintext versus TLS with client-certificate verification, keeping the kernel ZeroMQ transport excluded from the internal-TLS claim. For the section 11 NativeAuthenticator controls, check a sub-minimum-length or empty password rejected at signup versus accepted under the effective-zero defaults, a login refused after the configured failed-attempt threshold (items, part 1 of 4). The items to check for the section 11 NativeAuthenticator controls (part 2 of 4): the cooldown length confirmed by a correct password refused about two seconds after the last failure at seconds_before_next_try 1200 versus accepted at the effective-zero cooldown, the lockout disabled entirely at allowed_failed_logins 0. The items to check for the section 11 NativeAuthenticator controls (part 3 of 4): the pending-account password-confirmation oracle (a distinct awaiting-authorization message for a correct password during lockout), a self-registered account held for administrator authorization on /hub/authorize (with allow_all set and allowed_users empty) versus admitted immediately under open_signup. The items to check for the section 11 NativeAuthenticator controls (part 4 of 4): a not-yet-registered allowed_users username claimed as an immediately-authorized account at its first signup, the signup route refusing GET and POST with 404 when enable_signup is False, and the stored password held as a bcrypt hash. | ## 8. Preserve the deployment path prefix through the reverse proxy When a reverse proxy serves Jupyter under a path prefix such as `/jupyter/`, the application must be told the same prefix, or another route can reach the application outside the authentication and routing boundary the proxy establishes. Both the standalone Server and JupyterHub default their prefix to `/`. For a standalone Jupyter Server behind a prefix, set it in `jupyter_server_config.py`: ```python c.ServerApp.base_url = '/jupyter/' ``` The Server applies the prefix to its registered handlers. See the [Server v2.21.1 application definition](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py) (lines 534 to 539 and 1531 to 1548). For JupyterHub behind the same-host proxy, set the listener and base URL together in `jupyterhub_config.py`: ```python c.JupyterHub.bind_url = 'http://127.0.0.1:8000/jupyter/' ``` This sets both the bind address and `JupyterHub.base_url`. Configure the prefix consistently on both sides of the proxy; do not combine a `bind_url` prefix with separate conflicting URL settings, and do not reach for the configurable-http-proxy `--no-include-prefix` or `--no-prepend-path` flags as a generic prefix fix, since neither establishes an authentication boundary. The 6.2.0 proxy configuration page gives a `bind_url` example, which "Sets protocol, IP, base_url", and the 6.2.0 `bind_url` and `base_url` definitions are at `app.py` lines 800 to 847. See the [JupyterHub 6.2.0 proxy configuration](https://jupyterhub.readthedocs.io/en/6.2.0/howto/configuration/config-proxy.html) and the [configurable-http-proxy 5.4.0 command-line options](https://raw.githubusercontent.com/jupyterhub/configurable-http-proxy/5.4.0/bin/configurable-http-proxy). JupyterHub 6 adds Unix-socket support between the Hub and configurable-http-proxy. At 6.2.0, `bind_url` also accepts an `http+unix://` URL holding the URL-encoded socket path, and the Hub then starts the proxy with `--socket` (and `--api-socket` for a socket API URL) in place of `--ip` and `--port` (`proxy.py` lines 736 to 762). The 5.5.5 `proxy.py` always passes `--ip`, `--port`, `--api-ip` and `--api-port` (lines 720 to 732). 6.2.0 also carries the 6.0.1 fix listed as "fix proxy binding on all interfaces". This section's loopback `bind_url` uses neither feature. The 5.4.0 option list defines `--socket` and `--api-socket`, but whether configurable-http-proxy 5.4.0 works with JupyterHub 6.2.0 is not established here: no compatibility statement was found and nothing was run. [Upgrading to JupyterHub 6](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md), [6.2.0 proxy launch](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/proxy.py), [5.5.5 proxy launch](https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/proxy.py), [6.2.0 changelog](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md). ## 9. Trust forwarded headers only behind a sanitizing ingress A standalone Jupyter Server does not trust proxy-forwarded headers by default (`ServerApp.trust_xheaders` is `False`), while JupyterHub constructs its HTTP server with forwarded-header trust enabled. Trusting these headers while the backend can still be reached by an untrusted peer, or while the proxy passes client-supplied headers through unchanged, lets a client spoof its apparent address or scheme. Bind the backend privately and trust the headers only behind an ingress that overwrites them. The Hub builds its HTTP server with `xheaders=True` and `trusted_downstream=self.trusted_downstream_ips` at 6.2.0 (`app.py` lines 3966 to 3971). [JupyterHub 6.2.0 application](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py). For a standalone Server behind a same-host TLS ingress, in `jupyter_server_config.py`: ```python c.ServerApp.ip = '127.0.0.1' c.ServerApp.trust_xheaders = True ``` Keep `trust_xheaders` at `False` unless a trusted ingress sets `X-Forwarded-For`/`X-Real-IP` and `X-Scheme`/`X-Forwarded-Proto` itself. See the [Server v2.21.1 HTTP-server construction](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py) (lines 1744 to 1753 and 2659 to 2665). For the same-host JupyterHub topology, in `jupyterhub_config.py`: ```python c.JupyterHub.hub_ip = '127.0.0.1' c.JupyterHub.trusted_downstream_ips = ['127.0.0.1'] c.JupyterHub.public_url = 'https://jupyter.example.com/jupyter/' ``` `trusted_downstream_ips` names the proxy hops that are trusted and skipped when Hub selects the client address from `X-Forwarded-For`; it controls address selection, not whether a connecting peer may supply headers, so it is not a substitute for an ingress that sanitizes them, and an empty list does not disable header trust. The ingress must normalize both forwarded-IP forms (`X-Forwarded-For`, `X-Real-IP`), both scheme forms (`X-Forwarded-Proto`, `X-Scheme`), and the standard RFC 7239 `Forwarded` header, and reject unintended `Host` values. JupyterHub reads the `Forwarded` header before the `X-` scheme forms when deciding the browser-facing scheme, so the ingress must own it too: `get_browser_protocol` takes `proto` from the first `Forwarded` entry as its "first choice" and only then `X-Scheme` or `X-Forwarded-Proto` (6.2.0 `utils.py` lines 819 to 844). In this guide's configuration `public_url` (this section) and `cookie_host_prefix_enabled` (section 6) already guard the scheme-derived redirect and single-user OAuth cookie paths, but leaving `Forwarded` unsanitized still lets a client-supplied value reach any scheme decision, so normalize it alongside the other forwarded headers. Binding to loopback excludes a remote peer but not other local processes. The 6.2.0 configuration reference documents the `trusted_downstream_ips`, `public_url` and `hub_ip` settings. See the [JupyterHub 6.2.0 configuration reference](https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html) and the [6.2.0 forwarded-scheme detection](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/utils.py). ## 10. Encrypt internal Hub communication Public HTTPS (sections 3 and 4) protects only the browser-facing listener. By default JupyterHub leaves the internal traffic between the Hub, the proxy, and the single-user servers unencrypted (`internal_ssl` is `False`). Enable internal TLS in `jupyterhub_config.py`: ```python c.JupyterHub.internal_ssl = True c.JupyterHub.internal_certs_location = '/srv/jupyterhub/internal-ssl' ``` JupyterHub then generates an internal certificate authority and per-component certificates and enables TLS with client-certificate verification for the Hub-to-proxy API, the proxy-to-Hub and proxy-to-single-user connections, and the Hub-to-single-user connection used during spawn readiness. Treat the certificate directory as protected persistent state, and give the spawner the ability to deliver each server's key, certificate, and trust bundle (`LocalProcessSpawner` relocates them; a remote spawner needs its own `move_certs` support). Set `Spawner.ssl_alt_names` to the actual connection names when they are not the defaults, and never resolve a certificate error by disabling verification. Internal hostname verification is on by default from [JupyterHub 5.4](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md), whose changelog entry reads "Fix internal ssl: do not disable hostname verification by default" (6.2.0 changelog line 634); earlier 5.x releases left it disabled, so upgrade before relying on it. The `internal_ssl` and `internal_certs_location` definitions are at 6.2.0 `app.py` lines 672 to 688. See the [internal_ssl definition](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py) and the [JupyterHub 6.2.0 configuration reference](https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html). The Hub's own internal client differs between the two releases. JupyterHub 6.0 replaced Tornado's HTTP client with aiohttp for internal requests ("use aiohttp for internal http requests", #5447). At 6.2.0 the Hub builds its internal client context with `make_ssl_context` and its default `purpose=ssl.Purpose.SERVER_AUTH` (`app.py` lines 2099 to 2111), which sets `ssl_context.verify_mode = ssl.CERT_REQUIRED` and `ssl_context.check_hostname = True` (`utils.py` lines 155 to 204). `JupyterHubHTTPClient` applies that context after any `connector_options` a deployment sets, so configuration cannot replace it (`httpclient.py` lines 114 to 118). At 5.5.5 the Hub instead configures Tornado's `AsyncHTTPClient` with the internal CA bundle, client key and certificate and `validate_cert=True` (`app.py` lines 1993 to 2004). As this guide's inference, Tornado then checks hostnames and aiohttp uses the passed context as given; neither library's source was read here. Both releases' `make_ssl_context` also calls `ssl_context.load_default_certs()` after loading the internal CA (6.2.0 line 199), so as this guide's inference from Python's `ssl` documentation, not read here, the context may also trust the system's default CAs. Not established at 6.2.0 or 5.5.5: whether the spawn-readiness request uses the client context above, since `user.py` was not read at either release and the 5.5.5 `wait_for_http_server` takes its SSL context per call (`utils.py` lines 294 to 296), and how configurable-http-proxy checks hostnames on its proxy-to-Hub and proxy-to-single-user connections. [6.2.0 internal TLS context](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/utils.py), [6.2.0 internal HTTP client](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/httpclient.py), [5.5.5 application](https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/app.py), [5.5.5 spawn-readiness check](https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/utils.py), [Upgrading to JupyterHub 6](https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md). Internal TLS does not encrypt the single-user server's connection to its kernels. That link uses the Jupyter messaging protocol over ZeroMQ, whose HMAC signature authenticates messages but does not encrypt them. Local kernels bind loopback; a remote-kernel deployment needs a separately configured encrypted transport such as an SSH tunnel, which this guide does not configure. See the [Jupyter messaging protocol](https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/docs/messaging.rst) and the [kernel connection defaults](https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/jupyter_client/connect.py). Jupyter Server 2.20.0 added an optional kernel transport encryption policy ("Add a toggle to enable curve encryption for all kernels that support it", #1638). At v2.21.1, `MappingKernelManager.transport_encryption` takes `'disabled'`, `'auto'` or `'required'`, defaults to `'disabled'`, and its help text says "'disabled' (default) does not provision Curve credentials, 'auto' provisions when the kernelspec declares support, and 'required' enforces provisioning and fails kernel startup if encryption cannot be applied." (`kernelmanager.py` lines 71 to 81). The Server passes any value other than `'disabled'` on to jupyter_client's kernel start (lines 220 to 225, 254 and 260). As this guide's inference, it therefore needs a jupyter_client release that accepts `transport_encryption`, and for `'auto'` a kernelspec that declares support. The cited jupyter_client 8.6.3 files do not mention Curve encryption, and whether 8.6.3 supports this policy, and which jupyter_client release v2.21.1 requires, are not established here. This guide does not configure the policy, so its default leaves the kernel link unencrypted, as above. [Kernel transport encryption policy](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/services/kernels/kernelmanager.py), [Server v2.21.1 changelog](https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/CHANGELOG.md). ## 11. Harden NativeAuthenticator password and signup policy Section 5 configures `PAMAuthenticator`, which delegates credentials to the host's user accounts and so inherits the operating system's password and lockout policy. A deployment that instead manages its own login names and passwords in the Hub, rather than authenticating against host accounts, commonly reaches for the separate [NativeAuthenticator](https://native-authenticator.readthedocs.io/en/1.3.0/quickstart.html) package (`c.JupyterHub.authenticator_class = 'native'`). It stores each password as a bcrypt hash in the Hub database and adds its own signup form, so the host no longer supplies the login password policy and NativeAuthenticator's own settings become the only floor. NativeAuthenticator replaces the host only as the credential store for login; a spawner such as section 5's `LocalProcessSpawner` may still require a matching Unix account to run each user's server. Those defaults are permissive, and two of them are weaker than the defaults their own source declarations annotate, so set them explicitly. This section applies only when NativeAuthenticator is the authenticator; under `PAMAuthenticator` the operating system owns these controls. In `jupyterhub_config.py`, set a password policy and a login-failure lockout: ```python c.NativeAuthenticator.minimum_password_length = 12 c.NativeAuthenticator.check_common_password = True c.NativeAuthenticator.allowed_failed_logins = 5 c.NativeAuthenticator.seconds_before_next_try = 1200 ``` Set each of these explicitly; for two of them the shipped runtime defaults are weaker than the `.tag(default=...)` annotations in their source declarations suggest. `minimum_password_length` and `seconds_before_next_try` are declared as `Integer(...).tag(default=1)` and `.tag(default=600)`, but that tag is traitlets metadata, not the trait's default value: an `Integer` with no explicit default is `0`, so both effectively default to `0`. For `seconds_before_next_try` the package's own help text and documentation also state the 600-second default that the code does not implement; for `minimum_password_length` the documentation is accurate that there is no strength check by default. A `minimum_password_length` of `0` accepts even an empty password, since the check applied when a password is set is `len(password) >= minimum_password_length`; and a `seconds_before_next_try` of `0` leaves essentially no cooldown after a block. `check_common_password` and `allowed_failed_logins` instead default to the permissive values their declarations annotate (`False` and `0`): with common-password checking off any listed password is allowed, and with `allowed_failed_logins` at `0` lockout is disabled and the login endpoint can be brute-forced until it is set. Enabling `check_common_password` rejects a new password found in the package's bundled common-password list (its 10,000-entry `common-credentials.txt`), a floor against the most obvious choices, not a breach-corpus check. Pair `allowed_failed_logins` with `seconds_before_next_try` to throttle repeated login attempts after that many consecutive failures for one account. Some limits are worth knowing: the failure counters are held in memory, so restarting the Hub clears them; the wait compares whole elapsed seconds with `timedelta.seconds`, which ignores elapsed days, so an uncleared counter can re-block a returning user for the wait period at each 24-hour multiple until a successful login or a restart clears it. Another limit is that the lockout throttles the login itself, not password guessing against a not-yet-approved account, because the login handler still returns a distinct authorization-pending message, rather than the generic error, when a pending account's password is correct. Where password guessing against a not-yet-approved account matters, rate-limit the login endpoint at the proxy or ingress, matching the endpoint's actual path: `/hub/login`, or the prefixed form such as `/jupyter/hub/login` when `JupyterHub.base_url` sets a prefix as in section 8. These password rules are applied whenever a password is set, at signup or through a password change, so an account keeps a previously accepted password until it is changed and existing passwords are not re-checked against a newly raised policy. See the [NativeAuthenticator configuration](https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/nativeauthenticator.py). Control who may register, and treat NativeAuthenticator's own approval as separate from the Hub's admission. The signup form is enabled by default (`enable_signup` is `True`), and a self-registered account is created unauthorized and cannot log in until it is authorized: ```python c.NativeAuthenticator.open_signup = False ``` Keep `open_signup` at its default `False`: with it off, a pending account waits for an administrator to approve it on the Hub's authorization panel (`/hub/authorize`; these `/hub/...` paths sit under `JupyterHub.base_url`, so a section 8 prefix makes them `/jupyter/hub/...`). Never set it to `True` on an internet-reachable Hub, where it would pre-authorize every self-registration, so anyone who reaches the signup form obtains a pre-authorized account (its login is still subject to the Hub's `check_allowed`). One case authorizes an account at signup even with `open_signup` off: NativeAuthenticator pre-authorizes any username already present in the section 5 `allowed_users` or `admin_users` (its `create_user` sets `pre_authorized = self.open_signup or username in self.get_authed_users()`, and `get_authed_users` includes `allowed_users` and `admin_users`). JupyterHub's own admission (`check_allowed`) independently lets a user log in only when `allow_all` is set or the username is in `allowed_users`. A listed username that has not yet registered can therefore, under the section 5 configuration in this guide (`allow_all = False` with a populated `allowed_users`), be claimed at the signup form, with a password of the claimant's choosing, by anyone who can reach it, arriving immediately authorized without the approval step ever running. Approval still gates an account that registered before its name was listed, since adding a name to `allowed_users` does not retroactively authorize an existing pending record, and an administrator can revoke an account's authorization. Treat the signup form as reachable only by trusted users, register the `allowed_users` accounts deliberately, and if self-service registration is not wanted at all set `enable_signup = False`, which makes the signup route reject every request. Passwords are stored as bcrypt hashes in the Hub database, so protect that database and its backups as a credential store. Self-service signup lets users choose their own usernames, which GHSA-93gc-h923-mp36 concerns: "Insufficient validation of user and service names in URL components" (medium; affected `< 5.5.3` and `>=6.0.0,<6.1.0`, patched `5.5.3` and `6.1.0`). Its description says "In deployments where users can signup with arbitrary usernames (not most deployments), certain malicious usernames could interfere with URL handling." and gives the workaround "Set `Authenticator.username_regex` to a reasonable pattern." With signup enabled, run JupyterHub 6.1.0 or later, or 5.5.3 or later. Whether NativeAuthenticator 1.3.0 works with JupyterHub 6.2.0, or with 5.5.5, is not established here: no compatibility statement was found and nothing was run. [GHSA-93gc-h923-mp36](https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-93gc-h923-mp36). ## Sources (checked September 2026) - Jupyter Server public server guide and password/TLS setup (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/latest/operators/public-server.html - Jupyter Server security and token/password authentication (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/latest/operators/security.html - Jupyter Server API base handler, protected API responses, and metrics enforcement (v2.21.1, checked November 2026): https://github.com/jupyter-server/jupyter_server/blob/v2.21.1/jupyter_server/base/handlers.py - Jupyter Server configuration reference and authentication migrations (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/latest/other/full-config.html - Migrating from the classic Notebook server (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/latest/operators/migrate-from-nbserver.html - Jupyter Server API reference for authentication, origin, Host, root, and terminal controls (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.html - Jupyter Server authentication API (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.auth.html - Jupyter Server release history, including endpoint authentication under 2.13.0 and 2.18.0 (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/stable/other/changelog.html - Jupyter Server startup, dynamic defaults, and root refusal implementation (v2.21.1, checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py - Jupyter Server default port constant (v2.21.1, checked November 2026): - Jupyter Server identity provider and token-authenticated origin exception (v2.21.1, checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/auth/identity.py - Jupyter Server Host, origin, XSRF, and default authentication implementation (v2.21.1, checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/base/handlers.py - Jupyter Server kernel API implementation (v2.21.1, checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/services/kernels/handlers.py - Jupyter Server REST API (rolling documentation, checked September 2026): https://jupyter-server.readthedocs.io/en/stable/developers/rest-api.html - Notebook 7 configuration and Jupyter Server backend (rolling documentation, checked September 2026): https://jupyter-notebook.readthedocs.io/en/stable/configuring/config_overview.html - JupyterLab kernel and terminal management: https://jupyterlab.readthedocs.io/en/stable/user/running.html - JupyterHub 6.2.0 authenticator API and PAM admission settings, including auth_refresh_strict (checked November 2026): https://jupyterhub.readthedocs.io/en/6.2.0/reference/api/auth.html - JupyterHub authenticators and Hub 5 admission changes (rolling documentation, checked September 2026): https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html - JupyterHub 6.2.0 configuration reference (checked November 2026): https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html - JupyterHub spawner API and user-configuration limitations (rolling documentation, checked September 2026): https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html - JupyterHub 6.2.0 spawner resource-limit support (checked November 2026): https://jupyterhub.readthedocs.io/en/6.2.0/reference/spawners.html - JupyterHub browser security, per-user domains, cookies, and server environments (rolling documentation, checked September 2026): https://jupyterhub.readthedocs.io/en/stable/explanation/websecurity.html - JupyterHub 6.2.0 HTTPS reverse-proxy example (checked November 2026): https://jupyterhub.readthedocs.io/en/6.2.0/howto/configuration/config-proxy.html - Tornado XSRF cookies and request headers: https://www.tornadoweb.org/en/stable/guide/security.html - curl TLS certificate and hostname verification: https://curl.se/docs/sslcerts.html - curl options, stdin headers, cookie files, and diagnostic variables: https://curl.se/docs/manpage.html - JupyterHub 6.2.0 proxy configuration and path prefix (checked November 2026): https://jupyterhub.readthedocs.io/en/6.2.0/howto/configuration/config-proxy.html - configurable-http-proxy 5.4.0 command-line options: https://raw.githubusercontent.com/jupyterhub/configurable-http-proxy/5.4.0/bin/configurable-http-proxy - NativeAuthenticator configuration traits and password, lockout, and signup enforcement: https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/nativeauthenticator.py - NativeAuthenticator quickstart and authenticator_class: https://native-authenticator.readthedocs.io/en/1.3.0/quickstart.html - NativeAuthenticator request handlers (signup 404 when signup is disabled, and the authorization panel): https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/handlers.py - JupyterHub 6.2.0 configuration reference (trusted_downstream_ips, public_url, internal_ssl) (checked November 2026): https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html - JupyterHub 6.2.0 changelog (internal hostname verification from 5.4, 6.0 and 6.1 changes, 6.1.0 security fixes, the 6.1.1 token-length fix and the 6.2.0 database-upgrade default) (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md - Jupyter Server v2.21.1 application definition and HTTP-server construction (checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py - JupyterHub 6.2.0 application (internal_ssl and forwarded-header trust), including the upgrade_db default and downgrade-db (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py - JupyterHub 6.2.0 forwarded-scheme detection and internal TLS context (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/utils.py - JupyterHub 6.2.0 internal HTTP client (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/httpclient.py - JupyterHub 6.2.0 Hub cookie attributes and Host handling (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/handlers/base.py - JupyterHub 6.2.0 single-user OAuth cookie and OAuth state cookie name (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/services/auth.py - JupyterHub 6.2.0 LocalProcessSpawner privilege drop (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/spawner.py - JupyterHub 6.2.0 proxy launch options, including Unix sockets (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/proxy.py - JupyterHub 6.2.0 upgrade notes (Python, database, Unix sockets, PKCE, spawn options and internal HTTP client) (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md - JupyterHub 5.5.5 authenticator API, without auth_refresh_strict (checked November 2026): https://jupyterhub.readthedocs.io/en/5.5.5/reference/api/auth.html - JupyterHub 5.5.5 changelog (5.5.3 security fixes, the 5.5.4 token-length fix and the 5.5.5 regression fixes) (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/docs/source/reference/changelog.md - JupyterHub 5.5.5 application (Tornado internal client, the upgrade_db default and no oauth_require_pkce) (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/app.py - JupyterHub 5.5.5 spawn-readiness check (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/utils.py - JupyterHub 5.5.5 proxy launch options (checked November 2026): https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/proxy.py - Jupyter Server v2.21.1 origin pattern check (checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/utils.py - Jupyter Server v2.21.1 kernel transport encryption policy (checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/services/kernels/kernelmanager.py - Jupyter Server v2.21.1 changelog (2.20.0 kernel transport encryption toggle) (checked November 2026): https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/CHANGELOG.md - JupyterHub advisory GHSA-5jhc-4h3c-q97h, "Incomplete privilege drop in LocalProcessSpawner when setgroups() fails" (medium; affected `>=6.0.0, <6.1.0` and `<=5.5.2`, patched `6.1.0` and `5.5.3`) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-5jhc-4h3c-q97h - JupyterHub advisory GHSA-93gc-h923-mp36, "Insufficient validation of user and service names in URL components" (medium; affected `< 5.5.3` and `>=6.0.0,<6.1.0`, patched `5.5.3` and `6.1.0`) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-93gc-h923-mp36 - JupyterHub advisory GHSA-hx46-2cv3-mh6g, "Denial of Service via invalid spawner options" (medium; affected `>=6.0.0, <6.1.0` and `<5.5.3`, patched `6.1.0` and `5.5.3`) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-hx46-2cv3-mh6g - JupyterHub advisory GHSA-pvm9-48cg-w76j, "token_expires_in_max_seconds not properly enforced" (medium; affected `>= 5.1.0, <5.5.3` and `>=6.0.0, <6.1.0`, patched `5.5.3` and `6.1.0`) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-pvm9-48cg-w76j - JupyterHub advisory GHSA-r764-xgvf-jrfj, "SharedPasswordAuthenticator: missing username normalization" (medium; affected `>=5.3.0,<5.5.3` and `>=6.0.0, <6.1.0`, patched `5.5.3` and `6.1.0`) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-r764-xgvf-jrfj - JupyterHub advisory GHSA-x7xh-7hc7-9436, "Missing validation on user rename admin API" (medium; affected `< 5.5.3` and `>=6.0.0, <6.1.0`, patched `5.5.3` and `6.1.0`) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-x7xh-7hc7-9436 - JupyterHub advisory GHSA-p43p-whwx-q52h (CVE-2026-54338), "JupyterHub has Unauthenticated Denial of Service via Unbounded Username Logging on Failed Login" (medium; affected `< 5.5.0`, patched `5.5.0`; no 6.x range listed) (checked November 2026): https://github.com/advisories/GHSA-p43p-whwx-q52h - JupyterHub advisory GHSA-c4gm-pwx9-9w8j, "Reflected XSS in /hub/accept-share" (high; affected `>= 5.0.0,<5.5.1`, patched `5.5.1`; no 6.x range listed) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-c4gm-pwx9-9w8j - JupyterHub advisory GHSA-69wv-m5fw-2fhp, "improper scope enforcement for limited admins of users and groups" (low; affected `>=2.0.0,<=5.5.0`, patched `5.5.1`; no 6.x range listed) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-69wv-m5fw-2fhp - JupyterHub advisory GHSA-mj6q-mp44-53v8, "Share scopes do not follow user renames" (low; affected `>=5.0.0,<5.5.2`, patched `5.5.2`; no 6.x range listed) (checked November 2026): https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-mj6q-mp44-53v8 - Jupyter Server advisory GHSA-24qx-w28j-9m6p (CVE-2026-40110), "Jupyter Server has a CORS Origin Validation Bypass via `re.match()` in `allow_origin_pat`" (high; affected `<= 2.17.0`, patched `2.18.0`) (checked November 2026): https://github.com/advisories/GHSA-24qx-w28j-9m6p - Jupyter messaging protocol (jupyter_client 8.6.3): https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/docs/messaging.rst - Jupyter kernel connection defaults (jupyter_client 8.6.3): https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/jupyter_client/connect.py ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - server: Jupyter Server (v2.21.1) - hub: JupyterHub (6.2.0) - hub-5: JupyterHub (5.5.5) - client: jupyter_client (8.6.3) - lab: JupyterLab documentation (unknown) - server-docs: Jupyter Server documentation (rolling) (unknown) - notebook-docs: Notebook documentation (rolling) (unknown) - hub-docs: JupyterHub documentation (rolling) (unknown) - proxy: configurable-http-proxy (5.4.0) - native: NativeAuthenticator (1.3.0) - tornado: Tornado documentation (unknown) - curl: curl documentation (unknown) - advisories: JupyterHub and Jupyter Server security advisories (unknown) Claims (full source mapping in the JSON block below): - native-auth (REASONED; server-docs): Generated tokens enable authentication by default; a password replaces the generated token by default, and configured token/password alternatives are not MFA. - password (REASONED; server-docs): Standalone Server password prompt stores a hash in jupyter_server_config.json; generate-config writes jupyter_server_config.py under the operating account. - endpoint-auth (REASONED; server-docs, server): Set allow_unauthenticated_access=False for unclassified Jupyter handlers; explicitly public handlers and third-party authentication need separate review. - endpoint-default (REASONED; server-docs): Reference lists allow_unauthenticated_access=True; feature appears under 2.13.0 and 2.18.0, with 2.18+ the guide baseline, not introduction date. - metrics-auth (REASONED; server, server-docs): authenticate_prometheus=True independently requires authentication for metrics. - migration (REASONED; server-docs, notebook-docs): Notebook 7 uses Server; migrate NotebookApp to ServerApp and Server 2 password/token traits to PasswordIdentityProvider and IdentityProvider; avoid credential argv. - root (REASONED; server): allow_root=False refuses Unix root startup; use a dedicated unprivileged account for server and kernels; it neither changes UID nor sandboxes execution. - terminals (REASONED; server-docs, server): terminals_enabled=False removes terminals but not kernel shell execution; reference False differs from the implementation's dynamic True subject to availability. - native-tls (REASONED; server-docs, server): certfile/keyfile or --certfile/--keyfile enable native HTTPS; that listener no longer serves ordinary plaintext HTTP. - private-bind (REASONED; server-docs): Retain loopback; do not expose 0.0.0.0 without authentication and HTTPS; disabling both password and token removes native authentication. - proxy-tls (REASONED; hub): An authenticated HTTPS proxy before a loopback server can replace native TLS; authentication alone does not encrypt transport. - mfa (REASONED; server-docs, hub-docs): Standalone password is single-factor; fronting authentication or a Hub provider adds MFA only when explicitly enforced. - origins (REASONED; server-docs): Same-origin settings keep allow_origin and allow_origin_pat empty and allow_credentials=False; cross-origin clients require reviewed exceptions. - origin-cve (REASONED; server, advisories): Server 2.18.0 fixed GHSA-24qx-w28j-9m6p (CVE-2026-40110, high; affected up to 2.17.0); at v2.21.1 origin_matches_pat returns False for an empty pattern and otherwise requires re.fullmatch, so the empty allow_origin_pat matches no origin. - xsrf (REASONED; server-docs, server): Keep disable_check_xsrf=False; disabling it removes browser-session request-forgery protection. - host (REASONED; server-docs, server): allow_remote_access=False retains Host checks; local_hostnames permits actual standalone hostnames; Hub servers need their generated hostnames. - token-exception (REASONED; server): Token-authenticated requests bypass origin and XSRF checks; test browser protections with a cookie session and no token. - hub-admission (REASONED; hub, hub-docs): Hub 6.1.0+ or 5.5.3+ PAM example disables allow_all and allow_existing_users, lists allowed_users and empties allowed_groups, settings the 6.2.0 authenticator API page documents; other authenticators need separate review. - hub-floor (REASONED; hub, hub-5, advisories): Run Hub 6.1.0+ or 5.5.3+: GHSA-5jhc-4h3c-q97h lists up to 5.5.2 and 6.0.0 to before 6.1.0 as affected, and 5 more advisories share those patched releases; 6.1.1 and 5.5.4 fix a token-length regression and 6.2.0 and 5.5.5 other regressions, naming no advisory; not established: that 6.x carries the 5.5.0 to 5.5.2 fixes, and whether #5565 bears on GHSA-p43p-whwx-q52h. - hub-accounts (REASONED; server-docs, hub-docs, hub): LocalProcessSpawner needs matching Unix accounts; use separate users/servers, and avoid SimpleLocalProcessSpawner, which provides no user isolation. - hub-privilege-drop (REASONED; advisories, hub): GHSA-5jhc-4h3c-q97h (medium; patched 6.1.0 and 5.5.3): a failed setgroups could leave Hub groups; the 6.2.0 LocalProcessSpawner then attempts to clear supplemental groups and, after setuid, prints the failure to standard error and raises PermissionError if extra groups remain (#5584). - hub-limits (REASONED; hub-docs, hub): Local processes are not containers; LocalProcessSpawner does not enforce generic CPU/memory limits, and PAM does not automatically provide MFA. - hub-6-upgrade (REASONED; hub, hub-5): Hub 6 needs Python 3.10; notes say run jupyterhub upgrade-db; 6.1 adds auth_refresh_strict (default False; prior behavior True); oauth_require_pkce (default False) marked 6.0, config-tagged in 6.1.0; 5.5.5 has neither; unprefixed GET options mostly work except display_name, opt-a (pass opt-opt-a), mixed opt- use, future Hub keys; POST options go in user_options, else whole body if no display_name. - hub-upgrade-db (REASONED; hub, hub-5): From 6.2.0 upgrade_db defaults True, so the Hub upgrades the schema at startup; upgrade_db=False opts out; help text says --no-upgrade-db aborts on a schema mismatch and only SQLite is backed up automatically; downgrade-db is new; 5.5.5 keeps False; this guide infers a first 6.2.0 start upgrades a 5.x database, so back it up first; dbutil.py was not read. - hub-domains (REASONED; hub-docs, hub): For mutually untrusted Hub users, subdomain_host separates browser origins; provide HTTPS/DNS for every user domain on a separate application domain. - hub-cookies (REASONED; hub, hub-docs): cookie_host_prefix_enabled requires Hub 4.1+, HTTPS and per-user domains; the combined example targets Hub 6.1.0+ or 5.5.3+, and the 6.2.0 reference documents the setting. - hub-user-config (REASONED; hub-docs): disable_user_config ignores home configuration; administrator-controlled server executables, environments and launch paths prevent replacement; kernel environments may remain writable. - server-prefix (REASONED; server): Server base_url defaults to /; configure /jupyter/ consistently with the proxy so registered routes remain within the intended boundary. - hub-prefix (REASONED; hub, proxy): Hub prefix defaults to /; bind_url=http://127.0.0.1:8000/jupyter/ sets both listener and base_url at 6.2.0; conflicting URL settings or proxy prefix flags are not an auth boundary. - hub-unix-sockets (REASONED; hub, hub-5, proxy): At 6.2.0 bind_url accepts http+unix:// and the Hub passes --socket/--api-socket to configurable-http-proxy; 5.5.5 passes only --ip/--port and --api-ip/--api-port; the loopback bind_url uses neither; configurable-http-proxy 5.4.0 compatibility with 6.2.0 is not established. - server-headers (REASONED; server): Server trust_xheaders defaults False; enable only behind a private, sanitizing ingress owning forwarded address and scheme headers. - hub-headers (REASONED; hub): Hub enables forwarded-header trust at 6.2.0; hub_ip and trusted_downstream_ips constrain address selection, not which peers may send headers; empty trusted hops do not disable trust. - forwarded-scheme (REASONED; hub): Ingress must normalize Forwarded and both X- address/scheme forms and reject unintended Host values; at 6.2.0 get_browser_protocol reads Forwarded first; public_url and host-prefixed cookies guard specified scheme paths. - forwarded-host (REASONED; hub): Forwarded-header handling rewrites address/scheme, not Host; in the 6.2.0 files read, forwarded_host_header appears only as its trait definition; whether another file consumes X-Forwarded-Host is not established, so test it. - internal-tls (REASONED; hub): internal_ssl defaults False; enable it with protected persistent internal_certs_location for mutual TLS on Hub/proxy/single-user and spawn-readiness connections. - internal-certs (REASONED; hub-docs, hub): Spawners must deliver certificates and trust bundles; LocalProcessSpawner relocates them, remote spawners need move_certs, and ssl_alt_names must match connection names. - internal-hostnames (REASONED; hub, hub-5): Internal hostname verification defaults on from Hub 5.4; at 6.2.0 the aiohttp client context sets CERT_REQUIRED and check_hostname=True; 5.5.5 sets Tornado validate_cert=True, its hostname check inferred; spawn-readiness and proxy-side checks are not established at 6.2.0 or 5.5.5. - kernel-transport (REASONED; client, server): Hub internal_ssl excludes server-to-kernel ZeroMQ; HMAC authenticates without encryption; Server 2.20+ transport_encryption defaults to disabled and needs jupyter_client support not established for 8.6.3; local kernels bind loopback; remote kernels need separate encrypted transport. - native-store (REASONED; hub-docs, native): NativeAuthenticator replaces PAM credentials with bcrypt hashes in the Hub database, not the spawner's Unix account requirement; protect database/backups. - native-length (REASONED; native): minimum_password_length effectively defaults 0 despite tag(default=1); set 12; zero admits empty passwords. - native-common (REASONED; native): check_common_password defaults False; True checks the bundled 10,000-entry list, not a breach corpus. - native-lockout (REASONED; native): allowed_failed_logins defaults 0, disabling lockout; set 5 consecutive failures per account. - native-cooldown (REASONED; native): seconds_before_next_try effectively defaults 0 despite the 600 annotation/help; set 1200 and distinguish by a correct password about two seconds after failure. - native-counters (REASONED; native): Failure counters are in memory and reset on restart; timedelta.seconds ignores days and can re-block at 24-hour multiples until success or restart. - native-oracle (REASONED; native): Pending accounts still disclose a distinct correct-password authorization-pending message during lockout; rate-limit the actual prefixed /hub/login route. - native-policy-scope (REASONED; native): Password strength applies at signup/password change; raising policy does not recheck existing passwords. - native-approval (REASONED; native): open_signup defaults False; new pending users need /hub/authorize approval; True pre-authorizes signup, still subject to Hub admission. - native-preauthorization (REASONED; native): allowed_users/admin_users names are pre-authorized at first signup even with open_signup=False; deliberate registration is required; later allowlisting does not approve an existing pending record. - native-signup (REASONED; native): enable_signup defaults True; False refuses signup GET/POST with 404; restrict signup to trusted users. - native-usernames (REASONED; advisories, native): Self-service signup bears on GHSA-93gc-h923-mp36 (medium; patched 5.5.3 and 6.1.0), whose workaround is Authenticator.username_regex; whether NativeAuthenticator 1.3.0 works with Hub 6.2.0 or 5.5.5 is not established. - verify-tls (REASONED; curl): Validated HTTPS must complete with exit=0; transport failures alone prove nothing; use real hostname and CA, retaining certificate validation. - verify-api (REASONED; server-docs, server): Anonymous /api/kernels returns native 403 with auth, authenticated kernel JSON returns 200; fronting login may redirect, and password-only deployments need a browser positive control. - verify-host (REASONED; server): Wrong-Host token request is refused with native 403 while normal Host succeeds; a fronting rejection alone does not demonstrate Server Host checks. - verify-metrics (REASONED; server): Compare anonymous metrics with authenticate_prometheus off/on and authenticated metrics content; absent routes or generic errors are inconclusive. - verify-extension (REASONED; server-docs, server): Compare an actual unclassified extension route with allow_unauthenticated_access True/False and its authenticated content; protected kernel routes do not substitute. - verify-xsrf (REASONED; server, tornado): Cookie-session same-origin POST without XSRF creates a kernel when checks are disabled; fixed missing header gives 403, matching header gives 201 with successful cookie GET. - verify-origin (REASONED; server-docs, server): Wrong-Origin cookie GET returns kernel JSON with allow_origin=*; fixed APIHandler rejects with 404 while same-origin returns 200; also inspect browser CORS headers. - verify-cleanup (REASONED; lab): Record and shut down every disposable kernel created in either state through the authenticated UI. - verify-root (REASONED; server): Compare allowed versus refused root startup and inspect server/kernel ownership; fixed unprivileged execution must retain its intended nonzero UID. - verify-terminals (REASONED; server-docs, server): Compare authenticated /api/terminals enabled/disabled, retaining a working /api/kernels positive control and kernel shell execution. - verify-hub-admission (REASONED; hub, hub-docs): Compare valid extra and historical users against the allowlist, with allowed-user login and spawn as positive controls. - verify-hub-accounts (REASONED; hub-docs): Two users' server/kernel UIDs must differ as intended, compared with a shared-account fixture. - verify-hub-origins (REASONED; hub, hub-docs): Compare shared versus distinct user origins and inspect __Host- cookies for Secure, path / and no Domain attribute. - verify-hub-config (REASONED; hub-docs): Compare a user terminal override loading versus being ignored, deny server-environment writes, and retain a working notebook. - verify-listeners (REASONED; server): ss inventories local binds, not firewall/NAT; confirm actual private listener and external entry point with a working positive control; failed netlink inventory proves no absence. - port-default (REASONED; server): Standalone port starts at 8888 but configuration and retries can change it; inventory the actual listener. - verify-prefix (REASONED; hub, server): Probe prefixed and unprefixed Server/Hub routes; require no application alias outside the boundary and working authenticated prefixed access and kernel WebSocket. - verify-headers (REASONED; hub, server): Compare spoofed address/scheme/Forwarded headers through real ingress and direct backend access; inspect logs, redirects and cookies with login/WebSocket controls, and the OAuth state cookie's Set-Cookie, whose Secure comes from JUPYTERHUB_PUBLIC_URL, the scheme or, failing both, cookie_options; test a spoofed X-Forwarded-Host and real Host separately. - verify-internal-tls (REASONED; hub, hub-5, client): Compare each internal connection with TLS off/on; trusted client certificates succeed, untrusted certificates and wrong hostnames are expected to fail on Hub 5.4+; also try a system-trusted non-internal CA; keep kernel ZeroMQ excluded. - verify-native (REASONED; native): Test length/common-password, threshold/cooldown and pending-account oracle independently; compare approval with allow_all and empty allowed_users, first-signup preauthorization, disabled signup and stored bcrypt. ````json { "schema": 1, "checked": "2026-11-05", "documentation_checked": "2026-09", "body_sha256": "e2f48a37fb32ee9376df6c9543b5a8a26dc5029aa810a1e000537543ea44116f", "components": { "server": { "name": "Jupyter Server", "basis": "v2.21.1", "sources": { "sbb1ed2d8d382": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/serverapp.py", "sb273e7f052ec": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/__init__.py", "s8bbaa48f00fd": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/auth/identity.py", "s70c18acec73c": "https://github.com/jupyter-server/jupyter_server/blob/v2.21.1/jupyter_server/base/handlers.py", "sf3aa08d40bb0": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/base/handlers.py", "s52319d21e10e": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/services/kernels/handlers.py", "s3d4d1ac80620": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/services/kernels/kernelmanager.py", "sb03775f88157": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/jupyter_server/utils.py", "sa4b4df42d17e": "https://raw.githubusercontent.com/jupyter-server/jupyter_server/v2.21.1/CHANGELOG.md" } }, "hub": { "name": "JupyterHub", "basis": "6.2.0", "sources": { "s4fdde18a3419": "https://jupyterhub.readthedocs.io/en/6.2.0/howto/configuration/config-proxy.html", "sad5b905cdb59": "https://jupyterhub.readthedocs.io/en/6.2.0/reference/config-reference.html", "s7c71eace1738": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/reference/changelog.md", "s750b8fbe1494": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/app.py", "sb11724c432ce": "https://jupyterhub.readthedocs.io/en/6.2.0/reference/api/auth.html", "s87f961f32fea": "https://jupyterhub.readthedocs.io/en/6.2.0/reference/spawners.html", "sfa069065f2b4": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/utils.py", "s9ffc14c42d50": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/httpclient.py", "s96763a61285f": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/handlers/base.py", "s01a5aa50c6db": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/services/auth.py", "s3c238419926c": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/spawner.py", "s025029dbb373": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/jupyterhub/proxy.py", "sc42c961641ee": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/6.2.0/docs/source/howto/upgrading-v6.md" } }, "hub-5": { "name": "JupyterHub", "basis": "5.5.5", "sources": { "s15d683f540eb": "https://jupyterhub.readthedocs.io/en/5.5.5/reference/api/auth.html", "s0b2e76f05394": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/docs/source/reference/changelog.md", "sac762ff7fdad": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/app.py", "s2bc5bba2d6e8": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/utils.py", "s7c4fabcdf879": "https://raw.githubusercontent.com/jupyterhub/jupyterhub/5.5.5/jupyterhub/proxy.py" } }, "client": { "name": "jupyter_client", "basis": "8.6.3", "sources": { "s254bb75e8fcd": "https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/docs/messaging.rst", "s4a4cbff2fc52": "https://raw.githubusercontent.com/jupyter/jupyter_client/v8.6.3/jupyter_client/connect.py" } }, "lab": { "name": "JupyterLab documentation", "basis": "unknown", "sources": { "s8021535e4b8f": "https://jupyterlab.readthedocs.io/en/stable/user/running.html" } }, "server-docs": { "name": "Jupyter Server documentation (rolling)", "basis": "unknown", "sources": { "s7e2219e89598": "https://jupyter-server.readthedocs.io/en/latest/operators/public-server.html", "sc703a8030c8f": "https://jupyter-server.readthedocs.io/en/latest/operators/security.html", "se96d81c5de5a": "https://jupyter-server.readthedocs.io/en/latest/other/full-config.html", "s030744ca2941": "https://jupyter-server.readthedocs.io/en/latest/operators/migrate-from-nbserver.html", "s3036d65c135e": "https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.html", "s0d0d3fa341e9": "https://jupyter-server.readthedocs.io/en/stable/api/jupyter_server.auth.html", "s2e77936fa221": "https://jupyter-server.readthedocs.io/en/stable/developers/rest-api.html", "s3f8d489dcf1a": "https://jupyter-server.readthedocs.io/en/stable/other/changelog.html" } }, "notebook-docs": { "name": "Notebook documentation (rolling)", "basis": "unknown", "sources": { "s0650d57213a3": "https://jupyter-notebook.readthedocs.io/en/stable/configuring/config_overview.html" } }, "hub-docs": { "name": "JupyterHub documentation (rolling)", "basis": "unknown", "sources": { "s1a8d05a8cf13": "https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html", "s06a88ed8ee36": "https://jupyterhub.readthedocs.io/en/stable/explanation/websecurity.html", "sad07061feaf6": "https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html" } }, "proxy": { "name": "configurable-http-proxy", "basis": "5.4.0", "sources": { "s6b0b5bf4a8bf": "https://raw.githubusercontent.com/jupyterhub/configurable-http-proxy/5.4.0/bin/configurable-http-proxy" } }, "native": { "name": "NativeAuthenticator", "basis": "1.3.0", "sources": { "s7e8115fc47c7": "https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/nativeauthenticator.py", "s1cc87f8d9e78": "https://native-authenticator.readthedocs.io/en/1.3.0/quickstart.html", "s0bd461b12e33": "https://raw.githubusercontent.com/jupyterhub/nativeauthenticator/1.3.0/nativeauthenticator/handlers.py" } }, "tornado": { "name": "Tornado documentation", "basis": "unknown", "sources": { "s0f3c220a61fa": "https://www.tornadoweb.org/en/stable/guide/security.html" } }, "curl": { "name": "curl documentation", "basis": "unknown", "sources": { "s0dc5c76f65b4": "https://curl.se/docs/sslcerts.html", "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "advisories": { "name": "JupyterHub and Jupyter Server security advisories", "basis": "unknown", "sources": { "s158f4f874e25": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-5jhc-4h3c-q97h", "s6b64492f0407": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-93gc-h923-mp36", "se4b58a1cb487": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-hx46-2cv3-mh6g", "s47f46c11fe5a": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-pvm9-48cg-w76j", "scbb5f6fa0fbf": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-r764-xgvf-jrfj", "s108c721e1a63": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-x7xh-7hc7-9436", "sb2ca96572698": "https://github.com/advisories/GHSA-p43p-whwx-q52h", "scea3747ff64d": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-c4gm-pwx9-9w8j", "s2f69570e8f35": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-69wv-m5fw-2fhp", "s7cf89b38a85e": "https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-mj6q-mp44-53v8", "s6bc6c920eb04": "https://github.com/advisories/GHSA-24qx-w28j-9m6p" } } }, "claims": { "native-auth": {"text": "Generated tokens enable authentication by default; a password replaces the generated token by default, and configured token/password alternatives are not MFA.", "components": ["server-docs"], "sources": ["server-docs:sc703a8030c8f"], "status": "REASONED"}, "password": {"text": "Standalone Server password prompt stores a hash in jupyter_server_config.json; generate-config writes jupyter_server_config.py under the operating account.", "components": ["server-docs"], "sources": ["server-docs:s7e2219e89598", "server-docs:se96d81c5de5a"], "status": "REASONED"}, "endpoint-auth": {"text": "Set allow_unauthenticated_access=False for unclassified Jupyter handlers; explicitly public handlers and third-party authentication need separate review.", "components": ["server-docs", "server"], "sources": ["server-docs:s3036d65c135e", "server:sf3aa08d40bb0"], "status": "REASONED"}, "endpoint-default": {"text": "Reference lists allow_unauthenticated_access=True; feature appears under 2.13.0 and 2.18.0, with 2.18+ the guide baseline, not introduction date.", "components": ["server-docs"], "sources": ["server-docs:se96d81c5de5a", "server-docs:s3f8d489dcf1a"], "status": "REASONED"}, "metrics-auth": {"text": "authenticate_prometheus=True independently requires authentication for metrics.", "components": ["server", "server-docs"], "sources": ["server:s70c18acec73c", "server-docs:s3036d65c135e"], "status": "REASONED"}, "migration": {"text": "Notebook 7 uses Server; migrate NotebookApp to ServerApp and Server 2 password/token traits to PasswordIdentityProvider and IdentityProvider; avoid credential argv.", "components": ["server-docs", "notebook-docs"], "sources": ["server-docs:s030744ca2941", "server-docs:s0d0d3fa341e9", "notebook-docs:s0650d57213a3"], "status": "REASONED"}, "root": {"text": "allow_root=False refuses Unix root startup; use a dedicated unprivileged account for server and kernels; it neither changes UID nor sandboxes execution.", "components": ["server"], "sources": ["server:sbb1ed2d8d382"], "status": "REASONED"}, "terminals": {"text": "terminals_enabled=False removes terminals but not kernel shell execution; reference False differs from the implementation's dynamic True subject to availability.", "components": ["server-docs", "server"], "sources": ["server-docs:se96d81c5de5a", "server:sbb1ed2d8d382"], "status": "REASONED"}, "native-tls": {"text": "certfile/keyfile or --certfile/--keyfile enable native HTTPS; that listener no longer serves ordinary plaintext HTTP.", "components": ["server-docs", "server"], "sources": ["server-docs:s7e2219e89598", "server:sbb1ed2d8d382"], "status": "REASONED"}, "private-bind": {"text": "Retain loopback; do not expose 0.0.0.0 without authentication and HTTPS; disabling both password and token removes native authentication.", "components": ["server-docs"], "sources": ["server-docs:s7e2219e89598", "server-docs:sc703a8030c8f", "server-docs:se96d81c5de5a"], "status": "REASONED"}, "proxy-tls": {"text": "An authenticated HTTPS proxy before a loopback server can replace native TLS; authentication alone does not encrypt transport.", "components": ["hub"], "sources": ["hub:s4fdde18a3419"], "status": "REASONED"}, "mfa": {"text": "Standalone password is single-factor; fronting authentication or a Hub provider adds MFA only when explicitly enforced.", "components": ["server-docs", "hub-docs"], "sources": ["server-docs:sc703a8030c8f", "hub-docs:s1a8d05a8cf13"], "status": "REASONED"}, "origins": {"text": "Same-origin settings keep allow_origin and allow_origin_pat empty and allow_credentials=False; cross-origin clients require reviewed exceptions.", "components": ["server-docs"], "sources": ["server-docs:s3036d65c135e"], "status": "REASONED"}, "origin-cve": {"text": "Server 2.18.0 fixed GHSA-24qx-w28j-9m6p (CVE-2026-40110, high; affected up to 2.17.0); at v2.21.1 origin_matches_pat returns False for an empty pattern and otherwise requires re.fullmatch, so the empty allow_origin_pat matches no origin.", "components": ["server", "advisories"], "sources": ["server:sb03775f88157", "advisories:s6bc6c920eb04"], "status": "REASONED"}, "xsrf": {"text": "Keep disable_check_xsrf=False; disabling it removes browser-session request-forgery protection.", "components": ["server-docs", "server"], "sources": ["server-docs:s3036d65c135e", "server:sf3aa08d40bb0"], "status": "REASONED"}, "host": {"text": "allow_remote_access=False retains Host checks; local_hostnames permits actual standalone hostnames; Hub servers need their generated hostnames.", "components": ["server-docs", "server"], "sources": ["server-docs:se96d81c5de5a", "server:sf3aa08d40bb0"], "status": "REASONED"}, "token-exception": {"text": "Token-authenticated requests bypass origin and XSRF checks; test browser protections with a cookie session and no token.", "components": ["server"], "sources": ["server:s8bbaa48f00fd", "server:sf3aa08d40bb0"], "status": "REASONED"}, "hub-admission": {"text": "Hub 6.1.0+ or 5.5.3+ PAM example disables allow_all and allow_existing_users, lists allowed_users and empties allowed_groups, settings the 6.2.0 authenticator API page documents; other authenticators need separate review.", "components": ["hub", "hub-docs"], "sources": ["hub:sb11724c432ce", "hub-docs:s1a8d05a8cf13"], "status": "REASONED"}, "hub-floor": {"text": "Run Hub 6.1.0+ or 5.5.3+: GHSA-5jhc-4h3c-q97h lists up to 5.5.2 and 6.0.0 to before 6.1.0 as affected, and 5 more advisories share those patched releases; 6.1.1 and 5.5.4 fix a token-length regression and 6.2.0 and 5.5.5 other regressions, naming no advisory; not established: that 6.x carries the 5.5.0 to 5.5.2 fixes, and whether #5565 bears on GHSA-p43p-whwx-q52h.", "components": ["hub", "hub-5", "advisories"], "sources": ["hub:s7c71eace1738", "hub-5:s0b2e76f05394", "advisories:s158f4f874e25", "advisories:s6b64492f0407", "advisories:se4b58a1cb487", "advisories:s47f46c11fe5a", "advisories:scbb5f6fa0fbf", "advisories:s108c721e1a63", "advisories:sb2ca96572698", "advisories:scea3747ff64d", "advisories:s2f69570e8f35", "advisories:s7cf89b38a85e"], "status": "REASONED"}, "hub-accounts": {"text": "LocalProcessSpawner needs matching Unix accounts; use separate users/servers, and avoid SimpleLocalProcessSpawner, which provides no user isolation.", "components": ["server-docs", "hub-docs", "hub"], "sources": ["server-docs:s7e2219e89598", "hub-docs:sad07061feaf6", "hub:sad5b905cdb59"], "status": "REASONED"}, "hub-privilege-drop": {"text": "GHSA-5jhc-4h3c-q97h (medium; patched 6.1.0 and 5.5.3): a failed setgroups could leave Hub groups; the 6.2.0 LocalProcessSpawner then attempts to clear supplemental groups and, after setuid, prints the failure to standard error and raises PermissionError if extra groups remain (#5584).", "components": ["advisories", "hub"], "sources": ["advisories:s158f4f874e25", "hub:s3c238419926c"], "status": "REASONED"}, "hub-limits": {"text": "Local processes are not containers; LocalProcessSpawner does not enforce generic CPU/memory limits, and PAM does not automatically provide MFA.", "components": ["hub-docs", "hub"], "sources": ["hub-docs:s1a8d05a8cf13", "hub:s87f961f32fea"], "status": "REASONED"}, "hub-6-upgrade": {"text": "Hub 6 needs Python 3.10; notes say run jupyterhub upgrade-db; 6.1 adds auth_refresh_strict (default False; prior behavior True); oauth_require_pkce (default False) marked 6.0, config-tagged in 6.1.0; 5.5.5 has neither; unprefixed GET options mostly work except display_name, opt-a (pass opt-opt-a), mixed opt- use, future Hub keys; POST options go in user_options, else whole body if no display_name.", "components": ["hub", "hub-5"], "sources": ["hub:sc42c961641ee", "hub:sb11724c432ce", "hub:s750b8fbe1494", "hub:s7c71eace1738", "hub-5:s15d683f540eb", "hub-5:sac762ff7fdad"], "status": "REASONED"}, "hub-upgrade-db": {"text": "From 6.2.0 upgrade_db defaults True, so the Hub upgrades the schema at startup; upgrade_db=False opts out; help text says --no-upgrade-db aborts on a schema mismatch and only SQLite is backed up automatically; downgrade-db is new; 5.5.5 keeps False; this guide infers a first 6.2.0 start upgrades a 5.x database, so back it up first; dbutil.py was not read.", "components": ["hub", "hub-5"], "sources": ["hub:s750b8fbe1494", "hub:s7c71eace1738", "hub:sc42c961641ee", "hub:sad5b905cdb59", "hub-5:sac762ff7fdad"], "status": "REASONED"}, "hub-domains": {"text": "For mutually untrusted Hub users, subdomain_host separates browser origins; provide HTTPS/DNS for every user domain on a separate application domain.", "components": ["hub-docs", "hub"], "sources": ["hub-docs:s06a88ed8ee36", "hub:sad5b905cdb59"], "status": "REASONED"}, "hub-cookies": {"text": "cookie_host_prefix_enabled requires Hub 4.1+, HTTPS and per-user domains; the combined example targets Hub 6.1.0+ or 5.5.3+, and the 6.2.0 reference documents the setting.", "components": ["hub", "hub-docs"], "sources": ["hub:sad5b905cdb59", "hub-docs:s06a88ed8ee36"], "status": "REASONED"}, "hub-user-config": {"text": "disable_user_config ignores home configuration; administrator-controlled server executables, environments and launch paths prevent replacement; kernel environments may remain writable.", "components": ["hub-docs"], "sources": ["hub-docs:sad07061feaf6", "hub-docs:s06a88ed8ee36"], "status": "REASONED"}, "server-prefix": {"text": "Server base_url defaults to /; configure /jupyter/ consistently with the proxy so registered routes remain within the intended boundary.", "components": ["server"], "sources": ["server:sbb1ed2d8d382"], "status": "REASONED"}, "hub-prefix": {"text": "Hub prefix defaults to /; bind_url=http://127.0.0.1:8000/jupyter/ sets both listener and base_url at 6.2.0; conflicting URL settings or proxy prefix flags are not an auth boundary.", "components": ["hub", "proxy"], "sources": ["hub:s4fdde18a3419", "proxy:s6b0b5bf4a8bf", "hub:sad5b905cdb59", "hub:s750b8fbe1494"], "status": "REASONED"}, "hub-unix-sockets": {"text": "At 6.2.0 bind_url accepts http+unix:// and the Hub passes --socket/--api-socket to configurable-http-proxy; 5.5.5 passes only --ip/--port and --api-ip/--api-port; the loopback bind_url uses neither; configurable-http-proxy 5.4.0 compatibility with 6.2.0 is not established.", "components": ["hub", "hub-5", "proxy"], "sources": ["hub:s025029dbb373", "hub:s750b8fbe1494", "hub:sc42c961641ee", "hub:s7c71eace1738", "hub-5:s7c4fabcdf879", "proxy:s6b0b5bf4a8bf"], "status": "REASONED"}, "server-headers": {"text": "Server trust_xheaders defaults False; enable only behind a private, sanitizing ingress owning forwarded address and scheme headers.", "components": ["server"], "sources": ["server:sbb1ed2d8d382"], "status": "REASONED"}, "hub-headers": {"text": "Hub enables forwarded-header trust at 6.2.0; hub_ip and trusted_downstream_ips constrain address selection, not which peers may send headers; empty trusted hops do not disable trust.", "components": ["hub"], "sources": ["hub:sad5b905cdb59", "hub:s750b8fbe1494"], "status": "REASONED"}, "forwarded-scheme": {"text": "Ingress must normalize Forwarded and both X- address/scheme forms and reject unintended Host values; at 6.2.0 get_browser_protocol reads Forwarded first; public_url and host-prefixed cookies guard specified scheme paths.", "components": ["hub"], "sources": ["hub:sad5b905cdb59", "hub:sfa069065f2b4"], "status": "REASONED"}, "forwarded-host": {"text": "Forwarded-header handling rewrites address/scheme, not Host; in the 6.2.0 files read, forwarded_host_header appears only as its trait definition; whether another file consumes X-Forwarded-Host is not established, so test it.", "components": ["hub"], "sources": ["hub:sad5b905cdb59", "hub:s750b8fbe1494", "hub:s96763a61285f"], "status": "REASONED"}, "internal-tls": {"text": "internal_ssl defaults False; enable it with protected persistent internal_certs_location for mutual TLS on Hub/proxy/single-user and spawn-readiness connections.", "components": ["hub"], "sources": ["hub:sad5b905cdb59", "hub:s750b8fbe1494"], "status": "REASONED"}, "internal-certs": {"text": "Spawners must deliver certificates and trust bundles; LocalProcessSpawner relocates them, remote spawners need move_certs, and ssl_alt_names must match connection names.", "components": ["hub-docs", "hub"], "sources": ["hub-docs:sad07061feaf6", "hub:sad5b905cdb59"], "status": "REASONED"}, "internal-hostnames": {"text": "Internal hostname verification defaults on from Hub 5.4; at 6.2.0 the aiohttp client context sets CERT_REQUIRED and check_hostname=True; 5.5.5 sets Tornado validate_cert=True, its hostname check inferred; spawn-readiness and proxy-side checks are not established at 6.2.0 or 5.5.5.", "components": ["hub", "hub-5"], "sources": ["hub:s7c71eace1738", "hub:s750b8fbe1494", "hub:sfa069065f2b4", "hub:s9ffc14c42d50", "hub:sc42c961641ee", "hub-5:sac762ff7fdad", "hub-5:s2bc5bba2d6e8"], "status": "REASONED"}, "kernel-transport": {"text": "Hub internal_ssl excludes server-to-kernel ZeroMQ; HMAC authenticates without encryption; Server 2.20+ transport_encryption defaults to disabled and needs jupyter_client support not established for 8.6.3; local kernels bind loopback; remote kernels need separate encrypted transport.", "components": ["client", "server"], "sources": ["client:s254bb75e8fcd", "client:s4a4cbff2fc52", "server:s3d4d1ac80620", "server:sa4b4df42d17e"], "status": "REASONED"}, "native-store": {"text": "NativeAuthenticator replaces PAM credentials with bcrypt hashes in the Hub database, not the spawner's Unix account requirement; protect database/backups.", "components": ["hub-docs", "native"], "sources": ["hub-docs:sad07061feaf6", "native:s7e8115fc47c7", "native:s1cc87f8d9e78"], "status": "REASONED"}, "native-length": {"text": "minimum_password_length effectively defaults 0 despite tag(default=1); set 12; zero admits empty passwords.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-common": {"text": "check_common_password defaults False; True checks the bundled 10,000-entry list, not a breach corpus.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-lockout": {"text": "allowed_failed_logins defaults 0, disabling lockout; set 5 consecutive failures per account.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-cooldown": {"text": "seconds_before_next_try effectively defaults 0 despite the 600 annotation/help; set 1200 and distinguish by a correct password about two seconds after failure.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-counters": {"text": "Failure counters are in memory and reset on restart; timedelta.seconds ignores days and can re-block at 24-hour multiples until success or restart.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-oracle": {"text": "Pending accounts still disclose a distinct correct-password authorization-pending message during lockout; rate-limit the actual prefixed /hub/login route.", "components": ["native"], "sources": ["native:s7e8115fc47c7", "native:s0bd461b12e33"], "status": "REASONED"}, "native-policy-scope": {"text": "Password strength applies at signup/password change; raising policy does not recheck existing passwords.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-approval": {"text": "open_signup defaults False; new pending users need /hub/authorize approval; True pre-authorizes signup, still subject to Hub admission.", "components": ["native"], "sources": ["native:s7e8115fc47c7", "native:s0bd461b12e33"], "status": "REASONED"}, "native-preauthorization": {"text": "allowed_users/admin_users names are pre-authorized at first signup even with open_signup=False; deliberate registration is required; later allowlisting does not approve an existing pending record.", "components": ["native"], "sources": ["native:s7e8115fc47c7"], "status": "REASONED"}, "native-signup": {"text": "enable_signup defaults True; False refuses signup GET/POST with 404; restrict signup to trusted users.", "components": ["native"], "sources": ["native:s7e8115fc47c7", "native:s0bd461b12e33"], "status": "REASONED"}, "native-usernames": {"text": "Self-service signup bears on GHSA-93gc-h923-mp36 (medium; patched 5.5.3 and 6.1.0), whose workaround is Authenticator.username_regex; whether NativeAuthenticator 1.3.0 works with Hub 6.2.0 or 5.5.5 is not established.", "components": ["advisories", "native"], "sources": ["advisories:s6b64492f0407", "native:s7e8115fc47c7"], "status": "REASONED"}, "verify-tls": {"text": "Validated HTTPS must complete with exit=0; transport failures alone prove nothing; use real hostname and CA, retaining certificate validation.", "components": ["curl"], "sources": ["curl:s0dc5c76f65b4", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-api": {"text": "Anonymous /api/kernels returns native 403 with auth, authenticated kernel JSON returns 200; fronting login may redirect, and password-only deployments need a browser positive control.", "components": ["server-docs", "server"], "sources": ["server-docs:sc703a8030c8f", "server:sf3aa08d40bb0", "server-docs:s2e77936fa221"], "status": "REASONED", "verify": [1]}, "verify-host": {"text": "Wrong-Host token request is refused with native 403 while normal Host succeeds; a fronting rejection alone does not demonstrate Server Host checks.", "components": ["server"], "sources": ["server:sf3aa08d40bb0"], "status": "REASONED", "verify": [1]}, "verify-metrics": {"text": "Compare anonymous metrics with authenticate_prometheus off/on and authenticated metrics content; absent routes or generic errors are inconclusive.", "components": ["server"], "sources": ["server:s70c18acec73c"], "status": "REASONED", "verify": [1]}, "verify-extension": {"text": "Compare an actual unclassified extension route with allow_unauthenticated_access True/False and its authenticated content; protected kernel routes do not substitute.", "components": ["server-docs", "server"], "sources": ["server-docs:s3036d65c135e", "server:sf3aa08d40bb0"], "status": "REASONED", "verify": [1]}, "verify-xsrf": {"text": "Cookie-session same-origin POST without XSRF creates a kernel when checks are disabled; fixed missing header gives 403, matching header gives 201 with successful cookie GET.", "components": ["server", "tornado"], "sources": ["server:sf3aa08d40bb0", "server:s52319d21e10e", "tornado:s0f3c220a61fa"], "status": "REASONED", "verify": [2]}, "verify-origin": {"text": "Wrong-Origin cookie GET returns kernel JSON with allow_origin=*; fixed APIHandler rejects with 404 while same-origin returns 200; also inspect browser CORS headers.", "components": ["server-docs", "server"], "sources": ["server-docs:s3036d65c135e", "server:sf3aa08d40bb0"], "status": "REASONED", "verify": [2]}, "verify-cleanup": {"text": "Record and shut down every disposable kernel created in either state through the authenticated UI.", "components": ["lab"], "sources": ["lab:s8021535e4b8f"], "status": "REASONED"}, "verify-root": {"text": "Compare allowed versus refused root startup and inspect server/kernel ownership; fixed unprivileged execution must retain its intended nonzero UID.", "components": ["server"], "sources": ["server:sbb1ed2d8d382"], "status": "REASONED"}, "verify-terminals": {"text": "Compare authenticated /api/terminals enabled/disabled, retaining a working /api/kernels positive control and kernel shell execution.", "components": ["server-docs", "server"], "sources": ["server-docs:se96d81c5de5a", "server:sbb1ed2d8d382"], "status": "REASONED"}, "verify-hub-admission": {"text": "Compare valid extra and historical users against the allowlist, with allowed-user login and spawn as positive controls.", "components": ["hub", "hub-docs"], "sources": ["hub:sb11724c432ce", "hub-docs:s1a8d05a8cf13"], "status": "REASONED"}, "verify-hub-accounts": {"text": "Two users' server/kernel UIDs must differ as intended, compared with a shared-account fixture.", "components": ["hub-docs"], "sources": ["hub-docs:sad07061feaf6"], "status": "REASONED"}, "verify-hub-origins": {"text": "Compare shared versus distinct user origins and inspect __Host- cookies for Secure, path / and no Domain attribute.", "components": ["hub", "hub-docs"], "sources": ["hub:sad5b905cdb59", "hub-docs:s06a88ed8ee36"], "status": "REASONED"}, "verify-hub-config": {"text": "Compare a user terminal override loading versus being ignored, deny server-environment writes, and retain a working notebook.", "components": ["hub-docs"], "sources": ["hub-docs:sad07061feaf6", "hub-docs:s06a88ed8ee36"], "status": "REASONED"}, "verify-listeners": {"text": "ss inventories local binds, not firewall/NAT; confirm actual private listener and external entry point with a working positive control; failed netlink inventory proves no absence.", "components": ["server"], "sources": ["server:sbb1ed2d8d382", "server:sb273e7f052ec"], "status": "REASONED", "verify": [3]}, "port-default": {"text": "Standalone port starts at 8888 but configuration and retries can change it; inventory the actual listener.", "components": ["server"], "sources": ["server:sbb1ed2d8d382", "server:sb273e7f052ec"], "status": "REASONED"}, "verify-prefix": {"text": "Probe prefixed and unprefixed Server/Hub routes; require no application alias outside the boundary and working authenticated prefixed access and kernel WebSocket.", "components": ["hub", "server"], "sources": ["hub:s4fdde18a3419", "server:sbb1ed2d8d382"], "status": "REASONED"}, "verify-headers": {"text": "Compare spoofed address/scheme/Forwarded headers through real ingress and direct backend access; inspect logs, redirects and cookies with login/WebSocket controls, and the OAuth state cookie's Set-Cookie, whose Secure comes from JUPYTERHUB_PUBLIC_URL, the scheme or, failing both, cookie_options; test a spoofed X-Forwarded-Host and real Host separately.", "components": ["hub", "server"], "sources": ["hub:sad5b905cdb59", "server:sbb1ed2d8d382", "hub:s750b8fbe1494", "hub:s01a5aa50c6db", "hub:s96763a61285f", "hub:s3c238419926c"], "status": "REASONED"}, "verify-internal-tls": {"text": "Compare each internal connection with TLS off/on; trusted client certificates succeed, untrusted certificates and wrong hostnames are expected to fail on Hub 5.4+; also try a system-trusted non-internal CA; keep kernel ZeroMQ excluded.", "components": ["hub", "hub-5", "client"], "sources": ["hub:s7c71eace1738", "hub:s750b8fbe1494", "hub:sfa069065f2b4", "hub-5:s2bc5bba2d6e8", "client:s254bb75e8fcd"], "status": "REASONED"}, "verify-native": {"text": "Test length/common-password, threshold/cooldown and pending-account oracle independently; compare approval with allow_all and empty allowed_users, first-signup preauthorization, disabled signup and stored bcrypt.", "components": ["native"], "sources": ["native:s7e8115fc47c7", "native:s0bd461b12e33"], "status": "REASONED"} } } ```` ====================================================================== ==> ollama.md ====================================================================== # Ollama: it has no built-in authentication or TLS Ollama runs AI language models on your own machine and serves them to other apps through a local web API. This guide helps your AI assistant configure it securely, including a private listener, login, TLS and MFA at a fronting layer, model management limited to administrators, and bounded inference load. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/ollama.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Ollama v0.40.14, Ollama (go list sweep and go.sum reading) v0.34.2, Docker Engine localhost boundary 28.0, systemd v257, Go go1.26.5, go1.26.0, go1.26.1, go1.26.2, go1.26.3, go1.26.4, go1.26.6, go1.26.7, go1.26.8, go1.26.9, and go1.26.10, and github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48, plus 3 more recorded bases; 7 further sources carry no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 79 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Ollama's local HTTP API has **no native inbound authentication and no TLS**. A reachable listener gives callers inference and management capabilities, including model acquisition, replacement, export, and deletion. Model metadata may disclose templates and, by this guide's source reading (section 9), system prompts. Cloud credentials can also authorize upstream use. These capabilities do not establish arbitrary access to every host file. The security boundary for remote access is an authenticating proxy or tunnel, with a policy that separates inference from administration. See the [authentication reference](https://docs.ollama.com/api/authentication) and [server implementation](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go). This guide's baseline is **Ollama v0.40.14**, published on 3 November 2026 and identified as the newest release on 4 November 2026, at commit **`9a3e8177767245acc62b735eba7d5d8a553606d0`**. The GitHub release records fetched for this guide on 5 November 2026 list no later release other than the v0.40.15-rc1 prerelease. The guide earlier rested on v0.34.2; the `go list` sweeps and dependency reading in section 9 were made at v0.34.2 and say so. Review subsequent releases before changing the deployment pin. See the [release](https://github.com/ollama/ollama/releases/tag/v0.40.14) and [release commit](https://github.com/ollama/ollama/commit/9a3e8177767245acc62b735eba7d5d8a553606d0). The standalone binary defaults to `127.0.0.1:11434`. The official Docker image instead sets `OLLAMA_HOST=0.0.0.0:11434` inside the container. Under ordinary bridge networking, a bare `-p 11434:11434` publishes that unauthenticated API on every host interface by default. Container binding and host publication are different controls. See the [pinned FAQ](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx), [Dockerfile](https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile), and [Docker publication reference](https://docs.docker.com/engine/network/port-publishing/). Rules: 1. Leave the host listener on loopback unless a restricted private path is required. 2. Never expose an unprotected wildcard host listener. Free compute, model tampering, model export, disclosed metadata, and unintended upstream use are meaningful exposure. 3. Publish only an authenticated TLS boundary. Restrict inference identities to the exact inference operations they need. The numbered settings build one deployment. Merge the selected systemd settings into one drop-in and choose one fronting authentication arrangement. The proxy alternatives are replacements, not additional public paths. ## 1. Make the private listener explicit **Tier-1 rationale:** an independently reachable backend bypasses every authentication and transport control at the proxy. For a host-installed systemd service, use `systemctl edit ollama.service`: ```ini [Service] Environment="OLLAMA_HOST=127.0.0.1:11434" ``` After assembling the settings below, run `systemctl daemon-reload` and `systemctl restart ollama.service` with the required administrative privileges. Setting the variable in an interactive shell does not change an already running systemd service. See the [Linux environment instructions](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx). For the official image under bridge networking, retain its internal wildcard listener and use this publication with a host-run proxy: ```text -p 127.0.0.1:11434:11434 ``` Use Docker Engine **28.0 or later** for this localhost-publication boundary. Before 28.0, other hosts on the same layer-2 segment can reach localhost-published ports; upgrade or leave the port unpublished. Alternatively, omit publication and attach the proxy to a restricted container network. Address Ollama by its container name there: a proxy container's own `127.0.0.1` does not reach Ollama. See [Docker port publishing](https://docs.docker.com/engine/network/port-publishing/). With `--network host`, Docker ignores `-p`; there is no publication address to constrain. Override the image's `OLLAMA_HOST` with loopback or the deliberately selected private interface, and restrict that private interface to authorized peers. Loopback permits local callers. An unpublished container remains reachable by permitted network peers. Inspect IPv4, IPv6, every publication, and additional network attachments. A missing `ss` listener does not establish that Docker NAT publication is absent. **Exposed/fixed comparison, REASONED:** no deployed Ollama host or external observer is available here. An outside observer can obtain an HTTP response from the exposed backend on 11434; after the fix, that direct path is unreachable while the approved proxy path completes inference. ## 2. Put authentication, TLS, and MFA at the fronting layer **Tier-1 rationale:** every accepted remote request needs an authenticated caller and protected transport. The local API does not require authentication. Ollama cloud API keys and signing in concern upstream services; they do not protect the local listener. Despite its name, **`OLLAMA_AUTH` is a client-side outbound-signing setting, not an inbound server-authentication switch**. See the [authentication reference](https://docs.ollama.com/api/authentication) and [pinned client implementation](https://github.com/ollama/ollama/blob/v0.40.14/api/client.go). Ollama serves plaintext HTTP. An `https://` prefix in `OLLAMA_HOST` does not install certificates or enable server TLS. See [server startup](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go) and [HTTP serving](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go). ### Option A: reverse proxy with TLS and Basic authentication Keep Ollama on loopback and publish only the proxy. This nginx fragment belongs inside the existing `http` context; see [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) for the surrounding configuration. It includes C3's inference-only policy and C6's example admission budgets: ```nginx # http context: define each zone once. limit_req_zone $binary_remote_addr zone=ollama_rate:10m rate=2r/s; limit_conn_zone $server_name zone=ollama_active:10m; server { listen 443 ssl; server_name ollama.example.com; ssl_certificate /etc/letsencrypt/live/ollama.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ollama.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; auth_basic "Ollama"; auth_basic_user_file /etc/nginx/.htpasswd; location = /api/chat { if ($request_method != POST) { return 405; } client_max_body_size 1m; limit_req zone=ollama_rate burst=4 nodelay; limit_req_status 429; limit_conn ollama_active 4; limit_conn_status 429; proxy_pass http://127.0.0.1:11434; proxy_set_header Host localhost:11434; proxy_read_timeout 300s; } location / { return 404; } } ``` Substitute the hostname and certificate paths. Generate password entries with `htpasswd -B -C 12`, using its interactive password prompt and the intended password-file path and username. The prompt keeps the password out of the command text, shell history and shell tracing; the account owner and root can still read it in the `htpasswd` process while it runs. Bare `-B` defaults to bcrypt cost 5, below OWASP's minimum of 10; regenerate existing cost-5 hashes. Protect the password file and its backups. See [nginx Basic authentication](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html), [htpasswd](https://httpd.apache.org/docs/2.4/programs/htpasswd.html), and [OWASP password storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html). Authentication is at `server` scope so every permitted location inherits it. Do not disable it in an added inference location. A denied path or method can return 404 or 405 before authentication; those responses do not imply access to Ollama. Retain `proxy_set_header Host localhost:11434;` in every proxied location. The relevant loopback-bound host middleware can reject a public hostname with 403. This rewrite makes that middleware accept the proxy's backend request; **it is not authentication**. See the [pinned host middleware](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go). `proxy_read_timeout 300s` permits slow model responses but limits the interval between upstream reads. It is not a five-minute generation deadline: continuous streaming can outlast it. Enforce any total execution deadline in the application or gateway and propagate cancellation. See the [timeout reference](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout). TLS termination protects the client-to-proxy leg. If the proxy and Ollama are on different hosts, protect the backend transport with a tunnel or another verified encrypted transport. See [nginx TLS configuration](https://nginx.org/en/docs/http/ngx_http_ssl_module.html). ### Caddy alternative The corresponding authentication and path policy in [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) is: ```caddyfile ollama.example.com { @chat { path /api/chat method POST } handle @chat { basic_auth { admin $2a$14$REPLACE_WITH_HASH_FROM_caddy_hash-password } reverse_proxy 127.0.0.1:11434 { header_up Host localhost:11434 } } handle { respond 404 } } ``` Generate the hash with `caddy hash-password`. Run it without `--plaintext`, which would put the password in its arguments, and enter the password at its prompt. The prompt keeps the password out of the command text, shell history and shell tracing; the account owner and root can still read it in the `caddy` process while it runs. The exact matcher and fallback keep other paths private. Stock Caddy has no built-in rate-limiting directive; provide the admission and resource controls in C6 through a suitable fronting layer before publishing this alternative. See [Basic authentication](https://caddyserver.com/docs/caddyfile/directives/basic_auth), [request matchers](https://caddyserver.com/docs/caddyfile/matchers), [exclusive handles](https://caddyserver.com/docs/caddyfile/directives/handle), and [reverse proxy configuration](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy). ### Bearer-only clients For clients that only send bearer tokens, replace the Basic-auth directives and the two locations in the nginx TLS server with the following. Retain its certificates, protocols, and the `http`-context zones: ```nginx auth_basic off; location = /api/chat { if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM_TOKEN") { return 401; } if ($request_method != POST) { return 405; } client_max_body_size 1m; limit_req zone=ollama_rate burst=4 nodelay; limit_req_status 429; limit_conn ollama_active 4; limit_conn_status 429; proxy_pass http://127.0.0.1:11434; proxy_set_header Host localhost:11434; proxy_read_timeout 300s; } location / { return 404; } ``` Generate the token per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) and keep it out of the repository. The exact comparison followed by `return 401` is valid nginx syntax, but it is a **shared static credential stored in configuration, with no constant-time verification guarantee**. Protect configuration dumps, backups, and deployment output. Prefer Basic authentication or an Access service token where suitable. See the [nginx rewrite module](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html). Apply this authentication check to **every** permitted location added later. Do not retain the former catch-all proxy alongside the allowlist. A token-checking catch-all authenticates management operations too, granting token holders more authority than inference requires. ### Mutual TLS for machine clients An alternative authentication choice inside the existing TLS server is: ```nginx ssl_client_certificate /etc/nginx/ollama-client-ca.pem; ssl_verify_client on; ``` Use a dedicated client CA and define certificate issuance, expiry, and revocation procedures. Remove the Basic-auth requirement if choosing mTLS alone. Combining Basic authentication and mTLS is valid when deliberate; copying fragments must not make both accidental requirements. Retain the inference allowlist and admission controls. See [nginx client-certificate verification](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_verify_client). ### Option B: Cloudflare Tunnel with Access Follow [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) and add an Access policy, or a service-token policy for API clients, on the published hostname. The [Ollama FAQ](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx) documents tunnelling to `http://localhost:11434`; adding Access authenticates that route, but a direct tunnel to Ollama still gives valid callers its management surface. For the inference-only deployment, point the tunnel at the local policy proxy instead. Adapt the nginx server above to `listen 127.0.0.1:11435;`, remove its TLS directives, and remove its Basic-auth directives when Access is the selected authentication boundary. Retain the exact locations, limits, and upstream Host rewrite. Route the tunnel to `http://127.0.0.1:11435`. Keep both local listeners unreachable from untrusted peers. On a Mac running the Ollama desktop app, its Claude Desktop gateway, when on, also uses `127.0.0.1:11435` (section 9): this guide's inference, not observed, is that the gateway and this proxy would both try to bind that port. Retain the tunnel route's HTTP Host Header setting of `localhost:11434`, named `originRequest.httpHostHeader` in a config-file tunnel. The final proxy hop also sets that Host header before reaching Ollama. See [origin parameters](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/origin-parameters/) and [Access service tokens](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/). Access authentication does not itself supply the inference rate, concurrency, or resource budgets. Retain C6's controls; [Cloudflare WAF rate-limiting rules](https://developers.cloudflare.com/waf/rate-limiting-rules/) can provide an additional rate boundary. Configure trusted client-address handling if rate accounting must distinguish callers behind the connector. MFA: Ollama has no local login, so a second factor comes from the fronting layer: an Access policy backed by an MFA-enforcing identity provider, or an [Authelia](https://www.authelia.com/)-protected proxy. See [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Machine service tokens are not a human MFA flow. **Exposed/fixed comparison, REASONED:** no live proxy, credentials, certificates, or Access deployment is available here. An unprotected listener accepts anonymous inference or exposes credentials over plaintext; the fixed remote path rejects missing or invalid credentials and uses verified TLS for accepted requests. ## 3. Reserve management for administrators **Tier-1 rationale:** an inference credential must not authorize model replacement, deletion, uploads, or server-initiated transfers. The C2 server permits only native chat. Its essential routing policy is: ```nginx # Inside the existing authenticated TLS server. # Retain C6's admission directives in this permitted location. location = /api/chat { if ($request_method != POST) { return 405; } proxy_pass http://127.0.0.1:11434; proxy_set_header Host localhost:11434; proxy_read_timeout 300s; } location / { return 404; } ``` This illustrates the routing component already assembled in C2; do not add duplicate locations. For bearer authentication, retain the token check from C2 inside each permitted location. Add separately reviewed exact locations only when the application requires them, for example `/api/generate` or `/v1/chat/completions`. Repeat method restrictions, authentication, admission limits, and upstream settings. Allowing `/api/` or `/v1/` wholesale defeats this policy. See nginx [location selection](https://nginx.org/en/docs/http/ngx_http_core_module.html#location) and [return directives](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html). **A public `/api/tags` may intentionally return 404 even with valid credentials.** Use an allowed inference operation to verify public authentication, and the private administrative path to inspect model inventory. Keep these operations private: | API | Authority to keep private | |---|---| | `POST /api/pull` | Fetch models and consume local storage and network capacity. | | `POST /api/push` | Upload model material; upstream registry authorization is separate. | | `POST /api/create` | Create or replace model definitions and process uploaded model files. | | `POST /api/copy` | Create another model name. | | `DELETE /api/delete` | Remove models and associated data. | | `POST /api/blobs/:digest` | Upload bytes into the model store. | | `HEAD /api/blobs/:digest` | Discover whether a particular blob exists. | Methods and blob semantics come from the [pinned API reference](https://github.com/ollama/ollama/blob/v0.40.14/docs/api.md). Administrators can use the loopback endpoint from a restricted host session, or a separately authenticated and network-restricted administrative path. An inference credential must not grant access to that path. Also exclude account operations, experimental operations, diagnostics, and unused compatibility routes. At v0.40.14 the server [registers](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2385-L2450) `/api/me`, `GET /api/balance`, `GET /api/usage`, `/api/signout`, `/api/user/keys/:encodedKey`, `/api/experimental/web_search`, `/api/experimental/web_fetch`, `GET /api/experimental/model-recommendations`, and a desktop proxy route under `/api/codex`, behind only the router's CORS and host middleware. It also registers `POST /v1/systemone`, and `POST /v1/audio/translations` and `POST /v1/audio/speech` beside `POST /v1/audio/transcriptions`. Default denial avoids maintaining an exhaustive blacklist. See [route registration](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go). The balance and usage routes [act as the account signed into the server, whoever calls them](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2684-L2696): each handler carries the comment `// Use the account signed into this server, regardless of caller credentials.`, runs `c.Request.Header.Del("Authorization")` and passes the request to the cloud proxy. [That proxy](https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L176-L226) answers `403` when the server's cloud status reads as disabled; otherwise it sends the caller's path and query to [`https://ollama.com:443` by default](https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L28-L31), after [signing the request with `auth.Sign` and setting that signature as `Authorization`](https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L408-L422) when the target host is the signing host, `ollama.com` by default. This guide's inference, not observed: any caller that passes the Host and CORS checks can read the signed-in account's balance and usage, signed with the server's own key. Whether `OLLAMA_NO_CLOUD` sets the cloud status that check reads is not established, because `internal/cloud`, which defines it, was not read. Current create requests map filenames to uploaded blob digests. The implementation validates relative filenames and digest paths. Historical arbitrary host-file read/write reports must not be presented as documented v0.40.14 functionality. See [create validation](https://github.com/ollama/ollama/blob/v0.40.14/server/create.go) and [blob-path validation](https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go). Pull, push, and creation still provide server-side transfer and storage authority: an **SSRF-relevant egress and disk-fill surface**. At v0.40.14 the registry request helper's [default redirect policy](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1753-L1822) is not strictly same-host. It first refuses any redirect after the 10th with `errMaxRedirectsExceeded`. Within that limit, it accepts a redirect to the same host, or between two hosts that each equal, or are a subdomain of, an entry in `allowedRedirectHosts = []string{"ollama.com", "ollama.ai", "hf.co", "huggingface.co"}`, and otherwise returns `errBlockedRedirect`. With `insecure`, it skips that host check and accepts a redirect to any host within the same 10-redirect limit, and the helper also switches the scheme of the URL it is given to `http`, but not the scheme of a redirect it follows (section 7). That default applies only when the caller sets no redirect check of its own. A pull's blob download [sets its own](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L336-L349) only on [the GET request that finds the blob's direct URL](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L219-L331), with `newOpts.CheckRedirect = downloadDiscoveryRedirect(requestURL)`. Within the same 10-redirect limit, after a `transfer.ValidateRedirectScheme` check, that GET follows only redirects to the same scheme, host and port. When no `-partial-*` part files exist yet, the HEAD size request that [`Prepare` in `server/download.go`](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L131-L186) sends first, `makeRequestWithRetry(ctx, http.MethodHead, requestURL, nil, nil, opts)` at line 151, carries the caller's options, on which `server/` sets no `CheckRedirect`, so the default policy above governs it. The downloader then [passes any other redirect target to `transfer.ValidateRedirectTarget`](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L219-L331). The `transfer` package was not read, so where a pulled blob's download may be redirected is not established. This is not evidence of an unrestricted, demonstrated SSRF vulnerability. Blocking public management also prevents inference callers from selecting insecure transfers. See [transfer implementation](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go). A path allowlist does not constrain model names or generation parameters inside allowed requests. C6 and C7 address those boundaries. **Exposed/fixed comparison, REASONED:** no live Ollama, proxy, or disposable model fixtures are available here. A valid credential on the former catch-all proxy can create a model alias; the inference-only boundary refuses that operation without forwarding it, while a private administrator can complete it. ## 4. Limit additional browser origins **Tier-1 rationale:** a browser on a trusted machine must not grant arbitrary websites access to its reachable inference service. If an explicitly required browser frontend needs an additional origin, add: ```ini [Service] Environment="OLLAMA_ORIGINS=https://chat.example.com" ``` Otherwise, leave additional origins unset and remove inherited wildcard assignments. Avoid `*` and blanket extension-origin patterns. The desktop app's Browser setting is one such assignment: when it is on, the app [sets `OLLAMA_ORIGINS` to `*`](https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L241-L255) for the `ollama serve` process it runs (section 9), so leave it off unless a browser frontend requires it. **`OLLAMA_ORIGINS` adds to built-in origins; it does not replace them.** The pin retains localhost, loopback, wildcard-port origins, and application/file/webview schemes. Neither an empty value nor one explicit origin creates an exclusive one-origin policy. See [pinned `AllowedOrigins`](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go). CORS is not caller authentication and does not constrain a non-browser client. An exclusive browser policy belongs at the frontend boundary. Cross-origin authentication also needs a deliberate preflight policy. C2's POST-only example is intended for non-browser clients or a same-origin backend arrangement; it rejects browser preflight OPTIONS requests. **Exposed/fixed comparison, REASONED:** no live endpoint or browser frontend is available here. A wildcard additional-origin configuration permits an unwanted website's browser requests; removing that wildcard refuses unnecessary additional origins while an intended browser flow still works. Built-in origins remain exceptions to test, not evidence of an exclusive policy. ## 5. Use a dedicated account and protect the model store **Tier-1 rationale:** model-store writes and a compromised inference process must not inherit root privileges or unrelated application secrets. For the vendor-style Linux service: ```ini [Service] User=ollama Group=ollama UMask=0077 Environment="OLLAMA_MODELS=/var/lib/ollama/models" ``` Ensure the account exists and pre-provision the store. For new directories, an administrator can use `install -d -o ollama -g ollama -m 0700 /var/lib/ollama /var/lib/ollama/models`. Plan migration separately if models already exist elsewhere. | Object | Proposed ownership and mode | |---|---| | `/var/lib/ollama` and model-store directories | `ollama:ollama`, `0700` | | Model-store regular files | `ollama:ollama`; in the v0.40.14 code read (below), Ollama sets `0644` with `chmod` on the blobs `manifest/layer.go` writes and on manifests, while `server/download.go` writes pulled blobs with no `chmod`, so the `0700` directories carry the restriction | | Service unit and administrative configuration | Root-owned and not writable by `ollama` | | Proxy credentials and TLS private keys | Accessible to the required proxy identity, not Ollama | The account and writable-store requirements follow the [Linux instructions](https://github.com/ollama/ollama/blob/v0.40.14/docs/linux.mdx) and [installer](https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh). The restrictive modes are deployment policy; `UMask` is a systemd setting, not an Ollama option. See the [systemd execution reference](https://github.com/systemd/systemd/blob/v257/man/systemd.exec.xml). At v0.40.14, Ollama sets the mode of some of the store files it writes. In `manifest/layer.go` it runs `os.Chmod(blob, 0o644)` on each new blob, both when [it writes the blob from a stream](https://github.com/ollama/ollama/blob/v0.40.14/manifest/layer.go#L68-L77) and when [it moves a file into the store](https://github.com/ollama/ollama/blob/v0.40.14/manifest/layer.go#L112-L121). It runs `os.Chmod(tempName, 0o644)` before renaming into place [each legacy-path manifest](https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1219-L1245), [each manifest it stores as a new blob](https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1287-L1329) and [each copied manifest](https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1331-L1357). It creates [each manifest root](https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go#L51-L58) with `os.MkdirAll(path, 0o755)` and [the blob directory](https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go#L400-L420) with `os.MkdirAll(dirPath, 0o755)`. A pull writes its blobs another way. For a model without tensor layers, [the pull](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1261-L1293) calls [`downloadBlob`, which downloads each blob to its store path](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L509-L551). [The downloader](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L219-L331) opens that path plus `-partial` with `os.OpenFile(b.Name+"-partial", os.O_CREATE|os.O_RDWR, 0o644)` and renames it into place, and `server/download.go` calls no `chmod`. A model with tensor layers is pulled through `pullWithTransfer` and the `transfer` package instead, which was not read, so the mode of those pulled blobs is not established. This guide's inference from POSIX semantics, not observed: the process umask masks the modes Ollama creates directories and files with, so `UMask=0077` makes those directories `0700` and a `-partial` file the downloader creates `0600`, but it does not mask `chmod`, so the files set with `chmod` above end at `0644`, not `0600`. By the same inference, a store can then hold both `0600` pulled blobs and `0644` files, so a check that expects a single mode for every file after a pull or create can fail. The `0700` store directories are then what keep other local accounts from the `0644` files, provided no ACL grants them traversal. Whether v0.34.2 already set these modes is not established, because its `manifest/layer.go` was not read. `UMask` does not repair existing permissions or ACLs. Inspect existing directories, files, parent-directory access, and backups. Preserve required GPU device-group access. `OLLAMA_MODELS` relocates models, not the entire service home or its signing keys. Do not assume the official container inherits the installer's service-user arrangement. At v0.40.14 the installer's `configure_systemd` function runs `$SUDO useradd -r -s /bin/false -U -m -d /usr/share/ollama ollama` only under `if ! id ollama >/dev/null 2>&1`, that is, when no `ollama` account exists, and then, with no condition, prints "Adding current user to ollama group..." and runs `$SUDO usermod -a -G ollama $(whoami)` ([`scripts/install.sh` lines 197 to 212](https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh#L197-L212)). The [Linux instructions](https://github.com/ollama/ollama/blob/v0.40.14/docs/linux.mdx) give the same `useradd` and `sudo usermod -a -G ollama $(whoami)` commands for a manual install, and neither file says what that membership is for. In the script, `$(whoami)` is expanded by the shell running the script, not under `$SUDO`, so it names the account that ran the script, which is root when the script itself runs as root. This guide's inference from POSIX permissions, not observed: the proposed `0700` store directories grant their group nothing, so that membership gives the invoking user no access to the store, and anything that relied on it, such as reading model files or manifests as that user, stops working once the directories are `0700`. On such a setup, this guide suggests doing that work as the `ollama` account or through the loopback API, and removing the invoking user from the `ollama` group if nothing else needs it. That removal does not last through an update: the [installer](https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh) calls `configure_systemd` on every run where `systemctl` is available (lines 250 and 251), and the [Linux instructions](https://github.com/ollama/ollama/blob/v0.40.14/docs/linux.mdx) say "Update Ollama by running the install script again". Each update through the script therefore adds the account that runs it to the `ollama` group again, so recheck that group's members after every update. If group access is a requirement, a deliberate alternative is `0750` store directories with `UMask=0027`, because by the same inference `UMask=0077` makes the directories Ollama creates `0700`. Every `ollama` group member can then read the store's `0644` and `0640` files, so keep that group to accounts trusted with the models. **Exposed/fixed comparison, REASONED:** no running service, model store, or test identities are available here. Root execution or a broadly writable store permits wider damage or model replacement; the fixed service uses its dedicated identity, required model operations succeed, and an unrelated identity cannot modify the store. ## 6. Bound inference pressure and distinguish limits from defaults **Tier-1 rationale:** anonymous traffic and compromised authenticated clients must encounter finite admission and resource budgets. These are illustrative settings to size against the selected models and hardware: ```ini [Service] Environment="OLLAMA_MAX_LOADED_MODELS=1" Environment="OLLAMA_NUM_PARALLEL=1" Environment="OLLAMA_MAX_QUEUE=16" Environment="OLLAMA_KEEP_ALIVE=1m" Environment="OLLAMA_CONTEXT_LENGTH=4096" ``` At v0.40.14, parallelism defaults to **1**, queue capacity to **512**, and keep-alive to **5 minutes**. Loaded-model capacity is selected automatically when not explicitly configured. See the [environment configuration](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go) and [scheduler](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go). Context defaults are **VRAM-dependent: 4K, 32K, or 256K**. The dedicated context document describes 4K below 24 GiB, 32K for the intermediate VRAM band, and 256K at 48 GiB or more; the implementation selects these tiers slightly lower, at roughly 23 GiB and 47 GiB of summed detected VRAM after the configured GPU overhead, so treat the documented figures as nominal. See the [pinned context reference](https://github.com/ollama/ollama/blob/v0.40.14/docs/context-length.mdx) and [server startup](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go). The v0.40.14 documentation gives 3 different `num_ctx` defaults for local models. The context document gives the VRAM tiers above and adds "Cloud models are set to their maximum context length by default." The [pinned FAQ](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx) says "By default, Ollama uses a context window size of 4096 tokens.", and the [Modelfile parameter table](https://github.com/ollama/ollama/blob/v0.40.14/docs/modelfile.mdx) describes `num_ctx` with "(Default: 2048)". In the v0.40.14 code, [`api.DefaultOptions`](https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L1237-L1246) takes `NumCtx` from `OLLAMA_CONTEXT_LENGTH`, which [defaults to `0`](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L229-L230). [The server's option builder](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L139-L173) replaces a `0` with [the VRAM-tier default set at startup](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2571-L2586), `4096`, `32768` or `262144`. It then applies the model's generation defaults, the model's parameters and the request's options, in that order, so a Modelfile `num_ctx` or a request's `options.num_ctx` overrides either default. In the files read for this, `api/types.go`, `envconfig/config.go`, `server/routes.go` and `server/sched.go`, the only `2048` applied to `num_ctx` is a floor: [the scheduler](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L212-L220) raises `num_ctx` to at least 4 for every model and, with `opts.NumCtx = max(opts.NumCtx, 2048)`, to at least 2048 for a model with vision capability or projector files. In the code read, the scheduler also lowers `num_ctx`, or the value it plans a load with. Where `num_ctx` exceeds a model's nonzero training context, [`effectiveContext`](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L192-L210) returns the training context, which the scheduler uses to predict a load's memory (line 578) and to compare a request with a loaded runner (line 1440). After loading a model file, [it sets `num_ctx` to the nonzero context length the runner reports](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L735-L740); how the runner sets that length is in code not read. When `llm.IsOutOfMemory` reports a load failure as out of memory, [the scheduler retries the load once](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L694-L708) with a lower context, but only when `num_ctx` came from the automatic default. [The server counts it as automatic](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L202-L212) only when neither the request's options nor the model's parameters set `num_ctx` and `OLLAMA_CONTEXT_LENGTH` reads as `0`. For that retry, [`reduceAutoNumCtxForLoadOOM`](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L811-L837) first caps the value at the training context, and [`nextLowerAutoNumCtx`](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L953-L961) then gives 32768 for a value above 32768 or 4096 for a value above 4096; at 4096 or below there is no such retry. For a model it runs through MLX, the scheduler instead passes `num_ctx` to [`mlxrunner.NewClient(modelName, req.opts.NumCtx)`](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L631-L634), whose package was not read, so the context those models load with is not established. This guide therefore reads the FAQ's flat 4096 and the Modelfile table's 2048 as disagreeing with the v0.40.14 code read. When `OLLAMA_CONTEXT_LENGTH` is unset, `0` or not a valid unsigned integer, which [its parser](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L261-L272) reads as `0`, and no model or request `num_ctx` applies, that code selects 4096 at startup only below the 23 GiB tier. It can also step an automatic default down to 32768 or 4096 after an out-of-memory load, and it uses 2048 only as that floor. This example sets 4096 explicitly. C2 already includes these nginx admission controls. Define the zones once in `http`: ```nginx limit_req_zone $binary_remote_addr zone=ollama_rate:10m rate=2r/s; limit_conn_zone $server_name zone=ollama_active:10m; ``` Retain these directives in each permitted inference location: ```nginx client_max_body_size 1m; limit_req zone=ollama_rate burst=4 nodelay; limit_req_status 429; limit_conn ollama_active 4; limit_conn_status 429; ``` These are example budgets, not universal recommendations. The first zone accounts by source address; the second shares an active-request budget for this server name. Configure trusted client-address handling when another proxy is in front. The connection limiter does not cap every idle or incomplete connection. See [request limiting](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html), [connection limiting](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html), and [body-size limits](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size). Context length and keep-alive are **defaults, not mandatory ceilings**. Request parameters can override them, and model defaults also matter. A trusted application or gateway must validate permitted models, `options.num_ctx`, output limits such as `options.num_predict`, and `keep_alive` when untrusted callers receive inference access. A deployment policy might allow one approved model, context at most 4096, at most 128 generated tokens, and keep-alive at most one minute; the nginx path policy does not implement those JSON validations. See [override semantics](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx) and [request types](https://github.com/ollama/ollama/blob/v0.40.14/api/types.go). Ollama scheduling limits are not total RAM, VRAM, disk, or HTTP-connection caps. Apply host/container budgets appropriate to the deployment. Use a finite application deadline where required; the nginx idle-read timeout alone is insufficient. Per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) rule 9, authenticated but unthrottled inference remains a denial-of-wallet risk. Apply the same admission controls to the Caddy and Access alternatives. **Exposed/fixed comparison, REASONED:** no inference runtime, model workload, or GPU is available here. Excess requests enter larger queues or consume more concurrent work in the exposed configuration; explicit admission limits reject excess, accepted requests complete, and normal service recovers. GPU-specific capacity claims require GPU hardware; HTTP admission checks do not. ## 7. Control model provenance and registry transfers **Tier-1 rationale:** API access must not become permission to install or export arbitrary model material. Make acquisition an administrative provisioning step. For an illustrative controlled registry, this is a private `POST /api/pull` request body: ```json { "model": "registry.example.com/team/app:release-2026-09", "insecure": false, "stream": false } ``` Substitute the approved registry and release before sending it through the private administrative path. Fully qualified registry/namespace/model/tag names are supported. Unqualified names default to Ollama's registry, and an omitted tag defaults to `latest`. See [pinned name parsing](https://github.com/ollama/ollama/blob/v0.40.14/types/model/name.go). At v0.40.14, `api/types.go` [declares the `PullRequest` field `Insecure`](https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L856-L867) with the comment `// Deprecated: ignored`, but the handler code does not ignore it. [The pull handler](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1530-L1532) builds `regOpts := ®istryOptions{` with `Insecure: req.Insecure,`, and [the push handler](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1584-L1586) does the same from `PushRequest`, whose field carries no such comment. Without it, [pull](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1213-L1215) and [push](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1047-L1053) refuse a model name whose protocol scheme is `http` with `errInsecureProtocol`. With it, [the registry request helper](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1753-L1822) sets the scheme of the URL it is given to `http` before sending, even for an `https` name, and its default redirect check skips the host check but keeps the 10-redirect limit (section 3). That check does not test or rewrite a redirect's scheme, so, as this guide's inference from Go's `net/http` client, not observed, the helper follows a redirect to the `Location` URL as given, `https` included. A caller that sets its own redirect check replaces that default. For a model without tensor layers, [the blob downloader](https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L219-L331) also passes `insecure` into the `transfer` package in 2 places: line 264 runs `transfer.ValidateRedirectTarget(ctx, loc, requestURL.String(), opts.Insecure)` on the direct-URL redirect, and line 274 builds the client that downloads the blob's parts with `transfer.NewRedirectClient(requestURL.String(), opts.Insecure)`. The `transfer` package was not read, so what `insecure` changes in those 2 calls, and so where a pulled blob's download may be redirected with it, is not established. With it, [the `transfer` download](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1502-L1560) used for models with tensor layers and [the `transfer` upload](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1594-L1653) also switch their base URL to `http` and pass `AllowPrivateHosts: regOpts != nil && regOpts.Insecure`. What that option permits inside the `transfer` package, which was not read, is not established. The [API reference](https://github.com/ollama/ollama/blob/v0.40.14/docs/api.md) describes `insecure` as "allow insecure connections to the library. Only use this if you are pulling from your own library during development."; keep `"insecure": false` in administrative requests unless the registry is a trusted local one served over plain HTTP. Record the approved artifact or model digest independently, then compare it with deployed inventory before admitting traffic. `/api/tags` supplies model digests. Preserve approved bytes or a controlled registry artifact: a named tag alone does not guarantee immutability. See [model inventory](https://docs.ollama.com/api/tags). An alternative for a reviewed local GGUF artifact is this **Modelfile**, not a Dockerfile: ```dockerfile FROM /srv/ollama-import/app.gguf ``` Independently verify the artifact's approved SHA-256 before administrative import. The administrative CLI reads the path during import; this does not mean arbitrary remote API callers can read that host path. See the [Modelfile reference](https://github.com/ollama/ollama/blob/v0.40.14/docs/modelfile.mdx) and [CLI import flow](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go). A digest establishes byte identity, not publisher trust or model safety. Do not assume a universal `model@sha256` deployment recipe or native signature enforcement. Restrict deployment egress when only approved registries may be contacted. Keep export authority and registry credentials on the administrative side. **Exposed/fixed comparison, REASONED:** no controlled registry, approved model artifacts, or live inventory is available here. Exposed management lets callers choose acquisition/export targets and mutate models; the fixed deployment admits independently recorded artifacts through administrators and denies mutation to inference identities. ## 8. Disable unused cloud execution **Tier-1 rationale:** an exposed local API must not spend the host's upstream authority or send prompts to an unintended remote service. For a local-only deployment: ```ini [Service] Environment="OLLAMA_NO_CLOUD=1" ``` Restart Ollama after changing it. It is one of two documented controls for cloud models and web search: the FAQ gives `disable_ollama_cloud` in `~/.ollama/server.json` first, and this variable as an alternative. See the [pinned FAQ](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx). This is not a general no-network switch. It does not replace management-route restrictions or registry egress policy. `OLLAMA_REMOTES` concerns remote-model hosts; it is not a pull/push registry allowlist. See [environment configuration](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go). The account balance and usage routes in section 3 answer `403` without contacting ollama.com when the server's cloud status reads as disabled; whether this setting is what sets that status is not established, so keep those routes behind the default denial whatever its value. **Exposed/fixed comparison, REASONED:** no signed-in server, authorized cloud fixture, or live local model is available here. A cloud-enabled signed-in server completes an authorized cloud-model request; after disabling cloud functionality, that request is refused while local inference still completes. ## 9. Keep diagnostics private and disable request-body logging **Tier-1 rationale:** operational endpoints and diagnostic files can disclose deployment details and submitted prompts. C3's default-deny location also covers diagnostics. Keep `/`, `/api/version`, `/api/status`, `/api/tags`, `/api/ps`, and `/api/show` private unless individually required. Model details may contain templates and system prompts. The system-prompt part is this guide's source reading, not documentation: the served [model-details reference](https://docs.ollama.com/api-reference/show-model-details) documents a `template` field but no `system` field. At v0.40.14, `ShowResponse` [declares](https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L806-L812) `System string` with the tag `json:"system,omitempty"`, and `GetModelInfo` [fills it](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1935-L1947) from the model with `System: m.System,`. `ShowHandler` answers [a cloud model reference](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1779-L1800) from a cloud cache or `proxyCloudJSONRequest`, and [a request with `all_manifests` set](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1819-L1829) from `GetAllManifestsInfo`, whose code was not read, so whether those responses include `system` is not established. [For other requests](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1831-L1835) it calls `GetModelInfo` unless it answers from its show cache, whose code was not read. See the [router](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go). Use: ```ini [Service] Environment="OLLAMA_DEBUG=0" Environment="OLLAMA_DEBUG_LOG_REQUESTS=false" ``` Request debugging writes request bodies and replay scripts into a temporary directory. Disabling it prevents new captures; existing captures still need a retention and deletion policy. Protect diagnostic files and ensure proxy/application logging does not independently capture credentials or prompts. See [logging configuration](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go) and the [request logger](https://github.com/ollama/ollama/blob/v0.40.14/server/inference_request_log.go). No `/metrics` registration appears in the [reviewed main router](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go); do not assume a native metrics port. At v0.40.14, the [CLI entry point](https://github.com/ollama/ollama/blob/v0.40.14/main.go#L3-L13) runs the CLI built by [`NewCLI`](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2541-L2831), which [registers](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2809-L2831) the [serve command (`start` alias)](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2644-L2650), whose [server path](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2269-L2285) calls [`server.Serve`](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2472-L2596) (the [`server` import](https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L3-L60) names the [`server` package](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1)) when invoked as `serve` or `start`. That function [registers the generated router with `http.Handle("/", h)`](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2523-L2528), creates an HTTP server with a [nil `Handler` and a comment saying `http.DefaultServeMux` was chosen to get `net/http/pprof` for free](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2537-L2547), and [serves the listener](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2588-L2593). The [server import block](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L3-L55) has no `net/http/pprof` import. Whether any other first-party code at v0.40.14 imports `net/http/pprof` or registers a pprof handler is not established, because this guide read only the v0.40.14 files Sources cites, and the `go list` sweeps and dependency reading below were made at v0.34.2 and not repeated at v0.40.14. This guide's source assessment is that first-party code in v0.34.2 registers no pprof handler: its non-test Go code has no `net/http/pprof` import, pprof handler registration, or `/debug/pprof` route. A `go list` import resolution run for this guide on 21 October 2026 with Go 1.26.5, for the main package at v0.34.2 (commit `dfabde45`) on linux, darwin and windows amd64 with cgo on and off, listed neither `net/http/pprof` nor `runtime/pprof` in any of its six package lists (413 to 419 import paths each). Twelve further `go list -deps .` runs for this guide on 21 October 2026, in the v0.34.2 tree root with Go 1.26.5 on a linux/amd64 host, `GOFLAGS=-mod=readonly` and the default `GOPROXY`, each exited 0 and listed neither `net/http/pprof` nor `runtime/pprof` in any of their twelve package lists (411 to 418 import paths each). The only path containing `pprof` in each of those twelve lists is `internal/runtime/pprof/label`. The twelve runs used default tags on linux and darwin arm64 with cgo on and off, and on windows arm64 and freebsd, openbsd, netbsd and solaris amd64 with cgo off; and, with cgo off, `-tags updater_live` on windows amd64 and darwin arm64 and `-tags release,library` on linux amd64. The darwin arm64 list with `-tags updater_live` is identical to the one without. In the tree's first-party `//go:build` lines at v0.34.2, the custom tags `integration` (for example [`integration/basic_test.go`](https://github.com/ollama/ollama/blob/v0.34.2/integration/basic_test.go#L1)), `fast`, `release` and `library` ([`integration/reg_groups_test.go`](https://github.com/ollama/ollama/blob/v0.34.2/integration/reg_groups_test.go#L1)), `generate` ([`integration/generate_jinja_test.go`](https://github.com/ollama/ollama/blob/v0.34.2/integration/generate_jinja_test.go#L1)) and `updater_live` ([`app/updater/updater_live_test.go`](https://github.com/ollama/ollama/blob/v0.34.2/app/updater/updater_live_test.go#L1)) gate only `_test.go` files. The tags `appengine` and `os400` appear in the tree's first-party `//go:build` lines at v0.34.2 only in the [constraint of `readline/term.go`, whose only import is `syscall`](https://github.com/ollama/ollama/blob/v0.34.2/readline/term.go#L1-L7). This guide's inference, not run, is that the test-only tags select no additional first-party non-test files, because `_test.go` files are not part of a non-test build ([`go build` ignores files that end in `_test.go`](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L83) in Go 1.26.5), so they leave the main package's first-party imports unchanged. The dependency search below ignores build constraints, so which dependency modules' files those tags select was not determined, though the sweep below shows that `generate` selects a `github.com/rivo/uniseg` file. A further 227 `go list -deps .` runs were made for this guide on 22 October 2026 in the v0.34.2 tree (commit `dfabde45`) with Go 1.26.5 on a linux/amd64 host, using `/usr/local/go/bin/go` with `GOENV=off`, `GOTOOLCHAIN=local`, `GOWORK=off`, `GOFLAGS=-mod=readonly`, `GOPROXY=off` and a module cache filled by `go mod download` (all modules verified, with a clean tree afterwards). The 227 runs, like the runs above, resolve imports without compiling a binary or running Ollama code. These 227 runs cover default tags for all 47 GOOS/GOARCH pairs that Go 1.26.5's `go tool dist list` reports, with cgo off, and for the 40 of those that report cgo support, with cgo on. These 227 runs also cover, with cgo off and on, the tag sets `updater_live`, `release,library`, `appengine`, `os400`, `appengine,os400`, `integration,fast,generate,release,library,updater_live` and those six tags plus `appengine` and `os400`, each on linux 386, amd64, arm and arm64, darwin amd64 and arm64, windows 386, amd64 and arm64, and aix ppc64. The 182 runs that exited 0 listed neither `net/http/pprof` nor `runtime/pprof`, and the only path containing `pprof` in any of their lists is `internal/runtime/pprof/label`. Those 182 runs are 82 default-tag runs, covering every pair with cgo off except the five below and every cgo-capable pair with cgo on, and all 100 runs with the `updater_live`, `release,library`, `appengine`, `os400` and `appengine,os400` tag sets. The other 45 exited 1. With cgo off, android 386, amd64 and arm and ios arm64 reported that the pair "requires external (cgo) linking, but cgo is not enabled" and ios amd64 reported "default PIE binary requires external (cgo) linking, but cgo is not enabled"; a `go list -e` rerun of each listed only the main package, carrying that error. The 40 runs with a tag set containing `generate` reported `found packages uniseg (doc.go) and main (gen_breaktest.go)` in the `github.com/rivo/uniseg` v0.4.7 module; their `go list -e` reruns listed neither pprof package but marked `github.com/rivo/uniseg` with that load error, so its imports may be incomplete. In the same session, a search of the main module's non-test Go files found no `"net/http/pprof"` or `"runtime/pprof"` import, and a `go list` query over the standard library named only `net/http/pprof` and `testing/internal/testdeps` as packages depending on either. Listing the dependency modules to search first failed with `go: module lookup disabled by GOPROXY=off`. A rerun the same day with the default `GOPROXY`, after `go mod download all` (whose added `go.sum` lines were then reverted), listed 108 dependency modules besides the main module, each with a directory, and every one was searched for those import paths in non-test Go files and for the text `/debug/pprof`. That search ignores build constraints, so it over-approximates. That search found `"net/http/pprof"` or `"runtime/pprof"` only in `package main` commands, in the `golang.org/x/tools` internal packages `go/analysis/internal/checker` and `internal/tool`, and as string data in `golang.org/x/tools/internal/stdlib`. The `package main` commands with those matches are seven in `github.com/dgryski/trifles`, plus `github.com/klauspost/compress/s2/cmd/s2c`, `golang.org/x/text/collate/tools/colcmp` and `golang.org/x/tools/cmd/deadcode`, `goimports` and `ssadump`. That search found `/debug/pprof` only in the `package main` command `github.com/dgryski/trifles/fastpprof`. None of those package paths, and no `github.com/dgryski/trifles` or `golang.org/x/tools` package, appears in the output of any of the 227 runs or the 45 `go list -e` reruns. This guide's inference, not run, is that these matches add no `net/http/pprof` or `runtime/pprof` import to the graphs of those runs. It judges reachability only against those runs' graphs, and the same searches, run over the main module the same day, ignore build constraints and found neither import nor `/debug/pprof` text there. A source reading for this guide on 26 October 2026, with no Go code built or run, followed those import paths through the extracted sources of the 93 dependency modules whose zips v0.34.2's [`go.sum`](https://github.com/ollama/ollama/blob/v0.34.2/go.sum) lists, from the module cache of the 22 October 2026 sweep; each of those zips has a `.ziphash` and an `h1:` hash recomputed from its contents that match `go.sum`. The other 15 modules in that cache, including `golang.org/x/tools` v0.38.0, have no `go.sum` hash for their module zip (nine have only a `/go.mod` line), so the reading excluded them and did not read them. Reading the package clause and imports of every non-test Go file in those 93 modules and in the main module, ignoring build constraints, found `net/http/pprof` or `runtime/pprof` imported only by the `package main` files of ten commands. Those ten commands are [`cstbucket`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/cstbucket/main.go#L1-L17), [`fastpprof`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/fastpprof/main.go#L1-L13), [`jumpreplica`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/jumpreplica/main.go#L1-L18), [`lz`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/lz/lz.go#L1-L11), [`matcher`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/matcher/main.go#L1-L13), [`quantiles`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/quantiles/main.go#L1-L22) and [`udprelay`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/udprelay/main.go#L1-L26) in `github.com/dgryski/trifles` v0.0.0-20200323201526-dd97f9abfb48, [`s2/cmd/s2c`](https://github.com/klauspost/compress/blob/v1.18.3/s2/cmd/s2c/main.go#L1-L27) in `github.com/klauspost/compress` v1.18.3 and [`collate/tools/colcmp`](https://github.com/golang/text/blob/v0.30.0/collate/tools/colcmp/colcmp.go#L5-L22) in `golang.org/x/text` v0.30.0. The text `/debug/pprof` appears in those modules only in [the `package main` command `github.com/dgryski/trifles/fastpprof`](https://github.com/dgryski/trifles/blob/dd97f9abfb48/fastpprof/main.go#L16-L42) and in a `github.com/pelletier/go-toml/v2` v2.2.2 test file, at [line 1571](https://github.com/pelletier/go-toml/blob/v2.2.2/marshaler_test.go#L1571) and [line 1649](https://github.com/pelletier/go-toml/blob/v2.2.2/marshaler_test.go#L1649). Following imports from every main-module package through the non-test files of packages other than `package main`, with standard-library packages taken as leaves, reached no file importing either pprof package and no import path outside those 93 modules and the standard library. The `golang.org/x/text` and `golang.org/x/exp` packages that import `golang.org/x/tools` packages are imported in those modules only by `package main` and test files, and the main module imports none of them. This guide's inference, not run, is that none of those 93 dependency modules adds `net/http/pprof` or `runtime/pprof` to a build of v0.34.2's main package at these module versions, for any GOOS/GOARCH pair, cgo setting or tag set, because a `package main` command is not imported by other packages. The reading does not cover the standard library, whose importers of either package come from the linux/amd64 query above and the Go 1.26.5 source reading below, other Go versions, or what the failed runs or a shipped binary would contain. In Go 1.26.5, `net/http/pprof`'s [`init` function](https://github.com/golang/go/blob/go1.26.5/src/net/http/pprof/pprof.go#L96-L106) registers `/debug/pprof/` and its `cmdline`, `profile`, `symbol` and `trace` paths, each prefixed with the method `GET` unless the `httpmuxgo121` GODEBUG setting is `1`, through [`http.HandleFunc`, which registers on `DefaultServeMux`](https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L2870-L2878); a server's [`Handler` is `http.DefaultServeMux` if nil](https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L2971), as [its request dispatch applies](https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L3302-L3306). A `go list` of the `server` package's Go files on linux amd64 in the same session includes `routes.go`. This guide's inference, not run, is that a build whose import graph includes `net/http/pprof` would serve those paths on the Ollama listener through `Serve`'s nil `Handler`, and that the builds above whose graphs exclude it get no handler from that package. It assumes that a package outside the graph is not linked, so its `init` never runs, and that no other linked code registers a `/debug/pprof/` handler, which this guide checked in first-party code and, by a search for the text `/debug/pprof`, in the 93 dependency modules whose zips `go.sum` hashes. v0.34.2's [`go.mod` declares `go 1.26.0`](https://github.com/ollama/ollama/blob/v0.34.2/go.mod#L3) with no `toolchain` line, a requirement that the Go 1.26.5 used for these runs satisfies. v0.40.14's [`go.mod` declares `go 1.26.10`](https://github.com/ollama/ollama/blob/v0.40.14/go.mod#L3), also with no `toolchain` line, a requirement that Go 1.26.5 does not meet. This guide's reading of the v0.40.14 release configuration, which shows the builds intended and not what a published artifact contains, is that the [Dockerfile's Go build stage](https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile#L257-L273) downloads the Go release named by that `go` line, Go 1.26.10, and builds the main package with `CGO_ENABLED=1`, `-trimpath -buildmode=pie` and no `-tags`. By the same reading, the [macOS build script](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_darwin.sh#L88-L89) sets `GOOS=darwin GOARCH=$ARCH CGO_ENABLED=1` for a CMake build whose [Go step](https://github.com/ollama/ollama/blob/v0.40.14/cmake/local.cmake#L711-L758) runs `go build -trimpath` on the main package with `CGO_ENABLED=1` and no `-tags`. By the same reading, the [Windows build script](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L865-L920) builds the CLI with no `-tags`, and its [`checkEnv` function sets `CGO_ENABLED=1` unconditionally](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L296) and [runs before the build steps](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L1299), so its [default CLI build, whose `go build` sets no `GOARCH`](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L896-L908), uses `CGO_ENABLED=1`, as does its arm64 build, whose wrapper sets `GOOS=windows`, `GOARCH=arm64` and `CGO_ENABLED=1` explicitly. By the same reading, for the default build, the architecture `checkEnv` [selects](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L210-L230) is [copied to `TARGET_ARCH`](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L231), which reaches that build only as [its output directory](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L295). By the same reading, the release workflow's [macOS](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L71-L82) and [Windows](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L354-L378) jobs set up Go with `go-version-file: go.mod` before running those scripts. By the same reading, the workflow's [`GOFLAGS` value](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L13-L25) carries one `-ldflags` flag (`-w -s` and two `-X` settings) and no `-tags`, and the [macOS job's](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L34) and [Windows job's](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L338) environments apply it, so those environments add no `-tags` to the scripts' builds. By the same reading, the workflow's [`linux-depends` job](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L480-L557) passes that value as a Docker build argument only to dependency targets, none of which is or copies from the [Dockerfile's](https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile) Go build stage. By the same reading, the [`docker-build-push` job](https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L562-L667), whose builds do reach that stage, names `GOFLAGS` without a value among its build arguments and sets the workflow value in its environment; whether the build receives that value depends on Docker build tooling not traced here, and [the stage's own `GOFLAGS` default](https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile#L257-L273) carries no `-tags` either. The sweep's matching default-tag runs (linux, darwin and windows amd64 and arm64, cgo on and off) list neither pprof package, but they resolved v0.34.2 with Go 1.26.5, which is neither the tree nor the Go release these v0.40.14 builds use. A source reading for this guide on 26 October 2026, with no Go code built or run, compared the standard library of every other stable Go 1.26.x release, Go 1.26.0 to 1.26.4 and 1.26.6 to 1.26.10, with that of Go 1.26.5, the release the `go list` runs above used. Each release's source tarball was fetched from go.dev on 26 October 2026, and the fetch record shows its SHA-256 matching the value go.dev publishes; Sources cites each release's `goversion` line, `go/build` release-tag lines and `runtime/pprof` imports at its `go1.26.N` tag. In each of those releases, as in Go 1.26.5, `internal/goversion` sets `Version = 26`, from which `go/build` derives the release tags `go1.1` to `go1.26` (`go/build/build.go` lines 354 and 355). Reading the import declarations of the non-test Go files outside the `cmd` and `testdata` directories, ignoring build constraints, found `runtime/pprof` imported in each release only by `net/http/pprof`, by `testing/internal/testdeps` and by the `package main` command `simd/archsimd/_gen/simdgen`, at the same lines as in Go 1.26.5. The same reading found no file importing `net/http/pprof` or `testing/internal/testdeps`, and found the text `/debug/pprof` only in `net/http/pprof` and in doc comments of `runtime/pprof` and `runtime/trace`. A file-by-file comparison of each release's import lists with Go 1.26.5's, whose listed additions were each found in that release's source, found across the ten releases one added file, which has no imports, and added imports of 14 packages. Those packages are `bytes`, `crypto/internal/entropy/v1.0.0`, `crypto/internal/fips140/sha256`, `crypto/internal/sysrand`, `crypto/sha256`, `encoding/hex`, `internal/runtime/atomic`, `internal/stringslite`, `internal/syscall/unix`, `math`, `runtime`, `slices`, `sync` and `sync/atomic`; none is a pprof package or one of the importers above, and removed imports and files cannot add one. This guide's inference, not run, is that none of those releases adds `net/http/pprof` or `runtime/pprof` to a v0.34.2 build graph that the Go 1.26.5 runs above recorded without them, because in each release no standard-library package other than those importers imports either one. The reading compares source and checked no `go list` graph, so which files the build constraints and cgo select, and whether the builds that failed with Go 1.26.5 load, are recorded for Go 1.26.5 only. Read the same way, Go 1.26.5's own standard library has the same three importers of `runtime/pprof`, at the lines Sources cites, and outside `cmd` the quoted import paths otherwise appear only in doc comments, in `testdata` files and in `_test.go` files, so no non-test file outside `cmd` and `testdata` imports `net/http/pprof` or `testing/internal/testdeps`. The `simdgen` command is [`package main`](https://github.com/golang/go/blob/go1.26.5/src/simd/archsimd/_gen/simdgen/main.go#L71) in a `_gen` directory, which, like a `testdata` directory, [the go tool ignores](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L3225-L3226), and `testing/internal/testdeps` is [importable only by code under `testing`](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L2811-L2812), where no non-test file imports it; in `cmd`, [the main package that `go test` generates](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/test.go#L790-L801) imports it. [The packages that the go command adds to a main package's link](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/pkg.go#L2638-L2672), `runtime` and, depending on the build, `runtime/cgo`, `math`, `runtime/race`, `runtime/msan`, `runtime/asan` and `runtime/coverage`, are not among those importers. [The imports that the go command adds](https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/pkg.go#L1912-L1935) to a package that uses cgo, `unsafe`, `runtime/cgo` and `syscall` (`cmd/go/internal/load/pkg.go` lines 1912 to 1922), and to a package that uses SWIG, those three and `sync` (lines 1924 to 1931), are not among those importers either. A comment in `pkg.go` (lines 1933 and 1934) notes that `.swig` and `.swigcxx` files can also import other packages through `%go_import` directives. This guide's inference, not run, is that in Go 1.26.5 no standard-library package adds `net/http/pprof` or `runtime/pprof` to a non-test build whose other packages import neither, on any GOOS/GOARCH pair or tag set, because the reading ignores build constraints and so covers every standard-library file a build could select. With the first-party search and the 93-module reading above, this guide infers that no Go 1.26.5 build of v0.34.2's main package at these module versions imports either package, on any pair, cgo setting or tag set; the inference rests on no `go list` graph and does not cover code that cgo generates, whether the builds that failed load, the desktop app or a shipped binary. That inference also does not cover `%go_import` directives in any `.swig` or `.swigcxx` files, for which this guide searched neither v0.34.2 nor its modules. These `go list` results resolve imports without building a binary and did not inspect the shipped release binary. No `go list` graph was made at v0.40.14, and the dependency reading was not rerun over v0.40.14's `go.sum`, so both remain unestablished. Builds with Go releases outside the 1.26.x patch line, `go list` graphs for the other 1.26.x releases (including the Go 1.26.0 that v0.34.2's `go` line names), the five cgo-off android and ios runs and the 40 `generate` runs that failed, the swept tag sets on GOOS/GOARCH pairs other than the ten they were run on, and other tag sets and combinations remain unestablished. For the Go 1.26.5 builds among those that load, the source readings above leave open, of their imports, only those of code that cgo generates and of any `%go_import` directives in SWIG files, since this guide infers from those readings, with no `go list` graph, that no first-party, `go.sum` dependency or standard-library package adds either to those builds. This guide searched neither v0.34.2 nor its modules for `.swig` or `.swigcxx` files, the SWIG files whose `%go_import` directives can import other packages (Go 1.26.5 `cmd/go/internal/load/pkg.go` lines 1933 and 1934). Beyond this guide's inferences above, whether these or such builds give the served mux pprof handlers also remains unestablished. Shipped-binary pprof availability remains unestablished, and the comment alone does not establish it. This guide's advice: include `/debug/pprof/` in live inventory and public-denial checks. Do not claim either default availability or that `OLLAMA_DEBUG=0` disables profiling. At v0.34.2, the desktop app main package [`./app/cmd/app`, whose files build only on windows or darwin and whose import block names no pprof package](https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/app.go#L1-L33), which the release scripts also build, was resolved by ten further `go list -e -deps ./app/cmd/app` runs made for this guide on 23 October 2026. They ran in the same v0.34.2 tree and environment as the sweep above, on darwin and windows amd64 and arm64 and linux amd64, cgo off and on. No run listed `net/http/pprof` or `runtime/pprof`, and the only path containing `pprof` in any of their lists is `internal/runtime/pprof/label`. Every run carries a load error, and a package carrying one may list incomplete imports. Both linux amd64 runs fail on that constraint (`build constraints exclude all Go files`) and list only the main package, marked with that error, establishing only that the package has no linux files at v0.34.2. All eight darwin and windows runs mark `app/ui` with `pattern app/dist: no matching files found`, because [its `//go:embed app/dist` directive](https://github.com/ollama/ollama/blob/v0.34.2/app/ui/app.go#L15-L16) names web assets that are not in the v0.34.2 tree (the macOS build script builds them with npm before its Go builds). The four cgo-off darwin and windows runs cannot load [`app/webview`, which is cgo-only through its `import "C"`](https://github.com/ollama/ollama/blob/v0.34.2/app/webview/webview.go#L51) and also omit the main package's own cgo files ([`app_darwin.go`](https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/app_darwin.go#L10) and [`webview.go`](https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/webview.go#L6)) with no error, while the cgo-on runs, the setting the release scripts use, load them. These runs were not repeated at v0.40.14. There, [the tree](https://github.com/ollama/ollama/tree/v0.40.14) still holds no `app/ui/app/dist`, and the main package has further non-test files, among them `claude_desktop_status.go`, whose name carries no OS suffix and whose build constraint was not read, so whether the package still builds only on windows or darwin at v0.40.14 is not established. By source reading at v0.40.14, not run, the import blocks of [`app/ui/app.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/ui/app.go#L5-L13), [`app/ui/ui.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L6-L39) and [`app/ui/extract.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/ui/extract.go#L5-L14), the three non-test Go files directly in `app/ui`, of [`app/webview/webview.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/webview/webview.go#L53-L60), the only Go file in `app/webview`, and of the main package's [`app_darwin.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L12-L44), [`webview.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/webview.go#L8-L26) and [`codex_app_darwin.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go#L5-L25) name neither `net/http/pprof` nor `runtime/pprof`. Not read here are the main package's other files, the `app/ui/responses` subpackage, the imports of the packages those blocks name, and how `go list` records a package's imports despite load errors, so whether the app's build imports either pprof package at v0.40.14 remains open. In the app's own non-test code read here, two listeners appear, both loopback. Neither v0.40.14 Codex Desktop integration file read here opens one: [`internal/proxy/codex_desktop.go`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go) and [`app/cmd/app/codex_app_darwin.go`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go), read whole, contain no `net.Listen` or `http.Server`, and the first [describes its handler as routing requests "using the existing Ollama listener"](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L60-L62) under [`/api/codex`, which it calls the "loopback-only Ollama server route"](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L27-L30). The Ollama server [registers the handler that `newCodexDesktopProxy` returns for every method under `/api/codex`](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2385-L2450), the desktop proxy route section 3 names, beside a comment that Codex uses this existing Ollama listener; `newCodexDesktopProxy` itself and [the `cmd/launch` `CodexApp` controller that the second file drives](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go#L35-L49) were not read. [The `CodexDesktop` handler's `ServeHTTP`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L157-L177) answers `403` unless [the request's remote address is `localhost` or a loopback IP](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L640-L650), answers `NotFound` outside that prefix, and serves `/_health` and [a `/_status` route reporting request counters and the last model, route and upstream status](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L513-L529) with no further check. [Other requests](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L193-L264) go to Ollama when the body's model is in the routing catalog file, [with only the `Accept`, `Content-Type`, `OpenAI-Beta` and `User-Agent` headers](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L359-L376) [copied](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L675-L681), and otherwise to the OpenAI API or the ChatGPT upstream, except that a request carrying the managed sentinel key and not routed to Ollama is answered `401`. This guide's inference, not observed: the loopback check reads the TCP peer address, so if the Ollama server serves this handler on its own listener, a request relayed by a proxy on the same host would pass it only when that proxy connects to Ollama over loopback, and then the policy proxy's default denial of unlisted paths, not this check, is what refuses `/api/codex` to remote callers. The UI listener is [`net.Listen("tcp", "127.0.0.1:0")`, or `127.0.0.1:3001` in dev mode](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app.go#L186-L196), and [`srv := &http.Server{Handler: uiServer.Handler()}` serves it with a non-nil `Handler`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app.go#L280-L293), so, as this guide's inference from the Go sources cited above, a build that linked `net/http/pprof` would still not serve `DefaultServeMux`'s handlers on this listener. [The UI mux](https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L278-L327) registers `/api/v1/` routes behind [a token-cookie check that development mode skips](https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L194-L225), reverse-proxy routes for seven Ollama API paths (eight method patterns) and catch-all app routes, both wired without that check, and no `/debug/pprof` pattern. The second listener traced here is the macOS Claude Desktop gateway: [`app_darwin.go` is cgo-only through its `import "C"`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L10), so the darwin cgo builds, the release setting, compile it, and [at startup the app reconciles that gateway](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L535), [starting it only when Claude Desktop is installed and configured to use it](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L546-L561). The app's Claude Desktop connect and model-mapping actions also start it without an already configured Claude Desktop, through [`setClaudeGatewayInstalled`](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1202-L1229), [`prepareClaudeDesktopConnection`, which starts it before configuring Claude Desktop and stops it only when Claude Desktop does not end up using the gateway](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1478-L1493), and [the model-mapping path](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1645-L1667). However, [the start itself skips when Claude Desktop is not installed](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L592-L598). `setClaudeGatewayInstalled` also starts it before configuring Claude Desktop and stops it when Claude Desktop does not end up using the gateway, unless `effectiveClaudeDesktopAutoMode` has already returned an error. The model-mapping path, [when it finds no existing gateway](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1574-L1579), stops it when its profile change returns an error other than `ErrClaudeDesktopRestartConfirmationRequired`, while [with an existing gateway it returns such an error joined with `restoreState()`'s result, without stopping it](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1700-L1704). The start [constructs the gateway](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L614-L631) with [a listen address set to `proxy.DefaultClaudeDesktopListenAddr` beside a 750 ms start retry window](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L95-L96), [`127.0.0.1:11435`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L23-L26) [copied to the gateway's own listen address at construction](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L131-L132), and [calls its `Start`, retrying briefly while the address is in use](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L944-L951). [`Start` listens there and serves an `http.Server` whose `Handler` is the gateway itself, non-nil](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L198-L221), and [its `ServeHTTP` serves only `/_ollama/health`, `/v1/models`, `/v1/messages/count_tokens` and `/v1/messages`, rejects a request whose first `Origin` header value is non-empty and answers other paths with `NotFound`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L300-L361), with no `/debug/pprof` route. [Go 1.26.5's `Header.Get` returns a header's first value](https://github.com/golang/go/blob/go1.26.5/src/net/http/header.go#L43-L51); this guide's inference, not observed, is that a non-empty `Origin` value after an empty first one does not trigger that rejection. [Before that `Origin` check, `ServeHTTP` answers `403`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L300-L361) to any request whose `Host` header [is not `localhost`, in any letter case, or a loopback IP literal, followed by the port the gateway is listening on](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L419-L435), so a `Host` with no port is refused too. Only `POST /v1/messages` passes to the gateway's reverse proxy, since the other routes return first: the gateway [removes `Authorization`, `Cookie` and `Proxy-Authorization`, sets `X-Api-Key: ollama` and the upstream `Host`, waits for the upstream, and forwards the request](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L363-L380) through a reverse proxy [whose transport's `Proxy` is nil](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L126-L130). [That wait](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L442-L511) dials the upstream over TCP until a connection succeeds or 10 seconds pass, and a success is reused for 5 seconds: [the constants `upstreamReadyTimeout` and `upstreamReadyTTL`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L27-L31) hold those values, and [construction sets the wait from the first](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L143). [The start takes the upstream from `envconfig.ConnectableHost()` and refuses an `OLLAMA_HOST` whose port is the gateway's](https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L599-L609), then passes it as the gateway's Ollama URL: [`Host`](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L20-L60) reads `OLLAMA_HOST` [from the app process's environment](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L378-L380), defaulting to `http://127.0.0.1:11434`, and [`ConnectableHost`](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L62-L83) replaces an unspecified address with loopback. This guide's inference, not observed: if the app's own environment sets `OLLAMA_HOST` to another address, the gateway forwards Claude Desktop's messages there without the caller's `Authorization`, `Cookie` or `Proxy-Authorization` headers, so an authenticating proxy at that address that expects them would refuse those requests. [Model routing](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L594-L634) reads the body's `model` and admits it only when it [equals a listed model's gateway ID or Ollama model name](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L741-L748), [otherwise answering `400`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L300-L361); it answers [`401`, `403`, `404` or `503`](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L807-L818) when that model's access check fails, and otherwise rewrites `model` to the listed Ollama model name. [A `400` from Ollama to a replayable `POST /v1/messages` is retried once](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L636-L724), with each image replaced by a text notice, under three further conditions. The error body, at most [64 KiB](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L27-L31), must decode as an `anthropic.ErrorResponse` whose `Type` is `error` and whose `Error.Type` is `invalid_request_error`, with a message that, ignoring letter case, contains `image` or `vision` and `not support` or `unsupported`. The JSON request body, at most 64 MiB, must name a string `model` that is not a listed model supporting vision, and at least one image must be replaced. By source reading, not run, the gateway keeps at most five models, each holding one of [five fixed Claude route IDs](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L16-L35) (`claude-fable-5`, `claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001` and `claude-sonnet-4-6`), which [it assigns in that order unless the models already hold distinct ones](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L308-L353), and [with no configured models it uses](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L146-L148) [five built-in cloud models](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L183-L200). Of the models these files construct, only models named `kimi-k3:cloud`, such as the built-in one, [are marked as supporting vision](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L433-L467). [`/v1/models` lists only the models whose access check returns available](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L519-L555). [That check](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_access.go#L55-L124) admits a local model only when the local inventory is known and lists it, and a cloud model only when Cloud is on, the account is signed in, the model's entitlement is known and the plan meets its requirement: an empty or `free` requirement admits any plan, and any other needs a plan that is neither empty nor `free`. Otherwise it reports `cloud_off`, `sign_in_required`, `upgrade_required` or `model_not_installed`, which the message and token-count routes [answer](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L807-L818) with `403`, `401`, `403` and `404`, or unknown availability with `verification_unavailable`, answered with `503`, when the inventory, Cloud status or account state is undetermined or a cloud model's entitlement is unknown. With no access resolver, [the gateway assumes Cloud on, a signed-in account and plan `pro`, and with no local-model lister it treats every local model as installed](https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L750-L799); the app callbacks that supply these and refresh the list are not traced here. This guide's inference, not observed: on a Mac running the desktop app with that gateway on, the gateway and the Option B policy proxy recipe's `listen 127.0.0.1:11435;` would both try to bind that address and port. The app does not serve the Ollama API listener itself: it runs [the separate `ollama` binary resolved at construction](https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L49-L51) [with `serve`, copying the app's own environment, setting `OLLAMA_HOST=0.0.0.0` when the app's Expose setting is on, and setting `OLLAMA_ORIGINS=*` when its Browser setting is on](https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L241-L255), the server the rest of this guide covers, and [restarts it in a supervision loop when it exits](https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L190-L225). So a desktop deployment can be exposed by the Expose setting, as well as by an `OLLAMA_HOST` in the app's own environment, and the Browser setting adds `*` to that server's allowed origins, the wildcard section 4 advises against. By this guide's reading of the intended builds, not artifacts, [the macOS build script](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_darwin.sh#L200-L216) builds the web assets with npm and then this package with `GOARCH=amd64` and `arm64`, `CGO_ENABLED=1`, `GOOS=darwin` and no `-tags`. By the same reading, [`build_windows.ps1`'s `buildApp` runs `go build` with no `-tags`](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L981-L1005), once as the default app build, which sets no `GOARCH` and uses the architecture `checkEnv` selects in its output file name only, under its unconditional `CGO_ENABLED=1`. By the same reading, [only when the cross-compile tools are present](https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L995-L998), `buildApp` runs that `go build` once more, for arm64, inside `withWindowsArm64GoEnv`, which sets `GOOS=windows`, `GOARCH=arm64` and `CGO_ENABLED=1`. These runs resolve imports without compiling and no shipped app binary was inspected; other Go versions, other GOOS/GOARCH pairs and tag sets, the imports the load errors above may hide, the gateway behaviour not traced above and every runtime response of these listeners remain unestablished. **Exposed/fixed comparison, REASONED:** no runtime, diagnostic files, or live profiling inventory is available here. An exposed deployment returns operational metadata and may capture request bodies; the fixed public boundary denies diagnostics, administrators retain necessary visibility, and disabled request logging creates no new capture. For the host-installed, local-only example, the assembled systemd drop-in is: ```ini [Service] User=ollama Group=ollama UMask=0077 Environment="OLLAMA_HOST=127.0.0.1:11434" Environment="OLLAMA_MODELS=/var/lib/ollama/models" Environment="OLLAMA_MAX_LOADED_MODELS=1" Environment="OLLAMA_NUM_PARALLEL=1" Environment="OLLAMA_MAX_QUEUE=16" Environment="OLLAMA_KEEP_ALIVE=1m" Environment="OLLAMA_CONTEXT_LENGTH=4096" Environment="OLLAMA_NO_CLOUD=1" Environment="OLLAMA_DEBUG=0" Environment="OLLAMA_DEBUG_LOG_REQUESTS=false" ``` Add C4's explicit origin only if required, and remove inherited unwanted origins. Apply this to the existing service, reload systemd, and restart once the account, store, and proxy configuration are ready. Preserve GPU access required by the installed service. ## Common mistakes - Publishing `11434:11434` and assuming the standalone loopback default protects the official container. - Using `-p` as a host-networking control, or treating an absent `ss` listener as proof that Docker has no published path. - Treating cloud login, API keys, `OLLAMA_AUTH`, a Host rewrite, or CORS as local inbound authentication. - Adding `https://` to `OLLAMA_HOST` and assuming the backend now serves TLS. - Authenticating `/api/generate` while leaving management or compatibility routes reachable. - Retaining a catch-all proxy beside an inference allowlist, or omitting the bearer check from a newly permitted location. - Expecting public `/api/tags` to return 200 after intentionally making inventory private. - Treating one `OLLAMA_ORIGINS` value as an exclusive browser-origin policy. - Treating context length, keep-alive, or `proxy_read_timeout` as enforced total resource or execution ceilings. - Assuming a model tag is immutable, `OLLAMA_NO_CLOUD` blocks every network transfer, or `OLLAMA_REMOTES` restricts registries. - Running as root, leaving the store broadly writable, assuming `UMask` repairs existing files, or expecting it to make the manifests and the blobs `manifest/layer.go` writes `0600`. - Disabling debug logging without handling existing captures, or assuming that also disables profiling. ## Verify REASONED: service-comparison scope from the cited v0.40.14 sources; no authorized live deployment or external observer. No service was started or contacted for these checks. All service-behavior checks below are **REASONED, not demonstrated**. The authoring environment has no Ollama, Docker, Podman, nginx, or Caddy executable in `PATH`, no Ollama runtime or GPU, and no supplied authorized deployment, external observer, credentials, or model fixtures. Filesystem and network restrictions prevent provisioning a live comparison here. **Lack of a GPU alone would not prevent listener, API, CORS, or management testing.** REASONED: mixed local-validation evidence and unobserved service comparisons from the cited v0.40.14 sources; a fresh local run with Bash 5.3.9 bash -n and ShellCheck 0.11.0 run with -s bash accepted all four Verify shell fences with exit 0 and no output, establishing only syntax and lint results. Native proxy parsing and live comparisons remain unobserved without the binaries and an authorized deployment; the following validation account is historical, including its 74-case count, guard-scanner result and drop-in gate statement, and that count was not re-established here. Local validation covered the four Bash fragments with `bash -n`, ShellCheck 0.11.0, and the repository's strict guard-convention scanner. Guard-only tests rejected 74 invalid or incomplete positional-input cases under `bash -u`; four substituted controls reached local markers in place of the operational branch. These observations establish shell and outer-guard behavior only. The fresh bash -n and ShellCheck run and the historical guard-only tests covered the four Verify fences in an earlier form, whose guard refusals exited with status 1; those runs did not cover the later forms, whose guard refusals exit with status 2. In that earlier form, V6 also exited with status 1 when its result directory could not be created or protected, and with status 0 after a failed transfer, and V1, V2 and V3 ended with the status of their last command. The current V6 exits with status 3 in both of those V6 cases and keeps its values in positional parameters, and the current V1, V2 and V3 report the statuses their sections describe. On the form that preceded V1's `command -v ss` check, bash -n in Bash 5.3.9 exited 0, stub runs without network access checked its exit statuses, and `tools/run_all_checks.sh` ran ShellCheck 0.11.0 over it with no findings; no server run is recorded, and no demonstration was re-run. V1 changed again after every run recorded here: it now checks `command -v ss` before running `ss -tlnp`, and when `ss` is not installed it prints `ss is not installed here; not listing sockets` and exits with status 2 unless an inspection command failed, which still gives 3. Those runs covered an earlier form of V1; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs with `ss` present and passing, present and failing, and missing. Native proxy parsing remains unconfirmed; every service comparison is REASONED from the cited v0.40.14 sources and vendor documentation. The whole-corpus gate suite was not run against this drop-in. REASONED: shell preparation and limitations from the printed guards and curl reference. Local Bash 5.3.9 ran bash -n on all four Verify shell fences with exit 0 and no output. For each complete subshell, operational commands were replaced with local printf markers; valid controls were ollama.service/NONE, 192.168.50.7, and https://fixture.internal/api/chat with readable disposable JSON and empty mode-0600 client configuration files, POST and application/json where applicable. Replacing the first value after the marker with an empty string or bad value, keeping the original placeholders, or using set -- without arguments produced no operational marker and exit 1. Those runs covered the four Verify fences in an earlier form, whose guard refusals exited with status 1; those runs did not cover the later forms, whose guard refusals exit with status 2; in the current form V1, V2, V3 and V6 also report the statuses their sections describe. V1 changed again after those runs and now also exits with status 2 when `ss` is not installed and no inspection command failed; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs, not by these marker runs. The V6 arms that stop when the result directory cannot be created or protected are not guard refusals; they exited with status 1 in that earlier form and now exit with status 3, and V6 now also exits with status 3 after any failed transfer, where the earlier form ended with status 0. The valid controls reached their markers; V6 wrote eight LOCAL_CURL status files in a mode-0700 directory. This tests only those shell cases, not address syntax, client configuration acceptance, inference, or deployment isolation. Paste complete blocks, including the parentheses, marker, and count checks. Substitute inside the single quotes on each `set --` line; a literal apostrophe requires shell escaping. The guards assume normal shell builtins and cannot protect a fragment pasted from below them. Use curl 7.75.0 or later for the displayed `exitcode` and `errormsg` variables. REASONED: comparison procedure from the cited service and proxy sources; isolated exposed/fixed deployments, identities and observers are unavailable. Use isolated exposed and fixed deployments with disposable fixtures. Do not make production public to obtain a baseline. Record binary/image identity, effective configuration, observer location, HTTP results, completed response bodies, upstream observations, state changes, and cleanup without secrets. ### V1. Inspect the listener, identity, and store (REASONED: listener, systemd/container identity, publication, ownership, ACL and writable-store comparisons, with administrative and inference positive controls, follow C1 and C5; no deployed store, service identity or external observer. Local command-marker tests above do not inspect a deployment) **REASONED:** no deployed service, container runtime, store, or test identities are available. Run this on the deployment host with enough privilege to inspect processes and sockets. Use `NONE` for a host-only deployment. Expected behavior follows C1's [Docker publication reference](https://docs.docker.com/engine/network/port-publishing/) and C5's [Linux service instructions](https://github.com/ollama/ollama/blob/v0.40.14/docs/linux.mdx). ```bash ( set -- PASTE_WHOLE_BLOCK 'ollama.service' 'REPLACE_WITH_CONTAINER_NAME_OR_NONE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not inspecting"; exit 2; } shift [ "$#" -eq 2 ] || { echo "supply unit and container name or NONE"; exit 2; } case "$1|$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|'|'*|*'|'|*'||'*|*[[:space:]]*) echo "replace both values; not inspecting"; exit 2 ;; *) set -- "$1" "$2" 0 # the 3rd value is the block's status: 3 once an inspection command fails, else 2 if ss is missing, otherwise 0 systemctl cat -- "$1" || { echo "systemctl cat exit $?; inconclusive"; set -- "$1" "$2" 3; } systemctl show --property=User,Group,UMask,Environment,MainPID -- "$1" || { echo "systemctl show exit $?; inconclusive"; set -- "$1" "$2" 3; } ps -C ollama -o pid,user,group,args || { echo "ps exit $? (no ollama process, or ps failed); inconclusive"; set -- "$1" "$2" 3; } if command -v ss >/dev/null; then ss -tlnp || { echo "ss -tlnp exit $?; inconclusive"; set -- "$1" "$2" 3; } else echo "ss is not installed here; not listing sockets" case "$3" in 0) set -- "$1" "$2" 2 ;; esac fi stat -c '%U:%G %a %n' /var/lib/ollama /var/lib/ollama/models || { echo "stat exit $?; inconclusive"; set -- "$1" "$2" 3; } getfacl -p /var/lib/ollama /var/lib/ollama/models || { echo "getfacl exit $?; inconclusive"; set -- "$1" "$2" 3; } if [ "$2" != NONE ]; then docker ps --format 'table {{.Names}}\t{{.Ports}}' || { echo "docker ps exit $?; inconclusive"; set -- "$1" "$2" 3; } docker inspect --format '{{json .HostConfig.PortBindings}} {{json .NetworkSettings.Networks}} {{json .Config.User}}' -- "$2" || { echo "docker inspect exit $?; inconclusive"; set -- "$1" "$2" 3; } fi echo "block exit $3; nothing above is classified, so judge every listing against the comparisons below" exit "$3" ;; esac ) ``` The exposed comparison shows unintended listeners/publications, root execution, or writable model state. The fixed comparison shows the intended private path and dedicated identity. Check existing regular files and ACLs, service-home signing keys, root-owned configuration, and proxy key separation too. REASONED: container-only inspection and this block's exit statuses, from the block's own code and the C1 and C5 references cited before the block; no deployment was inspected. For container-only deployments, systemd inspection may be inapplicable. Inspect the actual container process identity and mounted store; an empty configured container user is not proof of an unprivileged process. Inspect every relevant network namespace, IPv4/IPv6 address, publication, and network attachment. The block exits 3 when any inspection command fails, as the systemd or store commands can on a container-only deployment or with the store elsewhere, 2 when `ss` is not installed and no inspection command failed, and otherwise 0, because it classifies nothing and leaves every listing for you to judge. From an unrelated unprivileged test account, run `test -w /var/lib/ollama/models` and inspect its exit status; writable access is a failure of the proposed policy. In an isolated store, confirm that identity cannot create a disposable file, while the service identity can perform the approved import/inference workflow. Pair ownership inspection with V4's successful administrative operation and V3's inference control. ### V2. Probe direct-backend bypass from another machine (REASONED: direct-origin reachability and approved-proxy inference comparison follow C1 binding documentation; no live target or external observer. The local address-guard cases above establish no network outcome) No deployed target or disallowed external observer is available. Use the actual deployment address, not a documentation address. For IPv6, supply the actual address enclosed in square brackets inside the quotes. Repeat for each deployed address family and publication: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "supply exactly one address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|*'<'*|*'>'*|*example.com*|""|*@*|*/*|*\?*|*\#*|*[[:space:]]*|203.0.113.*|198.51.100.*|192.0.2.*|*2001:db8*) echo "supply the real deployment address; not probing"; exit 2 ;; *) if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "http://$1:11434/api/tags"; then echo "FINDING: the direct port answered over HTTP, which establishes the bypass even if its status is an error"; exit 1 else set -- "$1" "$?" # the address, then curl's exit status case "$2" in 7|28) echo "curl exit $2, no connection (refused, unreachable or timed out): unclassified observation; it is the fixed result only when V1 and firewall evidence attribute it to the disallowed remote path and inference through the approved proxy succeeds"; exit 0 ;; *) echo "curl exit $2: read err above; a resolver failure, local socket error or other local error is inconclusive"; exit 3 ;; esac fi ;; esac ) ``` Exposed: an HTTP response establishes that the direct port answered, even if its status is an error. Fixed: refusal or timeout attributable to the actual disallowed remote path, paired with successful inference through the approved proxy. Read `err`, not merely the exit number. A resolver failure, local socket error, wrong address, stopped service, or unexplained timeout is inconclusive. Correlate the result with V1 and firewall evidence. The block exits 1 when the direct port answers over HTTP, 0 with an unclassified-observation line when curl exits 7 or 28 (no connection, or a timeout), which you still attribute as above, and 3 on any other curl failure. See C1's [binding documentation](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx). ### V3. Verify authentication and TLS with a real inference request (REASONED: cited Ollama, proxy and curl behavior; no live proxy, certificates, credentials or model) No proxy deployment, trusted certificates, credentials, or loaded model is available. Prepare a small approved local model and a request body file such as `chat.json`: ```json { "model": "REPLACE_WITH_APPROVED_LOCAL_MODEL", "messages": [ { "role": "user", "content": "Reply with the word ready." } ], "stream": false, "options": { "num_predict": 16 } } ``` Replace the model before probing. Keep fixture prompts non-sensitive. Prepare client configuration files with mode `0600` in a private directory. For Basic authentication, the correct-credential file contains: ```text user = "admin:REPLACE_WITH_PASSWORD" ``` Prepare a separate file containing the deliberately wrong password and a separate empty file for no credentials. For bearer authentication, use this file content instead: ```text header = "Authorization: Bearer REPLACE_WITH_TOKEN" ``` For mTLS, add the required trust and certificate paths: ```text cacert = "/absolute/path/to/server-ca.pem" cert = "/absolute/path/to/client-cert.pem" key = "/absolute/path/to/client-key.pem" ``` Use the normal trust store when appropriate. Escape backslashes and double quotes according to curl's configuration-file syntax. Never put live passwords, bearer tokens, or Access secrets on the command line. Inspect these files before use: allow only the intended authentication, trust, client-certificate, and test-header settings. Do not include URLs, redirects, proxy settings, insecure TLS options, extra transfers, or request/output overrides. An intentional `resolve` entry can support a hostname-negative test against the same server address. The files are trusted test inputs, not a safe format for untrusted instructions. The following reusable probe reads the client configuration on stdin, keeping credentials off curl's argv. No credential is typed into the command, and the block's `set +x` turns tracing off before it reads the file, so neither shell history nor `set -x` tracing records the credentials; write the file with an editor, not a shell command, so preparing it does not put them in history either. Stdin closes the argv channel only: the account owner and root can still read the file and the memory of the block's subshell, whose placeholder check reads the file, and of curl. It supports the concrete operations in V3 through V8. Use `/dev/null` as the body for GET, HEAD, and OPTIONS. Use `application/octet-stream` for blob bytes and `application/json` for JSON fixtures. The probe exits 0 when the transfer completes, leaving every response for you to compare with the table for your check, and 3 when curl fails; for the wrong-trust, wrong-hostname and client-certificate negatives that failure is the expected fixed result, so read its `err=` text as well as its status. ```bash ( set +x set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ENDPOINT_URL' 'POST' 'REPLACE_WITH_BODY_FILE_OR_DEV_NULL' 'REPLACE_WITH_CURL_CONFIG_FILE' 'application/json' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "supply URL, method, body file, client config, and content type"; exit 2; } case "$1|$2|$3|$4|$5" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|'|'*|*'|'|*'||'*|*[[:cntrl:]]*) echo "replace every value; not probing"; exit 2 ;; *) case "$1" in *@*|*\?*|*\#*|*[[:space:]]*) echo "use a URL without credentials, query, or fragment"; exit 2 ;; https://*|http://127.0.0.1:11434/*|http://localhost:11434/*) ;; *) echo "use HTTPS, or the administrative loopback endpoint"; exit 2 ;; esac case "$2" in GET|HEAD|OPTIONS) [ "$3" = /dev/null ] || { echo "use /dev/null for this method"; exit 2; } ;; POST|DELETE) ;; *) echo "unsupported test method"; exit 2 ;; esac case "$5" in application/json|application/octet-stream) ;; *) echo "choose the fixture content type"; exit 2 ;; esac [ -r "$3" ] || { echo "body file is unreadable"; exit 2; } [ -r "$4" ] || { echo "client config is unreadable"; exit 2; } case "$(< "$4")" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo "replace placeholders in the client config; not probing"; exit 2 ;; esac case "$2" in HEAD) set -- "$4" --head --url "$1" ;; GET|OPTIONS) set -- "$4" --request "$2" --url "$1" ;; POST|DELETE) set -- "$4" --request "$2" --url "$1" \ --header "Content-Type: $5" --data-binary "@$3" ;; esac if curl -q -g -sS --config - --noproxy '*' --connect-timeout 5 --max-time 120 \ --dump-header /dev/stderr \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "${@:2}" < "$1"; then echo "unclassified observation: compare the headers, body and http= line above with the table for the check you are running" else echo "curl exit $?: the transfer failed; inconclusive, though a certificate or handshake failure is the expected fixed result of a wrong-trust, wrong-hostname or client-certificate negative, so read err above" exit 3 fi ;; esac ) ``` For the public authentication comparison, use the actual HTTPS `/api/chat` URL, POST, and the same valid `chat.json` each time: | Client configuration | Exposed comparison | Fixed comparison | |---|---|---| | Empty, no credentials | Unprotected API completes inference. | Basic/bearer boundary rejects with 401 and no upstream inference request. | | Deliberately wrong credentials | Credentials do not protect an unprotected API. | Request is rejected, never accepted inference. | | Correct credentials | Request completes. | HTTP 200 with a completed, successful inference response. | For mTLS, retain a valid client certificate while testing missing Basic credentials if both mechanisms are deliberately required. Test missing and untrusted client certificates separately; both must fail before inference. nginx may report a TLS failure or a proxy rejection depending on the failure stage. Repeat the valid request with a correct hostname and trust chain, an unrelated valid CA trust file, and a wrong hostname directed to the same server. Correct trust succeeds; wrong trust or hostname must fail certificate verification. Do not use `-k`. A missing local certificate file is not evidence that the server enforced mTLS. For Access, use protected client-config header entries for the required service-token fields. Verify missing/wrong-token denial and valid-token inference. For a human MFA policy, use the actual browser login flow; a service-token request does not demonstrate MFA. Retain the original model-inventory check with corrected expectations: use V3 with `GET /api/tags` and `/dev/null`. On the public C3 boundary, expect 404 even with valid inference credentials. On the approved private administrative path, expect 200 with model-list JSON. A public 404 is not an authentication test. A streamed 200 alone does not prove model work succeeded. Inspect the complete response, including completion and error fields. Transport failures, rate-limit rejection, missing models, and timeouts are distinct from authentication denial. Inspect proxy and upstream records without logging credentials or bodies. These comparisons follow C2's [authentication reference](https://docs.ollama.com/api/authentication), [nginx Basic authentication](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html), [client-certificate verification](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_verify_client), and the [curl manual](https://curl.se/docs/manpage.html). ### V4. Exercise management authorization and blob writes (REASONED: management, account, experimental and blob operations, public denial and private administrative success, cleanup and public-chat controls follow C3 API methods and route registration; no isolated model store, registry, identities or proxy. Python 3.14.4 json.loads accepted the four literal JSON bodies in this table; that is syntax only. sha256sum on a disposable file containing OLLAMA_VERIFY_FIXTURE followed by LF returned 9b18adfd436acae378346d9452646db562690662d43ded1df7adceea38540c95; no blob upload or existence check ran) **REASONED:** no isolated model store, registry fixtures, administrative identity, or live proxy is available. Use V3's reusable probe against three paths: an isolated exposed catch-all proxy, the fixed public inference proxy, and the private administrative endpoint. Direct loopback requests run on the service host with an empty client configuration. Prepare valid, disposable fixtures. The exposed comparison must demonstrate actual authorized operations, not malformed-request errors. | Operation for V3 | Body or fixture | Evidence to retain | |---|---|---| | `POST /api/copy` | `{"source":"REPLACE_WITH_SOURCE_MODEL","destination":"REPLACE_WITH_DISPOSABLE_ALIAS"}` | Alias appears in private `/api/tags` inventory after exposed/private success. | | `POST /api/create` | `{"model":"REPLACE_WITH_DISPOSABLE_MODEL","from":"REPLACE_WITH_REVIEWED_GGUF_MODEL","stream":false}` | Successful completion and the new definition in private inventory. | | `POST /api/pull` | C7's pull body, substituted for a small controlled registry fixture. | Successful transfer and the expected deployed digest. | | `POST /api/push` | `{"model":"REPLACE_WITH_CONTROLLED_EXPORT_MODEL","insecure":false,"stream":false}` | Authorized test registry receives the intended disposable artifact. | | `DELETE /api/delete` | `{"model":"REPLACE_WITH_DISPOSABLE_ALIAS"}` | Only the selected disposable name disappears. | | `POST /api/blobs/sha256:REPLACE_WITH_FIXTURE_SHA256` | A small file containing unique non-sensitive bytes; use `application/octet-stream`. | Upload succeeds and subsequent HEAD finds that digest. | | `HEAD /api/blobs/sha256:REPLACE_WITH_FIXTURE_SHA256` | `/dev/null` | Existence result distinguishes a stored fixture from a fresh absent digest. | Substitute the JSON values before use. Calculate the blob fixture's SHA-256 locally with `sha256sum` and place its hexadecimal digest after `sha256:` in the URL. Never use a disk-filling workload. For the fixed public boundary, every management request must return 404 without an upstream request or state change, even with valid inference credentials. Confirm the operation succeeds for the private administrator, then remove disposable aliases/models and clean up the isolated registry/store. Do not manually delete shared production blobs. Also test the account and experimental methods registered at the pin: POST to `/api/me`, `/api/signout`, `/api/experimental/web_search`, and `/api/experimental/web_fetch`, GET to `/api/balance`, `/api/usage`, and `/api/experimental/model-recommendations`, plus DELETE to `/api/user/keys/:encodedKey`, using an isolated account where state can change. For the balance and usage routes, a private success returns the signed-in account's data, so use a test account. Inventory the deployed desktop proxy route and unused compatibility endpoints, including `POST /v1/systemone` and the `/v1/audio/` routes, and confirm public denial. Never sign out or delete keys on a production account merely to obtain evidence. Use proxy upstream-status records or equivalent observation to distinguish a proxy-generated 404 from an upstream error. Pair each denial set with successful public `/api/chat`. The discriminators are C3's [API methods](https://github.com/ollama/ollama/blob/v0.40.14/docs/api.md), [route registration](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go), and [create validation](https://github.com/ollama/ollama/blob/v0.40.14/server/create.go). ### V5. Compare browser origins and preflights (REASONED: required/unrelated/built-in/missing Origin, preflight, actual browser calls and authenticated/anonymous POST comparisons follow C4 AllowedOrigins and C2 proxy policies; no live backend, proxy or browser fixture. The header text was not exercised by a browser) No live backend or authorized browser frontend is available. Use V3 with OPTIONS, `/dev/null`, and a client configuration containing these non-secret test headers: ```text header = "Origin: https://REPLACE_WITH_BROWSER_ORIGIN" header = "Access-Control-Request-Method: POST" header = "Access-Control-Request-Headers: content-type" ``` Run against the private test backend to isolate Ollama's CORS behavior, then against the actual frontend boundary. Repeat with the required origin, an unrelated controlled origin, representative built-in origins, and no Origin header. Exposed: with wildcard additional origins, the unrelated browser origin is permitted. Fixed: unnecessary additional origins are refused; required origins still work. Inspect access-control response headers and perform an actual browser request, because curl itself does not enforce CORS. For the C2 POST-only proxy, an OPTIONS request is not forwarded; the exact status depends on the variant: the nginx Basic-auth example returns 405, the nginx bearer variant returns 401 for a missing or wrong credential and 405 once a valid one passes, and the Caddy example falls through to 404. Each denies forwarding, so any of these is the expected non-browser policy, not a successful cross-origin frontend deployment. If a cross-origin browser frontend is required, demonstrate its separately designed preflight/authentication flow, including any authorization header it needs. Repeat the actual POST with and without Origin. Removing Origin must not bypass proxy authentication; a non-browser caller with valid credentials is not constrained by CORS. The built-in origins remain as described by C4's [AllowedOrigins implementation](https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go). ### V6. Exercise finite admission and resource budgets (REASONED: admission, body size, model loading, expiry, parameter overrides, queue capacity and recovery comparisons follow C6 scheduler/types and nginx limit references; no inference runtime, loaded model, capacity fixture or GPU. Local V6 marker files above demonstrate child completion only, not either limiter or successful inference) No inference runtime, loaded models, capacity fixture, or GPU is available. The following probe launches exactly eight requests, each bounded to 120 seconds, waits for every child, and retains separate results. Like V3's reusable probe, it reads the client configuration on each curl's stdin, keeping credentials off curl's argv. No credential is typed into the command, and the block's `set +x` turns tracing off before it reads the file, so neither shell history nor `set -x` tracing records the credentials; write the file with an editor, not a shell command, so preparing it does not put them in history either. Stdin closes the argv channel only: the account owner and root can still read the file and the memory of the block's subshell, whose placeholder check reads the file, and of each curl. Use only an isolated capacity test with the same small, valid chat fixture as V3: ```bash ( set +x set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_CHAT_URL' 'REPLACE_WITH_CHAT_BODY_FILE' 'REPLACE_WITH_CURL_CONFIG_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "supply URL, body file, and client config"; exit 2; } case "$1|$2|$3" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|'|'*|*'|'|*'||'*|*[[:cntrl:]]*) echo "replace every value; not probing"; exit 2 ;; *) case "$1" in *@*|*\?*|*\#*|*[[:space:]]*) echo "use a URL without credentials, query, or fragment"; exit 2 ;; https://*/api/chat) ;; *) echo "use the HTTPS chat URL"; exit 2 ;; esac [ -r "$2" ] || { echo "body file is unreadable"; exit 2; } [ -r "$3" ] || { echo "client config is unreadable"; exit 2; } case "$(< "$3")" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*) echo "replace placeholders in the client config; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$(if mktemp -d; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%%$'\n'*}" "${4##*$'\n'}" # URL, body file, client config, the result directory, then mktemp's exit status [ "$5" = 0 ] && [ -n "$4" ] || { echo "cannot create result directory"; exit 3; } chmod 700 "$4" || { echo "cannot protect results"; exit 3; } set -- "$1" "$2" "$3" "$4" while [ "$#" -lt 12 ]; do # eight transfers: each appends its process ID, and $(($# - 3)) numbers its result files 1 to 8 curl -q -g -sS --config - --noproxy '*' --connect-timeout 5 --max-time 120 \ --request POST --header 'Content-Type: application/json' --data-binary "@$2" \ --output "$4/$(($# - 3)).body" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" \ < "$3" > "$4/$(($# - 3)).status" \ 2> "$4/$(($# - 3)).stderr" & set -- "$@" "$!" done set -- "$4" 0 "${@:5}" # the result directory, the block's status so far, then the eight process IDs while [ "$#" -gt 2 ]; do if wait "$3"; then : else echo "one transfer failed; inspect its status and stderr" set -- "$1" 3 "${@:3}" # records the failure; the block exits with status 3 below fi set -- "$1" "$2" "${@:4}" done printf 'Results retained in %s\n' "$1" echo "block exit $2; the eight results are not classified, so judge them against the exposed and fixed comparisons below" exit "$2" # status 3 if any transfer failed; otherwise 0, and you judge the results ;; esac ) ``` Exposed: without admission limits, the workload reaches Ollama subject to its own scheduler. Fixed: excess requests receive the configured proxy 429 responses while accepted requests complete. Inspect limiter/upstream evidence to identify which control acted. After the bounded burst ends and the rate budget recovers, repeat one ordinary request and require successful inference. A fast fixture may not hold four requests active simultaneously. To demonstrate the connection limit separately, use a bounded fixture that keeps four accepted requests active, pace their admission within the rate budget, then attempt one additional request from another test client. It must be rejected while the accepted requests complete; freeing a slot must permit another request. An eight-request burst alone does not prove both limiters acted. Using V3, also perform these comparisons: | Operation | Exposed versus fixed evidence | |---|---| | Submit a valid chat JSON file of 1,048,577 bytes, using harmless JSON whitespace for padding. | Without the selected body cap it can reach upstream; the 1 MiB public limit returns 413 without an upstream request. Pair with a small accepted body. | | Request a second approved small model and inspect private `GET /api/ps`. | Compare automatic loaded-model behavior with the explicit one-model budget; record loading, eviction, and recovery. | | Send a request without `keep_alive`, then inspect `/api/ps` before and after the selected expiry. | Compare the five-minute default with the configured one-minute default, allowing observation overhead. | | Attempt `options.num_ctx: 8192`, `options.num_predict: 256`, `keep_alive: "2m"`, and a disallowed model under the illustrative gateway policy. | Direct Ollama may accept overrides; the validating gateway must reject or constrain them. Environment defaults and the nginx path policy alone do not pass this check. | | Fill the scheduler queue with an isolated bounded workload that actually enters it. | Compare default and explicit queue capacities using scheduler observations and completed requests. Do not infer that the seventeenth HTTP request must return 503. | The public four-active-request budget may prevent a single test client from filling Ollama's sixteen-entry scheduler queue. Test scheduler behavior separately through the isolated private test path, with an explicit workload bound and cleanup. Do not remove production proxy limits. These comparisons follow C6's [scheduler](https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go), [request override types](https://github.com/ollama/ollama/blob/v0.40.14/api/types.go), and nginx [request](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html), [connection](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html), and [body](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size) limits. ### V7. Check provenance, cloud disablement, and administrative egress (REASONED: digest approval, private inventory, public mutation denial, allowed/disallowed registry transfers, cloud-enabled/disabled inference and local-model controls follow C7 and C8; no approved artifacts, controlled registries, cloud identity or live server) **REASONED:** no approved artifact inventory, controlled registry, cloud credentials, or live server is available. Use V3 for private `GET /api/tags`. Compare each deployed model digest with the independently approved inventory. For a GGUF import, compare `sha256sum /srv/ollama-import/app.gguf` with the approved artifact checksum before import. The raw GGUF SHA-256 and a model manifest digest are different identifiers; record each for its purpose. Exposed: callers can install or export arbitrary chosen material through public management. Fixed: V4's public mutations are denied, administrative provisioning yields the approved model digest, and ordinary inference succeeds. Where registry exclusivity is required, compare an approved transfer with a small transfer to a separately controlled disallowed registry. The egress boundary must deny the latter without blocking the former. For C8, use V3's POST `/api/chat` with a currently authorized cloud-model fixture and a non-sensitive prompt. In the isolated cloud-enabled state, establish a completed response. After setting `OLLAMA_NO_CLOUD=1` and restarting, require a cloud-disabled refusal, then repeat the local-model positive control. An invalid model or missing upstream credential is not proof of cloud disablement. Sources: C7's [model inventory](https://docs.ollama.com/api/tags) and [transfer implementation](https://github.com/ollama/ollama/blob/v0.40.14/server/images.go), and C8's [cloud-disable documentation](https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx). ### V8. Inspect diagnostics, profiling, and prompt captures (REASONED: public/private diagnostics, actual profiling availability, public-chat controls and debug-enabled/disabled capture inspection follow C9 router/logger; no running binary, service temporary namespace or diagnostic fixtures. Python 3.14.4 json.loads accepted the literal show-request body; it establishes syntax only. At v0.34.2, default-tag go list runs for linux, darwin and windows amd64, twelve further platform, cgo and tag variants, and the 182 of 227 runs in a 22 October 2026 Go 1.26.5 platform, cgo and tag-set sweep that exited 0 listed no net/http/pprof or runtime/pprof dependency; the sweep's other 45 runs failed, none ran at v0.40.14, and none establishes a runtime response) **REASONED:** no running binary, diagnostic fixtures, or service temporary directory is available. Use V3's probe with valid public inference credentials and then through the private administrative path: | Method | Path | Body | |---|---|---| | GET | `/` | `/dev/null` | | GET | `/api/version` | `/dev/null` | | GET | `/api/status` | `/dev/null` | | GET | `/api/tags` | `/dev/null` | | GET | `/api/ps` | `/dev/null` | | POST | `/api/show` | `{"model":"REPLACE_WITH_APPROVED_LOCAL_MODEL"}` in a substituted fixture file | | GET | `/debug/pprof/` | `/dev/null` | Exposed: registered operational routes disclose their responses through the catch-all boundary. Fixed: public requests return 404 without reaching upstream, while private administrative diagnostics still work. Pair the denial set with successful public chat. For `/debug/pprof/`, first observe the actual private binary; this guide's source trace does not establish its response. This guide's source assessment is that first-party v0.34.2 registers no pprof handler, and the `go list` runs at v0.34.2 for the platform, cgo and tag combinations listed in C9 found no `net/http/pprof` or `runtime/pprof` dependency in any run that loaded; C9 did not resolve the v0.40.14 import graphs, which remain unestablished. Builds with Go releases outside the 1.26.x patch line, `go list` graphs for the other 1.26.x releases (C9 gives only an inference from source), the C9 runs that failed, the tagged sets on platforms not run, other tag sets, exposure through the served mux in such builds (C9 gives only this guide's inference for the builds that loaded) and shipped-binary pprof availability remain unestablished. If no handler exists, record that absence; do not call it proof that debug settings disabled profiling. If it exists, retain the observed response and demonstrate public denial. For logging, use an isolated service and a unique non-sensitive prompt. With request debugging deliberately enabled only for the comparison, send one completed V3 request and inspect the service's actual temporary-directory namespace for `ollama-request-logs-*`, request bodies, and replay scripts. Then apply C9, restart, send another completed request, and verify that no new request capture is created. Account for a service-specific `TMPDIR` or private temporary namespace. Retain timestamps and file evidence without sharing captured prompts or credentials, then clean up the test captures. Expected behavior follows C9's [router](https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go) and [request logger](https://github.com/ollama/ollama/blob/v0.40.14/server/inference_request_log.go). ### Native proxy parsing (REASONED: native acceptance and its limits follow the linked nginx/Caddy command references; neither executable is on PATH and deployment certificate/password files are unavailable. Neither native validation command ran) Parsing is an offline check, but it was **not demonstrated here** because nginx and Caddy are absent. On the deployment host, run `nginx -t` against the assembled configuration and real referenced certificate/password files before reloading. For the Caddy alternative, run `caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile`. These commands validate configuration acceptance; they do not prove authentication, management denial, TLS enforcement, or resource behavior. Avoid sharing `nginx -T` output from a bearer configuration because it contains the shared token. See the [nginx command reference](https://nginx.org/en/docs/switches.html) and [Caddy validation reference](https://caddyserver.com/docs/command-line#caddy-validate). ### Verification scope and local work (REASONED: service-comparison and native-parsing prerequisites from V1-V8 and the cited sources; no authorized deployment, external observer, service credentials or native proxy binaries. Historical local observations above are not new deployment evidence) | Check scope | Status | Procedure and prerequisites | |---|---|---| | Service behavior | REASONED from the cited v0.40.14 sources and vendor documentation; not demonstrated | On v0.40.14 or a newly reviewed explicit pin, load the assembled service/proxy configuration and reproduce every applicable exposed/fixed comparison above in isolated states. Record binary/image identity, effective configuration, commands, responses, upstream observations, completed positive controls, state changes, and cleanup without secrets. Include (1 of 2): IPv4/IPv6 and Docker bypass paths; absent/wrong/correct authentication; TLS trust and mTLS negatives; all management methods and blob writes; browser preflights and actual calls; finite admission, scheduler observations, parameter overrides, and recovery. The items to include (2 of 2): effective identity, ACLs, and model digests; controlled registry transfers; cloud disablement; diagnostic denial, prompt-capture behavior, and actual profiling availability. Use CPU fixtures where sufficient and GPU hardware for GPU-specific claims. Configuration inspection or `nginx -t` alone does not demonstrate service behavior. | | Native proxy parsing | Not demonstrated here because nginx and Caddy are absent; retained local work | Run the offline parsing checks above against the assembled configuration and real referenced certificate/password files. | ## Sources (checked September 2026) - Ollama v0.40.14 release (checked November 2026): https://github.com/ollama/ollama/releases/tag/v0.40.14 - Ollama release commit (checked November 2026): https://github.com/ollama/ollama/commit/9a3e8177767245acc62b735eba7d5d8a553606d0 - Ollama repository at the reviewed tag, its file list (checked November 2026): https://github.com/ollama/ollama/tree/v0.40.14 - Ollama FAQ: https://docs.ollama.com/faq - Pinned FAQ, service environment, proxy examples, overrides, and cloud disablement (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx - Ollama local API authentication (rolling documentation, checked September 2026): https://docs.ollama.com/api/authentication - Official Docker image wildcard listener (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile - Linux service instructions (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/docs/linux.mdx - Linux installer and service account (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh - Linux installer `configure_systemd` account creation and `ollama` group membership for the invoking user (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh#L197-L212 - Environment configuration and additive origins (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go - API methods and blob operations (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/docs/api.md - HTTP serving, routes, and host middleware (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go - Route registration behind the CORS and host middleware, including the account, experimental, `/v1/systemone`, audio and `/api/codex` routes (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2385-L2450 - Account balance and usage handlers that drop the caller's `Authorization` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2684-L2696 - Cloud proxy default base URL and signing host (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L28-L31 - Cloud proxy disabled-status refusal, header copy, signing call and send (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L176-L226 - Cloud proxy request signing with `auth.Sign` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L408-L422 - Client-side outbound signing (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/api/client.go - Server startup and administrative import (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go - CLI entry point (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/main.go#L3-L13 - CLI entry point at v0.34.2, the main package the `go list` sweeps resolved (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/main.go#L3-L13 - Serve command definition (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2644-L2650 - CLI constructor NewCLI (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2541-L2831 - Serve command registration (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2809-L2831 - Serve command server path (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2269-L2285 - Router attachment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2523-L2528 - HTTP server handler and pprof comment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2537-L2547 - HTTP listener serve call (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2588-L2593 - HTTP server import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L3-L55 - Serve function (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2472-L2596 - cmd package imports, including the server package (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L3-L60 - server package clause (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1 - integration test build tag at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/integration/basic_test.go#L1 - fast, release and library test build tags at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/integration/reg_groups_test.go#L1 - generate test build tag at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/integration/generate_jinja_test.go#L1 - updater_live test build tag at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/app/updater/updater_live_test.go#L1 - readline terminal build constraint and syscall import at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/readline/term.go#L1-L7 - Go module `go` line at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/go.mod#L3 - Go module `go` line at v0.40.14 (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/go.mod#L3 - Go module requirement blocks at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/go.mod#L5-L91 - Go module checksums at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/go.sum - `github.com/dgryski/trifles` `cstbucket` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/cstbucket/main.go#L1-L17 - `github.com/dgryski/trifles` `fastpprof` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/fastpprof/main.go#L1-L13 - `github.com/dgryski/trifles` `jumpreplica` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/jumpreplica/main.go#L1-L18 - `github.com/dgryski/trifles` `lz` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/lz/lz.go#L1-L11 - `github.com/dgryski/trifles` `matcher` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/matcher/main.go#L1-L13 - `github.com/dgryski/trifles` `quantiles` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/quantiles/main.go#L1-L22 - `github.com/dgryski/trifles` `udprelay` command package clause and imports at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/udprelay/main.go#L1-L26 - `github.com/dgryski/trifles` `fastpprof` command `/debug/pprof` handlers at v0.0.0-20200323201526-dd97f9abfb48 (checked October 2026): https://github.com/dgryski/trifles/blob/dd97f9abfb48/fastpprof/main.go#L16-L42 - `github.com/klauspost/compress` `s2/cmd/s2c` command package clause and imports at v1.18.3 (checked October 2026): https://github.com/klauspost/compress/blob/v1.18.3/s2/cmd/s2c/main.go#L1-L27 - `golang.org/x/text` `collate/tools/colcmp` command package clause and imports at v0.30.0 (checked October 2026): https://github.com/golang/text/blob/v0.30.0/collate/tools/colcmp/colcmp.go#L5-L22 - `github.com/pelletier/go-toml/v2` test file `/debug/pprof` text, first match at v2.2.2 (checked October 2026): https://github.com/pelletier/go-toml/blob/v2.2.2/marshaler_test.go#L1571 - `github.com/pelletier/go-toml/v2` test file `/debug/pprof` text, second match at v2.2.2 (checked October 2026): https://github.com/pelletier/go-toml/blob/v2.2.2/marshaler_test.go#L1649 - Dockerfile Go build stage (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile#L257-L273 - macOS build script Go environment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_darwin.sh#L88-L89 - CMake Go build step and local payload target (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/cmake/local.cmake#L711-L758 - Windows build script CLI and arm64 builds (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L865-L920 - Windows build script architecture selection, from `ARCH` or host detection (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L210-L230 - Windows build script unconditional `CGO_ENABLED` setting in `checkEnv` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L296 - Windows build script `checkEnv` call before the build steps (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L1299 - Windows build script default CLI build, its `go build` without `GOARCH` and its output directory argument (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L896-L908 - Windows build script selected architecture copied to `TARGET_ARCH` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L231 - Windows build script output directory named from `TARGET_ARCH` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L295 - Release workflow macOS Go setup and build (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L71-L82 - Release workflow Windows Go setup and build (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L354-L378 - Release workflow `GOFLAGS` value (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L13-L25 - Release workflow Windows job `GOFLAGS` environment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L338 - Release workflow macOS job `GOFLAGS` environment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L34 - Release workflow `linux-depends` job, its dependency targets and `GOFLAGS` build argument (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L480-L557 - Release workflow `docker-build-push` job, its value-less `GOFLAGS` build argument and environment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L562-L667 - Desktop app main package constraint and imports at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/app.go#L1-L33 - Desktop app loopback listener (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app.go#L186-L196 - Desktop app HTTP server with non-nil handler and serve call (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app.go#L280-L293 - Desktop app UI token-cookie check (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L194-L225 - Desktop app UI mux routes (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L278-L327 - Desktop app managed ollama serve process and its Expose and Browser settings (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L241-L255 - Desktop app embedded web assets directive at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/app/ui/app.go#L15-L16 - Desktop app webview cgo-only import at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/app/webview/webview.go#L51 - macOS build script desktop app npm and Go builds (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_darwin.sh#L200-L216 - Windows build script desktop app builds (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L981-L1005 - Desktop app `app_darwin.go` cgo-only import (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L10 - Desktop app `app_darwin.go` cgo-only import at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/app_darwin.go#L10 - Desktop app main package webview cgo-only import at v0.34.2 (checked October 2026): https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/webview.go#L6 - Desktop app Claude gateway listen address and start retry window variables (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L95-L96 - Desktop app Claude gateway reconcile call (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L535 - Desktop app Claude gateway reconcile conditions (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L546-L561 - Desktop app Claude gateway start installed check (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L592-L598 - Desktop app Claude gateway `setClaudeGatewayInstalled` start and conditional stop (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1202-L1229 - Desktop app Claude gateway `prepareClaudeDesktopConnection` start and conditional stop (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1478-L1493 - Desktop app Claude gateway model-mapping start with no existing gateway, and in that case stop on a failed profile change other than restart confirmation (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1645-L1667 - Desktop app Claude gateway model-mapping read of any existing gateway (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1574-L1579 - Desktop app Claude gateway model-mapping failed profile change with an existing gateway, returned without a stop (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1700-L1704 - Desktop app Claude gateway construction and start call (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L614-L631 - Desktop app Claude gateway start retry loop (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L944-L951 - Claude Desktop gateway default listen address (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L23-L26 - Claude Desktop gateway configured listen address copy (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L131-L132 - Claude Desktop gateway listener and HTTP server (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L198-L221 - Claude Desktop gateway routes (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L300-L361 - Claude Desktop gateway host allowlist (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L419-L435 - Claude Desktop gateway credential-header removal, `X-Api-Key` and `Host` rewrite, upstream wait and forwarding (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L363-L380 - Claude Desktop gateway reverse proxy and its transport with a nil `Proxy` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L126-L130 - Claude Desktop gateway upstream readiness wait (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L442-L511 - Claude Desktop gateway body size limits and upstream wait and reuse durations (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L27-L31 - Claude Desktop gateway upstream wait set from its timeout at construction (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L143 - Claude Desktop gateway model routing and body rewrite (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L594-L634 - Claude Desktop gateway model ID and Ollama name match (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L741-L748 - Claude Desktop gateway access error statuses (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L807-L818 - Claude Desktop gateway image-rejection retry (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L636-L724 - Claude Desktop gateway model limit and five fixed route IDs (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L16-L35 - Claude Desktop gateway model selection and route assignment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L308-L353 - Claude Desktop gateway built-in model fallback at construction (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L146-L148 - Claude Desktop gateway built-in cloud models (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L183-L200 - Claude Desktop gateway model construction and vision mark (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_models.go#L433-L467 - Claude Desktop gateway model list filtered by access (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L519-L555 - Claude Desktop gateway model access rules and plan check (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop_access.go#L55-L124 - Claude Desktop gateway default access state and local-model facts (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L750-L799 - Desktop app Claude gateway upstream from `ConnectableHost` and gateway-port refusal (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L599-L609 - Environment `Host` parsing of `OLLAMA_HOST` and its default (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L20-L60 - Environment `ConnectableHost` loopback substitution (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L62-L83 - Environment `Var` read from the process environment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L378-L380 - Desktop app UI `app.go` import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/ui/app.go#L5-L13 - Desktop app UI `ui.go` import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L6-L39 - Desktop app webview package import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/webview/webview.go#L53-L60 - Desktop app `app_darwin.go` import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L12-L44 - Desktop app main package `webview.go` import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/webview.go#L8-L26 - Desktop app UI `extract.go` import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/ui/extract.go#L5-L14 - Desktop app `codex_app_darwin.go` import block (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go#L5-L25 - Desktop app Codex Desktop integration, read whole (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go - Desktop app Codex Desktop controller interface and `launch.CodexApp` value (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go#L35-L49 - Codex Desktop proxy handler, read whole (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go - Codex Desktop proxy `/api/codex` prefix comment (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L27-L30 - Codex Desktop proxy handler comment on the existing Ollama listener (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L60-L62 - Codex Desktop proxy loopback refusal, prefix, health and status routes (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L157-L177 - Codex Desktop proxy routing to Ollama or the native upstream and managed-key refusal (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L193-L264 - Codex Desktop proxy outbound rewrite and Ollama header reset (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L359-L376 - Codex Desktop proxy status response (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L513-L529 - Codex Desktop proxy loopback remote-address check (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L640-L650 - Codex Desktop proxy headers copied to Ollama (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L675-L681 - Desktop app managed ollama serve restart loop (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L190-L225 - Desktop app ollama binary resolution (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L49-L51 - Windows build script conditional arm64 app build (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L995-L998 - Go go1.26.5 net/http/pprof handler registration (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/net/http/pprof/pprof.go#L96-L106 - Go go1.26.5 http.HandleFunc on DefaultServeMux (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L2870-L2878 - Go go1.26.5 Server Handler default (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L2971 - Go go1.26.5 nil-Handler request dispatch (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L3302-L3306 - Go go1.26.5 go build test-file exclusion (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L83 - Go go1.26.5 build -tags flag (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L236-L240 - Go go1.26.5 net/http `Header.Get` first value (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/net/http/header.go#L43-L51 - Go go1.26.5 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/internal/goversion/goversion.go#L12 - Go go1.26.5 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/go/build/build.go#L354-L355 - Go go1.26.5 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/net/http/pprof/pprof.go#L88 - Go go1.26.5 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.5 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.5 `simd/archsimd/_gen/simdgen` `package main` clause (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/simd/archsimd/_gen/simdgen/main.go#L71 - Go go1.26.5 go tool ignores `_` and `testdata` directories (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L3225-L3226 - Go go1.26.5 internal-directory import rule (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L2811-L2812 - Go go1.26.5 `go test` generated main importing `testing/internal/testdeps` (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/test.go#L790-L801 - Go go1.26.5 `LinkerDeps` linker-induced dependencies (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/pkg.go#L2638-L2672 - Go go1.26.5 cgo and SWIG implicit imports (checked October 2026): https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/pkg.go#L1912-L1935 - Go go1.26.0 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.0/src/internal/goversion/goversion.go#L12 - Go go1.26.0 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.0/src/go/build/build.go#L354-L355 - Go go1.26.0 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.0/src/net/http/pprof/pprof.go#L88 - Go go1.26.0 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.0/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.0 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.0/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.1 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.1/src/internal/goversion/goversion.go#L12 - Go go1.26.1 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.1/src/go/build/build.go#L354-L355 - Go go1.26.1 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.1/src/net/http/pprof/pprof.go#L88 - Go go1.26.1 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.1/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.1 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.1/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.2 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.2/src/internal/goversion/goversion.go#L12 - Go go1.26.2 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.2/src/go/build/build.go#L354-L355 - Go go1.26.2 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.2/src/net/http/pprof/pprof.go#L88 - Go go1.26.2 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.2/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.2 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.2/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.3 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.3/src/internal/goversion/goversion.go#L12 - Go go1.26.3 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.3/src/go/build/build.go#L354-L355 - Go go1.26.3 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.3/src/net/http/pprof/pprof.go#L88 - Go go1.26.3 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.3/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.3 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.3/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.4 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.4/src/internal/goversion/goversion.go#L12 - Go go1.26.4 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.4/src/go/build/build.go#L354-L355 - Go go1.26.4 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.4/src/net/http/pprof/pprof.go#L88 - Go go1.26.4 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.4/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.4 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.4/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.6 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.6/src/internal/goversion/goversion.go#L12 - Go go1.26.6 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.6/src/go/build/build.go#L354-L355 - Go go1.26.6 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.6/src/net/http/pprof/pprof.go#L88 - Go go1.26.6 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.6/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.6 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.6/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.7 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.7/src/internal/goversion/goversion.go#L12 - Go go1.26.7 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.7/src/go/build/build.go#L354-L355 - Go go1.26.7 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.7/src/net/http/pprof/pprof.go#L88 - Go go1.26.7 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.7/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.7 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.7/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.8 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.8/src/internal/goversion/goversion.go#L12 - Go go1.26.8 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.8/src/go/build/build.go#L354-L355 - Go go1.26.8 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.8/src/net/http/pprof/pprof.go#L88 - Go go1.26.8 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.8/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.8 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.8/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.9 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.9/src/internal/goversion/goversion.go#L12 - Go go1.26.9 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.9/src/go/build/build.go#L354-L355 - Go go1.26.9 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.9/src/net/http/pprof/pprof.go#L88 - Go go1.26.9 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.9/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.9 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.9/src/simd/archsimd/_gen/simdgen/main.go#L91 - Go go1.26.10 `internal/goversion` `Version = 26` (checked October 2026): https://github.com/golang/go/blob/go1.26.10/src/internal/goversion/goversion.go#L12 - Go go1.26.10 `go/build` release tags derived from `goversion.Version` (checked October 2026): https://github.com/golang/go/blob/go1.26.10/src/go/build/build.go#L354-L355 - Go go1.26.10 `net/http/pprof` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.10/src/net/http/pprof/pprof.go#L88 - Go go1.26.10 `testing/internal/testdeps` import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.10/src/testing/internal/testdeps/deps.go#L23 - Go go1.26.10 `simd/archsimd/_gen/simdgen` command import of `runtime/pprof` (checked October 2026): https://github.com/golang/go/blob/go1.26.10/src/simd/archsimd/_gen/simdgen/main.go#L91 - Create-file validation (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/create.go - Blob-path validation (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go - Manifest root creation with `os.MkdirAll(path, 0o755)` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go#L51-L58 - Blob directory creation with `os.MkdirAll(dirPath, 0o755)` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go#L400-L420 - New blob from a stream, set to `0o644` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/layer.go#L68-L77 - New blob moved from a file, set to `0o644` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/layer.go#L112-L121 - Legacy-path manifest write, set to `0o644` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1219-L1245 - Manifest stored as a blob, set to `0o644` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1287-L1329 - Copied manifest, set to `0o644` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1331-L1357 - Pull through `downloadBlob` for a model without tensor layers, `pullWithTransfer` otherwise (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1261-L1293 - `downloadBlob` downloading to the blob's store path (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L509-L551 - Pulled blob opened as `-partial` with `0o644`, renamed into place, no `chmod`, redirect target check (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L219-L331 - Blob-download redirect check, same origin within 10 redirects (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L336-L349 - Pulled blob `Prepare`, whose HEAD size request at line 151 passes the caller's options (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L131-L186 - `PullRequest` field `Insecure` with the comment `// Deprecated: ignored` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L856-L867 - Pull handler passing `Insecure: req.Insecure` to the registry options (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1530-L1532 - Push handler passing `Insecure: req.Insecure` to the registry options (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1584-L1586 - Push refusal of an `http` model name without `insecure` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1047-L1053 - Pull refusal of an `http` model name without `insecure` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1213-L1215 - `transfer` download with `http` base URL and `AllowPrivateHosts` under `insecure` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1502-L1560 - `transfer` upload with `http` base URL and `AllowPrivateHosts` under `insecure` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1594-L1653 - Registry transfers and redirect restrictions (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go - Registry request helper default redirect policy and `allowedRedirectHosts` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1753-L1822 - Registry and model-name parsing (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/types/model/name.go - Modelfile reference (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/docs/modelfile.mdx - Ollama model inventory and digests (rolling documentation, checked September 2026): https://docs.ollama.com/api/tags - Ollama model details (rolling documentation, checked September 2026): https://docs.ollama.com/api-reference/show-model-details - Scheduler (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go - VRAM-dependent context defaults (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/docs/context-length.mdx - Server startup VRAM-tier default context, 4096, 32768 or 262144 (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2571-L2586 - Server option builder, default `num_ctx` then model and request options (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L139-L173 - Scheduler `num_ctx` floors of 4 and, for multimodal models, 2048 (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L212-L220 - Scheduler `effectiveContext` training-context cap (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L192-L210 - Scheduler setting `num_ctx` to the runner's reported context length after a load (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L735-L740 - Scheduler single load retry with a lower automatic context after an out-of-memory failure (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L694-L708 - Server test for an automatic `num_ctx`: no request or model `num_ctx` and `OLLAMA_CONTEXT_LENGTH` at 0 (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L202-L212 - `reduceAutoNumCtxForLoadOOM`, capping at the training context before stepping down (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L811-L837 - `nextLowerAutoNumCtx`, 32768 above 32768 and 4096 above 4096 (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L953-L961 - Scheduler passing `num_ctx` to `mlxrunner.NewClient` for MLX models (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L631-L634 - Unsigned environment parser returning the default for an invalid value (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L261-L272 - Default options taking `NumCtx` from `OLLAMA_CONTEXT_LENGTH` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L1237-L1246 - `OLLAMA_CONTEXT_LENGTH` default of 0 (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L229-L230 - Request options and overrides (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/api/types.go - `ShowResponse` field `System` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L806-L812 - `GetModelInfo` response with `System: m.System` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1935-L1947 - `ShowHandler` show-cache branch and `GetModelInfo` call (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1831-L1835 - `ShowHandler` cloud model reference branch, cloud cache or `proxyCloudJSONRequest` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1779-L1800 - `ShowHandler` `all_manifests` branch calling `GetAllManifestsInfo` (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1819-L1829 - Request-body logging (checked November 2026): https://github.com/ollama/ollama/blob/v0.40.14/server/inference_request_log.go - Docker publication and the pre-28.0 same-L2 caveat: https://docs.docker.com/engine/network/port-publishing/ - systemd identities, environment, and umask: https://github.com/systemd/systemd/blob/v257/man/systemd.exec.xml - nginx Basic authentication: https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html - nginx TLS and client-certificate verification: https://nginx.org/en/docs/http/ngx_http_ssl_module.html - nginx locations and request-body size: https://nginx.org/en/docs/http/ngx_http_core_module.html - nginx conditions and returns: https://nginx.org/en/docs/http/ngx_http_rewrite_module.html - nginx proxy headers and timeout semantics: https://nginx.org/en/docs/http/ngx_http_proxy_module.html - nginx request-rate limiting: https://nginx.org/en/docs/http/ngx_http_limit_req_module.html - nginx connection limiting: https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html - nginx configuration testing: https://nginx.org/en/docs/switches.html - Apache htpasswd bcrypt options: https://httpd.apache.org/docs/2.4/programs/htpasswd.html - OWASP bcrypt work-factor guidance: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - Caddy Basic authentication: https://caddyserver.com/docs/caddyfile/directives/basic_auth - Caddy request matchers: https://caddyserver.com/docs/caddyfile/matchers - Caddy exclusive handles: https://caddyserver.com/docs/caddyfile/directives/handle - Caddy reverse proxy: https://caddyserver.com/docs/caddyfile/directives/reverse_proxy - Caddy validation and password hashing: https://caddyserver.com/docs/command-line - Cloudflare Tunnel origin parameters: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/origin-parameters/ - Cloudflare Access service tokens: https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/ - Cloudflare WAF rate-limiting rules: https://developers.cloudflare.com/waf/rate-limiting-rules/ - curl configuration input, timeouts, and write-out variables: https://curl.se/docs/manpage.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - ollama: Ollama (v0.40.14) - ollama-sweep: Ollama (go list sweep and go.sum reading) (v0.34.2) - ollama-rolling: Ollama documentation (rolling) (unknown) - docker: Docker Engine localhost boundary (28.0) - systemd: systemd (v257) - go: Go (go1.26.5) - go-1-26-0: Go (go1.26.0) - go-1-26-1: Go (go1.26.1) - go-1-26-2: Go (go1.26.2) - go-1-26-3: Go (go1.26.3) - go-1-26-4: Go (go1.26.4) - go-1-26-6: Go (go1.26.6) - go-1-26-7: Go (go1.26.7) - go-1-26-8: Go (go1.26.8) - go-1-26-9: Go (go1.26.9) - go-1-26-10: Go (go1.26.10) - trifles: github.com/dgryski/trifles (v0.0.0-20200323201526-dd97f9abfb48) - compress: github.com/klauspost/compress (v1.18.3) - x-text: golang.org/x/text (v0.30.0) - go-toml: github.com/pelletier/go-toml/v2 (v2.2.2) - nginx: nginx (unknown) - htpasswd: Apache htpasswd (unknown) - caddy: Caddy (unknown) - cloudflare: Cloudflare (unknown) - owasp: OWASP password guidance (unknown) - curl: curl (unknown) Claims (full source mapping in the JSON block below): - local-auth (REASONED; ollama, ollama-rolling): Local API has no inbound authentication; cloud keys and OLLAMA_AUTH concern upstream signing. - local-tls (REASONED; ollama): Ollama serves plaintext; an https prefix in OLLAMA_HOST does not enable TLS. - host-bind (REASONED; ollama, systemd): Standalone default is 127.0.0.1:11434; set OLLAMA_HOST in the service and reload/restart systemd. - container-bind (REASONED; ollama, docker): Official image sets 0.0.0.0:11434 internally; bridge publication and container binding are separate. - publication (REASONED; docker): Use 127.0.0.1:11434:11434 with Engine 28.0+; earlier same-L2 access defeats this boundary; unpublished private networks are an alternative. - host-network (REASONED; ollama, docker): Host networking ignores publication flags; override the image listener and restrict authorized peers. - basic-auth (REASONED; nginx): nginx server-scope Basic auth protects permitted locations; denied paths/methods may reject before authentication. - password-hash (REASONED; htpasswd, owasp): Use prompted htpasswd -B -C 12; bare -B defaults to bcrypt cost 5, below the cited minimum 10. - tls-proxy (REASONED; nginx): Publish authenticated TLS; encrypt the backend leg separately when proxy and Ollama are on different hosts. - host-header (REASONED; ollama, nginx): Proxy Host localhost:11434 avoids loopback middleware hostname rejection; it is not authentication. - idle-timeout (REASONED; nginx): proxy_read_timeout 300s limits gaps between reads, not total generation duration. - caddy-policy (REASONED; caddy): Caddy Basic auth, exact POST /api/chat matcher and fallback deny other paths; supply admission limits separately. - bearer-policy (REASONED; nginx): Exact nginx bearer comparison is a shared configuration secret without a constant-time guarantee; repeat it in every permitted location. - mtls (REASONED; nginx): Dedicated client CA and ssl_verify_client require certificate lifecycle management; combine Basic auth only deliberately. - access (REASONED; cloudflare, nginx): Access authenticates the tunnel; target a loopback policy proxy on 11435 to retain inference-only authorization. - access-host (REASONED; cloudflare, ollama): originRequest.httpHostHeader and the final proxy retain localhost:11434; keep both local listeners private. - access-limits (REASONED; cloudflare, nginx): Access authentication supplies no inference budgets; retain proxy limits, optionally add WAF rate limits and trusted client-address handling. - mfa (REASONED; cloudflare): Human MFA belongs at the fronting identity layer; machine service tokens are not a human MFA flow. - inference-policy (REASONED; nginx, ollama): Allow only exact required inference paths/methods; broad /api/ or /v1/ prefixes grant excessive authority. - pull (REASONED; ollama): Keep POST /api/pull private: acquisition consumes storage and network capacity. - push (REASONED; ollama): Keep POST /api/push private: export has separate upstream authorization. - create (REASONED; ollama): Keep POST /api/create private: it creates/replaces definitions and processes uploaded model material. - copy (REASONED; ollama): Keep POST /api/copy private: it creates another model name. - delete (REASONED; ollama): Keep DELETE /api/delete private: it removes models and associated data. - blob-write (REASONED; ollama): Keep POST /api/blobs/:digest private: it uploads model-store bytes. - blob-read (REASONED; ollama): Keep HEAD /api/blobs/:digest private: it reveals blob existence. - other-routes (REASONED; ollama): Default denial excludes account, experimental, desktop proxy and unused compatibility routes, including v0.40.14's balance and usage routes, which drop caller Authorization and sign ollama.com requests (this guide infers with the server's key). Source reading, not run: the /api/codex handler opens no listener and accepts any loopback TCP peer, which a same-host proxy has only over loopback. - file-validation (REASONED; ollama): Create validates relative filenames and blob paths; arbitrary host-file access is not established at this pin. - transfer (REASONED; ollama): Management transfers are egress/disk-fill surfaces; the registry helper's default redirects stay same-host or between hosts allowlisted under ollama.com, ollama.ai, hf.co and huggingface.co, insecure lifts that host check but not the 10-redirect cap, a blob download's direct-URL GET sets its own check but its HEAD size request keeps that default, and no unrestricted SSRF is demonstrated. - origins (REASONED; ollama): OLLAMA_ORIGINS adds to built-in origins; removing wildcards is neither authentication nor an exclusive-origin policy. - service-user (REASONED; ollama, systemd): Use a dedicated account and writable store; preserve GPU access and do not assume the container shares installer identity. install.sh adds the invoking user to the ollama group on each run with systemctl, which this guide infers grants no store access under 0700 directories; a group-access setup uses the ollama account or API, or deliberately chooses 0750 directories with systemd UMask=0027. - store (REASONED; ollama, systemd): Proposed 0700 directories and UMask=0077 need existing-permission/ACL inspection; v0.40.14's layer.go and manifest writers chmod files to 0644, which this guide infers UMask does not mask, while download.go writes pulled blobs with no chmod and transfer-package pulls are unread, so the 0700 directories carry the restriction; separate service configuration and proxy keys. - model-path (REASONED; ollama): OLLAMA_MODELS relocates models, not the entire service home or signing keys. - loaded-models (REASONED; ollama): Loaded-model capacity is automatic unless configured; the example selects one model, not a total memory cap. - parallel (REASONED; ollama): OLLAMA_NUM_PARALLEL defaults to 1. - queue (REASONED; ollama): OLLAMA_MAX_QUEUE defaults to 512; the example selects 16 without promising the seventeenth HTTP request is refused. - keep-alive (REASONED; ollama): Keep-alive defaults to five minutes; one minute is the example, and requests can override it. - context (REASONED; ollama): With OLLAMA_CONTEXT_LENGTH unset, 0 or invalid and no model or request num_ctx, the default is VRAM-dependent 4096/32768/262144 at about 23/47 GiB after overhead (documented 24/48 GiB). The FAQ's 4096 and Modelfile table's 2048 disagree with the code read, where 2048 is only a multimodal floor and the scheduler applies a training-context cap and an OOM step-down; MLX is unread. - rate (REASONED; nginx): Example nginx source-address rate is 2r/s, burst 4 nodelay, with 429 responses; trusted client-address handling matters behind proxies. - concurrency (REASONED; nginx): Example nginx server-name budget is four active requests with 429; it does not cap every idle/incomplete connection. - body-limit (REASONED; nginx): Example body cap is 1 MiB; a larger valid fixture should get 413 without upstream work. - parameters (REASONED; ollama): Gateway must validate model, num_ctx, num_predict and keep_alive; defaults/path allowlists do not enforce JSON ceilings. - registry-names (REASONED; ollama): Fully qualified registry names are supported; unqualified names select Ollama's registry and omitted tags select latest. - provenance (REASONED; ollama, ollama-rolling): Compare deployed tags digests with independent approval; mutable tags/digest equality do not establish publisher trust. - local-import (REASONED; ollama): CLI imports reviewed local GGUF through a Modelfile; artifact SHA-256 and manifest digest are different identifiers. - registry-egress (REASONED; ollama): Restrict acquisition/export to administrators and constrain egress; compare approved and controlled disallowed transfers. At v0.40.14 the pull and push handlers pass insecure despite PullRequest's Deprecated: ignored comment; it admits http model names, makes helper request URLs, not redirects, http, skips its redirect host check, sets AllowPrivateHosts and feeds unread transfer redirect checks. - cloud (REASONED; ollama): OLLAMA_NO_CLOUD=1 disables cloud models and web search after restart; it is not a no-network switch. - remotes (REASONED; ollama): OLLAMA_REMOTES selects remote-model hosts, not a registry allowlist. - diagnostics (REASONED; ollama, ollama-rolling): Keep root, version, status, tags, ps and show private; model details can disclose templates and, by source reading of v0.40.14's ShowResponse and GetModelInfo rather than the served page, which documents no system field, system prompts. - request-logs (REASONED; ollama): Disable OLLAMA_DEBUG and OLLAMA_DEBUG_LOG_REQUESTS; existing request captures/replay scripts need separate cleanup. - profiling (REASONED; ollama, ollama-sweep): No metrics route was found in the main router; this guide assesses that first-party v0.34.2 code registers no pprof handler, and at v0.40.14 found no pprof import in the server import block, other first-party code being unchecked. Shipped-binary pprof availability remains unestablished: observe the binary, deny public profiling, infer nothing from debug settings. - profiling-scope (REASONED; ollama-sweep, trifles, compress, x-text, go): Go 1.26.5 go list runs for this guide found neither net/http/pprof nor runtime/pprof in the default-tag v0.34.2 main-package graph for every go tool dist list pair that loaded, cgo off and, where supported, on; five cgo-off android/ios runs failed. This guide infers no go.sum or standard-library package adds either; Go outside 1.26.x, cgo/SWIG output, those runs and served-mux exposure stay open. - profiling-tags (REASONED; ollama-sweep, go): From source reading, v0.34.2's first-party custom tags integration, fast, generate, release, library and updater_live gate only _test.go files, which go build ignores; appengine and os400 appear only in readline/term.go, importing only syscall. The dependency search ignored build constraints, so which dependency files these tags select was not determined; go list runs show generate selects one. - profiling-tag-runs (REASONED; ollama-sweep, go, trifles, compress, x-text): Go 1.26.5 go list runs for this guide found neither net/http/pprof nor runtime/pprof in v0.34.2 main-package graphs with tag set updater_live, release,library, appengine, os400 or appengine,os400 on the eight first-class pairs, windows arm64 and aix ppc64, cgo off and on. This guide infers no first-party, go.sum or standard-library file adds either; cgo/SWIG output and Go outside 1.26.x are open. - profiling-generate (REASONED; ollama-sweep, go): Go 1.26.5 go list runs for v0.34.2 with tag sets containing generate failed on all ten pairs tried, cgo off and on, reporting both uniseg and main packages in dependency github.com/rivo/uniseg v0.4.7; -e reruns listed neither pprof package but marked that package with a load error, so whether such builds import either remains open. - profiling-go-versions (REASONED; go-1-26-0, go-1-26-1, go-1-26-2, go-1-26-3, go-1-26-4, go-1-26-6, go-1-26-7, go-1-26-8, go-1-26-9, go-1-26-10, go): Source reading, not run: Go 1.26.0 to 1.26.10 except 1.26.5 match 1.26.5: goversion is 26. Ignoring build constraints, only net/http/pprof, testing/internal/testdeps and a package main command import runtime/pprof in non-test files outside cmd and testdata. No such file imports the first two. This guide infers no such release adds either package to the 1.26.5 go list graphs; no graph was checked. - profiling-deps-scan (REASONED; ollama-sweep, trifles, compress, x-text, go-toml): From source reading, ignoring build constraints, only package main files in the 93 dependency modules with go.sum zip hashes import net/http/pprof or runtime/pprof. Imports followed from first-party packages stay in those modules and the standard library, so this guide infers no such module adds either at those versions. The 15 cached modules with no go.sum zip hash were excluded. - profiling-mux (REASONED; go, ollama): This guide's inference, not run: Go 1.26.5 net/http/pprof's init registers /debug/pprof/ handlers on http.DefaultServeMux, which a server with a nil Handler, as in v0.40.14's Serve, uses, so a build linking that package would serve them on the Ollama listener, while builds whose go list graph lacks it get none from it. - profiling-release (REASONED; ollama, ollama-sweep): From this guide's reading of v0.40.14's Dockerfile, build scripts and release workflow, which show intended builds rather than artifact contents, the main package is built with no -tags, and with CGO_ENABLED=1 in the Dockerfile, the darwin build and both Windows CLI builds. Its go.mod declares go 1.26.10, which the Dockerfile uses to choose Go; the go list runs used Go 1.26.5 on v0.34.2. - profiling-app-runs (REASONED; ollama-sweep): Go 1.26.5 go list -e -deps runs for this guide list no net/http/pprof or runtime/pprof for the v0.34.2 desktop app main package on darwin/windows amd64 and arm64, cgo off and on; linux lists only the error-marked main package. Loaded cells mark app/ui (no app/dist); cgo-off cells cannot load cgo-only app/webview and omit the main package's cgo files with no error, so imports may be incomplete. - profiling-app-listener (REASONED; ollama, go): From source reading, the app's UI listener is loopback (127.0.0.1:0, or 3001 in dev mode), served by an http.Server with a non-nil Handler whose mux has a token-cookie check on /api/v1 routes, proxy and catch-all routes without it, and no /debug/pprof pattern. This guide's inference, not run: even a build linking net/http/pprof would not serve DefaultServeMux handlers there. - profiling-app-gateway (REASONED; ollama, go): From source reading, another loopback app listener is the Claude Desktop gateway on 127.0.0.1:11435, in macOS cgo builds, started only when Claude Desktop is installed. Its non-nil Handler refuses non-loopback Host values and a non-empty first Origin, serves four routes, not /debug/pprof, and forwards only POST /v1/messages, stripping caller auth headers, to the app's OLLAMA_HOST; not run. - profiling-app-gateway-start (REASONED; ollama): Source reading, not run: startup starts the gateway only if Claude Desktop is installed and configured to use it; setClaudeGatewayInstalled, prepareClaudeDesktopConnection and model mapping start it if installed. The first two stop it unless Claude Desktop then uses it (the first not after an auto-mode error); mapping, if none existed, stops it on profile-change errors except restart confirmation. - profiling-app-release (REASONED; ollama): By this guide's reading of intended builds, not artifacts, build_darwin.sh builds the app package with GOOS=darwin, CGO_ENABLED=1, amd64 and arm64 and no -tags. In build_windows.ps1, buildApp builds it with no -tags, by default setting no GOARCH under its unconditional CGO_ENABLED=1 and, when the cross-compile tools are present, for arm64 (GOOS=windows, GOARCH=arm64, CGO_ENABLED=1). - desktop-app-server (REASONED; ollama): From source reading, the desktop app serves no HTTP on the Ollama API listener itself: it runs the separate ollama binary with serve and restarts it, copying its environment, setting OLLAMA_HOST=0.0.0.0 when Expose is on and OLLAMA_ORIGINS=* when Browser is on, so Expose or an inherited OLLAMA_HOST can expose that process. No shipped app binary was inspected and no run observed these behaviours. - verify-inventory (REASONED; ollama, docker, systemd): Inspect namespaces, publication, identity, store and ACLs; local ss output alone does not establish isolation. - verify-bypass (REASONED; ollama, curl): HTTP means direct 11434 answered; fixed refusal needs the real remote path plus successful proxy inference; local errors are inconclusive. - verify-auth (REASONED; nginx, ollama, curl): Absent/wrong credentials must refuse while correct credentials complete chat; streamed 200 alone is insufficient. - verify-tls (REASONED; nginx, curl): Correct trust/hostname succeeds; wrong trust/hostname and missing/untrusted mTLS certificates must fail before inference. - verify-management (REASONED; ollama): Public management must be denied without upstream mutation; private admin operations and public chat supply positive controls. - verify-tags (REASONED; nginx, ollama-rolling): Public tags may return 404 with valid credentials; private tags should return model JSON; public 404 is not an authentication test. - verify-origins (REASONED; ollama, nginx, caddy): Compare unwanted, required and built-in origins with actual browser calls; POST-only proxy preflight refusals differ by variant. - verify-rate (REASONED; nginx): Eight bounded requests test admission with limiter/upstream evidence and recovery; the burst alone does not demonstrate both limiters. - verify-concurrency (REASONED; nginx): Hold four accepted requests active within the rate budget, reject another, then confirm a freed slot accepts work. - verify-scheduler (REASONED; ollama): Compare loading/eviction, keep-alive expiry and actual queue occupancy; public limits can prevent exercising the private queue. - verify-cloud (REASONED; ollama): Authorized cloud chat works before disablement and refuses afterward while local chat succeeds; invalid credentials/models are inconclusive. - verify-captures (REASONED; ollama): Compare completed canary requests with logging enabled/disabled in the actual temporary namespace; deny public diagnostics while private controls work. - proxy-parsing (REASONED; nginx, caddy): nginx -t and caddy validate check configuration acceptance only; native parsing was not demonstrated here. - curl-version (REASONED; curl): Displayed exitcode and errormsg write-out variables need curl 7.75.0 or later; no tested curl release is recorded. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "d4001588908bdf2723fb2ae68911a612a4680bb06f230088e472c8ff48efbc36", "components": { "ollama": { "name": "Ollama", "basis": "v0.40.14", "sources": { "s926454766be0": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go", "s763beb3a2234": "https://github.com/ollama/ollama/blob/v0.40.14/api/client.go", "s10d55d89ff19": "https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go", "s477d97fbecc7": "https://github.com/ollama/ollama/blob/v0.40.14/main.go#L3-L13", "s577d2578b863": "https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2644-L2650", "sbb511d763012": "https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2809-L2831", "s4c83d37be5fa": "https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2541-L2831", "s4dbf716fa9c0": "https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L2269-L2285", "s934786aa1508": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2523-L2528", "s6bce9b343229": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2537-L2547", "sbc0cc53c7ea8": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2588-L2593", "s5b265711d2af": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L3-L55", "se69445e252da": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2472-L2596", "s2474467b2e60": "https://github.com/ollama/ollama/blob/v0.40.14/cmd/cmd.go#L3-L60", "s2f1d94710feb": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1", "s6be064685bba": "https://github.com/ollama/ollama/blob/v0.40.14/docs/faq.mdx", "s26be17f980e1": "https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go", "se20b98b4fa6f": "https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile", "s8fdbeefa696b": "https://github.com/ollama/ollama/blob/v0.40.14/docs/api.md", "s670d892bf359": "https://github.com/ollama/ollama/blob/v0.40.14/server/create.go", "sce8a0d1f3831": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go", "s7cb14cc64574": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go", "sc0e1dbae723e": "https://github.com/ollama/ollama/blob/v0.40.14/docs/linux.mdx", "sdd9be070f841": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh", "s8451cd2c04b7": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go", "s597bdfe54329": "https://github.com/ollama/ollama/blob/v0.40.14/api/types.go", "scf9b09b4b118": "https://github.com/ollama/ollama/blob/v0.40.14/docs/context-length.mdx", "s885a39bf1889": "https://github.com/ollama/ollama/blob/v0.40.14/types/model/name.go", "sf6fd078295ae": "https://github.com/ollama/ollama/blob/v0.40.14/docs/modelfile.mdx", "s2265f8345962": "https://github.com/ollama/ollama/blob/v0.40.14/server/inference_request_log.go", "sba6a4f320b6a": "https://github.com/ollama/ollama/blob/v0.40.14/go.mod#L3", "s1ac39b3660a2": "https://github.com/ollama/ollama/blob/v0.40.14/Dockerfile#L257-L273", "se3193be32015": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_darwin.sh#L88-L89", "s38976770bc28": "https://github.com/ollama/ollama/blob/v0.40.14/cmake/local.cmake#L711-L758", "s1be5d1576a50": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L865-L920", "s164b13109d88": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L71-L82", "s3d5c5764fed2": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L354-L378", "s6b9f9627a574": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L210-L230", "s14d11e19ac27": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L296", "sc06aae169fc9": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L1299", "s2332130fa1e7": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L13-L25", "s6129c0dd7a10": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L338", "saa84b3aea335": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L896-L908", "s55a8b7a39807": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L231", "s284d5c9ccbf6": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L295", "s589f0e50fa35": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L34", "s3b7fa1fcefbd": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L480-L557", "s828bd193c1fd": "https://github.com/ollama/ollama/blob/v0.40.14/.github/workflows/release.yaml#L562-L667", "s3cc1f9009971": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app.go#L186-L196", "s63598805dfec": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app.go#L280-L293", "sf3b9c404c46f": "https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L194-L225", "s8cc22efcaaca": "https://github.com/ollama/ollama/blob/v0.40.14/app/ui/ui.go#L278-L327", "s4fb8958f1218": "https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L241-L255", "seaa45ac56dd7": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_darwin.sh#L200-L216", "s41d553145c17": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L981-L1005", "sa56366559d62": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L10", "sa55b9f381b09": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L95-L96", "scbcba9fa2026": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L535", "s776bbcd95f3c": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L546-L561", "s3d5b427b463a": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L592-L598", "s32d2fa1014f8": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1202-L1229", "scde20ea5a74a": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1478-L1493", "sb2938e867f20": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1645-L1667", "sab2fbcd0c98e": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L614-L631", "s14ee6c3159e4": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L944-L951", "sfc4c24d8630b": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L23-L26", "s498d9212196e": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L131-L132", "s80406fd9e16d": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L198-L221", "s5f05208cf314": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L300-L361", "sacbb561c6cfe": "https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L190-L225", "sadbce7446a0d": "https://github.com/ollama/ollama/blob/v0.40.14/app/server/server.go#L49-L51", "s7283b88c8765": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/build_windows.ps1#L995-L998", "sc9151806147c": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1574-L1579", "s61e666769722": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L1700-L1704", "sb816d460f392": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L419-L435", "s1efc29a8d9c6": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L363-L380", "s741bae4c4fa6": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/claude_desktop.go#L126-L130", "sb40469426b0e": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/app_darwin.go#L599-L609", "sada8b309ca30": "https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L20-L60", "sfee7de68771c": "https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L62-L83", "s6540e626aa77": "https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L378-L380", "s5e41321e18ef": "https://github.com/ollama/ollama/blob/v0.40.14/app/cmd/app/codex_app_darwin.go", "s476a70052b98": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go", "s04e05b71ae0c": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L27-L30", "sf17ba77d4cfb": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L60-L62", "s5e484a0c4247": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L157-L177", "s26e9dfa27980": "https://github.com/ollama/ollama/blob/v0.40.14/internal/proxy/codex_desktop.go#L640-L650", "s7a9f22479c78": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2385-L2450", "s5fa22654febc": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2684-L2696", "s0376a63afed9": "https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L28-L31", "sf652fe336dd3": "https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L176-L226", "s6beaf6f88873": "https://github.com/ollama/ollama/blob/v0.40.14/server/cloud_proxy.go#L408-L422", "s6fe675aeacf6": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1753-L1822", "sc767079217fa": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go#L51-L58", "sda1300806a6f": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/paths.go#L400-L420", "s86c505b22a2e": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/layer.go#L68-L77", "sbfd802f779fc": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/layer.go#L112-L121", "s14695a033e02": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1219-L1245", "sd702c5befe55": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1287-L1329", "sf7e0612d39ea": "https://github.com/ollama/ollama/blob/v0.40.14/manifest/manifest.go#L1331-L1357", "s862914c50e5d": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1261-L1293", "s780ccf68092b": "https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L509-L551", "s1c5af6f0c574": "https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L219-L331", "s57e44eeb534d": "https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L336-L349", "s39a4f25f6ac7": "https://github.com/ollama/ollama/blob/v0.40.14/server/download.go#L131-L186", "sdf91d0608d37": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L2571-L2586", "sb618c5e4f17b": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L139-L173", "sf3ccbc200368": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L212-L220", "s09cfe9587540": "https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L1237-L1246", "sf5b30e423c90": "https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L229-L230", "s77b969258f4f": "https://github.com/ollama/ollama/blob/v0.40.14/scripts/install.sh#L197-L212", "sc86b87d0a117": "https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L806-L812", "s94fdd53a108e": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1935-L1947", "s5d162677208c": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1831-L1835", "se630a0a320f1": "https://github.com/ollama/ollama/blob/v0.40.14/api/types.go#L856-L867", "s47731e254da7": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1530-L1532", "saf82203abe6f": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1584-L1586", "s73ed57de607f": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1047-L1053", "s4adb801bd1b4": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1213-L1215", "s6b099c8e9f4a": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1502-L1560", "s071857f111bf": "https://github.com/ollama/ollama/blob/v0.40.14/server/images.go#L1594-L1653", "s8bb7e5126025": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L192-L210", "s33a315a5654d": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L735-L740", "s5f74467e50db": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L694-L708", "s0779856619f5": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L202-L212", "se3fc27596f3f": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L811-L837", "sc1ca059531b9": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L953-L961", "se5f97896e369": "https://github.com/ollama/ollama/blob/v0.40.14/server/sched.go#L631-L634", "sc38e9c04399d": "https://github.com/ollama/ollama/blob/v0.40.14/envconfig/config.go#L261-L272", "s7c9c7bfb6e3f": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1779-L1800", "sf5128e4106c3": "https://github.com/ollama/ollama/blob/v0.40.14/server/routes.go#L1819-L1829" } }, "ollama-sweep": { "name": "Ollama (go list sweep and go.sum reading)", "basis": "v0.34.2", "sources": { "s9bf8940c3001": "https://github.com/ollama/ollama/blob/v0.34.2/main.go#L3-L13", "s98a6abaa3838": "https://github.com/ollama/ollama/blob/v0.34.2/integration/basic_test.go#L1", "s9a29ad2cbe09": "https://github.com/ollama/ollama/blob/v0.34.2/integration/reg_groups_test.go#L1", "s748c551a2a08": "https://github.com/ollama/ollama/blob/v0.34.2/integration/generate_jinja_test.go#L1", "sd7c08f30e61e": "https://github.com/ollama/ollama/blob/v0.34.2/app/updater/updater_live_test.go#L1", "s720db4671dfd": "https://github.com/ollama/ollama/blob/v0.34.2/readline/term.go#L1-L7", "s2422d51c7511": "https://github.com/ollama/ollama/blob/v0.34.2/go.mod#L3", "sc8a033a390da": "https://github.com/ollama/ollama/blob/v0.34.2/go.mod#L5-L91", "sfc02278c501f": "https://github.com/ollama/ollama/blob/v0.34.2/go.sum", "sad61f21372d7": "https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/app.go#L1-L33", "sf38afcc73250": "https://github.com/ollama/ollama/blob/v0.34.2/app/ui/app.go#L15-L16", "s45897fae891f": "https://github.com/ollama/ollama/blob/v0.34.2/app/webview/webview.go#L51", "s2b2be911f51e": "https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/app_darwin.go#L10", "se6c615cc2b3a": "https://github.com/ollama/ollama/blob/v0.34.2/app/cmd/app/webview.go#L6" } }, "ollama-rolling": { "name": "Ollama documentation (rolling)", "basis": "unknown", "sources": { "sf49fae5df9fd": "https://docs.ollama.com/api/authentication", "s6c2961786919": "https://docs.ollama.com/api/tags", "s528f69db04e0": "https://docs.ollama.com/api-reference/show-model-details" } }, "docker": { "name": "Docker Engine localhost boundary", "basis": "28.0", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } }, "systemd": { "name": "systemd", "basis": "v257", "sources": { "sd4c1aeb45417": "https://github.com/systemd/systemd/blob/v257/man/systemd.exec.xml" } }, "go": { "name": "Go", "basis": "go1.26.5", "sources": { "sab55ef9009c4": "https://github.com/golang/go/blob/go1.26.5/src/net/http/pprof/pprof.go#L96-L106", "s2e779f75153e": "https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L2870-L2878", "s038b2186ee21": "https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L2971", "sa0c56e16001f": "https://github.com/golang/go/blob/go1.26.5/src/net/http/server.go#L3302-L3306", "s7f4cde22e030": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L83", "sdba7b87fda8d": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L236-L240", "s7290caae7a3c": "https://github.com/golang/go/blob/go1.26.5/src/net/http/header.go#L43-L51", "s0388b420c217": "https://github.com/golang/go/blob/go1.26.5/src/internal/goversion/goversion.go#L12", "s41262ad87b65": "https://github.com/golang/go/blob/go1.26.5/src/net/http/pprof/pprof.go#L88", "s80e5cbbd9e14": "https://github.com/golang/go/blob/go1.26.5/src/testing/internal/testdeps/deps.go#L23", "s63df9d2be607": "https://github.com/golang/go/blob/go1.26.5/src/simd/archsimd/_gen/simdgen/main.go#L91", "s247aaccc10e3": "https://github.com/golang/go/blob/go1.26.5/src/simd/archsimd/_gen/simdgen/main.go#L71", "sdbf899cd837b": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L3225-L3226", "s28c91a6e011d": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/alldocs.go#L2811-L2812", "s6d68526316f0": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/test.go#L790-L801", "sd3be35e6620b": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/pkg.go#L2638-L2672", "sd58d43f93322": "https://github.com/golang/go/blob/go1.26.5/src/cmd/go/internal/load/pkg.go#L1912-L1935" } }, "go-1-26-0": { "name": "Go", "basis": "go1.26.0", "sources": { "s7670be382fd6": "https://github.com/golang/go/blob/go1.26.0/src/internal/goversion/goversion.go#L12", "sc4d5a418cc8a": "https://github.com/golang/go/blob/go1.26.0/src/net/http/pprof/pprof.go#L88", "s63f63360f142": "https://github.com/golang/go/blob/go1.26.0/src/testing/internal/testdeps/deps.go#L23", "se924f730cd16": "https://github.com/golang/go/blob/go1.26.0/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-1": { "name": "Go", "basis": "go1.26.1", "sources": { "sc2735a74234b": "https://github.com/golang/go/blob/go1.26.1/src/internal/goversion/goversion.go#L12", "sd0122b4596e5": "https://github.com/golang/go/blob/go1.26.1/src/net/http/pprof/pprof.go#L88", "sa24dcdd9e52c": "https://github.com/golang/go/blob/go1.26.1/src/testing/internal/testdeps/deps.go#L23", "s2a55e411da1d": "https://github.com/golang/go/blob/go1.26.1/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-2": { "name": "Go", "basis": "go1.26.2", "sources": { "sa8ba29c45871": "https://github.com/golang/go/blob/go1.26.2/src/internal/goversion/goversion.go#L12", "s058a974ee5d5": "https://github.com/golang/go/blob/go1.26.2/src/net/http/pprof/pprof.go#L88", "s019e0fb220fc": "https://github.com/golang/go/blob/go1.26.2/src/testing/internal/testdeps/deps.go#L23", "s8a4dc2d3db9b": "https://github.com/golang/go/blob/go1.26.2/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-3": { "name": "Go", "basis": "go1.26.3", "sources": { "sdc07d97a87b6": "https://github.com/golang/go/blob/go1.26.3/src/internal/goversion/goversion.go#L12", "sed7ba616e62c": "https://github.com/golang/go/blob/go1.26.3/src/net/http/pprof/pprof.go#L88", "s040d1ea399ae": "https://github.com/golang/go/blob/go1.26.3/src/testing/internal/testdeps/deps.go#L23", "s898567a72486": "https://github.com/golang/go/blob/go1.26.3/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-4": { "name": "Go", "basis": "go1.26.4", "sources": { "s2fd10ac9b790": "https://github.com/golang/go/blob/go1.26.4/src/internal/goversion/goversion.go#L12", "s860b46eaab36": "https://github.com/golang/go/blob/go1.26.4/src/net/http/pprof/pprof.go#L88", "s2104c8373c85": "https://github.com/golang/go/blob/go1.26.4/src/testing/internal/testdeps/deps.go#L23", "s0e9e578b8243": "https://github.com/golang/go/blob/go1.26.4/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-6": { "name": "Go", "basis": "go1.26.6", "sources": { "s8b8ebc7e8db6": "https://github.com/golang/go/blob/go1.26.6/src/internal/goversion/goversion.go#L12", "sb1475154fbb7": "https://github.com/golang/go/blob/go1.26.6/src/net/http/pprof/pprof.go#L88", "s974c60526bb6": "https://github.com/golang/go/blob/go1.26.6/src/testing/internal/testdeps/deps.go#L23", "s52851e8b4312": "https://github.com/golang/go/blob/go1.26.6/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-7": { "name": "Go", "basis": "go1.26.7", "sources": { "s17ad8bf46090": "https://github.com/golang/go/blob/go1.26.7/src/internal/goversion/goversion.go#L12", "s54141d62f948": "https://github.com/golang/go/blob/go1.26.7/src/net/http/pprof/pprof.go#L88", "sf081388dd0f7": "https://github.com/golang/go/blob/go1.26.7/src/testing/internal/testdeps/deps.go#L23", "s64e2db3b685b": "https://github.com/golang/go/blob/go1.26.7/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-8": { "name": "Go", "basis": "go1.26.8", "sources": { "s6175e03794cf": "https://github.com/golang/go/blob/go1.26.8/src/internal/goversion/goversion.go#L12", "s88d4166a7bee": "https://github.com/golang/go/blob/go1.26.8/src/net/http/pprof/pprof.go#L88", "sa79ec4182285": "https://github.com/golang/go/blob/go1.26.8/src/testing/internal/testdeps/deps.go#L23", "s9ad74046b534": "https://github.com/golang/go/blob/go1.26.8/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-9": { "name": "Go", "basis": "go1.26.9", "sources": { "s69637c613830": "https://github.com/golang/go/blob/go1.26.9/src/internal/goversion/goversion.go#L12", "s71887e5f7cdf": "https://github.com/golang/go/blob/go1.26.9/src/net/http/pprof/pprof.go#L88", "s6728c0a5d0d1": "https://github.com/golang/go/blob/go1.26.9/src/testing/internal/testdeps/deps.go#L23", "s110ef73adaf3": "https://github.com/golang/go/blob/go1.26.9/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "go-1-26-10": { "name": "Go", "basis": "go1.26.10", "sources": { "scff07ded2fad": "https://github.com/golang/go/blob/go1.26.10/src/internal/goversion/goversion.go#L12", "sd61f4ff8d5d6": "https://github.com/golang/go/blob/go1.26.10/src/net/http/pprof/pprof.go#L88", "s9b82f1629b4d": "https://github.com/golang/go/blob/go1.26.10/src/testing/internal/testdeps/deps.go#L23", "s599b4645909f": "https://github.com/golang/go/blob/go1.26.10/src/simd/archsimd/_gen/simdgen/main.go#L91" } }, "trifles": { "name": "github.com/dgryski/trifles", "basis": "v0.0.0-20200323201526-dd97f9abfb48", "sources": { "s38ad5e627932": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/cstbucket/main.go#L1-L17", "seeae3b9d9459": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/fastpprof/main.go#L1-L13", "s56182db1ad94": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/jumpreplica/main.go#L1-L18", "s4e12f43a1afe": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/lz/lz.go#L1-L11", "s6a11f78133a6": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/matcher/main.go#L1-L13", "s82d128642faa": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/quantiles/main.go#L1-L22", "s53fe5b36bae7": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/udprelay/main.go#L1-L26", "s3c8772bb591f": "https://github.com/dgryski/trifles/blob/dd97f9abfb48/fastpprof/main.go#L16-L42" } }, "compress": { "name": "github.com/klauspost/compress", "basis": "v1.18.3", "sources": { "sd309de5a57e2": "https://github.com/klauspost/compress/blob/v1.18.3/s2/cmd/s2c/main.go#L1-L27" } }, "x-text": { "name": "golang.org/x/text", "basis": "v0.30.0", "sources": { "s7b232c7ac531": "https://github.com/golang/text/blob/v0.30.0/collate/tools/colcmp/colcmp.go#L5-L22" } }, "go-toml": { "name": "github.com/pelletier/go-toml/v2", "basis": "v2.2.2", "sources": { "s263bb2467c05": "https://github.com/pelletier/go-toml/blob/v2.2.2/marshaler_test.go#L1571", "sf307292d806f": "https://github.com/pelletier/go-toml/blob/v2.2.2/marshaler_test.go#L1649" } }, "nginx": { "name": "nginx", "basis": "unknown", "sources": { "sf32ce2956917": "https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html", "s40bdf1af1596": "https://nginx.org/en/docs/http/ngx_http_core_module.html", "sf0bc2adae8cb": "https://nginx.org/en/docs/http/ngx_http_rewrite_module.html", "sda4ba3fcedbe": "https://nginx.org/en/docs/http/ngx_http_ssl_module.html", "sf3430c5a0b22": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html", "sb3bee9429629": "https://nginx.org/en/docs/http/ngx_http_limit_req_module.html", "sddc43aac8779": "https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html", "sa28876996ece": "https://nginx.org/en/docs/switches.html" } }, "htpasswd": { "name": "Apache htpasswd", "basis": "unknown", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "caddy": { "name": "Caddy", "basis": "unknown", "sources": { "sbc2bd9599bd1": "https://caddyserver.com/docs/caddyfile/directives/basic_auth", "s38b1b78ce980": "https://caddyserver.com/docs/caddyfile/matchers", "s39d7d5ee55e2": "https://caddyserver.com/docs/caddyfile/directives/handle", "s3865a73ccda3": "https://caddyserver.com/docs/caddyfile/directives/reverse_proxy", "sc5832c7dc52f": "https://caddyserver.com/docs/command-line" } }, "cloudflare": { "name": "Cloudflare", "basis": "unknown", "sources": { "s4b65a8359a37": "https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/configure-tunnels/origin-parameters/", "s15ab6de85740": "https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/", "sbd88e529ebf2": "https://developers.cloudflare.com/waf/rate-limiting-rules/" } }, "owasp": { "name": "OWASP password guidance", "basis": "unknown", "sources": { "s49e8d76431d9": "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html" } }, "curl": { "name": "curl", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "local-auth": {"text": "Local API has no inbound authentication; cloud keys and OLLAMA_AUTH concern upstream signing.", "components": ["ollama", "ollama-rolling"], "sources": ["ollama-rolling:sf49fae5df9fd", "ollama:s926454766be0", "ollama:s763beb3a2234"], "status": "REASONED"}, "local-tls": {"text": "Ollama serves plaintext; an https prefix in OLLAMA_HOST does not enable TLS.", "components": ["ollama"], "sources": ["ollama:s926454766be0", "ollama:s10d55d89ff19"], "status": "REASONED"}, "host-bind": {"text": "Standalone default is 127.0.0.1:11434; set OLLAMA_HOST in the service and reload/restart systemd.", "components": ["ollama", "systemd"], "sources": ["ollama:s6be064685bba", "ollama:s26be17f980e1", "systemd:sd4c1aeb45417"], "status": "REASONED"}, "container-bind": {"text": "Official image sets 0.0.0.0:11434 internally; bridge publication and container binding are separate.", "components": ["ollama", "docker"], "sources": ["ollama:se20b98b4fa6f", "docker:s1e53417c513d"], "status": "REASONED"}, "publication": {"text": "Use 127.0.0.1:11434:11434 with Engine 28.0+; earlier same-L2 access defeats this boundary; unpublished private networks are an alternative.", "components": ["docker"], "sources": ["docker:s1e53417c513d"], "status": "REASONED"}, "host-network": {"text": "Host networking ignores publication flags; override the image listener and restrict authorized peers.", "components": ["ollama", "docker"], "sources": ["ollama:se20b98b4fa6f", "docker:s1e53417c513d"], "status": "REASONED"}, "basic-auth": {"text": "nginx server-scope Basic auth protects permitted locations; denied paths/methods may reject before authentication.", "components": ["nginx"], "sources": ["nginx:sf32ce2956917", "nginx:s40bdf1af1596", "nginx:sf0bc2adae8cb"], "status": "REASONED"}, "password-hash": {"text": "Use prompted htpasswd -B -C 12; bare -B defaults to bcrypt cost 5, below the cited minimum 10.", "components": ["htpasswd", "owasp"], "sources": ["htpasswd:scde9004bb967", "owasp:s49e8d76431d9"], "status": "REASONED"}, "tls-proxy": {"text": "Publish authenticated TLS; encrypt the backend leg separately when proxy and Ollama are on different hosts.", "components": ["nginx"], "sources": ["nginx:sda4ba3fcedbe"], "status": "REASONED"}, "host-header": {"text": "Proxy Host localhost:11434 avoids loopback middleware hostname rejection; it is not authentication.", "components": ["ollama", "nginx"], "sources": ["ollama:s926454766be0", "nginx:sf3430c5a0b22"], "status": "REASONED"}, "idle-timeout": {"text": "proxy_read_timeout 300s limits gaps between reads, not total generation duration.", "components": ["nginx"], "sources": ["nginx:sf3430c5a0b22"], "status": "REASONED"}, "caddy-policy": {"text": "Caddy Basic auth, exact POST /api/chat matcher and fallback deny other paths; supply admission limits separately.", "components": ["caddy"], "sources": ["caddy:sbc2bd9599bd1", "caddy:s38b1b78ce980", "caddy:s39d7d5ee55e2", "caddy:s3865a73ccda3"], "status": "REASONED"}, "bearer-policy": {"text": "Exact nginx bearer comparison is a shared configuration secret without a constant-time guarantee; repeat it in every permitted location.", "components": ["nginx"], "sources": ["nginx:sf0bc2adae8cb"], "status": "REASONED"}, "mtls": {"text": "Dedicated client CA and ssl_verify_client require certificate lifecycle management; combine Basic auth only deliberately.", "components": ["nginx"], "sources": ["nginx:sda4ba3fcedbe"], "status": "REASONED"}, "access": {"text": "Access authenticates the tunnel; target a loopback policy proxy on 11435 to retain inference-only authorization.", "components": ["cloudflare", "nginx"], "sources": ["cloudflare:s4b65a8359a37", "cloudflare:s15ab6de85740", "nginx:s40bdf1af1596"], "status": "REASONED"}, "access-host": {"text": "originRequest.httpHostHeader and the final proxy retain localhost:11434; keep both local listeners private.", "components": ["cloudflare", "ollama"], "sources": ["cloudflare:s4b65a8359a37", "ollama:s926454766be0"], "status": "REASONED"}, "access-limits": {"text": "Access authentication supplies no inference budgets; retain proxy limits, optionally add WAF rate limits and trusted client-address handling.", "components": ["cloudflare", "nginx"], "sources": ["cloudflare:sbd88e529ebf2", "nginx:sb3bee9429629"], "status": "REASONED"}, "mfa": {"text": "Human MFA belongs at the fronting identity layer; machine service tokens are not a human MFA flow.", "components": ["cloudflare"], "sources": ["cloudflare:s15ab6de85740"], "status": "REASONED"}, "inference-policy": {"text": "Allow only exact required inference paths/methods; broad /api/ or /v1/ prefixes grant excessive authority.", "components": ["nginx", "ollama"], "sources": ["nginx:s40bdf1af1596", "nginx:sf0bc2adae8cb", "ollama:s926454766be0"], "status": "REASONED"}, "pull": {"text": "Keep POST /api/pull private: acquisition consumes storage and network capacity.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b"], "status": "REASONED"}, "push": {"text": "Keep POST /api/push private: export has separate upstream authorization.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b"], "status": "REASONED"}, "create": {"text": "Keep POST /api/create private: it creates/replaces definitions and processes uploaded model material.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b", "ollama:s670d892bf359"], "status": "REASONED"}, "copy": {"text": "Keep POST /api/copy private: it creates another model name.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b"], "status": "REASONED"}, "delete": {"text": "Keep DELETE /api/delete private: it removes models and associated data.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b"], "status": "REASONED"}, "blob-write": {"text": "Keep POST /api/blobs/:digest private: it uploads model-store bytes.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b"], "status": "REASONED"}, "blob-read": {"text": "Keep HEAD /api/blobs/:digest private: it reveals blob existence.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b"], "status": "REASONED"}, "other-routes": {"text": "Default denial excludes account, experimental, desktop proxy and unused compatibility routes, including v0.40.14's balance and usage routes, which drop caller Authorization and sign ollama.com requests (this guide infers with the server's key). Source reading, not run: the /api/codex handler opens no listener and accepts any loopback TCP peer, which a same-host proxy has only over loopback.", "components": ["ollama"], "sources": ["ollama:s926454766be0", "ollama:s7a9f22479c78", "ollama:s5fa22654febc", "ollama:sf652fe336dd3", "ollama:s6beaf6f88873", "ollama:s0376a63afed9", "ollama:s476a70052b98", "ollama:s5e41321e18ef", "ollama:s04e05b71ae0c", "ollama:sf17ba77d4cfb", "ollama:s5e484a0c4247", "ollama:s26e9dfa27980"], "status": "REASONED"}, "file-validation": {"text": "Create validates relative filenames and blob paths; arbitrary host-file access is not established at this pin.", "components": ["ollama"], "sources": ["ollama:s670d892bf359", "ollama:sce8a0d1f3831"], "status": "REASONED"}, "transfer": {"text": "Management transfers are egress/disk-fill surfaces; the registry helper's default redirects stay same-host or between hosts allowlisted under ollama.com, ollama.ai, hf.co and huggingface.co, insecure lifts that host check but not the 10-redirect cap, a blob download's direct-URL GET sets its own check but its HEAD size request keeps that default, and no unrestricted SSRF is demonstrated.", "components": ["ollama"], "sources": ["ollama:s7cb14cc64574", "ollama:s6fe675aeacf6", "ollama:s57e44eeb534d", "ollama:s1c5af6f0c574", "ollama:s39a4f25f6ac7"], "status": "REASONED"}, "origins": {"text": "OLLAMA_ORIGINS adds to built-in origins; removing wildcards is neither authentication nor an exclusive-origin policy.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1"], "status": "REASONED"}, "service-user": {"text": "Use a dedicated account and writable store; preserve GPU access and do not assume the container shares installer identity. install.sh adds the invoking user to the ollama group on each run with systemctl, which this guide infers grants no store access under 0700 directories; a group-access setup uses the ollama account or API, or deliberately chooses 0750 directories with systemd UMask=0027.", "components": ["ollama", "systemd"], "sources": ["ollama:sc0e1dbae723e", "ollama:sdd9be070f841", "ollama:s77b969258f4f", "systemd:sd4c1aeb45417"], "status": "REASONED"}, "store": {"text": "Proposed 0700 directories and UMask=0077 need existing-permission/ACL inspection; v0.40.14's layer.go and manifest writers chmod files to 0644, which this guide infers UMask does not mask, while download.go writes pulled blobs with no chmod and transfer-package pulls are unread, so the 0700 directories carry the restriction; separate service configuration and proxy keys.", "components": ["ollama", "systemd"], "sources": ["ollama:sc0e1dbae723e", "systemd:sd4c1aeb45417", "ollama:s86c505b22a2e", "ollama:sbfd802f779fc", "ollama:s14695a033e02", "ollama:sd702c5befe55", "ollama:sf7e0612d39ea", "ollama:sc767079217fa", "ollama:sda1300806a6f", "ollama:s862914c50e5d", "ollama:s780ccf68092b", "ollama:s1c5af6f0c574"], "status": "REASONED"}, "model-path": {"text": "OLLAMA_MODELS relocates models, not the entire service home or signing keys.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1", "ollama:sc0e1dbae723e"], "status": "REASONED"}, "loaded-models": {"text": "Loaded-model capacity is automatic unless configured; the example selects one model, not a total memory cap.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1", "ollama:s8451cd2c04b7"], "status": "REASONED"}, "parallel": {"text": "OLLAMA_NUM_PARALLEL defaults to 1.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1", "ollama:s8451cd2c04b7"], "status": "REASONED"}, "queue": {"text": "OLLAMA_MAX_QUEUE defaults to 512; the example selects 16 without promising the seventeenth HTTP request is refused.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1", "ollama:s8451cd2c04b7"], "status": "REASONED"}, "keep-alive": {"text": "Keep-alive defaults to five minutes; one minute is the example, and requests can override it.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1", "ollama:s597bdfe54329"], "status": "REASONED"}, "context": {"text": "With OLLAMA_CONTEXT_LENGTH unset, 0 or invalid and no model or request num_ctx, the default is VRAM-dependent 4096/32768/262144 at about 23/47 GiB after overhead (documented 24/48 GiB). The FAQ's 4096 and Modelfile table's 2048 disagree with the code read, where 2048 is only a multimodal floor and the scheduler applies a training-context cap and an OOM step-down; MLX is unread.", "components": ["ollama"], "sources": ["ollama:scf9b09b4b118", "ollama:s926454766be0", "ollama:sdf91d0608d37", "ollama:sb618c5e4f17b", "ollama:sf3ccbc200368", "ollama:s09cfe9587540", "ollama:sf5b30e423c90", "ollama:s6be064685bba", "ollama:sf6fd078295ae", "ollama:s8bb7e5126025", "ollama:s33a315a5654d", "ollama:s5f74467e50db", "ollama:s0779856619f5", "ollama:se3fc27596f3f", "ollama:sc1ca059531b9", "ollama:se5f97896e369", "ollama:sc38e9c04399d"], "status": "REASONED"}, "rate": {"text": "Example nginx source-address rate is 2r/s, burst 4 nodelay, with 429 responses; trusted client-address handling matters behind proxies.", "components": ["nginx"], "sources": ["nginx:sb3bee9429629"], "status": "REASONED"}, "concurrency": {"text": "Example nginx server-name budget is four active requests with 429; it does not cap every idle/incomplete connection.", "components": ["nginx"], "sources": ["nginx:sddc43aac8779"], "status": "REASONED"}, "body-limit": {"text": "Example body cap is 1 MiB; a larger valid fixture should get 413 without upstream work.", "components": ["nginx"], "sources": ["nginx:s40bdf1af1596"], "status": "REASONED", "verify": [3]}, "parameters": {"text": "Gateway must validate model, num_ctx, num_predict and keep_alive; defaults/path allowlists do not enforce JSON ceilings.", "components": ["ollama"], "sources": ["ollama:s6be064685bba", "ollama:s597bdfe54329"], "status": "REASONED", "verify": [3]}, "registry-names": {"text": "Fully qualified registry names are supported; unqualified names select Ollama's registry and omitted tags select latest.", "components": ["ollama"], "sources": ["ollama:s885a39bf1889"], "status": "REASONED"}, "provenance": {"text": "Compare deployed tags digests with independent approval; mutable tags/digest equality do not establish publisher trust.", "components": ["ollama", "ollama-rolling"], "sources": ["ollama-rolling:s6c2961786919", "ollama:s885a39bf1889"], "status": "REASONED", "verify": [3]}, "local-import": {"text": "CLI imports reviewed local GGUF through a Modelfile; artifact SHA-256 and manifest digest are different identifiers.", "components": ["ollama"], "sources": ["ollama:sf6fd078295ae", "ollama:s10d55d89ff19"], "status": "REASONED", "verify": [3]}, "registry-egress": {"text": "Restrict acquisition/export to administrators and constrain egress; compare approved and controlled disallowed transfers. At v0.40.14 the pull and push handlers pass insecure despite PullRequest's Deprecated: ignored comment; it admits http model names, makes helper request URLs, not redirects, http, skips its redirect host check, sets AllowPrivateHosts and feeds unread transfer redirect checks.", "components": ["ollama"], "sources": ["ollama:s7cb14cc64574", "ollama:se630a0a320f1", "ollama:s47731e254da7", "ollama:saf82203abe6f", "ollama:s73ed57de607f", "ollama:s4adb801bd1b4", "ollama:s6fe675aeacf6", "ollama:s6b099c8e9f4a", "ollama:s071857f111bf", "ollama:s8fdbeefa696b", "ollama:s1c5af6f0c574"], "status": "REASONED", "verify": [3]}, "cloud": {"text": "OLLAMA_NO_CLOUD=1 disables cloud models and web search after restart; it is not a no-network switch.", "components": ["ollama"], "sources": ["ollama:s6be064685bba"], "status": "REASONED", "verify": [3]}, "remotes": {"text": "OLLAMA_REMOTES selects remote-model hosts, not a registry allowlist.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1"], "status": "REASONED"}, "diagnostics": {"text": "Keep root, version, status, tags, ps and show private; model details can disclose templates and, by source reading of v0.40.14's ShowResponse and GetModelInfo rather than the served page, which documents no system field, system prompts.", "components": ["ollama", "ollama-rolling"], "sources": ["ollama:s926454766be0", "ollama-rolling:s528f69db04e0", "ollama:sc86b87d0a117", "ollama:s94fdd53a108e", "ollama:s5d162677208c", "ollama:s7c9c7bfb6e3f", "ollama:sf5128e4106c3"], "status": "REASONED", "verify": [3]}, "request-logs": {"text": "Disable OLLAMA_DEBUG and OLLAMA_DEBUG_LOG_REQUESTS; existing request captures/replay scripts need separate cleanup.", "components": ["ollama"], "sources": ["ollama:s26be17f980e1", "ollama:s2265f8345962"], "status": "REASONED", "verify": [3]}, "profiling": {"text": "No metrics route was found in the main router; this guide assesses that first-party v0.34.2 code registers no pprof handler, and at v0.40.14 found no pprof import in the server import block, other first-party code being unchecked. Shipped-binary pprof availability remains unestablished: observe the binary, deny public profiling, infer nothing from debug settings.", "components": ["ollama", "ollama-sweep"], "sources": ["ollama:s926454766be0", "ollama-sweep:s9bf8940c3001", "ollama:s477d97fbecc7", "ollama:s577d2578b863", "ollama:sbb511d763012", "ollama:s4c83d37be5fa", "ollama:s4dbf716fa9c0", "ollama:s934786aa1508", "ollama:s6bce9b343229", "ollama:sbc0cc53c7ea8", "ollama:s5b265711d2af", "ollama:se69445e252da", "ollama:s2474467b2e60", "ollama:s2f1d94710feb"], "status": "REASONED", "verify": [3]}, "profiling-scope": {"text": "Go 1.26.5 go list runs for this guide found neither net/http/pprof nor runtime/pprof in the default-tag v0.34.2 main-package graph for every go tool dist list pair that loaded, cgo off and, where supported, on; five cgo-off android/ios runs failed. This guide infers no go.sum or standard-library package adds either; Go outside 1.26.x, cgo/SWIG output, those runs and served-mux exposure stay open.", "components": ["ollama-sweep", "trifles", "compress", "x-text", "go"], "sources": ["ollama-sweep:s9bf8940c3001", "ollama-sweep:sfc02278c501f", "trifles:s38ad5e627932", "trifles:seeae3b9d9459", "trifles:s56182db1ad94", "trifles:s4e12f43a1afe", "trifles:s6a11f78133a6", "trifles:s82d128642faa", "trifles:s53fe5b36bae7", "compress:sd309de5a57e2", "x-text:s7b232c7ac531", "go:s41262ad87b65", "go:s80e5cbbd9e14", "go:s63df9d2be607", "go:s247aaccc10e3", "go:sdbf899cd837b", "go:s28c91a6e011d", "go:s6d68526316f0", "go:sd3be35e6620b", "go:sd58d43f93322"], "status": "REASONED", "verify": [3]}, "profiling-tags": {"text": "From source reading, v0.34.2's first-party custom tags integration, fast, generate, release, library and updater_live gate only _test.go files, which go build ignores; appengine and os400 appear only in readline/term.go, importing only syscall. The dependency search ignored build constraints, so which dependency files these tags select was not determined; go list runs show generate selects one.", "components": ["ollama-sweep", "go"], "sources": ["ollama-sweep:s9bf8940c3001", "ollama-sweep:s98a6abaa3838", "ollama-sweep:s9a29ad2cbe09", "ollama-sweep:s748c551a2a08", "ollama-sweep:sd7c08f30e61e", "ollama-sweep:s720db4671dfd", "go:s7f4cde22e030"], "status": "REASONED", "verify": [3]}, "profiling-tag-runs": {"text": "Go 1.26.5 go list runs for this guide found neither net/http/pprof nor runtime/pprof in v0.34.2 main-package graphs with tag set updater_live, release,library, appengine, os400 or appengine,os400 on the eight first-class pairs, windows arm64 and aix ppc64, cgo off and on. This guide infers no first-party, go.sum or standard-library file adds either; cgo/SWIG output and Go outside 1.26.x are open.", "components": ["ollama-sweep", "go", "trifles", "compress", "x-text"], "sources": ["ollama-sweep:s9bf8940c3001", "ollama-sweep:s720db4671dfd", "go:sdba7b87fda8d", "ollama-sweep:sfc02278c501f", "trifles:s38ad5e627932", "trifles:seeae3b9d9459", "trifles:s56182db1ad94", "trifles:s4e12f43a1afe", "trifles:s6a11f78133a6", "trifles:s82d128642faa", "trifles:s53fe5b36bae7", "compress:sd309de5a57e2", "x-text:s7b232c7ac531", "go:s41262ad87b65", "go:s80e5cbbd9e14", "go:s63df9d2be607", "go:s247aaccc10e3", "go:sdbf899cd837b", "go:s28c91a6e011d", "go:s6d68526316f0", "go:sd3be35e6620b", "go:sd58d43f93322"], "status": "REASONED", "verify": [3]}, "profiling-generate": {"text": "Go 1.26.5 go list runs for v0.34.2 with tag sets containing generate failed on all ten pairs tried, cgo off and on, reporting both uniseg and main packages in dependency github.com/rivo/uniseg v0.4.7; -e reruns listed neither pprof package but marked that package with a load error, so whether such builds import either remains open.", "components": ["ollama-sweep", "go"], "sources": ["ollama-sweep:s9bf8940c3001", "go:sdba7b87fda8d"], "status": "REASONED"}, "profiling-go-versions": {"text": "Source reading, not run: Go 1.26.0 to 1.26.10 except 1.26.5 match 1.26.5: goversion is 26. Ignoring build constraints, only net/http/pprof, testing/internal/testdeps and a package main command import runtime/pprof in non-test files outside cmd and testdata. No such file imports the first two. This guide infers no such release adds either package to the 1.26.5 go list graphs; no graph was checked.", "components": ["go-1-26-0", "go-1-26-1", "go-1-26-2", "go-1-26-3", "go-1-26-4", "go-1-26-6", "go-1-26-7", "go-1-26-8", "go-1-26-9", "go-1-26-10", "go"], "sources": ["go-1-26-0:s7670be382fd6", "go-1-26-0:sc4d5a418cc8a", "go-1-26-0:s63f63360f142", "go-1-26-0:se924f730cd16", "go-1-26-1:sc2735a74234b", "go-1-26-1:sd0122b4596e5", "go-1-26-1:sa24dcdd9e52c", "go-1-26-1:s2a55e411da1d", "go-1-26-2:sa8ba29c45871", "go-1-26-2:s058a974ee5d5", "go-1-26-2:s019e0fb220fc", "go-1-26-2:s8a4dc2d3db9b", "go-1-26-3:sdc07d97a87b6", "go-1-26-3:sed7ba616e62c", "go-1-26-3:s040d1ea399ae", "go-1-26-3:s898567a72486", "go-1-26-4:s2fd10ac9b790", "go-1-26-4:s860b46eaab36", "go-1-26-4:s2104c8373c85", "go-1-26-4:s0e9e578b8243", "go-1-26-6:s8b8ebc7e8db6", "go-1-26-6:sb1475154fbb7", "go-1-26-6:s974c60526bb6", "go-1-26-6:s52851e8b4312", "go-1-26-7:s17ad8bf46090", "go-1-26-7:s54141d62f948", "go-1-26-7:sf081388dd0f7", "go-1-26-7:s64e2db3b685b", "go-1-26-8:s6175e03794cf", "go-1-26-8:s88d4166a7bee", "go-1-26-8:sa79ec4182285", "go-1-26-8:s9ad74046b534", "go-1-26-9:s69637c613830", "go-1-26-9:s71887e5f7cdf", "go-1-26-9:s6728c0a5d0d1", "go-1-26-9:s110ef73adaf3", "go-1-26-10:scff07ded2fad", "go-1-26-10:sd61f4ff8d5d6", "go-1-26-10:s9b82f1629b4d", "go-1-26-10:s599b4645909f", "go:s0388b420c217", "go:s41262ad87b65", "go:s80e5cbbd9e14", "go:s63df9d2be607"], "status": "REASONED"}, "profiling-deps-scan": {"text": "From source reading, ignoring build constraints, only package main files in the 93 dependency modules with go.sum zip hashes import net/http/pprof or runtime/pprof. Imports followed from first-party packages stay in those modules and the standard library, so this guide infers no such module adds either at those versions. The 15 cached modules with no go.sum zip hash were excluded.", "components": ["ollama-sweep", "trifles", "compress", "x-text", "go-toml"], "sources": ["ollama-sweep:s9bf8940c3001", "ollama-sweep:s2422d51c7511", "ollama-sweep:sc8a033a390da", "ollama-sweep:sfc02278c501f", "trifles:s38ad5e627932", "trifles:seeae3b9d9459", "trifles:s56182db1ad94", "trifles:s4e12f43a1afe", "trifles:s6a11f78133a6", "trifles:s82d128642faa", "trifles:s53fe5b36bae7", "trifles:s3c8772bb591f", "compress:sd309de5a57e2", "x-text:s7b232c7ac531", "go-toml:s263bb2467c05", "go-toml:sf307292d806f"], "status": "REASONED"}, "profiling-mux": {"text": "This guide's inference, not run: Go 1.26.5 net/http/pprof's init registers /debug/pprof/ handlers on http.DefaultServeMux, which a server with a nil Handler, as in v0.40.14's Serve, uses, so a build linking that package would serve them on the Ollama listener, while builds whose go list graph lacks it get none from it.", "components": ["go", "ollama"], "sources": ["go:sab55ef9009c4", "go:s2e779f75153e", "go:s038b2186ee21", "go:sa0c56e16001f", "ollama:s6bce9b343229", "ollama:sbc0cc53c7ea8"], "status": "REASONED", "verify": [3]}, "profiling-release": {"text": "From this guide's reading of v0.40.14's Dockerfile, build scripts and release workflow, which show intended builds rather than artifact contents, the main package is built with no -tags, and with CGO_ENABLED=1 in the Dockerfile, the darwin build and both Windows CLI builds. Its go.mod declares go 1.26.10, which the Dockerfile uses to choose Go; the go list runs used Go 1.26.5 on v0.34.2.", "components": ["ollama", "ollama-sweep"], "sources": ["ollama:sba6a4f320b6a", "ollama-sweep:s2422d51c7511", "ollama:s1ac39b3660a2", "ollama:se3193be32015", "ollama:s38976770bc28", "ollama:s1be5d1576a50", "ollama:s6b9f9627a574", "ollama:s14d11e19ac27", "ollama:sc06aae169fc9", "ollama:s164b13109d88", "ollama:s3d5c5764fed2", "ollama:s2332130fa1e7", "ollama:s6129c0dd7a10", "ollama:saa84b3aea335", "ollama:s55a8b7a39807", "ollama:s284d5c9ccbf6", "ollama:s589f0e50fa35", "ollama:s3b7fa1fcefbd", "ollama:s828bd193c1fd"], "status": "REASONED"}, "profiling-app-runs": {"text": "Go 1.26.5 go list -e -deps runs for this guide list no net/http/pprof or runtime/pprof for the v0.34.2 desktop app main package on darwin/windows amd64 and arm64, cgo off and on; linux lists only the error-marked main package. Loaded cells mark app/ui (no app/dist); cgo-off cells cannot load cgo-only app/webview and omit the main package's cgo files with no error, so imports may be incomplete.", "components": ["ollama-sweep"], "sources": ["ollama-sweep:sad61f21372d7", "ollama-sweep:sf38afcc73250", "ollama-sweep:s45897fae891f", "ollama-sweep:s2b2be911f51e", "ollama-sweep:se6c615cc2b3a"], "status": "REASONED"}, "profiling-app-listener": {"text": "From source reading, the app's UI listener is loopback (127.0.0.1:0, or 3001 in dev mode), served by an http.Server with a non-nil Handler whose mux has a token-cookie check on /api/v1 routes, proxy and catch-all routes without it, and no /debug/pprof pattern. This guide's inference, not run: even a build linking net/http/pprof would not serve DefaultServeMux handlers there.", "components": ["ollama", "go"], "sources": ["ollama:s3cc1f9009971", "ollama:s63598805dfec", "ollama:sf3b9c404c46f", "ollama:s8cc22efcaaca", "go:sab55ef9009c4", "go:s2e779f75153e", "go:s038b2186ee21", "go:sa0c56e16001f"], "status": "REASONED"}, "profiling-app-gateway": {"text": "From source reading, another loopback app listener is the Claude Desktop gateway on 127.0.0.1:11435, in macOS cgo builds, started only when Claude Desktop is installed. Its non-nil Handler refuses non-loopback Host values and a non-empty first Origin, serves four routes, not /debug/pprof, and forwards only POST /v1/messages, stripping caller auth headers, to the app's OLLAMA_HOST; not run.", "components": ["ollama", "go"], "sources": ["ollama:sa56366559d62", "ollama:sa55b9f381b09", "ollama:s498d9212196e", "ollama:s3d5b427b463a", "ollama:sab2fbcd0c98e", "ollama:s14ee6c3159e4", "ollama:sfc4c24d8630b", "ollama:s80406fd9e16d", "ollama:s5f05208cf314", "ollama:sb816d460f392", "ollama:s1efc29a8d9c6", "ollama:s741bae4c4fa6", "ollama:sb40469426b0e", "ollama:sada8b309ca30", "ollama:sfee7de68771c", "ollama:s6540e626aa77", "go:s7290caae7a3c"], "status": "REASONED"}, "profiling-app-gateway-start": {"text": "Source reading, not run: startup starts the gateway only if Claude Desktop is installed and configured to use it; setClaudeGatewayInstalled, prepareClaudeDesktopConnection and model mapping start it if installed. The first two stop it unless Claude Desktop then uses it (the first not after an auto-mode error); mapping, if none existed, stops it on profile-change errors except restart confirmation.", "components": ["ollama"], "sources": ["ollama:scbcba9fa2026", "ollama:s776bbcd95f3c", "ollama:s32d2fa1014f8", "ollama:scde20ea5a74a", "ollama:sb2938e867f20", "ollama:s3d5b427b463a", "ollama:sc9151806147c", "ollama:s61e666769722"], "status": "REASONED"}, "profiling-app-release": {"text": "By this guide's reading of intended builds, not artifacts, build_darwin.sh builds the app package with GOOS=darwin, CGO_ENABLED=1, amd64 and arm64 and no -tags. In build_windows.ps1, buildApp builds it with no -tags, by default setting no GOARCH under its unconditional CGO_ENABLED=1 and, when the cross-compile tools are present, for arm64 (GOOS=windows, GOARCH=arm64, CGO_ENABLED=1).", "components": ["ollama"], "sources": ["ollama:seaa45ac56dd7", "ollama:s41d553145c17", "ollama:s7283b88c8765", "ollama:s14d11e19ac27", "ollama:sc06aae169fc9", "ollama:s1be5d1576a50"], "status": "REASONED"}, "desktop-app-server": {"text": "From source reading, the desktop app serves no HTTP on the Ollama API listener itself: it runs the separate ollama binary with serve and restarts it, copying its environment, setting OLLAMA_HOST=0.0.0.0 when Expose is on and OLLAMA_ORIGINS=* when Browser is on, so Expose or an inherited OLLAMA_HOST can expose that process. No shipped app binary was inspected and no run observed these behaviours.", "components": ["ollama"], "sources": ["ollama:s4fb8958f1218", "ollama:sacbb561c6cfe", "ollama:sadbce7446a0d"], "status": "REASONED"}, "verify-inventory": {"text": "Inspect namespaces, publication, identity, store and ACLs; local ss output alone does not establish isolation.", "components": ["ollama", "docker", "systemd"], "sources": ["ollama:sc0e1dbae723e", "docker:s1e53417c513d", "systemd:sd4c1aeb45417"], "status": "REASONED", "verify": [1]}, "verify-bypass": {"text": "HTTP means direct 11434 answered; fixed refusal needs the real remote path plus successful proxy inference; local errors are inconclusive.", "components": ["ollama", "curl"], "sources": ["ollama:s6be064685bba", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2]}, "verify-auth": {"text": "Absent/wrong credentials must refuse while correct credentials complete chat; streamed 200 alone is insufficient.", "components": ["nginx", "ollama", "curl"], "sources": ["nginx:sf32ce2956917", "ollama:s926454766be0", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [3]}, "verify-tls": {"text": "Correct trust/hostname succeeds; wrong trust/hostname and missing/untrusted mTLS certificates must fail before inference.", "components": ["nginx", "curl"], "sources": ["nginx:sda4ba3fcedbe", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [3]}, "verify-management": {"text": "Public management must be denied without upstream mutation; private admin operations and public chat supply positive controls.", "components": ["ollama"], "sources": ["ollama:s8fdbeefa696b", "ollama:s926454766be0", "ollama:s670d892bf359", "ollama:s7a9f22479c78"], "status": "REASONED", "verify": [3]}, "verify-tags": {"text": "Public tags may return 404 with valid credentials; private tags should return model JSON; public 404 is not an authentication test.", "components": ["nginx", "ollama-rolling"], "sources": ["ollama-rolling:s6c2961786919", "nginx:s40bdf1af1596"], "status": "REASONED", "verify": [3]}, "verify-origins": {"text": "Compare unwanted, required and built-in origins with actual browser calls; POST-only proxy preflight refusals differ by variant.", "components": ["ollama", "nginx", "caddy"], "sources": ["ollama:s26be17f980e1", "nginx:s40bdf1af1596", "nginx:sf0bc2adae8cb", "caddy:s38b1b78ce980"], "status": "REASONED", "verify": [3]}, "verify-rate": {"text": "Eight bounded requests test admission with limiter/upstream evidence and recovery; the burst alone does not demonstrate both limiters.", "components": ["nginx"], "sources": ["nginx:sb3bee9429629", "nginx:sddc43aac8779"], "status": "REASONED", "verify": [4]}, "verify-concurrency": {"text": "Hold four accepted requests active within the rate budget, reject another, then confirm a freed slot accepts work.", "components": ["nginx"], "sources": ["nginx:sddc43aac8779"], "status": "REASONED", "verify": [3]}, "verify-scheduler": {"text": "Compare loading/eviction, keep-alive expiry and actual queue occupancy; public limits can prevent exercising the private queue.", "components": ["ollama"], "sources": ["ollama:s8451cd2c04b7", "ollama:s26be17f980e1"], "status": "REASONED", "verify": [3]}, "verify-cloud": {"text": "Authorized cloud chat works before disablement and refuses afterward while local chat succeeds; invalid credentials/models are inconclusive.", "components": ["ollama"], "sources": ["ollama:s6be064685bba"], "status": "REASONED", "verify": [3]}, "verify-captures": {"text": "Compare completed canary requests with logging enabled/disabled in the actual temporary namespace; deny public diagnostics while private controls work.", "components": ["ollama"], "sources": ["ollama:s2265f8345962", "ollama:s926454766be0"], "status": "REASONED", "verify": [3]}, "proxy-parsing": {"text": "nginx -t and caddy validate check configuration acceptance only; native parsing was not demonstrated here.", "components": ["nginx", "caddy"], "sources": ["nginx:sa28876996ece", "caddy:sc5832c7dc52f"], "status": "REASONED"}, "curl-version": {"text": "Displayed exitcode and errormsg write-out variables need curl 7.75.0 or later; no tested curl release is recorded.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED"} } } ```` ====================================================================== ==> open-webui.md ====================================================================== # Open WebUI: signup control, TLS, and MFA Open WebUI is a chat website you host yourself for talking to local or cloud-hosted language models. This guide helps your AI assistant configure it securely, including controlled registration, a private bind with TLS in front, authentication kept on, and restricted tools and code execution. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/open-webui.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Open WebUI (pinned to a commit) and Docker Engine localhost boundary 28.0.0; 1 further source carries no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 62 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Open WebUI has account-based authentication built in; the risks are open signup on an exposed instance and running it on plain HTTP. It provides no TLS of its own, so encryption comes from a fronting layer. At the time of writing, the pinned source identifies release **v0.11.3** at commit `0a7c15832fb30b1903753e83f81dc7d27e5b0944`; the environment reference labels itself current through **v0.11.1**. The controls below were checked against that commit and the linked vendor documentation in September 2026. Introduction versions are unverified unless stated. See the [release version](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/package.json) and [environment reference](https://docs.openwebui.com/reference/env-configuration/). ## 1. Control who can register Environment variables (defaults per the Open WebUI reference): ``` ENABLE_SIGNUP=false # default true; disable once your accounts exist (persisted, see below) DEFAULT_USER_ROLE=pending # the default; new accounts wait for admin approval # other values: user, admin ``` `ENABLE_SIGNUP` is a persisted setting: the reference marks it a `ConfigVar`, which means the value is written to the database on first launch and on later starts the stored value wins over the environment, unless `ENABLE_PERSISTENT_CONFIG=false` (default `true`). On an instance that has already started, change signup in the Admin panel rather than in the environment, then confirm the change took effect (Verify below). With signup left on, keep `DEFAULT_USER_ROLE=pending` so a stranger who registers gets no access until approved. An admin account can also be created at startup by setting `WEBUI_ADMIN_EMAIL` together with `WEBUI_ADMIN_PASSWORD` (supply the password via the environment, not a compose file in git; see [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). With normal persisted configuration, the first normal registration automatically disables signup; successful environment-based admin creation does too. On an existing installation, "signup left on" means signup has been explicitly re-enabled. `DEFAULT_USER_ROLE=user` automatically approves subsequent registrations; `admin` gives those registrations administrative privileges. Keep `pending`. See [registration and bootstrap behavior](https://docs.openwebui.com/getting-started/advanced-topics/hardening/). The first account created becomes the administrator, whatever `DEFAULT_USER_ROLE` is set to; that role governs only the accounts that follow. Claim the admin account yourself while the instance is still bound to loopback, before anyone else can reach it, or preset it with `WEBUI_ADMIN_EMAIL` and `WEBUI_ADMIN_PASSWORD`. On an exposed instance with signup on, whoever registers first is the administrator. **An empty database is exposed even with `ENABLE_SIGNUP=false` when the login form is enabled.** `/api/v1/auths/signup` does not apply that switch to the first account. `ENABLE_LOGIN_FORM` defaults to `true`. With the form disabled, `ENABLE_INITIAL_ADMIN_SIGNUP=true` permits first-admin bootstrap; its default is `false`, and it should remain off outside private bootstrap. See the [signup handler](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). For SSO, the reference documents OAuth/OIDC settings plus `ENABLE_PASSWORD_AUTH=false` to turn off password login once SSO works; enforcing MFA then happens at the identity provider ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). That recommendation applies to **OAuth/OIDC-only login**. `/signin` checks `ENABLE_PASSWORD_AUTH` before trusted-header authentication, so setting it to `false` also blocks trusted-header signin. Ordinary signup refusal does not close OAuth or trusted-header provisioning; configure those separately in step 4. See the [signin handler](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). The database-over-environment rule also matters for the other `ConfigVar` settings below. On an existing installation, inspect and change their effective values in the Admin panel, or deliberately manage configuration through the environment with persistence disabled. Restarting with a different environment value alone is not proof that a persisted control changed. ## 2. Bind privately and add TLS in front ```bash docker run -d -p 127.0.0.1:3000:8080 \ -v open-webui:/app/backend/data \ ghcr.io/open-webui/open-webui:main ``` The `-v open-webui:/app/backend/data` volume holds the accounts and the persisted config; the vendor requires it. Reuse the same volume whenever you replace the container (an upgrade starts a fresh one), or the database resets and, by the first-account rule above, the next account to register becomes the administrator. When you do start on an empty database, keep the public proxy route disabled until your admin account exists and signup is confirmed off. Publish it through [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)/[nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), through a [Cloudflare Tunnel with Access](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) in front, or over a [tailnet with Tailscale Serve](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) (restricted by tailnet policy). Tailscale Funnel is public and adds no login of its own, so keep Open WebUI's own authentication on behind it. Never expose port 8080 directly: login forms over plain HTTP send passwords in cleartext. Before publishing over HTTPS, set both `WEBUI_SESSION_COOKIE_SECURE=true` and `WEBUI_AUTH_COOKIE_SECURE=true` in the Open WebUI environment and restart. At this pin, the session setting defaults to `false`; the auth setting inherits it unless explicitly overridden. TLS termination alone does not set the cookies' `Secure` attribute, so an HTTP request can send a cookie before a redirect. See the [cookie defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py) and [signin cookie creation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). The command above establishes the original private binding and data mount. Before publishing, inject the authentication, secret, and applicable controls below through the deployment configuration. For repeatable deployments, replace the mutable `:main` image reference with the release or digest you have tested. ## 3. Keep authentication on and persist the signing key ``` WEBUI_AUTH=true # default true JWT_EXPIRES_IN=24h # example shorter lifetime; default 4w ``` Generate a strong `WEBUI_SECRET_KEY` with `openssl rand -hex 32`, store it in your secret manager, and inject the same persistent value into every replica. Do not commit it or put its value in command arguments. See [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), the [authentication defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py), and [token lifetime configuration](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py). The container launcher otherwise generates `.webui_secret_key` **once when the file is absent**, then reads it on subsequent starts. It does not generate a new key on every restart. Its default location is `/app/backend/.webui_secret_key`, outside `/app/backend/data`, so replacing the container can lose it despite retaining the data volume. See the [launcher](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/start.sh) and [container working directory](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/Dockerfile). `WEBUI_SECRET_KEY` signs HS256 login tokens. Disclosure enables token forgery for existing users, including administrators. Without Redis, signout clears the browser session but does not revoke a copied JWT; it remains usable until expiry. Choose a finite lifetime suited to the deployment and use shared Redis when revocation must propagate across workers and replicas. See the [token signing and revocation implementation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). Direct backend startup rejects an empty signing key when authentication is enabled. Do not work around that refusal by disabling authentication. `WEBUI_JWT_SECRET_KEY` is the deprecated alias; use `WEBUI_SECRET_KEY`. Plan rotation deliberately: it invalidates existing login tokens, and the key also supplies defaults for OAuth encryption keys. See the [startup requirement](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py) and [OAuth key configuration](https://docs.openwebui.com/features/authentication-access/auth/sso/). ## 4. Close alternative registration paths and protect trusted headers For OAuth deployments that should not provision new users: ``` ENABLE_OAUTH_SIGNUP=false # default false OAUTH_ALLOWED_DOMAINS=example.com # example approved email domain; default * OAUTH_MERGE_ACCOUNTS_BY_EMAIL=false # default false ENABLE_OAUTH_ROLE_MANAGEMENT=false # default false ``` Replace `example.com` with your approved email domain or comma-separated domains. OAuth account creation checks its own `ENABLE_OAUTH_SIGNUP` switch, independently of `ENABLE_SIGNUP`. Keep email-based account merging off unless the provider's verified-email guarantees have been established, and leave role management off unless you have deliberately reviewed the claims that assign user and admin roles. See [OAuth configuration](https://docs.openwebui.com/features/authentication-access/auth/sso/) and the [pinned defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py). `ENABLE_OAUTH_PERSISTENT_CONFIG` defaults to `false`: OAuth settings remain environment-authoritative, unlike ordinary persisted settings. With that default, the OAuth Admin panel is read-only. Enabling OAuth persistence allows database values to take precedence when general persistence is enabled. See [OAuth persistence](https://docs.openwebui.com/features/authentication-access/auth/sso/). Leave `WEBUI_AUTH_TRUSTED_EMAIL_HEADER` unset unless a trusted authentication proxy supplies identity. If configured, the proxy **must strip client-supplied identity headers**, inject only authenticated values, and be the only route to the backend. Apply the same rule to configured name, role, and group headers. A forged email header can impersonate an existing administrator; trusted-header signin can also provision accounts independently of ordinary signup. See [trusted-header authentication](https://docs.openwebui.com/features/authentication-access/auth/sso/) and the [signin implementation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). For trusted-header deployments, retain `ENABLE_PASSWORD_AUTH=true` because of the `/signin` ordering described in step 1. Enforce MFA and admission at the authenticating proxy, and prevent direct backend access. ## 5. Restrict Tools, Functions, and code execution If server-side Python plugins are unused: ``` ENABLE_PLUGINS=false # default true USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS=false # default false USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT=false # default false ENABLE_CODE_EXECUTION=false # default true ENABLE_CODE_INTERPRETER=false # default true ``` Creating or importing Tools and Functions loads submitted Python through `exec`. Top-level code runs during loading, with the Open WebUI process's access to secrets, files, database, and network. Treat permission to submit that code as server access, not as an ordinary chat feature. Function creation is administrative; workspace Tool creation is controlled by `workspace.tools`. See the vendor's [plugin-loader explanation](https://docs.openwebui.com/features/extensibility/plugin/development/under-the-hood/). `ENABLE_PLUGINS=false` stops uploaded plugins loading and running. It is not a blanket authorization switch for every plugin-management endpoint, and it does **not** disable built-in tools, MCP/OpenAPI connections, or terminals. Review those connections and their access separately. The two code-execution switches are separate controls; their engines default to `pyodide`, and the code-execution reference describes the legacy execution path. Browser Pyodide execution is distinct from server-side Python plugins. See [plugin and code-execution controls](https://docs.openwebui.com/reference/env-configuration/). Audit **every group**, including all memberships of each ordinary user. Group permissions are additive: a false default or a false permission in one group cannot cancel a true grant from another group. Remove unintended Tools Access and Import grants as well as setting the defaults above. Apply this audit to model-workspace access and API-key permissions too. See [group permission merging](https://docs.openwebui.com/features/authentication-access/rbac/groups/) and the [permission defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py). ## 6. Constrain models, connections, and administrative data access ``` BYPASS_MODEL_ACCESS_CONTROL=false # default false USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS=false # default false ENABLE_DIRECT_CONNECTIONS=false # default false ENABLE_ADMIN_CHAT_ACCESS=false # default true ENABLE_ADMIN_EXPORT=false # default true ENABLE_COMMUNITY_SHARING=false # default true ENV=prod # backend default dev; official Docker default prod ``` Set each restricted model's **Access Control to Private**, then grant only the intended users or groups access. Workspace Models Access governs creation and editing, not which existing models a user may invoke. Inspect existing grants after changing visibility. Current resource payloads use `access_grants`; do not copy an older `access_control` JSON example into current configuration. See [resource access](https://docs.openwebui.com/features/authentication-access/rbac/groups/), [workspace permissions](https://docs.openwebui.com/features/authentication-access/rbac/permissions/), and the [access-grant schema](https://docs.openwebui.com/reference/database-schema/). Ollama pull, create, copy, and delete routes already require an administrator. There is no `ENABLE_MODEL_*` switch for those operations: the boundary is the caller's role. Restrict admin membership and keep the Ollama service itself private so callers cannot bypass Open WebUI. See the [Ollama route dependencies](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/ollama.py). Pin the intended backend destinations in **Settings > Admin > Connections**. The initial OpenAI-compatible destination defaults to `https://api.openai.com/v1`; inspect every configured connection on an existing installation. These URLs specify where Open WebUI sends requests; they are **not an egress firewall**. Independently restrict outbound destinations, including metadata services and unintended internal networks, as described in [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md). See [connection configuration](https://docs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-openai-compatible/) and the [URL defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py). Keep Direct Connections disabled unless required. They send inference requests **from the browser to the provider**, bypassing the backend inference proxy; this setting is not a server-side SSRF defense. See [Direct Connections](https://docs.openwebui.com/features/chat-conversations/direct-connections/). For chat confidentiality, disable **both** administrative chat access and administrative export, then restart. Otherwise export can expose conversations through a second surface. These controls restrict the application's admin features; they do not conceal data from an operator with database or deployment access. See the [admin control reference](https://docs.openwebui.com/reference/env-configuration/) and [chat-list and export enforcement](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/chats.py). `ENABLE_COMMUNITY_SHARING=false` removes community sharing buttons and discovery UI. It does not disable every local sharing or export operation. Review the separate sharing permissions and existing shares where confidentiality requires them. See the [community-sharing reference](https://docs.openwebui.com/reference/env-configuration/). Interactive API documentation is controlled by `ENV`: `/docs` and `/openapi.json` are registered only in `dev`. The backend defaults to `dev`; the official Docker image sets `prod`. Keep `ENV=prod` in production rather than relying on an unverified `ENABLE_SWAGGER_UI` variable. This removes documentation, not the APIs. See [application initialization](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/main.py), [backend defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py), and the [Dockerfile](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/Dockerfile). ## 7. Keep API keys off unless needed ``` ENABLE_API_KEYS=false # default false USER_PERMISSIONS_FEATURES_API_KEYS=false # default false ``` If API clients are required, enable the global switch and grant `features.api_keys` only to selected groups under **Admin Panel > Users > Groups**. Keep the default permission false. Administrators can create keys whenever the global switch is enabled; ordinary users need the feature permission too. Keys inherit the owner's authority, so use an ordinary account for an ordinary application. See [API-key setup and permissions](https://docs.openwebui.com/features/authentication-access/api-keys/). For an enabled deployment, restrict the routes: ``` ENABLE_API_KEYS=true ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS=true # default false API_KEYS_ALLOWED_ENDPOINTS=/api/chat/completions,/api/models ``` The endpoint list is an example for inference and model discovery; remove anything the client does not need. It matches an exact path **and its slash-separated descendants**, not HTTP methods. For example, allowing `/api/models` also allows `/api/models/anything` through this particular check. Enforce narrower path-and-method rules at the proxy when required. The restrictions apply to API keys across the instance, not separately per key. See [API-key configuration](https://docs.openwebui.com/features/authentication-access/api-keys/) and [endpoint matching](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). Disabling the global switch rejects existing API keys as well as preventing creation; it does not delete those keys, and it does **not** disable JWT-authenticated APIs. See the [separate API-key and JWT authentication paths](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). `ENABLE_API_KEY`, `ENABLE_API_KEY_ENDPOINT_RESTRICTIONS`, and `API_KEY_ALLOWED_ENDPOINTS` are deprecated names. Use the plural names shown above; do not assume every deprecated name is still honored by every release. See the [environment reference](https://docs.openwebui.com/reference/env-configuration/). ## 8. Bound retrieval, network access, and resource use ``` ENABLE_LOCAL_WEB_FETCH=false # default false AIOHTTP_CLIENT_ALLOW_REDIRECTS=false # default false WEB_FETCH_FILTER_LIST=docs.example.com,!internal.example.com ENABLE_WEB_SEARCH=false # default false; keep off if unused RAG_FILE_MAX_SIZE=10 # example limit in MB; default unset RAG_FILE_MAX_COUNT=5 # example file count; default unset USER_PERMISSIONS_CHAT_FILE_UPLOAD=false # default true USER_PERMISSIONS_CHAT_WEB_UPLOAD=false # default true USER_PERMISSIONS_FEATURES_WEB_SEARCH=false # default true ``` Replace the filter domains with your intended policy. `WEB_FETCH_FILTER_LIST` is comma-separated: entries without `!` form an allowlist, while `!` entries deny destinations. Custom entries extend the built-in blocklist. Keep local fetch and redirects disabled to reduce SSRF paths into private networks and cloud metadata services; enforce network egress separately. `ENABLE_RAG_LOCAL_WEB_FETCH` is the deprecated alias of `ENABLE_LOCAL_WEB_FETCH`. See [web-fetch configuration](https://docs.openwebui.com/reference/env-configuration/), the [filter and alias definitions](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py), and the [redirect default](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py). Choose finite positive file limits for the available storage and processing capacity. They are not an aggregate storage quota or a general request quota. In the upload handler, the size check occurs after the storage upload has read the file, so also bound request bodies at the proxy. See the [upload handler](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/files.py). **The feature permissions above are not sufficient to prohibit direct ingestion requests.** `POST /api/v1/files/` and `POST /api/v1/retrieval/process/web` require a verified user, but their handlers do not check the corresponding file-upload or web-upload feature permission. A verified user means an authenticated `user` or `admin`, not a pending account. If uploads must be unavailable, deny `/api/v1/files/` and applicable ingestion routes at the proxy. If URL ingestion must be unavailable, deny `/api/v1/retrieval/process/web`, `/api/v1/retrieval/process/youtube` (the same handler), and `/api/v1/retrieval/process/url` (which fetches URLs and can invoke web processing or file ingestion). Audit group grants and prevent access around the proxy. See [file uploads](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/files.py), [URL ingestion](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/retrieval.py), and [verified-user roles](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). The native password-signin limiter is **15 attempts per 180 seconds per email address**. It is not an inference quota. Add proxy rate and concurrency limits, plus provider or gateway spend limits, for authenticated requests as well as anonymous traffic. See the [signin limiter](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), and [litellm.md](https://github.com/jposluns/secureconfig/blob/main/litellm.md). ## 9. Verify **REASONED:** whole Verify section; every service comparison below rests on the Sources entries cited at that comparison. During marking review (October 2026), the three then-current bash blocks ran from files under Bash 5.3.9(1)-release with a Python 3.14.4 harness and scratch-confined command stubs; each passed `bash -n` and ShellCheck 0.11.0 with exit 0 and no output. The refusal and substituted-input results are recorded at each block's declaration. Earlier declarations elsewhere in this section describe historical authoring runs, which were not independently reproduced in this review. No live Open WebUI, TLS ingress, second-host fixture, identity provider or provider test credentials were supplied for this review. No service response was observed, and the exposed and fixed service comparisons remain REASONED from the pinned source and linked vendor documentation. During marking review (2026-10-14), the block 2 and 3 stub cases were repeated after adding credential refusals; those results are recorded at their declarations. During marking review (2026-10-14), all three then-current Verify bash blocks were extracted to files and each passed Bash 5.3.9(1)-release `bash -n` and ShellCheck 0.11.0 with exit 0 and no output. These syntax and lint checks did not execute the blocks or repeat the stub cases. Every marking-review and historical authoring run of bash -n, ShellCheck or stubs that this section records covered the three Verify bash blocks in an earlier form, whose guard refusals exited with status 1. Those runs also predate the change that makes each of the three blocks report its result in its exit status (1 for a finding, 2 for a guard refusal, 3 for a failed or inconclusive check, 0 for a pass or a named unclassified observation), so none of them covered the current form. They also predate block 1's listener-read refusal, which prints `ss is not installed here; not listing sockets` and makes block 1 exit 2 when `ss` is missing and no other part found something or was inconclusive. The current form of all three blocks, whose guard refusals exit with status 2, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and the current form of block 1 also by the 2026-10-31 stub runs of the whole block recorded at its declaration. **REASONED:** operating guidance for every comparison below; no live Open WebUI or ordinary and administrator test accounts in the authoring environment. The operating guidance carries no command of its own, so nothing in it ran locally. The operating guidance rests on the verified-user roles in the guide's cited Open WebUI authentication helpers entry and the additive grants in the guide's cited Open WebUI groups entry: an administrator can pass a check that an ordinary user must fail, so an administrator's success alone cannot separate the exposed state from the fixed one. Run exposed comparisons only on a private, disposable installation. Keep production bootstrap private. Use ordinary users as well as administrators: an administrator's successful request does not demonstrate an ordinary user's boundary. ### Private binding, TLS, and ordinary signup **REASONED:** This needs a deployed container, TLS ingress, and a second LAN/VPC host, unavailable here. The original listener, TLS, feature-flag, and browser checks are retained below with guarded URL substitution. Substitute your actual HTTPS origin inside the quotes on the `set --` line. Paste the whole block; do not put credentials in URLs. Values containing a literal apostrophe require proper shell quoting rather than direct substitution. This declaration covers the following block's listener read, certificate-validated HTTPS request, `/api/config` signup flag and browser registration attempt. No live Open WebUI, TLS ingress or second LAN/VPC host was supplied for marking review; publication expectations rest on the cited Docker port publishing entry and signup expectations on the cited Open WebUI auth routes and configuration defaults. During marking review (October 2026), the then-current block ran from files under Bash 5.3.9(1)-release with stub ss and curl: the written placeholder, empty value, literal LF and angle-bracket value printed the substitution refusal; zero or two values printed the exactly-one-origin refusal; omitting the initial `set --` line with a caller argument printed the whole-block refusal. The same October 2026 run printed the HTTPS-without-credentials refusal for HTTP, the three userinfo forms `https://user:pw@chat.internal`, `https://user@chat.internal` and `https://@chat.internal`, and an @ in a path, query or fragment. Each substitution, exactly-one-origin, whole-block and HTTPS-without-credentials refusal case in that October 2026 run exited 1 without calling a stub. Valid `https://chat.internal`, its trailing-slash form, an explicit port and a bracketed IPv6 origin reached `ss -tlnp` and both curl stubs and exited 0; the valid origin also exited 0 under `bash -u`. These October 2026 marking-review stub runs covered the following block in an earlier form, whose guard refusals exited with status 1. The current form, whose guard refusals exit with status 2 and which prints `ss is not installed here; not listing sockets` and exits 2 when `ss` is missing and no other part found something or was inconclusive, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes. During review of this change (2026-10-31), the current block ran whole with scratch-confined ss and curl stubs in a plain shell and under inherited readonly and integer variables and an inherited `set -e`, with the same exit status in each. The fixed state exited 0, a failing ss 3, and a missing ss 2 with `ss is not installed here; not listing sockets` printed once. A port 3000 listener on 0.0.0.0, or `enable_signup: true` with ss missing or failing, exited 1. A failed HTTPS request with ss missing exited 3, and the unsubstituted placeholder exited 2 without calling a stub. Static reasoning: an HTTPS authority containing userinfo has a literal @ and matches the refusal arm, whereas a valid HTTPS origin has no @. Inputs with a path, query or fragment are URLs rather than origins. The guard is not a complete origin validator. No listener, TLS, feature-flag or browser result was observed. Expected exposed signup reports `features.enable_signup: true` and admits pending registration; fixed signup reports `false` and refuses registration. Expected exposed publication is reachable externally or lacks working certificate-validated HTTPS; the fixed deployment serves HTTPS and publishes only the intended loopback mapping. A certificate error, local socket error or DNS failure does not prove private ingress. See [Docker port publishing](https://docs.docker.com/engine/network/port-publishing/). ```bash ( set +e # errexit off here, so a command that ends nonzero is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'https://chat.example.com' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one HTTPS origin; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute your HTTPS origin inside the quotes; not probing"; exit 2 ;; https://*@*) echo "use an HTTPS origin without credentials; not probing"; exit 2 ;; https://*) set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused to run), which outranks 0 if command -v ss >/dev/null; then set -- "$1" "$2" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the listener table, then ss's exit status printf '%s' "$3" # read every listener; the published mapping should be 127.0.0.1:3000 only, never 0.0.0.0 or ::. # ss shows host listeners, not Docker's NAT: use Docker Engine 28.0+ for loopback publishing # (older engines let a same-L2 host reach a localhost-published port), confirm the mapping is # 127.0.0.1:3000->8080/tcp, and from another LAN/VPC host confirm nothing answers on port 3000. # The check below also counts any 127.x.x.x, [::1] or [::ffff:127.x.x.x] address as loopback. [ "$4" = 0 ] || { echo "ss -tlnp failed; inconclusive"; set -- "$1" 3 "$3" "$4"; } if printf '%s' "$3" | awk 'NR > 1 && $4 ~ /:3000$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 3000 is bound to a non-loopback address"; set -- "$1" 1 "$3" "$4" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "$3" "$4" ;; esac fi else echo "ss is not installed here; not listing sockets" case "$2" in 0) set -- "$1" 2 ;; esac fi curl -q -g -sSI --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${1%/}/" # serves over TLS without -k; --noproxy so a client proxy cannot answer case "$?" in 0) ;; *) echo "the HTTPS request failed, and a certificate error, local socket error or DNS failure does not prove private ingress; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac # Signup state, programmatically: Open WebUI serves its feature flags at /api/config. set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 "${1%/}/api/config"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then the response body printf '%s\n' "$4" # JSON feature flags; confirm signup is disabled there (features.enable_signup: false) if printf '%s' "$4" | grep -q '"enable_signup" *: *true'; then echo "FINDING: /api/config reports enable_signup true, so ordinary signup is open"; set -- "$1" 1 elif [ "$3" != 0 ]; then echo "curl exit $3: the /api/config request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 elif printf '%s' "$4" | grep -q '"enable_signup" *: *false'; then : else echo "/api/config reported enable_signup as neither true nor false; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 fi # In a private browser window, AFTER your admin account exists: the sign-up option is absent AND an actual # registration attempt is REFUSED (a missing button alone is not proof). With signup on, registering a new # account yields a pending/unapproved user, not access. echo "block exit $2; listeners other than a non-loopback port 3000, the Docker mapping, the HTTPS response headers and the browser registration attempt are not classified, so judge them yourself" exit "$2" ;; *) echo "use an HTTPS origin without credentials; not probing"; exit 2 ;; esac ) ``` **REASONED:** following block; from the separate LAN/VPC host, probe the actual Open WebUI host address, per the cited Docker port publishing entry. This second host is unavailable here. During marking review (October 2026), the then-current block ran from files under Bash 5.3.9(1)-release with a curl stub: the written placeholder, empty value, literal LF and angle-bracket value printed the substitution refusal, zero or two host values printed the exactly-one-host refusal, and omitting the initial `set --` line with a caller argument printed the whole-block refusal. Each of those refusal cases exited 1 without calling curl. The addresses `127.0.0.1` and `[::1]` in the same October 2026 run each reached only the curl stub with the corresponding HTTP URL on port 3000 and exited 0. These runs observed no network boundary. For IPv6, put the literal address in square brackets inside the quotes. A privately isolated exposed fixture should answer on port 3000; the fixed publication must not. Confirm that the intended HTTPS service still works and inspect the reported error before attributing a failure to the network boundary. During marking review (2026-10-14), the updated block ran from a file under Bash 5.3.9(1)-release with a scratch-confined curl stub: all cases recorded above repeated with the same messages and exit statuses; `user:pw@host`, `user@host`, `@host`, `host/path@tail`, `host?q=@tail` and `host#@tail` each printed "use a host address without credentials; not probing", exited 1 and called no stub. No network boundary was observed. Both sets of stub runs covered the following block in an earlier form, whose guard refusals exited with status 1; neither was re-run on the current form, whose guard refusals exit with status 2. ```bash ( set +e # errexit off here, so a failed request is reported below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_OPENWEBUI_HOST_IP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one host address; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute the actual host address inside the quotes; not probing"; exit 2 ;; *@*) echo "use a host address without credentials; not probing"; exit 2 ;; *) set -- "$1" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{time_connect}' "http://$1:3000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # curl's exit status, then its output printf '%s\n' "${3%$'\n'*}" # the http= line case "$2:${3##*$'\n'}" in 0:*) echo "FINDING: port 3000 answered over HTTP from this host"; exit 1 ;; 7:0.000000|28:0.000000) echo "block exit 0, an unclassified observation: nothing answered on port 3000, as the fixed publication requires, but a local error gives the same exit status, so read the err= text above and confirm the intended HTTPS service still works before attributing this to the network boundary"; exit 0 ;; *) echo "curl exit $2 is neither an answer nor a refused or timed-out connection; inconclusive"; exit 3 ;; esac ;; esac ) ``` ### Protected requests for the comparisons below **REASONED:** preparation for the protected request block below. The Bearer header and API-key use rest on the Sources entries [Open WebUI API endpoints: model discovery, chat requests, ingestion, and development documentation (rolling documentation, checked September 2026)](https://docs.openwebui.com/reference/api-endpoints/) and [Open WebUI API keys: global enablement, group permissions, and inherited authority (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/authentication-access/api-keys/). The authoring environment had no live Open WebUI or test identity, so only the file handling ran locally: a mode-0600 header file in a mode-0700 directory, an empty header file and an empty body file were created. The block, with `curl` stubbed in that local run, accepted the empty header file as an anonymous request and passed each file to curl only as an `@` path (`-H`, `--data-binary`, or `-F file=` for `UPLOAD`), so no file content reached the command line. That stub run covered the protected request block in an earlier form, whose guard refusals exited with status 1; it was not re-run on the current form, whose guard refusals exit with status 2. Prepare a mode-0600 header file in a private directory containing `Authorization: Bearer ` followed by the disposable test JWT or API key. Use an empty file for anonymous requests. Put JSON request bodies, including signup passwords, in a separate protected file; use an empty body file for GET. For `UPLOAD`, the body file is the harmless file to upload. **REASONED:** endpoint and response guidance for the protected request block below. Signin, signup and API-key responses rest on the Sources entry [Open WebUI auth routes: first-admin signup, auto-disable, password-auth ordering, trusted-header provisioning, signin limiter, and API-key creation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py), and export responses on [Open WebUI chat router: administrative chat access and export checks](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/chats.py). The authoring environment had no live Open WebUI, so only the guard and response directory ran locally, against a stub `curl`: the block accepted `https://chat.internal/api/models` and `http://127.0.0.1:3000/docs`, and refused `http://chat.internal/api/models` and `http://127.0.0.1:8080/api/models` with "use HTTPS or the loopback backend; not probing". The block, in the same local run against the stub `curl`, refused an unsubstituted `REPLACE_WITH_TEST_USER_ID` path with "substitute every placeholder inside the quotes; not probing", and created each private response directory with mode 0700. Those stub runs covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. No response content was observed. Use the actual HTTPS endpoint, or `http://127.0.0.1:3000/...` on the backend host. Replace placeholders inside the quoted arguments. Responses are captured privately because signin, signup, API-key, and export responses can contain credentials or private data. **REASONED:** following block; protected requests support the comparisons below from the cited Open WebUI handlers; no live deployment or test identities are available. ```bash ( set +e # errexit off here, so a failed request is reported below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ENDPOINT_URL' 'GET' 'REPLACE_WITH_HEADER_FILE' 'REPLACE_WITH_BODY_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "provide endpoint, method, header file and body file; not probing"; exit 2; } case "|$1|$2|$3|$4|" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|*'||'*|*[[:cntrl:]]*) echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; esac case "$1" in https://*@*|http://127.0.0.1:3000/*@*) echo "use an endpoint without credentials; not probing"; exit 2 ;; https://*|http://127.0.0.1:3000/*) [ -r "$3" ] || { echo "header file is unreadable"; exit 2; } [ -r "$4" ] || { echo "body file is unreadable"; exit 2; } case "$2" in GET|POST|DELETE|UPLOAD) ;; *) echo "use GET, POST, DELETE or UPLOAD; not probing"; exit 2 ;; esac umask 077 set -- "$@" "$(mktemp -d)" # empty if mktemp fails, which the next line catches [ -d "$5" ] || { echo "cannot create private results directory"; exit 3; } printf 'Private response directory: %s\n' "$5" case "$2" in GET) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; POST) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -H 'Content-Type: application/json' --data-binary "@$4" \ -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; DELETE) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ --request DELETE \ -H "@$3" -H 'Content-Type: application/json' --data-binary "@$4" \ -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; UPLOAD) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -F "file=@$4" -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$?" # curl's exit status case "$6" in 0) echo "block exit 0, an unclassified observation: a completed request is not a pass on its own, so compare the http= line above and the response in $5 with the comparison you are running"; exit 0 ;; *) echo "curl exit $6: the request failed; inconclusive"; exit 3 ;; esac ;; *) echo "use HTTPS or the loopback backend; not probing"; exit 2 ;; esac ) ``` **REASONED:** preceding block; covers its guard and all four request modes. No live Open WebUI or test identities were supplied for marking review; each service comparison states its expected exposed and fixed outcomes where it uses the block, per the Sources entries cited there. During marking review (October 2026), the then-current block ran from files under Bash 5.3.9(1)-release with scratch-confined curl and mktemp stubs: the written placeholders and a placeholder, empty value or literal LF in each of the four argument positions printed the substitution refusal; three or five values printed the four-argument refusal. The same October 2026 run produced further refusals: omitting the initial `set --` line with a caller argument printed the whole-block refusal; `http://chat.internal/api/models` and `http://127.0.0.1:8080/api/models` printed the HTTPS-or-loopback refusal. The same October 2026 run printed the corresponding unreadable-file message separately for nonexistent header and body paths, and printed the supported-method refusal for PATCH. Each substitution, four-argument, whole-block, HTTPS-or-loopback, unreadable-file and supported-method case in that October 2026 run exited 1 without calling curl or mktemp or creating a results directory. GET, POST, DELETE and UPLOAD at `https://chat.internal/api/models`, and GET at `http://127.0.0.1:3000/docs`, each exited 0 with empty readable header and body fixtures, called the mktemp stub's scratch-only directory helper, created one mode-0700 results directory and reached the curl stub. Each passed the header file as an `@` path; POST and DELETE supplied JSON content type and body-file `@` paths, DELETE added `--request DELETE`, and UPLOAD supplied `file=@` with the body-file path. Static reasoning: the outer method case admits only these four methods and the intervening `set --` appends a directory without changing the method, so no inner default arm is needed. No response content was observed. The guard checks arguments, not file contents. Inspect request files before sending them, especially registration bodies and requests captured from administrative actions. Do not use a production credential for an exposed comparison. Remove test accounts, keys, uploads, and private response files after recording redacted evidence. During marking review (2026-10-14), the updated block ran from a file under Bash 5.3.9(1)-release with scratch-confined curl and mktemp stubs: all cases recorded above repeated with the same messages and exit statuses; an embedded `REPLACE_WITH_TEST_USER_ID` endpoint also printed the substitution refusal and exited 1 without calling a stub. HTTPS endpoints with `user:pw@`, `user@` or empty userinfo, and HTTPS or allowed loopback endpoints containing @ in a path, query or fragment, each printed "use an endpoint without credentials; not probing", exited 1 and called neither stub nor created a results directory. Accepted runs again created mode-0700 response directories and passed request files as @ paths. No service response was observed. Both sets of stub runs covered the preceding block in an earlier form, whose guard refusals exited with status 1; neither was re-run on the current form, whose guard refusals exit with status 2. ### Authentication and provisioning **REASONED:** Cookie attributes need a running Open WebUI, HTTPS ingress, disposable login, and configured OAuth provider; no container runtime or live fixture is available here. With password signin enabled on the private fixture, use the protected helper to POST `/api/v1/auths/signin` with valid `email` and `password` fields; inspect the saved `headers` file for the `token` cookie. In a fresh browser, visit `/oauth/REPLACE_WITH_CONFIGURED_PROVIDER/login` with the provider substituted, complete login, and inspect `Set-Cookie` headers throughout the flow for `owui-session`. With both Secure settings false and no proxy cookie rewriting, issued cookies should lack `Secure`. After setting both true and restarting, repeat through public HTTPS: require `Secure` and `HttpOnly` on both cookies and check `SameSite` against the intended configuration (`lax` by default). A response that issues no relevant cookie is inconclusive; record unused flows as not applicable. Redact cookie values. See [auth cookie creation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py), [session middleware](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/main.py), and [cookie defaults](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py). **REASONED:** These comparisons require a running disposable Open WebUI, controlled accounts, a trusted proxy, an identity provider, and optional shared Redis, unavailable here. Use the protected request block and the specified browser flows. | Control | Concrete comparison and expected outcomes | | --- | --- | | Ordinary signup and first-admin exception | **REASONED:** whole row; no container runtime or live Open WebUI with an empty and a bootstrapped database in the authoring environment, per the guide's cited Open WebUI auth routes and Open WebUI configuration defaults entries. Locally, the protected request block in POST mode for `https://chat.internal/api/v1/auths/signup`, with an empty header file and a JSON body that jq parsed, reached only a stub curl with those arguments. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: on an empty database with the login form enabled, signup bootstraps an administrator even with `ENABLE_SIGNUP=false`; after bootstrap, explicitly enabled signup admits a pending account (exposed) and disabled signup returns 403 (fixed). POST `/api/v1/auths/signup` anonymously with valid `name`, `email`, and `password` fields in the protected body. On an empty fixture with the login form enabled, even `ENABLE_SIGNUP=false` permits bootstrap. After bootstrap, explicitly enabled signup admits a pending account; disabled signup returns 403. See the [signup handler](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). | | OAuth provisioning | **REASONED:** whole row; no identity provider, browser session or live Open WebUI in the authoring environment, per the guide's cited Open WebUI SSO and Open WebUI configuration defaults entries. The row is a browser flow with no command or literal configuration, so nothing in it ran locally. Expected: with OAuth signup enabled a new approved-domain identity is provisioned despite ordinary signup being off (exposed); with it disabled the new identity is not provisioned while an already-linked permitted account still signs in (fixed). In a fresh browser, visit `/oauth/REPLACE_WITH_CONFIGURED_PROVIDER/login`, substituting the configured provider, and complete login with a new approved-domain identity. With OAuth signup enabled, provisioning can occur despite ordinary signup being off; with it disabled, the new identity must not be provisioned. An already-linked permitted account should still sign in. Separately try an excluded domain. See [OAuth signup and domain controls](https://docs.openwebui.com/features/authentication-access/auth/sso/). | | Trusted identity and password-auth ordering | **REASONED:** whole row; no trusted proxy, controlled accounts or live Open WebUI in the authoring environment, per the guide's cited Open WebUI auth routes and Open WebUI SSO entries. Locally, the protected request block in POST mode for `http://127.0.0.1:3000/api/v1/auths/signin`, with a header file holding a forged identity header and a signin body that jq parsed, reached only a stub curl, which received the header file as an `@` path. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: an unstripped forged administrator header impersonates that administrator (exposed); fixed ingress rejects it or replaces it with the authenticated identity (fixed); with `ENABLE_PASSWORD_AUTH=false`, `/signin` returns 403. POST `/api/v1/auths/signin` with the fixture's configured identity header in the protected header file and a valid signin-shaped JSON body. An unstripped forged admin header can impersonate that admin; fixed ingress must reject it or replace it with the authenticated identity. Separately, `ENABLE_PASSWORD_AUTH=false` makes `/signin` return 403 before trusted-header handling. See the [signin handler](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). | | Key persistence and token lifecycle | **REASONED:** whole row; no container runtime, replicas, shared Redis or live Open WebUI in the authoring environment, per the guide's cited Open WebUI authentication helpers, Open WebUI launcher and Open WebUI environment defaults entries. Locally, the protected request block in GET mode for `https://chat.internal/api/models` and in POST mode for `https://chat.internal/api/v1/auths/signout` with an empty body reached only a stub curl. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: with the same injected key the unexpired token keeps working across recreation and replicas, and a changed key rejects it; after signout the token stays usable until expiry without Redis (exposed) and is refused on every worker with working shared revocation (fixed). Sign in and save the JWT privately. GET `/api/models` before and after recreating the disposable container with the same data volume and injected key, then against each replica: the unexpired token should continue working. A changed signing key rejects it. POST `/api/v1/auths/signout`, then repeat GET using the saved token: without Redis it remains usable until expiry; with working shared revocation it is refused on every worker. Check expiry separately. See [token validation and revocation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). | ### Execution, models, connections, and data **REASONED:** These comparisons need a running UI, disposable users and groups, reviewed harmless plugins, a working model backend, and controlled connection targets, unavailable here. Save any browser-generated request used for replay into protected files; do not put its credentials in command arguments. | Control | Concrete comparison and expected outcomes | | --- | --- | | Plugin execution | **REASONED:** whole row; no live Open WebUI, model backend or reviewed canary plugin in the authoring environment, per the guide's cited Open WebUI plugin loader and Open WebUI environment configuration reference entries. Locally, the protected request block in GET mode for `https://chat.internal/api/v1/functions/` and `https://chat.internal/api/v1/tools/` reached only a stub curl. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: the canary marker appears with plugins enabled (exposed) and the plugin no longer executes after disabling and restarting (fixed), when local plugins also disappear from both lists while external tool servers may remain. With a reviewed canary Tool or Function enabled on the private fixture, submit a chat that invokes it and record its harmless marker. Disable plugins, restart, and repeat the same chat request: the plugin must no longer execute. GET `/api/v1/functions/` and `/api/v1/tools/` too; local plugins disappear, while external tool servers may remain. A management request being accepted does not by itself mean execution is enabled. See [plugin loading](https://docs.openwebui.com/features/extensibility/plugin/development/under-the-hood/) and [the switch's scope](https://docs.openwebui.com/reference/env-configuration/). | | Additive group permissions | **REASONED:** whole row; no live Open WebUI, test users or groups in the authoring environment, per the guide's cited Open WebUI groups entry. The creation request is replayed from the browser through the protected request block, whose POST mode, run with other endpoints, reached only a stub curl locally; nothing else in the row can run here. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: with a group Tools Access grant the ordinary user saves the reviewed Tool (exposed); with the grant removed from every membership the same valid request is refused (fixed), and a false grant in a second group is not a deny. With plugins enabled only on the private fixture, give a test user a group Tools Access grant while the default remains false. Save a reviewed harmless Tool through the workspace. Remove the grant from every membership and repeat the same valid creation request: ordinary-user authorization must refuse it. A false grant in a second group must not be mistaken for a deny. See [permission merging](https://docs.openwebui.com/features/authentication-access/rbac/groups/). | | Code execution | **REASONED:** whole row; no live Open WebUI or configured code-execution engine in the authoring environment, per the guide's cited Open WebUI hardening and Open WebUI configuration defaults entries. Locally, the harmless payload ran under the authoring Python 3: `print(1 + 1)` printed `2`, the marker an enabled execution path should show. Expected: the chat code block and the code-interpreter request execute before their switches are disabled (exposed) and those paths are unavailable afterwards (fixed). On the private fixture, compare execution of a harmless `print(1 + 1)` chat code block and a code-interpreter request before and after disabling their respective switches. Record browser and configured-engine activity separately; after disabling, those execution paths should be unavailable. This does not test terminals or server-side plugins. See [code-execution controls](https://docs.openwebui.com/getting-started/advanced-topics/hardening/). | | Model access | **REASONED:** whole row; no live Open WebUI, configured test model or test users in the authoring environment, per the guide's cited Open WebUI groups and Open WebUI API endpoints entries. Locally, the protected request block in GET mode for `https://chat.internal/api/models` and in POST mode for `https://chat.internal/api/chat/completions` with a chat body that jq parsed reached only a stub curl. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: an excluded user who can still invoke the model is the exposed outcome; after the model is made Private the user with a read grant still obtains a completion while the excluded user cannot invoke it or create workspace models (fixed). GET `/api/models`, then POST a minimal valid chat to `/api/chat/completions` using a configured test model. Compare a user with a read grant and one without it after making the model Private. The authorized user must still obtain a completion; the excluded user must not invoke that model. Also confirm the excluded user cannot create workspace models. See [model access grants](https://docs.openwebui.com/features/authentication-access/rbac/groups/) and [API request examples](https://docs.openwebui.com/reference/api-endpoints/). | | Ollama administration | **REASONED:** whole row; no isolated Ollama backend, test administrator and ordinary accounts or live Open WebUI in the authoring environment, per the guide's cited Open WebUI Ollama router entry. Locally, the protected request block in DELETE mode for `https://chat.internal/ollama/api/delete` and `http://127.0.0.1:3000/ollama/api/delete/0`, with a body holding a `model` field that jq parsed, reached only a stub curl as `--request DELETE` with the JSON content type and the body file. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: an ordinary user completing the operation is the exposed outcome; ordinary users receive authorization refusal before the backend operation while the matched administrator request succeeds (fixed). On an isolated backend, capture valid pull, create, copy, and delete requests for disposable model resources. Use `DELETE /ollama/api/delete` for deletion (or `/ollama/api/delete/{url_idx}` with the actual backend index), selecting `DELETE` in the protected helper and putting the disposable model's `model` field in the protected JSON body. Replay each with an ordinary user's JWT, then an administrator's JWT. Ordinary users must receive authorization refusal before the backend operation; matched admin requests establish that the payload and backend work. Never test deletion against a production model. See [Ollama administrative routes](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/ollama.py). | | Connection destinations | **REASONED:** whole row; no approved backend connection, controlled excluded destination, egress records or live Open WebUI in the authoring environment, per the guide's cited Open WebUI OpenAI-compatible connections and Open WebUI Direct Connections entries; the exclusion itself is the network policy from egress-metadata.md, not an Open WebUI control. A canary chat can use the protected request block, whose POST mode for `https://chat.internal/api/chat/completions` reached only a stub curl locally; the egress and browser records cannot be produced here. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: a request reaching the excluded destination is the exposed outcome; the approved destination works while the excluded one receives no request (fixed); browser-to-provider inference appears with Direct Connections enabled and is absent when disabled. Send a canary chat through each approved backend connection and inspect backend egress records. In a private fixture with disposable credentials, compare an approved destination with a controlled destination excluded by the network policy. The latter must receive no request while the approved destination works. If testing Direct Connections, browser network records should show browser-to-provider inference when enabled and its absence when disabled. See [Direct Connections](https://docs.openwebui.com/features/chat-conversations/direct-connections/) and [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md). | | Administrative chat access and export | **REASONED:** whole row; no live Open WebUI, test administrator and user accounts or canary chats in the authoring environment, per the guide's cited Open WebUI chat router and Open WebUI environment defaults entries. Locally, the protected request block refused the unsubstituted `REPLACE_WITH_TEST_USER_ID` path with "substitute every placeholder inside the quotes; not probing", and in GET mode with a substituted ID and for `/api/v1/chats/all/db` reached only a stub curl. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: with the controls enabled both requests expose the canary chats (exposed); after disabling both and restarting both are refused while the chat owner keeps normal access (fixed). As an admin, GET `/api/v1/chats/list/user/REPLACE_WITH_TEST_USER_ID` and `/api/v1/chats/all/db`, substituting the test user ID. With the controls enabled, the fixture exposes its canary chats. After disabling both controls and restarting, both requests must be refused; the chat owner must retain normal access. See [chat-list and export enforcement](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/chats.py). | | Documentation and community UI | **REASONED:** whole row; no isolated `ENV=dev` and `ENV=prod` fixtures or live Open WebUI in the authoring environment, per the guide's cited Open WebUI application initialization, Open WebUI environment defaults and Open WebUI environment configuration reference entries. Locally, the protected request block in GET mode for `http://127.0.0.1:3000/docs` and `https://chat.internal/openapi.json` with an empty header file reached only a stub curl. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: Swagger and the OpenAPI schema are served under `ENV=dev` (exposed) and disappear under `ENV=prod` (fixed); with community sharing disabled the discovery sections and share buttons disappear. GET `/docs` and `/openapi.json` on the backend and public origin. Compare an isolated `ENV=dev` fixture with `ENV=prod`: Swagger and the OpenAPI schema must disappear. Inspect content, since a 200 response containing the application shell is not API documentation. With community sharing disabled, inspect the workspace discovery sections and community-share buttons; their disappearance does not demonstrate that local export or sharing is disabled. See [documentation registration](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/main.py) and [community UI scope](https://docs.openwebui.com/reference/env-configuration/). | ### API keys, ingestion, and resource limits **REASONED:** These comparisons require a live deployment, disposable API keys, controlled web destinations, proxy logs, and an approved bounded provider allowance, unavailable here. | Control | Concrete comparison and expected outcomes | | --- | --- | | API-key enablement and scope | **REASONED:** whole row; no live Open WebUI, ordinary-user API key or JWT in the authoring environment, per the guide's cited Open WebUI API keys and Open WebUI authentication helpers entries. Locally, the protected request block in GET mode for `https://chat.internal/api/models` and `https://chat.internal/api/v1/auths/` with a dummy key header file reached only a stub curl. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: an existing key that still works after the global switch is disabled is the exposed outcome; disabled, the key is refused while a valid JWT works; with the step 7 allowlist `/api/models` is permitted and `/api/v1/auths/` refused for the key; with the API-key permission removed from every group the permitted request fails (fixed). With a selected ordinary user's working key, GET `/api/models`. Disable the global key switch and repeat: the existing key must be refused while a valid JWT still works. Re-enable keys with the step 7 allowlist: `/api/models` remains permitted, while GET `/api/v1/auths/` is refused for the key. Remove the user's API-key permission from every group and repeat the permitted request: it must now fail. Use real existing routes when testing descendants and methods; a nonexistent route's 404 proves no scope boundary. See [key permissions](https://docs.openwebui.com/features/authentication-access/api-keys/) and [endpoint enforcement](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). | | Upload permission limitation and body limits | **REASONED:** whole row; no live Open WebUI, verified ordinary user or restricting proxy in the authoring environment, per the guide's cited Open WebUI file router entry. Locally, the protected request block in UPLOAD mode for `https://chat.internal/api/v1/files/` reached only a stub curl, which received the harmless file as `file=`. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: with the UI feature permission false and no proxy route block the handler still accepts the upload (exposed); with the proxy restriction it is refused (fixed); where uploads are allowed, the smaller file works and the larger one is refused. Use `UPLOAD` with `/api/v1/files/` and a small harmless document as a verified ordinary user. With the UI feature permission false but no proxy route block, the direct handler can still accept it. With the intended proxy restriction, it must be refused. Where uploads are allowed, compare files just below and above the chosen size limit, including multipart overhead in the proxy limit. Confirm the smaller upload works. Test the configured file count separately; it does not establish an aggregate quota. See [upload enforcement](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/files.py). | | URL ingestion and SSRF | **REASONED:** whole row; no live Open WebUI, controlled test pages, proxy or egress logs in the authoring environment, per the guide's cited Open WebUI retrieval router, Open WebUI hardening, Open WebUI configuration defaults and Open WebUI environment defaults entries. Locally, the protected request block in POST mode for each of the three routes with `?process=false`, and a body holding `url` that jq parsed, reached only a stub curl with the query string intact. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: without proxy denial each route can fetch the page despite the disabled UI permission (exposed); with proxy denial each is refused before its handler with no destination contact, and fixed controls prevent forbidden contact while the allowed page succeeds (fixed). POST `/api/v1/retrieval/process/web?process=false` with a JSON body containing `url` set to your controlled test page. Compare an allowed public destination, a controlled private destination, a filter-denied destination, and a redirect to the controlled private destination. Inspect destination and egress logs. Fixed controls must prevent forbidden contact while the allowed page succeeds. Where URL ingestion is prohibited, directly POST to each of `/api/v1/retrieval/process/web?process=false`, `/api/v1/retrieval/process/youtube?process=false`, and `/api/v1/retrieval/process/url?process=false` using a verified ordinary user's JWT and the protected JSON body containing the controlled public page's `url`. On the private fixture without proxy denial, establish that each request can fetch the page despite the disabled UI permission. With proxy denial, each must be refused before reaching its handler, with no destination contact; confirm this in proxy and destination logs. A malformed request or failed fetch does not demonstrate prohibition. Never probe real metadata services. See [URL ingestion](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/retrieval.py) and [outbound fetch protections](https://docs.openwebui.com/getting-started/advanced-topics/hardening/). | | Search, rate, and spend | **REASONED:** whole row; no live Open WebUI, controlled search provider, proxy rate limit or bounded provider allowance in the authoring environment, per the guide's cited Open WebUI hardening and Open WebUI configuration defaults entries. Locally, the protected request block in POST mode for `https://chat.internal/api/chat/completions` reached only a stub curl; no series was sent. The stub runs recorded in this row covered the protected request block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expected: excess requests that still reach the provider are the exposed outcome; under-limit requests work and excess requests are refused by the configured limiting layer without further provider work (fixed); disabling web search stops calls to the controlled search provider. Compare a harmless search-enabled chat on the private fixture before and after disabling web search; check the controlled search provider for calls. Separately submit a bounded series of valid `/api/chat/completions` requests across the chosen proxy rate threshold and provider/gateway spend threshold. Under-limit controls must work; excess requests must be refused by the configured limiting layer without further provider work. The signin limiter is not evidence for either result. See [network-layer limits](https://docs.openwebui.com/getting-started/advanced-topics/hardening/), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), and [litellm.md](https://github.com/jposluns/secureconfig/blob/main/litellm.md). | ### Verification scope **REASONED:** whole Verify section, summarizing the units above; no container runtime, network access or live Open WebUI in the authoring environment, per the Sources entries cited at each comparison; the only local runs are the shell, guard, stub, jq and Python checks recorded at the start of this section and at the units above that record them, which observed no service response. The marking-review and historical authoring shell, guard and stub runs covered the three Verify bash blocks in an earlier form, whose guard refusals exited with status 1. The current form of all three blocks, whose guard refusals exit with status 2, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and the current form of block 1, which also exits 2 when `ss` is missing and no other part found something or was inconclusive, also by the 2026-10-31 stub runs of the whole block recorded at its declaration. The original probes and the new controls are REASONED from the pinned source and linked vendor documentation. Configuration inspection alone does not demonstrate service behavior. Deployment checks must explicitly record the DELETE model comparison, individual proxy refusal for all three URL-ingestion routes, and exposed/fixed authentication and session `Set-Cookie` attributes. | Check scope | Deployment comparison and prerequisites | Status | | --- | --- | --- | | Service comparisons | Demonstrate every REASONED comparison above on a recorded Open WebUI release and image digest, with disposable data, a second-host IPv4/IPv6 ingress fixture, controlled users/groups, OAuth and trusted-header fixtures, shared Redis and multiple workers, reviewed canary plugins, an Ollama test backend, controlled web destinations, and bounded provider credentials. Include (1 of 2): first-admin signup with an empty database and signup disabled; bootstrap with the login form disabled; signup auto-disable and persistence; password-auth ordering; forged-header rejection; OAuth signup/domain boundaries; signing-key persistence across recreation and replicas; expiry and signout revocation; additive permissions. The items to include (2 of 2): plugin and code-execution separation; model access and administrative operations; destination controls; admin chat/export refusal; documentation and community UI scope; API-key disablement and endpoint matching; direct ingestion despite UI permissions; file limits, SSRF and redirects; and proxy/provider rate and spend enforcement. Record commands, response content, positive controls, versions, propagation timing, costs, and cleanup. | **REASONED:** every comparison listed in this row; none ran, because the authoring environment has no container runtime, network access or live Open WebUI and none of the fixtures listed, per the Sources entries cited at each comparison above. The only checks that ran were the local shell, guard, stub, jq and Python checks recorded at the start of this section and at the units above that record them, and each comparison above states its expected exposed and fixed outcomes. The marking-review and historical authoring shell, guard and stub runs recorded there covered the three Verify bash blocks in an earlier form, whose guard refusals exited with status 1. The current form of all three blocks, whose guard refusals exit with status 2, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and the current form of block 1, which also exits 2 when `ss` is missing and no other part found something or was inconclusive, also by the 2026-10-31 stub runs of the whole block recorded at its declaration. REASONED from the pinned source and linked vendor documentation; service behavior is not demonstrated. | ## Sources (checked September 2026) - Open WebUI environment configuration reference (rolling documentation, checked September 2026): https://docs.openwebui.com/reference/env-configuration/ - Open WebUI FAQ (the first account created becomes the administrator): https://docs.openwebui.com/faq/ - Open WebUI repository (the Docker Quick Start `-v open-webui:/app/backend/data` data volume): https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/README.md - Docker port publishing (localhost publishing; releases older than 28.0.0 let a same-L2 host reach a localhost-published port): https://docs.docker.com/engine/network/port-publishing/ - [Open WebUI pinned release version](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/package.json). - [Open WebUI configuration defaults: signup, permissions, OAuth, code execution, API keys, connections, retrieval limits, and web-fetch filters](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py). - [Open WebUI environment defaults: authentication, signing-key requirement, trusted headers, plugins, model bypass, admin chat access, redirects, and ENV](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py). - [Open WebUI launcher: persistent key-file generation and loading](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/start.sh). - [Open WebUI Dockerfile: backend working directory and production environment](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/Dockerfile). - [Open WebUI auth routes: first-admin signup, auto-disable, password-auth ordering, trusted-header provisioning, signin limiter, and API-key creation](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py). - [Open WebUI authentication helpers: HS256 signing, Redis revocation, API-key enforcement, path matching, and verified-user roles](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py). - [Open WebUI application initialization: environment-based admin creation, API documentation, and OAuth routes](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/main.py). - [Open WebUI Ollama router: model access and administrator-only management operations](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/ollama.py). - [Open WebUI chat router: administrative chat access and export checks](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/chats.py). - [Open WebUI file router: verified-user upload dependency and size enforcement](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/files.py). - [Open WebUI retrieval router: web and URL ingestion handlers](https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/retrieval.py). - [Open WebUI hardening: registration lifecycle, execution controls, network limits, and outbound protections (rolling documentation, checked September 2026)](https://docs.openwebui.com/getting-started/advanced-topics/hardening/). - [Open WebUI SSO: OAuth/OIDC, trusted headers, and OAuth persistence (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/authentication-access/auth/sso/). - [Open WebUI plugin loader: exec and server-process authority (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/extensibility/plugin/development/under-the-hood/). - [Open WebUI groups: additive permissions and private resource grants (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/authentication-access/rbac/groups/). - [Open WebUI permissions: workspace and sharing controls (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/authentication-access/rbac/permissions/). - [Open WebUI database schema: normalized access grants (rolling documentation, checked September 2026)](https://docs.openwebui.com/reference/database-schema/). - [Open WebUI API keys: global enablement, group permissions, and inherited authority (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/authentication-access/api-keys/). - [Open WebUI Direct Connections: browser-to-provider inference (rolling documentation, checked September 2026)](https://docs.openwebui.com/features/chat-conversations/direct-connections/). - [Open WebUI OpenAI-compatible connections: administrative provider configuration (rolling documentation, checked September 2026)](https://docs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-openai-compatible/). - [Open WebUI API endpoints: model discovery, chat requests, ingestion, and development documentation (rolling documentation, checked September 2026)](https://docs.openwebui.com/reference/api-endpoints/). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - webui: Open WebUI (0a7c15832fb30b1903753e83f81dc7d27e5b0944) - webui-docs: Open WebUI documentation (rolling) (unknown) - docker: Docker Engine localhost boundary (28.0.0) Claims (full source mapping in the JSON block below): - signup (REASONED; webui): ENABLE_SIGNUP defaults true; disable ordinary registration after accounts exist. - persistence (REASONED; webui, webui-docs): ConfigVar database values override environment after first launch unless ENABLE_PERSISTENT_CONFIG=false; default is true. - roles (REASONED; webui): DEFAULT_USER_ROLE defaults pending; user auto-approves and admin grants administrative privileges to later registrations. - first-admin (REASONED; webui): First account is admin regardless of DEFAULT_USER_ROLE; bootstrap privately or preset admin email/password. - signup-auto-disable (REASONED; webui, webui-docs): First normal registration and successful environment admin creation disable signup under normal persisted configuration. - bootstrap-exception (REASONED; webui): Empty-database signup bypasses ENABLE_SIGNUP with ENABLE_LOGIN_FORM=true, its default; disabled-form bootstrap needs ENABLE_INITIAL_ADMIN_SIGNUP=true, default false. - password-auth (REASONED; webui): ENABLE_PASSWORD_AUTH=false blocks password and trusted-header signin because its check runs first; use for OAuth/OIDC-only login. - sso-mfa (REASONED; webui-docs): OAuth/OIDC MFA belongs at the identity provider; ordinary signup refusal does not close alternative provisioning. - publication (REASONED; webui, docker): Publish host 127.0.0.1:3000 to container 8080 with Engine 28.0+; inspect NAT and external reachability as well as ss. - data-volume (REASONED; webui): Retain /app/backend/data for accounts/config; lost data reopens first-admin bootstrap, so keep ingress closed until claimed. - transport (REASONED; webui-docs): Open WebUI has no native TLS; use a TLS fronting layer and keep authentication enabled behind public access paths. - session-cookie (REASONED; webui): Set WEBUI_SESSION_COOKIE_SECURE=true before HTTPS publication; default is false. - auth-cookie (REASONED; webui): WEBUI_AUTH_COOKIE_SECURE inherits the session setting unless overridden; set it true explicitly, since TLS termination alone does not set Secure. - cookie-test (REASONED; webui): Issued token and owui-session cookies need Secure/HttpOnly and intended SameSite, lax by default; no issued cookie is inconclusive. - auth (REASONED; webui): WEBUI_AUTH defaults true; do not disable it to bypass empty signing-key startup refusal. - jwt-lifetime (REASONED; webui): JWT_EXPIRES_IN defaults 4w; 24h is an example finite lifetime. - signing-key (REASONED; webui): Inject the same persistent WEBUI_SECRET_KEY into every replica; HS256 signing-key disclosure permits existing-user impersonation. - key-file (REASONED; webui): Launcher generates .webui_secret_key only when absent; default /app/backend location is outside the data volume and can be lost on replacement. - revocation (REASONED; webui): Without Redis signout does not revoke a copied JWT before expiry; shared revocation needs Redis across workers. - rotation (REASONED; webui, webui-docs): WEBUI_JWT_SECRET_KEY is deprecated; signing-key rotation invalidates tokens and affects default OAuth encryption keys. - key-persistence-test (REASONED; webui): Unexpired JWT should survive recreation with retained key/data and work across replicas; a changed key rejects it. - oauth-signup (REASONED; webui, webui-docs): ENABLE_OAUTH_SIGNUP defaults false and independently controls OAuth provisioning; test new and already-linked identities. - oauth-domains (REASONED; webui, webui-docs): OAUTH_ALLOWED_DOMAINS defaults *; set approved domains and test excluded-domain refusal. - oauth-merge (REASONED; webui, webui-docs): OAUTH_MERGE_ACCOUNTS_BY_EMAIL defaults false; retain it absent verified-email guarantees. - oauth-roles (REASONED; webui, webui-docs): ENABLE_OAUTH_ROLE_MANAGEMENT defaults false; enable only after reviewing role claims. - oauth-persistence (REASONED; webui-docs): ENABLE_OAUTH_PERSISTENT_CONFIG defaults false with environment authority/read-only admin UI; enabling persistence permits stored precedence. - trusted-headers (REASONED; webui, webui-docs): Leave identity headers unset unless a sole trusted proxy strips client values and supplies authenticated identity; forged headers can impersonate/provision users. - plugins (REASONED; webui, webui-docs): ENABLE_PLUGINS defaults true; uploaded Tools/Functions execute server-side Python, including top-level code at load. - plugin-scope (REASONED; webui-docs): Disabling plugins blocks loading/execution, not every management endpoint, built-in tool, MCP/OpenAPI connection or terminal. - tools-access (REASONED; webui, webui-docs): USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS defaults false; ordinary Tool creation depends on workspace.tools, Function creation is administrative. - tools-import (REASONED; webui, webui-docs): USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT defaults false; remove unintended import grants from every group. - code-execution (REASONED; webui, webui-docs): ENABLE_CODE_EXECUTION defaults true with pyodide engine; disable unused legacy execution independently of server plugins. - code-interpreter (REASONED; webui, webui-docs): ENABLE_CODE_INTERPRETER defaults true with pyodide engine; test its own request path separately. - groups (REASONED; webui-docs): Group permissions add grants; a false default or another false membership cannot cancel a true grant. - model-bypass (REASONED; webui): BYPASS_MODEL_ACCESS_CONTROL defaults false; keep bypass disabled. - model-workspace (REASONED; webui, webui-docs): USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS defaults false and controls creation/editing, not invocation of existing models. - model-grants (REASONED; webui-docs): Private models need user/group grants; current payloads use access_grants; permitted completion is the positive control for excluded-user refusal. - ollama-admin (REASONED; webui): Pull/create/copy/delete require admin role, not ENABLE_MODEL_* switches; test ordinary-user refusal, including DELETE /ollama/api/delete, against valid admin controls. - connections (REASONED; webui, webui-docs): Pin every backend; initial OpenAI destination is https://api.openai.com/v1, and connection URLs do not enforce network egress. - direct-connections (REASONED; webui, webui-docs): ENABLE_DIRECT_CONNECTIONS defaults false; enabling it sends browser-to-provider inference, not server-side SSRF protection. - admin-chat (REASONED; webui): ENABLE_ADMIN_CHAT_ACCESS defaults true; disable/restart and test admin list-user refusal while owner access works. - admin-export (REASONED; webui): ENABLE_ADMIN_EXPORT defaults true; disable separately and test /api/v1/chats/all/db refusal; operator/database access remains possible. - community (REASONED; webui, webui-docs): ENABLE_COMMUNITY_SHARING defaults true; false removes community UI, not all local sharing/export. - docs (REASONED; webui): ENV defaults dev in backend and prod in official image; prod removes docs/schema registration, not APIs; inspect content rather than HTTP 200 alone. - api-keys (REASONED; webui, webui-docs): ENABLE_API_KEYS defaults false; disabling rejects existing keys without deleting them or disabling JWTs. - key-permission (REASONED; webui, webui-docs): API-key permission defaults false; ordinary users need it and global enablement, admins need the global switch; keys inherit owner authority. - key-routes (REASONED; webui, webui-docs): Endpoint restrictions default false; allowed exact paths also admit slash descendants and do not constrain methods or vary per key. - local-fetch (REASONED; webui, webui-docs): ENABLE_LOCAL_WEB_FETCH defaults false; ENABLE_RAG_LOCAL_WEB_FETCH is its deprecated alias. - redirects (REASONED; webui, webui-docs): AIOHTTP_CLIENT_ALLOW_REDIRECTS defaults false; keep redirects disabled and enforce independent egress. - fetch-filter (REASONED; webui, webui-docs): WEB_FETCH_FILTER_LIST uses comma-separated allow entries and ! deny entries extending the built-in blocklist. - web-search (REASONED; webui, webui-docs): ENABLE_WEB_SEARCH defaults false; disabling should stop controlled search-provider calls. - file-size (REASONED; webui): RAG_FILE_MAX_SIZE defaults unset; 10 MB is an example and handler checks after storage upload, so proxy body limits are also needed. - file-count (REASONED; webui, webui-docs): RAG_FILE_MAX_COUNT defaults unset; five is an example count, not aggregate storage/request quota. - file-permission (REASONED; webui): Chat file-upload permission defaults true; false alone does not block verified-user POST /api/v1/files/, requiring proxy denial when prohibited. - web-permission (REASONED; webui): Chat web-upload permission defaults true; false alone does not block web/youtube/url handlers; test each proxy denial with a working fixture. - search-permission (REASONED; webui, webui-docs): USER_PERMISSIONS_FEATURES_WEB_SEARCH defaults true; audit every group grant alongside global search disablement. - signin-rate (REASONED; webui): Password signin is limited to 15 attempts per 180 seconds per email; this is not an inference quota. - inference-limits (REASONED; webui-docs): Apply proxy rate/concurrency and provider/gateway spend limits; under-limit requests work while excess work is refused without provider calls. - verify-signup (REASONED; webui): After bootstrap require disabled feature flag, absent signup option and actual registration refusal; enabled signup should yield pending, not access. - verify-network (REASONED; docker): Actual host port 3000 must not answer from a second LAN/VPC host while trusted HTTPS works; DNS/local errors do not prove isolation. - verify-ssrf (REASONED; webui, webui-docs): Compare allowed public, controlled private, filter-denied and redirected destinations using contact/egress logs; failed fetch alone proves no boundary. - recorded-version (REASONED; webui, webui-docs): Pinned package identifies v0.11.3; the environment reference describes coverage through v0.11.1; introduction versions remain unverified. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "e51d97b1051005da6ba4d083b5949d60a91a1cb1e0910407c333aad9e2c6b281", "components": { "webui": { "name": "Open WebUI", "basis": "0a7c15832fb30b1903753e83f81dc7d27e5b0944", "sources": { "s94d055784840": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/config.py", "s9d6f5fb157de": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/auths.py", "s8952330d0246": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/main.py", "s241050fbe377": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/README.md", "s7d025cba5af9": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/env.py", "seb3e13955745": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/utils/auth.py", "s4fed797e27ab": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/start.sh", "s0a3440b8f305": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/Dockerfile", "s0b1139eccad8": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/ollama.py", "s416fdb7c65df": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/chats.py", "s377a6a5e9f43": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/files.py", "sbd0096118f9c": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/backend/open_webui/routers/retrieval.py", "s26981e2dc6de": "https://raw.githubusercontent.com/open-webui/open-webui/0a7c15832fb30b1903753e83f81dc7d27e5b0944/package.json" } }, "webui-docs": { "name": "Open WebUI documentation (rolling)", "basis": "unknown", "sources": { "sb5ac55b056ab": "https://docs.openwebui.com/reference/env-configuration/", "s4618b483c060": "https://docs.openwebui.com/getting-started/advanced-topics/hardening/", "sddffd30a3b0f": "https://docs.openwebui.com/features/authentication-access/auth/sso/", "sf412328c22af": "https://docs.openwebui.com/features/extensibility/plugin/development/under-the-hood/", "seec6b2b9c299": "https://docs.openwebui.com/features/authentication-access/rbac/groups/", "sab6706f74917": "https://docs.openwebui.com/features/authentication-access/rbac/permissions/", "s724ec4249003": "https://docs.openwebui.com/reference/database-schema/", "s3d61ecad0833": "https://docs.openwebui.com/reference/api-endpoints/", "sf11ac1428acb": "https://docs.openwebui.com/getting-started/quick-start/connect-a-provider/starting-with-openai-compatible/", "sbebbd1dda5c6": "https://docs.openwebui.com/features/chat-conversations/direct-connections/", "sb28825887b54": "https://docs.openwebui.com/features/authentication-access/api-keys/" } }, "docker": { "name": "Docker Engine localhost boundary", "basis": "28.0.0", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } } }, "claims": { "signup": {"text": "ENABLE_SIGNUP defaults true; disable ordinary registration after accounts exist.", "components": ["webui"], "sources": ["webui:s94d055784840", "webui:s9d6f5fb157de"], "status": "REASONED", "verify": [1]}, "persistence": {"text": "ConfigVar database values override environment after first launch unless ENABLE_PERSISTENT_CONFIG=false; default is true.", "components": ["webui", "webui-docs"], "sources": ["webui-docs:sb5ac55b056ab", "webui:s94d055784840"], "status": "REASONED"}, "roles": {"text": "DEFAULT_USER_ROLE defaults pending; user auto-approves and admin grants administrative privileges to later registrations.", "components": ["webui"], "sources": ["webui:s94d055784840", "webui:s9d6f5fb157de"], "status": "REASONED"}, "first-admin": {"text": "First account is admin regardless of DEFAULT_USER_ROLE; bootstrap privately or preset admin email/password.", "components": ["webui"], "sources": ["webui:s9d6f5fb157de", "webui:s8952330d0246"], "status": "REASONED"}, "signup-auto-disable": {"text": "First normal registration and successful environment admin creation disable signup under normal persisted configuration.", "components": ["webui", "webui-docs"], "sources": ["webui:s9d6f5fb157de", "webui:s8952330d0246", "webui-docs:s4618b483c060"], "status": "REASONED"}, "bootstrap-exception": {"text": "Empty-database signup bypasses ENABLE_SIGNUP with ENABLE_LOGIN_FORM=true, its default; disabled-form bootstrap needs ENABLE_INITIAL_ADMIN_SIGNUP=true, default false.", "components": ["webui"], "sources": ["webui:s9d6f5fb157de", "webui:s94d055784840"], "status": "REASONED", "verify": [3]}, "password-auth": {"text": "ENABLE_PASSWORD_AUTH=false blocks password and trusted-header signin because its check runs first; use for OAuth/OIDC-only login.", "components": ["webui"], "sources": ["webui:s9d6f5fb157de"], "status": "REASONED", "verify": [3]}, "sso-mfa": {"text": "OAuth/OIDC MFA belongs at the identity provider; ordinary signup refusal does not close alternative provisioning.", "components": ["webui-docs"], "sources": ["webui-docs:sddffd30a3b0f"], "status": "REASONED"}, "publication": {"text": "Publish host 127.0.0.1:3000 to container 8080 with Engine 28.0+; inspect NAT and external reachability as well as ss.", "components": ["webui", "docker"], "sources": ["webui:s241050fbe377", "docker:s1e53417c513d"], "status": "REASONED", "verify": [1, 2]}, "data-volume": {"text": "Retain /app/backend/data for accounts/config; lost data reopens first-admin bootstrap, so keep ingress closed until claimed.", "components": ["webui"], "sources": ["webui:s241050fbe377", "webui:s9d6f5fb157de"], "status": "REASONED"}, "transport": {"text": "Open WebUI has no native TLS; use a TLS fronting layer and keep authentication enabled behind public access paths.", "components": ["webui-docs"], "sources": ["webui-docs:sb5ac55b056ab", "webui-docs:s4618b483c060"], "status": "REASONED"}, "session-cookie": {"text": "Set WEBUI_SESSION_COOKIE_SECURE=true before HTTPS publication; default is false.", "components": ["webui"], "sources": ["webui:s7d025cba5af9"], "status": "REASONED", "verify": [3]}, "auth-cookie": {"text": "WEBUI_AUTH_COOKIE_SECURE inherits the session setting unless overridden; set it true explicitly, since TLS termination alone does not set Secure.", "components": ["webui"], "sources": ["webui:s7d025cba5af9", "webui:s9d6f5fb157de"], "status": "REASONED", "verify": [3]}, "cookie-test": {"text": "Issued token and owui-session cookies need Secure/HttpOnly and intended SameSite, lax by default; no issued cookie is inconclusive.", "components": ["webui"], "sources": ["webui:s7d025cba5af9", "webui:s9d6f5fb157de", "webui:s8952330d0246"], "status": "REASONED", "verify": [3]}, "auth": {"text": "WEBUI_AUTH defaults true; do not disable it to bypass empty signing-key startup refusal.", "components": ["webui"], "sources": ["webui:s7d025cba5af9"], "status": "REASONED"}, "jwt-lifetime": {"text": "JWT_EXPIRES_IN defaults 4w; 24h is an example finite lifetime.", "components": ["webui"], "sources": ["webui:s94d055784840"], "status": "REASONED"}, "signing-key": {"text": "Inject the same persistent WEBUI_SECRET_KEY into every replica; HS256 signing-key disclosure permits existing-user impersonation.", "components": ["webui"], "sources": ["webui:s7d025cba5af9", "webui:seb3e13955745"], "status": "REASONED"}, "key-file": {"text": "Launcher generates .webui_secret_key only when absent; default /app/backend location is outside the data volume and can be lost on replacement.", "components": ["webui"], "sources": ["webui:s4fed797e27ab", "webui:s0a3440b8f305"], "status": "REASONED"}, "revocation": {"text": "Without Redis signout does not revoke a copied JWT before expiry; shared revocation needs Redis across workers.", "components": ["webui"], "sources": ["webui:seb3e13955745"], "status": "REASONED", "verify": [3]}, "rotation": {"text": "WEBUI_JWT_SECRET_KEY is deprecated; signing-key rotation invalidates tokens and affects default OAuth encryption keys.", "components": ["webui", "webui-docs"], "sources": ["webui:s7d025cba5af9", "webui-docs:sddffd30a3b0f"], "status": "REASONED"}, "key-persistence-test": {"text": "Unexpired JWT should survive recreation with retained key/data and work across replicas; a changed key rejects it.", "components": ["webui"], "sources": ["webui:seb3e13955745", "webui:s4fed797e27ab"], "status": "REASONED", "verify": [3]}, "oauth-signup": {"text": "ENABLE_OAUTH_SIGNUP defaults false and independently controls OAuth provisioning; test new and already-linked identities.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sddffd30a3b0f"], "status": "REASONED", "verify": [3]}, "oauth-domains": {"text": "OAUTH_ALLOWED_DOMAINS defaults *; set approved domains and test excluded-domain refusal.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sddffd30a3b0f"], "status": "REASONED", "verify": [3]}, "oauth-merge": {"text": "OAUTH_MERGE_ACCOUNTS_BY_EMAIL defaults false; retain it absent verified-email guarantees.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sddffd30a3b0f"], "status": "REASONED"}, "oauth-roles": {"text": "ENABLE_OAUTH_ROLE_MANAGEMENT defaults false; enable only after reviewing role claims.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sddffd30a3b0f"], "status": "REASONED"}, "oauth-persistence": {"text": "ENABLE_OAUTH_PERSISTENT_CONFIG defaults false with environment authority/read-only admin UI; enabling persistence permits stored precedence.", "components": ["webui-docs"], "sources": ["webui-docs:sddffd30a3b0f"], "status": "REASONED"}, "trusted-headers": {"text": "Leave identity headers unset unless a sole trusted proxy strips client values and supplies authenticated identity; forged headers can impersonate/provision users.", "components": ["webui", "webui-docs"], "sources": ["webui:s7d025cba5af9", "webui:s9d6f5fb157de", "webui-docs:sddffd30a3b0f"], "status": "REASONED", "verify": [3]}, "plugins": {"text": "ENABLE_PLUGINS defaults true; uploaded Tools/Functions execute server-side Python, including top-level code at load.", "components": ["webui", "webui-docs"], "sources": ["webui:s7d025cba5af9", "webui-docs:sf412328c22af"], "status": "REASONED"}, "plugin-scope": {"text": "Disabling plugins blocks loading/execution, not every management endpoint, built-in tool, MCP/OpenAPI connection or terminal.", "components": ["webui-docs"], "sources": ["webui-docs:sb5ac55b056ab", "webui-docs:sf412328c22af"], "status": "REASONED", "verify": [3]}, "tools-access": {"text": "USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS defaults false; ordinary Tool creation depends on workspace.tools, Function creation is administrative.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sf412328c22af"], "status": "REASONED", "verify": [3]}, "tools-import": {"text": "USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT defaults false; remove unintended import grants from every group.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:seec6b2b9c299"], "status": "REASONED"}, "code-execution": {"text": "ENABLE_CODE_EXECUTION defaults true with pyodide engine; disable unused legacy execution independently of server plugins.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sb5ac55b056ab"], "status": "REASONED", "verify": [3]}, "code-interpreter": {"text": "ENABLE_CODE_INTERPRETER defaults true with pyodide engine; test its own request path separately.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sb5ac55b056ab"], "status": "REASONED", "verify": [3]}, "groups": {"text": "Group permissions add grants; a false default or another false membership cannot cancel a true grant.", "components": ["webui-docs"], "sources": ["webui-docs:seec6b2b9c299"], "status": "REASONED", "verify": [3]}, "model-bypass": {"text": "BYPASS_MODEL_ACCESS_CONTROL defaults false; keep bypass disabled.", "components": ["webui"], "sources": ["webui:s7d025cba5af9"], "status": "REASONED"}, "model-workspace": {"text": "USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS defaults false and controls creation/editing, not invocation of existing models.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sab6706f74917"], "status": "REASONED", "verify": [3]}, "model-grants": {"text": "Private models need user/group grants; current payloads use access_grants; permitted completion is the positive control for excluded-user refusal.", "components": ["webui-docs"], "sources": ["webui-docs:seec6b2b9c299", "webui-docs:s724ec4249003", "webui-docs:s3d61ecad0833"], "status": "REASONED", "verify": [3]}, "ollama-admin": {"text": "Pull/create/copy/delete require admin role, not ENABLE_MODEL_* switches; test ordinary-user refusal, including DELETE /ollama/api/delete, against valid admin controls.", "components": ["webui"], "sources": ["webui:s0b1139eccad8"], "status": "REASONED", "verify": [3]}, "connections": {"text": "Pin every backend; initial OpenAI destination is https://api.openai.com/v1, and connection URLs do not enforce network egress.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sf11ac1428acb"], "status": "REASONED", "verify": [3]}, "direct-connections": {"text": "ENABLE_DIRECT_CONNECTIONS defaults false; enabling it sends browser-to-provider inference, not server-side SSRF protection.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sbebbd1dda5c6"], "status": "REASONED", "verify": [3]}, "admin-chat": {"text": "ENABLE_ADMIN_CHAT_ACCESS defaults true; disable/restart and test admin list-user refusal while owner access works.", "components": ["webui"], "sources": ["webui:s7d025cba5af9", "webui:s416fdb7c65df"], "status": "REASONED", "verify": [3]}, "admin-export": {"text": "ENABLE_ADMIN_EXPORT defaults true; disable separately and test /api/v1/chats/all/db refusal; operator/database access remains possible.", "components": ["webui"], "sources": ["webui:s7d025cba5af9", "webui:s416fdb7c65df"], "status": "REASONED", "verify": [3]}, "community": {"text": "ENABLE_COMMUNITY_SHARING defaults true; false removes community UI, not all local sharing/export.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sb5ac55b056ab"], "status": "REASONED", "verify": [3]}, "docs": {"text": "ENV defaults dev in backend and prod in official image; prod removes docs/schema registration, not APIs; inspect content rather than HTTP 200 alone.", "components": ["webui"], "sources": ["webui:s7d025cba5af9", "webui:s0a3440b8f305", "webui:s8952330d0246"], "status": "REASONED", "verify": [3]}, "api-keys": {"text": "ENABLE_API_KEYS defaults false; disabling rejects existing keys without deleting them or disabling JWTs.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui:seb3e13955745", "webui-docs:sb28825887b54"], "status": "REASONED", "verify": [3]}, "key-permission": {"text": "API-key permission defaults false; ordinary users need it and global enablement, admins need the global switch; keys inherit owner authority.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui:seb3e13955745", "webui-docs:sb28825887b54"], "status": "REASONED", "verify": [3]}, "key-routes": {"text": "Endpoint restrictions default false; allowed exact paths also admit slash descendants and do not constrain methods or vary per key.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui:seb3e13955745", "webui-docs:sb28825887b54"], "status": "REASONED", "verify": [3]}, "local-fetch": {"text": "ENABLE_LOCAL_WEB_FETCH defaults false; ENABLE_RAG_LOCAL_WEB_FETCH is its deprecated alias.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sb5ac55b056ab"], "status": "REASONED"}, "redirects": {"text": "AIOHTTP_CLIENT_ALLOW_REDIRECTS defaults false; keep redirects disabled and enforce independent egress.", "components": ["webui", "webui-docs"], "sources": ["webui:s7d025cba5af9", "webui-docs:s4618b483c060"], "status": "REASONED"}, "fetch-filter": {"text": "WEB_FETCH_FILTER_LIST uses comma-separated allow entries and ! deny entries extending the built-in blocklist.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sb5ac55b056ab"], "status": "REASONED", "verify": [3]}, "web-search": {"text": "ENABLE_WEB_SEARCH defaults false; disabling should stop controlled search-provider calls.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:s4618b483c060"], "status": "REASONED", "verify": [3]}, "file-size": {"text": "RAG_FILE_MAX_SIZE defaults unset; 10 MB is an example and handler checks after storage upload, so proxy body limits are also needed.", "components": ["webui"], "sources": ["webui:s94d055784840", "webui:s377a6a5e9f43"], "status": "REASONED", "verify": [3]}, "file-count": {"text": "RAG_FILE_MAX_COUNT defaults unset; five is an example count, not aggregate storage/request quota.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:sb5ac55b056ab"], "status": "REASONED", "verify": [3]}, "file-permission": {"text": "Chat file-upload permission defaults true; false alone does not block verified-user POST /api/v1/files/, requiring proxy denial when prohibited.", "components": ["webui"], "sources": ["webui:s94d055784840", "webui:s377a6a5e9f43", "webui:seb3e13955745"], "status": "REASONED", "verify": [3]}, "web-permission": {"text": "Chat web-upload permission defaults true; false alone does not block web/youtube/url handlers; test each proxy denial with a working fixture.", "components": ["webui"], "sources": ["webui:s94d055784840", "webui:sbd0096118f9c", "webui:seb3e13955745"], "status": "REASONED", "verify": [3]}, "search-permission": {"text": "USER_PERMISSIONS_FEATURES_WEB_SEARCH defaults true; audit every group grant alongside global search disablement.", "components": ["webui", "webui-docs"], "sources": ["webui:s94d055784840", "webui-docs:seec6b2b9c299"], "status": "REASONED"}, "signin-rate": {"text": "Password signin is limited to 15 attempts per 180 seconds per email; this is not an inference quota.", "components": ["webui"], "sources": ["webui:s9d6f5fb157de"], "status": "REASONED"}, "inference-limits": {"text": "Apply proxy rate/concurrency and provider/gateway spend limits; under-limit requests work while excess work is refused without provider calls.", "components": ["webui-docs"], "sources": ["webui-docs:s4618b483c060"], "status": "REASONED", "verify": [3]}, "verify-signup": {"text": "After bootstrap require disabled feature flag, absent signup option and actual registration refusal; enabled signup should yield pending, not access.", "components": ["webui"], "sources": ["webui:s9d6f5fb157de", "webui:s94d055784840"], "status": "REASONED", "verify": [1]}, "verify-network": {"text": "Actual host port 3000 must not answer from a second LAN/VPC host while trusted HTTPS works; DNS/local errors do not prove isolation.", "components": ["docker"], "sources": ["docker:s1e53417c513d"], "status": "REASONED", "verify": [2]}, "verify-ssrf": {"text": "Compare allowed public, controlled private, filter-denied and redirected destinations using contact/egress logs; failed fetch alone proves no boundary.", "components": ["webui", "webui-docs"], "sources": ["webui:sbd0096118f9c", "webui-docs:s4618b483c060"], "status": "REASONED", "verify": [3]}, "recorded-version": {"text": "Pinned package identifies v0.11.3; the environment reference describes coverage through v0.11.1; introduction versions remain unverified.", "components": ["webui", "webui-docs"], "sources": ["webui:s26981e2dc6de", "webui-docs:sb5ac55b056ab"], "status": "REASONED"} } } ```` ====================================================================== ==> litellm.md ====================================================================== # LiteLLM proxy: master key and virtual keys LiteLLM's proxy lets your apps reach many AI model providers through one address, using paid keys it holds. This guide helps your AI assistant configure it securely, including a protected master key, per-application virtual keys with spending limits, a private bind behind TLS, and request content kept out of logs. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/litellm.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against LiteLLM SSO allowance v1.76.0, LiteLLM metrics default v1.85.0, and LiteLLM CLI v1.102.1; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 51 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A LiteLLM proxy fronts paid model APIs, so an exposed, keyless instance spends your provider credits for whoever finds it. Authentication is built in and must be switched on before anything else. ## 1. Set the master key Set `general_settings.master_key` in the `config.yaml` that LiteLLM reads. Provision that file with mode `0600`, owned by the service account, in a directory other accounts cannot modify. Merge this setting into the existing configuration rather than replacing its model and database settings: ```yaml general_settings: master_key: sk-REPLACE_WITH_LONG_RANDOM_VALUE ``` The master key is the root credential for the proxy; it belongs to the operator only and never to client applications. Have your secret store generate 32 random bytes encoded as hex with an `sk-` prefix and write the value into the protected file, replacing the placeholder before startup. Do not paste the key into a shell command or export `LITELLM_MASTER_KEY`. LiteLLM reads the key from the file, keeping it out of launch argv and the process environment. The file is plaintext and remains readable by the service account, root and any backup that copies it; keep it and editor backups out of version control ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). When both the environment and `config.yaml` set the master key, the `config.yaml` value wins. At the time of writing, current LiteLLM refuses to start when the resolved master key is unset, empty or whitespace-only, or `sk-1234`. Either `LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true` or the YAML setting `general_settings.dangerously_permit_weak_or_unset_master_key: true` permits weak or keyless startup independently; keep both overrides absent or false in production. Older releases and overridden deployments can still operate without authentication, so the unauthenticated-deployment warning above remains relevant. A startup refusal is a protection to retain, not a reason to enable the override. No first release is asserted here. See [master-key startup protection](https://docs.litellm.ai/docs/proxy/master_key_rotations) and the [current startup enforcement call](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/proxy_server.py). ## 2. Issue virtual keys per application Virtual keys need a PostgreSQL database: set `DATABASE_URL=postgresql://user:password@host:5432/dbname` in the environment (or `database_url` under `general_settings`) before `/key/generate` will work. The original alias-only request below illustrates issuance and attribution. It does not establish least privilege: use the restricted request body below for application keys. This block assumes a clean Bash shell. Substitute your actual HTTPS origin inside the single quotes (an apostrophe needs shell escaping), paste the whole block, and enter the master key at the hidden prompt. Paste the key there alone: a multi-line paste leaves every line after the first for your shell to run. The key stays in an unexported subshell variable, is sent to curl on stdin, and is unset on exit; no ambient master key is used. The response contains the newly issued virtual key, so keep the output private. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -o pipefail set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_ORIGIN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'expected HTTPS origin'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*example.com*|*example.net*|*example.org*|*[[:cntrl:]]*) echo 'replace the origin placeholder'; exit 2 ;; esac case "$1" in https://?*) ;; *) echo 'HTTPS origin required'; exit 2 ;; esac { unset -n LITELLM_MASTER_KEY && unset -v LITELLM_MASTER_KEY; } 2>/dev/null || { echo 'cannot clear LITELLM_MASTER_KEY in this shell'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell'; exit 2; } trap 'unset -v LITELLM_MASTER_KEY' EXIT printf 'LiteLLM master key (input hidden): ' IFS= read -r -s LITELLM_MASTER_KEY || { printf '\n'; echo 'no secret read'; exit 2; } printf '\n' case "$LITELLM_MASTER_KEY" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty secret, placeholder or control character'; exit 2 ;; esac printf 'Authorization: Bearer %s\n' "$LITELLM_MASTER_KEY" | curl -q -g "$1/key/generate" \ -H @- \ -H "Content-Type: application/json" \ -d '{"key_alias": "app-frontend"}' ) ``` Each app gets its own virtual key, which can be revoked or budgeted independently; LiteLLM's docs cover per-key models, budgets, and expiry. Clients send the virtual key in the `Authorization` header (the header name is configurable via `litellm_key_header_name`). Putting the key in curl's `-H` argument would expose it through `/proc//cmdline`. The prompt above and stdin header avoid that exposure and keep the entered key out of shell history and tracing. They do not hide process memory from the same account or root, or erase an earlier export. The Verify blocks below also pass headers on stdin. Virtual keys are stored hashed, but the provider API keys LiteLLM persists when `general_settings.store_model_in_db` is on are ENCRYPTED with `LITELLM_SALT_KEY` (which falls back to the master key when unset): set a permanent `LITELLM_SALT_KEY` BEFORE adding any credential, because changing it later strands what it encrypted, and protect the database and its backups as the store of those secrets. A credential written literally into `config.yaml` stays plaintext in that file, so keep it out of version control ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Keep `DATABASE_URL` in secret storage too: its password is a database credential. Leave `general_settings.store_model_in_db: false` unless database-managed models are needed. Disabling model storage does not remove the database requirement for virtual keys. See [configuration settings](https://docs.litellm.ai/docs/proxy/config_settings). ### Set spend, throughput, model access, and expiry together Use this JSON body with `POST /key/generate`. The amounts are examples, not defaults. Replace the IDs, and use the exact `model_name` from your `model_list`; `app-chat` is the example name used below. The owner must already exist and have the appropriate team membership. ```json { "key_alias": "app-frontend", "user_id": "REPLACE_WITH_NON_ADMIN_USER_ID", "team_id": "REPLACE_WITH_TEAM_ID", "models": ["app-chat"], "max_budget": 25, "budget_duration": "30d", "rpm_limit": 30, "tpm_limit": 50000, "max_parallel_requests": 4, "duration": "30d", "allowed_routes": [ "/chat/completions", "/v1/chat/completions", "/models", "/v1/models" ] } ``` `max_budget` is USD; its default is `null`, meaning no key-level spend limit. `budget_duration` controls spend resets. It does not expire the credential: `duration` controls key lifetime. `rpm_limit` limits requests per minute, `tpm_limit` limits tokens per minute, and `max_parallel_requests` limits concurrent requests. `soft_budget` only triggers configured alerts; it does not block spending. See [budgets and rate limits](https://docs.litellm.ai/docs/proxy/users) and the [request schemas and defaults](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/_types.py). Set an explicit model allowlist on BOTH the key and its team. **`models: []` means ALL models, not no models.** A team-attached key must satisfy both lists. The master key bypasses model restrictions, so never use it to demonstrate an application's model boundary. See [model-access resolution](https://docs.litellm.ai/docs/proxy/key_auth_arch) and the [master-key authentication path](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/user_api_key_auth.py). Create the shared spending boundary with `POST /team/new`: ```json { "team_alias": "app-production", "models": ["app-chat"], "max_budget": 100, "budget_duration": "30d", "rpm_limit": 120, "tpm_limit": 200000 } ``` Use the returned `team_id` on every application key that should share those limits. For an existing team, send the fields to change plus its `team_id` to `POST /team/update`. Team spend and throughput limits aggregate the attached keys; independent per-key limits alone do not cap the application's combined use across multiple keys. Add the intended non-admin owner to the team with `POST /team/member_add` before issuing its key. See [team budgets](https://docs.litellm.ai/docs/proxy/team_budgets), [team rate limits](https://docs.litellm.ai/docs/proxy/users), and the [team-update schema](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/_types.py). ### Share enforcement state across workers and replicas Shared Redis is REQUIRED for correct shared limits and revocation across multiple workers or replicas. A single container with multiple workers also needs it. Without shared state, workers can maintain separate counters and keep accepting a revoked key until their local cache expires. Configure both router Redis and proxy caching; merely exporting Redis variables is insufficient. See [What Needs Redis](https://docs.litellm.ai/docs/proxy/redis_requirements). Merge these settings into the existing sections of `config.yaml`; do not create duplicate top-level YAML keys: ```yaml router_settings: redis_host: os.environ/REDIS_HOST redis_port: os.environ/REDIS_PORT redis_password: os.environ/REDIS_PASSWORD litellm_settings: cache: true cache_params: type: redis host: os.environ/REDIS_HOST port: os.environ/REDIS_PORT password: os.environ/REDIS_PASSWORD general_settings: fail_closed_budget_enforcement: true ``` Point every worker at the same shared Redis service and protect its credentials and network access. This configuration also enables response caching: keeping content out of logs does not mean content is absent from the cache. `general_settings.fail_closed_budget_enforcement: true` rejects requests with 503 when current spend cannot be verified against Redis or the database. Keep budget reservation enabled. This is not an absolute provider-spend cap: batch submissions do not expose their full workload for estimation, and routes whose cost cannot be estimated can fall back to recorded-spend checks. Concurrent or delayed charges can therefore exceed a nominal budget. See [budget reservation, batch limitations, and fail-closed enforcement](https://docs.litellm.ai/docs/proxy/users). ### Expire, block, and revoke keys Keep a finite `"duration": "30d"` on generation, and rotate before expiry. For permanent revocation, send `POST /key/delete` with this body: ```json { "keys": ["REPLACE_WITH_VIRTUAL_KEY"] } ``` For reversible suspension, send `POST /key/block`: ```json { "key": "REPLACE_WITH_VIRTUAL_KEY" } ``` `POST /key/unblock` accepts the same single-key body to restore a blocked key. Deletion and blocking are different operations; do not treat removing a key from an application's configuration as revocation. At the time of writing, in-place virtual-key rotation through `/key/regenerate` is Enterprise-gated. Without it, generate a replacement with the same intended restrictions, deploy it to the application, confirm it works, then delete the old key. Preserve the shared team budget during replacement so issuing a new key does not create a fresh application-wide spending allowance. Test revocation on every worker; Redis coordination is not evidence that your particular deployment propagated it successfully. See [blocking and rotation](https://docs.litellm.ai/docs/proxy/virtual_keys) and the [delete and regenerate implementations](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/management_endpoints/key_management_endpoints.py). Key values in these JSON bodies are credentials. Supply them through a protected request file, as in Verify, rather than putting the JSON containing a real key in curl's arguments. Protect issuance responses too: they contain the new key. ## 3. Bind privately and add TLS in front Run the proxy on loopback (or a private container network) and publish it only through a TLS layer: [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)/[nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), or [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)/[tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) for no-open-port setups. The proxy's `--host` defaults to `0.0.0.0` (as of v1.102.1), so bind it explicitly: `litellm --host 127.0.0.1 --port 4000 --config config.yaml`. The Verify check below confirms it is on loopback. Bearer keys over plain HTTP are compromised on first use. For human access to the LiteLLM admin UI, add MFA at the fronting layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). At the time of writing, LiteLLM's `allowed_ips` filtering is an Enterprise feature. Where it is unavailable, filter ingress independently with the firewall, private network, or fronting proxy; a configured but unavailable feature is not an access boundary. See [IP address filtering](https://docs.litellm.ai/docs/proxy/ip_address). ## 4. Admin UI, routes, and outbound destinations The admin UI at `/ui` has its own login, separate from inference auth: `UI_USERNAME` defaults to `admin`, and with no `UI_PASSWORD` set the UI accepts the master key itself. Set individual admin logins or SSO, then set `general_settings.disable_env_credential_login: true` and restart so the shared bootstrap login (the env `UI_USERNAME`/master-key credential) stops working, since creating personal logins does not by itself disable it; or disable an unused UI entirely with `DISABLE_ADMIN_UI="True"`. Set `PROXY_BASE_URL` to the public `https://` origin so the UI's session cookies are marked `Secure` when TLS terminates at the proxy in front. At the time of writing, SSO is free for up to five users from v1.76.0; more users require Enterprise. The username/password bootstrap fallback above still needs deliberate removal after personal login or SSO works. See [SSO licensing](https://docs.litellm.ai/docs/proxy/admin_ui_sso) and [disabling environment credential login](https://docs.litellm.ai/docs/proxy/ui). ### Separate application credentials from administrative authority Never give application keys a `proxy_admin` owner. Management authority follows the owner's role, so a key intended for inference can otherwise carry administrative powers. Use `internal_user` for ordinary human users and reserve `proxy_admin` for operators. Keep application keys separate from human UI credentials. The `allowed_routes` entries in step 2 match the named paths and their slash-separated descendants: `/v1/models` also matches `/v1/models/anything`. This is a prefix allowlist, not an exact-path or per-HTTP-method allowlist. Enforce exact paths and methods at the ingress proxy where that is the intended boundary; remove model listing if the application does not need it. See [key ownership and inherited authority](https://docs.litellm.ai/docs/proxy/virtual_keys) and [RBAC roles](https://docs.litellm.ai/docs/proxy/access_control). Where Enterprise is available, `general_settings.admin_only_routes` can further restrict individual management endpoints: ```yaml general_settings: admin_only_routes: - /key/generate - /key/delete - /key/block - /key/unblock - /team/new - /team/update ``` This list is EXACT-MATCH: `/key/*` does not cover the key-management routes. List every route you intend to restrict. Without Enterprise, current source logs an error and skips this additional check; it does not turn the list into an enforced policy. Independently restrict management ingress and key ownership. See [route configuration](https://docs.litellm.ai/docs/proxy/public_routes) and [the licence check and exact comparison](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/route_checks.py). ### Remove public documentation and isolate health and metrics Not every route sits behind the key. `/health/liveliness` (and `/health/liveness`) are unauthenticated worker checks, and the docs routes (`/`, `/redoc`, `/openapi.json`) are public by default, so a reply from any of them proves nothing about inference auth; `/health` is authenticated when key auth is on. `/metrics` on the proxy port is authenticated by default (confirmed in v1.85.0; `litellm_settings.require_auth_for_metrics_endpoint: false` reopens it) and exposes spend and operational labels, not provider keys. Restrict the routes you do not need at the fronting proxy. `/health/readiness` is also unauthenticated. `/health` runs real model probes, which can incur provider charges; keep it private rather than using it as a public uptime check. See [health-route authentication and probe behavior](https://docs.litellm.ai/docs/proxy/health). `PROMETHEUS_METRICS_PORT` can open a SEPARATE UNAUTHENTICATED metrics listener. `require_auth_for_metrics_endpoint` protects only `/metrics` on the proxy port. Keep the separate port private and allow only trusted collectors; inspect container publications and load-balancer listeners as well as the proxy socket. See [dedicated metrics-listener security](https://docs.litellm.ai/docs/proxy/prometheus) and the [v1.85.0 default](https://raw.githubusercontent.com/BerriAI/litellm/v1.85.0/litellm/__init__.py). Disable unused documentation in the proxy's actual startup environment and restart: ```bash unset DOCS_URL REDOC_URL OPENAPI_URL export NO_DOCS=True NO_REDOC=True NO_OPENAPI=True ``` Explicit `DOCS_URL`, `REDOC_URL`, and `OPENAPI_URL` values override the corresponding disable flags. Remove those values from the service or container configuration too; unsetting them in an unrelated shell does not change the service. Disabling documentation does not disable the APIs it describes. See [environment-variable reference](https://docs.litellm.ai/docs/proxy/config_settings) and [URL-selection precedence](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/utils.py). ### Constrain destinations and pass-through routes A model's `api_base` and any pass-through `target` are outbound destinations the proxy will call, so a config that lets the wrong person add them is an SSRF and egress path: restrict who may change destinations and constrain the proxy's egress ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)); the documentation still labels pass-through route authentication as an Enterprise feature. At the time of writing, current `main` disagrees with that documentation: `auth: true` attaches LiteLLM authentication without an Enterprise licence check. Caller authorization is separate: a non-admin caller must also be authorized through `allowed_passthrough_routes` in key or team metadata; adding the path to `allowed_routes` alone is insufficient. The normal top-level `allowed_passthrough_routes` setting is an Enterprise-gated metadata field. Consequently, treat this authenticated forwarder as effectively admin/master-key territory on non-Enterprise deployments and omit the optional `pass_through_endpoints` for application traffic. Verify the behavior and licensed provisioning support of your installed release; no first release is asserted here. Do not resolve a licence or startup failure by disabling authentication or giving applications an admin or master key. See the [pass-through documentation](https://docs.litellm.ai/docs/proxy/pass_through) and [current route registration](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py). Pin `model_list[].litellm_params.api_base` to the intended provider. For any required forwarder, use an explicit target, authentication, the narrowest methods, no arbitrary subpaths, and no wholesale forwarding of incoming headers: ```yaml model_list: - model_name: app-chat litellm_params: model: openai/REPLACE_WITH_PROVIDER_MODEL api_base: https://api.example.com/v1 api_key: os.environ/PROVIDER_API_KEY general_settings: store_model_in_db: false pass_through_endpoints: - path: /vendor-status target: https://api.example.com/status auth: true methods: ["GET"] include_subpath: false forward_headers: false ``` Replace the provider model, origin, and target with endpoints you operate or have approved. Omit `pass_through_endpoints` entirely when no forwarder is needed. This example's upstream status endpoint requires no credential; if your target requires authentication, provision only its required upstream credentials through secret storage. `auth: true` authenticates the caller to LiteLLM. See [model configuration](https://docs.litellm.ai/docs/proxy/virtual_keys) and the [pass-through field reference](https://docs.litellm.ai/docs/proxy/pass_through). **Pinning a model is NOT a destination allowlist.** Callers can supply `api_base` or `base_url` overrides on supported request paths. Enforce an egress allowlist independently and reject caller-supplied destination overrides at a trusted request-validation layer. Account for redirects and DNS resolution in that egress policy, and deny metadata and unintended internal destinations. See [request routing with `api_base`](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/route_llm_request.py), [`base_url` handling](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/main.py), and [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md). ## 5. Keep request content out of logs Merge these settings into the existing configuration: ```yaml litellm_settings: turn_off_message_logging: true general_settings: store_prompts_in_spend_logs: false ``` Keep the environment switch unset in the service's startup configuration: ```bash unset STORE_PROMPTS_IN_SPEND_LOGS ``` `turn_off_message_logging` suppresses message and response content in supported logging integrations while retaining operational metadata such as spend. Spend-log payload storage has a separate switch: either `general_settings.store_prompts_in_spend_logs` or `STORE_PROMPTS_IN_SPEND_LOGS` enabling it is sufficient. A YAML value of `false` does not override an environment value of `true`. See [message redaction](https://docs.litellm.ai/docs/proxy/logging) and [the spend-log storage decision](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/spend_tracking/spend_tracking_utils.py). Avoid production `--detailed_debug` and `LITELLM_LOG=DEBUG`; inspect the actual process arguments and deployment environment. These settings do not promise to sanitize arbitrary custom callbacks, application logging, or fronting-proxy body capture. Review each configured callback and log destination, including error paths. See [CLI debugging](https://docs.litellm.ai/docs/proxy/cli), [logging callbacks](https://docs.litellm.ai/docs/proxy/logging), and [logging environment settings](https://docs.litellm.ai/docs/proxy/config_settings). ## 6. Verify ### Backend authentication and listener exposure (REASONED: cited LiteLLM auth and CLI behavior; no service or second-host fixture) **REASONED:** No live LiteLLM service, PostgreSQL/Redis deployment, provider credentials, or second-host network fixture is available in this read-only authoring environment; LiteLLM and a container runtime are not installed. The checks below have not been demonstrated against exposed and fixed deployments. Sources: [virtual-key authentication](https://docs.litellm.ai/docs/proxy/virtual_keys) and [CLI listener settings](https://docs.litellm.ai/docs/proxy/cli). Expected exposed and fixed outcomes are stated in the block. Use a valid virtual key whose `allowed_routes` includes `/v1/models`. Substitute inside the single quotes and paste the whole block. Do not paste a value containing an apostrophe directly into a single-quoted substitution site. The stdin technique closes curl's argument exposure; it does not hide secrets from shell history, shell tracing, or the account owner. ```bash # Test LiteLLM's OWN auth against the BACKEND directly on loopback, with no fronting proxy in the path, # three ways: with no key and with a WRONG key LiteLLM must refuse (401); with a VALID virtual key it # returns the model list. A gateway that answers 401 and then forwards a header-bearing request to a # keyless proxy would pass a public-edge test, so probe the backend. The valid key is fed to curl on # STDIN (-H @-) so it never enters curl's arguments, a temp file, or a shell variable; the guard refuses # on a placeholder. (The key you substitute on the set -- line does enter your shell history, so use a # short-lived key or clear that history line afterward.) ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input or found ss missing), which outranks 0 ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_VIRTUAL_KEY' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute the virtual key on the set -- line above; not probing"; exit 2 ;; *) set -- "$1" 0 # the 2nd value is this part's status so far, ranked as above set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null -w 'no-key=%{http_code} exit=%{exitcode}\n%{http_code}' http://127.0.0.1:4000/v1/models; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its output printf '%s\n' "${4%$'\n'*}" # the no-key= line case "$3:${4##*$'\n'}" in 0:401) ;; 0:200) echo "FINDING: /v1/models answered 200 with no key"; set -- "$1" 1 ;; 0:*) echo "no-key HTTP ${4##*$'\n'} is not LiteLLM's 401; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the no-key request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac # guard-conventions: allow deliberate bad-key negative control; sk-not-a-real-key is a dummy, not a live credential set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null -w 'bad-key=%{http_code} exit=%{exitcode}\n%{http_code}' -H 'Authorization: Bearer sk-not-a-real-key' http://127.0.0.1:4000/v1/models; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its output printf '%s\n' "${4%$'\n'*}" # the bad-key= line case "$3:${4##*$'\n'}" in 0:401) ;; 0:200) echo "FINDING: /v1/models answered 200 to a wrong key"; set -- "$1" 1 ;; 0:*) echo "bad-key HTTP ${4##*$'\n'} is not LiteLLM's 401; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the bad-key request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" "$(if printf 'Authorization: Bearer %s\n' "$1" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -w '\ngood-key=%{http_code} exit=%{exitcode}\n%{http_code}' -H @- http://127.0.0.1:4000/v1/models; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its output printf '%s\n' "${4%$'\n'*}" # the model list and the good-key= line case "$3:${4##*$'\n'}" in 0:200) ;; 0:*) echo "good-key HTTP ${4##*$'\n'} is not the 200 model list, so this positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the good-key request failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac exit "$2" ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # Expected: no-key and bad-key => 401 from LiteLLM; good-key => 200 with the model list (a 200 without a # key is the finding). Then repeat the good-key request through the public https ingress to confirm the # TLS front works. A transport error (exit != 0) is inconclusive, not a pass. if command -v ss >/dev/null; then set -- "$1" "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far, then the listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the listener table, then ss's exit status printf '%s' "$2" # a listener inventory in THIS network namespace, not a firewall/NAT/publication check: # confirm 4000 is loopback-only here, then from ANOTHER host confirm 4000 is refused # externally (a Docker DNAT publication need not appear in this list at all). The check # below also counts any 127.x.x.x, [::1] or [::ffff:127.x.x.x] address as loopback. [ "$3" = 0 ] || { echo "ss -tlnp failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:4000$/ && $4 !~ /^127\./ && index($4, "[::1]") != 1 && index($4, "::ffff:127.") != 2 { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 4000 is bound to a non-loopback address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac fi echo "block exit $1; listeners on other ports (every listener, if ss is missing), a Docker publication this list cannot show, the outside-host check of port 4000 and the repeat through your HTTPS ingress are not classified, so judge them yourself" exit "$1" ) ``` The `/v1/models` comparison tests authentication and listing, not successful inference or model authorization. Complete the inference comparisons below too. Inspect IPv4 and IPv6 reachability, published container ports, and any separate metrics listener. ### Protected requests for the comparisons (REASONED: protected GET/POST preparation, private responses and input-guard limitations follow the cited endpoint documentation; no live service or credentials. Bash 5.3.9 bash -n accepted both Verify shell fences. With curl replaced by a printf LOCAL_CURL marker, the complete request subshell accepted https://fixture.internal/api/chat, POST, an empty mode-0600 header file and a readable JSON file, printing a private response directory and one marker, exit 0. Empty endpoint, original placeholders, set -- without arguments and bad value as endpoint printed guard diagnostics without a marker, exit 1. The bash -n run covered both fences, and the stub runs the request fence, in an earlier form, whose guard refusals exited with status 1; the current forms, whose guard refusals exit with status 2, are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and the backend block's current form also by stub runs with curl and ss replaced. These observations establish neither file-content validation nor API acceptance) **REASONED:** The live deployment and test credentials described above are unavailable. Use this request block for the concrete comparisons below; a successful curl transport alone is never a pass. Inspect its private response body and headers for the specified LiteLLM result. The bash -n and stub runs recorded in the heading above also predate the change that makes both Verify shell fences report their result in their exit status (1 for a finding, 2 for a guard refusal, 3 for a failed or inconclusive check, 0 for a pass or a named unclassified observation), so they covered an earlier form. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of both fences, and stub runs with curl and ss replaced also cover the backend block's. Since then the backend block also exits 2 when `ss` is missing, unless another part found something or was inconclusive. Before running it, provision a mode-0600 header file in a private directory containing `Authorization: Bearer ` followed by the appropriate test or administrative key. Use an empty header file for unauthenticated requests. Prepare the JSON body in another protected file, substituting its placeholders before use; `{}` suffices for GET, which ignores that file. Keep URLs free of credentials. The block captures responses privately because management responses can contain keys. Use `http://127.0.0.1:4000/...` on the backend host, or your actual HTTPS endpoint. For each POST, use the endpoint and JSON specified in the comparison. Run destructive operations only against disposable test keys and teams. **REASONED:** following block; requests follow the cited LiteLLM endpoint documentation; the live service and test credentials are unavailable. ```bash ( set +e # errexit off here, so a failed request is reported below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ENDPOINT_URL' 'POST' 'REPLACE_WITH_HEADER_FILE' 'REPLACE_WITH_JSON_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "provide endpoint, method, header file and JSON file; not probing"; exit 2; } case "|$1|$2|$3|$4|" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|*'||'*|*[[:cntrl:]]*) echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; esac case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the endpoint; not probing"; exit 2 ;; https://*|http://127.0.0.1:4000/*) [ -r "$3" ] || { echo "header file is unreadable"; exit 2; } [ -r "$4" ] || { echo "JSON file is unreadable"; exit 2; } umask 077 set -- "$@" "$(mktemp -d)" # empty if mktemp fails, which the next line catches [ -d "$5" ] || { echo "cannot create private results directory"; exit 3; } printf 'Private response directory: %s\n' "$5" case "$2" in POST) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -H 'Content-Type: application/json' \ --data-binary "@$4" -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; GET) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 \ -H "@$3" -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; *) echo "only GET and POST are supported; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$?" # curl's exit status case "$6" in 0) echo "block exit 0, an unclassified observation: a completed request is not a pass on its own, so compare the http= line above and the response in $5 with the comparison you are running"; exit 0 ;; *) echo "curl exit $6: the request failed; inconclusive"; exit 3 ;; esac ;; *) echo "use HTTPS or the loopback backend; not probing"; exit 2 ;; esac ) ``` This guard checks substituted arguments, not the contents of the input files. Inspect those files before a mutation. Delete the private response directories and test credential files after recording the necessary redacted evidence. ### Model access, limits, expiry, and revocation (REASONED: model authorization, key/team budgets, RPM/TPM/concurrency, expiry, block/unblock/delete and fail-closed dependency/recovery comparisons follow the linked key, users and Redis references; no running proxy, database-backed keys/teams, Redis or provider allowance. Python 3.14.4 json.loads accepted the printed inference JSON; this does not show provider acceptance, accounting or any enforcement) **REASONED:** These comparisons need a running proxy, disposable database-backed keys and teams, shared Redis, and an approved provider test allowance, none of which is available here. Use the protected POST block with `/v1/chat/completions` and this body, choosing a configured chat model that supports the request: ```json { "model": "app-chat", "messages": [ { "role": "user", "content": "Reply with LITELLM-VERIFY-CANARY." } ], "max_tokens": 16 } ``` Use a distinct harmless marker for each paid request and confirm that the provider was actually called; cached responses do not demonstrate spend enforcement. Establish a successful permitted request before each negative comparison. A missing model, bad provider credential, timeout, or unrelated rate limit does not demonstrate the intended restriction. | Control | Concrete comparison | Expected exposed and fixed outcomes | | --- | --- | --- | | Key and team model lists | Send the body for two configured models that both work with a suitably authorized control key. First exclude the second model only on the test key, then only on its team, using the generation body and `/team/update`. | With unrestricted lists, both models work. Each narrow-list case must reject the excluded model with a model-access error while `app-chat` still works. See [model-access resolution](https://docs.litellm.ai/docs/proxy/key_auth_arch). | | Spend and team aggregation | Give a disposable key a small positive budget sufficient for one known-cost call. Repeat bounded requests until it is exhausted. Separately exhaust a small team budget using two attached keys that remain below their individual budgets. | Uncapped control keys continue; the fixed key or team returns a budget-exceeded rejection without another provider call. Record costs and any overshoot. See [budget enforcement](https://docs.litellm.ai/docs/proxy/users). | | RPM, TPM, and parallel requests | Test one limit at a time. For RPM, use a test limit of 1 and send two requests in the same window. For TPM, use a known token workload above a small test limit. For parallel requests, use a limit of 1 and overlap two requests while the first remains active. | Without the tested limit, both requests can proceed. With it, excess work receives the corresponding limiter rejection, normally 429; an under-limit control still succeeds. Repeat across workers, not just one process. See [rate limits](https://docs.litellm.ai/docs/proxy/users) and [shared counters](https://docs.litellm.ai/docs/proxy/redis_requirements). | | Finite lifetime | Generate a disposable key with `"duration": "1m"` and send the same permitted request before and after its returned expiry time. | A non-expiring control remains usable. The finite key works before expiry and is rejected afterward; a separate unexpired control still works. See [key lifetime](https://docs.litellm.ai/docs/proxy/virtual_keys). | | Block, unblock, and delete | After proving the test key works, use administrative POST requests to `/key/block`, `/key/unblock`, and finally `/key/delete`, with the respective bodies from step 2. Repeat its inference request after each operation on every worker. | Block rejects; unblock restores access; delete rejects permanently. Continued acceptance on any worker is a finding. The replacement key must still work. See [key operations](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/management_endpoints/key_management_endpoints.py) and [cache invalidation](https://docs.litellm.ai/docs/proxy/redis_requirements). | Do not extrapolate the RPM result to TPM or concurrency: each needs its own matched workload. Bound the request count and provider spend before starting. **REASONED:** Fail-closed testing additionally needs an isolated deployment where Redis/database failures can be injected without affecting production. Repeat the same budgeted inference request with spend verification unavailable and `fail_closed_budget_enforcement` enabled: expect 503 and no provider call. Restore the dependencies and confirm a below-budget request succeeds. Compare the flag-disabled state to identify any admission on unverifiable spend. Test batch and unestimable-cost routes separately; their successful admission does not establish an absolute cap. See [fail-closed enforcement and reservation limitations](https://docs.litellm.ai/docs/proxy/users). ### Administrative routes, public surfaces, destinations, and logs (REASONED: application/admin and licence comparisons, docs/health/metrics, ingress, pass-through methods/subpaths, destination overrides, content logs and UI login/cookies follow the linked endpoint and pinned source references; no deployed ingress, licensed fixture, test identities, controlled upstream, log collector or browser login) **REASONED:** These comparisons require the deployed ingress, installed LiteLLM release, test identities, controlled upstream endpoints, and collected logs, which are unavailable here. Use the protected request block for the GET and POST requests below. Keep any deliberately exposed comparison private and isolated. | Control | Concrete comparison | Expected exposed and fixed outcomes | | --- | --- | --- | | Application/admin separation | POST the restricted generation body to `/key/generate` with the application key, then with the operator credential in an isolated test deployment. | An overprivileged application credential may create a key. The fixed application key must receive an authorization rejection; the operator's matched valid request succeeds. Revoke any test keys created. See [key ownership](https://docs.litellm.ai/docs/proxy/virtual_keys) and [RBAC](https://docs.litellm.ai/docs/proxy/access_control). | | Enterprise admin-only routes | With a non-admin identity otherwise allowed to create its own keys, POST a valid `/key/generate` request before and after enabling the exact route restriction on a licensed fixture. | The added restriction denies the non-admin request while the operator succeeds. An unlicensed fixture logs the feature error and skips this extra restriction; ingress and application-key restrictions must still hold. See [route-check implementation](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/route_checks.py). | | Documentation and health | Make unauthenticated GET requests to `/`, `/redoc`, `/openapi.json`, and `/health/readiness` on the backend and public origin. Inspect `/health` ingress policy without repeatedly running paid probes. | Exposed docs return documentation/schema content. After disabling them, that content is absent, including at previously configured custom docs paths. Readiness can still answer without a key and proves no inference protection. Public `/health` must be blocked by the chosen ingress policy. See [docs URL precedence](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/utils.py) and [health checks](https://docs.litellm.ai/docs/proxy/health). | | Metrics and ingress filtering | GET `/metrics` on the proxy port without a key and with an authorized collector credential. If a separate metrics port is configured, test it from both a collector network and an excluded network. | Proxy-port metrics reject unauthenticated access. A dedicated listener can return metrics without a key to its allowed collector network; the excluded network must not reach it. Collector success distinguishes filtering from a broken listener. See [metrics-listener security](https://docs.litellm.ai/docs/proxy/prometheus). | | Pass-through boundary | For the optional `/vendor-status` example, GET it without a key and with a key explicitly allowed to use that route; also try POST `/vendor-status` and GET `/vendor-status/extra`. | An exposed forwarder can be reached anonymously or forward unintended requests. The fixed route rejects anonymous access, forwards the permitted authenticated GET, and does not forward the wrong method or subpath. Confirm at the controlled upstream. See [pass-through configuration](https://docs.litellm.ai/docs/proxy/pass_through). | | Destination overrides | In an isolated fixture with disposable upstream credentials, add `"api_base": "https://REPLACE_WITH_CONTROLLED_SINK/v1"` to the inference JSON; repeat separately with `"base_url"` instead. Replace the sink placeholder in the file. | The exposed comparison may contact the controlled sink. The fixed request-validation/egress policy must prevent that contact while the normal pinned destination still works. Inspect sink and egress records; a provider error alone is inconclusive. Never use metadata services or unrelated internal systems as test targets. See [override routing](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/route_llm_request.py) and [`base_url` handling](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/main.py). | | Content logging | Send the canary inference request, then repeat with a new marker after applying step 5. Inspect the matching request window in spend logs, process logs, and every configured callback destination, including a controlled error case. | The exposed comparison establishes that content can reach the tested sink. The fixed comparison retains the expected request/spend metadata while omitting prompt and response content. A missing log record is not proof of redaction; arbitrary callbacks require separate review. See [message redaction](https://docs.litellm.ai/docs/proxy/logging) and [spend-log storage](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/spend_tracking/spend_tracking_utils.py). | **REASONED:** UI testing needs a live UI and configured personal-login or SSO identities. Open the actual HTTPS `/ui` in a fresh browser session: establish that the intended personal login works, then confirm the old environment login is refused after `disable_env_credential_login` and restart. Inspect the session cookie's `Secure` attribute. If the UI is disabled, confirm it no longer serves the admin interface while an authorized inference request still works. See [UI login settings](https://docs.litellm.ai/docs/proxy/ui), [SSO](https://docs.litellm.ai/docs/proxy/admin_ui_sso), and [cookie security](https://docs.litellm.ai/docs/proxy/security_best_practices). ### REASONED service checks (REASONED: full service comparison inventory and startup-key controls from the cited LiteLLM documentation and pinned sources; no two-worker deployment, database, Redis, provider allowance or second host. No deployment behavior was observed) The checks below remain REASONED from the cited LiteLLM documentation and pinned sources. Service behavior in this replacement is not demonstrated. | Scope | Exposed/fixed checks and prerequisites | Status | | --- | --- | --- | | LiteLLM controls | Check every REASONED comparison above on a pinned LiteLLM release with PostgreSQL, shared Redis, at least two workers, approved provider test credentials, and a second-host ingress fixture. Cover (1 of 2): backend authentication and actual inference; IPv4/IPv6 and container publication; key/team model intersections; key and aggregate team spend; RPM, TPM, and parallel limits; budget verification failures and batch/unestimable-route limitations; expiry, block/unblock, delete, and replacement across workers; application/admin separation and licensed/unlicensed route behavior. The items to cover (2 of 2): documentation URL overrides; health and both metrics listeners; UI fallback removal and Secure cookies; pass-through authentication, methods, subpaths, and headers; destination overrides and egress evidence; and prompt/response absence across spend, debug, error, and callback logs. Include isolated startup tests for unsafe master keys with both overrides absent or false, then with `LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true` and YAML `general_settings.dangerously_permit_weak_or_unset_master_key: true` enabled independently. Record versions, commands, diagnostics, positive controls, propagation timing, costs, and cleanup. Configuration inspection alone does not demonstrate service behavior. | REASONED from the cited documentation and pinned sources; service behavior is not demonstrated. | ## Sources (checked September 2026) - LiteLLM proxy virtual keys (master_key, /key/generate, header name): https://docs.litellm.ai/docs/proxy/virtual_keys - LiteLLM proxy CLI (`--host` default `0.0.0.0`, `--port` default `4000`): https://docs.litellm.ai/docs/proxy/cli - LiteLLM admin UI (`UI_USERNAME`/`UI_PASSWORD`, master-key fallback, `DISABLE_ADMIN_UI`): https://docs.litellm.ai/docs/proxy/ui - LiteLLM security best practices (route exposure, `PROXY_BASE_URL` and `Secure` cookies): https://docs.litellm.ai/docs/proxy/security_best_practices - LiteLLM config settings (`store_model_in_db`, `LITELLM_SALT_KEY`, `require_auth_for_metrics_endpoint`): https://docs.litellm.ai/docs/proxy/config_settings - LiteLLM Prometheus metrics (authenticated by default): https://docs.litellm.ai/docs/proxy/prometheus - LiteLLM pass-through endpoints (`target`, Enterprise auth): https://docs.litellm.ai/docs/proxy/pass_through - [LiteLLM budgets, throughput limits, reservation, batch limitations, and fail-closed enforcement](https://docs.litellm.ai/docs/proxy/users). - [LiteLLM team budgets and shared spending](https://docs.litellm.ai/docs/proxy/team_budgets). - [LiteLLM request schemas: key budget defaults, lifetime, throughput, soft budgets, and team updates](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/_types.py). - [LiteLLM model-access resolution: empty lists and key/team intersections](https://docs.litellm.ai/docs/proxy/key_auth_arch). - [LiteLLM authentication source: master-key authority](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/user_api_key_auth.py). - [LiteLLM Redis requirements: configuration, shared limits, and revocation propagation](https://docs.litellm.ai/docs/proxy/redis_requirements). - [LiteLLM key-management source: deletion, blocking, and Enterprise-gated virtual-key regeneration](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/management_endpoints/key_management_endpoints.py). - [LiteLLM role-based access control](https://docs.litellm.ai/docs/proxy/access_control). - [LiteLLM public/private routes and exact-match admin-only lists](https://docs.litellm.ai/docs/proxy/public_routes). - [LiteLLM route-check source: Enterprise error and skipped admin-only check](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/route_checks.py). - [LiteLLM IP filtering and Enterprise requirement](https://docs.litellm.ai/docs/proxy/ip_address). - [LiteLLM SSO: free allowance from v1.76.0](https://docs.litellm.ai/docs/proxy/admin_ui_sso). - [LiteLLM health routes: unauthenticated readiness and paid model probes](https://docs.litellm.ai/docs/proxy/health). - [LiteLLM v1.85.0 source: authenticated metrics default](https://raw.githubusercontent.com/BerriAI/litellm/v1.85.0/litellm/__init__.py). - [LiteLLM utility source: explicit documentation URLs override disable flags](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/utils.py). - [LiteLLM pass-through source: authentication without an Enterprise gate on current main](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py). - [LiteLLM request routing: caller-supplied api_base](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/route_llm_request.py). - [LiteLLM completion source: base_url handling](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/main.py). - [LiteLLM logging: message redaction and custom callbacks](https://docs.litellm.ai/docs/proxy/logging). - [LiteLLM spend-log source: configuration/environment OR condition for payload storage](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/spend_tracking/spend_tracking_utils.py). - [LiteLLM master-key documentation: unsafe-key startup refusal and configuration precedence](https://docs.litellm.ai/docs/proxy/master_key_rotations). - [LiteLLM startup source: master-key boot enforcement and override hook](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/proxy_server.py). - [LiteLLM pass-through authorization: required allowed_passthrough_routes for non-admin callers](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/route_checks.py). - [LiteLLM premium metadata fields: allowed_passthrough_routes](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/_types.py). - [LiteLLM metadata setter: Enterprise licence enforcement](https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/management_endpoints/common_utils.py). - [LiteLLM proxy CLI `--host` default `0.0.0.0` (pinned tag v1.102.1)](https://github.com/BerriAI/litellm/blob/v1.102.1/litellm/proxy/proxy_cli.py#L666-L667) ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - sso: LiteLLM SSO allowance (v1.76.0) - metrics: LiteLLM metrics default (v1.85.0) - cli: LiteLLM CLI (v1.102.1) - docs: LiteLLM documentation (unknown) - source: LiteLLM source (6ef7b86748118ceecd95271727c2fa167ce55fec) Claims (full source mapping in the JSON block below): - master-file (REASONED; docs): Keep operator-only master_key in protected config.yaml, not argv/exported environment; YAML wins over environment and remains plaintext at rest. - startup (REASONED; docs, source): Current startup refuses missing, empty, whitespace-only or sk-1234 master keys; no first release is asserted. - env-override (REASONED; docs, source): LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true independently allows weak/keyless startup; retain refusal in production. - yaml-override (REASONED; docs, source): general_settings.dangerously_permit_weak_or_unset_master_key=true independently allows weak/keyless startup. - database (REASONED; docs): Virtual-key generation requires PostgreSQL via DATABASE_URL or general_settings.database_url, even with model storage disabled. - virtual-keys (REASONED; docs): Issue separate revocable application keys; default Authorization header is configurable via litellm_key_header_name. - salt (REASONED; docs): Stored virtual keys are hashed; database provider credentials are encrypted with LITELLM_SALT_KEY, falling back to master key; preserve the salt. - model-storage (REASONED; docs): Leave store_model_in_db false unless required; protect database credentials, backups and plaintext configuration. - ownership (REASONED; docs, source): Use non-admin owners with team membership; keys inherit owner authority and master keys bypass application restrictions. - key-models (REASONED; docs): Explicit key and team model lists intersect; models: [] means all models, not none. - key-budget (REASONED; docs, source): max_budget is USD and defaults null, with no key-level cap; budget_duration resets spend and does not expire the key. - expiry (REASONED; docs, source): duration controls lifetime separately; compare finite-key success before expiry/refusal afterward against an unexpired control. - rpm (REASONED; docs, source): rpm_limit limits requests per minute; test its own window and successful under-limit control across workers. - tpm (REASONED; docs, source): tpm_limit limits tokens per minute; RPM results do not demonstrate this limit. - parallel (REASONED; docs, source): max_parallel_requests limits concurrent requests; overlap a bounded workload to distinguish it from rate limits. - soft-budget (REASONED; docs, source): soft_budget triggers configured alerts without blocking spending. - teams (REASONED; docs, source): POST team/new and team/update set shared model/spend/throughput limits; attach every relevant key and add the intended owner with team/member_add. - shared-state (REASONED; docs): Multiple workers, even in one container, require shared Redis for limits/revocation; configure router Redis and proxy caching, not variables alone. - cache (REASONED; docs): The shown Redis cache configuration also caches responses; absent content logs do not imply absent cached content. - fail-closed (REASONED; docs): fail_closed_budget_enforcement=true returns 503 when spend cannot be verified; restore dependencies and confirm permitted work. - reservation (REASONED; docs): Keep reservation enabled; batch/unestimable costs and delayed or concurrent charges mean budgets are not absolute provider-spend caps. - delete (REASONED; docs, source): POST /key/delete permanently revokes the listed keys; removing application configuration is not revocation. - block (REASONED; docs, source): POST /key/block suspends and /key/unblock restores; compare each operation on every worker with replacement-key positive controls. - rotation (REASONED; docs, source): In-place /key/regenerate is Enterprise-gated; otherwise generate restricted replacement, deploy/test it, then delete old key while retaining team budget. - bind (REASONED; docs, cli): CLI defaults 0.0.0.0:4000; use --host 127.0.0.1 --port 4000 --config config.yaml behind TLS. - ip-filter (REASONED; docs): allowed_ips is Enterprise; otherwise ingress filtering must be enforced independently. - ui-login (REASONED; docs): UI_USERNAME defaults admin; without UI_PASSWORD the UI accepts the master key; remove fallback with disable_env_credential_login after personal login works. - ui-cookie (REASONED; docs): Set PROXY_BASE_URL to the actual HTTPS origin for Secure UI cookies and test in a fresh browser. - ui-disable (REASONED; docs): DISABLE_ADMIN_UI=true removes an unused UI; authorized inference must still work. - sso (REASONED; sso): SSO is free for up to five users from v1.76.0; more require Enterprise and bootstrap credentials still need removal. - mfa (REASONED; docs, sso): Human UI MFA belongs at the fronting identity layer. - routes (REASONED; docs, source): allowed_routes admits named paths and slash descendants, not exact path/method pairs; narrow at ingress where required. - admin-routes (REASONED; docs, source): admin_only_routes uses exact matches, not /key/*; without Enterprise source logs an error and skips this additional check. - health-live (REASONED; docs): Liveliness/liveness and readiness are unauthenticated; their success proves no inference authentication. - health-paid (REASONED; docs): /health is authenticated with key auth and runs potentially paid model probes; keep it private. - docs (REASONED; source, docs): Docs are public by default; clear DOCS_URL/REDOC_URL/OPENAPI_URL and set NO_DOCS/NO_REDOC/NO_OPENAPI in actual startup environment; explicit URLs win. - metrics-proxy (REASONED; metrics, docs): Proxy-port /metrics defaults authenticated, confirmed in v1.85.0; require_auth_for_metrics_endpoint=false reopens it. - metrics-port (REASONED; docs): PROMETHEUS_METRICS_PORT can create a separate unauthenticated listener; proxy-port auth does not cover it, so isolate collectors. - passthrough-auth (REASONED; docs, source): Docs call pass-through auth Enterprise, but pinned main attaches auth without a licence gate; verify the installed release. - passthrough-authorization (REASONED; source): Non-admin callers also need allowed_passthrough_routes metadata; allowed_routes alone is insufficient and normal metadata provisioning is Enterprise-gated. - passthrough-policy (REASONED; docs, source): Optional forwarder pins target, auth, GET-only, no subpaths and no wholesale incoming-header forwarding; omit unused forwarders. - destinations (REASONED; source): Pin api_base/target and control who changes them; caller api_base/base_url overrides still need trusted validation and independent egress controls. - message-logging (REASONED; docs): turn_off_message_logging suppresses content in supported integrations while retaining operational metadata; arbitrary callbacks need separate review. - spend-logging (REASONED; source): Either store_prompts_in_spend_logs or STORE_PROMPTS_IN_SPEND_LOGS enables payload storage; YAML false does not override environment true. - debug-logging (REASONED; docs): Avoid detailed_debug and LITELLM_LOG=DEBUG; inspect process, error, callback and proxy logs with canaries and positive log controls. - verify-auth (REASONED; docs, source): Probe backend directly: absent/wrong keys must get 401 and valid key a model list; public proxy responses alone cannot prove backend auth. - verify-network (REASONED; docs, cli): Inspect every listener and actual external backend reachability; refusal must be attributable to the intended remote path, not DNS/local errors. - verify-spend (REASONED; docs): Use bounded uncached provider calls; exhaust key and aggregate team budgets separately with successful controls and record overshoot. - verify-admin (REASONED; docs, source): Application key must fail a valid management request that operator credentials complete; licensed/unlicensed admin-route cases are separate. - verify-forwarding (REASONED; docs, source): Require anonymous refusal and permitted authenticated GET; wrong method/subpath must not reach the controlled upstream. - verify-egress (REASONED; source): Compare api_base and base_url overrides separately against a controlled sink; no contact plus working pinned destination establishes the intended boundary. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "7387fed7b27ae09d4fe2e2aee7e98347ae2f6366ea0b63f192b2562863424a15", "components": { "sso": { "name": "LiteLLM SSO allowance", "basis": "v1.76.0", "sources": { "s683431f972f9": "https://docs.litellm.ai/docs/proxy/admin_ui_sso" } }, "metrics": { "name": "LiteLLM metrics default", "basis": "v1.85.0", "sources": { "s7ae2561db315": "https://raw.githubusercontent.com/BerriAI/litellm/v1.85.0/litellm/__init__.py" } }, "cli": { "name": "LiteLLM CLI", "basis": "v1.102.1", "sources": { "s2c55655e39fd": "https://github.com/BerriAI/litellm/blob/v1.102.1/litellm/proxy/proxy_cli.py#L666-L667" } }, "docs": { "name": "LiteLLM documentation", "basis": "unknown", "sources": { "s135f0dce83d9": "https://docs.litellm.ai/docs/proxy/virtual_keys", "s66efd69f5587": "https://docs.litellm.ai/docs/proxy/master_key_rotations", "sf9c1840fffc9": "https://docs.litellm.ai/docs/proxy/config_settings", "sfe517a7be891": "https://docs.litellm.ai/docs/proxy/access_control", "s038950d49558": "https://docs.litellm.ai/docs/proxy/key_auth_arch", "seb5042b85a02": "https://docs.litellm.ai/docs/proxy/users", "sdc6aeba6f388": "https://docs.litellm.ai/docs/proxy/team_budgets", "s26fc2080e183": "https://docs.litellm.ai/docs/proxy/redis_requirements", "sdbe6ab7362a0": "https://docs.litellm.ai/docs/proxy/cli", "sc420b1851bf6": "https://docs.litellm.ai/docs/proxy/ip_address", "sb980affae79d": "https://docs.litellm.ai/docs/proxy/ui", "se991dc6f2424": "https://docs.litellm.ai/docs/proxy/security_best_practices", "sb3839a335288": "https://docs.litellm.ai/docs/proxy/public_routes", "sa6aaab9c250a": "https://docs.litellm.ai/docs/proxy/health", "sf84e8bdc2a9c": "https://docs.litellm.ai/docs/proxy/prometheus", "s629597256553": "https://docs.litellm.ai/docs/proxy/pass_through", "s1fceb11a6977": "https://docs.litellm.ai/docs/proxy/logging" } }, "source": { "name": "LiteLLM source", "basis": "6ef7b86748118ceecd95271727c2fa167ce55fec", "sources": { "s7960d883e823": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/proxy_server.py", "sbe6fa0b81017": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/user_api_key_auth.py", "s6dca7e865d4f": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/_types.py", "s801396dd889f": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/management_endpoints/key_management_endpoints.py", "s6632dfcab93a": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/route_checks.py", "scf988bc26d27": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/utils.py", "s4dac61a60fa2": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py", "sd13121968bd0": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/management_endpoints/common_utils.py", "s2260325bd6b8": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/route_llm_request.py", "sc93fb1082e0c": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/main.py", "s6ce39f96b246": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/spend_tracking/spend_tracking_utils.py" } } }, "claims": { "master-file": {"text": "Keep operator-only master_key in protected config.yaml, not argv/exported environment; YAML wins over environment and remains plaintext at rest.", "components": ["docs"], "sources": ["docs:s135f0dce83d9", "docs:s66efd69f5587"], "status": "REASONED"}, "startup": {"text": "Current startup refuses missing, empty, whitespace-only or sk-1234 master keys; no first release is asserted.", "components": ["docs", "source"], "sources": ["docs:s66efd69f5587", "source:s7960d883e823"], "status": "REASONED"}, "env-override": {"text": "LITELLM_DANGEROUSLY_PERMIT_WEAK_OR_UNSET_MASTER_KEY=true independently allows weak/keyless startup; retain refusal in production.", "components": ["docs", "source"], "sources": ["docs:s66efd69f5587", "source:s7960d883e823"], "status": "REASONED"}, "yaml-override": {"text": "general_settings.dangerously_permit_weak_or_unset_master_key=true independently allows weak/keyless startup.", "components": ["docs", "source"], "sources": ["docs:s66efd69f5587", "source:s7960d883e823"], "status": "REASONED"}, "database": {"text": "Virtual-key generation requires PostgreSQL via DATABASE_URL or general_settings.database_url, even with model storage disabled.", "components": ["docs"], "sources": ["docs:s135f0dce83d9", "docs:sf9c1840fffc9"], "status": "REASONED"}, "virtual-keys": {"text": "Issue separate revocable application keys; default Authorization header is configurable via litellm_key_header_name.", "components": ["docs"], "sources": ["docs:s135f0dce83d9"], "status": "REASONED"}, "salt": {"text": "Stored virtual keys are hashed; database provider credentials are encrypted with LITELLM_SALT_KEY, falling back to master key; preserve the salt.", "components": ["docs"], "sources": ["docs:sf9c1840fffc9"], "status": "REASONED"}, "model-storage": {"text": "Leave store_model_in_db false unless required; protect database credentials, backups and plaintext configuration.", "components": ["docs"], "sources": ["docs:sf9c1840fffc9"], "status": "REASONED"}, "ownership": {"text": "Use non-admin owners with team membership; keys inherit owner authority and master keys bypass application restrictions.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:sbe6fa0b81017", "docs:sfe517a7be891"], "status": "REASONED", "verify": [2]}, "key-models": {"text": "Explicit key and team model lists intersect; models: [] means all models, not none.", "components": ["docs"], "sources": ["docs:s038950d49558"], "status": "REASONED", "verify": [2]}, "key-budget": {"text": "max_budget is USD and defaults null, with no key-level cap; budget_duration resets spend and does not expire the key.", "components": ["docs", "source"], "sources": ["docs:seb5042b85a02", "source:s6dca7e865d4f"], "status": "REASONED", "verify": [2]}, "expiry": {"text": "duration controls lifetime separately; compare finite-key success before expiry/refusal afterward against an unexpired control.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:s6dca7e865d4f"], "status": "REASONED", "verify": [2]}, "rpm": {"text": "rpm_limit limits requests per minute; test its own window and successful under-limit control across workers.", "components": ["docs", "source"], "sources": ["docs:seb5042b85a02", "source:s6dca7e865d4f"], "status": "REASONED", "verify": [2]}, "tpm": {"text": "tpm_limit limits tokens per minute; RPM results do not demonstrate this limit.", "components": ["docs", "source"], "sources": ["docs:seb5042b85a02", "source:s6dca7e865d4f"], "status": "REASONED", "verify": [2]}, "parallel": {"text": "max_parallel_requests limits concurrent requests; overlap a bounded workload to distinguish it from rate limits.", "components": ["docs", "source"], "sources": ["docs:seb5042b85a02", "source:s6dca7e865d4f"], "status": "REASONED", "verify": [2]}, "soft-budget": {"text": "soft_budget triggers configured alerts without blocking spending.", "components": ["docs", "source"], "sources": ["docs:seb5042b85a02", "source:s6dca7e865d4f"], "status": "REASONED"}, "teams": {"text": "POST team/new and team/update set shared model/spend/throughput limits; attach every relevant key and add the intended owner with team/member_add.", "components": ["docs", "source"], "sources": ["docs:sdc6aeba6f388", "docs:seb5042b85a02", "source:s6dca7e865d4f"], "status": "REASONED", "verify": [2]}, "shared-state": {"text": "Multiple workers, even in one container, require shared Redis for limits/revocation; configure router Redis and proxy caching, not variables alone.", "components": ["docs"], "sources": ["docs:s26fc2080e183"], "status": "REASONED"}, "cache": {"text": "The shown Redis cache configuration also caches responses; absent content logs do not imply absent cached content.", "components": ["docs"], "sources": ["docs:s26fc2080e183", "docs:sf9c1840fffc9"], "status": "REASONED"}, "fail-closed": {"text": "fail_closed_budget_enforcement=true returns 503 when spend cannot be verified; restore dependencies and confirm permitted work.", "components": ["docs"], "sources": ["docs:seb5042b85a02"], "status": "REASONED", "verify": [2]}, "reservation": {"text": "Keep reservation enabled; batch/unestimable costs and delayed or concurrent charges mean budgets are not absolute provider-spend caps.", "components": ["docs"], "sources": ["docs:seb5042b85a02"], "status": "REASONED"}, "delete": {"text": "POST /key/delete permanently revokes the listed keys; removing application configuration is not revocation.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:s801396dd889f"], "status": "REASONED", "verify": [2]}, "block": {"text": "POST /key/block suspends and /key/unblock restores; compare each operation on every worker with replacement-key positive controls.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:s801396dd889f", "docs:s26fc2080e183"], "status": "REASONED", "verify": [2]}, "rotation": {"text": "In-place /key/regenerate is Enterprise-gated; otherwise generate restricted replacement, deploy/test it, then delete old key while retaining team budget.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:s801396dd889f"], "status": "REASONED"}, "bind": {"text": "CLI defaults 0.0.0.0:4000; use --host 127.0.0.1 --port 4000 --config config.yaml behind TLS.", "components": ["docs", "cli"], "sources": ["docs:sdbe6ab7362a0", "cli:s2c55655e39fd"], "status": "REASONED", "verify": [1]}, "ip-filter": {"text": "allowed_ips is Enterprise; otherwise ingress filtering must be enforced independently.", "components": ["docs"], "sources": ["docs:sc420b1851bf6"], "status": "REASONED"}, "ui-login": {"text": "UI_USERNAME defaults admin; without UI_PASSWORD the UI accepts the master key; remove fallback with disable_env_credential_login after personal login works.", "components": ["docs"], "sources": ["docs:sb980affae79d"], "status": "REASONED", "verify": [2]}, "ui-cookie": {"text": "Set PROXY_BASE_URL to the actual HTTPS origin for Secure UI cookies and test in a fresh browser.", "components": ["docs"], "sources": ["docs:se991dc6f2424"], "status": "REASONED", "verify": [2]}, "ui-disable": {"text": "DISABLE_ADMIN_UI=true removes an unused UI; authorized inference must still work.", "components": ["docs"], "sources": ["docs:sb980affae79d"], "status": "REASONED", "verify": [2]}, "sso": {"text": "SSO is free for up to five users from v1.76.0; more require Enterprise and bootstrap credentials still need removal.", "components": ["sso"], "sources": ["sso:s683431f972f9"], "status": "REASONED"}, "mfa": {"text": "Human UI MFA belongs at the fronting identity layer.", "components": ["docs", "sso"], "sources": ["docs:se991dc6f2424", "sso:s683431f972f9"], "status": "REASONED"}, "routes": {"text": "allowed_routes admits named paths and slash descendants, not exact path/method pairs; narrow at ingress where required.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:s6632dfcab93a"], "status": "REASONED"}, "admin-routes": {"text": "admin_only_routes uses exact matches, not /key/*; without Enterprise source logs an error and skips this additional check.", "components": ["docs", "source"], "sources": ["docs:sb3839a335288", "source:s6632dfcab93a"], "status": "REASONED", "verify": [2]}, "health-live": {"text": "Liveliness/liveness and readiness are unauthenticated; their success proves no inference authentication.", "components": ["docs"], "sources": ["docs:sa6aaab9c250a"], "status": "REASONED", "verify": [2]}, "health-paid": {"text": "/health is authenticated with key auth and runs potentially paid model probes; keep it private.", "components": ["docs"], "sources": ["docs:sa6aaab9c250a"], "status": "REASONED"}, "docs": {"text": "Docs are public by default; clear DOCS_URL/REDOC_URL/OPENAPI_URL and set NO_DOCS/NO_REDOC/NO_OPENAPI in actual startup environment; explicit URLs win.", "components": ["source", "docs"], "sources": ["source:scf988bc26d27", "docs:sf9c1840fffc9"], "status": "REASONED", "verify": [2]}, "metrics-proxy": {"text": "Proxy-port /metrics defaults authenticated, confirmed in v1.85.0; require_auth_for_metrics_endpoint=false reopens it.", "components": ["metrics", "docs"], "sources": ["metrics:s7ae2561db315", "docs:sf84e8bdc2a9c", "docs:sf9c1840fffc9"], "status": "REASONED", "verify": [2]}, "metrics-port": {"text": "PROMETHEUS_METRICS_PORT can create a separate unauthenticated listener; proxy-port auth does not cover it, so isolate collectors.", "components": ["docs"], "sources": ["docs:sf84e8bdc2a9c"], "status": "REASONED", "verify": [2]}, "passthrough-auth": {"text": "Docs call pass-through auth Enterprise, but pinned main attaches auth without a licence gate; verify the installed release.", "components": ["docs", "source"], "sources": ["docs:s629597256553", "source:s4dac61a60fa2"], "status": "REASONED"}, "passthrough-authorization": {"text": "Non-admin callers also need allowed_passthrough_routes metadata; allowed_routes alone is insufficient and normal metadata provisioning is Enterprise-gated.", "components": ["source"], "sources": ["source:s6632dfcab93a", "source:s6dca7e865d4f", "source:sd13121968bd0"], "status": "REASONED"}, "passthrough-policy": {"text": "Optional forwarder pins target, auth, GET-only, no subpaths and no wholesale incoming-header forwarding; omit unused forwarders.", "components": ["docs", "source"], "sources": ["docs:s629597256553", "source:s4dac61a60fa2"], "status": "REASONED", "verify": [2]}, "destinations": {"text": "Pin api_base/target and control who changes them; caller api_base/base_url overrides still need trusted validation and independent egress controls.", "components": ["source"], "sources": ["source:s2260325bd6b8", "source:sc93fb1082e0c"], "status": "REASONED", "verify": [2]}, "message-logging": {"text": "turn_off_message_logging suppresses content in supported integrations while retaining operational metadata; arbitrary callbacks need separate review.", "components": ["docs"], "sources": ["docs:s1fceb11a6977"], "status": "REASONED", "verify": [2]}, "spend-logging": {"text": "Either store_prompts_in_spend_logs or STORE_PROMPTS_IN_SPEND_LOGS enables payload storage; YAML false does not override environment true.", "components": ["source"], "sources": ["source:s6ce39f96b246"], "status": "REASONED", "verify": [2]}, "debug-logging": {"text": "Avoid detailed_debug and LITELLM_LOG=DEBUG; inspect process, error, callback and proxy logs with canaries and positive log controls.", "components": ["docs"], "sources": ["docs:sdbe6ab7362a0", "docs:sf9c1840fffc9", "docs:s1fceb11a6977"], "status": "REASONED", "verify": [2]}, "verify-auth": {"text": "Probe backend directly: absent/wrong keys must get 401 and valid key a model list; public proxy responses alone cannot prove backend auth.", "components": ["docs", "source"], "sources": ["docs:s135f0dce83d9", "source:sbe6fa0b81017"], "status": "REASONED", "verify": [1]}, "verify-network": {"text": "Inspect every listener and actual external backend reachability; refusal must be attributable to the intended remote path, not DNS/local errors.", "components": ["docs", "cli"], "sources": ["docs:sdbe6ab7362a0", "cli:s2c55655e39fd"], "status": "REASONED", "verify": [1]}, "verify-spend": {"text": "Use bounded uncached provider calls; exhaust key and aggregate team budgets separately with successful controls and record overshoot.", "components": ["docs"], "sources": ["docs:seb5042b85a02", "docs:sdc6aeba6f388"], "status": "REASONED", "verify": [2]}, "verify-admin": {"text": "Application key must fail a valid management request that operator credentials complete; licensed/unlicensed admin-route cases are separate.", "components": ["docs", "source"], "sources": ["docs:sfe517a7be891", "source:s6632dfcab93a"], "status": "REASONED", "verify": [2]}, "verify-forwarding": {"text": "Require anonymous refusal and permitted authenticated GET; wrong method/subpath must not reach the controlled upstream.", "components": ["docs", "source"], "sources": ["docs:s629597256553", "source:s4dac61a60fa2"], "status": "REASONED", "verify": [2]}, "verify-egress": {"text": "Compare api_base and base_url overrides separately against a controlled sink; no contact plus working pinned destination establishes the intended boundary.", "components": ["source"], "sources": ["source:s2260325bd6b8", "source:sc93fb1082e0c"], "status": "REASONED", "verify": [2]} } } ```` ====================================================================== ==> model-servers.md ====================================================================== # Model servers: llama.cpp, vLLM, TGI, SGLang, Triton, and LM Studio llama.cpp, vLLM, TGI, SGLang, Triton, LM Studio and text-generation-webui run AI models and answer prompts over a network API. This guide helps your AI assistant configure each securely, including a private bind, a per-client API key checked natively or by a proxy, TLS in front, and public share flags off. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/model-servers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against llama.cpp (pinned to a commit), vLLM v0.30.0, TGI launcher/image v3.3.7, SGLang v0.5.20, and curl minimum write-out version 7.75.0; 7 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 75 claims: 3 demonstrated by a check that ran and 72 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Self-hosted model servers follow the [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) pattern: exposing one means someone else's prompts run on your GPU. Most default to local use, but vLLM, TGI, and Triton bind to `0.0.0.0` out of the box, and Triton enables no authentication by default (its native controls, gRPC mutual TLS and shared-secret restricted APIs, do not replace the gateway). Keep every server on loopback or a private network, require an API key where the server supports one, and terminate TLS in front. LocalAI is an OpenAI-compatible model server too, but its bind and authentication controls are documented in [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) rather than here, so the facts live in one place. ## llama.cpp (llama-server) `llama-server` listens on `127.0.0.1:8080` by default; keep that bind. Require a key, and hand it to the server in a file rather than on the command line: a key expanded into `--api-key` sits in argv, where `ps` and `/proc//cmdline` show it to other local accounts for the server's whole lifetime, and quoting does not change that. At the pinned llama.cpp commit, `--api-key-file` reads the keys from a file, one per line, and the pinned README documents no stdin input for them. Create the file once, as the account that runs `llama-server`: ```bash ( set -eC umask 077 mkdir -p -- "$HOME/.config/llama-server" chmod 700 -- "$HOME/.config/llama-server" if [ -e "$HOME/.config/llama-server/api-keys" ] || [ -L "$HOME/.config/llama-server/api-keys" ]; then echo 'api-keys already exists in ~/.config/llama-server; nothing written'; exit 2 fi command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on ~/.config/llama-server cannot be ruled out; install the acl package; nothing written'; exit 2; } case "$(getfacl -d -c -p -- "$HOME/.config/llama-server"; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'the directory ~/.config/llama-server has a default ACL, which a new file inherits in place of umask 077; nothing written'; exit 2 ;; *) echo 'getfacl could not read the ACL of ~/.config/llama-server; nothing written'; exit 2 ;; esac openssl rand -hex 32 > "$HOME/.config/llama-server/api-keys" case "$(ls -ln -- "$HOME/.config/llama-server/api-keys")" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'api-keys is not mode 0600, so other accounts may read it; delete ~/.config/llama-server/api-keys and do not use the key in it'; exit 2 ;; esac ) ``` `umask 077` applies before anything is written, so the directory is created mode `0700` and the file mode `0600`, unless the directory carries a default ACL, as it does when `~/.config` has one for `mkdir` to copy: a new file then takes its permissions from that ACL in place of the umask, so the key can be readable by the group or by other accounts, and `chmod 700` does not remove a default ACL. So before it writes, the block runs `getfacl -d -c -p` on the directory, which prints the directory's default ACL and nothing else, and refuses when that prints anything, when `getfacl` fails, or when `getfacl` is not installed; remove a default ACL you do not need with `setfacl -k ~/.config/llama-server` and run the block again. A missing `getfacl` fails closed because the block then cannot tell, before the key exists, whether the file would be private, and a key written to a readable file has to be thrown away; `getfacl` comes from the `acl` package, and where it is not installed the block refuses to run. After the write the block reads the file's permission field from `ls -ln` and fails unless it is exactly `-rw-------` followed by a space or a `.`; any other field fails the check, including the `+`-marked field a named ACL entry produced in the recorded runs. If the check fails, delete the file and do not use the key in it. The other writers in this guide, and those in [chat-uis.md](https://github.com/jposluns/secureconfig/blob/main/chat-uis.md) and [image-gen-uis.md](https://github.com/jposluns/secureconfig/blob/main/image-gen-uis.md), make the same two checks. Without these checks, each of this guide's five writer blocks wrote its file mode `0644` when `~/.config` carried a default ACL (run in a scratch home directory with bash 5.3.9, acl 2.3.2 and `openssl` stubbed). The block makes the directory mode `0700` first and then refuses when `api-keys` already exists in any form (a regular file, a FIFO, or a symlink, dangling or not), so running the block again cannot replace a key your clients already hold; `set -C` also refuses an existing regular file, and the explicit check handles the other forms. As with the other writers in this guide, these checks hold only in owner-only directories no other account can replace. If `openssl` fails, the block leaves an empty file behind: delete it before running the block again, and the launch block below refuses to start on it. The key goes from `openssl` straight into the file, so it never passes through a shell variable or a command line. Add one line per further client key; at the pinned commit a line that begins with `#` is a comment. Clients running as that account on this host can read the same file; give remote clients their copy through your secret store ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). The file holds the key in plaintext at rest, readable by that account, by root, and by any backup that copies it, so keep it and its backups out of source control. Start the server from the file: ```bash ( { unset -n LLAMA_API_KEY LLAMA_ARG_API_KEY_FILE && unset -v LLAMA_API_KEY LLAMA_ARG_API_KEY_FILE; } 2>/dev/null || { echo 'cannot clear LLAMA_API_KEY or LLAMA_ARG_API_KEY_FILE in this shell; not starting'; exit 2; } set -- "${!LLAMA_ARG_@}" [ "$#" -eq 0 ] || { printf 'LLAMA_ARG_* variables set in this shell:'; printf ' %s' "$@"; printf '; unset them first; not starting\n'; exit 2; } grep -q '^[^#[:space:]]' "$HOME/.config/llama-server/api-keys" || { echo 'no key line in ~/.config/llama-server/api-keys; not starting'; exit 2; } llama-server -m model.gguf --api-key-file "$HOME/.config/llama-server/api-keys" ) ``` The pinned README also lists `LLAMA_API_KEY` as an environment input for `--api-key` and `LLAMA_ARG_API_KEY_FILE` for `--api-key-file`. The block clears both names first and refuses to start when it cannot (a readonly name in your shell), so an inherited value can neither become a key nor point the server at a different file. It then refuses to start while any other `LLAMA_ARG_*` variable is set in the calling shell, naming each: the pinned README gives most llama-server options an `LLAMA_ARG_*` environment input, `LLAMA_ARG_HOST` for `--host` among them, so an inherited one would start the server differently from what the block shows, on another interface included. Unset them, or start from a clean shell. A key passed through `LLAMA_API_KEY` would stay out of argv but remain readable through `/proc//environ` by the same account and by root for the server's whole lifetime; the file avoids that channel. The block refuses a file with no line that starts with a key character, so a file of blank lines and `#` comments alone is refused, but it cannot tell a real key from other text, and what llama-server does with a file that holds no key line was not checked at the pinned commit. Confirm the key is enforced by probing the llama.cpp backend DIRECTLY on its own host and port from the trusted network (not through the proxy, which returns its own 401/200 regardless): a no-key request must be refused there and a keyed request accepted. Native TLS exists when the binary is built with OpenSSL (`-DLLAMA_OPENSSL=ON`): `--ssl-key-file` and `--ssl-cert-file` take PEM files ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) or [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md)). A reverse proxy per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)/[caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) is the alternative when your build lacks SSL support. ## vLLM (OpenAI-compatible server) vLLM's server requires an API key when one is set: at both pinned commits `--api-key` sets it, and when that flag is unset the server falls back to the `VLLM_API_KEY` environment variable. Flags move between vLLM releases, so confirm `--api-key` and `--config` in `vllm serve --help` on your installed version. Do not put the key on the command line, where `ps` and `/proc//cmdline` show it to other local accounts for the server's whole lifetime. At the pinned commits `--config` reads the server options from a YAML file inside the process, so put the key in a file only the account running vLLM can read. Create it once, as that account: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -eC umask 077 mkdir -p -- "$HOME/.config/vllm" chmod 700 -- "$HOME/.config/vllm" if [ -e "$HOME/.config/vllm/server.yaml" ] || [ -L "$HOME/.config/vllm/server.yaml" ]; then echo 'server.yaml already exists in ~/.config/vllm; nothing written'; exit 2 fi command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on ~/.config/vllm cannot be ruled out; install the acl package; nothing written'; exit 2; } case "$(getfacl -d -c -p -- "$HOME/.config/vllm"; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'the directory ~/.config/vllm has a default ACL, which a new file inherits in place of umask 077; nothing written'; exit 2 ;; *) echo 'getfacl could not read the ACL of ~/.config/vllm; nothing written'; exit 2 ;; esac set -- "$(openssl rand -hex 32)" [ "${#1}" -eq 64 ] || { echo 'key generation failed; nothing written'; exit 2; } case "$1" in *[!0123456789abcdef]*) echo 'key generation failed; nothing written'; exit 2 ;; esac printf 'api-key: "%s"\n' "$1" > "$HOME/.config/vllm/server.yaml" case "$(ls -ln -- "$HOME/.config/vllm/server.yaml")" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'server.yaml is not mode 0600, so other accounts may read it; delete ~/.config/vllm/server.yaml and do not use the key in it'; exit 2 ;; esac ) ``` The block makes the directory mode `0700` first, then refuses, before it generates a key, when `server.yaml` already exists in any form (a regular file, a FIFO, or a symlink, dangling or not), so a rerun cannot replace a key your clients already hold. It writes nothing unless the generated key is 64 hex characters. `umask 077` creates the directory mode `0700` and the file mode `0600`, and `set -C` still refuses to overwrite an existing regular file. As in the llama.cpp block, it also refuses, before it generates a key, a directory with a default ACL, a failed `getfacl` or a missing one, and after the write it fails unless `ls -ln` shows the file as `-rw-------`; delete a file that check refuses and do not use its key. These checks run before the write, not atomically with it, so they hold only while no other account can replace the directory or any directory above it: run the block in owner-only directories you control, such as your own home directory, never under a path another account can write to. The key passes only through the subshell's positional parameters and the builtin `printf`, never a command line; the block clears inherited traps first because a DEBUG, RETURN or ERR trap from your shell could otherwise read it, and it assumes a clean shell. Start the server with only non-secret values on its command line, substituting your model inside the quotes, and do not add `--api-key` there, which would put the value back in argv: ```bash ( { unset -n VLLM_API_KEY VLLM_USE_RUST_FRONTEND && unset -v VLLM_API_KEY VLLM_USE_RUST_FRONTEND; } 2>/dev/null || { echo 'cannot clear VLLM_API_KEY or VLLM_USE_RUST_FRONTEND in this shell; not starting'; exit 2; } grep -Eq '^api-key: "[0123456789abcdef]{64}"$' "$HOME/.config/vllm/server.yaml" || { echo 'no generated api-key line in ~/.config/vllm/server.yaml; not starting'; exit 2; } vllm serve 'REPLACE_WITH_MODEL' --host 127.0.0.1 --config "$HOME/.config/vllm/server.yaml" ) ``` The launch block clears `VLLM_API_KEY` first and refuses to start when it cannot (a readonly name in your shell), so an inherited value cannot stand in for the key, and it refuses a file with no generated `api-key` line. It clears `VLLM_USE_RUST_FRONTEND` the same way, because with that opt-in set the pinned server starts a Rust frontend process and hands it the non-default arguments, `api_key` included, as a JSON `--args-json` value on that process's command line. Keep `--config` and the path as two words: the pinned parser expands the file only when `--config` is an argument of its own, and it accepts `--config=FILE` without reading the file, so that server would start with no key. `--host 127.0.0.1` stays on the command line because it is not secret. vLLM merges the file's values into its argument list inside the Python process, and API server processes it starts receive their arguments through Python's `spawn` pipe rather than a command line, so the key stays out of `/proc//cmdline` and `ps`. The HTTP server's startup log prints the non-default arguments with `api_key` redacted, but `vllm serve --grpc` logs its whole argument set unredacted, the key included, so protect that log if you use `--grpc`. The key remains in process memory and in the file (plaintext at rest, readable by that account and by root, so keep it and its backups out of source control). Clients read the key from the file's `api-key` line; give remote clients their copy through your secret store ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). A key passed through `VLLM_API_KEY` would stay out of argv but remain readable through `/proc//environ` by the same account and by root for the server's whole lifetime, and in any process that inherits it; the file avoids that channel. This was read in the pinned source (cited in Sources). The pinned parser class was also run on its own, outside vLLM, with vLLM's logger stubbed and a minimal `serve` parser in place of vLLM's own: with `--config FILE` it turned the generated file's `api-key` line into a one-element key list, and with `--config=FILE` it left the key unset. No vLLM server was run. The key does not cover the whole server. vLLM's own security page states that at v0.30.0 it authenticates only the `/v1`, `/v2`, `/inference`, and `/cohere` path prefixes, and lists `/invocations`, the SageMaker-compatible route, as requiring no key while reaching the same inference capability as the protected `/v1` routes. The same security page lists the profiler routes `/start_profile` and `/stop_profile`, available when profiling is enabled via `--profiler-config`, as likewise unauthenticated at v0.30.0, and a plugin route outside those prefixes as unauthenticated unless the plugin enforces its own check. vLLM says plainly not to rely on the key alone. Allowlist only the routes your application needs at the proxy and refuse everything else there, `/invocations` included, rather than assuming the key covers the surface. The vLLM server binds every interface by default: `vllm serve` leaves `--host` unset, which listens on `0.0.0.0` (the startup log shows `http://0.0.0.0:8000`), so pass `--host 127.0.0.1` to keep it on loopback. The vLLM server can terminate TLS natively (`--ssl-keyfile`, `--ssl-certfile`, and `--ssl-ca-certs`, passed through to uvicorn), but fronting it with a TLS proxy or tunnel is the recommended pattern; either way keep the server itself on loopback or a private network. ### Multi-node and data-parallel deployments At v0.30.0, `--host` controls the frontend listener, not all the engine's network traffic. Multi-node deployments must run on an isolated network with firewall rules that admit internal traffic only from trusted cluster members. The HTTP API key and frontend TLS do not authenticate or encrypt the internal TCPStore, ZMQ or collective-communication channels. A private IP alone does not establish that isolation. When Ray is the backend, apply [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md) as well; vLLM treats the Ray cluster as one trust domain. The listeners depend on the backend and topology: - vLLM's security page warns that PyTorch TCP initialization creates a TCPStore listening on all interfaces by default; do not infer its bind from the advertised master address. `StatelessProcessGroup.create()` explicitly binds the supplied address on rank zero when no listening socket is supplied; other stateless initialization paths can delegate to ordinary rendezvous. Multi-node multiprocessing uses `--master-addr` and `--master-port` (default port 29501). Data-parallel group initialization can allocate additional ports, and Ray V2 creates a store on port zero. vLLM creates Gloo and device-backend groups; their transport sockets depend on PyTorch and the selected backend and must be inventoried at runtime. - Engine clients use unauthenticated ZMQ sockets. The multiprocessing DP launch path uses a startup-scoped TCP handshake on `--data-parallel-address` and `--data-parallel-rpc-port` (default port 29550) when engines are remote or elastic expert parallelism is enabled; otherwise the handshake uses IPC. The Ray DP launch path bypasses that handshake constructor. Request/result channels use dynamic TCP ports when clients need remote engines; co-located channels normally use IPC, with an elastic expert-parallelism exception. When online DP needs a coordinator, its frontend and engine channels use IPC or dynamic TCP ports according to locality. The DP address defaults to loopback for the multiprocessing backend unless a master address is supplied; Ray uses the detected node IP. Worker message queues add a dynamic TCP listener only when remote readers exist, using vLLM's detected node IP for multiprocessing and Ray's node IP for Ray V2. `--headless` removes the HTTP frontend, not the internal channels required by that topology. - KV-transfer listeners are connector-specific and opt-in. The NIXL handshake defaults to `localhost`, port 5600 plus the data-parallel index, and starts when handshake metadata is supplied. Mooncake's producer functionality, including `kv_both`, starts a bootstrap server on `0.0.0.0`, port 8998, at the designated TP/PP/DP rank, and producer worker side channels use the detected node IP and dynamic ports. Consumer-only workers do not start those producer listeners. These vLLM control paths add no peer authentication; this is not an audit of the native transfer libraries. Audit the selected connector and its transfer library; there is no universal KV-transfer bind switch. - Optional `--grpc` replaces HTTP with an unauthenticated, unencrypted gRPC server on `--host`/`--port`, default `0.0.0.0:8000`. The security page's `--grpc-port` description does not match this release's CLI. `--data-parallel-multi-port-external-lb` also adds a health supervisor after the child servers become ready, without an API-key check, on `--host`, default port 9256 (`--data-parallel-supervisor-port`), alongside one API port per local rank. The supervisor also accepts the frontend TLS options. Set `VLLM_HOST_IP` to each node's own isolated-network IP; automatic detection can select a public-facing interface and falls back to `0.0.0.0`. Set `--data-parallel-address` to the head node's isolated-network IP and use the same `--data-parallel-rpc-port` on participating nodes. For multi-node multiprocessing, set `--master-addr` to the intended master IP too. `VLLM_PORT` is a starting point for the open-port allocator, not a firewall port range: sockets that bind port zero still receive dynamic ports. `VLLM_DP_MASTER_IP` and `VLLM_DP_MASTER_PORT` populate the environment-driven DP fallback, for example offline SPMD; they are not substitutes for the serving flags. Setting `VLLM_DP_MASTER_PORT` also reserves ten ports starting at that value in the common open-port allocator, so its effects are not confined to offline DP. NIXL has separate `VLLM_NIXL_SIDE_CHANNEL_HOST` and `VLLM_NIXL_SIDE_CHANNEL_PORT` settings; `VLLM_MOONCAKE_BOOTSTRAP_PORT` changes Mooncake's bootstrap port, not its wildcard bind. Single-GPU serving normally uses a file store and local engine IPC in this release, with a TCP-store exception for ROCm AITER custom all-reduce. This is not a guarantee that no other socket listens: vLLM still creates Gloo groups. Inventory the running processes and test the firewall on single-host deployments too. ## Hugging Face Text Generation Inference (TGI) `text-generation-launcher` listens on `0.0.0.0:3000` by default (as of v3.3.7; `--hostname`, env `HOSTNAME`; `--port`, env `PORT`). The image built from the repository's main `Dockerfile` sets `PORT=80`, so a bare container listens on port 80 instead, and on every interface: Docker sets `HOSTNAME` to the container's hostname (by default its short ID), which is not an IP address, and the router then falls back to `0.0.0.0`. Bind it to loopback (substitute your model ID inside the quotes in the block below), or publish nothing from the container network except the proxy: Lifecycle note, as of September 2026: the TGI repository is in maintenance mode and was archived on 2026-03-21 (read-only). Hugging Face recommends vLLM, SGLang, or local engines such as llama.cpp going forward. A server that no longer receives fixes belongs behind the same controls as any other, and on a migration list. ```bash text-generation-launcher --model-id 'REPLACE_WITH_MODEL_ID' --hostname 127.0.0.1 --port 3000 ``` The launcher reference lists `--api-key` (env `API_KEY`) without describing it. At v3.3.7 the launcher takes that value from the flag or from `API_KEY` and then starts the router with it as a `--api-key` argument, so however you supply it, the key is readable through the router process's `ps` and `/proc//cmdline` by other local accounts while TGI runs; no launcher input avoids that, and passing it through the environment does not help. TGI's native key is a control for network clients, not for other users of the host: run it where no account you do not trust can list its processes, and enforce the bearer check at the proxy. The router source shows what it does: when set, requests to the standard inference `base_routes` must carry a matching `Authorization: Bearer ` header or receive 401, while the health, info, and metrics routes stay unauthenticated. Builds that enable the KServe (`/v2/...`) endpoints register them outside that key middleware, `/v1/models` sits outside it too, and the Vertex feature route configured through `AIP_PREDICT_ROUTE` is registered after the auth layer as well, so those paths are unauthenticated; allowlist the routes you use and enforce the bearer check at the proxy. Treat it as a second layer and enforce the bearer check at the proxy too (pattern in [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md)). The launcher has no TLS option, so front TGI per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)/[caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). The Prometheus listener (`--prometheus-port`, default 9000) is unauthenticated as well; keep it private. ## SGLang `python -m sglang.launch_server` listens on `127.0.0.1:30000` by default (`--host`, `--port`; values as of v0.5.20); keep that bind. `--api-key` sets the key the OpenAI-compatible endpoints require, and `--admin-api-key` separately protects administrative endpoints (at v0.5.20, the weight-update endpoints and `/flush_cache` among them, but not `/server_info`; see below), which then require `Authorization: Bearer `. Do not put either value on the command line: there it is readable through `ps` and `/proc//cmdline` by other local accounts for the server's whole lifetime. At v0.5.20, `--config` reads the server options from a YAML file instead, and the pinned sources document no stdin or environment input for either key, so put both keys in a file that only the account running SGLang can read. Create it once, as that account: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -eC umask 077 mkdir -p -- "$HOME/.config/sglang" chmod 700 -- "$HOME/.config/sglang" if [ -e "$HOME/.config/sglang/server.yaml" ] || [ -L "$HOME/.config/sglang/server.yaml" ]; then echo 'server.yaml already exists in ~/.config/sglang; nothing written'; exit 2 fi command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on ~/.config/sglang cannot be ruled out; install the acl package; nothing written'; exit 2; } case "$(getfacl -d -c -p -- "$HOME/.config/sglang"; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'the directory ~/.config/sglang has a default ACL, which a new file inherits in place of umask 077; nothing written'; exit 2 ;; *) echo 'getfacl could not read the ACL of ~/.config/sglang; nothing written'; exit 2 ;; esac set -- "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" [ "${#1}" -eq 64 ] || { echo 'key generation failed; nothing written'; exit 2; } [ "${#2}" -eq 64 ] || { echo 'key generation failed; nothing written'; exit 2; } case "$1$2" in *[!0123456789abcdef]*) echo 'key generation failed; nothing written'; exit 2 ;; esac printf 'api-key: "%s"\nadmin-api-key: "%s"\n' "$1" "$2" > "$HOME/.config/sglang/server.yaml" case "$(ls -ln -- "$HOME/.config/sglang/server.yaml")" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'server.yaml is not mode 0600, so other accounts may read it; delete ~/.config/sglang/server.yaml and do not use the keys in it'; exit 2 ;; esac ) ``` The block writes nothing unless both generated keys are 64 hex characters, because SGLang serves ordinary requests whenever the API key is empty, even when `--admin-api-key` is set, so an empty value would leave the OpenAI-compatible endpoints open. `umask 077` creates the directory mode `0700` and the file mode `0600`. The block makes the directory mode `0700` first and then refuses, before it generates a key, when `server.yaml` already exists in any form (a regular file, a FIFO, or a symlink, dangling or not), so a rerun cannot replace keys your clients already hold; `set -C` also refuses an existing regular file, and the explicit check handles the other forms. These checks hold only in owner-only directories no other account can replace. As in the llama.cpp block, it also refuses, before it generates the keys, a directory with a default ACL, a failed `getfacl` or a missing one, and after the write it fails unless `ls -ln` shows the file as `-rw-------`; delete a file that check refuses and do not use its keys. The keys pass only through the subshell's positional parameters and the builtin `printf`, never a command line; the block clears inherited traps first because a DEBUG, RETURN or ERR trap from your shell could otherwise read them, and it assumes a clean shell. Start the server with only non-secret values on its command line, substituting your model path inside the quotes, and do not add `--api-key` or `--admin-api-key` there, which would put the values back in argv: ```bash python -m sglang.launch_server --model-path 'REPLACE_WITH_MODEL_PATH' --config "$HOME/.config/sglang/server.yaml" ``` Clients read the API key from the file's `api-key` line; give the admin key only to the operators who use the administrative endpoints. SGLang turns the file's values into an internal argument list before parsing them; that list lives inside the Python process, not in the kernel's `/proc//cmdline`, so the keys stay out of `ps`. They remain in process memory and in the file (plaintext at rest, readable by that account and by root, so keep it and its backups out of source control), and SGLang itself hands them out. At v0.5.20 the launch path logs `server_args=` with every resolved field, both keys included, at INFO level, and `/server_info` (and its deprecated alias `/get_server_info`) returns the same fields plus `launch_command`, the argument list with the file's values merged in, to any request the ordinary API key authorizes, because it carries no admin auth level. This was read in the pinned source (cited in Sources), not run. So a holder of the API key can read the admin key. Either give the API key only to clients you would also trust with the administrative endpoints, or, if other clients must hold it, have the reverse proxy allow only the routes those clients need (which excludes `/server_info` and `/get_server_info`) and refuse every other route, the same allowlist the vLLM section prescribes; either way, protect the startup log. Native TLS exists: `--ssl-keyfile` and `--ssl-certfile` take PEM files ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md) or [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md)), `--ssl-ca-certs` names a CA bundle, and `--enable-ssl-refresh` hot-reloads renewed certificates. A reverse proxy remains the simpler choice when you already run one. ## NVIDIA Triton Inference Server `tritonserver` starts three listeners on `0.0.0.0`: HTTP on 8000, gRPC on 8001, and Prometheus metrics on 8002. No authentication is enabled by default: its optional gRPC mutual TLS and restricted-API shared secrets can cover inference as well as model control, but do not replace the gateway. NVIDIA's secure deployment guidance is that Triton is a microservice that is "not exposed directly to an untrusted network": a dedicated gateway or proxy (NGINX, Envoy, Istio, Kong are the examples given) handles authorization, access control, and encryption, and Triton "handles only trusted, validated requests". Bind each listener privately and disable the protocols you do not use: ```bash tritonserver --model-repository=/models --http-address=127.0.0.1 --grpc-address=127.0.0.1 --metrics-address=127.0.0.1 ``` `--allow-http` and `--allow-grpc` default to true; NVIDIA recommends setting either to false when not required, and `--allow-metrics` switches off the metrics listener. For gRPC, `--grpc-use-ssl` with `--grpc-server-cert` and `--grpc-server-key` enables a TLS channel, and `--grpc-use-ssl-mutual` requires client certificates. HTTP has no TLS option; the proxy provides it. `--http-restricted-api` and `--grpc-restricted-protocol` fence the API groups you name (model-repository control, and inference too if you list it) behind a shared-secret header, a useful second layer against network clients but not a substitute for the gateway. The secret is part of the flag's value (`--http-restricted-api=,:=`), and the pinned parser shows how these flags consume it: `getopt_long` reads argv, both handlers pass `optarg` to `ParseRestrictedFeatureOption`, and `ParseGenericConfigOption` extracts the value after `=` as a literal string. That path does not interpret the value as a file name or an environment-variable name. Passing a secret in these flags puts it in argv, where local command-line readers may see it through `ps` or `/proc//cmdline`; quoting does not remove it. Command-line auditing on the host (auditd `EXECVE` records, for example) can also record the value and keep it after the process exits. Under an orchestrator the same value also sits in the workload definition (a Kubernetes pod spec's `args`, for example) for everyone who can read it. A server's shared secret is neither throwaway nor short-lived, so it is no secret from other accounts on the host: do not rely on it as a control against them. Enforce authorization at the reverse proxy or gateway in front of Triton, which is the control, and treat the restricted-API value only as a check against network clients that reach Triton past it; rotate the value whenever an account you do not trust could have read it, or a command-line audit record holding it could have reached one. If you do not load and unload models at run time, leave `--model-control-mode` at its default, `none`, under which the model-control API returns an error for load and unload requests, whatever the secret. Builds with cloud endpoints add conditional listeners beyond these three: `AIP_MODE=PREDICTION` enables a Vertex AI endpoint (its port is `AIP_HTTP_PORT`, otherwise 8080), and a SageMaker endpoint may also be present, so disable the ones you do not use or add them to the bind inventory and the checks below. At the pinned commit (first shipped in v2.69.0), every restricted category defaults to unrestricted: `health`, `metadata`, `inference`, `shared-memory`, `model-config`, `model-repository`, `statistics`, `trace`, and `logging`. Model control is only one part of that surface. These defaults apply when the corresponding feature is compiled in: | Category | Exposure without a restriction | | --- | --- | | `health`, `metadata`, `inference` | Server/model health and metadata, inference, and HTTP `generate`/`generate_stream` requests need no key. | | `logging` | Read the log settings and file path; change info/warning/error logging, verbosity, and format. A request cannot change `log_file`. | | `trace` | Read global or per-model settings, including, in the default `triton` mode, the trace file path; change level, rate, count, and log frequency. A request cannot change `trace_file`. Tracing starts OFF with no file path in the default `triton` mode, so enabling it through the API fails until startup supplies a path, for example `--trace-config triton,file=/var/log/triton/trace.json`. That flag leaves the level OFF; `--trace-config level=TIMESTAMPS` enables it at startup, and an unrestricted client can enable or change it later once the configuration is valid. The settings API itself is not disabled by OFF. | | `shared-memory` | Read system/CUDA region status. Registration, unregistration, and inference use are disabled by default (`--allow-client-shm=false`); `--allow-client-shm=true` enables them. Clients can then register system shared-memory objects or CUDA IPC handles for tensor input/output and unregister regions. CUDA also requires GPU support; the protocol documentation lists no shared memory on Windows and no CUDA shared memory on Jetson. | | `model-repository` | List repository models, including unloaded models. Load/reload and unload require `--model-control-mode=explicit`; `none` (default) and `poll` do not enable those API operations. Load accepts a JSON-string `config` override and inline `file:VERSION/FILE` contents (base64 over HTTP, bytes over gRPC, with `config` required); unload can include `unload_dependents=true`. | | `statistics`, `model-config` | Read per-model/version inference statistics and model configuration. Model control mode does not protect these reads or the repository index. Statistics require a build with statistics support. | Restrictions are per category and per protocol, not a single admin switch. Repeat a flag for disjoint groups with different keys; a category cannot appear in two groups for the same protocol. This syntax example covers all nine categories with a value held by the gateway, which must not give that administrative value to ordinary inference clients: ```text --http-restricted-api=health,metadata,inference,shared-memory,model-config,model-repository,statistics,trace,logging:gateway-key=REPLACE_WITH_GATEWAY_VALUE --grpc-restricted-protocol=health,metadata,inference,shared-memory,model-config,model-repository,statistics,trace,logging:gateway-key=REPLACE_WITH_GATEWAY_VALUE ``` Replace the placeholder through your deployment's secret handling, subject to the argv exposure above. HTTP expects `gateway-key: VALUE`; gRPC expects metadata `triton-grpc-protocol-gateway-key: VALUE`, because Triton prefixes the configured key. Missing or wrong values receive HTTP 403 or gRPC `UNAVAILABLE` with a restriction message. Restricting `health` also requires the header on health probes. These rejections apply to the standard HTTP and gRPC handlers; two cloud endpoints at the pinned commit fall outside them. With the SageMaker listener enabled, a multi-model endpoint's `POST /models/{model}/invoke` runs inference with no restriction check, so gateway authorization and network isolation must cover it, or disable the SageMaker listener. The Vertex AI prediction route dispatches to other handlers named in the `X-Vertex-Ai-Triton-Redirect` request header, `metrics` among them with no restriction check, so a gateway that attaches the administrative value to Vertex prediction requests must strip or reject that header from ordinary clients. The metrics listener is separate: `GET /metrics` (also `/metrics/`) on 8002 returns Prometheus text with no authentication, and neither restricted flag covers it. Keep it private or use `--allow-metrics=false`. Keep the gateway's route allowlist narrow even when every category is restricted. ## LM Studio (local server) LM Studio's developer server is a desktop feature. The documentation addresses it at `http://localhost:1234` throughout (the port is a field in Developers Page > Server Settings), and "By default, LM Studio does not require authentication for API requests." The "Serve on Local Network" switch (or `lms server start --bind 0.0.0.0`) rebinds it to every interface; LM Studio's own note reads: "Any bind other than 127.0.0.1 exposes the server beyond localhost; we recommend enabling authentication." Leave that switch off. If another machine must reach it, first enable "Require Authentication" (LM Studio 0.4.0 or newer) and create a token under "Manage Tokens"; clients then send `Authorization: Bearer `. The server settings list no TLS option, so anything beyond the local machine goes through a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)) or an authenticated TLS proxy, never a port-forward. ## text-generation-webui One process, two surfaces: the Gradio UI (default `127.0.0.1:7860`) and, when started with `--api`, an OpenAI-compatible API (default `127.0.0.1:5000`, endpoints under `/v1`). Both default to loopback, and both start with no authentication. `--listen` rebinds to `0.0.0.0`, and it widens both surfaces at once: opening the UI to your LAN also opens the API port whenever `--api` is set. `--listen-port` moves the UI port, `--api-port` moves the API port, and `--listen-host` picks a specific bind address instead of `0.0.0.0`; like the wider binding itself it takes effect only with `--listen`, and on its own it does nothing. Never start an internet-adjacent instance with `--share`: it publishes the UI through a public `*.gradio.live` tunnel, reachable by anyone who has the URL. `--public-api` does the same for the API through a Cloudflare tunnel. Treat both flags as publishing, not as remote access. Auth is per surface, and neither control covers the other: - UI: `--gradio-auth-path FILE` turns on a Gradio login form from the `user:password` entries in the file; `--gradio-auth user:password` takes the same entries on the command line, where they do not belong (below). Neither does anything for the API. - API: `--api-key KEY` requires `Authorization: Bearer KEY` on the OpenAI-compatible routes such as `/v1/models` and `/v1/chat/completions`; the Anthropic-compatible `/v1/messages` route reads the same key from an `x-api-key` header instead. `--admin-key` guards the admin endpoints (model load and unload) and falls back to the `--api-key` value when unset; an admin key alone does not protect the ordinary routes. Without `--api-key`, the API answers anyone who can reach the port, even when the UI has a Gradio login in front of it. At the pinned commits, starting the API logs the configured `--api-key` (and any distinct `--admin-key`) in plaintext at INFO level to stdout and the service log, so protect that output as you protect the key file below (owner-only, out of source control and out of shared log collection) and rotate the key after any disclosure. All three are secrets, and none belongs on the command line, where `ps` and `/proc//cmdline` show it to other local accounts for the server's whole lifetime; quoting does not change that. Put the login in the `--gradio-auth-path` file. At the pinned `server.py` the loader reads `user:password` entries separated by commas or line breaks, strips the whitespace around each, and splits each entry at every `:`, so neither the user name nor the password may contain `:` or `,`. The block below allows only letters, digits, dot, underscore and hyphen in the user name and generates a hex password. Create the file once, as the account that runs text-generation-webui, substituting the user name inside the quotes on the `set --` line: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -eC umask 077 set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_UI_USER' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; nothing written'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value, inside the quotes; nothing written'; exit 2; } case "$1" in ""|*REPLACE_WITH_*) echo 'substitute a user name inside the quotes on the set -- line above; nothing written'; exit 2 ;; esac case "$1" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo 'use only letters, digits, dot, underscore or hyphen in the user name; nothing written'; exit 2 ;; esac mkdir -p -- "$HOME/.config/text-generation-webui" chmod 700 -- "$HOME/.config/text-generation-webui" if [ -e "$HOME/.config/text-generation-webui/gradio-auth" ] || [ -L "$HOME/.config/text-generation-webui/gradio-auth" ]; then echo 'gradio-auth already exists in ~/.config/text-generation-webui; nothing written'; exit 2 fi command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on ~/.config/text-generation-webui cannot be ruled out; install the acl package; nothing written'; exit 2; } case "$(getfacl -d -c -p -- "$HOME/.config/text-generation-webui"; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'the directory ~/.config/text-generation-webui has a default ACL, which a new file inherits in place of umask 077; nothing written'; exit 2 ;; *) echo 'getfacl could not read the ACL of ~/.config/text-generation-webui; nothing written'; exit 2 ;; esac set -- "$1" "$(openssl rand -hex 16)" [ "${#2}" -eq 32 ] || { echo 'password generation failed; nothing written'; exit 2; } case "$2" in *[!0123456789abcdef]*) echo 'password generation failed; nothing written'; exit 2 ;; esac printf '%s:%s\n' "$1" "$2" > "$HOME/.config/text-generation-webui/gradio-auth" case "$(ls -ln -- "$HOME/.config/text-generation-webui/gradio-auth")" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'gradio-auth is not mode 0600, so other accounts may read it; delete ~/.config/text-generation-webui/gradio-auth and do not use the password in it'; exit 2 ;; esac ) ``` The `set --` line opens with a marker the block checks, so a paste that drops that line is refused rather than run on your shell's own arguments. The block makes the directory mode `0700` first, then refuses, before it generates a password, when `gradio-auth` already exists in any form (a regular file, a FIFO, or a symlink, dangling or not); as with the vLLM block, that holds only in owner-only directories no other account can replace. `umask 077` creates the directory mode `0700` and the file mode `0600`, `set -C` still refuses to overwrite an existing regular file, and the block writes nothing unless the generated password is 32 hex characters. As in the llama.cpp block, it also refuses, before it generates the password, a directory with a default ACL, a failed `getfacl` or a missing one, and after the write it fails unless `ls -ln` shows the file as `-rw-------`; delete a file that check refuses and do not use its password. The password passes only through the subshell's positional parameters and the builtin `printf`; the block clears inherited traps first and assumes a clean shell. Read the password from the file once into your password manager. The file holds it in plaintext at rest, readable by that account and by root, so keep it and its backups out of source control. The API keys have no file flag and no environment input at the pinned sources: the API reads them only from the parsed arguments. The pinned `modules/shared.py` does read further flags from `CMD_FLAGS.txt` in the user-data directory, and it splices them into Python's own argument list inside the process, so keys placed there stay out of `/proc//cmdline`. Do not use the checkout's own `user_data/CMD_FLAGS.txt` for them: at the pinned commit it is a tracked file (it ships with three comment lines), so a key written there is a change to a tracked file that `git diff` prints and `git stash` copies into the repository. At the pinned commit, the update wizard in `one_click.py` runs `git merge --autostash` (stashing the change) and offers `git reset --hard` (discarding it). This was read from the code, not run. Point `--user-data-dir` at a private directory outside the checkout instead, readable only by the account that runs text-generation-webui; it then stands in for the whole `user_data` directory (the model, LoRA and cache directories, among others, default under it), so copy across the models and settings you use. Write generated keys into a new `CMD_FLAGS.txt` there, as that account, substituting the directory's absolute path inside the quotes on the `set --` line (a path with spaces stays one value there). Write each `'` in the path as `'\''`; this is required, because a `'` left unescaped or escaped wrongly ends the quoting early, and the rest of the line can then change the path the block uses or run as shell syntax: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -eC umask 077 set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PRIVATE_USER_DATA_DIR' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; nothing written'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value, inside the quotes; nothing written'; exit 2; } case "$1" in ""|*REPLACE_WITH_*) echo 'substitute the private user-data directory inside the quotes on the set -- line above; nothing written'; exit 2 ;; esac case "$1" in /*) ;; *) echo 'give the user-data directory as an absolute path; nothing written'; exit 2 ;; esac mkdir -p -- "$1" cd -- "$1" chmod 700 . ( while :; do if [ -e .git ] || [ -L .git ]; then exit 3; fi if [ . -ef .. ]; then exit 0; fi cd -P .. || exit 4 done ) || { echo 'that directory or one above it holds a .git entry, or the walk could not reach /; no key written'; exit 2; } if [ -e CMD_FLAGS.txt ] || [ -L CMD_FLAGS.txt ]; then echo 'CMD_FLAGS.txt already exists there; use a new private directory; no key written'; exit 2 fi command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on that directory cannot be ruled out; install the acl package; no key written'; exit 2; } case "$(getfacl -d -c -p -- .; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'that directory has a default ACL, which a new file inherits in place of umask 077; no key written'; exit 2 ;; *) echo 'getfacl could not read the ACL of that directory; no key written'; exit 2 ;; esac set -- "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" [ "${#1}" -eq 64 ] || { echo 'key generation failed; nothing written'; exit 2; } [ "${#2}" -eq 64 ] || { echo 'key generation failed; nothing written'; exit 2; } case "$1$2" in *[!0123456789abcdef]*) echo 'key generation failed; nothing written'; exit 2 ;; esac printf -- '--api-key %s\n--admin-key %s\n' "$1" "$2" > CMD_FLAGS.txt case "$(ls -ln -- CMD_FLAGS.txt)" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'CMD_FLAGS.txt is not mode 0600, so other accounts may read it; delete CMD_FLAGS.txt in that directory and do not use the keys in it'; exit 2 ;; esac ) ``` The block creates `CMD_FLAGS.txt` once and never appends to one. Its `set --` line opens with a marker the block checks, so a paste that drops that line is refused rather than run on your shell's own arguments. Before it generates a key, it refuses a relative path; a directory that holds a `.git` entry of any kind, or has one in any directory above it, whether a repository directory, a submodule's or worktree's `.git` file, a symlink or a malformed entry, which covers the checkout's own `user_data`; and a directory where `CMD_FLAGS.txt` already exists in any form, a dangling symlink included. The walk starts in the target directory and steps up with `cd -P ..` until `.` and `..` are the same directory, testing `.git` relative to each one, so symlinks are resolved and no path string is built: a newline or other unusual byte in a directory name, or a physical path longer than the system's path limit, cannot hide an ancestor's `.git`. It refuses if any step fails. The walk does not run git, so neither a git error nor an inherited `GIT_*` variable can let the write through. It cannot see a checkout reached through a bind mount, or a repository whose work tree is set elsewhere (a bare "dotfiles" repository used with `--work-tree=$HOME`, for example), so choose a directory you know is outside any checkout. Like the other writers' checks, the walk and the existence check run before the write, not atomically with it, so they hold only in owner-only directories no other account can change, and a `.git` created there between the check and the write is not seen. A directory the block refuses after entering it is left behind, empty if the block created it, and mode `0700`. The block makes the directory mode `0700` after entering it, so for a directory another account owns `chmod` itself fails and the block stops there, with `chmod`'s error rather than its own message and the directory's mode unchanged; run as root, that `chmod` succeeds on any directory, so do not run the block as root. `umask 077` creates the file mode `0600`, `set -C` also refuses an existing regular file (the explicit check handles the other forms), and the block writes nothing unless both keys are 64 hex characters. As in the llama.cpp block, it also refuses, before it generates a key, a directory with a default ACL, which `chmod 700` does not remove and a new file would inherit in place of the umask, a failed `getfacl` or a missing one, and after the write it fails unless `ls -ln` shows `CMD_FLAGS.txt` as `-rw-------`; delete a file that check refuses and do not use its keys. The keys pass only through the subshell's positional parameters and the builtin `printf`. Add any other flags you want in the file on new lines after the two key lines, and never add or edit a key line: the loader joins the file's lines before splitting them into arguments, and a later `--api-key` or `--admin-key` would silently replace a key. To rotate the keys, run the block again with a new private directory and move your settings across. Start `server.py` from the installation directory with only non-secret values on its command line, substituting the same path inside the quotes: ```bash python server.py --user-data-dir 'REPLACE_WITH_PRIVATE_USER_DATA_DIR' --api --gradio-auth-path "$HOME/.config/text-generation-webui/gradio-auth" ``` Start it this way, directly, not through the one-click `start_*` scripts. At the pinned `one_click.py` the launcher joins its own arguments into one string without quoting them (line 24), builds `python server.py` plus that string (line 486) and runs the result through `bash` with `shell=True` (line 208), so a quoted path is split at its spaces and any shell syntax in it runs, and a key passed to those scripts would land on `server.py`'s command line again. This was read in the pinned source, not run. Clients read the API key from the file's `--api-key` line; give the admin key only to the operators who load and unload models. The keys remain in process memory, in the file, and in the startup log (above). The pinned `modules/shared.py`, imported with `--user-data-dir` pointing at a directory the block had written (a path containing a space, with a `--listen-port` line added after the key lines), parsed both keys and that flag from `CMD_FLAGS.txt` while the process's `/proc/self/cmdline` held neither. The launcher and API behaviour above was read in the pinned source (cited in Sources), and no text-generation-webui server was run. `--api --nowebui` runs the API alone, the right shape for a server where the UI has no business existing. `--ssl-keyfile` and `--ssl-certfile` give both surfaces TLS, but the better pattern is the usual one: keep both ports on loopback and front them with a reverse proxy that terminates TLS and enforces auth (`--subpath` exists for serving the UI under a proxy path). Without TLS, the Gradio login submits credentials in the clear. One proxy detail is specific to this API: unless `--listen` (or `--public-api`) is set, it rejects any request whose `Host` header is not `localhost` or `127.0.0.1` with a `400`, so a reverse proxy in front of a loopback API must send `Host: localhost` upstream rather than forwarding the public hostname. ## The pattern, whatever the server 1. Bind to `127.0.0.1` (or a private container network); confirm with `ss -tlnp`. 2. Require a per-client API key at the server where supported, or at the proxy otherwise (bearer-token check per [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md)); generate keys per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Keep the key off the server's command line, child processes included: use stdin where the server offers it, otherwise a file the server reads itself, created owner-only, or the guarded one-command prefix assignment of CONTRIBUTING rule 7, which leaves the key readable through `/proc//environ` for the server's lifetime. Where a secret is passed in argv (the TGI router's key or the Triton restricted-API flags), its native check is a control against network clients, not against other local accounts; enforce auth at the proxy. 3. TLS in front: [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), or [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md). 4. Human-facing UIs on top of these servers ([open-webui.md](https://github.com/jposluns/secureconfig/blob/main/open-webui.md)) carry their own login and MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). ## Verify **REASONED:** following block; service checks follow the cited server handlers and need unavailable live servers/proxy fixtures. The pgrep subcheck has the recorded stub-process demonstration below. The /invocations subshell was run from a file in Bash 5.3.9 with curl replaced by a stub that prints its arguments: a substituted model name reached the stub once, carrying that name in the JSON body. That /invocations subshell run gave these results: a missing set -- line (with and without a caller argument), a set -- line holding only the marker (also under set -u) and one holding two values each printed its refusal and exited 0, and the unsubstituted placeholder printed its refusal and exited 2, none of them reaching the stub. Bash 5.3.9 bash -n accepted the whole block. The /invocations runs and the bash -n result covered an earlier form, before the key and /invocations subshells took the model server's host on guarded `set --` lines that refuse example.com, example.net, example.org and their subdomains and exit 2 on a partial paste or a wrong value count. They also came before the listener inventory checked for `ss` and before the block ran its parts in one subshell with one exit status. Local bash -n and stub runs of the whole block with `ss` and `pgrep` present, failing and missing, with the placeholders left in and substituted, and with a partial paste, a wrong value count and a reserved name on each host line, also under an inherited `readonly`, `declare -i` and `set -e`, covered a later form, whose host lines lowercased the host with `tr` before refusing those names. Local bash -n, ShellCheck 0.11.0 and stub runs on 2026-11-01 covered the next form, which had no bash check ahead of its parts: each host line refused those names in lower, upper and mixed case, also with `tr` absent and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and under dash refused every host with status 2. A stub run of that form under dash 0.5.12 on 2026-11-02 then stopped at a Bad substitution error in the status fold, with status 2 and no last line. The current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n, ShellCheck 0.11.0 and stub runs on 2026-11-02 under Bash 5.3.9 and dash 0.5.12. Under dash it refused at its bash check, before any part ran, with status 2 and the bash message, calling no stub, with the placeholders left in and substituted. Under bash, also under an inherited `set -e` or `nocasematch`, it exited 2 with the placeholders left in or a reserved name on both host lines, 3 for a 401, a keyed 200 and an /invocations 404, and 1 for a 200 without a key or an /invocations 200. With `ss` missing, that 401 case still exited 3. With a lower-case `replace_with_` host on both host lines it exited 2 without calling curl under an inherited `nocasematch` and 3 after probing without it, as the form before the bash check also did in the same runs. The block runs its listener inventory, `pgrep` count, model-list key check and /invocations request as four parts of one subshell with errexit off, and exits once at the end. It exits 1 if the model list answers a request without a key with a 2xx or /invocations answers 200 without one. Otherwise it exits 3 if `ss`, `pgrep` or a request fails, the request without a key gets neither 401 nor a 2xx, the positive control with the key does not answer 200, or /invocations answers anything but 200. It exits 2 if a part refuses, including a missing `ss` or `pgrep` and an unsubstituted or partly pasted `set --` line, only while no other part found something or was inconclusive, and otherwise 0. In a shell without bash's `shopt`, such as dash, it exits 2 at its bash check, before any part runs; that check runs in a subshell of its own, so it leaves the block's `nocasematch` setting as your shell left it. A fixed deployment therefore also ends with 3, because the block cannot tell a proxy's refusal of /invocations from vLLM's own error; its last line leaves the listener list, any process count above 0 and the printed /invocations headers and `invocations-body.txt` for you to judge. These local guard runs observe no server or proxy, so the whole mixed block remains REASONED. ```bash ( # one subshell around the whole block, so one exit status covers all four parts set +e # errexit off here, so each part's status is recorded below instead of ending the block (shopt -u nocasematch) || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # a shell without bash's shopt, such as dash, refuses here before any part runs; the check runs in its own subshell, so the block's nocasematch setting stays as your shell left it set -- # each part's exit status, in order, folded into the block's status at the end ( # the listener inventory: 2 when ss is not installed, 3 when it fails command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp # every listener; 8080/8000/8001/8002/3000/80/9000/30000/5000/7860/1234. ss shows only a # namespace-local BIND, not a host firewall, a cloud security group, or Docker -p NAT # publication. From another host, probe EACH backend listener's own host and port (adapt the # subshell below, which shows the technique for one endpoint) and confirm each is refused set -- "$?" # ss's exit status [ "$1" = 0 ] || { echo "ss -tlnp exit $1: the listener list did not print; inconclusive"; exit 3; } exit 0 # the listener list is not classified: judge it yourself ) set -- "$@" "$?" ( # the argv count: 2 when pgrep is not installed, 3 when it fails command -v pgrep >/dev/null || { echo "pgrep is not installed here; not counting processes"; exit 2; } pgrep -c -f -- '(^| )--((admin-)?api-key|admin-key|gradio-auth|http-restricted-api|grpc-restricted-protocol)( |=)' # counts visible command lines carrying one of those secret-bearing arguments; 0 means none was seen # right now, and any match needs a look. Triton's restricted-API flags carry the secret in their # value, so a match is expected when you pass either flag on the command line. It does not see a key in a file the server # reads, in a server's environment (VLLM_API_KEY, LLAMA_API_KEY), inside another argument's value # (vLLM's Rust-frontend --args-json), on an abbreviated option that argparse or getopt_long accepts # (--api-k X, --http-restricted=...), or on a flag the pattern does not name set -- "$?" # pgrep's exit status: 0 when a process matched, 1 when none did case "$1" in 0) echo "a visible command line carries one of those arguments: a lead to check yourself, not classified" ;; 1) ;; *) echo "pgrep exit $1: the count is not reliable; inconclusive"; exit 3 ;; esac exit 0 ) set -- "$@" "$?" ( # Feed the API key to curl on stdin (curl --header @-), never in argv: # -H "Authorization: Bearer KEY" is readable in ps / /proc//cmdline. trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The API key you substitute on the set -- line enters shell history. # Use a short-lived key or clear that history line afterward. # Stdin closes the argv channel only: it does not hide the key from set -x # tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_MODEL_HOST' 'REPLACE_WITH_API_KEY' # the public host name your proxy serves the model API on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the API key: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the API key on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(curl -q -g -sS --noproxy '*' -w 'http=%{http_code} exit=%{exitcode}\n' "https://$1/v1/models"; printf '\n%s' "$?")" # PROXY-policy test: 401 without a key. TGI leaves /v1/models outside its native key middleware, so a 200 is not proof the backend enforces the key set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the host, the key, curl's output, then curl's exit status printf '%s' "$3" case "$4:${3##*http=}" in 0:401\ *) set -- "$1" "$2" 0 ;; # the expected refusal, a pass only if the positive control below answers 200 0:2??\ *) echo "FINDING: the model list answered without a key"; set -- "$1" "$2" 1 ;; *) echo "the request without a key got neither 401 nor a 2xx; inconclusive"; set -- "$1" "$2" 3 ;; esac set -- "$3" "$(printf 'Authorization: Bearer %s\n' "$2" | curl -q -g -sS --noproxy '*' -w 'http=%{http_code} exit=%{exitcode}\n' -H @- "https://$1/v1/models"; printf '\n%s' "$?")" # positive control: 200 with a model list, printed set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status so far, curl's output, then curl's exit status; the key is no longer held printf '%s' "$2" case "$3:${2##*http=}" in 0:200\ *) ;; *) echo "the positive control with the key did not answer 200; inconclusive"; [ "$1" = 1 ] || set -- 3 ;; esac exit "$1" ) set -- "$@" "$?" ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_MODEL_HOST' 'REPLACE_WITH_A_REAL_SERVED_MODEL' # the same model API host name as the key check above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then a real served model name: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*) echo 'substitute a real served model name inside the quotes on the set -- line above; not probing'; exit 2 ;; esac set -- "$(curl -q -g -sS --noproxy '*' -D - -o invocations-body.txt -w 'http=%{http_code}\n' -X POST -H 'Content-Type: application/json' \ -d "{\"model\":\"$2\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" "https://$1/invocations" printf '\n%s' "$?")" # curl's headers and http= line, then curl's exit status set -- "${1%$'\n'*}" "${1##*$'\n'}" printf '%s' "$1" case "$2:${1##*http=}" in 0:200*) echo "FINDING: /invocations answered 200 without a key, so the route reaches vLLM unauthenticated"; exit 1 ;; 0:*) echo "/invocations did not answer 200: judge the headers above and invocations-body.txt as the comments below say; inconclusive"; exit 3 ;; *) echo "curl exit $2: the /invocations request failed; inconclusive"; exit 3 ;; esac ) set -- "$@" "$?" # vLLM /invocations needs JSON (a bare -d '{}' sends form # content-type and fails the schema) and a REAL served model. # Read the printed headers and saved body, not the status alone: an # exposed vLLM given an unknown model also returns 404, so # http=404 is AMBIGUOUS with the proxy's own denial. A # vLLM-originated reply (200, or a JSON error body) means the # route is reachable without the key; a 403/404 only proves # denial if the PROXY rejected BEFORE forwarding (its route config # or correlated upstream/backend logs) - a proxy that intercepts an # upstream error (nginx proxy_intercept_errors) serves its own page # while the no-key request reached vLLM, which is reachability, not # denial. Treat anything ambiguous as inconclusive set -- 0 "$@" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 2, a part that refused to run, which outranks 0, so a refusal gives 2 only while nothing else was recorded), then each part's status while [ "$#" -gt 1 ]; do case "$2:$1" in 1:*|3:0|3:2|3:3|2:0) set -- "$2" "${@:3}" ;; 0:*|2:*|3:*) set -- "$1" "${@:3}" ;; *) echo "a part ended with status $2, which none of them sets; inconclusive"; case "$1" in 1) set -- 1 "${@:3}" ;; *) set -- 3 "${@:3}" ;; esac ;; esac done echo "block exit $1; the listener list and any process count above 0 are not classified, and an inconclusive /invocations answer needs your proxy's logs, so judge them yourself" exit "$1" ) ``` REASONED: following block; text-generation-webui authentication follows the cited modules/api/script.py, and no container runtime is available. This paragraph also records the mixed process-matching evidence and parser/visibility limitations of the preceding block's `pgrep` line, from the printed expression and cited parser sources. The historical stub-process observations below were reproduced locally with Python 3.14.4 sleeping for 20 seconds and procps-ng 4.0.4 running the printed pgrep line against one stub at a time, with all seven listed matching cases printing 1 and exiting 0 and all nine listed non-matching cases printing 0 and exiting 1, no stderr, and each stub stopped afterward. The pgrep line's demonstration covered only local expression matching, without a listener or model server, leaving service-parser and process-visibility behavior untested and the preceding mixed service block REASONED. The current form of that block runs the unchanged `pgrep` line as its own part and records its status. The `pgrep` line counts processes whose visible command line has an argument that is `--api-key`, `--admin-api-key`, `--admin-key`, `--gradio-auth`, `--http-restricted-api` or `--grpc-restricted-protocol` followed by a space or `=`, the forms in which llama-server, vLLM, SGLang, the TGI router, text-generation-webui and Triton take a secret on the command line. The `pgrep` line prints a count and never a key, and it excludes itself. A count is a lead, not proof: an empty value, or an argument of unrelated text that contains ` --api-key `, matches too, and `0` means only that no visible process matched at that moment. It was demonstrated on the authoring host against stub processes that opened no listener (`python3` sleeping with the test arguments, procps-ng 4.0.4). The stub arguments `--api-key DUMMY_NOT_A_SECRET`, `--admin-key DUMMY_NOT_A_SECRET`, `--admin-api-key=DUMMY_NOT_A_SECRET`, `--gradio-auth u:DUMMY_NOT_A_SECRET`, `--http-restricted-api=model-repository:admin-key=DUMMY_NOT_A_SECRET`, `--grpc-restricted-protocol=model-repository:admin-key=DUMMY_NOT_A_SECRET` and an empty `--api-key=` each counted `1`. The stub arguments `--api-key-file /x`, `--gradio-auth-path /x`, `--config /x`, `notes--api-key x`, `--admin-keys x`, `--http-restricted-api-x y`, `--args-json '{"api_key":["DUMMY_NOT_A_SECRET"]}'`, and the abbreviations `--api-k DUMMY_NOT_A_SECRET` and `--http-restricted=model-repository:k=DUMMY_NOT_A_SECRET` each counted `0`. The `pgrep` line therefore tells the file-based launch forms from the argv ones, and it misses a key inside a JSON argument or behind an abbreviated option (the pinned text-generation-webui parser keeps argparse's default prefix matching, and Triton's `getopt_long` accepts an unambiguous prefix). No model server was run for it. A `hidepid` proc mount limits the count to your own processes, and a process that has scrubbed its own arguments is not seen. For the text-generation-webui block that follows, anonymous model-list authentication, direct-listener targeting and transport/Host-error interpretation follow the cited modules/api/script.py; no live text-generation-webui instance or external vantage was available. Bash 5.3.9 bash -n accepted all four Verify shell fences, the first in an earlier form, before its key and /invocations probes took the model server's host on guarded `set --` lines, its listener inventory checked for `ss` and it ran its parts in one subshell with one exit status. The first fence's current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by the local bash -n and stub runs recorded above it. The complete following subshell with curl replaced by printf LOCAL_CURL emitted one marker for 192.168.50.7 and for bad value, but none for empty input, the original placeholder or set -- without arguments. All exited 0; this guard does not validate host syntax and a skipped probe is not a pass. For the text-generation-webui block, the bash -n and stub runs recorded here used an earlier form, whose guard refusals left the subshell with status 0. Local bash -n and stub runs of a later form, whose guard refusals left the subshell with status 2 and whose leading comment no longer declared it, saw each guard refusal exit 2 without calling curl and a substituted host reach curl once, the block then exiting with curl's own status. Its current form exits 1 for a 200 and 3 for a 401 or any other answer. That form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n, ShellCheck 0.11.0 and stub runs on 2026-11-02 under Bash 5.3.9 and dash 0.5.12 with a curl stub. In those runs, also under an inherited `set -e`, each guard refusal exited 2 without calling curl, a 200 exited 1, and a 401, a 400, 403 or 404, a curl exit 7 or 28 and a missing curl each exited 3. For the Triton block, the bash -n run recorded here preceded the removal of `REASONED:` from its leading comment, which changed no command, and local bash -n and ShellCheck 0.11.0 runs covered a later form that ended with curl's own status. Its current form exits 1 for a 200, 0 for a 403 carrying Triton's restriction message and 3 for any other answer. That form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n, ShellCheck 0.11.0 and stub runs on 2026-11-02 under Bash 5.3.9 and dash 0.5.12 with a curl stub. In those runs a 200 exited 1 and that 403 exited 0, and a 403 without the message, a 401, a 404, a curl exit 7 or 28 and a missing curl each exited 3. For the vLLM internal-listener block, the bash -n run recorded here used an earlier form, whose Python target refusal and `NO CONNECTION` result each exited with status 1. A later form, whose target refusal exits with status 2 and whose `NO CONNECTION` result exits with status 3, was covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and in a stub run on 2026-11-02 under Bash 5.3.9 it ended with status 127 when `python3` was not installed. Its current form checks for `python3` first and refuses with status 2 when it is missing. That form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n, ShellCheck 0.11.0 and stub runs on 2026-11-02 under Bash 5.3.9 and dash 0.5.12. In those runs a missing `python3` and the placeholders left in each exited 2 without running `python3`, and a `python3` stub's statuses 0, 2 and 3 passed through unchanged. For text-generation-webui, ask the API edge for the model list without a key. This step is reasoned, not demonstrated: the authoring environment has no container runtime to stand up a live instance, so the expected `401` is derived from the bearer-token check in `modules/api/script.py` (cited in Sources), not observed. The block prints the `exitcode` and `errormsg` write-out variables, which need curl 7.75.0 or newer. A `200` with a model list means the API is open to anyone; a `401` means the key is enforced. The block exits 1 for that `200` and 3 for a `401`, any other answer or a failed request, because it cannot tell the API's own `401` from a fronting proxy's; confirm a `401` against the API listener's own address and port directly. A transfer error is inconclusive: a TLS failure (`exit=35` or `exit=60`) in particular means something did answer, since the API serves plain HTTP unless `--ssl-keyfile`/`--ssl-certfile` are set, so read the `exit` and `err` fields, fix any DNS, TLS, or client cause, and confirm the API listener's own address and port directly from the intended vantage. A `400` carrying `Invalid host header` is the API rejecting the forwarded `Host` (see the reverse-proxy note above), not an authentication result, and a rejection page from your proxy proves only the proxy. ```bash # text-generation-webui authentication follows the cited modules/api/script.py; no container runtime is available. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host on the set -- line above; not probing"; exit 2 ;; esac set -- "$(curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1/v1/models")" # the response body, then curl's http= line printf '%s\n' "$1" case "${1##*http=}" in '200 exit=0 '*) echo "FINDING: the API listed its models without a key"; exit 1 ;; '401 exit=0 '*) echo "the request without a key got 401, but this block cannot tell the API's own 401 from a fronting proxy's; confirm it against the API listener directly; inconclusive"; exit 3 ;; *) echo "no 200 or 401 from a completed request; inconclusive: read the http, exit and err fields above"; exit 3 ;; esac ) ``` REASONED: following block; the before/after logging read and all stated inconclusive outcomes follow the linked Triton logging handler and restriction check; no running Triton HTTP listener or isolated logging fixture. Local bash -n checks syntax only, not the expected 200/settings and 403/restriction responses. For Triton, this read checks the `logging` restriction directly on the server host. **REASONED, not demonstrated:** no Triton server, container runtime, or GPU is available in the authoring environment. On an isolated instance with logging support, run it before and after applying the restriction above. Before, expect HTTP 200 with settings JSON containing `log_file` and `log_verbose_level`; after, expect HTTP 403 with `This API is restricted, expecting header 'gateway-key'`. These outcomes follow from the [logging handler](https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L2031-L2149) and [restriction check](https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L4939-L4953). Use the same address and port for both runs. This command assumes Triton's HTTP listener is on loopback port 8000 and needs curl 7.75.0 or newer. A connection error, a proxy rejection, 404, or a logging-unsupported response is inconclusive; a failing request alone is not a pass. The block exits 1 for a 200, the unrestricted state, 0 for a 403 whose body carries Triton's `This API is restricted` message and 3 for any other answer, including a 403 without that message, or a failed request. This checks only logging over HTTP, not the other categories, gRPC, or external listener isolation. The existing `pgrep` check measures argv exposure, not API authorization. ```bash # Triton logging restriction follows the pinned logging handler and restriction check; no server, runtime, or GPU is available. ( # a subshell, so each exit below ends only it set -- "$(curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ http://127.0.0.1:8000/v2/logging)" # the response body, then curl's http= line printf '%s\n' "$1" case "${1##*http=}" in '200 exit=0 '*) echo "FINDING: the logging API answered 200 without the restriction header"; exit 1 ;; '403 exit=0 '*) case "$1" in *'This API is restricted'*) echo "the logging API refused the request with 403 and Triton's restriction message, the fixed answer"; exit 0 ;; esac ;; esac echo "no 200, and no 403 with Triton's restriction message, from a completed request; inconclusive: read the http, exit and err fields above" exit 3 ) ``` ### vLLM internal-listener isolation (REASONED: cited v0.30.0 listener constructors; no multi-node cluster) **REASONED, not demonstrated:** no multi-GPU or multi-node cluster is available in the authoring environment. The expected distinction follows from v0.30.0's [internal-network warning and firewall guidance](https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L109-L138) and the listener constructors cited below. No vLLM server was run for this addition. On every node, first run `command -v ss`, which prints the path of `ss` or nothing; if it prints nothing, `ss` is not installed and no inventory can be taken, so install it rather than reading the missing output as no listeners. Then run `sudo ss -tlnp` in the host and service network namespaces, during startup and after the engines become ready. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH (the guide's cited sudo sudoers manual, secure_path entry), so `command -v ss` can print nothing although sudo would find `ss`, or print a path although sudo cannot run it. Record each vLLM, worker and backend listener, including dynamically assigned ports; re-inventory after a restart. Check container publications and cloud firewall rules too. A private bind or an absent startup-only handshake is not proof that the remaining ports are isolated. For each endpoint intended for cross-node traffic, run this block in `remote` mode from a trusted cluster peer and then from a host outside the allowed cluster network. Substitute the actual numeric node address reachable from that vantage and the observed port inside the existing quotes on the `set --` line; use the node endpoint, not the API proxy. For a deliberately loopback-only endpoint, such as a NIXL handshake left on localhost, first run the same block in `local` mode from the listener's own network namespace, substituting its observed numeric loopback address and port. Confirm with the socket inventory that the process binds only the intended loopback addresses, and check that no container publication or forwarding rule exposes it. Then run in `remote` mode from both remote vantages against the node's non-loopback addresses and corresponding ports, including any mapped ports found in the inventory. Do not widen a bind to obtain a remote positive control. Change the mode inside its existing quotes too. The block only attempts a TCP connection and sends no application payload: ```bash ( set -- PASTE_WHOLE_BLOCK 'remote' 'REPLACE_WITH_TARGET_IP' 'REPLACE_WITH_INTERNAL_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'the set -- line needs exactly 3 values; not probing'; exit 2; } case "$1$2$3" in *REPLACE_WITH_*) echo 'substitute the target IP and port; not probing'; exit 2 ;; esac command -v python3 >/dev/null || { echo 'python3 is not installed here; not probing'; exit 2; } python3 - "$@" <<'PYPROBE' import ipaddress import socket import sys try: mode = sys.argv[1] if mode not in ("remote", "local"): raise ValueError("mode must be remote or local") address = ipaddress.ip_address(sys.argv[2]) effective_address = getattr(address, "ipv4_mapped", None) or address port = int(sys.argv[3]) if effective_address.is_unspecified or not 1 <= port <= 65535: raise ValueError("use an observed listener address and a valid port") if mode == "remote" and effective_address.is_loopback: raise ValueError("remote mode needs the non-loopback node IP") if mode == "local" and not effective_address.is_loopback: raise ValueError("local mode needs the listener's loopback IP") except ValueError as exc: print(f"Invalid target; not probing: {exc}", file=sys.stderr) sys.exit(2) try: with socket.create_connection((str(address), port), timeout=5): print("CONNECTED: this internal listener is reachable from this host") except OSError as exc: print(f"NO CONNECTION: {exc}; inconclusive without the paired control", file=sys.stderr) sys.exit(3) PYPROBE ) ``` For cross-node endpoints in an intentionally exposed test deployment, the outside probe should report `CONNECTED`. After isolation, the trusted peer must still connect while the outside probe reports `NO CONNECTION`; confirm that firewall logs or counters attribute the latter to the intended rule. For deliberately loopback-only endpoints, the positive control is `CONNECTED` from the same network namespace; both remote probes should report `NO CONNECTION`, supported by the confirmed loopback binds and absence of forwarding. A remote connection to such an endpoint exposes it; a trusted peer is not expected to connect. Where a firewall also blocks a tested path, collect its rule evidence. A negative remote result alone proves neither bind isolation nor firewall enforcement. A stopped process, changed dynamic port, wrong address, routing failure or local socket error is inconclusive. Capture the handshake while it is listening during startup. This checks TCP reachability only; verify any RDMA or other non-TCP transport separately with the selected backend's tools. ## Sources (checked September 2026) - llama.cpp server README (defaults, `--api-key` and its `LLAMA_API_KEY` environment input, `--api-key-file` ("path to file containing API keys, one per line") and its `LLAMA_ARG_API_KEY_FILE` environment input, the `LLAMA_ARG_*` environment inputs most options carry, `LLAMA_ARG_HOST` for `--host` among them, SSL flags). Sources for this item: https://github.com/ggml-org/llama.cpp/blob/e0dff58475bc9ed68eedcb265ee998f2fcabb3b1/tools/server/README.md - vLLM documentation: https://docs.vllm.ai/en/latest/ - vLLM v0.30.0 network isolation, TCPStore warning and Ray trust model: https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L3-L55, https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L109-L138 and https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L442-L538 - vLLM v0.30.0 node-address detection, open-port allocator and unauthenticated ZMQ socket construction: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L34-L73, https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L166-L247 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L310-L368 - vLLM v0.30.0 engine IPC/TCP addresses, handshake and DP coordinator. Sources for the vLLM v0.30.0 engine IPC/TCP addresses, handshake and DP coordinator: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/engine/utils.py#L1039-L1084, https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/engine/utils.py#L1130-L1247 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/engine/coordinator.py#L79-L97. Sources for the Ray V2 dynamic store: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/ray_executor_v2.py#L127-L139 - vLLM v0.30.0 DP flags and headless deployment examples: https://github.com/vllm-project/vllm/blob/v0.30.0/docs/serving/data_parallel_deployment.md#L31-L74; headless execution: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/cli/serve.py#L215-L253 - vLLM v0.30.0 parallel configuration defaults and initialization: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/config/parallel.py#L100-L330 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/config/parallel.py#L890-L960; serving address selection: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/engine/arg_utils.py#L2310-L2336 - vLLM v0.30.0 TCP/file rendezvous and stateless stores. Sources for the vLLM v0.30.0 TCP/file rendezvous and stateless stores: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/parallel_state.py#L1793-L1880 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/utils.py#L467-L506 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/utils.py#L634-L647. Sources for the remote message-queue bind: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/device_communicators/shm_broadcast.py#L519-L537. Sources for the backend address selection: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/multiproc_executor.py#L148-L169 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/ray_executor_v2.py#L327-L336 - vLLM v0.30.0 single-GPU file-store path and Gloo group creation: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/uniproc_executor.py#L77-L88, https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L135-L147 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/parallel_state.py#L494-L508 - vLLM v0.30.0 environment settings for node IP/ports, offline DP and connector side channels: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/envs.py#L701-L715, https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/envs.py#L1433-L1445 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/envs.py#L1650-L1680 - vLLM v0.30.0 NIXL handshake address and listener. Sources for the vLLM v0.30.0 NIXL handshake address and listener: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py#L75-L79 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py#L299-L382. Sources for the Mooncake bootstrap and worker listeners: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L948-L1035 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L1160-L1187. Sources for the producer worker activation: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L1793-L1801. Sources for the bootstrap rank selection and port: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L2261-L2296 - vLLM v0.30.0 gRPC selector and insecure bind. Sources for the vLLM v0.30.0 gRPC selector and insecure bind: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/cli_args.py#L424-L429 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/grpc_server.py#L87-L118. Sources for the DP supervisor port default 9256: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/cli_args.py#L264-L266. Sources for the routes and bind: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/dp_supervisor.py#L220-L238 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/dp_supervisor.py#L322-L349. Sources for the supervisor activation: https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/cli/serve.py#L85-L95 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/cli/serve.py#L145-L148 - vLLM v0.30.0 frontend defaults (`host=None`, port 8000; unchanged, checked 2026-09-26): https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/cli_args.py#L256-L266 - vLLM v0.30.0 frontend socket bind (unset host binds every IPv4 interface; unchanged, checked 2026-09-26): https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/launcher.py#L211-L225 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/launcher.py#L270-L288 - vLLM v0.30.0 API key scope (including `/cohere`), unprotected `/invocations` and profiler routes: https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L140-L247 and https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/serve/middleware/authenticate.py#L11-L62 - vLLM `--api-key` (`FrontendArgs.api_key`, a list of keys; read in source, not run): https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/cli_args.py#L304 and https://github.com/vllm-project/vllm/blob/8c1557a79c539ffe82d004d2a0c8d7b5e71159ce/vllm/entrypoints/launchers/cli_args.py#L296 - vLLM frontend TLS options `ssl_keyfile`, `ssl_certfile` and `ssl_ca_certs` (`FrontendArgs`; read in source, not run): https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/cli_args.py#L315-L320 - vLLM `VLLM_API_KEY`, declared in `envs.py` (https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/envs.py#L803, https://github.com/vllm-project/vllm/blob/8c1557a79c539ffe82d004d2a0c8d7b5e71159ce/vllm/envs.py#L801) and read as the fallback when `--api-key` is unset, the CLI taking precedence (identical at both commits). Sources for the fallback and CLI precedence: https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/serve/middleware/register.py#L32-L36 - vLLM `--config` YAML loader (expanded only when `--config` is its own argument, `.yaml` or `.yml` required, `yaml.safe_load`, each key turned into the option `--` and merged into the in-process argument list before parsing). Sources for the vLLM `--config` YAML loader (1 of 2): https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L329-L330 and https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L517-L548. Sources for the vLLM `--config` YAML loader (2 of 2): https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L585-L596 and https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L621-L622. Sources for the `--config` option that accepts `--config=FILE` as a plain value: https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/cli_args.py#L425-L430 - vLLM startup log of non-default arguments with `api_key` redacted (https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/serve/utils/api_utils.py#L271-L286), and the unredacted `--grpc` argument log: https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/grpc_server.py#L64 - vLLM API server processes started through `multiprocessing` `spawn`, arguments pickled rather than on a command line (https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/v1/utils.py#L210-L246), and the opt-in Rust frontend. The opt-in Rust frontend is started only when `VLLM_USE_RUST_FRONTEND` is set, and receives the non-default arguments, `api_key` included, as `--args-json` on its command line. Sources for the opt-in Rust frontend: https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/v1/utils.py#L384-L411 and https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/cli/serve.py#L63-L64 - TGI launcher arguments (--hostname, --port, --api-key, --prometheus-port): https://huggingface.co/docs/text-generation-inference/reference/launcher - TGI launcher `hostname` default `0.0.0.0` and `port` default 3000, each also read from the environment (pinned tag v3.3.7, the last release before the repository was archived): https://github.com/huggingface/text-generation-inference/blob/v3.3.7/launcher/src/main.rs#L769-L774 - TGI image built from the repository's main `Dockerfile`, `ENV ... PORT=80` (pinned tag v3.3.7): https://github.com/huggingface/text-generation-inference/blob/v3.3.7/Dockerfile#L147-L149 - TGI router: a `--hostname` that does not parse as an IP address logs "Invalid hostname, defaulting to 0.0.0.0" and binds `0.0.0.0` (pinned tag v3.3.7): https://github.com/huggingface/text-generation-inference/blob/v3.3.7/router/src/server.rs#L1906-L1910 - TGI router source (what --api-key enforces): https://github.com/huggingface/text-generation-inference/blob/24ee40d143d8d046039f12f76940a85886cbe152/router/src/server.rs - TGI repository (maintenance-mode notice, archived 2026-03-21): https://github.com/huggingface/text-generation-inference - SGLang server arguments, --host, --port, --api-key, --admin-api-key and SSL flags; docs.sglang.ai redirects here (rolling documentation, checked September 2026): https://docs.sglang.io/docs/advanced_features/server_arguments - Triton secure deployment considerations (rolling documentation, checked September 2026): https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/deploy.html - Triton listener defaults: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.h#L191-L219 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.h#L51-L67 - Triton gRPC TLS flags and restricted protocols: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L558-L576 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L632-L640 - Triton command line parser (HTTP address and port, restricted APIs): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L477-L516 - Triton cloud endpoints outside the restriction flags (commit 546a78766fb112128aa0b10a70c55f4f39c3b1df): the SageMaker multi-model invoke dispatch, which reaches inference without a restriction check, and the Vertex AI redirect header with its unrestricted `metrics` target. Sources for the Triton cloud endpoints outside the restriction flags (1 of 2): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/sagemaker_server.cc#L198-L225 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/vertex_ai_server.cc#L34-L37. Sources for the Triton cloud endpoints outside the restriction flags (2 of 2): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/vertex_ai_server.cc#L142-L145 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/vertex_ai_server.cc#L245-L272. Sources for the shared-memory platform limits: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_shared_memory.md#L65-L68 - Triton `--http-restricted-api` and `--grpc-restricted-protocol` at 546a78766fb112128aa0b10a70c55f4f39c3b1df: option formats (508-516, 632-640), argv parse loop (1329-1330), HTTP and gRPC handlers passing `optarg` (1420-1423, 1561-1566), and the complete restricted-feature and generic value parsers (2094-2164). The latter split the argument and store its literal value, without file or environment lookup on that path. That statement is not a claim about every possible launch mechanism. Sources for this item (1 of 3): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L508-L516 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L632-L640. Sources for this item (2 of 3): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L1329-L1330 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L1420-L1423. Sources for this item (3 of 3): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L1561-L1566 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L2094-L2164 - Triton category names and initially unrestricted state: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/restricted_features.h#L37-L112 - Triton HTTP route patterns and dispatch: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L1140-L1149 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L4786-L4885 - Triton dynamic logging (mutable settings, file-path disclosure, refusal to change the file path): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L2031-L2149 - Triton trace settings API and file-path refusal. Sources for the Triton trace settings API and file-path refusal: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L1759-L2027. Sources for the defaults: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.h#L180-L188. Sources for the runtime updates and file-path requirement: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/tracer.cc#L221-L232 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/tracer.cc#L1244-L1249. Sources for the startup trace flags (1 of 2): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L739-L750 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L2247-L2278. Sources for the startup trace flags (2 of 2): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L2297-L2312 - Triton client shared memory (disabled by default, opt-in flag, platform limits): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_shared_memory.md#L29-L68; status and mutation handlers: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L2177-L2378 - Triton repository index and inline load overrides. Sources for this item (1 of 2): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_model_repository.md#L59-L90 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_model_repository.md#L136-L152. Sources for this item (2 of 2): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_model_repository.md#L350-L400 - Triton model configuration and statistics reads: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L1684-L1755; generation routes use the inference restriction: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L3324-L3334 - Triton restriction grouping/parser. Sources for the Triton restriction grouping/parser: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L2095-L2163. Sources for the gRPC header prefix: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.h#L47-L49. Sources for the HTTP rejection: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L4939-L4953. Sources for the gRPC rejection: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.cc#L194-L225 - Triton metrics path and unauthenticated Prometheus handler. Sources for the Triton metrics path and unauthenticated Prometheus handler: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.h#L143-L157 and https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L308-L345. Sources for the metrics controls: https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L708-L725 - Triton model control modes (`none` by default, repository polling under `poll`, API load/unload under `explicit`): https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L424-L434 - LM Studio local server: https://lmstudio.ai/docs/developer/core/server - LM Studio serve on local network: https://lmstudio.ai/docs/developer/core/server/serve-on-network - LM Studio server settings: https://lmstudio.ai/docs/developer/core/server/settings - LM Studio authentication: https://lmstudio.ai/docs/developer/core/authentication - LM Studio OpenAI compatibility (localhost:1234 examples): https://lmstudio.ai/docs/developer/openai-compat - text-generation-webui, OpenAI-compatible API documentation: https://github.com/oobabooga/textgen/blob/ceade2eb1ba3f84518076270df2240b6bbb01da0/docs/12%20-%20OpenAI%20API.md - text-generation-webui, flag definitions and defaults (modules/shared.py): https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/shared.py - text-generation-webui, `--user-data-dir` and the in-process `CMD_FLAGS.txt` loader (lines whose first non-space character is `#` skipped, the rest split as shell words and spliced into Python's `sys.argv` before parsing). Sources for this item: https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/shared.py#L50 and https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/shared.py#L221-L236. Sources for the directory resolution: https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/paths.py#L5-L21 - text-generation-webui, `user_data/CMD_FLAGS.txt` shipped as a tracked file of three comment lines: https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/user_data/CMD_FLAGS.txt - text-generation-webui one-click launcher (its own arguments joined unquoted and run through `bash` as `server.py`'s command line, the update wizard's `git merge --autostash` and `git reset --hard`; read in source, not run). Sources for this item (1 of 2): https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L24, https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L208 and https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L396. Sources for this item (2 of 2): https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L485-L486, https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L504 and https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L527 - text-generation-webui `--gradio-auth-path` loader (entries split at commas and line breaks, whitespace stripped, each split at every `:`): https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/server.py#L90-L95 - text-generation-webui, API bind and key checks (modules/api/script.py): https://github.com/oobabooga/textgen/blob/619a2b8ee4b7e48541a9be5c07e04cd31b91b44f/modules/api/script.py - text-generation-webui, the API key and a distinct admin key logged in plaintext at INFO at startup: https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/api/script.py#L594-L597 and https://github.com/oobabooga/textgen/blob/619a2b8ee4b7e48541a9be5c07e04cd31b91b44f/modules/api/script.py#L599-L602 - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0): https://curl.se/docs/manpage.html - SGLang `host` and `port` field defaults, `127.0.0.1` and `30000` (pinned tag v0.5.20; that the `--host`/`--port` flags use these fields rests on the server-arguments docs above): https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/arg_groups/fields/serving.py#L72-L73 - SGLang `--config` ("Read CLI options from a config file. Must be a YAML file with configuration options."; pinned tag v0.5.20): https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L397-L399 - SGLang configuration loader (`yaml.safe_load`, a `.yaml` or `.yml` suffix required, each key turned into the option `--` and the values merged into the argument list before parsing; pinned tag v0.5.20). Sources for this item: https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/utils/server_args_config_parser.py#L118-L187 and https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/utils/server_args_config_parser.py#L68-L83 - SGLang environment registry, checked for an API-key or admin-key entry and found to carry none (pinned tag v0.5.20): https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/environ.py - SGLang key exposure at v0.5.20 (read in source, not run). Sources for the `api_key` and `admin_api_key` fields: https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/arg_groups/fields/serving.py#L155-L162. Sources for `resolved_dict` over every field and `_launch_command` joined from the merged argument list: https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L311-L325, https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L704-L711 and https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L732-L736. Sources for the INFO log of `server_args=`: https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/entrypoints/engine.py#L1109. Sources for `/server_info` with no `auth_level` decorator: https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/entrypoints/http_server.py#L818-L850. Sources for a normal endpoint requiring only the API key: https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/utils/auth.py#L145-L151 - TGI launcher: `api_key` is `#[clap(long, env)]`, and the launcher pushes `--api-key` and the value into the router's arguments (pinned tag v3.3.7): https://github.com/huggingface/text-generation-inference/blob/v3.3.7/launcher/src/main.rs - Linux ACLs, acl(5): default ACL inheritance and how a default ACL takes the place of the umask for new files (man-pages; checked against the local acl 2.3.2 manual, October 2026): https://man7.org/linux/man-pages/man5/acl.5.html - getfacl(1): `-d` (default ACL), `-c` (omit the header) and `-p` (keep leading `/`) options (man-pages; checked against the local acl 2.3.2 manual, October 2026): https://man7.org/linux/man-pages/man1/getfacl.1.html - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - llama: llama.cpp (e0dff58475bc9ed68eedcb265ee998f2fcabb3b1) - vllm: vLLM (v0.30.0) - tgi: TGI launcher/image (v3.3.7) - sglang: SGLang (v0.5.20) - vllm-a: vLLM source (dee37d89115db4c94a820a79a78a7828e141c910) - vllm-b: vLLM second source (8c1557a79c539ffe82d004d2a0c8d7b5e71159ce) - tgi-router: TGI router (24ee40d143d8d046039f12f76940a85886cbe152) - tgi-docs: TGI documentation (unknown) - sglang-rolling: SGLang documentation (rolling) (unknown) - triton: Triton source (546a78766fb112128aa0b10a70c55f4f39c3b1df) - triton-rolling: Triton documentation (rolling) (unknown) - lmstudio: LM Studio documentation (unknown) - webui: text-generation-webui source (c022565b1257a9c7d9a5128c8b4c03587559cf08) - webui-api: text-generation-webui API source (619a2b8ee4b7e48541a9be5c07e04cd31b91b44f) - webui-docs: text-generation-webui API documentation (ceade2eb1ba3f84518076270df2240b6bbb01da0) - curl: curl minimum write-out version (7.75.0) Claims (full source mapping in the JSON block below): - llama-bind (REASONED; llama): llama-server defaults to 127.0.0.1:8080; retain loopback and exclude inherited LLAMA_ARG_* overrides. - llama-key (REASONED; llama): api-key-file reads one key per line, with # comments; protect the plaintext file and avoid argv/environment secret exposure. - llama-env (REASONED; llama): Clear LLAMA_API_KEY and LLAMA_ARG_API_KEY_FILE; reject a file without a key line and test native refusal/acceptance directly. - llama-tls (REASONED; llama): Native PEM TLS needs a build with DLLAMA_OPENSSL=ON and ssl-key-file/ssl-cert-file; otherwise use a TLS proxy. - vllm-key (REASONED; vllm-a, vllm-b): At both source commits --api-key takes precedence over VLLM_API_KEY fallback; keep secrets out of launch argv. - vllm-file (REASONED; vllm-a): Use protected YAML via two-word --config FILE; --config=FILE is accepted without loading the key; clear inherited VLLM_API_KEY. - vllm-process (REASONED; vllm-a): Python spawn transfers API server arguments through a pipe; opt-in Rust frontend puts api_key in --args-json argv, so clear VLLM_USE_RUST_FRONTEND. - vllm-logs (REASONED; vllm-a): HTTP startup redacts api_key, but gRPC logs the argument set unredacted; protect those logs. - vllm-scope (REASONED; vllm): API key covers /v1, /v2, /inference and /cohere; /invocations, enabled profiler routes and unprotected plugins remain outside it. - vllm-bind (REASONED; vllm): Unset frontend host listens on 0.0.0.0:8000; explicitly select 127.0.0.1. - vllm-tls (REASONED; vllm-a): Frontend ssl-keyfile, ssl-certfile and ssl-ca-certs support TLS; proxy or tunnel remains the recommended boundary. - vllm-isolation (REASONED; vllm): HTTP key/TLS do not protect internal TCPStore, ZMQ or collective channels; isolate trusted cluster members and treat Ray as one trust domain. - vllm-store (REASONED; vllm): TCPStore can bind all interfaces; stateless rank-zero binding and other rendezvous paths differ; inventory actual transport sockets. - vllm-master (REASONED; vllm): Multi-node multiprocessing uses master-addr and master-port, default 29501; DP adds ports and Ray V2 can bind port zero. - vllm-handshake (REASONED; vllm): Remote/elastic multiprocessing DP uses startup TCP handshake on data-parallel-address/rpc-port, default 29550; otherwise IPC, with Ray bypassing that constructor. - vllm-channels (REASONED; vllm): Remote engine request/result and coordinator channels use dynamic TCP, local paths generally IPC with elastic exceptions. - vllm-queues (REASONED; vllm): Worker queues add dynamic TCP only for remote readers, using detected multiprocessing or Ray node addresses; headless removes only the HTTP frontend. - nixl (REASONED; vllm): Opt-in NIXL handshake defaults localhost:5600 plus DP index and starts with metadata; its host/port settings are separate. - mooncake (REASONED; vllm): Producer/kv_both bootstrap uses wildcard 8998 at selected ranks; producer workers use node IP/dynamic ports; consumer-only workers omit those listeners. - vllm-grpc (REASONED; vllm): Optional gRPC replaces HTTP with unauthenticated plaintext on host/port, default 0.0.0.0:8000; the documented grpc-port does not match this CLI. - vllm-supervisor (REASONED; vllm): Multi-port external-LB mode adds keyless health supervision after child readiness on host:9256 by default; frontend TLS options apply. - vllm-node-ip (REASONED; vllm): Set per-node VLLM_HOST_IP and serving master/DP addresses deliberately; automatic node detection can choose public IP or fall back to wildcard. - vllm-ports (REASONED; vllm): VLLM_PORT starts allocation, not a firewall range; DP fallback variables are not serving-flag substitutes and DP master port also reserves ten ports. - vllm-single (REASONED; vllm): Single-GPU normally uses file store/local IPC with ROCm AITER TCP exception; Gloo groups still require listener inventory. - tgi-bind (REASONED; tgi): Launcher defaults 0.0.0.0:3000 via hostname/port flags or HOSTNAME/PORT; bind privately. - tgi-image (REASONED; tgi): Main image sets PORT=80; Docker's non-IP hostname makes router fall back to wildcard. - tgi-lifecycle (REASONED; tgi-docs): Guide records maintenance mode and archive on 2026-03-21; retain controls and plan migration. - tgi-key-argv (REASONED; tgi): Launcher passes API_KEY or --api-key to router argv; no launcher input avoids that exposure to local process observers. - tgi-auth (REASONED; tgi-router): Bearer middleware protects base inference routes with 401, not health/info/metrics, KServe, v1/models or Vertex prediction; enforce proxy allowlists/auth. - tgi-tls (REASONED; tgi-docs): Launcher has no TLS option; terminate TLS in front. - tgi-metrics (REASONED; tgi-docs, tgi): Separate Prometheus listener defaults 9000 and is unauthenticated; keep it private. - sglang-bind (REASONED; sglang): SGLang defaults 127.0.0.1:30000; retain private host/port. - sglang-auth (REASONED; sglang): api-key protects ordinary endpoints; admin-api-key separately protects administrative operations, not server_info; an empty API key leaves ordinary requests open. - sglang-file (REASONED; sglang): Protected YAML --config keeps both keys out of kernel argv; no documented stdin/environment input exists at the pin. - sglang-log (REASONED; sglang): INFO startup logs all resolved server_args including both keys; protect the log. - sglang-info (REASONED; sglang): server_info/get_server_info expose resolved keys and launch_command to ordinary API-key holders; exclude them or trust those holders with admin authority. - sglang-tls (REASONED; sglang-rolling): ssl-keyfile/ssl-certfile/ssl-ca-certs provide native TLS; enable-ssl-refresh reloads renewed certificates. - triton-listeners (REASONED; triton): Triton defaults to wildcard HTTP 8000, gRPC 8001 and metrics 8002; bind each privately. - triton-protocols (REASONED; triton, triton-rolling): HTTP/gRPC default enabled; disable unused protocols and metrics, while keeping gateway authorization. - triton-tls (REASONED; triton): gRPC supports TLS and mutual TLS with certificate/key flags; HTTP requires fronting TLS. - triton-secrets (REASONED; triton): The restricted-API handlers pass getopt_long optarg to ParseRestrictedFeatureOption, which extracts the literal key/value from the argument; these flags put the secret in argv. - triton-categories (REASONED; triton): All nine compiled categories default unrestricted; restrictions are per category/protocol, and groups must not overlap. - triton-health (REASONED; triton): Unrestricted health/metadata/inference, including HTTP generate/generate_stream, need no key; restricting health also affects probes. - triton-logging (REASONED; triton): Logging API reads settings/file path and changes verbosity/format/levels, but cannot change log_file. - triton-trace (REASONED; triton): Trace settings API remains available when tracing is OFF; default triton mode needs a startup file path before enabling tracing, and API cannot change that path. - triton-shm (REASONED; triton): Shared-memory status is readable; registration/use defaults off until allow-client-shm=true, with GPU/platform restrictions. - triton-repository (REASONED; triton): Repository index is readable; API load/unload require explicit mode, not default none or poll, and can accept config/inline files or unload dependents. - triton-reads (REASONED; triton): Model-config and supported statistics reads are independent of model-control mode. - triton-denial (REASONED; triton): HTTP expects configured header and returns 403 on failure; gRPC prefixes triton-grpc-protocol- and returns UNAVAILABLE with restriction message. - triton-cloud (REASONED; triton): Conditional Vertex/SageMaker listeners need separate inventory/isolation; SageMaker invoke bypasses restrictions and Vertex redirect headers can reach unrestricted metrics. - triton-metrics (REASONED; triton): Metrics 8002 at /metrics or /metrics/ is unauthenticated and outside both restriction flags; isolate or disable it. - lmstudio-bind (REASONED; lmstudio): Desktop server uses localhost:1234; Serve on Local Network or bind 0.0.0.0 widens access. - lmstudio-auth (REASONED; lmstudio): Authentication defaults off; Require Authentication and bearer tokens are documented for 0.4.0+; enable before remote access. - lmstudio-tls (REASONED; lmstudio): Server settings list no TLS; use a tailnet or authenticated TLS proxy for remote access. - webui-listeners (REASONED; webui, webui-api): Gradio defaults 127.0.0.1:7860; optional --api defaults 127.0.0.1:5000; both start unauthenticated. - webui-bind (REASONED; webui, webui-api): --listen widens both enabled surfaces; listen-port/api-port move them and listen-host only works with listen. - webui-publication (REASONED; webui-api, webui): --share publishes Gradio UI and --public-api publishes API through a Cloudflare tunnel; treat both as publication. - webui-ui-auth (REASONED; webui): gradio-auth-path protects UI only; file entries split on commas/newlines and every colon, so use permitted username and generated hex password. - webui-api-auth (REASONED; webui-api, webui-docs): api-key protects OpenAI routes via bearer and Anthropic messages via x-api-key; admin-key protects administration and defaults to api-key, not vice versa. - webui-key-logs (REASONED; webui-api, webui): API startup logs api-key and distinct admin-key in plaintext at INFO; protect output and rotate after disclosure. - webui-file (REASONED; webui): API keys lack file/environment flags; private external user-data-dir CMD_FLAGS.txt supplies in-process arguments, keeping keys out of kernel argv. - webui-checkout (REASONED; webui): Checkout CMD_FLAGS.txt is tracked; git diff/stash and one-click autostash/reset can expose or discard keys; use a new private directory outside checkout. - webui-launcher (REASONED; webui): Run server.py directly: one-click unquoted argument joining and shell=True can split paths or execute shell syntax. - webui-api-only (REASONED; webui, webui-docs): --api --nowebui removes UI; ssl-keyfile/ssl-certfile provide both surfaces TLS, with proxy TLS/auth the recommended pattern. - webui-host (REASONED; webui-api): Loopback API rejects Host other than localhost/127.0.0.1 with 400 unless listen/public-api is set; proxy must rewrite Host. - verify-listeners (REASONED; llama, vllm, tgi, triton, sglang): Inspect all backend listeners, namespaces and publications; ss alone proves no external isolation. - verify-proxy-auth (REASONED; tgi-router, curl): Proxy model-list probes expect no-key 401 and keyed 200/list; TGI model listing does not establish native key enforcement. - verify-invocations (REASONED; vllm): Use valid JSON/served model and upstream evidence: unknown-model 404 or proxy-intercepted errors cannot establish vLLM route denial. - verify-webui (REASONED; webui-api, curl): No-key API model-list 200 is exposure; 401 is inconclusive until confirmed at the API listener, as a proxy's 401 looks the same; TLS/transport errors or invalid Host are inconclusive and proxy rejection proves only proxy policy. - verify-triton (REASONED; triton, curl): Direct HTTP logging should change from 200/settings to 403/restriction; other errors prove neither this control nor other categories/protocols. - verify-cluster (REASONED; vllm): Inventory startup/dynamic sockets; trusted peers connect to cross-node endpoints while outsiders fail with firewall evidence; local-only endpoints need local positive controls. - verify-transport (REASONED; vllm): TCP probe establishes only reachability; stale ports, wrong address or stopped service are inconclusive, and RDMA/non-TCP need separate tests. - vllm-parser-run (DEMONSTRATED; vllm-a): Isolated parser harness with stubbed logger/minimal serve parser loaded --config FILE but not --config=FILE; no vLLM server was run. - webui-parser-run (DEMONSTRATED; webui): Imported shared.py parsed keys and a later flag from a private path containing a space while kernel cmdline held neither; no server was run. - argv-test (DEMONSTRATED; llama, vllm-a, sglang, tgi, webui, triton): Recorded procps-ng 4.0.4 stub-process checks distinguish named secret flags from file flags; JSON, abbreviations, hidden/scrubbed argv remain misses. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "732025b7f5972943243190c76962d47e3be508af0f78115257c1f7be9a2724a2", "components": { "llama": { "name": "llama.cpp", "basis": "e0dff58475bc9ed68eedcb265ee998f2fcabb3b1", "sources": { "s808f8d1c8da9": "https://github.com/ggml-org/llama.cpp/blob/e0dff58475bc9ed68eedcb265ee998f2fcabb3b1/tools/server/README.md" } }, "vllm": { "name": "vLLM", "basis": "v0.30.0", "sources": { "s65a60be2808c": "https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L140-L247", "s36fcb4c4fc18": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/serve/middleware/authenticate.py#L11-L62", "s90b2ada8e38b": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/cli_args.py#L256-L266", "sb9c63e342665": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/launcher.py#L211-L225", "s701a469f3c23": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/launcher.py#L270-L288", "se5660cf62498": "https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L109-L138", "sec32bab94990": "https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L3-L55", "s1de6a9f4ed49": "https://github.com/vllm-project/vllm/blob/v0.30.0/docs/usage/security.md#L442-L538", "sd18839fe8984": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/parallel_state.py#L1793-L1880", "s27ba1937dca0": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/utils.py#L467-L506", "s7abb84b61c6e": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/utils.py#L634-L647", "s1fc694d68aac": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/config/parallel.py#L100-L330", "s8c1221a0c7b9": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/config/parallel.py#L890-L960", "s15bf642ce14e": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/ray_executor_v2.py#L127-L139", "sfecabc3bc16d": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/engine/utils.py#L1039-L1084", "se3e528b9ca5c": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/engine/utils.py#L1130-L1247", "s8ddf90ed2cb1": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/engine/coordinator.py#L79-L97", "sb6a7effd4318": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/device_communicators/shm_broadcast.py#L519-L537", "s2dad806d290a": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/multiproc_executor.py#L148-L169", "s66d892167b5d": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/ray_executor_v2.py#L327-L336", "s19f4f6a6790f": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/cli/serve.py#L215-L253", "sdb9dac146cd4": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py#L299-L382", "s0ec5750d8161": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py#L75-L79", "s2c10a0015812": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/envs.py#L1650-L1680", "s97fe4257dfc6": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L1160-L1187", "s623327f195f8": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L1793-L1801", "s0341e26f43ae": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L2261-L2296", "s0fa9693f3666": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py#L948-L1035", "sd52612723dde": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/cli_args.py#L424-L429", "sb6766a13ce4a": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/grpc_server.py#L87-L118", "sbc83e9d09208": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/cli/serve.py#L145-L148", "s471a29420e3a": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/cli/serve.py#L85-L95", "sf811dc2aeb8d": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/cli_args.py#L264-L266", "s7d70cef86248": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/dp_supervisor.py#L220-L238", "sd80aa5c7d7cc": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/entrypoints/launchers/dp_supervisor.py#L322-L349", "sdf64db690b1c": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L34-L73", "s0497a5e3fe44": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/engine/arg_utils.py#L2310-L2336", "s92bab6d08b69": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/envs.py#L701-L715", "s7cb33671d83d": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L166-L247", "scfeadcdcd9a1": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/envs.py#L1433-L1445", "s3b1ce10c3e66": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/distributed/parallel_state.py#L494-L508", "s67bc8be47a91": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/utils/network_utils.py#L135-L147", "sed65b4428b70": "https://github.com/vllm-project/vllm/blob/v0.30.0/vllm/v1/executor/uniproc_executor.py#L77-L88" } }, "tgi": { "name": "TGI launcher/image", "basis": "v3.3.7", "sources": { "s6d442de48f37": "https://github.com/huggingface/text-generation-inference/blob/v3.3.7/launcher/src/main.rs#L769-L774", "se7504fa5bd0c": "https://github.com/huggingface/text-generation-inference/blob/v3.3.7/Dockerfile#L147-L149", "sbe4cfd078ceb": "https://github.com/huggingface/text-generation-inference/blob/v3.3.7/router/src/server.rs#L1906-L1910", "s3f8623221ee5": "https://github.com/huggingface/text-generation-inference/blob/v3.3.7/launcher/src/main.rs" } }, "sglang": { "name": "SGLang", "basis": "v0.5.20", "sources": { "saf08ef6910b0": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/arg_groups/fields/serving.py#L72-L73", "sbf57dc2f54d2": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/arg_groups/fields/serving.py#L155-L162", "sa5df753aef88": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/utils/auth.py#L145-L151", "scce1a63feba8": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L397-L399", "s0a4966847154": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/utils/server_args_config_parser.py#L118-L187", "s8ca6d030efbc": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/utils/server_args_config_parser.py#L68-L83", "scc4de2d1f4bc": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/environ.py", "s50e55d90147c": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/entrypoints/engine.py#L1109", "se29cdc6dade1": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L311-L325", "sc1a3bbee2b6e": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L704-L711", "sedc94f049a2d": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/server_args.py#L732-L736", "sd5cd1ab725c3": "https://github.com/sgl-project/sglang/blob/v0.5.20/python/sglang/srt/entrypoints/http_server.py#L818-L850" } }, "vllm-a": { "name": "vLLM source", "basis": "dee37d89115db4c94a820a79a78a7828e141c910", "sources": { "s7b472d8bd2d0": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/cli_args.py#L304", "sd13651484ed1": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/serve/middleware/register.py#L32-L36", "safb9d5e38921": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L329-L330", "sccd45a9ffb02": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/v1/utils.py#L210-L246", "sc14e6c37774c": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/v1/utils.py#L384-L411", "sba4bc217b9de": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/cli/serve.py#L63-L64", "scf43e725574b": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/serve/utils/api_utils.py#L271-L286", "s4b161060c056": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/grpc_server.py#L64", "s24c7ec8ee646": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/cli_args.py#L315-L320", "s7efc87237582": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L517-L548", "sf25d9a5a25e7": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L585-L596", "sfab88dc159ff": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/utils/argparse_utils.py#L621-L622", "sfe5345378dcf": "https://github.com/vllm-project/vllm/blob/dee37d89115db4c94a820a79a78a7828e141c910/vllm/entrypoints/launchers/cli_args.py#L425-L430" } }, "vllm-b": { "name": "vLLM second source", "basis": "8c1557a79c539ffe82d004d2a0c8d7b5e71159ce", "sources": { "sdbf0512be177": "https://github.com/vllm-project/vllm/blob/8c1557a79c539ffe82d004d2a0c8d7b5e71159ce/vllm/entrypoints/launchers/cli_args.py#L296", "se5d922e084ed": "https://github.com/vllm-project/vllm/blob/8c1557a79c539ffe82d004d2a0c8d7b5e71159ce/vllm/envs.py#L801" } }, "tgi-router": { "name": "TGI router", "basis": "24ee40d143d8d046039f12f76940a85886cbe152", "sources": { "sc46baeb2e20d": "https://github.com/huggingface/text-generation-inference/blob/24ee40d143d8d046039f12f76940a85886cbe152/router/src/server.rs" } }, "tgi-docs": { "name": "TGI documentation", "basis": "unknown", "sources": { "s07efbedb395d": "https://github.com/huggingface/text-generation-inference", "sf21d69ac884f": "https://huggingface.co/docs/text-generation-inference/reference/launcher" } }, "sglang-rolling": { "name": "SGLang documentation (rolling)", "basis": "unknown", "sources": { "sd4ed6d838a92": "https://docs.sglang.io/docs/advanced_features/server_arguments" } }, "triton": { "name": "Triton source", "basis": "546a78766fb112128aa0b10a70c55f4f39c3b1df", "sources": { "s33bf29bf71da": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.h#L191-L219", "s67ab19969241": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.h#L51-L67", "sa915d43059a2": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L708-L725", "sc8b533580b3b": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L558-L576", "s5f927b497e5d": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L508-L516", "s705af5df3dd8": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L632-L640", "s1b9ce89125bd": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/restricted_features.h#L37-L112", "sa9c069223fba": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L2095-L2163", "s79fae2bcb514": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L3324-L3334", "s2a4cdfc6be30": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L4939-L4953", "s7f5038fa0fe2": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L2031-L2149", "s2fee6c80ec1d": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.h#L180-L188", "s4b469f1c21de": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L1759-L2027", "sce84579a9a6e": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/tracer.cc#L1244-L1249", "s682fdddf06a8": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/tracer.cc#L221-L232", "sc68b8b9a1b21": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L739-L750", "sb1f8ac2356f6": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_shared_memory.md#L29-L68", "s601665063934": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L2177-L2378", "s9fe885785c49": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_shared_memory.md#L65-L68", "s6f16e1d770dc": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_model_repository.md#L136-L152", "s48cab4cab77d": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_model_repository.md#L350-L400", "sa63a1d3d8242": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/docs/protocol/extension_model_repository.md#L59-L90", "s7d85aace8e33": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L424-L434", "s8501fbc9ecb4": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L1684-L1755", "s2f7f38a83db9": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.cc#L194-L225", "s614cd673f20f": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/grpc/grpc_server.h#L47-L49", "sad0e75f73ba4": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/sagemaker_server.cc#L198-L225", "s672bf7fa57b3": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/vertex_ai_server.cc#L142-L145", "s0c7905e1e132": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/vertex_ai_server.cc#L245-L272", "s0ce822aef316": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/vertex_ai_server.cc#L34-L37", "se9f3f1a88325": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.cc#L308-L345", "s9cd72f11c287": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/http_server.h#L143-L157", "s9b9781d2f4b7": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L1329-L1330", "s187aa891771b": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L1420-L1423", "sa56acd1228f1": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L1561-L1566", "sae12ee4472dc": "https://github.com/triton-inference-server/server/blob/546a78766fb112128aa0b10a70c55f4f39c3b1df/src/command_line_parser.cc#L2094-L2164" } }, "triton-rolling": { "name": "Triton documentation (rolling)", "basis": "unknown", "sources": { "sf92f4669ae27": "https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/deploy.html" } }, "lmstudio": { "name": "LM Studio documentation", "basis": "unknown", "sources": { "s776be8700529": "https://lmstudio.ai/docs/developer/core/server", "s6b9f80dc5c59": "https://lmstudio.ai/docs/developer/core/server/serve-on-network", "sb756d0785d3d": "https://lmstudio.ai/docs/developer/openai-compat", "sb940f6563319": "https://lmstudio.ai/docs/developer/core/authentication", "sb7e0fa7490b2": "https://lmstudio.ai/docs/developer/core/server/settings" } }, "webui": { "name": "text-generation-webui source", "basis": "c022565b1257a9c7d9a5128c8b4c03587559cf08", "sources": { "sb0d40ae366d1": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/shared.py", "see2c95000c1a": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/server.py#L90-L95", "s7b1b7795f4bb": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/api/script.py#L594-L597", "s63f4da305bf6": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/shared.py#L50", "sb5e84cf31785": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/paths.py#L5-L21", "s9b1b924c1e6f": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/user_data/CMD_FLAGS.txt", "sb88452cb3112": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L24", "s45c4e1f82410": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/modules/shared.py#L221-L236", "s553a5ba9a675": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L396", "sbee956b3df58": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L504", "sfc2e359190f8": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L527", "s4655a1f1241b": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L208", "sc2a4d6823a2b": "https://github.com/oobabooga/textgen/blob/c022565b1257a9c7d9a5128c8b4c03587559cf08/one_click.py#L485-L486" } }, "webui-api": { "name": "text-generation-webui API source", "basis": "619a2b8ee4b7e48541a9be5c07e04cd31b91b44f", "sources": { "s7534b811a6d8": "https://github.com/oobabooga/textgen/blob/619a2b8ee4b7e48541a9be5c07e04cd31b91b44f/modules/api/script.py", "s3847a98d7189": "https://github.com/oobabooga/textgen/blob/619a2b8ee4b7e48541a9be5c07e04cd31b91b44f/modules/api/script.py#L599-L602" } }, "webui-docs": { "name": "text-generation-webui API documentation", "basis": "ceade2eb1ba3f84518076270df2240b6bbb01da0", "sources": { "s3ed187b2ec4e": "https://github.com/oobabooga/textgen/blob/ceade2eb1ba3f84518076270df2240b6bbb01da0/docs/12%20-%20OpenAI%20API.md" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "llama-bind": {"text": "llama-server defaults to 127.0.0.1:8080; retain loopback and exclude inherited LLAMA_ARG_* overrides.", "components": ["llama"], "sources": ["llama:s808f8d1c8da9"], "status": "REASONED"}, "llama-key": {"text": "api-key-file reads one key per line, with # comments; protect the plaintext file and avoid argv/environment secret exposure.", "components": ["llama"], "sources": ["llama:s808f8d1c8da9"], "status": "REASONED"}, "llama-env": {"text": "Clear LLAMA_API_KEY and LLAMA_ARG_API_KEY_FILE; reject a file without a key line and test native refusal/acceptance directly.", "components": ["llama"], "sources": ["llama:s808f8d1c8da9"], "status": "REASONED"}, "llama-tls": {"text": "Native PEM TLS needs a build with DLLAMA_OPENSSL=ON and ssl-key-file/ssl-cert-file; otherwise use a TLS proxy.", "components": ["llama"], "sources": ["llama:s808f8d1c8da9"], "status": "REASONED"}, "vllm-key": {"text": "At both source commits --api-key takes precedence over VLLM_API_KEY fallback; keep secrets out of launch argv.", "components": ["vllm-a", "vllm-b"], "sources": ["vllm-a:s7b472d8bd2d0", "vllm-b:sdbf0512be177", "vllm-a:sd13651484ed1", "vllm-b:se5d922e084ed"], "status": "REASONED"}, "vllm-file": {"text": "Use protected YAML via two-word --config FILE; --config=FILE is accepted without loading the key; clear inherited VLLM_API_KEY.", "components": ["vllm-a"], "sources": ["vllm-a:safb9d5e38921", "vllm-a:sd13651484ed1", "vllm-a:s7efc87237582", "vllm-a:sf25d9a5a25e7", "vllm-a:sfab88dc159ff", "vllm-a:sfe5345378dcf"], "status": "REASONED"}, "vllm-process": {"text": "Python spawn transfers API server arguments through a pipe; opt-in Rust frontend puts api_key in --args-json argv, so clear VLLM_USE_RUST_FRONTEND.", "components": ["vllm-a"], "sources": ["vllm-a:sccd45a9ffb02", "vllm-a:sc14e6c37774c", "vllm-a:sba4bc217b9de"], "status": "REASONED"}, "vllm-logs": {"text": "HTTP startup redacts api_key, but gRPC logs the argument set unredacted; protect those logs.", "components": ["vllm-a"], "sources": ["vllm-a:scf43e725574b", "vllm-a:s4b161060c056"], "status": "REASONED"}, "vllm-scope": {"text": "API key covers /v1, /v2, /inference and /cohere; /invocations, enabled profiler routes and unprotected plugins remain outside it.", "components": ["vllm"], "sources": ["vllm:s65a60be2808c", "vllm:s36fcb4c4fc18"], "status": "REASONED", "verify": [1]}, "vllm-bind": {"text": "Unset frontend host listens on 0.0.0.0:8000; explicitly select 127.0.0.1.", "components": ["vllm"], "sources": ["vllm:s90b2ada8e38b", "vllm:sb9c63e342665", "vllm:s701a469f3c23"], "status": "REASONED"}, "vllm-tls": {"text": "Frontend ssl-keyfile, ssl-certfile and ssl-ca-certs support TLS; proxy or tunnel remains the recommended boundary.", "components": ["vllm-a"], "sources": ["vllm-a:s24c7ec8ee646"], "status": "REASONED"}, "vllm-isolation": {"text": "HTTP key/TLS do not protect internal TCPStore, ZMQ or collective channels; isolate trusted cluster members and treat Ray as one trust domain.", "components": ["vllm"], "sources": ["vllm:se5660cf62498", "vllm:sec32bab94990", "vllm:s1de6a9f4ed49"], "status": "REASONED"}, "vllm-store": {"text": "TCPStore can bind all interfaces; stateless rank-zero binding and other rendezvous paths differ; inventory actual transport sockets.", "components": ["vllm"], "sources": ["vllm:sd18839fe8984", "vllm:s27ba1937dca0", "vllm:s7abb84b61c6e", "vllm:se5660cf62498"], "status": "REASONED"}, "vllm-master": {"text": "Multi-node multiprocessing uses master-addr and master-port, default 29501; DP adds ports and Ray V2 can bind port zero.", "components": ["vllm"], "sources": ["vllm:s1fc694d68aac", "vllm:s8c1221a0c7b9", "vllm:s15bf642ce14e"], "status": "REASONED"}, "vllm-handshake": {"text": "Remote/elastic multiprocessing DP uses startup TCP handshake on data-parallel-address/rpc-port, default 29550; otherwise IPC, with Ray bypassing that constructor.", "components": ["vllm"], "sources": ["vllm:sfecabc3bc16d", "vllm:se3e528b9ca5c", "vllm:s1fc694d68aac"], "status": "REASONED"}, "vllm-channels": {"text": "Remote engine request/result and coordinator channels use dynamic TCP, local paths generally IPC with elastic exceptions.", "components": ["vllm"], "sources": ["vllm:sfecabc3bc16d", "vllm:se3e528b9ca5c", "vllm:s8ddf90ed2cb1"], "status": "REASONED"}, "vllm-queues": {"text": "Worker queues add dynamic TCP only for remote readers, using detected multiprocessing or Ray node addresses; headless removes only the HTTP frontend.", "components": ["vllm"], "sources": ["vllm:sb6a7effd4318", "vllm:s2dad806d290a", "vllm:s66d892167b5d", "vllm:s19f4f6a6790f"], "status": "REASONED"}, "nixl": {"text": "Opt-in NIXL handshake defaults localhost:5600 plus DP index and starts with metadata; its host/port settings are separate.", "components": ["vllm"], "sources": ["vllm:sdb9dac146cd4", "vllm:s0ec5750d8161", "vllm:s2c10a0015812"], "status": "REASONED"}, "mooncake": {"text": "Producer/kv_both bootstrap uses wildcard 8998 at selected ranks; producer workers use node IP/dynamic ports; consumer-only workers omit those listeners.", "components": ["vllm"], "sources": ["vllm:s97fe4257dfc6", "vllm:s623327f195f8", "vllm:s0341e26f43ae", "vllm:s0fa9693f3666"], "status": "REASONED"}, "vllm-grpc": {"text": "Optional gRPC replaces HTTP with unauthenticated plaintext on host/port, default 0.0.0.0:8000; the documented grpc-port does not match this CLI.", "components": ["vllm"], "sources": ["vllm:sd52612723dde", "vllm:sb6766a13ce4a"], "status": "REASONED"}, "vllm-supervisor": {"text": "Multi-port external-LB mode adds keyless health supervision after child readiness on host:9256 by default; frontend TLS options apply.", "components": ["vllm"], "sources": ["vllm:sbc83e9d09208", "vllm:s471a29420e3a", "vllm:sf811dc2aeb8d", "vllm:s7d70cef86248", "vllm:sd80aa5c7d7cc"], "status": "REASONED"}, "vllm-node-ip": {"text": "Set per-node VLLM_HOST_IP and serving master/DP addresses deliberately; automatic node detection can choose public IP or fall back to wildcard.", "components": ["vllm"], "sources": ["vllm:sdf64db690b1c", "vllm:s0497a5e3fe44", "vllm:s92bab6d08b69"], "status": "REASONED"}, "vllm-ports": {"text": "VLLM_PORT starts allocation, not a firewall range; DP fallback variables are not serving-flag substitutes and DP master port also reserves ten ports.", "components": ["vllm"], "sources": ["vllm:s7cb33671d83d", "vllm:scfeadcdcd9a1", "vllm:s92bab6d08b69"], "status": "REASONED"}, "vllm-single": {"text": "Single-GPU normally uses file store/local IPC with ROCm AITER TCP exception; Gloo groups still require listener inventory.", "components": ["vllm"], "sources": ["vllm:s3b1ce10c3e66", "vllm:s67bc8be47a91", "vllm:sed65b4428b70"], "status": "REASONED"}, "tgi-bind": {"text": "Launcher defaults 0.0.0.0:3000 via hostname/port flags or HOSTNAME/PORT; bind privately.", "components": ["tgi"], "sources": ["tgi:s6d442de48f37"], "status": "REASONED"}, "tgi-image": {"text": "Main image sets PORT=80; Docker's non-IP hostname makes router fall back to wildcard.", "components": ["tgi"], "sources": ["tgi:se7504fa5bd0c", "tgi:sbe4cfd078ceb"], "status": "REASONED"}, "tgi-lifecycle": {"text": "Guide records maintenance mode and archive on 2026-03-21; retain controls and plan migration.", "components": ["tgi-docs"], "sources": ["tgi-docs:s07efbedb395d"], "status": "REASONED"}, "tgi-key-argv": {"text": "Launcher passes API_KEY or --api-key to router argv; no launcher input avoids that exposure to local process observers.", "components": ["tgi"], "sources": ["tgi:s3f8623221ee5"], "status": "REASONED"}, "tgi-auth": {"text": "Bearer middleware protects base inference routes with 401, not health/info/metrics, KServe, v1/models or Vertex prediction; enforce proxy allowlists/auth.", "components": ["tgi-router"], "sources": ["tgi-router:sc46baeb2e20d"], "status": "REASONED"}, "tgi-tls": {"text": "Launcher has no TLS option; terminate TLS in front.", "components": ["tgi-docs"], "sources": ["tgi-docs:sf21d69ac884f"], "status": "REASONED"}, "tgi-metrics": {"text": "Separate Prometheus listener defaults 9000 and is unauthenticated; keep it private.", "components": ["tgi-docs", "tgi"], "sources": ["tgi-docs:sf21d69ac884f", "tgi:s3f8623221ee5"], "status": "REASONED"}, "sglang-bind": {"text": "SGLang defaults 127.0.0.1:30000; retain private host/port.", "components": ["sglang"], "sources": ["sglang:saf08ef6910b0"], "status": "REASONED"}, "sglang-auth": {"text": "api-key protects ordinary endpoints; admin-api-key separately protects administrative operations, not server_info; an empty API key leaves ordinary requests open.", "components": ["sglang"], "sources": ["sglang:sbf57dc2f54d2", "sglang:sa5df753aef88"], "status": "REASONED"}, "sglang-file": {"text": "Protected YAML --config keeps both keys out of kernel argv; no documented stdin/environment input exists at the pin.", "components": ["sglang"], "sources": ["sglang:scce1a63feba8", "sglang:s0a4966847154", "sglang:s8ca6d030efbc", "sglang:scc4de2d1f4bc"], "status": "REASONED"}, "sglang-log": {"text": "INFO startup logs all resolved server_args including both keys; protect the log.", "components": ["sglang"], "sources": ["sglang:s50e55d90147c", "sglang:se29cdc6dade1"], "status": "REASONED"}, "sglang-info": {"text": "server_info/get_server_info expose resolved keys and launch_command to ordinary API-key holders; exclude them or trust those holders with admin authority.", "components": ["sglang"], "sources": ["sglang:sd5cd1ab725c3", "sglang:se29cdc6dade1", "sglang:sc1a3bbee2b6e", "sglang:sedc94f049a2d", "sglang:sa5df753aef88"], "status": "REASONED"}, "sglang-tls": {"text": "ssl-keyfile/ssl-certfile/ssl-ca-certs provide native TLS; enable-ssl-refresh reloads renewed certificates.", "components": ["sglang-rolling"], "sources": ["sglang-rolling:sd4ed6d838a92"], "status": "REASONED"}, "triton-listeners": {"text": "Triton defaults to wildcard HTTP 8000, gRPC 8001 and metrics 8002; bind each privately.", "components": ["triton"], "sources": ["triton:s33bf29bf71da", "triton:s67ab19969241"], "status": "REASONED"}, "triton-protocols": {"text": "HTTP/gRPC default enabled; disable unused protocols and metrics, while keeping gateway authorization.", "components": ["triton", "triton-rolling"], "sources": ["triton-rolling:sf92f4669ae27", "triton:s33bf29bf71da", "triton:sa915d43059a2"], "status": "REASONED"}, "triton-tls": {"text": "gRPC supports TLS and mutual TLS with certificate/key flags; HTTP requires fronting TLS.", "components": ["triton"], "sources": ["triton:sc8b533580b3b", "triton:s67ab19969241"], "status": "REASONED"}, "triton-secrets": {"text": "The restricted-API handlers pass getopt_long optarg to ParseRestrictedFeatureOption, which extracts the literal key/value from the argument; these flags put the secret in argv.", "components": ["triton"], "sources": ["triton:s5f927b497e5d", "triton:s705af5df3dd8", "triton:s9b9781d2f4b7", "triton:s187aa891771b", "triton:sa56acd1228f1", "triton:sae12ee4472dc"], "status": "REASONED"}, "triton-categories": {"text": "All nine compiled categories default unrestricted; restrictions are per category/protocol, and groups must not overlap.", "components": ["triton"], "sources": ["triton:s1b9ce89125bd", "triton:sa9c069223fba"], "status": "REASONED"}, "triton-health": {"text": "Unrestricted health/metadata/inference, including HTTP generate/generate_stream, need no key; restricting health also affects probes.", "components": ["triton"], "sources": ["triton:s1b9ce89125bd", "triton:s79fae2bcb514", "triton:s2a4cdfc6be30"], "status": "REASONED"}, "triton-logging": {"text": "Logging API reads settings/file path and changes verbosity/format/levels, but cannot change log_file.", "components": ["triton"], "sources": ["triton:s7f5038fa0fe2"], "status": "REASONED"}, "triton-trace": {"text": "Trace settings API remains available when tracing is OFF; default triton mode needs a startup file path before enabling tracing, and API cannot change that path.", "components": ["triton"], "sources": ["triton:s2fee6c80ec1d", "triton:s4b469f1c21de", "triton:sce84579a9a6e", "triton:s682fdddf06a8", "triton:sc68b8b9a1b21"], "status": "REASONED"}, "triton-shm": {"text": "Shared-memory status is readable; registration/use defaults off until allow-client-shm=true, with GPU/platform restrictions.", "components": ["triton"], "sources": ["triton:sb1f8ac2356f6", "triton:s601665063934", "triton:s9fe885785c49"], "status": "REASONED"}, "triton-repository": {"text": "Repository index is readable; API load/unload require explicit mode, not default none or poll, and can accept config/inline files or unload dependents.", "components": ["triton"], "sources": ["triton:s6f16e1d770dc", "triton:s48cab4cab77d", "triton:sa63a1d3d8242", "triton:s7d85aace8e33"], "status": "REASONED"}, "triton-reads": {"text": "Model-config and supported statistics reads are independent of model-control mode.", "components": ["triton"], "sources": ["triton:s8501fbc9ecb4"], "status": "REASONED"}, "triton-denial": {"text": "HTTP expects configured header and returns 403 on failure; gRPC prefixes triton-grpc-protocol- and returns UNAVAILABLE with restriction message.", "components": ["triton"], "sources": ["triton:sa9c069223fba", "triton:s2f7f38a83db9", "triton:s614cd673f20f", "triton:s2a4cdfc6be30"], "status": "REASONED"}, "triton-cloud": {"text": "Conditional Vertex/SageMaker listeners need separate inventory/isolation; SageMaker invoke bypasses restrictions and Vertex redirect headers can reach unrestricted metrics.", "components": ["triton"], "sources": ["triton:sad0e75f73ba4", "triton:s672bf7fa57b3", "triton:s0c7905e1e132", "triton:s0ce822aef316"], "status": "REASONED"}, "triton-metrics": {"text": "Metrics 8002 at /metrics or /metrics/ is unauthenticated and outside both restriction flags; isolate or disable it.", "components": ["triton"], "sources": ["triton:sa915d43059a2", "triton:se9f3f1a88325", "triton:s9cd72f11c287"], "status": "REASONED"}, "lmstudio-bind": {"text": "Desktop server uses localhost:1234; Serve on Local Network or bind 0.0.0.0 widens access.", "components": ["lmstudio"], "sources": ["lmstudio:s776be8700529", "lmstudio:s6b9f80dc5c59", "lmstudio:sb756d0785d3d"], "status": "REASONED"}, "lmstudio-auth": {"text": "Authentication defaults off; Require Authentication and bearer tokens are documented for 0.4.0+; enable before remote access.", "components": ["lmstudio"], "sources": ["lmstudio:sb940f6563319"], "status": "REASONED"}, "lmstudio-tls": {"text": "Server settings list no TLS; use a tailnet or authenticated TLS proxy for remote access.", "components": ["lmstudio"], "sources": ["lmstudio:sb7e0fa7490b2"], "status": "REASONED"}, "webui-listeners": {"text": "Gradio defaults 127.0.0.1:7860; optional --api defaults 127.0.0.1:5000; both start unauthenticated.", "components": ["webui", "webui-api"], "sources": ["webui:sb0d40ae366d1", "webui-api:s7534b811a6d8"], "status": "REASONED"}, "webui-bind": {"text": "--listen widens both enabled surfaces; listen-port/api-port move them and listen-host only works with listen.", "components": ["webui", "webui-api"], "sources": ["webui:sb0d40ae366d1", "webui-api:s7534b811a6d8"], "status": "REASONED"}, "webui-publication": {"text": "--share publishes Gradio UI and --public-api publishes API through a Cloudflare tunnel; treat both as publication.", "components": ["webui-api", "webui"], "sources": ["webui:sb0d40ae366d1", "webui-api:s7534b811a6d8"], "status": "REASONED"}, "webui-ui-auth": {"text": "gradio-auth-path protects UI only; file entries split on commas/newlines and every colon, so use permitted username and generated hex password.", "components": ["webui"], "sources": ["webui:see2c95000c1a"], "status": "REASONED"}, "webui-api-auth": {"text": "api-key protects OpenAI routes via bearer and Anthropic messages via x-api-key; admin-key protects administration and defaults to api-key, not vice versa.", "components": ["webui-api", "webui-docs"], "sources": ["webui-api:s7534b811a6d8", "webui-docs:s3ed187b2ec4e"], "status": "REASONED"}, "webui-key-logs": {"text": "API startup logs api-key and distinct admin-key in plaintext at INFO; protect output and rotate after disclosure.", "components": ["webui-api", "webui"], "sources": ["webui-api:s3847a98d7189", "webui:s7b1b7795f4bb"], "status": "REASONED"}, "webui-file": {"text": "API keys lack file/environment flags; private external user-data-dir CMD_FLAGS.txt supplies in-process arguments, keeping keys out of kernel argv.", "components": ["webui"], "sources": ["webui:sb0d40ae366d1", "webui:s63f4da305bf6", "webui:sb5e84cf31785", "webui:s45c4e1f82410"], "status": "REASONED"}, "webui-checkout": {"text": "Checkout CMD_FLAGS.txt is tracked; git diff/stash and one-click autostash/reset can expose or discard keys; use a new private directory outside checkout.", "components": ["webui"], "sources": ["webui:s9b1b924c1e6f", "webui:sb88452cb3112", "webui:s553a5ba9a675", "webui:sbee956b3df58", "webui:sfc2e359190f8"], "status": "REASONED"}, "webui-launcher": {"text": "Run server.py directly: one-click unquoted argument joining and shell=True can split paths or execute shell syntax.", "components": ["webui"], "sources": ["webui:sb88452cb3112", "webui:s4655a1f1241b", "webui:sc2a4d6823a2b"], "status": "REASONED"}, "webui-api-only": {"text": "--api --nowebui removes UI; ssl-keyfile/ssl-certfile provide both surfaces TLS, with proxy TLS/auth the recommended pattern.", "components": ["webui", "webui-docs"], "sources": ["webui:sb0d40ae366d1", "webui-docs:s3ed187b2ec4e"], "status": "REASONED"}, "webui-host": {"text": "Loopback API rejects Host other than localhost/127.0.0.1 with 400 unless listen/public-api is set; proxy must rewrite Host.", "components": ["webui-api"], "sources": ["webui-api:s7534b811a6d8"], "status": "REASONED"}, "verify-listeners": {"text": "Inspect all backend listeners, namespaces and publications; ss alone proves no external isolation.", "components": ["llama", "vllm", "tgi", "triton", "sglang"], "sources": ["llama:s808f8d1c8da9", "vllm:s90b2ada8e38b", "tgi:s6d442de48f37", "triton:s33bf29bf71da", "sglang:saf08ef6910b0"], "status": "REASONED", "verify": [1]}, "verify-proxy-auth": {"text": "Proxy model-list probes expect no-key 401 and keyed 200/list; TGI model listing does not establish native key enforcement.", "components": ["tgi-router", "curl"], "sources": ["tgi-router:sc46baeb2e20d", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-invocations": {"text": "Use valid JSON/served model and upstream evidence: unknown-model 404 or proxy-intercepted errors cannot establish vLLM route denial.", "components": ["vllm"], "sources": ["vllm:s65a60be2808c", "vllm:s36fcb4c4fc18"], "status": "REASONED", "verify": [1]}, "verify-webui": {"text": "No-key API model-list 200 is exposure; 401 is inconclusive until confirmed at the API listener, as a proxy's 401 looks the same; TLS/transport errors or invalid Host are inconclusive and proxy rejection proves only proxy policy.", "components": ["webui-api", "curl"], "sources": ["webui-api:s7534b811a6d8", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2]}, "verify-triton": {"text": "Direct HTTP logging should change from 200/settings to 403/restriction; other errors prove neither this control nor other categories/protocols.", "components": ["triton", "curl"], "sources": ["triton:s7f5038fa0fe2", "triton:s2a4cdfc6be30", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [3]}, "verify-cluster": {"text": "Inventory startup/dynamic sockets; trusted peers connect to cross-node endpoints while outsiders fail with firewall evidence; local-only endpoints need local positive controls.", "components": ["vllm"], "sources": ["vllm:se5660cf62498", "vllm:sfecabc3bc16d", "vllm:se3e528b9ca5c"], "status": "REASONED", "verify": [4]}, "verify-transport": {"text": "TCP probe establishes only reachability; stale ports, wrong address or stopped service are inconclusive, and RDMA/non-TCP need separate tests.", "components": ["vllm"], "sources": ["vllm:se5660cf62498", "vllm:sec32bab94990"], "status": "REASONED", "verify": [4]}, "vllm-parser-run": {"text": "Isolated parser harness with stubbed logger/minimal serve parser loaded --config FILE but not --config=FILE; no vLLM server was run.", "components": ["vllm-a"], "sources": ["vllm-a:safb9d5e38921"], "status": "DEMONSTRATED", "evidence": "The pinned parser class was also run on its own, outside vLLM, with vLLM's logger stubbed and a minimal `serve` parser in place of vLLM's own: with `--config FILE` it turned the generated file's `api-key` line into a one-element key list, and with `--config=FILE` it left the key unset."}, "webui-parser-run": {"text": "Imported shared.py parsed keys and a later flag from a private path containing a space while kernel cmdline held neither; no server was run.", "components": ["webui"], "sources": ["webui:s63f4da305bf6"], "status": "DEMONSTRATED", "evidence": "The pinned `modules/shared.py`, imported with `--user-data-dir` pointing at a directory the block had written (a path containing a space, with a `--listen-port` line added after the key lines), parsed both keys and that flag from `CMD_FLAGS.txt` while the process's `/proc/self/cmdline` held neither"}, "argv-test": {"text": "Recorded procps-ng 4.0.4 stub-process checks distinguish named secret flags from file flags; JSON, abbreviations, hidden/scrubbed argv remain misses.", "components": ["llama", "vllm-a", "sglang", "tgi", "webui", "triton"], "sources": ["llama:s808f8d1c8da9", "vllm-a:s7b472d8bd2d0", "sglang:sbf57dc2f54d2", "tgi:s3f8623221ee5", "webui:sb0d40ae366d1", "triton:s5f927b497e5d"], "status": "DEMONSTRATED", "evidence": "`--api-key DUMMY_NOT_A_SECRET`, `--admin-key DUMMY_NOT_A_SECRET`, `--admin-api-key=DUMMY_NOT_A_SECRET`, `--gradio-auth u:DUMMY_NOT_A_SECRET`, `--http-restricted-api=model-repository:admin-key=DUMMY_NOT_A_SECRET`, `--grpc-restricted-protocol=model-repository:admin-key=DUMMY_NOT_A_SECRET` and an empty `--api-key=` each counted `1`"} } } ```` ====================================================================== ==> gradio.md ====================================================================== # Gradio: launch() authentication and TLS Gradio is a Python framework that serves an interactive web app from a script's launch() call. This guide helps your AI assistant make sure an app is configured securely: a login required, TLS in place, public sharing avoided, file access and uploads restricted, and queued work and URL fetching bounded. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/gradio.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Gradio 6.31.0 and Gradio historical fixes 6.16.0 and 6.20.0; 2 further sources carry no recorded version. Metadata reviewed 2026-11-07; documentation checked September 2026. The guide records 53 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Gradio binds to `127.0.0.1` by default. Two launch choices create exposure: `server_name="0.0.0.0"` (all interfaces) and `share=True` (a public `*.gradio.live` URL through Gradio's relay). Neither is acceptable without authentication. At the time of writing, this guide targets **Gradio 6.31.0**, the recommended deployment baseline for these examples. Parameters and defaults were checked against the 6.28.0 source and vendor documentation in September 2026. In November 2026 the cited Interface ranges were re-read at 6.31.0 and the Blocks and routes source was compared between 6.28.0 and 6.31.0, by source reading with nothing run; the vendor documentation was not rechecked. Historical security fixes below explain why older minimum versions are insufficient. See the [6.31.0 release](https://github.com/gradio-app/gradio/releases/tag/gradio%406.31.0). The launch examples are alternatives. Apply the relevant controls to your app's single `launch()` call, and configure its queue before launching. ## 1. Require a login `launch()` takes credentials directly: ```python import os demo.launch( server_name="127.0.0.1", # bind loopback explicitly; if omitted it can inherit GRADIO_SERVER_NAME share=False, # if omitted, share can inherit GRADIO_SHARE=True or auto-enable in Colab/hosted notebooks auth=(os.environ["GRADIO_USER"], os.environ["GRADIO_PASS"]), auth_message="Authorized users only", ) ``` `auth` also accepts a list of `(user, password)` tuples or a callable `f(username, password) -> bool`, which lets you check hashed credentials per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). Keep the credentials in environment variables, not in the script. MFA: `auth` is single-factor, with no rate limiting, login throttling, or automatic lockout. The callable form allows a TOTP step (for example, verify a [pyotp](https://github.com/pyauth/pyotp) code appended to the password), but a bare verification callback is not enough on its own: it needs secure enrollment, secret storage, and replay rejection. Fronting the app with Cloudflare Access or an Authelia-protected proxy is the cleaner route, but configure that service's own brute-force controls (Authelia regulation, Cloudflare rate limiting) and disable or separately protect any Gradio login you keep behind it, since fronting does not add throttling or lockout to the built-in login. Options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). For external authentication, `launch(auth_dependency=callback)` accepts a callback receiving a FastAPI request and returning a user ID for an authorized request or `None` to deny it. It cannot be combined with `auth`. Validate the identity provider's session or token in that callback; do not trust an identity header supplied directly by a caller. If a proxy supplies identity, it must remove caller-supplied identity headers, inject authenticated values, and be the only route to the backend. Enforce MFA, login throttling, lockout, and session lifetime at the identity provider or proxy. See the [launch reference](https://gradio.app/docs/gradio/blocks) and [authentication dependency and login handlers](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). Native password login creates random bearer session tokens stored in the app process's memory. It issues an HttpOnly, Secure cookie and an HttpOnly insecure-cookie fallback; the server accepts either. There is **no server-side token expiry** in this native token store. Restarting the app clears its tokens, and the native `/logout` route revokes all sessions for the username by default (`all_session=True`). Browser cookie lifetime is not server-side revocation. Require HTTPS, prevent direct backend access, and enforce a finite session lifetime externally on every request; a copied native cookie must not bypass that external check. Inspect both cookies when configuring proxy cookie hardening. See the [native session implementation](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). ## 2. Enable TLS For a public deployment, prefer a reverse proxy or tunnel in front of a loopback-bound Gradio app: [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), or [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Gradio can also serve HTTPS itself with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md) or [self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md): ```python import os demo.launch( server_name="0.0.0.0", server_port=8443, share=False, # even for a TLS deployment: never implicitly share via GRADIO_SHARE or Colab ssl_certfile="/path/cert.pem", ssl_keyfile="/path/key.pem", ssl_verify=False, # only for self-signed certificates; skips validating your own cert auth=(os.environ["GRADIO_USER"], os.environ["GRADIO_PASS"]), ) ``` `ssl_keyfile_password` exists for encrypted keys. `ssl_verify=False` here affects how the launcher checks its own certificate; it is needed for self-signed certificates and unnecessary with a CA-issued one. Supply any key password through your secret manager or environment, not a literal in the script. See the [TLS parameters](https://gradio.app/docs/gradio/blocks) and [launcher certificate check](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py). Behind a proxy, keep `server_name="127.0.0.1"` explicit. The argument defaults to `None`, resolving through `GRADIO_SERVER_NAME` to loopback when that variable is unset. If the proxy publishes the app beneath `/demo`, `root_path="/demo"` builds the corresponding URLs. It is **not an access control** and does not replace authentication or prevent direct access to the backend. See the [launch reference](https://gradio.app/docs/gradio/blocks). ## 3. share=True is publication `share=True` publishes the app at a random public URL for anyone who obtains the link, with your machine executing the requests. Use it only for short demos, always combined with `auth`, and shut it down afterwards. It is not a deployment mechanism; for persistent authenticated remote access use [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md). Set `share=False` explicitly in the deployed launch call. When omitted, sharing can inherit `GRADIO_SHARE=True` or auto-enable in Colab and hosted notebooks. A loopback listener does not prove that a share tunnel is absent. See the [sharing defaults and launch behavior](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py). ## 4. Restrict file access `launch()` serves files over a `/gradio_api/file=` route. By default it serves three things: static files you register with `gr.set_static_paths()`, the directories you list in `allowed_paths`, and Gradio's own cache. A path handed back through a file-based output component can be served too when it lies in `allowed_paths`, the current working directory, or the system temp directory; files Gradio caches this way are reachable by every user of the app, not only the one who produced it. Dotfiles in the working directory are excluded from automatic caching unless explicitly permitted. This exception does not justify storing secrets beside the app. See [file access](https://gradio.app/guides/file-access). - The reliable boundary is a minimal filesystem available to the process, with narrowly scoped additional paths. `allowed_paths` **adds exposure**; it is not an exclusive sandbox. An empty list does not disable cache serving or eligible returned files. Launch from a directory that holds no `.env`, keys, or private source, and add only the directories users must download from to `allowed_paths` and `gr.set_static_paths()`, as absolute paths, each holding only public files, since a directory there exposes all of its files and subdirectories. - `blocked_paths` takes precedence over the default set, `allowed_paths`, and `set_static_paths()`, but it is a blocklist: filesystem quirks, including case handling and NTFS alternate data streams, have enabled bypasses of path blocklists, so treat it as a backstop and keep Gradio on a current release. - Never return untrusted user input through a file-based output component: Gradio can serve an eligible returned path that lies in the working or temp directory, so a crafted value can hand back a file you did not mean to expose. The working-directory dotfile exception does not protect other eligible files. - Set `max_file_size` on `launch()` (bytes, or a string such as `"5mb"`); it defaults to `None`, which leaves uploads unlimited. It is a per-file limit, not a total-storage quota, so pair it with disk and rate controls at the proxy. **6.20.0 fixed missing enforcement on multipart `/component_server` uploads**; setting this argument on an older version is insufficient. Use the 6.31.0 baseline. See the [upload fix](https://github.com/gradio-app/gradio/releases/tag/gradio%406.20.0). Both `allowed_paths` and `blocked_paths` default to `None` in 6.31.0. They fall back to the comma-separated `GRADIO_ALLOWED_PATHS` and `GRADIO_BLOCKED_PATHS` environment variables **even when passed `[]`**. Audit the environment of the actual service process. Remove unintended inherited values; when moving policy into explicit arguments, retain any required denies in `blocked_paths`. Do not assume an empty argument cleared an inherited grant or deny. See the [path resolution implementation](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py). Set `GRADIO_TEMP_DIR` in the service environment before starting Gradio, for example: ``` GRADIO_TEMP_DIR=/srv/gradio-cache ``` Provision that absolute directory for the Gradio service account, with restrictive filesystem permissions such as mode `0700`, a storage quota, and an appropriate cleanup policy. Keep it separate from source, secrets, and other services' temporary data. This relocates Gradio's cache; it does **not** add per-user download authorization. Cached uploads and outputs remain readable by other app users who obtain their URLs. Filesystem permissions constrain other local accounts, not requests served by the Gradio process. See the [cache location and sharing rules](https://gradio.app/guides/file-access). CVE-2023-51449, the historical `/file` traversal vulnerability, and the associated SSRF issue were fixed in **4.11.0**. That is a historical fix floor, not a deployment baseline. **6.16.0** includes FileExplorer traversal fixes. The case-handling advisory GHSA-j2jg-fq62-7c3h / CVE-2025-23042 remains a reason to distrust blocklists as the primary boundary; its patched-version records conflict, so this guide assigns it no precise fixed version. Use **6.31.0** as the baseline here. See the [historical traversal advisory](https://github.com/gradio-app/gradio/security/advisories/GHSA-6qm2-wpxq-7qh2), [case-handling advisory](https://github.com/gradio-app/gradio/security/advisories/GHSA-j2jg-fq62-7c3h), and [6.16.0 fixes](https://github.com/gradio-app/gradio/releases/tag/gradio%406.16.0). ## 5. Disable optional execution surfaces Leave `GRADIO_VIBE_MODE` **unset** in production, and do not start the deployed app with `gradio --vibe`. Its default is the empty string, and the implementation tests string truthiness: **any non-empty value, including `"False"` or `"0"`, enables it**. The vibe-code handlers lack a native login check and allow source reading and modification; vibe mode permits arbitrary code execution on the host. Adding `auth` to ordinary app routes does not secure those handlers. See the [vibe-mode setting](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py), [vibe handlers](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py), and [vendor development-only warning](https://gradio.app/guides/developing-faster-with-reload-mode). Set `launch(mcp_server=False)` unless you deliberately publish MCP tools. Its default is `None`, which consults `GRADIO_MCP_SERVER`; that variable defaults to `"False"`. Enabling it exposes tools beneath `/gradio_api/mcp/`. If MCP is required, protect and test that surface separately. A 401 from `/gradio_api/info` does **not** establish MCP protection; this guide has not demonstrated MCP authentication behavior. See the [MCP setup implementation](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py) and [MCP documentation](https://gradio.app/guides/building-mcp-server-with-gradio). API visibility is separate from authorization: | Setting in Gradio 6.31.0 | Effect | | --- | --- | | `launch(footer_links=["gradio", "settings"])` | Omits the API documentation link. It does not disable the API. This replaces `launch(show_api=False)`. | | Event listener `api_visibility="public"` | The default: documented and callable through Gradio clients. | | Event listener `api_visibility="undocumented"` | Hides documentation while remaining callable. | | Event listener `api_visibility="private"` | Hides documentation and blocks Gradio-client use. Direct HTTP requests can still invoke the function, subject to authentication and queue policy. | | `api_name=None` | Derives an endpoint name from the function. It does not create an inaccessible endpoint. `api_name=False` is no longer a supported public argument. | Both the migration guide and the current reference distinguish Gradio-client visibility from HTTP authorization: setting an event to `"private"` prevents Gradio-client discovery and use, but direct HTTP requests remain possible, and the HTTP prediction handler checks login and queue policy separately. Native login checks protect `/config`, `/gradio_api/info`, prediction routes, and queue join/data routes when `auth` or `auth_dependency` is configured. Hiding a link or endpoint name does not replace those checks. See the [6.x migration guide](https://gradio.app/guides/gradio-6-migration-guide), [event reference](https://gradio.app/docs/gradio/button), and [HTTP handlers](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). ## 6. Bound queued work and close the direct-API bypass Configure `demo.queue(api_open=False, default_concurrency_limit=1, max_size=20)` before launching, and set `max_threads=8` and `max_file_size="5mb"` on `launch()`. This complete, harmless example also applies the optional-surface and collection settings described in this guide: ```python import os import gradio as gr def echo(text): return text demo = gr.Interface( fn=echo, inputs="text", outputs="text", api_name="echo", flagging_mode="never", analytics_enabled=False, ) demo.queue(api_open=False, default_concurrency_limit=1, max_size=20) demo.launch( server_name="127.0.0.1", share=False, auth=(os.environ["GRADIO_USER"], os.environ["GRADIO_PASS"]), auth_message="Authorized users only", mcp_server=False, max_threads=8, max_file_size="5mb", run_history=False, footer_links=["gradio", "settings"], ) ``` Use the audited environment and isolated filesystem from sections 4 and 5, and publish through the authenticated HTTPS proxy from sections 1 and 2. Replace the echo function with the intended application. At the time of writing, the 6.31.0 defaults and these illustrative limits are: | Control | Default | Example | | --- | --- | --- | | `queue(api_open=...)` | Argument `None`; effective `True` off Hugging Face Spaces | `False` | | `default_concurrency_limit` | `1` unless changed through `GRADIO_DEFAULT_CONCURRENCY_LIMIT` | `1` | | `max_size` | `None`, no queue-size limit; ZeroGPU Spaces have a special default | `20` | | `max_threads` | `40` | `8` | | `max_file_size` | `None`, no upload-size limit | `"5mb"` | `api_open=False` closes the direct `/gradio_api/run/` and `/gradio_api/api/` bypass **for queued functions**. It does not disable the queued API. Events configured with `queue=False` remain outside this boundary, so audit listener overrides. The default concurrency limit applies to listeners that use the default, not as one global request limit across every function; explicit listener concurrency settings can override it. These numbers are illustrative capacity limits, not per-user quotas or rate limits. Apply request-rate, body-size, storage, and resource controls at the deployment layer as appropriate. A single authenticated caller can otherwise consume the available capacity. See the [queue and launch implementation](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py), [direct-request refusal](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py), and [launch reference](https://gradio.app/docs/gradio/blocks). ## 7. Restrict server-side URL fetching Treat URL inputs as an outbound network surface. MCP file inputs accept URLs, and component processing can fetch remote resources. Gradio 6.31.0 has no general destination-allowlist argument covering every server-side fetch. Use deployment egress restrictions and application-controlled destination allowlists, including validation of redirects and resolved destinations. Do not let callers choose arbitrary remote apps or URLs for server-side fetching. Authentication limits who submits a URL; it does not make the destination safe. See the [MCP file behavior](https://gradio.app/guides/building-mcp-server-with-gradio), [component cache downloads](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py), and [launch reference](https://gradio.app/docs/gradio/blocks). Current fetches are not all unprotected. **6.16.0** fixed SSRF in Image/Gallery SVG processing and Audio streaming processing. **6.20.0** changed `/gradio_api/file=` from an open redirect to an SSRF-protected streaming proxy. Keep those protections and the 6.31.0 baseline; deployment egress policy provides the application-specific destination boundary. See the [6.16.0 fixes](https://github.com/gradio-app/gradio/releases/tag/gradio%406.16.0), [6.20.0 proxy change](https://github.com/gradio-app/gradio/releases/tag/gradio%406.20.0), and [current file route](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). For `gr.load(name, src=..., token=...)`, keep `name` and `src` under application control and load only trusted targets. The `token` can inherit `HF_TOKEN`; the current reference distinguishes model loading, which can also use a locally saved token, from Space loading. A token supplied to a loaded Space can be read by that Space. Do not pass a privileged token to an untrusted Space, and do not assume `token=None` universally means no credentials will be used. See the [load reference](https://gradio.app/docs/gradio/load). ## 8. Disable unnecessary persistent collection Set `flagging_mode="never"` in **`gr.Interface(...)`**, as in section 6. This is the current spelling of `allow_flagging`; it is not a `launch()` argument. The default is resolved through `GRADIO_FLAGGING_MODE` and otherwise is `"manual"`. `"auto"` records every submission and its generated output. The default `CSVLogger` writes beneath `.gradio/flagged`, including saved file data for file-based components. Disable flagging when this collection is unnecessary, and apply an explicit retention and access policy where it is required. See the [Interface reference](https://gradio.app/docs/gradio/interface) and [flagging storage behavior](https://gradio.app/guides/flagging). Consider `launch(run_history=False)`. In 6.31.0 its default is `None`, resolving through `GRADIO_RUN_HISTORY` to `True`. Run history stores runs privately in the browser by default; users can connect a Hugging Face bucket for subsequent runs. Disabling it stops recording, disables the history page, and removes previously saved browser history for that app. Do not assume this deletes separately retained bucket data or existing flagging files. See the [run-history reference](https://gradio.app/docs/gradio/blocks). `analytics_enabled=False` belongs on `Blocks` or `Interface`, as shown in section 6. It disables basic telemetry and is separate from flagging and run history. Its default is `None`, resolving through `GRADIO_ANALYTICS_ENABLED` to `True`. This lower-priority privacy setting is not evidence that prediction inputs are sent as telemetry, and it does not establish that application logs or other integrations retain no inputs. See the [Blocks reference](https://gradio.app/docs/gradio/blocks) and [Interface reference](https://gradio.app/docs/gradio/interface). ## 9. Verify **REASONED:** Verification overview and historical authoring report; no live Gradio, HTTPS ingress, second host, or IdP fixture is supplied for this offline run. Per the guide's cited Gradio 6.31.0 Blocks source and Gradio 6.31.0 routes source, compare the exposed/fixed outcomes specified below. In this marking run, three Python examples parsed, all three Verify shell blocks passed bash -n and ShellCheck 0.11.0 with no output, and stubbed guards rejected invalid inputs without curl calls; these are local checks only. The bash -n, ShellCheck and stub runs recorded here covered the base-URL probe block and the protected request helper in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Every bash -n, ShellCheck and stub run recorded in this section also predates the change that makes each of the three Verify shell blocks report its result in its exit status (1 for a finding, 2 for a guard refusal, 3 for a failed or inconclusive check, 0 for a pass or a named unclassified observation), so those runs covered an earlier form. They also predate the listener inventory block's `ss` presence check, which refuses with status 2 when `ss` is not installed. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of each block. Stub runs of the listener inventory block's current form, with a stub `ss`, in a clean shell and under an inherited `readonly`, `declare -i` and `set -e`, exited 0 when `ss` passed, 3 when it failed and 2 when it was missing. The following environment and run account describes the earlier authoring session. These service checks are **reasoned, not demonstrated**. The authoring environment has no installed Gradio or container runtime, has restricted network access, and provides no authorized live Gradio deployment, second-host ingress fixture, or identity provider. Python syntax, Bash parsing, ShellCheck 0.11.0, and the repository's guard scan were checked locally; they do not demonstrate service behavior. Expected exposed/fixed outcomes rest on the cited Gradio 6.31.0 source and vendor documentation. **REASONED:** Comparison-fixture isolation and evidence collection, per the guide's cited Gradio 6.31.0 Blocks source and Gradio 6.31.0 routes source; no authorized live Gradio or public-ingress fixture is supplied here. The local shell and stub checks recorded above ran, but publication, native refusal, and successful authorized controls remain the expected comparisons below, not observations. Those shell and stub checks covered the base-URL probe block and the protected request helper in an earlier form, whose guard refusals exited with status 1; neither block was re-run in its current form, whose guard refusals exit with status 2. Use a disposable private fixture for exposed comparisons, with harmless data and disposable credentials. Record the installed Gradio version, effective configuration, response content, and the layer issuing each refusal. Do not expose an unauthenticated or vibe-enabled comparison to the internet. ### Binding, sharing, TLS, and native login **REASONED:** The deployment host and a live share-tunnel fixture are unavailable here. On the application host, inspect the listener and the current launch's complete startup output. An exposed fixture with `server_name="0.0.0.0"` should show an all-interface bind; the proxy-backed configuration should show loopback. A shared fixture should print its public URL and be reachable through it; the fixed fresh process must satisfy all three conditions below. See the [launch and sharing implementation](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py). ```bash ( # a subshell, so the exits below end only the subshell set +e # errexit off here, so a failed ss is reported below instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } ss -tlnp # With server_port=None, the port search starts at 7860 unless GRADIO_SERVER_PORT changes it. # Expect loopback unless deliberately exposed. ss shows a local BIND only, and it CANNOT detect # a share=True tunnel: that keeps the listener on loopback and opens an OUTBOUND frpc connection to # Gradio's relay publishing a *.gradio.live URL. Confirm share is off with all three checks, and if you # cannot confirm ALL THREE, treat it as UNKNOWN (not off): (1) the previous app process or # notebook kernel and its tunnel are terminated FIRST (a rerun with share=False in a persistent # kernel clears the URL but can leave the earlier tunnel connected), (2) share=False is set in # the launch() call, and (3) THIS fresh run's full startup output shows no "Running on public # URL: https://.gradio.live" line (a shared run prints it in its startup output, so read the current # launch's output, not a stale log). If you know a former public URL, also confirm it is now # unreachable. Terminate any share tunnel after a demo. case "$?" in 0) echo "block exit 0, an unclassified observation: ss cannot show a share=True tunnel, so read the listeners above and confirm all three share conditions yourself"; exit 0 ;; *) echo "ss -tlnp failed; inconclusive"; exit 3 ;; esac ) ``` **REASONED:** following block; native authentication and certificate checks require live HTTPS ingress and an outside client, unavailable here. A served login page does NOT prove the API is gated. Probe a protected API route WITHOUT a Gradio session cookie. With native authentication absent on the isolated comparison fixture, `/gradio_api/info` should return 200 with API information; with native authentication enabled, Gradio's own `login_check` should return 401. See the [protected info route and login check](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). **REASONED:** Native versus proxy authorization, per the guide's cited Gradio 6.31.0 routes source; no live Gradio behind an authenticated proxy is supplied here. The helper's stubbed GET retained the protected header-file argument and printed 'STUB curl called; no HTTP observation'. That stub run covered the protected request helper in an earlier form, whose guard refusals exited with status 1; it was not re-run on the current form, whose guard refusals exit with status 2. Expect API information without native auth, native 401 without its cookie after enabling auth, and 200 with a fresh cookie while proxy authorization stays constant. Keep the request otherwise identical to a real client, including any fronting-proxy authorization, so the ONLY thing removed is the Gradio cookie - then a 401 can be attributed to Gradio's native auth, not the proxy. Supply proxy credentials from a protected header file using the helper below; never put them in command arguments. `auth_dependency` deployments need the separate external-auth comparison below. **REASONED:** following block; base-URL substitution, native API authentication, and TLS, per the guide's cited Gradio 6.31.0 routes source and Gradio Blocks.launch() parameters (rolling documentation, checked September 2026). No live HTTPS Gradio or outside client is supplied here: expect exposed 200, native-auth 401, and, via the protected helper below, fresh-cookie 200. Locally bash -n and ShellCheck 0.11.0 exited 0 without output; missing marker/assignment, wrong count, empty/placeholder/example/angle/control-character URLs, and HTTP were rejected with explanatory messages and zero stub curl calls. A substituted HTTPS prefix reached stub curl with /gradio_api/info and printed 'STUB curl called; no HTTP observation'. The bash -n, ShellCheck and stub runs recorded here covered the following block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Substitute the actual base URL inside the quotes. For a fronting proxy this might be `https://gradio.example.com`; for a direct section-2 TLS launch use the actual host with `:8443`. Include any `root_path` prefix, and use the SAME base URL (host, port, prefix) for `/login` below. Do not put credentials or signed URLs in the substitution. A literal apostrophe requires proper shell quoting. Paste whole blocks; the marker and count checks do not protect fragments pasted below their guards or shells with shadowed builtins. ```bash # REASONED: native API authentication and TLS; no live HTTPS Gradio fixture or outside client here. Expected exposed/fixed outcomes follow the cited 6.31.0 handlers. ( set +e # errexit off here, so a failed request is reported below instead of ending the block set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_GRADIO_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one base URL; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute the actual base URL inside the quotes; not probing"; exit 2 ;; https://*) set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -D - -o /dev/null -w 'anon=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{http_code}' \ "${1%/}/gradio_api/info"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # curl's exit status, then its output printf '%s\n' "${3%$'\n'*}" # the response headers and the anon= line case "$2:${3##*$'\n'}" in 0:401) echo "pass: /gradio_api/info refused the request with 401; a fronting proxy's own 401 looks the same, so attribute it with the protected helper below"; exit 0 ;; 0:200) echo "FINDING: /gradio_api/info answered 200 without a Gradio session cookie"; exit 1 ;; 0:*) echo "HTTP ${3##*$'\n'} is neither native authentication's 401 nor the exposed 200; inconclusive"; exit 3 ;; *) echo "curl exit $2: a TLS, DNS or connection failure is not an authentication refusal; inconclusive"; exit 3 ;; esac ;; *) echo "use an HTTPS base URL; not probing"; exit 2 ;; esac ) ``` **REASONED:** preceding block; TLS and native API probing, including its URL guards and request options, per the guide's cited Gradio 6.31.0 routes source and Gradio Blocks.launch() parameters (rolling documentation, checked September 2026). No live HTTPS endpoint or deployment CA is supplied here; expect API 200 before native auth, 401 without its cookie afterwards, and, via the helper below, 200 with a fresh cookie, not a transport failure. Local bash -n and ShellCheck 0.11.0 exited 0 without output; guard failures made no stub curl calls, and valid HTTPS preserved -q, -g, --noproxy '*' and the prefix, printing 'STUB curl called; no HTTP observation'. The bash -n, ShellCheck and stub runs recorded here covered the preceding block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. `--noproxy '*'` prevents a client proxy from answering. With a self-signed certificate from section 2, add `--cacert your-ca.pem` using your actual CA file, never `-k`. `-D -` shows status and headers rather than just the body. A TLS, DNS, or connection failure is not an authentication refusal. **REASONED:** Login positive control, per the guide's cited Gradio 6.31.0 routes source; no live Gradio or browser fixture is supplied here. Stubbed LOGIN and GET printed 'STUB curl called; no HTTP observation', retained file-based request arguments, and created response files with mode 0600 in mode-0700 directories. Those stub runs covered the protected request helper in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expect cookie-free native 401 after enabling auth and fresh-cookie 200 with app content; browser gating and real cookie issuance remain unobserved. Positive control: get a Gradio session cookie by POSTing your credentials to `/login` (form fields `username` and `password`), then re-request that same route WITH the cookie and confirm 200 with app content. Static assets are public, so test a protected route, not the home page. In a private browser the login form should appear before the app (necessary, not sufficient). ### Protected requests for the comparisons below **REASONED:** Protected request headers, per the guide's cited Gradio 6.31.0 routes source; no live native/proxy authentication fixture is supplied here. A local mode-0600 dummy header file was passed unchanged by the stubbed helper; its GET printed 'STUB curl called; no HTTP observation'. That stub run covered the protected request helper in an earlier form, whose guard refusals exited with status 1; it was not re-run on the current form, whose guard refusals exit with status 2. Expect exposed API 200, native 401 when only its cookie is removed after enabling auth, and authorized 200 with a fresh cookie. Prepare a mode-0600 header file in a private directory. For a signed-in request, include the native `Cookie:` header and any required proxy authorization. For the native anonymous comparison, retain proxy authorization and remove only Gradio's cookies. Use an empty file when no headers are required. **REASONED:** Request bodies and private results, per the guide's cited Gradio 6.31.0 routes source; no live login/prediction fixture is supplied here. The local marker JSON parsed; stubbed POST and LOGIN printed 'STUB curl called; no HTTP observation' and retained body-file and content-type arguments, with response files mode 0600 and directories mode 0700. Those stub runs covered the protected request helper in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Expect unauthorized refusal after enabling native auth and successful fresh-cookie requests; no real credentials or responses were tested. Prepare a separate protected body file: JSON for `POST`, URL-encoded `username` and `password` form fields for `LOGIN`, and an empty file for `GET`. Do not put credentials in shell history or enable shell tracing while handling them. The helper keeps response headers, bodies, and login cookies private because they can contain credentials. **REASONED:** This helper's request outcomes require the live HTTPS fixture, unavailable here. Use it for the concrete exposed/fixed comparisons below. Its local parsing and guard checks do not establish those outcomes. Those parsing and guard checks covered the following block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. ```bash ( set +e # errexit off here, so a failed request is reported below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ENDPOINT_URL' 'GET' 'REPLACE_WITH_HEADER_FILE' 'REPLACE_WITH_BODY_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "provide endpoint, method, header file and body file; not probing"; exit 2; } case "|$1|$2|$3|$4|" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|*'||'*|*[[:cntrl:]]*) echo "substitute every placeholder inside the quotes; not probing"; exit 2 ;; esac case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the endpoint; not probing"; exit 2 ;; https://*) [ -r "$3" ] || { echo "header file is unreadable"; exit 2; } [ -r "$4" ] || { echo "body file is unreadable"; exit 2; } umask 077 set -- "$@" "$(mktemp -d)" # empty if mktemp fails, which the next line catches [ -d "$5" ] || { echo "cannot create private results directory"; exit 3; } printf 'Private response directory: %s\n' "$5" case "$2" in GET) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; POST) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -H 'Content-Type: application/json' --data-binary "@$4" \ -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; LOGIN) curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 60 \ -H "@$3" -H 'Content-Type: application/x-www-form-urlencoded' --data-binary "@$4" \ -c "$5/cookies" -o "$5/body" -D "$5/headers" \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ;; *) echo "use GET, POST or LOGIN; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$?" # curl's exit status case "$6" in 0) echo "block exit 0, an unclassified observation: a completed request is not a pass on its own, so compare the http= line above and the response in $5 with the comparison you are running"; exit 0 ;; *) echo "curl exit $6: the request failed; inconclusive"; exit 3 ;; esac ;; *) echo "use HTTPS; not probing"; exit 2 ;; esac ) ``` **REASONED:** preceding block; the complete protected helper, including guards, GET/POST/LOGIN dispatch, request files, private responses, and login controls, per the guide's cited Gradio 6.31.0 routes source. No live HTTPS Gradio or deployment CA is supplied here; expect the exposed/fixed comparisons below and fresh-cookie success. Locally bash -n and ShellCheck 0.11.0 exited 0 without output; invalid marker/count/URL/method and missing files produced explanatory refusals with zero stub curl calls. Valid GET/POST/LOGIN printed 'STUB curl called; no HTTP observation', retained request-file arguments, and produced mode-0600 response files in mode-0700 directories. The bash -n, ShellCheck and stub runs recorded here covered the preceding block in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. The guard checks arguments, not file contents. Inspect request files before sending them. For the login positive control, use `LOGIN` against `/login`, then place the returned session cookie in the protected header file and use `GET` against `/gradio_api/info`. Add the actual CA file to these curl invocations when using a private CA. Preserve redacted evidence, then remove disposable accounts, uploads, credentials, and response files. ### File access and upload limits **REASONED:** This requires a running Gradio with writable disposable marker directories and authenticated file downloads, unavailable here. Use the protected helper for each GET. The exposed and fixed outcomes follow the [file access rules](https://gradio.app/guides/file-access) and [authenticated file route](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). **REASONED:** File blocklist discrimination, per the guide's cited Gradio file access (rolling documentation, checked September 2026) and Gradio 6.31.0 routes source; no live authenticated file-serving Gradio is supplied here. Local marker creation/readback and the helper's stubbed GET ran; they do not exercise Gradio's file route. That stubbed GET covered the protected request helper in an earlier form, whose guard refusals exited with status 1; it was not re-run on the current form, whose guard refusals exit with status 2. Expect both marker contents before blocking, then blocked 403 with the other marker still served after restart and fresh login, as detailed below. While signed in, create two harmless marker files with recognizable contents in a directory you added to `allowed_paths`, then request each over `/gradio_api/file=` (with any deployment URL prefix) and confirm BOTH return their marker contents. Only then add one to `blocked_paths` and restart. After the restart, log in again for a fresh session cookie (a restart resets Gradio's session tokens, so the old cookie now returns 401, which is a session failure, not a blocklist result) and confirm BOTH files still exist on disk with their contents (a restart can also clear a temp or cache directory, and a file that vanished returns the same 403 as a blocklist refusal). The step after that on-disk check is to request each file with the fresh cookie: the blocked one must return 403 while the other still returns its contents. For a stronger check, remove it from `blocked_paths`, restart again, and confirm it serves once more. Establishing that both served their contents first, and that the blocked one still exists after the restart, is what distinguishes a `blocked_paths` refusal from a missing file, a wrong path, a login prompt, or a proxy denial (the `/gradio_api/file=` route returns the same refusal for several of these). | Comparison | Request and expected exposed/fixed outcomes | | --- | --- | | Environment fallback and cache isolation | **REASONED:** Requires a live app, two test users, and disposable directories, unavailable here. Put a harmless directory outside the working, system-temp, and cache directories in `GRADIO_ALLOWED_PATHS`; with `allowed_paths=[]`, GET its marker through `/gradio_api/file=` and expect its contents. Unset that environment grant, restart, log in again, and require refusal while an intentionally allowed marker still serves. Separately upload a harmless file as user A, then GET its returned cache URL as user B. Moving `GRADIO_TEMP_DIR` should change storage location, but user B can still read the cached file. Inspect filesystem ownership and quota separately; cache relocation is not user isolation. See [path fallback](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py) and [shared cache](https://gradio.app/guides/file-access). | | Upload size | **REASONED:** Requires a live file component and a component that sends multipart `/gradio_api/component_server` requests, unavailable here. Through the browser, upload valid harmless files below and above 5 MB and inspect the requests to `/gradio_api/upload` and, separately, `/gradio_api/component_server`. With `max_file_size=None`, both sizes should reach the applicable handler. With `"5mb"` on 6.31.0, the smaller file must still work and the larger must be refused for size. A malformed multipart body, absent component handler, or proxy rejection does not demonstrate Gradio's limit. Record the valid multipart fields from the actual component request. See the [6.20.0 enforcement fix](https://github.com/gradio-app/gradio/releases/tag/gradio%406.20.0) and [multipart handler](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). | ### Optional surfaces, authorization, capacity, and collection **REASONED:** Route-prefix and optional-surface comparisons, per the guide's cited Gradio 6.31.0 routes source and Gradio MCP server documentation (rolling documentation, checked September 2026); no live Gradio, browser, MCP client fixture, or IdP is supplied here. Local marker JSON parsed and stubbed helper requests preserved the HTTPS prefix, printing 'STUB curl called; no HTTP observation'. Those stubbed requests covered the protected request helper in an earlier form, whose guard refusals exited with status 1; none was re-run on the current form, whose guard refusals exit with status 2. Each row below specifies its expected exposed/fixed result and, except Vibe mode, a positive control; none was observed against a service. Use the protected request helper and the specified browser or MCP operations. Include the actual deployment prefix in every route. | Comparison | Request and expected exposed/fixed outcomes | | --- | --- | | Vibe mode | **REASONED:** Requires a disposable development app with a readable demo source file, unavailable here. Without a Gradio cookie, GET `/gradio_api/vibe-code`. On an isolated fixture with `GRADIO_VIBE_MODE="False"`, the non-empty value enables the handler and it should return the source. Unset the variable, recreate the process, and require the handler's 403 response. A missing demo file is not evidence that vibe mode is disabled. Record the disabled setting for the write handlers too; do not probe source modification on the deployed app. See the [setting](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py) and [vibe handlers](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). | | MCP | **REASONED:** Requires a working MCP client, Gradio's MCP dependency, and a harmless tool fixture, unavailable here. With MCP deliberately enabled on the private fixture, connect to `/gradio_api/mcp/`, list tools, and invoke the harmless tool successfully. Restart with `mcp_server=False`; that same connection must no longer list or invoke tools while ordinary authenticated app use still works. If publishing MCP, repeat with and without the intended authorization and require anonymous tool calls to be refused by the chosen protecting layer. `/gradio_api/info` is not this test. See [MCP operations](https://gradio.app/guides/building-mcp-server-with-gradio) and [conditional MCP setup](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). | | Visibility and native authorization | **REASONED:** Requires the live echo fixture, unavailable here. On the private comparison fixture, set `api_open=True` and POST `{"data":["GRADIO-LIVE marker"]}` to `/gradio_api/run/echo`. Changing visibility from `"public"` to `"undocumented"` or `"private"` must not be treated as HTTP denial: direct requests can still return the marker. Then require native auth and repeat without its cookie: expect 401. With a fresh cookie, require the marker again. Also compare GET `/config` and `/gradio_api/info`, and replay a valid browser `/gradio_api/queue/join` request with and without the native cookie. Malformed input is not an auth test. See the [event reference](https://gradio.app/docs/gradio/button) and [protected handlers](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). | | Direct queue bypass and capacity | **REASONED:** Requires a live queued fixture and observable handler execution, unavailable here. With native authentication satisfied, POST the same echo JSON to `/gradio_api/run/echo` and `/gradio_api/api/echo`: with `api_open=True`, expect the marker; with `False` and the function queued, expect Gradio's direct-request refusal, HTTP 404. POST it to `/gradio_api/call/echo` and retrieve `/gradio_api/call/echo/EVENT_ID`, substituting the returned event ID, to confirm queued work still completes. For capacity, use a bounded slow harmless handler, keep one call running, and submit enough valid queued calls to fill 20 waiting slots; excess submissions should receive a queue-full refusal. Compare with the private fixture's unlimited queue, and record handler overlap with concurrency 1 versus a deliberately higher listener limit. Inspect `max_threads=8` and listener overrides separately; a queue-full response does not demonstrate a per-user quota or the thread ceiling. See [queue controls](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py) and [queue/direct handlers](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py). | | External auth, prefix, and session lifetime | **REASONED:** Requires HTTPS ingress, a trusted proxy/IdP, two native sessions for one test username, and a second-host backend-access fixture, unavailable here. GET `/config` beneath the deployment prefix: an external-auth callback returning a user ID should permit it; a callback returning `None` should produce 401. Forged identity headers and direct backend access must not bypass the chosen boundary. For native auth, create two sessions using POST `/login`, confirm both can GET `/gradio_api/info`, then GET `/logout` with one session: both old cookies must subsequently fail. Recreate sessions and restart to check token invalidation separately. Inspect both login cookies' attributes. Before external lifetime enforcement, a retained native token has no server expiry; after the configured external lifetime, the same public request must be denied by that external layer until reauthentication, while a fresh authorized session works. Test the configured login-throttling threshold with disposable credentials and proxy/IdP logs. `root_path` only changes the request prefix. See [native sessions and callback handling](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py) and [root_path](https://gradio.app/docs/gradio/blocks). | | Outbound destinations | **REASONED:** Requires controlled allowed and denied destination servers, egress logs, and the relevant file/MCP components, unavailable here. Submit a valid allowed URL through each used fetch surface, including GET `/gradio_api/file=` followed by its encoded URL, and require the expected marker bytes. Repeat with a controlled destination outside the application's allowlist and with a redirect to it. Before application-specific restrictions, a controlled public destination can be fetched; after enforcement it must receive no request, while the allowed control still succeeds. Inspect destination and egress logs, not just HTTP errors. For MCP, invoke the actual file-input tool with those URLs. Never use real metadata services as probes. These comparisons test application destination policy, not a claim that current Gradio fetches lack SSRF protection. See [MCP URL inputs](https://gradio.app/guides/building-mcp-server-with-gradio), [component fetch fixes](https://github.com/gradio-app/gradio/releases/tag/gradio%406.16.0), and [file proxy](https://github.com/gradio-app/gradio/releases/tag/gradio%406.20.0). | | Flagging, history, and telemetry | **REASONED:** Requires a live Interface, disposable browser profile, file components, and observable storage/network activity, unavailable here. Submit a unique harmless marker. With `"manual"`, click Flag and require a CSV entry; with `"auto"`, require an entry without clicking, including saved file data where applicable. With `"never"`, require no new flagging record from the same submission. With `run_history=True`, make a browser submission and inspect `/gradio_api/runs`; after `False`, require recording and the history page to be disabled, while prediction still works. Inspect previous files and any connected bucket separately. Compare basic telemetry with `analytics_enabled=True` and `False`; do not infer prediction-input transmission from telemetry alone. See [flagging](https://gradio.app/docs/gradio/interface), [file storage](https://gradio.app/guides/flagging), and [history and analytics](https://gradio.app/docs/gradio/blocks). | ### REASONED service checks **REASONED:** Service-check evidence summary, per the guide's cited Gradio 6.31.0 Blocks source and Gradio 6.31.0 routes source; the live Gradio and deployment fixtures listed below are not supplied here. Local Python/JSON parsing, bash -n, ShellCheck 0.11.0, marker-file readback, and stubbed request guards ran successfully as recorded above; expected exposed success versus fixed refusal with working authorized controls remains unobserved. The bash -n, ShellCheck and stub runs of the base-URL probe block and the protected request helper covered an earlier form of each, whose guard refusals exited with status 1; neither block was re-run in its current form, whose guard refusals exit with status 2. The checks below remain REASONED from the cited Gradio 6.31.0 source and documentation. Configuration inspection and local syntax checks do not demonstrate service behavior. | Scope | Exposed/fixed checks and prerequisites | Status | | --- | --- | --- | | Gradio controls | Check every REASONED comparison above on Gradio 6.31.0 with recorded dependency versions, disposable data, HTTPS ingress, a second host, native test users, proxy/IdP fixtures, an isolated vibe fixture, MCP tools, queued handlers, multipart components, controlled outbound destinations, and observable browser/server storage. Include share-tunnel termination; native versus proxy refusals; file-marker positive controls across restarts; environment fallback; shared-cache access; both upload routes; visibility versus HTTP authorization; direct queue bypass; capacity and thread behavior; external lifetime and logout/restart revocation; egress restrictions; flagging, history, and telemetry. Record requests, response content, positive controls, effective configuration, timestamps, and cleanup. | **REASONED:** Full comparison inventory, per the guide's cited entries (1 of 2): Gradio 6.31.0 Blocks source, Gradio 6.31.0 routes source, Gradio Blocks.launch() parameters (rolling documentation, checked September 2026), Gradio file access (rolling documentation, checked September 2026), Gradio event reference (rolling documentation, checked September 2026). The guide's cited entries for the full comparison inventory (2 of 2): Gradio MCP server documentation (rolling documentation, checked September 2026), Gradio 6.16.0 release, Gradio 6.20.0 release, Gradio Interface reference (rolling documentation, checked September 2026), and Gradio flagging guide (rolling documentation, checked September 2026). The listed live deployment fixtures are not supplied here. Local syntax/parsing, marker readback, and stubbed request/permission checks ran as recorded above, not the service comparisons. The bash -n, ShellCheck and stub runs of the base-URL probe block and the protected request helper covered an earlier form of each, whose guard refusals exited with status 1; neither block was re-run in its current form, whose guard refusals exit with status 2. Expected exposed/fixed outcomes and controls remain those specified above. REASONED from the cited source and documentation; service behavior is not demonstrated. | ## Sources (checked September 2026) - [Gradio Blocks.launch() parameters (rolling documentation, checked September 2026)](https://gradio.app/docs/gradio/blocks): auth, auth_message, ssl_certfile, ssl_keyfile, ssl_keyfile_password, ssl_verify, server_name, and share; also auth_dependency, root_path, queue/launch limits, footer_links, run_history, and analytics. - [Gradio file access (rolling documentation, checked September 2026)](https://gradio.app/guides/file-access): the `/gradio_api/file=` route; the default set is `set_static_paths`, `allowed_paths`, and the cache; eligible returned paths in `allowed_paths`, the working directory, or the temp directory; working-directory dotfile exclusion; `blocked_paths` precedence; cache sharing across users; and `GRADIO_TEMP_DIR`. - Gradio security advisories: [the `/file` traversal and SSRF fixed in 4.11.0](https://github.com/gradio-app/gradio/security/advisories/GHSA-6qm2-wpxq-7qh2) and [the case-handling blocklist bypass](https://github.com/gradio-app/gradio/security/advisories/GHSA-j2jg-fq62-7c3h). Historical evidence for keeping a current release and minimizing the process filesystem and additional allowed paths; no precise fix version is assigned here to the case-handling advisory. - [Gradio 6.31.0 release](https://github.com/gradio-app/gradio/releases/tag/gradio%406.31.0). - [Gradio 6.31.0 Blocks source](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py): launch and queue signatures/defaults, off-Spaces api_open default, vibe-mode truthiness, path environment fallback, sharing behavior, component cache downloads, API names, and history settings. - [Gradio 6.31.0 routes source](https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py): login checks, native tokens/cookies/logout, auth_dependency, vibe handlers, conditional MCP setup, direct queue-bypass refusal, multipart limits, and SSRF-protected file streaming. - Gradio 6.31.0 Interface source: `flagging_mode` resolution through `GRADIO_FLAGGING_MODE` to `"manual"` ([lines 389 to 399](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L389-L399)) and flagging-callback setup only when the mode is not `"never"` ([lines 467 to 476](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L467-L476)). Flag buttons only for `"manual"` and the submit button for `"auto"` ([lines 607 to 610](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L607-L610) and [lines 657 to 662](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L657-L662)), and no flagging event without flag buttons, with `"auto"` flagging inputs and outputs after each successful submit ([lines 841 to 870](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L841-L870)). - Gradio 6.31.0 Interface source, flag buttons unset unless the mode renders them: the input column ([line 564](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L564)) and the output column ([lines 630 to 635](https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L630-L635)) each start `flag_btns` as `None`. - [Gradio event reference (rolling documentation, checked September 2026)](https://gradio.app/docs/gradio/button): api_visibility, api_name, queue, and listener concurrency. - [Gradio 6 migration guide (rolling documentation, checked September 2026)](https://gradio.app/guides/gradio-6-migration-guide): footer_links and the replacement event visibility API; read its visibility wording with the current reference and HTTP source. - [Gradio development and vibe-mode warning (rolling documentation, checked September 2026)](https://gradio.app/guides/developing-faster-with-reload-mode). - [Gradio MCP server documentation (rolling documentation, checked September 2026)](https://gradio.app/guides/building-mcp-server-with-gradio): tool publication, `/gradio_api/mcp/`, and URL file inputs. - [Gradio 6.16.0 release](https://github.com/gradio-app/gradio/releases/tag/gradio%406.16.0): FileExplorer traversal and Image/Gallery SVG and Audio streaming SSRF fixes. - [Gradio 6.20.0 release](https://github.com/gradio-app/gradio/releases/tag/gradio%406.20.0): multipart `/component_server` max_file_size enforcement and the SSRF-protected file streaming proxy. - [Gradio load reference (rolling documentation, checked September 2026)](https://gradio.app/docs/gradio/load): trusted targets, src, token, HF_TOKEN inheritance, and loaded-Space token disclosure. - [Gradio Interface reference (rolling documentation, checked September 2026)](https://gradio.app/docs/gradio/interface): flagging_mode, GRADIO_FLAGGING_MODE, CSVLogger, `.gradio/flagged`, and analytics_enabled. - [Gradio flagging guide (rolling documentation, checked September 2026)](https://gradio.app/guides/flagging): persistence of flagged inputs, outputs, and file data. ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - gradio: Gradio (6.31.0) - fixes: Gradio historical fixes (6.16.0) - multipart: Gradio historical fixes (6.20.0) - advisory: Gradio security advisories (unknown) - gradio-rolling: Gradio documentation (rolling) (unknown) Claims (full source mapping in the JSON block below): - bind (REASONED; gradio, gradio-rolling): server_name=None resolves through GRADIO_SERVER_NAME to 127.0.0.1 when unset; set loopback explicitly behind the proxy; 0.0.0.0 exposes all interfaces. - port (REASONED; gradio, gradio-rolling): With server_port=None, port search starts at 7860 unless GRADIO_SERVER_PORT changes it; native TLS example explicitly uses 8443. - password (REASONED; gradio, gradio-rolling): auth accepts one tuple, a tuple list or a credential-checking callable; auth_message customizes login; keep credentials outside source. - login-limits (REASONED; gradio, gradio-rolling): Native auth is single-factor without throttling, rate limiting or lockout; a TOTP callable also needs enrollment, secret custody and replay rejection. - external-auth (REASONED; gradio, gradio-rolling): auth_dependency returns a user ID or None and cannot combine with auth; validate identity, sanitize proxy headers and prevent direct backend bypass. - sessions (REASONED; gradio): Native bearer tokens live in process memory without server-side expiry; login issues HttpOnly Secure and insecure fallback cookies, and accepts either. - revocation (REASONED; gradio): Restart clears native tokens; /logout defaults all_session=True, revoking all sessions for a username; cookie lifetime is not server revocation. - external-policy (REASONED; gradio, gradio-rolling): Require HTTPS and externally enforced MFA, throttling, lockout and finite lifetime on every request; a copied native cookie must not bypass that boundary. - tls (REASONED; gradio, gradio-rolling): ssl_certfile/ssl_keyfile enable HTTPS and ssl_keyfile_password supports encrypted keys; ssl_verify=False affects launcher self-checks and is only needed for self-signed certificates. - prefix (REASONED; gradio-rolling): root_path builds URLs beneath the proxy prefix; it neither authenticates nor prevents direct backend access. - share (REASONED; gradio): share=True publishes a public relay URL; omitted share may inherit GRADIO_SHARE=True or auto-enable in hosted notebooks; deploy with explicit False. - file-route (REASONED; gradio, gradio-rolling): /gradio_api/file= serves registered static paths, allowed_paths and cache; returned file outputs can expose eligible working/temp paths to every app user. - allowed-paths (REASONED; gradio-rolling): allowed_paths adds exposure, not a sandbox; empty lists do not disable cache or eligible outputs, and granted directories expose descendants. - dotfiles (REASONED; gradio-rolling): Working-directory dotfiles are excluded from automatic caching unless explicitly permitted; this does not protect other eligible files or justify colocated secrets. - blocked-paths (REASONED; advisory, gradio-rolling): blocked_paths overrides default/static/allowed grants but is a backstop; minimize the process filesystem and never return untrusted file paths. - path-environment (REASONED; gradio): allowed_paths and blocked_paths default None; even [] falls back to comma-separated GRADIO_ALLOWED_PATHS and GRADIO_BLOCKED_PATHS. - cache (REASONED; gradio-rolling): GRADIO_TEMP_DIR relocates cache; restrict permissions, quota and cleanup, but relocation and local permissions do not provide per-user download authorization. - upload-limit (REASONED; gradio, gradio-rolling): max_file_size defaults None; set bytes or 5mb for a per-file limit, adding separate storage/rate controls. - multipart-fix (REASONED; gradio, multipart): 6.20.0 fixed missing max_file_size enforcement on multipart /component_server uploads; retain the 6.31.0 deployment baseline. - historical-traversal (REASONED; advisory): 4.11.0 fixed the historical /file traversal and associated SSRF; this is a historical floor, not the deployment baseline. - historical-files (REASONED; advisory, fixes): 6.16.0 includes FileExplorer traversal fixes; the case-handling advisory has conflicting patched-version records and no precise fix version here. - vibe (REASONED; gradio, gradio-rolling): GRADIO_VIBE_MODE defaults empty; any nonempty value including False or 0 enables unauthenticated source read/write handlers and arbitrary execution; leave unset and avoid --vibe. - mcp (REASONED; gradio, gradio-rolling): mcp_server defaults None, consulting GRADIO_MCP_SERVER (False); explicitly disable unless tools beneath /gradio_api/mcp/ are deliberately protected and tested. - footer (REASONED; gradio-rolling): footer_links can hide API documentation; it replaces show_api=False and does not disable the API. - visibility (REASONED; gradio, gradio-rolling): api_visibility defaults public; undocumented hides docs and private blocks Gradio-client use, but direct HTTP remains subject to separate auth/queue policy. - api-name (REASONED; gradio, gradio-rolling): api_name=None derives a function name; api_name=False is no longer a supported public argument and hiding names is not authorization. - protected-routes (REASONED; gradio): Native auth/auth_dependency checks cover /config, /gradio_api/info, prediction and queue join/data routes; public assets and a login page do not prove API protection. - queue-bypass (REASONED; gradio): queue(api_open=None) is effectively True off Spaces; False refuses direct run/api calls for queued functions, while queued API and queue=False events remain. - concurrency (REASONED; gradio, gradio-rolling): default_concurrency_limit defaults 1 unless changed through GRADIO_DEFAULT_CONCURRENCY_LIMIT; listener overrides apply, not a global per-user limit. - queue-size (REASONED; gradio): max_size defaults None with a ZeroGPU Spaces exception; 20 bounds waiting work but is not a per-user quota. - threads (REASONED; gradio, gradio-rolling): max_threads defaults 40; the example sets 8, separately from queue and listener capacity. - egress (REASONED; gradio, gradio-rolling): URL inputs and component/MCP fetches need application destination allowlists and deployment egress controls, including redirects/resolution; no general fetch allowlist covers all surfaces. - fetch-fixes (REASONED; fixes, multipart): 6.16.0 fixed SVG/Audio SSRF; 6.20.0 changed /gradio_api/file=<url> from open redirect to SSRF-protected streaming proxy. - load-token (REASONED; gradio-rolling): Keep gr.load name/src trusted; token can inherit HF_TOKEN, model loading can use a saved token, and a loaded Space can read a supplied token. - flagging (REASONED; gradio, gradio-rolling): Interface flagging_mode defaults through GRADIO_FLAGGING_MODE to manual; auto flags inputs and outputs after each successful submit, not in input-only Interfaces. In 6.31.0, never skips flagging-callback setup and renders no flag button, so Interface attaches no flagging event. The cited code only skips setup and events, so this guide's reading is that never leaves existing flagged files in place. - flag-storage (REASONED; gradio-rolling): Default CSVLogger writes .gradio/flagged including file data. As this guide's own recommendation, not a Gradio statement, apply an explicit access and retention policy wherever that collection is required. - history (REASONED; gradio, gradio-rolling): run_history defaults None through GRADIO_RUN_HISTORY to True; False disables recording/page and removes saved browser history, not separately retained bucket data. - analytics (REASONED; gradio-rolling): Blocks/Interface analytics_enabled defaults None through GRADIO_ANALYTICS_ENABLED to True; False disables basic telemetry independently of flagging/history and application logs. - verify-bind-share (REASONED; gradio): Inventory bind and terminate the prior process/tunnel, set share=False, and inspect this fresh run's full startup output; loopback ss alone cannot exclude a relay. - verify-native (REASONED; gradio): Compare /gradio_api/info without native cookies (200 exposed, native 401 fixed), retaining proxy authorization; fresh login cookie must restore 200 with app content. - verify-tls (REASONED; gradio-rolling): Use validated HTTPS at the same host/port/prefix and trust the actual CA; TLS, DNS and connection failures do not demonstrate auth refusal. - verify-files (REASONED; gradio, gradio-rolling): Both markers must serve before blocking; after restart use fresh cookies and confirm both files remain, with blocked 403 and allowed marker content; missing files also yield 403. - verify-environment (REASONED; gradio, gradio-rolling): With allowed_paths=[], an environment-granted marker serves; remove grant and restart to require refusal with an allowed control; relocated cache remains readable across app users. - verify-upload (REASONED; gradio, multipart): Compare valid below/above-limit uploads on upload and multipart component_server independently; malformed requests or proxy refusal do not demonstrate Gradio enforcement. - verify-vibe (REASONED; gradio): Unauthenticated GET /gradio_api/vibe-code returns demo source with GRADIO_VIBE_MODE=False as a string; unset/restart requires handler 403, not merely a missing file. - verify-mcp (REASONED; gradio, gradio-rolling): Enabled MCP must list/invoke a harmless tool; disabled MCP must stop both with ordinary app still working; published tools need their own anonymous/authorized comparison. - verify-visibility (REASONED; gradio, gradio-rolling): Direct echo HTTP can still work with undocumented/private visibility; auth must reject missing cookies and fresh cookies restore marker content, including config/info/queue comparisons. - verify-queue (REASONED; gradio): Authenticated queued run/echo and api/echo succeed with api_open=True and return 404 with False; call/echo plus its event URL must still complete. - verify-capacity (REASONED; gradio, gradio-rolling): Fill 20 waiting slots with a bounded slow handler, compare queue-full refusal and overlap against controls, and inspect thread/listener settings separately. - verify-external (REASONED; gradio, gradio-rolling): auth_dependency user ID permits prefixed config and None yields 401; forged headers/direct origin must fail, with a fresh authorized session as control. - verify-revocation (REASONED; gradio): Create two native sessions, require logout to invalidate both, test restart separately, inspect both cookies, and test external lifetime/throttling at the enforcing layer. - verify-egress (REASONED; gradio, fixes, multipart, gradio-rolling): Allowed markers must arrive; denied or redirected destinations must receive no request after policy enforcement; confirm with destination/egress logs across used fetch surfaces. - verify-collection (REASONED; gradio-rolling): Compare manual/auto flag records against never, history runs/page on/off and telemetry on/off while predictions work; inspect old files and buckets separately. ````json { "schema": 1, "checked": "2026-11-07", "documentation_checked": "2026-09", "body_sha256": "858cabb567e8e3a9f67586d2a03bfa122342d966f89398496890dcd53d6180f5", "components": { "gradio": { "name": "Gradio", "basis": "6.31.0", "sources": { "s629ceeab3c07": "https://github.com/gradio-app/gradio/releases/tag/gradio%406.31.0", "sae6db7547d00": "https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/blocks.py", "s37df1f92298b": "https://raw.githubusercontent.com/gradio-app/gradio/gradio%406.31.0/gradio/routes.py", "s96937c91853b": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L389-L399", "sbe57cf8cae8d": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L467-L476", "sa9f247ed7ad2": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L607-L610", "s7b4d418e3232": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L657-L662", "s3ba7f10d7fef": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L841-L870", "s36b4412971de": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L564", "sa06b065ecf88": "https://github.com/gradio-app/gradio/blob/gradio%406.31.0/gradio/interface.py#L630-L635" } }, "fixes": { "name": "Gradio historical fixes", "basis": "6.16.0", "sources": { "s4c2d0b42c65a": "https://github.com/gradio-app/gradio/releases/tag/gradio%406.16.0" } }, "multipart": { "name": "Gradio historical fixes", "basis": "6.20.0", "sources": { "sdcc6d6d01633": "https://github.com/gradio-app/gradio/releases/tag/gradio%406.20.0" } }, "advisory": { "name": "Gradio security advisories", "basis": "unknown", "sources": { "sdab1cc199434": "https://github.com/gradio-app/gradio/security/advisories/GHSA-j2jg-fq62-7c3h", "s72ab0d90c666": "https://github.com/gradio-app/gradio/security/advisories/GHSA-6qm2-wpxq-7qh2" } }, "gradio-rolling": { "name": "Gradio documentation (rolling)", "basis": "unknown", "sources": { "s2e352ec80647": "https://gradio.app/docs/gradio/blocks", "s236e6a1cfda7": "https://gradio.app/guides/file-access", "s26cf2d8c03e6": "https://gradio.app/docs/gradio/button", "scda58c57b93d": "https://gradio.app/guides/gradio-6-migration-guide", "sa70cd6adbc85": "https://gradio.app/guides/developing-faster-with-reload-mode", "sb9d13823991d": "https://gradio.app/guides/building-mcp-server-with-gradio", "s6241688bfec2": "https://gradio.app/docs/gradio/load", "s4c8865afcff1": "https://gradio.app/docs/gradio/interface", "s3f2e7e8ca402": "https://gradio.app/guides/flagging" } } }, "claims": { "bind": {"text": "server_name=None resolves through GRADIO_SERVER_NAME to 127.0.0.1 when unset; set loopback explicitly behind the proxy; 0.0.0.0 exposes all interfaces.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00"], "status": "REASONED"}, "port": {"text": "With server_port=None, port search starts at 7860 unless GRADIO_SERVER_PORT changes it; native TLS example explicitly uses 8443.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00"], "status": "REASONED"}, "password": {"text": "auth accepts one tuple, a tuple list or a credential-checking callable; auth_message customizes login; keep credentials outside source.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:s37df1f92298b"], "status": "REASONED"}, "login-limits": {"text": "Native auth is single-factor without throttling, rate limiting or lockout; a TOTP callable also needs enrollment, secret custody and replay rejection.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:s37df1f92298b"], "status": "REASONED"}, "external-auth": {"text": "auth_dependency returns a user ID or None and cannot combine with auth; validate identity, sanitize proxy headers and prevent direct backend bypass.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:s37df1f92298b"], "status": "REASONED"}, "sessions": {"text": "Native bearer tokens live in process memory without server-side expiry; login issues HttpOnly Secure and insecure fallback cookies, and accepts either.", "components": ["gradio"], "sources": ["gradio:s37df1f92298b"], "status": "REASONED"}, "revocation": {"text": "Restart clears native tokens; /logout defaults all_session=True, revoking all sessions for a username; cookie lifetime is not server revocation.", "components": ["gradio"], "sources": ["gradio:s37df1f92298b"], "status": "REASONED"}, "external-policy": {"text": "Require HTTPS and externally enforced MFA, throttling, lockout and finite lifetime on every request; a copied native cookie must not bypass that boundary.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:s37df1f92298b"], "status": "REASONED"}, "tls": {"text": "ssl_certfile/ssl_keyfile enable HTTPS and ssl_keyfile_password supports encrypted keys; ssl_verify=False affects launcher self-checks and is only needed for self-signed certificates.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00"], "status": "REASONED"}, "prefix": {"text": "root_path builds URLs beneath the proxy prefix; it neither authenticates nor prevents direct backend access.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647"], "status": "REASONED"}, "share": {"text": "share=True publishes a public relay URL; omitted share may inherit GRADIO_SHARE=True or auto-enable in hosted notebooks; deploy with explicit False.", "components": ["gradio"], "sources": ["gradio:sae6db7547d00"], "status": "REASONED"}, "file-route": {"text": "/gradio_api/file= serves registered static paths, allowed_paths and cache; returned file outputs can expose eligible working/temp paths to every app user.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7", "gradio:s37df1f92298b"], "status": "REASONED"}, "allowed-paths": {"text": "allowed_paths adds exposure, not a sandbox; empty lists do not disable cache or eligible outputs, and granted directories expose descendants.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7"], "status": "REASONED"}, "dotfiles": {"text": "Working-directory dotfiles are excluded from automatic caching unless explicitly permitted; this does not protect other eligible files or justify colocated secrets.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7"], "status": "REASONED"}, "blocked-paths": {"text": "blocked_paths overrides default/static/allowed grants but is a backstop; minimize the process filesystem and never return untrusted file paths.", "components": ["advisory", "gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7", "advisory:sdab1cc199434"], "status": "REASONED"}, "path-environment": {"text": "allowed_paths and blocked_paths default None; even [] falls back to comma-separated GRADIO_ALLOWED_PATHS and GRADIO_BLOCKED_PATHS.", "components": ["gradio"], "sources": ["gradio:sae6db7547d00"], "status": "REASONED"}, "cache": {"text": "GRADIO_TEMP_DIR relocates cache; restrict permissions, quota and cleanup, but relocation and local permissions do not provide per-user download authorization.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7"], "status": "REASONED"}, "upload-limit": {"text": "max_file_size defaults None; set bytes or 5mb for a per-file limit, adding separate storage/rate controls.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00"], "status": "REASONED"}, "multipart-fix": {"text": "6.20.0 fixed missing max_file_size enforcement on multipart /component_server uploads; retain the 6.31.0 deployment baseline.", "components": ["gradio", "multipart"], "sources": ["gradio:s629ceeab3c07", "multipart:sdcc6d6d01633"], "status": "REASONED"}, "historical-traversal": {"text": "4.11.0 fixed the historical /file traversal and associated SSRF; this is a historical floor, not the deployment baseline.", "components": ["advisory"], "sources": ["advisory:s72ab0d90c666"], "status": "REASONED"}, "historical-files": {"text": "6.16.0 includes FileExplorer traversal fixes; the case-handling advisory has conflicting patched-version records and no precise fix version here.", "components": ["advisory", "fixes"], "sources": ["advisory:sdab1cc199434", "fixes:s4c2d0b42c65a"], "status": "REASONED"}, "vibe": {"text": "GRADIO_VIBE_MODE defaults empty; any nonempty value including False or 0 enables unauthenticated source read/write handlers and arbitrary execution; leave unset and avoid --vibe.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:sae6db7547d00", "gradio:s37df1f92298b", "gradio-rolling:sa70cd6adbc85"], "status": "REASONED"}, "mcp": {"text": "mcp_server defaults None, consulting GRADIO_MCP_SERVER (False); explicitly disable unless tools beneath /gradio_api/mcp/ are deliberately protected and tested.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:sae6db7547d00", "gradio:s37df1f92298b", "gradio-rolling:sb9d13823991d"], "status": "REASONED"}, "footer": {"text": "footer_links can hide API documentation; it replaces show_api=False and does not disable the API.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio-rolling:scda58c57b93d"], "status": "REASONED"}, "visibility": {"text": "api_visibility defaults public; undocumented hides docs and private blocks Gradio-client use, but direct HTTP remains subject to separate auth/queue policy.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s26cf2d8c03e6", "gradio-rolling:scda58c57b93d", "gradio:s37df1f92298b"], "status": "REASONED"}, "api-name": {"text": "api_name=None derives a function name; api_name=False is no longer a supported public argument and hiding names is not authorization.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:sae6db7547d00", "gradio-rolling:s26cf2d8c03e6", "gradio-rolling:scda58c57b93d"], "status": "REASONED"}, "protected-routes": {"text": "Native auth/auth_dependency checks cover /config, /gradio_api/info, prediction and queue join/data routes; public assets and a login page do not prove API protection.", "components": ["gradio"], "sources": ["gradio:s37df1f92298b"], "status": "REASONED"}, "queue-bypass": {"text": "queue(api_open=None) is effectively True off Spaces; False refuses direct run/api calls for queued functions, while queued API and queue=False events remain.", "components": ["gradio"], "sources": ["gradio:sae6db7547d00", "gradio:s37df1f92298b"], "status": "REASONED"}, "concurrency": {"text": "default_concurrency_limit defaults 1 unless changed through GRADIO_DEFAULT_CONCURRENCY_LIMIT; listener overrides apply, not a global per-user limit.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:sae6db7547d00", "gradio-rolling:s26cf2d8c03e6"], "status": "REASONED"}, "queue-size": {"text": "max_size defaults None with a ZeroGPU Spaces exception; 20 bounds waiting work but is not a per-user quota.", "components": ["gradio"], "sources": ["gradio:sae6db7547d00"], "status": "REASONED"}, "threads": {"text": "max_threads defaults 40; the example sets 8, separately from queue and listener capacity.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00"], "status": "REASONED"}, "egress": {"text": "URL inputs and component/MCP fetches need application destination allowlists and deployment egress controls, including redirects/resolution; no general fetch allowlist covers all surfaces.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00", "gradio-rolling:sb9d13823991d"], "status": "REASONED"}, "fetch-fixes": {"text": "6.16.0 fixed SVG/Audio SSRF; 6.20.0 changed /gradio_api/file= from open redirect to SSRF-protected streaming proxy.", "components": ["fixes", "multipart"], "sources": ["fixes:s4c2d0b42c65a", "multipart:sdcc6d6d01633"], "status": "REASONED"}, "load-token": {"text": "Keep gr.load name/src trusted; token can inherit HF_TOKEN, model loading can use a saved token, and a loaded Space can read a supplied token.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s6241688bfec2"], "status": "REASONED"}, "flagging": {"text": "Interface flagging_mode defaults through GRADIO_FLAGGING_MODE to manual; auto flags inputs and outputs after each successful submit, not in input-only Interfaces. In 6.31.0, never skips flagging-callback setup and renders no flag button, so Interface attaches no flagging event. The cited code only skips setup and events, so this guide's reading is that never leaves existing flagged files in place.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s4c8865afcff1", "gradio-rolling:s3f2e7e8ca402", "gradio:s96937c91853b", "gradio:sbe57cf8cae8d", "gradio:s36b4412971de", "gradio:sa9f247ed7ad2", "gradio:sa06b065ecf88", "gradio:s7b4d418e3232", "gradio:s3ba7f10d7fef"], "status": "REASONED"}, "flag-storage": {"text": "Default CSVLogger writes .gradio/flagged including file data. As this guide's own recommendation, not a Gradio statement, apply an explicit access and retention policy wherever that collection is required.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s4c8865afcff1", "gradio-rolling:s3f2e7e8ca402"], "status": "REASONED"}, "history": {"text": "run_history defaults None through GRADIO_RUN_HISTORY to True; False disables recording/page and removes saved browser history, not separately retained bucket data.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:sae6db7547d00"], "status": "REASONED"}, "analytics": {"text": "Blocks/Interface analytics_enabled defaults None through GRADIO_ANALYTICS_ENABLED to True; False disables basic telemetry independently of flagging/history and application logs.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio-rolling:s4c8865afcff1"], "status": "REASONED"}, "verify-bind-share": {"text": "Inventory bind and terminate the prior process/tunnel, set share=False, and inspect this fresh run's full startup output; loopback ss alone cannot exclude a relay.", "components": ["gradio"], "sources": ["gradio:sae6db7547d00"], "status": "REASONED", "verify": [1]}, "verify-native": {"text": "Compare /gradio_api/info without native cookies (200 exposed, native 401 fixed), retaining proxy authorization; fresh login cookie must restore 200 with app content.", "components": ["gradio"], "sources": ["gradio:s37df1f92298b"], "status": "REASONED", "verify": [2, 3]}, "verify-tls": {"text": "Use validated HTTPS at the same host/port/prefix and trust the actual CA; TLS, DNS and connection failures do not demonstrate auth refusal.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647"], "status": "REASONED", "verify": [2, 3]}, "verify-files": {"text": "Both markers must serve before blocking; after restart use fresh cookies and confirm both files remain, with blocked 403 and allowed marker content; missing files also yield 403.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7", "gradio:s37df1f92298b"], "status": "REASONED", "verify": [3]}, "verify-environment": {"text": "With allowed_paths=[], an environment-granted marker serves; remove grant and restart to require refusal with an allowed control; relocated cache remains readable across app users.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s236e6a1cfda7", "gradio:sae6db7547d00"], "status": "REASONED", "verify": [3]}, "verify-upload": {"text": "Compare valid below/above-limit uploads on upload and multipart component_server independently; malformed requests or proxy refusal do not demonstrate Gradio enforcement.", "components": ["gradio", "multipart"], "sources": ["gradio:s37df1f92298b", "multipart:sdcc6d6d01633"], "status": "REASONED", "verify": [3]}, "verify-vibe": {"text": "Unauthenticated GET /gradio_api/vibe-code returns demo source with GRADIO_VIBE_MODE=False as a string; unset/restart requires handler 403, not merely a missing file.", "components": ["gradio"], "sources": ["gradio:sae6db7547d00", "gradio:s37df1f92298b"], "status": "REASONED", "verify": [3]}, "verify-mcp": {"text": "Enabled MCP must list/invoke a harmless tool; disabled MCP must stop both with ordinary app still working; published tools need their own anonymous/authorized comparison.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:s37df1f92298b", "gradio-rolling:sb9d13823991d"], "status": "REASONED"}, "verify-visibility": {"text": "Direct echo HTTP can still work with undocumented/private visibility; auth must reject missing cookies and fresh cookies restore marker content, including config/info/queue comparisons.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:s37df1f92298b", "gradio-rolling:s26cf2d8c03e6"], "status": "REASONED", "verify": [3]}, "verify-queue": {"text": "Authenticated queued run/echo and api/echo succeed with api_open=True and return 404 with False; call/echo plus its event URL must still complete.", "components": ["gradio"], "sources": ["gradio:s37df1f92298b"], "status": "REASONED", "verify": [3]}, "verify-capacity": {"text": "Fill 20 waiting slots with a bounded slow handler, compare queue-full refusal and overlap against controls, and inspect thread/listener settings separately.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio:sae6db7547d00", "gradio-rolling:s26cf2d8c03e6"], "status": "REASONED"}, "verify-external": {"text": "auth_dependency user ID permits prefixed config and None yields 401; forged headers/direct origin must fail, with a fresh authorized session as control.", "components": ["gradio", "gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio:s37df1f92298b"], "status": "REASONED", "verify": [3]}, "verify-revocation": {"text": "Create two native sessions, require logout to invalidate both, test restart separately, inspect both cookies, and test external lifetime/throttling at the enforcing layer.", "components": ["gradio"], "sources": ["gradio:s37df1f92298b"], "status": "REASONED", "verify": [3]}, "verify-egress": {"text": "Allowed markers must arrive; denied or redirected destinations must receive no request after policy enforcement; confirm with destination/egress logs across used fetch surfaces.", "components": ["gradio", "fixes", "multipart", "gradio-rolling"], "sources": ["gradio:s37df1f92298b", "gradio-rolling:sb9d13823991d", "fixes:s4c2d0b42c65a", "multipart:sdcc6d6d01633"], "status": "REASONED", "verify": [3]}, "verify-collection": {"text": "Compare manual/auto flag records against never, history runs/page on/off and telemetry on/off while predictions work; inspect old files and buckets separately.", "components": ["gradio-rolling"], "sources": ["gradio-rolling:s2e352ec80647", "gradio-rolling:s4c8865afcff1", "gradio-rolling:s3f2e7e8ca402"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> streamlit.md ====================================================================== # Streamlit: TLS and authentication Streamlit is a Python library that turns data scripts into interactive web apps. This guide helps your AI assistant configure an app securely, including a loopback bind with TLS from a proxy or tunnel, a native OIDC login or an authenticating proxy, and reviewed uploads, static files and outbound traffic. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/streamlit.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Streamlit 1.64.0, Google OpenID Connect documentation rolling documentation, checked 2026-10-15, Authlib minimum 1.3.2, httpx minimum 0.24.1, Caddy v2.11.4, oauth2-proxy v7.15.6 and v7.15.4, Keycloak 26.8.2 and 26.7.4, and curl minimum write-out version 7.75.0; 3 further sources carry no recorded version. Metadata reviewed 2026-11-06; documentation checked October 2026. The guide records 36 claims: 12 demonstrated by a check that ran and 24 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Streamlit apps have no access control unless you add it, and `streamlit run` listens on all interfaces on port `8501` by default (as of 1.64.0: `server.address` unset, which falls back to `0.0.0.0` and tries `::` first when Python supports IPv6, falling back to `0.0.0.0` if IPv6 is unavailable; `server.port` `8501`). Streamlit 1.64.0 tries up to 100 following ports when port `8501` is busy and was not set explicitly. Decide both layers before exposing an app. ## 1. TLS Preferred: keep Streamlit on loopback and terminate TLS in a reverse proxy or tunnel ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)): ```toml # .streamlit/config.toml [server] address = "127.0.0.1" port = 8501 # set explicitly: an unset port moves to 8502 and up when 8501 is busy, past the proxy and these checks ``` Point the proxy at `127.0.0.1:8501` and forward the WebSocket upgrade Streamlit depends on, applying the proxy's authentication to the upgrade as well as to ordinary requests (a separately unprotected WebSocket location bypasses the boundary) and preserving the browser's `Origin` header. For nginx, inside the authenticated `location /` block: ```nginx proxy_pass http://127.0.0.1:8501; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; ``` Streamlit can serve HTTPS itself via `server.sslCertFile` and `server.sslKeyFile`, but its own documentation says not to use this in production ("It has not gone through security audits or performance tests") and to prefer a reverse proxy or load balancer. Treat the built-in TLS as a development convenience only: ```toml [server] sslCertFile = "/path/cert.pem" sslKeyFile = "/path/key.pem" ``` Leave `server.enableXsrfProtection` and `server.enableCORS` at their defaults (both `true`). Advice to disable them so uploads or embedding work behind a proxy removes protection rather than fixing the proxy; correct the proxy's forwarded headers and WebSocket upgrade instead. XSRF does not enable CORS or require a token when opening a WebSocket. Disabling CORS permits cross-origin WebSockets even with XSRF enabled. Keep both enabled; configure `server.corsAllowedOrigins` with origins and `server.allowedHosts` with hostnames. Configuring native authentication separately enables both protections. These are cross-site controls, not user authentication ([cors.md](https://github.com/jposluns/secureconfig/blob/main/cors.md)). Configuration precedence runs command-line over environment variables over the project `.streamlit/config.toml` (relative to the service's working directory) over the global config, so inspect the launch configuration actually in effect and restart after changing server settings. Two other instructions apply: restrict writes to app code and configuration to deployment administrators, and note that `client.toolbarMode` only changes menu visibility and is not an authorization boundary. ## 2. Native login (OIDC) Streamlit 1.64.0 provides `st.login()`, `st.logout()`, and `st.user` for OpenID Connect authentication against Google, Microsoft Entra ID, Okta, or any OIDC provider (`st.login()` and `st.logout()` were introduced in 1.42.0; `st.user` was introduced in 1.45.0, replacing `st.experimental_user`). Streamlit's `auth` extra must be installed in the app's environment (`pip install "streamlit[auth]"`), which in 1.64.0 requires `Authlib` 1.3.2 or later and `httpx` 0.24.1 or later; without Authlib the `[auth]` block errors. The loopback run had Authlib installed but not httpx, and every browser login attempt got `Internal Server Error`, with `ModuleNotFoundError: No module named 'httpx'` in Streamlit's log. Configuration lives in `.streamlit/secrets.toml`: ```toml [auth] redirect_uri = "https://app.example.com/oauth2callback" cookie_secret = "REPLACE_WITH_LONG_RANDOM_STRING" client_id = "REPLACE_WITH_THE_CLIENT_ID" client_secret = "REPLACE_WITH_THE_CLIENT_SECRET" server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration" ``` The `server_metadata_url` above is Google's [Discovery-document URI](https://developers.google.com/identity/openid-connect/openid-connect#discovery); [Streamlit 1.64.0](https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/auth_util.py#L421-L429) copies any configured `[auth]` `client_kwargs` (such as an `hd` hint) into its default provider configuration, and [Google says the `hd` request parameter](https://developers.google.com/identity/openid-connect/openid-connect#hd-param) only optimizes account selection. Authenticate and authorize before rendering protected data or performing side effects: with `st.navigation`, put the shared gate in the entrypoint before executing the selected page; with independent page scripts, gate every protected page; and recheck authorization inside privileged callbacks before their side effects. `st.login()` on its own accepts any account the configured provider authenticates: the [Streamlit 1.64.0 callback](https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_auth_routes.py#L596-L620) stores returned user information without a domain check. For Google, [check the returned `hd` claim](https://developers.google.com/identity/openid-connect/openid-connect#id_token-hd) before granting access by hosted domain: ```python import streamlit as st ALLOWED_DOMAIN = "example.com" if not st.user.is_logged_in: st.login() st.stop() if st.user.get("hd") != ALLOWED_DOMAIN: st.error("This account is not authorized for this app.") st.stop() if not st.user.get("email_verified"): st.error("This account's email is not verified.") st.stop() st.write(f"Hello, {st.user.name}") ``` [Streamlit 1.64.0](https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/user_info.py#L550-L560) copies ID-token attributes onto `st.user`, readable via `st.user.get(...)` or `st.user["..."]`. [Google says](https://developers.google.com/identity/openid-connect/openid-connect#id_token-hd) the `hd` claim is the domain associated with a Google Workspace or Cloud organization, is provided only if the account belongs to a Google Cloud organization, and its absence indicates that the account does not belong to a Google hosted domain. The [`hd` request parameter](https://developers.google.com/identity/openid-connect/openid-connect#hd-param) only optimizes account selection and does not control access; check that the returned ID token has the expected `hd` claim. You cannot rely on the email domain to identify Workspace or Cloud organization users; use `hd` instead. For a small fixed user set, an explicit allowlist of addresses is the alternative. Allowlist rules and claim checks are in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md). Notes from the Streamlit docs: this is authentication only (identity, not per-resource authorization), the identity cookie lasts 30 days and that period is not configurable, and `secrets.toml` holds the client secret, so it must never be committed. `st.secrets` exposes secrets to server-side app code but does not make them safe to display, so never render or log secret values; exclude `.streamlit/secrets.toml` from Git and image build contexts, restrict its filesystem access to the service and deployment administrators, keep it outside any served directory, and rotate an exposed client or cookie secret. In Streamlit 1.64.0, `client.showErrorDetails` accepts `"full"` (default), `"stacktrace"`, `"type"`, and `"none"`; the deprecated booleans `true` and `false` map to `"full"` and `"stacktrace"`. Set `showErrorDetails = "none"` under `[client]` to hide exception details from browsers; details remain in the console logs. MFA: `st.login()` delegates authentication to the OIDC provider, so enforce MFA there (Google, Microsoft Entra ID, Okta, Keycloak, and authentik all support it). Without OIDC, front the app per section 3. Options in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 3. Alternatives when OIDC is not available - Basic auth at a reverse proxy in front of a loopback-bound app ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)). - Cloudflare Access in front of a tunnel ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)), which adds SSO or one-time-PIN login without touching the app. Basic auth or an emailed one-time PIN alone is not a second factor: for a sensitive app require MFA at the proxy or Access policy, disable alternative login methods that bypass it, and keep the Streamlit origin unreachable directly ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). A password typed into a plain `st.text_input` and compared in the script is not authentication; it ships no session management, no hashing, and no rate limiting. ## 4. Uploads, static files, and egress `st.file_uploader` defaults to a 200 MB per-file limit, configured globally by `server.maxUploadSize`; an explicit per-widget `max_upload_size` overrides it. Lower the global limit, review every widget override, and rate-limit at the proxy; extension and MIME filters are best-effort, not content validation, and uploaded content must never be executed. `server.enableStaticServing` (default `false`) serves every file under the app's `static/` directory through `/app/static/`, a route answered by the server rather than your script, so an `st.login()` gate does not cover it: leave it off unless that directory holds only public files, and never place secrets or private data there. TLS and login do not sandbox Python, so never pass user input to `eval`, `exec`, a shell, or unsafe deserialization, and run the service with least filesystem privilege and minimal credentials (injection defense itself is application security, beyond this deployment guide's scope). If the app fetches user-supplied URLs, constrain the host's egress and block cloud metadata and unrelated internal networks per [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md). ## 5. Verify Verification status: the checks below were demonstrated on loopback against Streamlit 1.64.0 (the PyPI wheel, its SHA-256 equal to PyPI's published digest), with native `st.login`, and behind Caddy v2.11.4 and oauth2-proxy v7.15.4, using Keycloak 26.7.4 as the OIDC provider, in the exposed and fixed states. The wildcard bind and block 3's external vantage are REASONED and marked at their steps; the end of this section records what was observed. Use curl 7.75.0 or newer; never add `-k`. Substitute the public HTTPS origin, without a trailing slash, and the server's public address inside the single quotes on their respective `set --` lines, and paste each complete subshell. ```bash # REASONED: whole-block scope includes wildcard binding, unavailable in the recorded loopback runs; demonstrated sub-results and source reasoning are recorded below. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input or found a required tool missing), which outranks 0, so the block ends with 2 only when no part recorded 1 or 3 if command -v ss >/dev/null; then # a missing ss refuses this part with status 2, ranked as above ss -tlnp # listeners on the origin host; 8501 must be 127.0.0.1 behind a proxy, not a public interface # (loopback runs saw 127.0.0.1:8501; the default wildcard bind is REASONED: the host forbids it) set -- "$1" "$?" # the block's status so far, then ss's exit status [ "$2" = 0 ] || { echo "ss -tlnp exit $2: no listener inventory; inconclusive"; set -- 3; } else echo "ss is not installed here; not listing sockets"; set -- 2 fi # 1) TLS on the public origin, then the fronting-proxy anonymous check. A transport error (nonzero exit, # including a timeout) is inconclusive, not a pass. Native st.login serves an anonymous 200 on the index # BY DESIGN and gates inside the script over the websocket, so judge that pattern by the browser checks # below, not this HTTP status. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PUBLIC_HTTPS_ORIGIN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "provide exactly one origin; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute your HTTPS origin; not probing"; exit 2 ;; https://*) ;; *) echo "use an HTTPS origin; not probing"; exit 2 ;; esac curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -o /dev/null \ -w 'tls=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/" || { echo "curl exit $?: the TLS request failed, which is inconclusive, not a pass; the anonymous check did not run"; exit 3; } # Authentication-proxy deployments only: inspect this route's policy and response. # Native st.login permits anonymous index requests; use the browser controls below. curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -D - \ -w '\nanon=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/_stcore/health" || { echo "curl exit $?: the anonymous request failed, which is inconclusive, not a pass"; exit 3; } ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the listener list, the tls= status and the anonymous health response are not classified, so judge them yourself" exit "$1" ) ``` ```bash # REASONED: whole-block scope is second-host reachability, unavailable in the recorded loopback runs; the same-host result and source reasoning are recorded below. # 3) From another host, port 8501 must not answer directly, or the proxy is bypassable. Any HTTP code, # including a 200 "ok", means 8501 answered externally (the finding); a refused or no-route connection # is consistent with closure but not proof; a DNS or local socket error or a timeout is inconclusive. # REASONED from another host: the loopback runs had one host and no second network (see below). ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_SERVER_PUBLIC_IP' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the server's public address on the set -- line above; not probing"; exit 2 ;; *) set -- "$1" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n' "http://$1:8501/_stcore/health"; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; esac set -- "${2%%$'\n'*}" "${2##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$1" case "$1" in 'http=000 '*) ;; # no HTTP code, so curl's exit status below decides 'http='[0123456789][0123456789][0123456789]' '*) set -- "$1" 0 ;; # an HTTP code is an answer even if the transfer then failed esac case "$2" in 0) echo "FINDING: port 8501 answered HTTP directly, so the proxy is bypassable"; set -- 1 ;; 7) echo "curl could not connect: a refused or no-route connection is consistent with closure but not proof, so read err above"; set -- 0 ;; *) echo "curl exit $2: a DNS or local socket error or a timeout is inconclusive"; set -- 3 ;; esac echo "block exit $1; a refused or no-route connection is not classified, so judge it yourself" exit "$1" ) ``` REASONED: preceding block; the direct-origin fence, the listener, TLS and proxy fence before it, and this browser paragraph; per twelve of the guide's cited entries. The first two of those entries are the guide's cited entries: Streamlit authentication concepts, st.login, st.logout, st.user, [auth] keys, default scope and stated limitations (rolling documentation, checked September 2026); and Streamlit st.user API reference, claims copied from the ID token and `st.user.email` (rolling documentation, checked September 2026). The third and fourth entries are the guide's cited entries: Streamlit 1.64.0 user_info.py and auth callback for attributes and cookie storage; and Google OpenID Connect `hd` claim and hosted-domain guidance (rolling documentation, checked October 2026). The fifth and sixth entries are the guide's cited entries: Keycloak TOTP policy and user-attribute-to-token-claim mapper used by the recorded fixture, which ran Keycloak 26.7.4 (pinned tag 26.8.2); and Streamlit config.toml reference, server.address, server.sslCertFile, server.sslKeyFile and the production warning (rolling documentation, checked September 2026). The seventh and eighth entries are the guide's cited entries: Streamlit `server.address` default unset and `server.port` default `8501` (pinned tag 1.64.0); and Streamlit's unset address falls back to `DEFAULT_SERVER_ADDRESS` `0.0.0.0` and is tried as `::` when `socket.has_ipv6` (pinned tag 1.64.0). The ninth and tenth entries are the guide's cited entries: Streamlit retries `0.0.0.0` when binding `::` fails with an IPv6-unavailable error (pinned tag 1.64.0); and Caddy basic_auth configuration and default bcrypt hashing (pinned tag v2.11.4). The eleventh and twelfth entries are the guide's cited entries: Streamlit app health endpoint, /_stcore/health without authentication (rolling documentation, checked September 2026); and curl manual (write-out variables require 7.75.0+). A live Streamlit, Caddy, oauth2-proxy or Keycloak stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available during marking review (October 2026). Local Bash syntax and ShellCheck 0.11.0 passed without output; both curl guards refused placeholder, empty, embedded-placeholder, omitted-assignment and wrong-count fixtures with exit 2 and no curl call, and the HTTPS guard rejected HTTP. Valid fixtures made two and one stub calls respectively. Those syntax and guard runs used an earlier form, in which these checks shared one fence whose exit status was its last curl's. The recorded loopback runs of blocks 1 and 3 later in this section, and the DEMONSTRATED claims drawn from those runs, also used an earlier form, before block 3 had its own fence and before either fence reported one exit status. The first current fence exits 3 if `ss` or either request fails, otherwise 2 if `ss` is not installed or its guard refuses and 0 if not, leaving the listener list and both responses for you to judge. The second exits 1 for any HTTP answer from port 8501, 0 when curl cannot connect, which you judge, and 3 for any other curl failure. On 2026-10-31 both fences passed bash -n and ran whole against stubs with no network, the first in an earlier form before its `ss` presence check; ShellCheck 0.11.0 was not part of that review. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form of both, and the first fence's current form also passed bash -n and ShellCheck 0.11.0 and ran whole against stubs with no network in a later review that day, with `ss` present and passing, present and failing, and missing. TOML and Python parsing passed; fake st.user fixtures exercised allow and deny branches. No listener inventory, HTTP, websocket, browser, MFA or second-host comparison ran. Expected anonymous and disallowed browsers to lack protected content, an allowed MFA user to reach it, unprotected canaries to be visible, TLS trust to validate, and direct external origin access to fail. The historical live results elsewhere in this section were not re-observed. Then check the application gate in a browser, using the same scheme, hostname, port, and protected page for every comparison. In a fresh profile with no session an anonymous visitor must not see protected content or reach a protected operation; an allowed user who completes the provider's MFA must; and a valid identity outside the allowlist (for the `hd` check, an account that does not belong to a Google hosted domain) must be denied with nothing rendered. Visit every protected page directly, including administrative ones. A login page, a blank page, or a broken websocket is not a positive control. In an isolated test deployment only, place `st.write("SECURECONFIG_AUTH_CANARY")` after the gate, disable the applicable authentication boundary, and confirm the anonymous visit then reveals the canary; restore protection afterward, and never disable authentication on a public deployment. For a fronting proxy, confirm an unauthenticated websocket upgrade is also denied and that an authenticated browser can establish it. On the loopback runs, with `server.address` set to `127.0.0.1`, `ss` showed Streamlit only on `127.0.0.1:8501`. With the address unset, `streamlit config show` lists it as unset, and Streamlit 1.64.0's source tries the IPv6 wildcard `::` first in that case when `socket.has_ipv6` holds, retrying `0.0.0.0` if IPv6 is unavailable (`_get_bind_address` and `_bind_server_socket` in `web/server/starlette/starlette_server.py`). That bind is REASONED, because the host forbids listening on every interface. The runs used Caddy and oauth2-proxy on `127.0.0.1:8443`, with TLS files from a private test CA that curl trusted through `CURL_CA_BUNDLE`, and a headless Chrome 154 with an SPKI exception for the test certificate's key. The Keycloak realm was served over plain HTTP on loopback. Section 2's example uses an HTTPS metadata URL, as a real provider should. The realm had four users, and three of them had an `hd` user attribute, which a user-attribute mapper maps to an `hd` claim. This fixture stands in for Google and does not demonstrate Google's account or claim behaviour: - an allowed user in `example.com` with a verified email and TOTP required; - a verified user in another domain; - an `example.com` user whose email was not verified; - a verified `example.com` user with no `hd` user attribute, standing in for an account that does not belong to a Google hosted domain and lacks the claim. What each run observed: - **Block 1 against native TLS:** `tls=200` with the test CA trusted. With curl not trusting the certificate it stopped at `tls=000 exit=60`. - **Proxy with no authentication (exposed):** block 1 printed `tls=200`, then `anon=200` with the body `ok`, which is the finding. A websocket upgrade to `/_stcore/stream` got `101` anonymously, and an anonymous browser rendered the canary. - **[caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)'s `basic_auth` covering every route (fixed):** - Block 1 got `401` on both requests, and a wrong credential also got `401`. - An app session over the websocket was refused with `401` anonymously and with the wrong credential, and received the canary with the right one. - The anonymous browser and the browser sending the wrong credential saw an empty page; the browser sending the credential rendered the canary. - **Websocket path in a separate unauthenticated location (exposed):** the index and the health route still returned `401`, but an anonymous client that opened `/_stcore/stream` and asked for a script run received the app's output, the canary. That is the bypass section 1 describes. - **oauth2-proxy with Keycloak, restricted to `example.com` (fixed):** - The anonymous health request got a `302`, and an anonymous websocket upgrade was redirected to the provider's authorization endpoint instead of upgraded. - The anonymous browser stopped at the provider's sign-in page. - The allowed user rendered the canary after the password and a TOTP code; with a wrong code the provider kept them at the one-time-code prompt ("Invalid authenticator code."). - The user in another domain got oauth2-proxy's `403 Forbidden`. - The unverified user got oauth2-proxy's `500 Internal Server Error` page, with oauth2-proxy logging "email in id_token … isn't verified". - **oauth2-proxy with `--email-domain='*'` (exposed allowlist):** the user in another domain rendered the canary. - **Native `st.login` with section 2's gate** (the guide's code, with only the final `Hello` line replaced by the canary): - The anonymous index returned `200` by design, and the anonymous browser was sent to the provider's sign-in page. - The allowed user rendered the canary after enrolling and then entering TOTP codes; a wrong code kept them at the one-time-code prompt. - The user in another domain, and the user with no `hd` attribute, saw "This account is not authorized for this app." - The unverified user saw "This account's email is not verified." - None of those three saw the canary. - **Native `st.login` exposed states:** - With only the `st.login()` check and no `hd` or email check, the user in another domain and the unverified user each rendered the canary. - With no gate at all, an anonymous browser rendered it. - **Block 3 from the same host:** `http=200` against the address Streamlit listened on, and `exit=7` against another loopback address. - **Static serving:** with `server.enableStaticServing` on, the script called `st.stop()` at once, yet `/app/static/public.txt` returned the file with `200` and `text/plain`. With it off, the same path returned `200` with the app's HTML page, not the file. Judge a static-route probe by its body, not its status. REASONED: the oauth2-proxy results above, read against the cited v7.15.6 release notes; no oauth2-proxy run was repeated at v7.15.6. Those runs used oauth2-proxy v7.15.4, so the demonstrated claim drawn from them cites the v7.15.4 email-domain option, while this guide's other oauth2-proxy citations are at v7.15.6. The [v7.15.6 release notes](https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.6) list "fix: return 403 instead of 500 when a user with an unverified email completes the OAuth2 callback", so at v7.15.6 the unverified user is expected to get a `403` rather than the `500` recorded above (this guide's inference from the release notes); that has not been observed here. REASONED: the historical external-vantage and wildcard limitations; per five of the guide's cited entries. The first two of those entries are the guide's cited entries: Streamlit `server.address` default unset and `server.port` default `8501` (pinned tag 1.64.0); and Streamlit's unset address falls back to `DEFAULT_SERVER_ADDRESS` `0.0.0.0` and is tried as `::` when `socket.has_ipv6` (pinned tag 1.64.0). The third to fifth entries are the guide's cited entries: Streamlit retries `0.0.0.0` when binding `::` fails with an IPv6-unavailable error (pinned tag 1.64.0); Streamlit app health endpoint, /_stcore/health without authentication (rolling documentation, checked September 2026); and curl manual (write-out variables require 7.75.0+). A live Streamlit, Caddy, oauth2-proxy or Keycloak stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available during marking review (October 2026). Both curl guards rejected placeholder, empty, embedded-placeholder, omitted assignment and wrong-count fixtures with exit 2 and no curl call; valid fixtures called the curl stub twice for HTTPS and once for port 8501. No second-host probe ran. Those guard runs used an earlier form, in which block 3 shared one fence with the listener and TLS checks; the paragraph after the fences gives the current statuses. Expected a default wildcard listener to expose the service where routing permits, and a loopback proxy deployment to prevent direct second-host HTTP access; refusals alone are not proof. The following is the guide's historical report, not a newly observed live result. REASONED from the cited Streamlit 1.64.0 source and the recorded loopback observations: - REASONED: this reachability limitation; per five of the guide's cited entries. The first two of those entries are the guide's cited entries: Streamlit `server.address` default unset and `server.port` default `8501` (pinned tag 1.64.0); and Streamlit's unset address falls back to `DEFAULT_SERVER_ADDRESS` `0.0.0.0` and is tried as `::` when `socket.has_ipv6` (pinned tag 1.64.0). The third to fifth entries are the guide's cited entries: Streamlit retries `0.0.0.0` when binding `::` fails with an IPv6-unavailable error (pinned tag 1.64.0); Streamlit app health endpoint, /_stcore/health without authentication (rolling documentation, checked September 2026); and curl manual (write-out variables require 7.75.0+). A live Streamlit, Caddy, oauth2-proxy or Keycloak stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available during marking review (October 2026). Both curl guards rejected placeholder, empty, embedded-placeholder, omitted assignment and wrong-count fixtures with exit 2 and no curl call; valid fixtures called the curl stub twice for HTTPS and once for port 8501. No second-host probe ran. Those guard runs used an earlier form, in which block 3 shared one fence with the listener and TLS checks; the paragraph after the fences gives the current statuses. Expected a default wildcard listener to expose the service where routing permits, and a loopback proxy deployment to prevent direct second-host HTTP access; refusals alone are not proof. The following is the guide's historical report, not a newly observed live result. an external vantage for block 3, since there was one host with no second network; - REASONED: this reachability limitation; per five of the guide's cited entries. The first two of those entries are the guide's cited entries: Streamlit `server.address` default unset and `server.port` default `8501` (pinned tag 1.64.0); and Streamlit's unset address falls back to `DEFAULT_SERVER_ADDRESS` `0.0.0.0` and is tried as `::` when `socket.has_ipv6` (pinned tag 1.64.0). The third to fifth entries are the guide's cited entries: Streamlit retries `0.0.0.0` when binding `::` fails with an IPv6-unavailable error (pinned tag 1.64.0); Streamlit app health endpoint, /_stcore/health without authentication (rolling documentation, checked September 2026); and curl manual (write-out variables require 7.75.0+). A live Streamlit, Caddy, oauth2-proxy or Keycloak stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available during marking review (October 2026). Both curl guards rejected placeholder, empty, embedded-placeholder, omitted assignment and wrong-count fixtures with exit 2 and no curl call; valid fixtures called the curl stub twice for HTTPS and once for port 8501. No second-host probe ran. Those guard runs used an earlier form, in which block 3 shared one fence with the listener and TLS checks; the paragraph after the fences gives the current statuses. Expected a default wildcard listener to expose the service where routing permits, and a loopback proxy deployment to prevent direct second-host HTTP access; refusals alone are not proof. The following is the guide's historical report, not a newly observed live result. the default wildcard bind, since the host forbids binding every interface. ## Sources (checked October 2026) Source-checked on 2026-09-18 against Streamlit 1.64.0, the current release at the time of writing; `server.address` defaults to unset (all interfaces) and `server.port` to `8501`, and the explicit loopback setting above is required for the fronting-proxy pattern. `st.login()` has been available since the 1.42.0 series. - Streamlit config.toml reference, server.address, server.sslCertFile, server.sslKeyFile and the production warning (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/api-reference/configuration/config.toml - Streamlit `server.address` default unset and `server.port` default `8501` (pinned tag 1.64.0): https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/config.py#L1016-L1036 - Streamlit's unset address falls back to `DEFAULT_SERVER_ADDRESS` `0.0.0.0` and is tried as `::` when `socket.has_ipv6` (pinned tag 1.64.0): https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server.py#L80-L98 - Streamlit retries `0.0.0.0` when binding `::` fails with an IPv6-unavailable error (pinned tag 1.64.0): https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server.py#L139-L160 - Streamlit's port search: `configured_port + attempt` for up to `MAX_PORT_SEARCH_RETRIES` (100) retries after the configured port, exiting instead on a busy (`EADDRINUSE`) or permission-denied (`EACCES`) port that was set explicitly (a value from `config.toml` counts; `config.py` `is_manually_set`) (pinned tag 1.64.0). Streamlit 1.64.0 port-search sources: https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server.py#L363-L400, with `MAX_PORT_SEARCH_RETRIES: Final = 100` defined in `starlette_server_config.py`: https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server_config.py#L55-L57 - Streamlit authentication concepts, st.login, st.logout, st.user, [auth] keys, default scope and stated limitations (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/concepts/connections/authentication - Streamlit st.user API reference, claims copied from the ID token and `st.user.email` (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/api-reference/user/st.user - Streamlit 1.64.0 `[auth]` default-provider parsing and `client_kwargs` passthrough: https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/auth_util.py#L415-L431 - Streamlit 1.64.0 OIDC callback, returned `userinfo` stored in the identity cookie without a domain check: https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_auth_routes.py#L596-L620 - Streamlit 1.64.0 `st.user`, ID-token attributes exposed to app code: https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/user_info.py#L550-L560 - Google OpenID Connect, `hd` claim and request parameter, email-domain guidance, and discovery URI (rolling documentation, checked 2026-10-15): https://developers.google.com/identity/openid-connect/openid-connect - Streamlit release notes (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/quick-reference/release-notes - Streamlit 2025 release notes, st.login and st.logout introduced in 1.42.0: https://docs.streamlit.io/develop/quick-reference/release-notes/2025#version-1420 - Streamlit st.login reference, OIDC and Authlib 1.3.2+ dependency (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/api-reference/user/st.login - Streamlit 1.64.0 package metadata (the `auth` extra requires `Authlib>=1.3.2` and `httpx>=0.24.1`): https://pypi.org/project/streamlit/1.64.0/ - Streamlit configuration options and precedence, command line over env over project over global; restart on server changes (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/concepts/configuration/options - Streamlit config.toml, enableCORS, enableXsrfProtection, maxUploadSize, enableStaticServing and showErrorDetails (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/api-reference/configuration/config.toml - Streamlit static file serving, server.enableStaticServing default false; served by the server, not the script (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/concepts/configuration/serving-static-files - Streamlit st.file_uploader, maxUploadSize per-file limit; filters are not content validation (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/api-reference/widgets/st.file_uploader - Streamlit secrets management and security reminders, never render or log secrets (rolling documentation, checked September 2026): https://docs.streamlit.io/develop/concepts/connections/security-reminders - Streamlit app health endpoint, /_stcore/health without authentication (rolling documentation, checked September 2026): https://docs.streamlit.io/deploy/tutorials/docker - OWASP SSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html - curl manual (write-out variables require 7.75.0+): https://curl.se/docs/manpage.html - Caddy bind, certificate files, and reverse-proxy configuration (pinned tag v2.11.4): https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/builtins.go#L58-L88, https://raw.githubusercontent.com/caddyserver/caddy/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go - Caddy basic_auth configuration and default bcrypt hashing (pinned tag v2.11.4): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/caddyauth/caddyfile.go#L29-L36 - oauth2-proxy email-domain restriction and wildcard configuration (pinned tag v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/pkg/apis/options/options.go#L141 - oauth2-proxy email-domain restriction and wildcard configuration at the release the recorded fixture used (pinned tag v7.15.4, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.4/pkg/apis/options/options.go#L141 - oauth2-proxy v7.15.6 release notes, the unverified-email callback now answering 403 instead of 500 (pinned tag v7.15.6, checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.6 - Keycloak TOTP policy and user-attribute-to-token-claim mapper used by the recorded fixture, which ran Keycloak 26.7.4; the cited text is the same at 26.8.2 (pinned tag 26.8.2, checked November 2026): https://raw.githubusercontent.com/keycloak/keycloak/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc, https://github.com/keycloak/keycloak/blob/26.8.2/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java#L96-L104 - Keycloak TOTP policy and user-attribute mapper at the release the fixture ran (pinned tag 26.7.4, checked November 2026): https://raw.githubusercontent.com/keycloak/keycloak/26.7.4/docs/documentation/server_admin/topics/authentication/otp-policies.adoc, https://github.com/keycloak/keycloak/blob/26.7.4/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java#L96-L104 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - streamlit: Streamlit (1.64.0) - streamlit-rolling: Streamlit documentation (rolling) (unknown) - release-notes: Streamlit release notes (unknown) - google-oidc: Google OpenID Connect documentation (rolling documentation, checked 2026-10-15) - authlib: Authlib minimum (1.3.2) - httpx: httpx minimum (0.24.1) - caddy: Caddy (v2.11.4) - oauth2-proxy: oauth2-proxy (v7.15.6) - oauth2-proxy-7154: oauth2-proxy (v7.15.4) - keycloak: Keycloak (26.8.2) - keycloak-2674: Keycloak (26.7.4) - ssrf: OWASP SSRF guidance (unknown) - curl: curl minimum write-out version (7.75.0) Claims (full source mapping in the JSON block below): - bind-default (REASONED; streamlit): server.address is unset and binds wildcard: try :: when IPv6 is supported, then 0.0.0.0 if unavailable; wildcard behavior was not demonstrated. - port-default (REASONED; streamlit): server.port defaults 8501 and may try 100 following ports when not explicitly set; explicitly configure 8501 to prevent that fallback. - private-bind (DEMONSTRATED; streamlit, streamlit-rolling): Loopback proxy configuration keeps Streamlit on 127.0.0.1:8501; observed inventory is local, not proof of external isolation. - proxy-websocket (DEMONSTRATED; streamlit-rolling): Proxy authentication must cover WebSocket upgrades as well as HTTP; a separately unprotected /_stcore/stream can expose app output despite index/health 401. - native-tls (REASONED; streamlit-rolling): sslCertFile/sslKeyFile provide native TLS for development; vendor recommends a production reverse proxy, with WebSocket upgrade and browser Origin preserved. - xsrf (REASONED; streamlit-rolling): enableXsrfProtection defaults true; it does not enable CORS or require a token when opening a WebSocket. - cors (REASONED; streamlit-rolling): enableCORS defaults true; disabling it permits cross-origin WebSockets even with XSRF; use corsAllowedOrigins and allowedHosts, and native auth separately enables both protections. - config (REASONED; streamlit-rolling): Effective precedence is CLI, environment, project config relative to working directory, then global; restart for server changes and restrict deployment writes. - toolbar (REASONED; streamlit-rolling): client.toolbarMode affects menu visibility, not authorization. - oidc-versions (REASONED; streamlit, authlib, httpx, streamlit-rolling, release-notes): st.login/st.logout date from 1.42.0; st.user from 1.45.0 replaces experimental_user; 1.64.0 auth extra needs Authlib>=1.3.2 and httpx>=0.24.1. - oidc-dependencies (DEMONSTRATED; streamlit): Recorded loopback login with Authlib but without httpx failed; install the complete auth extra. - oidc-config (REASONED; streamlit-rolling, streamlit, google-oidc): secrets.toml auth config supplies redirect_uri, cookie_secret, client_id/client_secret and metadata URL; the Google metadata URL is its discovery-document URI. Streamlit copies [auth] client_kwargs into its default provider configuration and its callback stores returned userinfo without a domain check, so st.login alone does not authorize a hosted domain; st.login authenticates identity, not resource authorization. - page-gates (REASONED; streamlit-rolling): Gate protected pages before rendering/side effects, before st.navigation page execution, and recheck authorization inside privileged callbacks. - native-gate (DEMONSTRATED; streamlit-rolling, keycloak, keycloak-2674): In the recorded Keycloak claim fixture, native login plus hd/email_verified checks admitted the allowed user and denied other-domain, missing-hd and unverified users. - email-gate (DEMONSTRATED; streamlit-rolling, keycloak, keycloak-2674): Recorded native gate denied unverified email; login without hd/email checks allowed the exposed fixture's other-domain and unverified users. - claims (REASONED; streamlit-rolling, streamlit, google-oidc): Streamlit 1.64.0 surfaces ID-token attributes on st.user. Google says hd names the Workspace or Cloud organization domain, is provided only for a Google Cloud organization account, and its absence indicates that an account does not belong to a Google hosted domain. The hd request parameter only optimizes account selection; access restrictions by hosted domain must check the returned claim. Explicit address allowlisting is an alternative for a fixed set. - cookie (REASONED; streamlit-rolling): Native identity cookie lasts 30 days and the lifetime is not configurable. - secrets (REASONED; streamlit-rolling): Keep secrets.toml out of Git, build contexts and served directories; restrict access, never render/log st.secrets, and rotate exposed client/cookie secrets. - errors (REASONED; streamlit-rolling): showErrorDetails defaults full; stacktrace/type/none are alternatives, deprecated true/false map to full/stacktrace; none hides browser details while console logs retain them. - mfa (REASONED; streamlit-rolling): OIDC MFA must be enforced at the provider; Basic auth or emailed PIN alone is not MFA, and alternative login paths/direct origin must not bypass policy. - password-widget (REASONED; streamlit-rolling): A plain text_input password comparison provides no session management, hashing or rate limiting. - uploads (REASONED; streamlit-rolling): file_uploader defaults 200 MB per file via maxUploadSize; widget max_upload_size overrides it; filters are best-effort, not content validation; rate-limit and never execute uploads. - static-default (REASONED; streamlit-rolling): enableStaticServing defaults false; enabled /app/static/ is served outside script login, so static/ must contain only public files. - static-test (DEMONSTRATED; streamlit-rolling): Recorded static serving bypassed st.stop; enabled returned marker text and disabled returned app HTML, both 200, so inspect the body. - execution-egress (REASONED; ssrf, streamlit-rolling): TLS/login do not sandbox Python; use least privilege and minimal credentials, avoid executing inputs, and restrict URL-fetch egress including metadata/internal networks. - verify-tls (DEMONSTRATED; curl, streamlit-rolling): Recorded native TLS returned 200 with the trusted test CA and failed trust with exit 60; never use -k. - verify-proxy (DEMONSTRATED; streamlit-rolling, caddy): Recorded Caddy loopback Basic-auth comparison returned exposed health 200/ok versus fixed 401; wrong credentials also failed and valid sessions received the canary. - verify-oidc-proxy (DEMONSTRATED; streamlit-rolling, oauth2-proxy-7154, keycloak, keycloak-2674): Recorded oauth2-proxy/Keycloak fixture redirected anonymous health and WebSocket requests; an allowed user reached the canary after password/TOTP, with other-domain 403. - oauth2-proxy-unverified-email (REASONED; oauth2-proxy): The recorded oauth2-proxy runs used v7.15.4, where the unverified user got a 500; the v7.15.6 release notes list a fix returning 403 instead of 500 when a user with an unverified email completes the OAuth2 callback, not observed here. - verify-native-browser (DEMONSTRATED; streamlit-rolling): Native anonymous index is intentionally 200; browser comparisons must check protected content, valid users, domain/email denial and MFA rather than index status. - verify-mfa (DEMONSTRATED; streamlit-rolling, keycloak, keycloak-2674): Recorded allowed native user reached the canary after TOTP; wrong code retained the provider's code prompt, using a loopback Keycloak fixture rather than Google. - verify-external (REASONED; streamlit, curl, streamlit-rolling): From another host any direct 8501 HTTP response is an exposure; refusal/no-route is not proof and DNS/local errors/timeouts are inconclusive; second-host vantage was unavailable. - verify-local-reachability (DEMONSTRATED; curl, streamlit-rolling): Recorded same-host probe distinguished a listening address from another loopback address; it does not demonstrate the external scenario. - verify-pages (REASONED; streamlit-rolling): Test every protected page and operation with anonymous, allowed and disallowed users, restoring an isolated exposed canary comparison; broken WebSockets or blank pages are not positive controls. - curl-version (REASONED; curl): Use curl 7.75.0+ for diagnostic variables and keep certificate validation enabled. - verify-fence (REASONED; streamlit, curl, streamlit-rolling): Both Verify fences remain REASONED because wildcard binding and second-host isolation were unavailable; the recorded loopback sub-results are separate historical demonstrated claims. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-10", "body_sha256": "0b49ec359ddfa611e974e64a14d8c9a03ce8acdeb15d5c93ea8487ed739702aa", "components": { "streamlit": { "name": "Streamlit", "basis": "1.64.0", "sources": { "s208ef00c9507": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/config.py#L1016-L1036", "s7e4fdd54875d": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server.py#L80-L98", "s41a3660b1c7b": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server.py#L139-L160", "s50b333f0759f": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server.py#L363-L400", "sa98504c29424": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_server_config.py#L55-L57", "s9d96cdf36131": "https://pypi.org/project/streamlit/1.64.0/", "s4d7021a05403": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/auth_util.py#L415-L431", "s8373238b25cd": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/web/server/starlette/starlette_auth_routes.py#L596-L620", "s5f9a6992d213": "https://github.com/streamlit/streamlit/blob/1.64.0/lib/streamlit/user_info.py#L550-L560" } }, "streamlit-rolling": { "name": "Streamlit documentation (rolling)", "basis": "unknown", "sources": { "s560ba59f10f9": "https://docs.streamlit.io/develop/api-reference/configuration/config.toml", "s5b7392df2d3a": "https://docs.streamlit.io/develop/concepts/connections/authentication", "s3f8bf05ddb38": "https://docs.streamlit.io/develop/api-reference/user/st.user", "s1322a5f1d6a0": "https://docs.streamlit.io/develop/api-reference/user/st.login", "s91c471ec9924": "https://docs.streamlit.io/develop/concepts/configuration/options", "s6f8f2b27ef37": "https://docs.streamlit.io/develop/concepts/configuration/serving-static-files", "sd09e0d235dd6": "https://docs.streamlit.io/develop/api-reference/widgets/st.file_uploader", "s720555b11b6e": "https://docs.streamlit.io/develop/concepts/connections/security-reminders", "s66e514173b37": "https://docs.streamlit.io/deploy/tutorials/docker" } }, "release-notes": { "name": "Streamlit release notes", "basis": "unknown", "sources": { "s6f6f9b0b14c2": "https://docs.streamlit.io/develop/quick-reference/release-notes", "se2c98289aac5": "https://docs.streamlit.io/develop/quick-reference/release-notes/2025#version-1420" } }, "google-oidc": { "name": "Google OpenID Connect documentation", "basis": "rolling documentation, checked 2026-10-15", "sources": { "s4f815acc2976": "https://developers.google.com/identity/openid-connect/openid-connect" } }, "authlib": { "name": "Authlib minimum", "basis": "1.3.2", "sources": { "s9d96cdf36131": "https://pypi.org/project/streamlit/1.64.0/" } }, "httpx": { "name": "httpx minimum", "basis": "0.24.1", "sources": { "s9d96cdf36131": "https://pypi.org/project/streamlit/1.64.0/" } }, "caddy": { "name": "Caddy", "basis": "v2.11.4", "sources": { "sb0b62262df7b": "https://github.com/caddyserver/caddy/blob/v2.11.4/caddyconfig/httpcaddyfile/builtins.go#L58-L88", "s9702adeb8c11": "https://raw.githubusercontent.com/caddyserver/caddy/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go", "s6b8d900baeb9": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/caddyauth/caddyfile.go#L29-L36" } }, "oauth2-proxy": { "name": "oauth2-proxy", "basis": "v7.15.6", "sources": { "s14dd604fb5ce": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/pkg/apis/options/options.go#L141", "s7acbcc639708": "https://github.com/oauth2-proxy/oauth2-proxy/releases/tag/v7.15.6" } }, "oauth2-proxy-7154": { "name": "oauth2-proxy", "basis": "v7.15.4", "sources": { "s088c1baa445d": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.4/pkg/apis/options/options.go#L141" } }, "keycloak": { "name": "Keycloak", "basis": "26.8.2", "sources": { "sa20c823bfbf3": "https://raw.githubusercontent.com/keycloak/keycloak/26.8.2/docs/documentation/server_admin/topics/authentication/otp-policies.adoc", "saaf5e049b457": "https://github.com/keycloak/keycloak/blob/26.8.2/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java#L96-L104" } }, "keycloak-2674": { "name": "Keycloak", "basis": "26.7.4", "sources": { "sf802dc9c4313": "https://raw.githubusercontent.com/keycloak/keycloak/26.7.4/docs/documentation/server_admin/topics/authentication/otp-policies.adoc", "se52072c97d20": "https://github.com/keycloak/keycloak/blob/26.7.4/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java#L96-L104" } }, "ssrf": { "name": "OWASP SSRF guidance", "basis": "unknown", "sources": { "s7eb820e1e53b": "https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "bind-default": {"text": "server.address is unset and binds wildcard: try :: when IPv6 is supported, then 0.0.0.0 if unavailable; wildcard behavior was not demonstrated.", "components": ["streamlit"], "sources": ["streamlit:s208ef00c9507", "streamlit:s7e4fdd54875d", "streamlit:s41a3660b1c7b"], "status": "REASONED"}, "port-default": {"text": "server.port defaults 8501 and may try 100 following ports when not explicitly set; explicitly configure 8501 to prevent that fallback.", "components": ["streamlit"], "sources": ["streamlit:s208ef00c9507", "streamlit:s50b333f0759f", "streamlit:sa98504c29424"], "status": "REASONED"}, "private-bind": {"text": "Loopback proxy configuration keeps Streamlit on 127.0.0.1:8501; observed inventory is local, not proof of external isolation.", "components": ["streamlit", "streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9", "streamlit:s208ef00c9507"], "status": "DEMONSTRATED", "evidence": "On the loopback runs, with `server.address` set to `127.0.0.1`, `ss` showed Streamlit only on `127.0.0.1:8501`."}, "proxy-websocket": {"text": "Proxy authentication must cover WebSocket upgrades as well as HTTP; a separately unprotected /_stcore/stream can expose app output despite index/health 401.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a", "streamlit-rolling:s66e514173b37"], "status": "DEMONSTRATED", "evidence": "the index and the health route still returned `401`, but an anonymous client that opened `/_stcore/stream` and asked for a script run received the app's output, the canary."}, "native-tls": {"text": "sslCertFile/sslKeyFile provide native TLS for development; vendor recommends a production reverse proxy, with WebSocket upgrade and browser Origin preserved.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9"], "status": "REASONED"}, "xsrf": {"text": "enableXsrfProtection defaults true; it does not enable CORS or require a token when opening a WebSocket.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9"], "status": "REASONED"}, "cors": {"text": "enableCORS defaults true; disabling it permits cross-origin WebSockets even with XSRF; use corsAllowedOrigins and allowedHosts, and native auth separately enables both protections.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9"], "status": "REASONED"}, "config": {"text": "Effective precedence is CLI, environment, project config relative to working directory, then global; restart for server changes and restrict deployment writes.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s91c471ec9924"], "status": "REASONED"}, "toolbar": {"text": "client.toolbarMode affects menu visibility, not authorization.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9"], "status": "REASONED"}, "oidc-versions": {"text": "st.login/st.logout date from 1.42.0; st.user from 1.45.0 replaces experimental_user; 1.64.0 auth extra needs Authlib>=1.3.2 and httpx>=0.24.1.", "components": ["streamlit", "authlib", "httpx", "streamlit-rolling", "release-notes"], "sources": ["release-notes:s6f6f9b0b14c2", "release-notes:se2c98289aac5", "streamlit-rolling:s1322a5f1d6a0", "streamlit:s9d96cdf36131", "authlib:s9d96cdf36131", "httpx:s9d96cdf36131"], "status": "REASONED"}, "oidc-dependencies": {"text": "Recorded loopback login with Authlib but without httpx failed; install the complete auth extra.", "components": ["streamlit"], "sources": ["streamlit:s9d96cdf36131"], "status": "DEMONSTRATED", "evidence": "every browser login attempt got `Internal Server Error`, with `ModuleNotFoundError: No module named 'httpx'` in Streamlit's log."}, "oidc-config": {"text": "secrets.toml auth config supplies redirect_uri, cookie_secret, client_id/client_secret and metadata URL; the Google metadata URL is its discovery-document URI. Streamlit copies [auth] client_kwargs into its default provider configuration and its callback stores returned userinfo without a domain check, so st.login alone does not authorize a hosted domain; st.login authenticates identity, not resource authorization.", "components": ["streamlit-rolling", "streamlit", "google-oidc"], "sources": ["streamlit-rolling:s5b7392df2d3a", "streamlit-rolling:s1322a5f1d6a0", "streamlit:s4d7021a05403", "streamlit:s8373238b25cd", "google-oidc:s4f815acc2976"], "status": "REASONED"}, "page-gates": {"text": "Gate protected pages before rendering/side effects, before st.navigation page execution, and recheck authorization inside privileged callbacks.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a"], "status": "REASONED"}, "native-gate": {"text": "In the recorded Keycloak claim fixture, native login plus hd/email_verified checks admitted the allowed user and denied other-domain, missing-hd and unverified users.", "components": ["streamlit-rolling", "keycloak", "keycloak-2674"], "sources": ["streamlit-rolling:s5b7392df2d3a", "streamlit-rolling:s3f8bf05ddb38", "keycloak:saaf5e049b457", "keycloak-2674:se52072c97d20"], "status": "DEMONSTRATED", "evidence": "The user in another domain, and the user with no `hd` attribute, saw \"This account is not authorized for this app.\""}, "email-gate": {"text": "Recorded native gate denied unverified email; login without hd/email checks allowed the exposed fixture's other-domain and unverified users.", "components": ["streamlit-rolling", "keycloak", "keycloak-2674"], "sources": ["streamlit-rolling:s5b7392df2d3a", "streamlit-rolling:s3f8bf05ddb38", "keycloak:saaf5e049b457", "keycloak-2674:se52072c97d20"], "status": "DEMONSTRATED", "evidence": "The unverified user saw \"This account's email is not verified.\""}, "claims": {"text": "Streamlit 1.64.0 surfaces ID-token attributes on st.user. Google says hd names the Workspace or Cloud organization domain, is provided only for a Google Cloud organization account, and its absence indicates that an account does not belong to a Google hosted domain. The hd request parameter only optimizes account selection; access restrictions by hosted domain must check the returned claim. Explicit address allowlisting is an alternative for a fixed set.", "components": ["streamlit-rolling", "streamlit", "google-oidc"], "sources": ["streamlit-rolling:s5b7392df2d3a", "streamlit-rolling:s3f8bf05ddb38", "streamlit:s5f9a6992d213", "google-oidc:s4f815acc2976"], "status": "REASONED"}, "cookie": {"text": "Native identity cookie lasts 30 days and the lifetime is not configurable.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a"], "status": "REASONED"}, "secrets": {"text": "Keep secrets.toml out of Git, build contexts and served directories; restrict access, never render/log st.secrets, and rotate exposed client/cookie secrets.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s720555b11b6e"], "status": "REASONED"}, "errors": {"text": "showErrorDetails defaults full; stacktrace/type/none are alternatives, deprecated true/false map to full/stacktrace; none hides browser details while console logs retain them.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9"], "status": "REASONED"}, "mfa": {"text": "OIDC MFA must be enforced at the provider; Basic auth or emailed PIN alone is not MFA, and alternative login paths/direct origin must not bypass policy.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a"], "status": "REASONED"}, "password-widget": {"text": "A plain text_input password comparison provides no session management, hashing or rate limiting.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a"], "status": "REASONED"}, "uploads": {"text": "file_uploader defaults 200 MB per file via maxUploadSize; widget max_upload_size overrides it; filters are best-effort, not content validation; rate-limit and never execute uploads.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9", "streamlit-rolling:sd09e0d235dd6"], "status": "REASONED"}, "static-default": {"text": "enableStaticServing defaults false; enabled /app/static/ is served outside script login, so static/ must contain only public files.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9", "streamlit-rolling:s6f8f2b27ef37"], "status": "REASONED"}, "static-test": {"text": "Recorded static serving bypassed st.stop; enabled returned marker text and disabled returned app HTML, both 200, so inspect the body.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s6f8f2b27ef37"], "status": "DEMONSTRATED", "evidence": "with `server.enableStaticServing` on, the script called `st.stop()` at once, yet `/app/static/public.txt` returned the file with `200` and `text/plain`. With it off, the same path returned `200` with the app's HTML page, not the file."}, "execution-egress": {"text": "TLS/login do not sandbox Python; use least privilege and minimal credentials, avoid executing inputs, and restrict URL-fetch egress including metadata/internal networks.", "components": ["ssrf", "streamlit-rolling"], "sources": ["streamlit-rolling:s720555b11b6e", "ssrf:s7eb820e1e53b"], "status": "REASONED"}, "verify-tls": {"text": "Recorded native TLS returned 200 with the trusted test CA and failed trust with exit 60; never use -k.", "components": ["curl", "streamlit-rolling"], "sources": ["streamlit-rolling:s560ba59f10f9", "curl:s2b2686afaf41"], "status": "DEMONSTRATED", "evidence": "**Block 1 against native TLS:** `tls=200` with the test CA trusted. With curl not trusting the certificate it stopped at `tls=000 exit=60`."}, "verify-proxy": {"text": "Recorded Caddy loopback Basic-auth comparison returned exposed health 200/ok versus fixed 401; wrong credentials also failed and valid sessions received the canary.", "components": ["streamlit-rolling", "caddy"], "sources": ["streamlit-rolling:s5b7392df2d3a", "streamlit-rolling:s66e514173b37", "caddy:sb0b62262df7b", "caddy:s9702adeb8c11", "caddy:s6b8d900baeb9"], "status": "DEMONSTRATED", "evidence": "Block 1 got `401` on both requests, and a wrong credential also got `401`."}, "verify-oidc-proxy": {"text": "Recorded oauth2-proxy/Keycloak fixture redirected anonymous health and WebSocket requests; an allowed user reached the canary after password/TOTP, with other-domain 403.", "components": ["streamlit-rolling", "oauth2-proxy-7154", "keycloak", "keycloak-2674"], "sources": ["streamlit-rolling:s5b7392df2d3a", "oauth2-proxy-7154:s088c1baa445d", "keycloak:sa20c823bfbf3", "keycloak-2674:sf802dc9c4313"], "status": "DEMONSTRATED", "evidence": "The anonymous health request got a `302`, and an anonymous websocket upgrade was redirected to the provider's authorization endpoint instead of upgraded."}, "oauth2-proxy-unverified-email": {"text": "The recorded oauth2-proxy runs used v7.15.4, where the unverified user got a 500; the v7.15.6 release notes list a fix returning 403 instead of 500 when a user with an unverified email completes the OAuth2 callback, not observed here.", "components": ["oauth2-proxy"], "sources": ["oauth2-proxy:s7acbcc639708"], "status": "REASONED"}, "verify-native-browser": {"text": "Native anonymous index is intentionally 200; browser comparisons must check protected content, valid users, domain/email denial and MFA rather than index status.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a"], "status": "DEMONSTRATED", "evidence": "The anonymous index returned `200` by design, and the anonymous browser was sent to the provider's sign-in page."}, "verify-mfa": {"text": "Recorded allowed native user reached the canary after TOTP; wrong code retained the provider's code prompt, using a loopback Keycloak fixture rather than Google.", "components": ["streamlit-rolling", "keycloak", "keycloak-2674"], "sources": ["streamlit-rolling:s5b7392df2d3a", "keycloak:sa20c823bfbf3", "keycloak-2674:sf802dc9c4313"], "status": "DEMONSTRATED", "evidence": "The allowed user rendered the canary after enrolling and then entering TOTP codes; a wrong code kept them at the one-time-code prompt."}, "verify-external": {"text": "From another host any direct 8501 HTTP response is an exposure; refusal/no-route is not proof and DNS/local errors/timeouts are inconclusive; second-host vantage was unavailable.", "components": ["streamlit", "curl", "streamlit-rolling"], "sources": ["streamlit:s208ef00c9507", "streamlit:s7e4fdd54875d", "streamlit-rolling:s66e514173b37", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2]}, "verify-local-reachability": {"text": "Recorded same-host probe distinguished a listening address from another loopback address; it does not demonstrate the external scenario.", "components": ["curl", "streamlit-rolling"], "sources": ["streamlit-rolling:s66e514173b37", "curl:s2b2686afaf41"], "status": "DEMONSTRATED", "evidence": "**Block 3 from the same host:** `http=200` against the address Streamlit listened on, and `exit=7` against another loopback address."}, "verify-pages": {"text": "Test every protected page and operation with anonymous, allowed and disallowed users, restoring an isolated exposed canary comparison; broken WebSockets or blank pages are not positive controls.", "components": ["streamlit-rolling"], "sources": ["streamlit-rolling:s5b7392df2d3a"], "status": "REASONED"}, "curl-version": {"text": "Use curl 7.75.0+ for diagnostic variables and keep certificate validation enabled.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED"}, "verify-fence": {"text": "Both Verify fences remain REASONED because wildcard binding and second-host isolation were unavailable; the recorded loopback sub-results are separate historical demonstrated claims.", "components": ["streamlit", "curl", "streamlit-rolling"], "sources": ["streamlit:s208ef00c9507", "streamlit:s7e4fdd54875d", "streamlit-rolling:s66e514173b37", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1, 2]} } } ```` ====================================================================== ==> n8n.md ====================================================================== # n8n: binding, TLS, and MFA n8n is a workflow automation tool with a web editor and webhook endpoints. This guide helps your AI assistant make sure it is configured securely: a private bind, TLS from a proxy or n8n itself, accounts with MFA, webhook authentication, and code-node execution isolated. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/n8n.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against n8n listener source n8n@2.47.2, n8n runtime source n8n@2.47.2, n8n environment policy minimum 2.18.0, n8n SSRF filter minimum 2.12.0, n8n 2.0 default exclusion source n8n@2.0.0, n8n 1.x default exclusion source n8n@1.123.85, and n8n later 2.x default exclusion source n8n@2.43.8, plus 7 more recorded bases; 2 further sources are pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-11-07; documentation checked October 2026. The guide records 92 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. n8n includes user management (complete the owner setup on first run), but its network defaults deserve attention: `N8N_LISTEN_ADDRESS` defaults to `::` and `N8N_PORT` to `5678` (both as of n8n 2.47.2), so n8n listens on **all interfaces** over plain HTTP. ## 1. Bind privately Behind a reverse proxy or tunnel (the recommended layout): ``` N8N_LISTEN_ADDRESS=127.0.0.1 N8N_PORT=5678 N8N_HOST=n8n.example.com ``` `N8N_LISTEN_ADDRESS=127.0.0.1` is right for n8n running directly on the host behind a same-host proxy. INSIDE a bridged Docker container it binds the container's own loopback, so a sibling proxy container or a published port cannot reach it: there, either put n8n and the proxy on a private Compose network with NO published n8n port, or publish to host loopback only (`127.0.0.1:5678:5678`). Keep the Postgres and task-runner broker ports unpublished either way. Publish only the proxy per [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)/[nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), or use [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)/[tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md). Set the public URL fully, not just `N8N_HOST`: `N8N_PROTOCOL=https`, `N8N_WEBHOOK_URL=https://n8n.example.com/` (the external base for generated webhook URLs; it replaces the `WEBHOOK_URL` alias, deprecated from n8n 2.35.0 though the old name still works with a warning), and keep `N8N_SECURE_COOKIE=true` (its default). Behind a proxy also set the trusted proxy-hop count (`N8N_PROXY_HOPS`) so client IPs and rate limiting are read from the right forwarded header. Webhook endpoints are meant to be reachable by external services; that is no reason for the editor UI to be. ## 2. Or terminate TLS in n8n itself ``` N8N_PROTOCOL=https # default is http N8N_SSL_KEY=/path/to/privkey.pem N8N_SSL_CERT=/path/to/fullchain.pem ``` ## 3. Accounts and MFA - Finish the owner-account setup before publication. At n8n 2.47.2, the owner setup handler skips authentication and issues the new owner a session cookie. - Individual users can enable two-factor authentication on their accounts (verify availability for your version and licence). - Instance-wide enforcement exists under **Settings > Security** ("Enforce two-factor authentication"), or via `N8N_MFA_ENFORCED_ENABLED=true` with `N8N_SECURITY_POLICY_MANAGED_BY_ENV=true` (the environment-managed policy is available from n8n 2.18.0, so confirm your version and that enforcement actually takes effect). The n8n docs state that this instance-wide enforcement requires a Business or Enterprise licence on self-hosted instances, and that it does not apply to SSO logins (enforce MFA at the identity provider for those; [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). - Credentials stored in n8n (API keys for the services your workflows touch) make the instance a secrets vault; treat access to it accordingly ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Those credentials are encrypted at rest with `N8N_ENCRYPTION_KEY`: leaving it unset does NOT mean plaintext (n8n generates a random key on first launch and saves it under `~/.n8n`), but losing that key while keeping the database makes every stored credential unrecoverable, and committing it hands the decryption secret to anyone who reads the repo. Set it explicitly, keep the same value across every worker/replica, back it up separately from the database, and keep it out of source control and images ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ## 4. Webhook authentication - A Webhook node left on **None** answers anyone who reaches its URL. For a webhook whose caller you control, set the node **Authentication** to **Basic auth**, **Header auth**, or **JWT auth** and attach the matching Webhook credential. - Webhook authentication is configured per node and is separate from the instance login and the public API key (`X-N8N-API-KEY`). Hardening the editor or `/api/v1` does nothing for webhook endpoints, so audit every Webhook node individually. - Where a third party must call the webhook and cannot present your credential, **None** is unavoidable at the node, so the workflow itself must authenticate the request: validate the provider's signature (for example an HMAC header) in the first node after the Webhook trigger and reject a missing or wrong signature before any further processing. A **None** webhook with no such check is open. ## 5. Code-node isolation and outbound requests - Workflow editors can run code: the **Code** node executes JavaScript/Python and the **Execute Command** node runs the command through a shell in a child process of the n8n process executing the node. At n8n 2.47.2, `NODES_EXCLUDE` disables Execute Command by default; the commit-pinned n8n 2.0.0 and 2.43.8 defaults exclude Execute Command and Local File Trigger, while the commit-pinned n8n 1.123.85 default on the 1.x line is empty (excludes none). Commit a4757cf0 ("chore: Initial V2 changes", 2025-12-01) changed this default in `nodes.config.ts` from empty to those two node types; n8n 2.0.0-rc.0, the lowest-version 2.x tag, has that default, while n8n 1.123.0 keeps it empty, and the four later commits to `nodes.config.ts` through ea8417e2 do not change it. This guide's assessment of `nodes.config.ts` at each n8n tag, as of October 2026, is that the default stays empty at every 1.123.x tag and excludes both nodes at every 2.x tag from 2.0.0-rc.0 through 2.45.0, including the commit-pinned 2.45.0 file; of the releases after 2.45.0 only 2.47.2, this guide's basis, was read, and no release was run, so confirm the default on your installed version. The rolling 2.0 changes page, which is unversioned, also lists both nodes as disabled by default in n8n 2.0. Leave it off unless a workflow truly needs it. Run code on hardened EXTERNAL task runners rather than the internal mode, which the vendor calls insecure by design, and keep workflow `$env` access blocked (`N8N_BLOCK_ENV_ACCESS_IN_NODE=true`; at n8n 2.47.2 the `$env` proxy is blocked unless the variable is set to `false`). This guide's assessment is that this guard alone does not establish isolation of every node or Code `process.env`. Allow only the modules a workflow needs, narrowly, and set the allowlist where the RUNNER reads it, not just on the main n8n container (whose values the external launcher overrides). The allowlist to set for the JavaScript runner is `NODE_FUNCTION_ALLOW_BUILTIN` and `NODE_FUNCTION_ALLOW_EXTERNAL` under `env-overrides` in the launcher's `/etc/n8n-task-runners.json`; the allowlist to set for the Python runner is `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW`. At n8n 2.47.2 an unset allowlist allows no modules (both runners' allowlists default to empty), while `*` grants every module of its kind. Because these defaults have shifted across versions and one vendor table is stale, set each value explicitly and confirm it on your installed version. - At n8n 2.47.2, the JS runner's all-items and per-item context has module resolution, console, workflow static data, selected native globals, the data proxy, RPC and additional properties. This guide reasons that no direct `process` binding appears in the explicit native list or context; imported modules and injected properties prevent treating that omission as complete `process.env` isolation. - From source reading at n8n 2.47.2, the JS data proxy exposes `$env`, `$input`, `$json`, `$node`, `$workflow`, `$evaluateExpression` and other `$` helpers, plus `getAdditionalKeys`'s `$execution`, `$evaluation`, `$vars`, `$secrets` and two deprecated keys. The JS runner calls `getAdditionalKeys` without its credential option, so `$secrets` is `undefined` there. - From source reading at n8n 2.47.2, the JS context spreads a task's `additionalProperties` after `require`, the data proxy and RPC, per-item runs then assign the data proxy and `item` over them, and a `runCode` context holds only a marker and them. The Code node's JS sandbox sends its constructor's `additionalProperties`, default `{}`. - From source reading at n8n 2.47.2, the Code and AI Transform nodes build that JS sandbox with the default `additionalProperties`, the Code Tool node with `{ query }` and the Sort and Item Lists v3 code sorts with `{ items }`. That only those two sorts call its `runCode`, wrapping their code in `return items.sort(...)`, rests on 2.40.6 searches of `packages` outside tests, not redone at 2.47.2. - From source reading at n8n 2.47.2, the JS runner binds RPC functions for seven `helpers` methods, including `httpRequest` and `request`, in all-items and per-item contexts, not in `runCode` ones; the n8n-side requester, which also allows `logNodeOutput`, calls the named method on the task's `executeFunctions`. Other listed helpers throw; only `getBinaryDataBuffer`'s own read path was traced. - From source reading at n8n 2.47.2, each bound RPC function's `makeRpcCall` sends a `runner:rpc` message over the runner's WebSocket to the task broker, which passes it to the task's requester, the in-process `LocalTaskRequester`, and returns its `requester:rpcresponse` to the runner. This guide reasons that the named helper runs in the n8n process that started the task, not in the runner. - At n8n 2.47.2, the JS data proxy receives `envProviderState`; if absent, its fallback is `env: {}`, `isEnvAccessBlocked: false` and `isProcessAvailable: true`. This guide reasons that the fallback avoids using the runner environment for `$env`; the cited consumer does not establish the contents or production of incoming state. - From source reading at n8n 2.47.2, `createEnvProviderState` sets `env` to `{}` unless `process` exists and `N8N_BLOCK_ENV_ACCESS_IN_NODE` is `false`, and otherwise to that process's `process.env`. The node context's `startJob` calls it and passes the result through `startRunnerTask` to `TaskRequester.startTask`, which stores it in the task data. - From source reading at n8n 2.47.2, the requester answers a runner's data request with that state, replaced by `env: {}` unless the request's `env` flag is set, and the broker forwards the answer to the runner. The JS runner sets the flag when its parser finds `$env` in the code or cannot parse the code. - This guide infers from the cited producer, requester and parser sources that, with `N8N_BLOCK_ENV_ACCESS_IN_NODE=false`, a JS Code task using `$env` receives the whole environment of the n8n process running the node, including any secrets set there, even on an external runner; with the variable unset or set to anything else it receives `env: {}`. No run observed this. - At n8n 2.47.2, JS all-items and per-item execution use `runInContext` in secure mode and `runDirectly` in insecure mode. This guide reasons that the latter, using `new Function` with `with(context)`, can resolve ambient `process.env` unless shadowed; this is not a live demonstration or proof of complete isolation in secure mode. - The task-runner environment-variable page, read 2026-10-24, says `N8N_RUNNERS_INSECURE_MODE` disables all task-runner security measures and `N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION` relaxes security; keep both `false`. By source reading at n8n 2.47.2, `insecureMode` in n8n (three JS spawn flags) and in the JS runner (prototype hardening) read that variable, default `false`. - From source reading at n8n 2.47.2, both `insecureMode` settings use the `@Env` decorator, which records `N8N_RUNNERS_INSECURE_MODE` as metadata; the `@Config` factory reads that variable or, if it is absent, the trimmed file that `N8N_RUNNERS_INSECURE_MODE_FILE` names. - From source reading at n8n 2.47.2, for both `insecureMode` settings the `@Config` factory accepts `true` or `1` and `false` or `0`, ignoring case; any other value, such as `yes` or a quoted or space-padded variable, logs a warning and leaves the default `false`. - From source reading at n8n 2.47.2, the JS runner in secure mode runs `freezeGlobals` unless `NODE_ENV` is `test` (`NODE_ENV=test` skips it). It freezes each function-valued global, its `prototype`, some timer, generator and iterator prototypes and the objects on their prototype chains, except `EventEmitter.prototype`, plus `Reflect`, `JSON` and `Math`, and locks six `EventEmitter.prototype` methods. This guide reasons that other non-function globals, such as `process`, are not themselves frozen. - From source reading at n8n 2.47.2, JS Code's `require` is the runner's resolver, which loads a builtin or external module only if its exact name is in `NODE_FUNCTION_ALLOW_BUILTIN` or `NODE_FUNCTION_ALLOW_EXTERNAL`, both empty by default, or the list is `*`. This guide infers that allowing `process`, `child_process` or `*` lets user code reach the runner process's own environment. - At n8n 2.47.2, Python all-items and per-item execution clear `os.environ` before user code only if `runner_env_deny` is true; the runner sets it from `N8N_BLOCK_RUNNER_ENV_ACCESS`, read with default `True`, so by default they clear it, and when false, they do not clear the task process environment. This guide did not establish effective module access. - At n8n 2.47.2, Python `main.py` builds the runner configuration with `TaskRunnerConfig.from_env()`, which reads `N8N_BLOCK_RUNNER_ENV_ACCESS` with `read_bool_env`; that returns its default when `read_env` returns `None`, otherwise true only when the trimmed, lower-cased value is `true`. This guide reasons that any other set value, such as `1`, disables clearing `os.environ` before user code. - At n8n 2.47.2, Python `read_env` reads `os.environ` first, otherwise a file named by the corresponding `_FILE` variable, or returns `None`. This guide assesses that this configuration reader does not itself establish which environment values user code can access. - The task-runner environment-variable page, read 2026-10-24, says variables with a `_FILE` suffix are not recognized in the task runner image, unlike the main n8n image, so do not rely on `_FILE` variables for runner-container secrets such as `N8N_RUNNERS_AUTH_TOKEN`. The next item reconciles that with the runners' own `_FILE` readers at n8n 2.47.2. - From source reading at n8n 2.47.2, both runners read a `_FILE` fallback for their own settings (Python `read_env`, JS through `@n8n/config`), but the runners image's shipped `n8n-task-runners.json` lists no `_FILE` name for either runner and neither internal spawn copies one. This guide reasons that for the runners' own settings, launch configuration, not runner code, matches the page. - From source reading of launcher 1.5.0 and 1.5.1, `LoadLauncherConfig` passes `LauncherLookuper` to go-envconfig for `BaseConfig`, which holds the required `N8N_RUNNERS_AUTH_TOKEN`; that each top-level setting is read through it is this guide's inference. `Lookup` prefers the file `KEY_FILE` names over `KEY`, strips trailing newlines and carriage returns and returns unset if the file is unreadable. - From source reading of launcher 1.5.0 and 1.5.1, the 3 shutdown settings that `launch.go` reads with `os.Getenv` and the runner environment that `env.go` copies from the launcher's own environment do not use that lookuper. Of the values `env.go` writes, only the task broker URI and the 2 legacy timeouts come from that configuration, so only they can carry a value read through `_FILE`. - The environment-variable page's `_FILE` statement is reported above as read. This guide's assessment, not a vendor statement, is that for the launcher's own settings at 1.5.0, the n8n 2.40.6 runners Dockerfile default, source reading does not match the page; launcher 1.6.0, the n8n 2.47.2 default, was not read (NOT ESTABLISHED). Whether a published image's launcher binary reads `_FILE` variables this way needs a run. - From source reading at n8n 2.47.2, the Python runner reads `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` as comma lists that default to empty, so no module is allowed unless set, and `*` must stand alone; checks use the top-level module name. `N8N_RUNNERS_BUILTINS_DENY` defaults to a list including `eval`, `exec`, `open` and `getattr`. - From source reading at n8n 2.47.2, the Python runner checks user code's imports against those allowlists before execution unless both are `*`; the executor limits `sys.modules` to allowed modules, a fixed safe set and their submodules, removes denied builtins and replaces `__import__` and `importlib`'s import entry points with allowlist-checking callables. Effective module access was not observed. - The task-runner environment-variable page, read 2026-10-24, says `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` (default `false`) lets an allowlisted package's own imports skip both Python allowlists, trusting all installed packages' imports; keep it `false` unless they are trusted. By source reading at n8n 2.47.2, the internal Python spawn copies it from n8n. - From source reading at n8n 2.47.2, the Python runner reads `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` with `read_bool_env`, default `False`, into `SecurityConfig`; the guards on `importlib.import_module` and `importlib.__import__` skip the allowlists when it is true and the importer is not user code. User code's own `__import__` guard always checks; `TaskAnalyzer` reads only the two allowlists. - This guide infers from Python import semantics, not cited here, that an allowlisted package's own `import` statements call the real `builtins.__import__`, which the cited executor does not replace, so neither allowlist checks them whatever this setting's value. No run observed this, and code outside the cited files could add a check. - Launcher 1.5.1 prepares the runner environment from its own `LANG`, `PATH`, `TZ` and `TERM` plus names in `allowed-env`; `env-overrides` sets values directly. This guide advises keeping secrets out of `allowed-env` and keeping that list narrow, because listed values can enter the runner process environment. - Launcher 1.5.1 sets the task broker URI, health-check enable flag and port, and legacy auto-shutdown and task timeouts. It removes inherited required runtime keys, including the grant token, and ignores their `env-overrides`; other overrides replace prior values, while grant-token insertion is not shown by this preparation function. - From source reading of launcher 1.5.1, the launch command builds the runner environment with `PrepareRunnerEnv`, fetches a grant token from the task broker, appends `N8N_RUNNERS_GRANT_TOKEN` to that environment and sets the result as the runner command's environment. - From source reading of the 29 launcher tags fetched on 2026-10-26, `env.go` is identical from 1.3.1 to 1.5.1 and `launch.go` at 1.5.0 and 1.5.1, and `config.go` has `env-overrides` only from 1.2.0. This guide infers from decoding defaults, not cited here, that earlier launchers ignore that field, so `env-overrides` allowlists need 1.2.0 or later. Untagged commits and later tags are not covered. - From source reading, the n8n 2.47.2 runners image Dockerfile defaults `LAUNCHER_VERSION` to 1.6.0, a launcher release this guide has not read, installs that release as the image entrypoint and copies `n8n-task-runners.json` to `/etc/n8n-task-runners.json`. This is image source; image reading found the 1.5.0 release binary in the 2.40.6 image of 2026-10-24 on both platforms, and no 2.47.2 image was read. - From source reading, the n8n 2.47.2 runners image's `n8n-task-runners.json` lists `PATH` and timeout, concurrency, Sentry and version variables in both runners' `allowed-env`, plus `GENERIC_TIMEZONE`, `NODE_OPTIONS`, `NODE_PATH`, `HOME` and a shutdown margin for JS and the launcher log level for Python. This is image source, not a deployed configuration; no 2.47.2 image was read. - From source reading, the n8n 2.47.2 runners image's `n8n-task-runners.json` uses `env-overrides` to set `NODE_FUNCTION_ALLOW_BUILTIN` to `crypto` and `NODE_FUNCTION_ALLOW_EXTERNAL` to `moment` for the JS runner and `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` to empty for the Python runner. This is image source, not a deployed configuration; no 2.47.2 image was read. - From source reading at n8n 2.47.2, `N8N_RUNNERS_MODE` defaults to `internal`, in which the task runner module starts a JS runner and, if the Python requirements check passes, a Python runner as child processes of n8n. Each spawn passes an explicit `env` object of named variables rather than n8n's whole `process.env`. - From source reading at n8n 2.47.2, the internal JS runner's `env` copies `PATH`, `HOME`, `NODE_PATH`, `GENERIC_TIMEZONE`, both `NODE_FUNCTION_ALLOW_*` lists, four Sentry names, `N8N_RUNNERS_INSECURE_MODE` and two shutdown settings from n8n, and the Python runner's copies `PATH`, `HOME`, its module settings and `N8N_BLOCK_RUNNER_ENV_ACCESS`; both add the grant token and runner settings. - The n8n task runner set-up page, checked October 2026, documents that an internal-mode runner is a child process sharing n8n's uid and gid, that code escaping its sandbox has the same access as n8n, including to stored credentials, and that n8n logs a deprecation warning for internal mode, deprecated from n8n 3.0, including when `N8N_RUNNERS_MODE` isn't set. - The hardening page, read 2026-10-24, recommends external-mode sidecars, the `-distroless` image, running as `nobody` (uid and gid 65532), a read-only root filesystem with a minimal `emptyDir` on `/tmp`, and the AppArmor rule `audit deny @{PROC}/[0-9]*/{environ,mounts} rwl,`. It says those `/proc` files can expose environment variables, including secrets and credentials, to code in the container. - The hardening page, read 2026-10-24, also says Code node executions are secure and that external runners provide a fully isolated environment for the Code node's JavaScript, without defining that isolation. This guide's assessment is that the cited `$env` and `os.environ` controls do not establish that the `/proc` channel the page names is closed, so apply its AppArmor rule as well. - From source reading at n8n 2.47.2, under the default allowlists and settings neither runner gives Code user code a direct file-read primitive: the JS context binds no `fs` or `process` and `require` refuses unlisted modules, while Python removes `open` and refuses unlisted imports. The next 3 items cover indirect paths; no run tested a `/proc` read, so keep the AppArmor rule. - From source reading at n8n 2.47.2, the `getBinaryDataBuffer` helper accepts a caller-built `id`, but the filesystem store's path check rejects any key resolving outside its storage root, so through that store this helper cannot name a process's `/proc` `environ` file by path. The check is on path text and symlinks were not traced; the database, s3 and azure managers were not read. - From source reading at n8n 2.47.2, the Python executor keeps `builtins`, `__main__`, `sys`, `traceback`, `linecache`, `importlib` and `importlib.machinery` and their submodules loaded whatever the allowlists. Its import check refuses every module name while both allowlists are empty, their default, so none of these is importable under the defaults; effective module access was not observed. - This guide has not traced, at n8n 2.47.2, the `httpRequest`, `request` and `binaryToString` helper bodies, data-proxy members such as `$evaluateExpression`, the allowed `crypto` and `moment` exports, JS host-realm objects, or Python routes other than import, such as attribute walks from objects user code is given and builtins not on the deny list, so indirect file reads through them remain open. - From source reading at n8n 2.47.2, `N8N_EXECUTION_DATA_STORAGE_MODE` takes `database`, its default, `filesystem`, `s3` or `azure`. In `database` mode n8n writes an execution's data to the database with its row; in the other modes it writes the row to the database and the data to a byte store, and it reads each execution's data from the store its recorded `storedAt` names. - From source reading at n8n 2.47.2, in `filesystem` mode that byte store is rooted at the storage path (`N8N_STORAGE_PATH`), also binary data's filesystem root unless `N8N_BINARY_DATA_STORAGE_PATH` is set, and `StorageConfig.sanitize()` throws when both are set to different values. In `s3` and `azure` modes it uses the same S3 or Azure Blob service as binary data's s3 or azure manager. - This guide infers, with nothing run, that in the `filesystem`, `s3` and `azure` execution-data storage modes a Code task is not confined to its own execution's stored data. It reasons from the 2 storage items before this one, which alone do not establish it, and the `getBinaryDataBuffer` item above, whose caveat that the s3 and azure managers were not read applies. The next 3 items advise on it. - This guide advises keeping `N8N_EXECUTION_DATA_STORAGE_MODE` at `database`, by its reading the only mode that keeps execution data out of the filesystem root and the S3 or Azure service that binary data's filesystem, s3 and azure managers use. Setting only `N8N_BINARY_DATA_STORAGE_PATH`, not traced, is not offered as a separation. It infers that switching back leaves written data in place. - Where people you do not trust can edit workflows, exclude the Code node (`n8n-nodes-base.code`) with `NODES_EXCLUDE` or keep them from editing workflows; this guide read no n8n setting that limits who can edit Code nodes. At n8n 2.47.2 the security audit lists the Code node among n8n's risky node types and recommends not loading unneeded node types with `NODES_EXCLUDE`. - This guide infers from the sandbox items above that the AI Transform and Code Tool nodes, which construct the same JS sandbox as the Code node and which the audit's list omits, run workflow code the same way, so excluding the Code node alone may not be enough. Whether `NODES_EXCLUDE` can name them was not read. At n8n 2.47.2 a source comment on `NODES_EXCLUDE` says generated tool variants are accepted too and the base node stays available; whether excluding a base node also removes its generated tool variant was not read (NOT ESTABLISHED). - From source reading at n8n 2.47.2, Execute Command spawns its command with `cwd`, `shell: true`, `detached` and `windowsHide` options and no `env` option. This guide infers from Node.js `child_process` semantics, not cited here, that the shell inherits the environment of the n8n process running the node; no run observed this. - This guide read all but the `exec` matches of a `git grep -F` at n8n 2.40.6, not repeated at 2.47.2, for `startJob`, `child_process`, `spawn`, `exec`, `fork`, `new Function` and `vm.` in `packages/nodes-base/nodes` and `packages/@n8n/nodes-langchain/nodes`, excluding tests. There, no node besides those named here starts a process or runs workflow code; it bounds those spellings and paths only. - For `exec`, this guide narrowed its node search at n8n 2.40.6, not repeated at 2.47.2, with `git grep -l -P -e 'exec(?!ut)'` in `packages/nodes-base/nodes` and `packages/@n8n/nodes-langchain/nodes` to 28 non-test files and read their matches. There, no node besides those named here starts a process or runs workflow code; it bounds that pattern and those paths only. - From source reading at n8n 2.47.2, the hidden Function and Function Item nodes and the LangChain Code node run workflow JavaScript in a vm2 `NodeVM` in the n8n process running the node, not on a task runner, with `require` limited by that process's `NODE_FUNCTION_ALLOW_BUILTIN` and `NODE_FUNCTION_ALLOW_EXTERNAL`. LangChain Code also allows LangChain packages; what vm2 exposes was not traced. - From source reading at n8n 2.47.2, the Merge node's SQL mode runs its query with alasql in an `isolated-vm` isolate whose file and network sources throw, and the SSH node runs its command on the remote host with `node-ssh`. The Git node passes `simple-git` an `env` object of two variables; this guide infers from a source comment, not simple-git's code, that it starts a git process. - This guide's assessment is that the cited n8n 2.47.2 runner and launcher 1.5.1 sources describe specific environment controls, not complete host/container isolation. Internal-mode isolation beyond the spawn environment, nodes beyond the searched spellings and paths, other launcher versions, deployed configuration and complete Code `process.env` isolation remain unestablished. - Outbound requests are a trust boundary too: a webhook or the **HTTP Request** node can be steered at internal services or the cloud metadata endpoint (SSRF). Enable n8n's own SSRF filter where available (`N8N_SSRF_PROTECTION_ENABLED=true`, from n8n 2.12.0) AND restrict the workload's egress so a request cannot reach your internal network or `169.254.169.254` ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)); the app-level filter supplements a network control and does not contain arbitrary code- or command-node networking. ## 6. Verify REASONED: At n8n 2.47.2 the public API mounts no OpenAPI validator: each version router runs the JSON body parser, the `@PublicApiController` routes and then a handler that answers 404 with message `not found` ([mount order, lines 163-179](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L163-L179)), so authentication happens only in each controller route's own middleware. The auth registry asks each registered strategy in order: `authenticated` ends the loop and authenticates the request, `rejected` ends it unauthenticated and `abstained` passes to the next strategy, so a request on which every strategy abstains is unauthenticated ([lines 55-79](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.registry.ts#L55-L79), [outcomes, lines 15-18](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.types.ts#L15-L18)). For an unauthenticated request the registry also collects one challenge per registered strategy, the rejecting strategy's own if it supplied one, otherwise what that strategy's optional `challenge` returns. The route middleware then answers 401 with message `Unauthorized` and puts any challenges, comma-separated, in one `WWW-Authenticate` header ([lines 339-350](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L339-L350)). The source `openapi.yml` still declares `ApiKeyAuth` as a header `apiKey` scheme named `X-N8N-API-KEY`, `BearerAuth` as a bearer JWT scheme and `CookieAuth` as an `apiKey` cookie named `n8n-auth`, each as a separate global security requirement ([lines 156-174](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.yml#L156-L174)). This guide infers that, with no validator mounted, these entries document authentication rather than enforce it. The public API's error responses come from `sendPublicApiErrorResponse` ([lines 24-64](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/public-api-error-response.ts#L24-L64)): an error with a numeric `httpStatusCode` and `errorCode` keeps that status and its message, a `UserError` gets 400 with its message, and any other error gets 500 with message `Internal server error` ([classification, lines 32-75](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/errors/rest-error-classifier.ts#L32-L75), [serialization, lines 18-78](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/errors/rest-error-response.ts#L18-L78)). REASONED: source limits and guide advice for the API probes below; no live n8n deployment is available and no API outcome was observed. The workflows controller also serves the deprecated `GET /workflows/{workflowId}/{workflowVersionId}` route as `getDeprecatedWorkflowVersion`, which declares 200 and returns the version mapped without `autosaved`, or 404 `Version not found` ([lines 172-185](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L172-L185), [lines 891-929](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L891-L929)). Each version router mounts the public controllers before the 404 handler ([controller activation, lines 123-127](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L123-L127), [mount order, lines 163-179](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L163-L179)), so a path no controller serves gets 404 `not found`. The registry answers a known path with a method no route serves, and `HEAD` on a known path, with 405 and an `Allow` header ([lines 126-154](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L126-L154)). The registry mounts each public controller's router at its base path ([registry activation, lines 141-166](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L141-L166)); `v1/controllers/index.ts` imports the workflows controller so its decorator metadata is registered ([lines 1-33](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/index.ts#L1-L33)), and that controller is `@PublicApiController('/workflows')` with `getWorkflows` on `@Get('/')` and `@ApiKeyScope('workflow:list')` ([lines 214-215](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L214-L215), [lines 239-245](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L239-L245)). The `@PublicApiController` decorator records the controller's base path, marks it as a public API controller not registered on the root path and registers it as a service ([lines 23-34](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/public-api-controller.ts#L23-L34)); `@Get` records the route's HTTP method and path ([lines 30-53](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/route.ts#L30-L53)) in the shared controller metadata ([lines 9-36](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts#L9-L36)). `public-api/index.ts` imports the v1 controllers ([line 19](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L19-L19)) and builds one router per `v*` folder ([lines 195-214](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L195-L214)), and the server mounts those routers with the endpoint from its `publicApi.path` setting ([line 192](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L192-L192), [lines 236-237](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L236-L237)), which defaults to `api` ([line 148](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/index.ts#L148-L148), [lines 79-80](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/public-api.config.ts#L79-L80)). This guide reasons that the workflows list is therefore at `/api/v1/workflows` by default, the base path that the n8n API authentication page in Sources also gives. The registry applies authentication middleware to every controller route and returns 401 `Unauthorized` when the auth registry does not authenticate ([controller authentication, lines 242-304](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L242-L304), [lines 339-350](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L339-L350)). On a route with an API key scope, a later middleware, after the rate limiters and any path-parameter check, returns 403 `Forbidden` when the request has no token grant or its key scopes do not satisfy that scope ([scope check, lines 382-393](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L382-L393)). On success, `getWorkflows` returns an object with a `data` array of workflows and a `nextCursor` ([lines 288-317](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L288-L317)). The registry writes nothing more if headers were already sent; when the status that `resolveSuccessStatus` returns is 204, or the route has no response DTO and the result is undefined, it sends that status with no body; otherwise it sends that status with JSON of the result parsed by the route's response DTO when it has one, else of the raw result ([lines 176-180](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L176-L180), [lines 224-239](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L224-L239)). That applies to routes without a binary body. `resolveSuccessStatus` returns the route's declared success status and throws when none is declared ([lines 164-178](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-route-resolver.ts#L164-L178)), and `@ApiResponse` records a route's success status and either its response DTO or a binary body ([lines 35-86](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/api-response.ts#L35-L86)). `getWorkflows` declares `@ApiResponse(200, WorkflowListPublicDto)` ([lines 239-245](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L239-L245)), a DTO with a `data` array and a nullable string `nextCursor` ([lines 189-192](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts#L189-L192)), so when the handler returns and the DTO parse succeeds, the registry sends status 200 with the parsed JSON; no response was observed. The source `openapi.yml` declares no paths (`paths: {}`, [lines 153-154](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.yml#L153-L154)); the generated `openapi.decorator-routes.generated.yml` lists `GET /workflows` from the generated `getWorkflows` spec ([lines 415-417](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml#L415-L417)), which records scope `workflow:list` and declares 200 with required `data` and `nextCursor`, 400, 401, 403 and 429 ([lines 1-6](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L1-L6), [lines 53-54](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L53-L54), [lines 577-590](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L577-L590)). This guide's assessment: a request without credentials gets 401 `Unauthorized` from the route's authentication middleware if no auth strategy authenticates it. The server registers `ApiKeyAuthStrategy` and then `SessionCookieAuthStrategy` ([lines 194-204](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L194-L204)), and the token-exchange module's `init` appends `ScopedJwtStrategy` only after its `N8N_ENV_FEAT_TOKEN_EXCHANGE` feature check passes ([lines 22-38](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/token-exchange.module.ts#L22-L38)). On main instances the `inbound-auth-core` module's `init` also appends `PublicApiOAuthStrategy` ([lines 89-95](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts#L89-L95)); whether that module loads by default is NOT ESTABLISHED. Each abstains without its credential: the API key strategy when API-key auth is disabled or the `x-n8n-api-key` header is absent or empty ([lines 88-95](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/api-key-auth.strategy.ts#L88-L95), [header name, line 18](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/api-key-auth.strategy.ts#L18-L18)), and the session strategy without the `n8n-auth` cookie ([lines 35-37](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/session-cookie-auth.strategy.ts#L35-L37), [line 74](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/constants.ts#L74-L74)). The scoped JWT strategy abstains without a `Bearer` authorization token or an `x-n8n-api-key` header ([lines 78-81](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L78-L81), [lines 111-124](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L111-L124), [prefix and header names, lines 16-17](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L16-L17)). The OAuth strategy abstains while the public API is disabled in configuration or by the license state ([lines 37-39](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts#L37-L39)), and when `extractBearer` fails or `isUrlIssuer` refuses the token's unverified `iss` ([lines 58-74](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts#L58-L74)); this guide did not read those two `@n8n/inbound-auth` functions. When every registered strategy abstains, the registry returns an unauthenticated outcome ([lines 55-79](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.registry.ts#L55-L79)). Strategy registrations outside the cited files are NOT ESTABLISHED, so the 401 stays this guide's assessment. What the OAuth strategy's `challenge` adds to that 401's `WWW-Authenticate` header rests on `@n8n/inbound-auth` code this guide did not read (NOT ESTABLISHED; [lines 49-56](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts#L49-L56)). With per-key rate limiting on (`N8N_PUBLIC_API_RATE_LIMIT_ENABLED`, default `false`), a keyed request over a limit gets 429 ([lines 6-11](https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/public-api.config.ts#L6-L11)), which the probe below reports as inconclusive. Compare no `X-N8N-API-KEY` with a valid key at that same URL, keeping proxy credentials constant and feeding the key on stdin; rejection (for example 401) and 200 workflow JSON remain expectations to observe, and a fronting proxy can alter the status. ### Listener, TLS, public API and webhook checks ```bash # REASONED: listener, TLS, API and webhook comparisons have no recorded n8n run; no live n8n deployment is available in the authoring environment. Expected API status outcomes require a live check; the assessment above traces conditional public-API refusal and the workflows list route's middleware, response body and declared 200 success status; pinned source still establishes the Webhook refusal codes. # On 2026-10-13 (UTC), against a stub curl, the placeholder was refused with the substitute message and a line feed, carriage return, tab or DEL with the control-character message, before any curl call; a valid dummy key let both curl calls run and reached only the with-key call, on its stdin. # The 2026-10-13 stub run covered the API key subshell in an earlier form, before the TLS, API and webhook probes took the n8n host on guarded set -- lines that refuse example.com, example.net, example.org and their subdomains, and before this block reported one exit status for all of its parts. # On 2026-10-31 an earlier form, before the ss presence check, passed bash -n and ran whole against stubs, including those example-domain refusals; no n8n deployment or network was involved. # A later review that day ran the form with that check, whose three host parts lowercased the host with tr before refusing those names, through bash -n and ShellCheck 0.11.0 and whole against stubs with no network, with ss present and passing, present and failing, and missing. # The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, which on 2026-11-01 also passed bash -n and ShellCheck 0.11.0 and ran whole against stubs with no network: each host part refused those names in lower, upper and mixed case, also with tr absent, and under dash refused every host with status 2. # Owner setup must be COMPLETE before the instance is public: the pinned owner setup handler # skips authentication and issues an owner session cookie. Confirm the owner exists; do NOT POST # owner-creation data to a live instance as a "test". ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input or found a required tool missing), which outranks 0, so the block ends with 2 only when no part recorded 1 or 3 if command -v ss >/dev/null; then # a missing ss refuses this part with status 2, ranked as above ss -tlnp # a listener inventory in THIS namespace, not a firewall/NAT check: n8n on 127.0.0.1 (or its # private container network), with the Postgres and task-runner ports NOT published. Loopback # can be 127.0.0.1 and/or ::1; also probe the real public IPv4/IPv6 path from another host. set -- "$1" "$?" # the block's status so far, then ss's exit status [ "$2" = 0 ] || { echo "ss -tlnp exit $2: no listener inventory; inconclusive"; set -- 3; } else echo "ss is not installed here; not listing sockets"; set -- 2 fi # TLS reachability (proves TLS, not authentication): ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_N8N_HOST' # the public host name your proxy serves n8n on (your N8N_HOST value), no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null \ -w 'tls=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1/" || { echo "curl exit $?: the TLS request failed; inconclusive"; exit 3; } ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # Public API auth, matched pair against the same URL: expect rejection without the n8n key # (for example 401) and 200 workflow JSON with a valid key; verify both outcomes on your instance. # The key is fed on stdin (-H @-), so it stays out of curl's argv; the one on the set -- line still enters shell history, so use a short-lived key and clear it. # Stdin closes the argv channel only: it does not hide the key from set -x tracing or your own # account's view of the process. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_N8N_HOST' 'REPLACE_WITH_N8N_API_KEY' # the same n8n host name as the TLS check above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the API key: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|"") echo "substitute the API key on the set -- line above; not probing"; exit 2 ;; esac case "$2" in *[[:cntrl:]]*) echo "the API key must be one line with no control characters; not probing"; exit 2 ;; esac set -- "$1" "$2" "https://$1/api/v1/workflows" 0 # the host, the key, the workflows URL, then this part's status so far, ranked as above set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null -w 'no-key=%{http_code} exit=%{exitcode}\n' "$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5%%$'\n'*}" "${5##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$5" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${5#no-key=}" # then the HTTP code and the rest of the line case "$6:${7%% *}" in 0:401|0:403) ;; 0:2??) echo "FINDING: the public API answered ${7%% *} without the n8n key"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "HTTP ${7%% *} without the key is neither a 401 or 403 rejection nor a 2xx finding; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $6: the request without the key failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if printf 'X-N8N-API-KEY: %s\n' "$2" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -w '\nwith-key=%{http_code} exit=%{exitcode}\n' -H @- "$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # the response and its write-out line, then curl's exit status printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${5##*with-key=}" # then the HTTP code and the rest of the line case "$6:${7%% *}" in 0:200) ;; 0:*) echo "HTTP ${7%% *} with the key is not the expected 200, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $6: the request with the key failed, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac exit "$4" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # no-key => expect rejection by n8n; check the observed status (a fronting proxy can also 401, so hold its # credential constant and vary only the n8n key). With-key => expect 200 and workflow JSON. # The editor login is POST /rest/login with the default REST prefix; confirm editor access separately. # Webhook auth: the api/v1 check says NOTHING about webhooks. Test each PRODUCTION webhook (prefix # /webhook/, never /webhook-test/) with the method the node expects and a valid payload, three ways: no # credential, a WRONG credential, and the valid one. With configured node credentials, missing # Basic/JWT auth returns 401 and missing Header auth returns 403 in the pinned handler. # Valid calls must perform the expected action; missing/wrong calls must not. A 200 alone is not proof (a workflow can respond 200 without acting); a 404 means inactive/wrong # method/wrong path, not protected. For a None webhook that validates a signature in the workflow, # confirm an unsigned request runs no action. Pick a workflow safe to trigger, and substitute your n8n host and webhook path. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_N8N_HOST' 'REPLACE_WITH_WEBHOOK_PATH' # the same n8n host name as the TLS check above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the production webhook path after /webhook/: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|"") echo "substitute the webhook path on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 10 -o /dev/null -w 'no-cred=%{http_code} exit=%{exitcode}\n' -X POST "https://$1/webhook/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${3%%$'\n'*}" "${3##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$1" set -- "$1" "$2" "${1#no-cred=}" # then the HTTP code and the rest of the line case "$2:${3%% *}" in 0:401|0:403) exit 0 ;; 0:404) echo "HTTP 404: the webhook is inactive or the method or path is wrong, not protected; inconclusive"; exit 3 ;; 0:*) echo "HTTP ${3%% *} without a credential is not classified: a 200 alone is not proof, so check whether the workflow acted"; exit 0 ;; *) echo "curl exit $2: the webhook request failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the listener list, the tls= status, the workflow JSON and a webhook answer other than 401, 403 or 404 are not classified, so judge them yourself, and test the wrong and valid webhook credentials separately" exit "$1" ) ``` ## Sources (checked October 2026) - n8n deployment environment variables (N8N_LISTEN_ADDRESS, N8N_PROTOCOL, N8N_SSL_KEY, N8N_SSL_CERT, defaults): https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/deployment - n8n security policies (MFA enforcement 2.18.0+, licensing, SSO exception): https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/manage-security-policies - n8n SSL setup: https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/set-up-ssl - n8n encryption key (`N8N_ENCRYPTION_KEY`, auto-generated, `~/.n8n`, back it up): https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/set-a-custom-encryption-key - n8n task runners (external runners, `NODE_FUNCTION_ALLOW_BUILTIN`/`NODE_FUNCTION_ALLOW_EXTERNAL`, `N8N_BLOCK_ENV_ACCESS_IN_NODE`): https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners - n8n task-runner environment variables (`N8N_RUNNERS_INSECURE_MODE`, `N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION`, `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS`, no `_FILE` variables in the runner image; Markdown export read 2026-10-24): https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/task-runners - n8n task-runner hardening (external-mode sidecars, distroless image, `nobody` user 65532, read-only root filesystem, AppArmor `/proc` `environ` and `mounts` rule, isolation statements; Markdown export read 2026-10-24): https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/harden-task-runners - n8n SSRF protection (`N8N_SSRF_PROTECTION_ENABLED`, from 2.12.0): https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/enable-ssrf-protection - n8n reverse-proxy webhook URLs (`N8N_WEBHOOK_URL`, proxy hops, `N8N_SECURE_COOKIE`): https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/configure-webhook-urls-with-reverse-proxy - n8n public API authentication (`/api/v1` base path, `X-N8N-API-KEY` header): https://docs.n8n.io/connect/n8n-api/authentication - n8n Webhook node (Authentication options, production and test URLs, HTTP Method): https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook - n8n Webhook credentials (Basic, Header, JWT auth): https://docs.n8n.io/integrations/builtin/credentials/webhook - n8n `N8N_PORT` default `5678` and `N8N_LISTEN_ADDRESS` default `'::'` (pinned tag n8n@2.47.2): https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/%40n8n/config/src/index.ts#L175-L182 - n8n auth cookie Secure default, recorded in the 1.32.0 changes (pinned tag n8n@2.47.2): https://raw.githubusercontent.com/n8n-io/n8n/n8n@2.47.2/packages/cli/BREAKING-CHANGES.md - n8n 2.0 changes, ExecuteCommand and LocalFileTrigger disabled by default (rolling documentation, checked October 2026): https://docs.n8n.io/changelog/v20-breaking-changes - n8n pinned tag n8n@2.0.0 `NODES_EXCLUDE` default excludes Execute Command and Local File Trigger (checked October 2026): https://github.com/n8n-io/n8n/blob/n8n%402.0.0/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36 - n8n pinned tag n8n@1.123.85 `NODES_EXCLUDE` default excludes none (checked October 2026): https://github.com/n8n-io/n8n/blob/n8n%401.123.85/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31 - n8n@1.123.85 commit-pinned `NODES_EXCLUDE` default excludes none (checked October 2026): https://github.com/n8n-io/n8n/blob/8ea1ccdf82fd040f2568a6255ed663f32f0fc41d/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31 - n8n@2.0.0 commit-pinned `NODES_EXCLUDE` default excludes Execute Command and Local File Trigger (checked October 2026): https://github.com/n8n-io/n8n/blob/a8ecda44f7627630bc8b78cf671405157ad41c4f/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36 - n8n@2.43.8 commit-pinned `NODES_EXCLUDE` default excludes Execute Command and Local File Trigger (checked October 2026): https://github.com/n8n-io/n8n/blob/bf8e523d409b74bceb990a37e9a2dc7ffe1507d7/packages/%40n8n/config/src/configs/nodes.config.ts#L38-L39 - n8n pinned tag n8n@2.0.0-rc.0, the lowest-version 2.x tag, `NODES_EXCLUDE` default excludes Execute Command and Local File Trigger (checked October 2026): https://github.com/n8n-io/n8n/blob/n8n%402.0.0-rc.0/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36 - n8n@2.0.0-rc.0 commit-pinned `NODES_EXCLUDE` default excludes Execute Command and Local File Trigger (checked October 2026): https://github.com/n8n-io/n8n/blob/84148c1774ab85163465dbe062e2e304aa85edac/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36 - n8n pinned tag n8n@1.123.0 `NODES_EXCLUDE` default excludes none (checked October 2026): https://github.com/n8n-io/n8n/blob/n8n%401.123.0/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31 - n8n@1.123.0 commit-pinned `NODES_EXCLUDE` default excludes none (checked October 2026): https://github.com/n8n-io/n8n/blob/6d67db4449db7ab8a5f8d413461ffc46f57d50bb/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31 - n8n@2.45.0 commit-pinned `NODES_EXCLUDE` default excludes Execute Command and Local File Trigger (checked October 2026): https://github.com/n8n-io/n8n/blob/7c79fd09a04e256a6b14bf29a40355a73a17d79d/packages/%40n8n/config/src/configs/nodes.config.ts#L38-L39 - n8n commit a4757cf009839a951e8cd5295cd9d549a21f5714 ("chore: Initial V2 changes (#22553)", 2025-12-01) changes the `NODES_EXCLUDE` default from none to Execute Command and Local File Trigger (checked October 2026). Item sources (checked October 2026): https://github.com/n8n-io/n8n/commit/a4757cf009839a951e8cd5295cd9d549a21f5714 and https://github.com/n8n-io/n8n/blob/a4757cf009839a951e8cd5295cd9d549a21f5714/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36 - n8n later `nodes.config.ts` commits through ea8417e29ad6f8b981d9a04b17283d5d77f2b5bb, none changing the `NODES_EXCLUDE` default (checked October 2026). Item sources (checked October 2026): https://github.com/n8n-io/n8n/commit/5ec61a6a1742208bf07e9f717ba2ff4888647d17 and https://github.com/n8n-io/n8n/commit/9e4b4beed92e1df8ca79b9e285efe123e6d4dd31 and https://github.com/n8n-io/n8n/commit/5e376b69edf3eb10dfa7f4a735ddf36a5cb027bf and https://github.com/n8n-io/n8n/commit/ea8417e29ad6f8b981d9a04b17283d5d77f2b5bb - n8n pinned tag n8n@2.47.2 owner setup handler (`skipAuth`, owner cookie): https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/cli/src/controllers/owner.controller.ts#L22-L29 - n8n pinned tag n8n@2.47.2 default REST endpoint (`rest`): https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/%40n8n/config/src/configs/endpoints.config.ts#L162-L164 - n8n pinned tag n8n@2.47.2 login controller (`POST /login`): https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/cli/src/controllers/auth.controller.ts#L39-L56 - n8n pinned tag n8n@2.47.2 excluded nodes (`NODES_EXCLUDE`): https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/%40n8n/config/src/configs/nodes.config.ts#L31-L39 - n8n pinned tag n8n@2.47.2 Execute Command shell spawn: https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/ExecuteCommand/ExecuteCommand.node.ts#L42-L54 - n8n pinned tag n8n@2.47.2 workflow `$env` guard and denial: https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/workflow/src/workflow-data-proxy-env-provider.ts#L13-L25 and https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/workflow/src/workflow-data-proxy-env-provider.ts#L53-L71 - n8n pinned tag n8n@2.47.2 Webhook Basic, Header, JWT refusal branches: https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/utils.ts#L275-L304 and https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/utils.ts#L324-L346 and https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/utils.ts#L359-L388 - n8n pinned tag n8n@2.47.2 Webhook authorization error code and message mapping: https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/error.ts#L1-L18 - n8n pinned tag n8n@2.47.2 Webhook authorization HTTP response write: https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/Webhook.node.ts#L293-L298 - n8n auth registry strategy order, outcomes and 401 challenges (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.registry.ts#L55-L79 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.types.ts#L15-L18 - n8n public API middleware and error dispatch (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L163-L190 - n8n public API error response (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/public-api-error-response.ts#L24-L64 - n8n public API error classification (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/errors/rest-error-classifier.ts#L32-L75 - n8n public API error serialization (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/errors/rest-error-response.ts#L18-L78 - n8n deprecated workflow-version route and its version mapper, not the workflows list route (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L172-L185. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L891-L929 - n8n controller activation (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L123-L127 - n8n controller route middleware order and authentication (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L242-L304 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L339-L350 - n8n public controller activation and base-path mount (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L141-L166 - n8n `HEAD` and unserved-method 405 handlers (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L126-L154 - n8n controller route success status and JSON response (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L176-L180 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L224-L239 - n8n controller API key scope middleware (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L382-L393 - n8n public controller imports (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/index.ts#L1-L33 - n8n workflows public controller and list route (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L214-L215 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L239-L245 - n8n workflows list response body (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L288-L317 - n8n public API source spec with no paths, its security schemes and global security (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.yml#L153-L154 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.yml#L156-L174 - n8n generated decorator-routes spec listing `GET /workflows` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml#L415-L417 - n8n generated workflows list spec, scope and responses (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L1-L6. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L53-L54. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L577-L590 - n8n public API success-status resolver (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-route-resolver.ts#L164-L178 - n8n `@ApiResponse` success status and response DTO or binary body decorator (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/api-response.ts#L35-L86 - n8n workflows list response DTO (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts#L189-L192 - n8n public API auth strategy registration order (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L194-L204 - n8n token-exchange scoped JWT strategy registration (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/token-exchange.module.ts#L22-L38 - n8n inbound-auth-core registration of `PublicApiOAuthStrategy` on main instances (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts#L89-L95 - n8n public API OAuth strategy challenge and abstention, and the public API availability check (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts#L49-L56. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts#L58-L74. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts#L37-L39 - n8n API key strategy abstention and `x-n8n-api-key` header name (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/api-key-auth.strategy.ts#L88-L95 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/api-key-auth.strategy.ts#L18-L18 - n8n session cookie strategy abstention and `n8n-auth` cookie name (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/session-cookie-auth.strategy.ts#L35-L37 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/constants.ts#L74-L74 - n8n scoped JWT strategy abstention, token extraction, `Bearer` prefix and `x-n8n-api-key` header name (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L78-L81. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L111-L124. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L16-L17 - n8n `@PublicApiController` decorator (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/public-api-controller.ts#L23-L34 - n8n controller metadata registry (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts#L9-L36 - n8n route decorators (`@Get`) (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/route.ts#L30-L53 - n8n public API v1 controller import, version routers and server mount (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L19-L19 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L195-L214. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L192-L192 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L236-L237 - n8n `publicApi` path default `api`, its `GlobalConfig` binding and the per-key rate-limit default `false` (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/public-api.config.ts#L79-L80. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/index.ts#L148-L148. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/public-api.config.ts#L6-L11 - n8n version-router mount order and 404 handler (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L163-L179 - n8n JS runner native globals (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L261-L281 - n8n JS all-items context and secure/insecure dispatch (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L334-L358 - n8n JS per-item context and secure/insecure dispatch (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L404-L433 - n8n JS data-proxy environment state and empty fallback (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L484-L515 - n8n JS context bindings (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L642-L659 - n8n JS insecure direct execution (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L693-L701 - n8n Python all-items conditional environment clearing and execution (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L310-L332 - n8n Python per-item conditional environment clearing and execution (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L352-L378 - n8n Python environment reader (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/env.py#L5-L19 - n8n Python `N8N_BLOCK_RUNNER_ENV_ACCESS` variable name (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L79-L79 - n8n Python `env_deny` read with default `True` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L129-L129 - n8n Python `env_deny` passed as `runner_env_deny` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_runner.py#L96-L96 - n8n Python `read_bool_env` default and `true` parsing (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/env.py#L41-L45 - n8n Python runner configuration from `TaskRunnerConfig.from_env()` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/main.py#L46-L51 - n8n launcher environment filtering, runtime values and overrides (pinned 1.5.1): https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/env/env.go#L13-L142 - n8n launcher allowed-env and env-overrides fields (pinned 1.5.1): https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/config.go#L95-L99 - n8n env provider state producer and its `env` expression (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/workflow/src/workflow-data-proxy-env-provider.ts#L13-L26 - n8n node-context `startJob` producing the env provider state (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts#L465-L488 - n8n `startRunnerTask` passing the env provider state to the task requester (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/workflow-execute-additional-data.ts#L1025-L1061 - n8n task requester storing the env provider state in the task data (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L91-L130 - n8n task requester answering a runner data request (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L356-L379 - n8n data-request response builder copying `envProviderState` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts#L17-L35 - n8n data-request stripper keeping `env` only when requested (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts#L72-L84 - n8n task broker forwarding the data response to the runner (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker.service.ts#L516-L526 - n8n JS built-ins parser `env` request flag, `$env` detection and parse-failure request (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser-state.ts#L57-L79. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts#L162-L164. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L221-L231 - n8n JS require resolver allowlist check (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/require-resolver.ts#L113-L137 - n8n JS runner allowlist and insecure-mode settings and allowlist parsing (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/config/js-runner-config.ts#L4-L13 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L136-L158 - n8n JS runner secure or insecure mode, module allowlist parsing and prototype hardening (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L130-L201 - n8n Python allowlist parsing, allowlist and builtins deny reads, variable names and builtins deny default (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L30-L46 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L117-L128. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L71-L73 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L121-L121 - n8n Python top-level module allow check (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/import_validation.py#L8-L47 - n8n Python pre-execution import analysis and its call before execution (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_analyzer.py#L309-L329. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_analyzer.py#L338-L372. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_runner.py#L313-L330 - n8n Python executor builtins filter, `sys.modules` cleaning and import hardening (pinned n8n@2.47.2). Item sources (1 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L532-L545. Item sources (2 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L591-L627. Item sources (3 of 3, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L635-L661 - n8n task runner mode default `internal` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/runners.config.ts#L13-L17 - n8n task runner module start and internal runner start (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-module.ts#L52-L65 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-module.ts#L116-L157 - n8n internal JS runner spawn environment (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-process-js.ts#L34-L95 - n8n internal Python runner spawn environment (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-process-py.ts#L32-L60 - n8n Code node JS and Python task-runner `startJob` calls (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts#L30-L43 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/PythonTaskRunnerSandbox.ts#L62-L66 - n8n runners image Dockerfile launcher version default, launcher install, configuration copy and entrypoint (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/docker/images/runners/Dockerfile#L96-L113 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/docker/images/runners/Dockerfile#L155-L164. At 2.47.2 the Dockerfile defaults to launcher 1.6.0; neither launcher 1.6.0 source nor any 2.47.2 image was read (NOT ESTABLISHED). The image reading below describes the earlier 2.40.6 image only. Image reading without a run: the `n8nio/runners:2.40.6` OCI index that Docker Hub served on 2026-10-24 is `sha256:7ad804d269a7eebccc8d338aec64f0f6c4dc4b50544a9aaadeb396574d7b3548`, listing the linux/amd64 manifest `sha256:28446089a51beb836a14c57d54cb1eb155f2e3db3d4a3b6abfdabaab510e247f` and the linux/arm64 manifest `sha256:dd1603b7e20e0038ddf16486d7187729a433f9ca3d911caf111fd82b369fad72`. In each platform's image, `/usr/local/bin/task-runner-launcher` is byte-identical to the binary in that platform's launcher 1.5.0 release archive, each archive matching its published SHA-256, and its Go build info records `vcs.revision` 3620c3dc, the commit the 1.5.0 tag named that day, with `vcs.modified=false`. In each platform's image, `/etc/n8n-task-runners.json` is byte-identical to the n8n 2.40.6 tag's file, which the 2.47.2 file cited in the next item extends with two shutdown names and the JS argument `--no-experimental-webstorage`. This guide keeps its launcher 1.5.1 citations, since the cited release-tag comparison finds `env.go` and `launch.go` identical at 1.5.0 and 1.5.1. Whether the 2.40.6 tag named this index before or after that day, and run-time behaviour, are not established. - n8n runners image launcher configuration file, `allowed-env` and `env-overrides` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/docker/images/runners/n8n-task-runners.json#L1-L60 - n8n launcher runner environment and grant-token insertion (pinned 1.5.1): https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/commands/launch.go#L91-L91 and https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/commands/launch.go#L155-L185 - n8n launcher environment preparation at 1.5.0, the n8n 2.40.6 runners Dockerfile default and the published 2.40.6 image's launcher release (pinned 1.5.0): https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/env/env.go#L13-L142 - n8n launcher runner configuration with `allowed-env` but no `env-overrides`, the last such tag of the 29 fetched on 2026-10-26 (pinned 1.1.5): https://github.com/n8n-io/task-runner-launcher/blob/64bcb118752be41794efc609a85a0ba809fceca3/internal/config/config.go#L65-L80 - n8n launcher runner configuration with `allowed-env` and `env-overrides`, the first such tag (pinned 1.2.0): https://github.com/n8n-io/task-runner-launcher/blob/c0c861a1f34c81204bc72a372f7b22958490be5a/internal/config/config.go#L65-L83 - n8n launcher `_FILE` lookuper, `BaseConfig` settings and `LoadLauncherConfig` lookuper (pinned 1.5.1). Item sources (1 of 2, pinned 1.5.1): https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/lookuper.go#L18-L31. Item sources (2 of 2, pinned 1.5.1): https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/config.go#L34-L61 and https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/config.go#L108-L110 - n8n launcher shutdown settings read with `os.Getenv` (pinned 1.5.1): https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/commands/launch.go#L29-L51 - n8n launcher `_FILE` lookuper, `BaseConfig` settings and `LoadLauncherConfig` lookuper at the n8n 2.40.6 runners Dockerfile default (pinned 1.5.0). Item sources (1 of 2, pinned 1.5.0): https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/config/lookuper.go#L18-L31. Item sources (2 of 2, pinned 1.5.0): https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/config/config.go#L34-L61 and https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/config/config.go#L108-L110 - n8n launcher shutdown settings read with `os.Getenv` (pinned 1.5.0): https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/commands/launch.go#L29-L51 - n8n execute context `getBinaryDataBuffer` helper and the caller-built binary-data object it accepts (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/execute-context.ts#L101-L108 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts#L128-L153. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/workflow/src/type-guards.ts#L103-L110 - n8n binary-data filesystem managers, `getAsBuffer` and the blob manager's read (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/binary-data.service.ts#L39-L47 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/binary-data.service.ts#L139-L147. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/blob.manager.ts#L67-L70 - n8n filesystem byte store read and storage-root path check (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/blob-storage/src/fs-byte-store.ts#L46-L54 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/blob-storage/src/fs-byte-store.ts#L104-L114 - n8n execution data storage modes, `database` default, mode tags, storage path and storage-path conflict check (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/storage.config.ts#L10-L38 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/storage.config.ts#L40-L46 - n8n execution persistence writing per configured mode and reading per recorded `storedAt` (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-persistence.ts#L62-L65 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-persistence.ts#L92-L106. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-persistence.ts#L1028-L1049 - n8n execution data's `fs` byte store, the shared `fs` byte store rooted at the storage path, and the binary-data storage path default (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-data/execution-data-json-store.ts#L10-L19 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/fs-byte-store.service.ts#L7-L20. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/binary-data.config.ts#L50-L59 - n8n s3 and azure services that execution data shares with the binary-data managers (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/commands/base-command.ts#L480-L510 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/commands/base-command.ts#L513-L541 - n8n security audit risky node types, including the Code node, and its `NODES_EXCLUDE` recommendation (pinned n8n@2.47.2). Item sources (1 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/security-audit/constants.ts#L45-L53. Item sources (2 of 2, pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/security-audit/risk-reporters/nodes-risk-reporter.ts#L48-L58 - n8n `TaskRunnersConfig.insecureMode` variable `N8N_RUNNERS_INSECURE_MODE` and default `false` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/runners.config.ts#L85-L90 - n8n JS data proxy keys, including `$env` and the additional keys, and its `Proxy` traps (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/workflow/src/workflow-data-proxy.ts#L1325-L1827 - n8n `getAdditionalKeys` keys and its `$secrets` credential option (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/utils/get-additional-keys.ts#L20-L56 - n8n JS RPC call object from `EXPOSED_RPC_METHODS` and `UNSUPPORTED_HELPER_FUNCTIONS` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L592-L610 - n8n exposed, unsupported and available RPC method lists (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/runner-types.ts#L106-L175 - n8n task requester RPC method check and call on the task's `executeFunctions` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L397-L485 - n8n JS runner broker WebSocket with its grant token, and its `send` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L122-L130 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L243-L245 - n8n JS runner `broker:rpcresponse` dispatch, `makeRpcCall` and `handleRpcResponse` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L271-L272 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L510-L552 - n8n task broker WebSocket server runner send and receive (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts#L150-L152 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts#L160-L200 - n8n task broker RPC call and response forwarding (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker.service.ts#L340-L366 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker.service.ts#L472-L514 - n8n in-process local task requester and its binding as `TaskRequester` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/local-task-requester.ts#L29-L39 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-module.ts#L94-L100 - n8n task requester dispatching `broker:rpc` to `handleRpc` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L285-L286 - n8n JS runner configuration's import of `Config` and `Env` from `@n8n/config` and its `@Config` decorator (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/config/js-runner-config.ts#L1-L3 - n8n JS `runCode` dispatch, context and execution (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L203-L219 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L288-L322 - n8n Code node JS sandbox `additionalProperties` and `runCode` task (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts#L19-L28 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts#L123-L142 - n8n JS `freezeGlobals`, the prototype roots and prototype-chain walk it freezes, the `EventEmitter.prototype` it leaves unfrozen and the six `EventEmitter.prototype` methods it locks (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/prototype-hardening.ts#L10-L122 - n8n Python `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` variable name (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L74-L74 - n8n Python `allow_transitive_imports` read with default `False` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L130-L130 - n8n Python `SecurityConfig` fields and construction (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/security_config.py#L4-L10 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_runner.py#L92-L98 - n8n Python `_GuardedImport` and its user-code frame check (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/_sandbox_callables.py#L205-L268 - n8n Python user-builtins `__import__` guard (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L629-L633 - n8n hidden Function node and its vm2 `NodeVM` run of workflow code (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Function/Function.node.ts#L18-L24 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Function/Function.node.ts#L157-L176 - n8n hidden Function Item node and its vm2 `NodeVM` run (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts#L18-L24 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts#L164-L187 - n8n `JavaScriptSandbox` vm2 resolver and `NodeVM` run (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JavaScriptSandbox.ts#L14-L58 - n8n LangChain Code resolver and sandbox (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts#L175-L190 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts#L192-L222 - n8n LangChain Code allowlist variables read from `process.env` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts#L27-L28 - n8n Merge node SQL query and sandbox call (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/actions/mode/combineBySql.ts#L225-L229 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/actions/mode/combineBySql.ts#L301-L301 - n8n Merge node `isolated-vm` alasql sandbox (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/helpers/sandbox-utils.ts#L57-L88 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/helpers/sandbox-utils.ts#L156-L192 - n8n Git node spawn comment and `simple-git` environment (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Git/Git.node.ts#L608-L613 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Git/Git.node.ts#L757-L765 - n8n SSH node `node-ssh` import and remote command (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Ssh/Ssh.node.ts#L19-L20 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Ssh/Ssh.node.ts#L368-L378 - n8n Sort and Item Lists v3 code sorts calling `runCode` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Transform/Sort/utils.ts#L8-L25 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/ItemLists/V3/helpers/utils.ts#L29-L48 - n8n Code and AI Transform JS sandbox construction (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/Code.node.ts#L213-L220 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts#L87-L88 - n8n Code Tool JS sandbox construction with `query` (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts#L70-L75 - n8n `@Env` variable and `_FILE` reader and config factory (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L19-L37 and https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L53-L58 - n8n `@Env` boolean parsing (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L84-L91 - n8n `@Env` decorator recording the variable name as metadata (pinned n8n@2.47.2): https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L123-L140 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - server: n8n listener source (n8n@2.47.2) - runtime: n8n runtime source (n8n@2.47.2) - policy: n8n environment policy minimum (2.18.0) - ssrf: n8n SSRF filter minimum (2.12.0) - v2-default: n8n 2.0 default exclusion source (n8n@2.0.0) - v1-default: n8n 1.x default exclusion source (n8n@1.123.85) - docs: n8n documentation (unknown) - v2-docs: n8n 2.0 breaking changes (unknown) - v2-later-default: n8n later 2.x default exclusion source (n8n@2.43.8) - launcher: n8n task-runner launcher (1.5.1) - v2-first-default: n8n lowest-version 2.x tag default exclusion source (n8n@2.0.0-rc.0) - v1-first-default: n8n 1.123.0 default exclusion source (n8n@1.123.0) - v2-change: n8n default exclusion change commit (a4757cf009839a951e8cd5295cd9d549a21f5714) - v2-later-commits: n8n later nodes config commits (ea8417e29ad6f8b981d9a04b17283d5d77f2b5bb) - v2-last-tag-default: n8n 2.45.0 default exclusion source (n8n@2.45.0) - launcher-150: n8n task-runner launcher, n8n 2.40.6 runners Dockerfile default (1.5.0) - launcher-115: n8n task-runner launcher, last tag without env-overrides (1.1.5) - launcher-120: n8n task-runner launcher, first tag with env-overrides (1.2.0) Claims (full source mapping in the JSON block below): - bind-default (REASONED; server): n8n 2.47.2 defaults N8N_LISTEN_ADDRESS to :: and N8N_PORT to 5678, exposing all interfaces. - protocol-default (REASONED; docs): N8N_PROTOCOL defaults http; private deployment sets listener, port and host explicitly. - container-bind (REASONED; docs): Host loopback is for a same-host proxy; container loopback blocks sibling/published access, so use a private network without publication or host-loopback 127.0.0.1:5678:5678. - other-ports (REASONED; docs): Keep Postgres and task-runner broker ports unpublished and publish only the HTTPS proxy. - public-url (REASONED; docs): Set N8N_PROTOCOL=https and the full N8N_WEBHOOK_URL; WEBHOOK_URL is deprecated from 2.35.0 but still warns and works. - secure-cookie (REASONED; docs, server): Retain N8N_SECURE_COOKIE=true, its default. - proxy-hops (REASONED; docs): Set N8N_PROXY_HOPS to the trusted hop count for forwarded client IPs and rate limiting; public webhooks do not require a public editor. - native-tls (REASONED; docs): N8N_PROTOCOL=https with N8N_SSL_KEY/N8N_SSL_CERT enables native TLS. - owner (REASONED; runtime): Finish first-run owner setup before publication. The pinned owner setup handler skips authentication and issues an owner session cookie. - user-mfa (REASONED; docs): Individual users can enable two-factor authentication, subject to version/licence availability. - mfa-enforcement (REASONED; policy): Enforce MFA through Settings > Security or N8N_MFA_ENFORCED_ENABLED with N8N_SECURITY_POLICY_MANAGED_BY_ENV; env policy requires 2.18.0+ and self-hosted Business/Enterprise. - sso-mfa (REASONED; docs): Instance MFA enforcement excludes SSO logins; enforce their MFA at the identity provider. - encryption (REASONED; docs): Stored credentials are encrypted; unset N8N_ENCRYPTION_KEY generates a key under ~/.n8n, not plaintext; losing it makes retained credentials unrecoverable. - key-custody (REASONED; docs): Set the same encryption key across workers/replicas, back it up separately from the database and exclude it from source and images. - webhook-auth (REASONED; docs): Webhook None is open; controlled callers use per-node Basic, Header or JWT auth with the matching credential. - auth-boundaries (REASONED; docs): Editor sessions, /api/v1 with X-N8N-API-KEY and per-node webhook auth are separate boundaries. - signature (REASONED; docs): A None webhook must validate the provider signature before downstream processing when callers cannot supply native webhook credentials. - code-execution (REASONED; docs, v2-docs, runtime, v2-default, v1-default, v2-later-default): Code runs JavaScript/Python and Execute Command a shell child of n8n; NODES_EXCLUDE turns Execute Command off by default at 2.47.2, and with Local File Trigger at 2.0.0 and 2.43.8, while 1.123.85 (1.x line) excludes none. The unversioned rolling 2.0 changes page also lists both as disabled by default in 2.0. - code-execution-introduced (REASONED; v2-change, v2-later-commits, v2-first-default, v1-first-default, v1-default): Commit a4757cf0 (2025-12-01) changed the n8n NODES_EXCLUDE default in nodes.config.ts from none to Execute Command and Local File Trigger, and four later nodes.config.ts commits through ea8417e2 keep the default. The lowest-version 2.x tag, 2.0.0-rc.0, excludes both; 1.123.0 and 1.123.85 exclude none. - code-execution-tags (REASONED; v1-first-default, v1-default, v2-first-default, v2-last-tag-default): This guide's assessment of n8n nodes.config.ts at each tag, as of October 2026: the NODES_EXCLUDE default is empty at every 1.123.x tag and excludes Execute Command and Local File Trigger at every 2.x tag from 2.0.0-rc.0 through 2.45.0. Releases after 2.45.0 are not covered, and no release was run. - runners (REASONED; docs): Use hardened external task runners; vendor describes internal mode as insecure by design. - environment (REASONED; runtime): At n8n 2.47.2, keep workflow `$env` access blocked with `N8N_BLOCK_ENV_ACCESS_IN_NODE=true`; the proxy is blocked unless it is `false`. This guide's assessment is that this guard alone does not establish isolation of every node or Code `process.env`. - environment-js-context (REASONED; runtime): At n8n 2.47.2, the JS runner's all-items and per-item context has module resolution, console, workflow static data, selected native globals, the data proxy, RPC and additional properties. This guide reasons that no direct `process` binding appears in the explicit native list or context; imported modules and injected properties prevent treating that omission as complete `process.env` isolation. - environment-js-keys (REASONED; runtime): From source reading at n8n 2.47.2, the JS data proxy exposes `$env`, `$input`, `$json`, `$node`, `$workflow`, `$evaluateExpression` and other `$` helpers, plus `getAdditionalKeys`'s `$execution`, `$evaluation`, `$vars`, `$secrets` and two deprecated keys. The JS runner calls `getAdditionalKeys` without its credential option, so `$secrets` is `undefined` there. - environment-js-additional (REASONED; runtime): From source reading at n8n 2.47.2, the JS context spreads a task's `additionalProperties` after `require`, the data proxy and RPC, per-item runs then assign the data proxy and `item` over them, and a `runCode` context holds only a marker and them. The Code node's JS sandbox sends its constructor's `additionalProperties`, default `{}`. - environment-js-runcode (REASONED; runtime): From source reading at n8n 2.47.2, the Code and AI Transform nodes build that JS sandbox with the default `additionalProperties`, the Code Tool node with `{ query }` and the Sort and Item Lists v3 code sorts with `{ items }`. That only those two sorts call its `runCode`, wrapping their code in `return items.sort(...)`, rests on 2.40.6 searches of `packages` outside tests, not redone at 2.47.2. - environment-js-rpc (REASONED; runtime): From source reading at n8n 2.47.2, the JS runner binds RPC functions for seven `helpers` methods, including `httpRequest` and `request`, in all-items and per-item contexts, not in `runCode` ones; the n8n-side requester, which also allows `logNodeOutput`, calls the named method on the task's `executeFunctions`. Other listed helpers throw; only `getBinaryDataBuffer`'s own read path was traced. - environment-js-rpc-transport (REASONED; runtime): From source reading at n8n 2.47.2, each bound RPC function's `makeRpcCall` sends a `runner:rpc` message over the runner's WebSocket to the task broker, which passes it to the task's requester, the in-process `LocalTaskRequester`, and returns its `requester:rpcresponse` to the runner. This guide reasons that the named helper runs in the n8n process that started the task, not in the runner. - environment-js-state (REASONED; runtime): At n8n 2.47.2, the JS data proxy receives `envProviderState`; if absent, its fallback is `env: {}`, `isEnvAccessBlocked: false` and `isProcessAvailable: true`. This guide reasons that the fallback avoids using the runner environment for `$env`; the cited consumer does not establish the contents or production of incoming state. - environment-js-state-producer (REASONED; runtime): From source reading at n8n 2.47.2, `createEnvProviderState` sets `env` to `{}` unless `process` exists and `N8N_BLOCK_ENV_ACCESS_IN_NODE` is `false`, and otherwise to that process's `process.env`. The node context's `startJob` calls it and passes the result through `startRunnerTask` to `TaskRequester.startTask`, which stores it in the task data. - environment-js-state-transport (REASONED; runtime): From source reading at n8n 2.47.2, the requester answers a runner's data request with that state, replaced by `env: {}` unless the request's `env` flag is set, and the broker forwards the answer to the runner. The JS runner sets the flag when its parser finds `$env` in the code or cannot parse the code. - environment-js-state-exposure (REASONED; runtime): This guide infers from the cited producer, requester and parser sources that, with `N8N_BLOCK_ENV_ACCESS_IN_NODE=false`, a JS Code task using `$env` receives the whole environment of the n8n process running the node, including any secrets set there, even on an external runner. With the variable unset or set to anything else, this guide infers the task gets `env: {}`; no run observed either case. - environment-js-execution (REASONED; runtime): At n8n 2.47.2, JS all-items and per-item execution use `runInContext` in secure mode and `runDirectly` in insecure mode. This guide reasons that the latter, using `new Function` with `with(context)`, can resolve ambient `process.env` unless shadowed; this is not a live demonstration or proof of complete isolation in secure mode. - runner-insecure-mode (REASONED; docs, runtime): The task-runner environment-variable page, read 2026-10-24, says `N8N_RUNNERS_INSECURE_MODE` disables all task-runner security measures and `N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION` relaxes security; keep both `false`. By source reading at n8n 2.47.2, `insecureMode` in n8n (three JS spawn flags) and in the JS runner (prototype hardening) read that variable, default `false`. - runner-insecure-parsing (REASONED; runtime): From source reading at n8n 2.47.2, both `insecureMode` settings use the `@Env` decorator, which records `N8N_RUNNERS_INSECURE_MODE` as metadata; the `@Config` factory reads that variable or, if it is absent, the trimmed file that `N8N_RUNNERS_INSECURE_MODE_FILE` names. - runner-insecure-parsing-values (REASONED; runtime): From source reading at n8n 2.47.2, for both `insecureMode` settings the `@Config` factory accepts `true` or `1` and `false` or `0`, ignoring case; any other value, such as `yes` or a quoted or space-padded variable, logs a warning and leaves the default `false`. - environment-js-freeze (REASONED; runtime): From source reading at n8n 2.47.2, secure-mode `freezeGlobals` (skipped if `NODE_ENV` is `test`) freezes function-valued globals, their `prototype`s, some timer, generator and iterator prototypes and the objects on those prototype chains except `EventEmitter.prototype`, plus `Reflect`, `JSON` and `Math`, and locks six `EventEmitter.prototype` methods. This guide reasons `process` stays unfrozen. - environment-js-require (REASONED; runtime): From source reading at n8n 2.47.2, JS Code's `require` is the runner's resolver, which loads a builtin or external module only if its exact name is in `NODE_FUNCTION_ALLOW_BUILTIN` or `NODE_FUNCTION_ALLOW_EXTERNAL`, both empty by default, or the list is `*`. This guide infers that allowing `process`, `child_process` or `*` lets user code reach the runner process's own environment. - environment-python (REASONED; runtime): At n8n 2.47.2, Python all-items and per-item execution clear `os.environ` before user code only if `runner_env_deny` is true; the runner sets it from `N8N_BLOCK_RUNNER_ENV_ACCESS`, read with default `True`, so by default they clear it, and when false, they do not clear the task process environment. This guide did not establish effective module access. - environment-python-setting (REASONED; runtime): At n8n 2.47.2, Python `main.py` builds the runner configuration with `TaskRunnerConfig.from_env()`, which reads `N8N_BLOCK_RUNNER_ENV_ACCESS` with `read_bool_env`; that returns its default when `read_env` returns `None`, otherwise true only when the trimmed, lower-cased value is `true`. This guide reasons that any other set value, such as `1`, disables clearing `os.environ` before user code. - environment-python-reader (REASONED; runtime): At n8n 2.47.2, Python `read_env` reads `os.environ` first, otherwise a file named by the corresponding `_FILE` variable, or returns `None`. This guide assesses that this configuration reader does not itself establish which environment values user code can access. - runner-file-secrets (REASONED; docs, runtime): The task-runner environment-variable page, read 2026-10-24, says variables with a `_FILE` suffix are not recognized in the task runner image, unlike the main n8n image, so do not rely on `_FILE` variables for runner-container secrets such as `N8N_RUNNERS_AUTH_TOKEN`. The next item reconciles that with the runners' own `_FILE` readers at n8n 2.47.2. - runner-file-reconcile (REASONED; docs, runtime, launcher, launcher-150): From source reading at n8n 2.47.2, both runners read a `_FILE` fallback for their own settings (Python `read_env`, JS through `@n8n/config`), but the runners image's shipped `n8n-task-runners.json` lists no `_FILE` name for either runner and neither internal spawn copies one. This guide reasons that for the runners' own settings, launch configuration, not runner code, matches the page. - runner-file-launcher-reader (REASONED; launcher, launcher-150): From source reading of launcher 1.5.0 and 1.5.1, `LoadLauncherConfig` passes `LauncherLookuper` to go-envconfig for `BaseConfig`, which holds the required `N8N_RUNNERS_AUTH_TOKEN`; that each top-level setting is read through it is this guide's inference. `Lookup` prefers the file `KEY_FILE` names over `KEY`, strips trailing newlines and carriage returns and returns unset if the file is unreadable. - runner-file-launcher-scope (REASONED; launcher, launcher-150): From source reading of launcher 1.5.0 and 1.5.1, the 3 shutdown settings that `launch.go` reads with `os.Getenv` and the runner environment that `env.go` copies from the launcher's own environment do not use that lookuper. Of the values `env.go` writes, only the task broker URI and the 2 legacy timeouts come from that configuration, so only they can carry a value read through `_FILE`. - runner-file-launcher-page (REASONED; docs, runtime, launcher-150): The environment-variable page's `_FILE` statement is reported above as read. This guide's assessment, not a vendor statement: for the launcher's own settings at 1.5.0, the n8n 2.40.6 runners Dockerfile default, source reading does not match the page; 1.6.0, the 2.47.2 default, was not read. Whether a published image's launcher reads `_FILE` this way needs a run. - environment-python-allowlists (REASONED; runtime): From source reading at n8n 2.47.2, the Python runner reads `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` as comma lists that default to empty, so no module is allowed unless set, and `*` must stand alone; checks use the top-level module name. `N8N_RUNNERS_BUILTINS_DENY` defaults to a list including `eval`, `exec`, `open` and `getattr`. - environment-python-enforcement (REASONED; runtime): From source reading at n8n 2.47.2, the Python runner checks user code's imports against those allowlists before execution unless both are `*`. The executor limits `sys.modules` to allowed modules, a fixed safe set and their submodules, removes denied builtins and replaces `__import__` and `importlib`'s import entry points with allowlist-checking callables; effective module access was not observed. - python-transitive-imports (REASONED; docs, runtime): The task-runner environment-variable page, read 2026-10-24, says `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` (default `false`) lets an allowlisted package's own imports skip both Python allowlists, trusting all installed packages' imports; keep it `false` unless they are trusted. By source reading at n8n 2.47.2, the internal Python spawn copies it from n8n. - python-transitive-reader (REASONED; runtime): From source reading at n8n 2.47.2, the Python runner reads `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` with `read_bool_env`, default `False`, into `SecurityConfig`; the guards on `importlib.import_module` and `importlib.__import__` skip the allowlists when it is true and the importer is not user code. User code's own `__import__` guard always checks; `TaskAnalyzer` reads only the two allowlists. - python-transitive-package-imports (REASONED; runtime): This guide infers from Python import semantics, not cited here, that an allowlisted package's own `import` statements call the real `builtins.__import__`, which the cited executor does not replace, so neither allowlist checks them whatever this setting's value. No run observed this, and code outside the cited files could add a check. - environment-launcher (REASONED; launcher): Launcher 1.5.1 prepares the runner environment from its own `LANG`, `PATH`, `TZ` and `TERM` plus names in `allowed-env`; `env-overrides` sets values directly. This guide advises keeping secrets out of `allowed-env` and keeping that list narrow, because listed values can enter the runner process environment. - environment-launcher-runtime (REASONED; launcher): Launcher 1.5.1 sets the task broker URI, health-check enable flag and port, and legacy auto-shutdown and task timeouts. It removes inherited required runtime keys, including the grant token, and ignores their `env-overrides`; other overrides replace prior values, while grant-token insertion is not shown by this preparation function. - environment-launcher-grant (REASONED; launcher): From source reading of launcher 1.5.1, the launch command builds the runner environment with `PrepareRunnerEnv`, fetches a grant token from the task broker, appends `N8N_RUNNERS_GRANT_TOKEN` to that environment and sets the result as the runner command's environment. - environment-launcher-tags (REASONED; launcher, launcher-150, launcher-115, launcher-120): From source reading of the 29 launcher tags fetched on 2026-10-26, `env.go` is identical from 1.3.1 to 1.5.1 and `launch.go` at 1.5.0 and 1.5.1, and `config.go` has `env-overrides` only from 1.2.0. This guide infers from decoding defaults, not cited here, that earlier launchers ignore that field, so `env-overrides` allowlists need 1.2.0 or later. Untagged commits and later tags are not covered. - environment-image-launcher (REASONED; runtime, launcher-150): From source reading, the n8n 2.47.2 runners image Dockerfile defaults `LAUNCHER_VERSION` to 1.6.0, which this guide has not read, installs that release as the image entrypoint and copies `n8n-task-runners.json` to `/etc/n8n-task-runners.json`. This is image source; image reading found the 1.5.0 release binary in the 2.40.6 image of 2026-10-24, and no 2.47.2 image was read. - environment-image-config (REASONED; runtime): From source reading, the n8n 2.47.2 runners image's `n8n-task-runners.json` lists `PATH` and timeout, concurrency, Sentry and version variables in both runners' `allowed-env`, plus `GENERIC_TIMEZONE`, `NODE_OPTIONS`, `NODE_PATH`, `HOME` and a shutdown margin for JS and the launcher log level for Python. This is image source, not a deployed configuration; no 2.47.2 image was read. - environment-image-allowlists (REASONED; runtime): From source reading, the n8n 2.47.2 runners image's `n8n-task-runners.json` uses `env-overrides` to set `NODE_FUNCTION_ALLOW_BUILTIN` to `crypto` and `NODE_FUNCTION_ALLOW_EXTERNAL` to `moment` for the JS runner and `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` to empty for the Python runner. This is image source, not a deployed configuration; no 2.47.2 image was read. - environment-internal-mode (REASONED; runtime): From source reading at n8n 2.47.2, `N8N_RUNNERS_MODE` defaults to `internal`, in which the task runner module starts a JS runner and, if the Python requirements check passes, a Python runner as child processes of n8n. Each spawn passes an explicit `env` object of named variables rather than n8n's whole `process.env`. - environment-internal-env (REASONED; runtime): From source reading at n8n 2.47.2, the internal JS runner's `env` copies `PATH`, `HOME`, `NODE_PATH`, `GENERIC_TIMEZONE`, both `NODE_FUNCTION_ALLOW_*` lists, four Sentry names, `N8N_RUNNERS_INSECURE_MODE` and two shutdown settings from n8n. The internal Python runner's `env` copies `PATH`, `HOME`, its module settings and `N8N_BLOCK_RUNNER_ENV_ACCESS`; both add the grant token and runner settings. - environment-internal-docs (REASONED; docs): The n8n task runner set-up page, checked October 2026, documents that an internal-mode runner is a child process sharing n8n's uid and gid and that code escaping its sandbox has the same access as n8n, including to stored credentials. It also documents that n8n logs a deprecation warning for internal mode, deprecated from n8n 3.0, including when `N8N_RUNNERS_MODE` isn't set. - runners-hardening (REASONED; docs): The hardening page, read 2026-10-24, recommends external-mode sidecars, the `-distroless` image, running as `nobody` (uid and gid 65532), a read-only root filesystem with a minimal `emptyDir` on `/tmp`, and the AppArmor rule `audit deny @{PROC}/[0-9]*/{environ,mounts} rwl,`. It says those `/proc` files can expose environment variables, including secrets and credentials, to code in the container. - runners-isolation-statements (REASONED; docs, runtime): The hardening page, read 2026-10-24, also says Code node executions are secure and that external runners provide a fully isolated environment for the Code node's JavaScript, without defining that isolation. This guide's assessment is that the cited `$env` and `os.environ` controls do not establish that the `/proc` channel the page names is closed, so apply its AppArmor rule as well. - environment-proc-primitive (REASONED; docs, runtime): From source reading at n8n 2.47.2, under the default allowlists and settings neither runner gives Code user code a direct file-read primitive: the JS context binds no `fs` or `process` and `require` refuses unlisted modules, while Python removes `open` and refuses unlisted imports. The next 3 items cover indirect paths; no run tested a `/proc` read, so keep the AppArmor rule. - environment-binary-helper (REASONED; runtime): From source reading at n8n 2.47.2, the `getBinaryDataBuffer` helper accepts a caller-built `id`, but the filesystem store's path check rejects any key resolving outside its storage root, so through that store this helper cannot name a process's `/proc` `environ` file by path. The check is on path text and symlinks were not traced; the database, s3 and azure managers were not read. - environment-python-kept-modules (REASONED; runtime): From source reading at n8n 2.47.2, the Python executor keeps `builtins`, `__main__`, `sys`, `traceback`, `linecache`, `importlib` and `importlib.machinery` and their submodules loaded whatever the allowlists. Its import check refuses every module name while both allowlists are empty, their default, so none of these is importable under the defaults; effective module access was not observed. - environment-indirect-open (REASONED; runtime): This guide has not traced, at n8n 2.47.2, the `httpRequest`, `request` and `binaryToString` helper bodies, data-proxy members such as `$evaluateExpression`, the allowed `crypto` and `moment` exports, JS host-realm objects, or Python routes other than import, such as attribute walks from objects user code is given and builtins not on the deny list, so indirect file reads through them remain open. - environment-storage-modes (REASONED; runtime): From source reading at n8n 2.47.2, `N8N_EXECUTION_DATA_STORAGE_MODE` takes `database`, its default, `filesystem`, `s3` or `azure`. In `database` mode n8n writes an execution's data to the database with its row; in the other modes it writes the row to the database and the data to a byte store, and it reads each execution's data from the store its recorded `storedAt` names. - environment-storage-shared (REASONED; runtime): From source reading at n8n 2.47.2, in `filesystem` mode that byte store is rooted at the storage path (`N8N_STORAGE_PATH`), also binary data's filesystem root unless `N8N_BINARY_DATA_STORAGE_PATH` is set, and `StorageConfig.sanitize()` throws when both are set to different values. In `s3` and `azure` modes it uses the same S3 or Azure Blob service as binary data's s3 or azure manager. - environment-storage-unconfined (REASONED; runtime): This guide infers, with nothing run, that in the `filesystem`, `s3` and `azure` execution-data storage modes a Code task is not confined to its own execution's stored data. It reasons from the 2 storage items before this one, which alone do not establish it, and the `getBinaryDataBuffer` item above, whose caveat that the s3 and azure managers were not read applies. The next 3 items advise on it. - environment-storage-mode-advice (REASONED; runtime): This guide advises keeping `N8N_EXECUTION_DATA_STORAGE_MODE` at `database`, by its reading the only mode that keeps execution data out of the filesystem root and the S3 or Azure service that binary data's filesystem, s3 and azure managers use. Setting only `N8N_BINARY_DATA_STORAGE_PATH`, not traced, is not offered as a separation. It infers that switching back leaves written data in place. - environment-code-exclude-advice (REASONED; runtime): Where people you do not trust can edit workflows, exclude the Code node (`n8n-nodes-base.code`) with `NODES_EXCLUDE` or keep them from editing workflows; this guide read no n8n setting that limits who can edit Code nodes. At n8n 2.47.2 the security audit lists the Code node among n8n's risky node types and recommends not loading unneeded node types with `NODES_EXCLUDE`. - environment-code-exclude-scope (REASONED; runtime): This guide infers from the sandbox items above that the AI Transform and Code Tool nodes, which construct the same JS sandbox as the Code node and which the audit's list omits, run workflow code the same way, so excluding the Code node alone may not be enough. Whether `NODES_EXCLUDE` can name them was not read. - environment-exec-command (REASONED; runtime): From source reading at n8n 2.47.2, Execute Command spawns its command with `cwd`, `shell: true`, `detached` and `windowsHide` options and no `env` option. This guide infers from Node.js `child_process` semantics, not cited here, that the shell inherits the environment of the n8n process running the node; no run observed this. - environment-nodes-search (REASONED; runtime): This guide read all but the `exec` matches of a `git grep -F` at n8n 2.40.6, not repeated at 2.47.2, for `startJob`, `child_process`, `spawn`, `exec`, `fork`, `new Function` and `vm.` in `packages/nodes-base/nodes` and `packages/@n8n/nodes-langchain/nodes`, excluding tests. There, no node besides those named here starts a process or runs workflow code; it bounds those spellings and paths only. - environment-nodes-search-exec (REASONED; runtime): For `exec`, this guide narrowed its node search at n8n 2.40.6, not repeated at 2.47.2, with `git grep -l -P -e 'exec(?!ut)'` in `packages/nodes-base/nodes` and `packages/@n8n/nodes-langchain/nodes` to 28 non-test files and read their matches. There, no node besides those named here starts a process or runs workflow code; it bounds that pattern and those paths only. - environment-nodes-vm2 (REASONED; runtime): From source reading at n8n 2.47.2, the hidden Function and Function Item nodes and the LangChain Code node run workflow JavaScript in a vm2 `NodeVM` in the n8n process running the node, not on a task runner, with `require` limited by that process's `NODE_FUNCTION_ALLOW_BUILTIN` and `NODE_FUNCTION_ALLOW_EXTERNAL`. LangChain Code also allows LangChain packages; what vm2 exposes was not traced. - environment-nodes-processes (REASONED; runtime): From source reading at n8n 2.47.2, the Merge node's SQL mode runs its query with alasql in an `isolated-vm` isolate whose file and network sources throw, and the SSH node runs its command on the remote host with `node-ssh`. The Git node passes `simple-git` an `env` object of two variables; this guide infers from a source comment, not simple-git's code, that it starts a git process. - environment-limits (REASONED; runtime, launcher, launcher-150): This guide's assessment is that the cited n8n 2.47.2 runner and launcher 1.5.1 sources describe specific environment controls, not complete host/container isolation. Internal-mode isolation beyond the spawn environment, nodes beyond the searched spellings and paths, other launcher versions, deployed configuration and complete Code `process.env` isolation remain unestablished. - js-modules (REASONED; docs): Narrow NODE_FUNCTION_ALLOW_BUILTIN/NODE_FUNCTION_ALLOW_EXTERNAL in external launcher's /etc/n8n-task-runners.json env-overrides; main-container values are overridden. - python-modules (REASONED; docs): Set N8N_RUNNERS_STDLIB_ALLOW/N8N_RUNNERS_EXTERNAL_ALLOW explicitly; wildcard grants broad module access and version-dependent defaults need confirmation. - ssrf (REASONED; ssrf): Enable N8N_SSRF_PROTECTION_ENABLED from 2.12.0 and restrict network egress; the app filter does not contain arbitrary code/command networking. - verify-inventory (REASONED; docs, runtime): Confirm completed owner setup without creating an owner as a probe; the pinned setup handler skips authentication. ss is local inventory, not firewall/NAT proof; check actual public IPv4/IPv6 paths separately. - verify-tls (REASONED; docs): HTTPS reachability tests TLS, not authentication. - public-controller-order (REASONED; runtime): At n8n 2.47.2, no OpenAPI validator is mounted: every public controller route gets auth middleware (401 Unauthorized if the auth registry does not authenticate), then rate limiters, and GET /workflows then requires API key scope workflow:list (403 Forbidden if no token grant satisfies it). Paths no controller serves get 404, and unserved methods on known paths 405. - public-api-wiring (REASONED; runtime): At n8n 2.47.2, @PublicApiController records a controller's base path and public-API flag and registers it as a service; @Get and @ApiResponse record method, path, success status and response DTO in controller metadata, and public-api/index.ts imports the v1 controllers and builds a router per v* folder. The server mounts them under its publicApi.path setting, default api. - public-api-strategies (REASONED; runtime): At n8n 2.47.2 the server registers ApiKeyAuthStrategy then SessionCookieAuthStrategy, token-exchange init adds ScopedJwtStrategy after its N8N_ENV_FEAT_TOKEN_EXCHANGE check and inbound-auth-core adds PublicApiOAuthStrategy on main. Each abstains without its credential, and the registry treats all abstaining as unauthenticated; other registrations are not established. - workflows-list-status (REASONED; runtime): At n8n 2.47.2, GET /workflows declares @ApiResponse(200, WorkflowListPublicDto); resolveSuccessStatus returns a route's declared status or throws if none is declared, and, when the handler returns and the DTO parse succeeds, the registry sends 200 with JSON parsed by that DTO (a data array and a nullable nextCursor). This is source reasoning; no response was observed. - verify-api (REASONED; docs, runtime): At /api/v1/workflows, compare no X-N8N-API-KEY and a valid key (fixed proxy credentials, key on stdin); on success n8n 2.47.2 returns data and nextCursor JSON (spec: 200). This guide assesses 401 if no auth strategy authenticates a keyless call; rejection and 200 stay unobserved expectations; a rate limit (429) or a proxy can alter status. - verify-editor (REASONED; runtime): Confirm editor session login at /rest/login separately; the pinned login controller registers POST /login under the default rest endpoint, which is configurable. A protected public API does not prove editor protection. - verify-webhook (REASONED; docs, runtime): Use each production /webhook/ method and valid payload with missing/wrong/valid credentials; the pinned Webhook handler returns 401 for missing Basic/JWT credentials and 403 for missing Header credentials when node credentials are configured. Verify actions, not status alone. - verify-signature (REASONED; docs): For None plus workflow signature checks, unsigned calls must run no action; 404 from inactive/wrong path/method is not protection. ````json { "schema": 1, "checked": "2026-11-07", "documentation_checked": "2026-10", "body_sha256": "2e25f725b4d85b468b3b227e86c8d369ffe93954210f0793ff250c24cbc44c96", "components": { "server": { "name": "n8n listener source", "basis": "n8n@2.47.2", "sources": { "sce7cfcd58f89": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/%40n8n/config/src/index.ts#L175-L182", "s1be9673eb30e": "https://raw.githubusercontent.com/n8n-io/n8n/n8n@2.47.2/packages/cli/BREAKING-CHANGES.md" } }, "runtime": { "name": "n8n runtime source", "basis": "n8n@2.47.2", "sources": { "s0b7d12bd42e8": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/cli/src/controllers/owner.controller.ts#L22-L29", "sc2cf4ed277af": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/%40n8n/config/src/configs/nodes.config.ts#L31-L39", "s53dd1627dc36": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/ExecuteCommand/ExecuteCommand.node.ts#L42-L54", "s0430b023e824": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/workflow/src/workflow-data-proxy-env-provider.ts#L13-L25", "s07a04227c94b": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/workflow/src/workflow-data-proxy-env-provider.ts#L53-L71", "sef93cf003a2e": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/cli/src/controllers/auth.controller.ts#L39-L56", "s9102860d8c00": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/%40n8n/config/src/configs/endpoints.config.ts#L162-L164", "s6ca45a2db9d7": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/utils.ts#L275-L304", "s3b9461ffe108": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/utils.ts#L324-L346", "s53b4ec59c7f9": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/utils.ts#L359-L388", "s08fc4f78d2a3": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/error.ts#L1-L18", "sc1daa0909389": "https://github.com/n8n-io/n8n/blob/n8n%402.47.2/packages/nodes-base/nodes/Webhook/Webhook.node.ts#L293-L298", "s2cf31e805899": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L163-L190", "s1ee52140beb1": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/public-api-error-response.ts#L24-L64", "sc06e41529ae9": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L123-L127", "s0a58e4df1f86": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L242-L304", "s13c1b523d30c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L163-L179", "se015f2b9f5e7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L141-L166", "s896a2f1e9fe4": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L176-L180", "s810404da5cd0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L224-L239", "s29efafd637a5": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L382-L393", "s455132332043": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/index.ts#L1-L33", "s7c8b15f5f85a": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L214-L215", "s812db9936b8a": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L239-L245", "s32ac844ff47b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L288-L317", "s72b17fb41881": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.yml#L153-L154", "sde3ac5d0c393": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.yml#L156-L174", "sd0ea03fb01f0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L1-L6", "sde09fc658883": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L53-L54", "sc339b19aea76": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml#L577-L590", "s34dbb90af772": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-route-resolver.ts#L164-L178", "s98282fefc3e0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/api-response.ts#L35-L86", "sb92360d4e535": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts#L189-L192", "sb38f743672f7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L194-L204", "s173dc26bf847": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/token-exchange.module.ts#L22-L38", "sa942759e2f6e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/api-key-auth.strategy.ts#L88-L95", "s916bd471a6c1": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/session-cookie-auth.strategy.ts#L35-L37", "s19c2a58ebfc7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/constants.ts#L74-L74", "s28f41de3e491": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L78-L81", "saad65dc2e058": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L111-L124", "s41cf0581c98c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/services/api-key-auth.strategy.ts#L18-L18", "s85996228edd5": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/token-exchange/services/scoped-jwt.strategy.ts#L16-L17", "s2db228ed9d91": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/public-api-controller.ts#L23-L34", "se07595583e0b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/controller-registry-metadata.ts#L9-L36", "s3120ef9b40d7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/decorators/src/controller/route.ts#L30-L53", "s5bdd06141045": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L19-L19", "s980f5507d016": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/index.ts#L195-L214", "s4d030ebbb45d": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L192-L192", "s8aed902477b4": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/server.ts#L236-L237", "s2b71b1704ff8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L261-L281", "s84a55cb905d6": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L334-L358", "sdc774735c216": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L404-L433", "s2897bf01a11f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L484-L515", "s6779b85cd510": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L642-L659", "s6c23c26c83c0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L693-L701", "s6ed7d26efcc8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L310-L332", "seb79704b4e15": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L352-L378", "s6525473b7851": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/env.py#L5-L19", "sc526b339a53b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L79-L79", "s567859bc372c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L129-L129", "s881c25c93ffa": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_runner.py#L96-L96", "s8740a3d6d448": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/env.py#L41-L45", "s7eddfc084e0b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/main.py#L46-L51", "se73d24d3b24b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/workflow/src/workflow-data-proxy-env-provider.ts#L13-L26", "sbaca5eebeeff": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts#L465-L488", "sd6864439a6ce": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/workflow-execute-additional-data.ts#L1025-L1061", "sef1cadf1315e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L91-L130", "sf35ab63c0569": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L356-L379", "s9b79756c1b6f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts#L17-L35", "s5df2837b2c71": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts#L72-L84", "sd7fd02b9050f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker.service.ts#L516-L526", "s5a4f5003e7e8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser-state.ts#L57-L79", "s8627029f0750": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/built-ins-parser/built-ins-parser.ts#L162-L164", "sf41042683217": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L221-L231", "s6cb51109766e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/runners.config.ts#L13-L17", "s2fc879d0d12e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-module.ts#L52-L65", "s9b16fd52ba50": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-module.ts#L116-L157", "s6e2c643af01e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-process-js.ts#L34-L95", "sed0102bb2818": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-process-py.ts#L32-L60", "sc29a229d2117": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/require-resolver.ts#L113-L137", "s62603dec9463": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/config/js-runner-config.ts#L4-L13", "se7e3e9527fc3": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L136-L158", "sbc374d1bf221": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L30-L46", "s53bdb6b203c3": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L117-L128", "s233029920026": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L71-L73", "s91344f3320aa": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L121-L121", "s475ee655111c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/import_validation.py#L8-L47", "sedc883937cc8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_analyzer.py#L309-L329", "s56c695d025a6": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_analyzer.py#L338-L372", "se466fb080e9e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_runner.py#L313-L330", "s68c66acfc2b2": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L532-L545", "sd1ab0428b358": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L591-L627", "sd962d3b4635d": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L635-L661", "s97ce0f7cb1cc": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts#L30-L43", "s0fa28d3516ff": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/PythonTaskRunnerSandbox.ts#L62-L66", "s3b6ead087ef0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/docker/images/runners/Dockerfile#L96-L113", "s99a35d575995": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/docker/images/runners/Dockerfile#L155-L164", "s3445a2c54ba0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/docker/images/runners/n8n-task-runners.json#L1-L60", "scd0065a6c6a6": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L130-L201", "s5ff71e7a416c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/runners.config.ts#L85-L90", "s0016b6e8f288": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/workflow/src/workflow-data-proxy.ts#L1325-L1827", "s8338602d5089": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/utils/get-additional-keys.ts#L20-L56", "s9105cc29a5b2": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L592-L610", "s8bfa639166a5": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/runner-types.ts#L106-L175", "s9d6e11b21275": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L397-L485", "s60ad947e9152": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L203-L219", "s9d866fe09577": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts#L288-L322", "s4a41a00b3275": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts#L19-L28", "se83703c2694f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JsTaskRunnerSandbox.ts#L123-L142", "s05fe702711de": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/js-task-runner/prototype-hardening.ts#L10-L122", "s47849b58fbf4": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/constants.py#L74-L74", "s87ee5631b9e7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/task_runner_config.py#L130-L130", "s8b965034a8da": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/config/security_config.py#L4-L10", "sa9a576f7843c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_runner.py#L92-L98", "s59e578749894": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/_sandbox_callables.py#L205-L268", "s1a7542cb5198": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner-python/src/task_executor.py#L629-L633", "sad1a3eaa47f3": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Function/Function.node.ts#L18-L24", "sb33857f11d87": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Function/Function.node.ts#L157-L176", "sa61820058b0e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts#L18-L24", "s528b9bf27ecb": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts#L164-L187", "s1ea25634f7e1": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/JavaScriptSandbox.ts#L14-L58", "s8b35f79db6d8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts#L175-L190", "s4ad934dcc135": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts#L192-L222", "s7788efc99cba": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts#L27-L28", "s152b56143ff3": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/actions/mode/combineBySql.ts#L225-L229", "s09355178ab7a": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/actions/mode/combineBySql.ts#L301-L301", "sd96b966db541": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/helpers/sandbox-utils.ts#L57-L88", "sa41f68745b3a": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Merge/v3/helpers/sandbox-utils.ts#L156-L192", "s2cc6743995a8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Git/Git.node.ts#L608-L613", "s3309e71a1bc9": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Git/Git.node.ts#L757-L765", "s83008292abbf": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Ssh/Ssh.node.ts#L19-L20", "s61757c2f52f2": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Ssh/Ssh.node.ts#L368-L378", "s881cd23ba891": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Transform/Sort/utils.ts#L8-L25", "sddd38bc4cda2": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/ItemLists/V3/helpers/utils.ts#L29-L48", "s2454e1c86c5a": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/Code/Code.node.ts#L213-L220", "s471de6f3ea8a": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts#L87-L88", "s119ab4d1b086": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts#L70-L75", "s1953d3a9284e": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L19-L37", "sb4435d40648d": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L53-L58", "s0010baf8a07d": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L84-L91", "saac3b879ef72": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/decorators.ts#L123-L140", "s95825de534a8": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L122-L130", "s2d059e7cde32": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L243-L245", "sd132d68d7589": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L271-L272", "sc2695c96fd27": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/task-runner.ts#L510-L552", "s610abbd0c894": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts#L150-L152", "s62631393176b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker-ws-server.ts#L160-L200", "sa6a5ffb4a012": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker.service.ts#L340-L366", "seb57457c97ee": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-broker/task-broker.service.ts#L472-L514", "s6557f83f4150": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/local-task-requester.ts#L29-L39", "sc1c1103895c1": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-managers/task-requester.ts#L285-L286", "sfa9f7ea80560": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/task-runners/task-runner-module.ts#L94-L100", "s3a098c22f0ba": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/task-runner/src/config/js-runner-config.ts#L1-L3", "sbe14c95909b9": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/execute-context.ts#L101-L108", "sb25df327fc95": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts#L128-L153", "s72b8a4bcdca5": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/workflow/src/type-guards.ts#L103-L110", "s8e5e274c6fcc": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/binary-data.service.ts#L39-L47", "sb6af873918b1": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/binary-data.service.ts#L139-L147", "s37b8216b8aa7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/blob.manager.ts#L67-L70", "s1e0bfa18f4ed": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/blob-storage/src/fs-byte-store.ts#L46-L54", "s3cd4ff3d13ef": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/blob-storage/src/fs-byte-store.ts#L104-L114", "sdbeee23515b1": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/storage.config.ts#L10-L38", "scfcdb24498c7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/storage.config.ts#L40-L46", "sd1f628662db7": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-persistence.ts#L62-L65", "sec7729d82157": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-persistence.ts#L92-L106", "s63d7c293b079": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-persistence.ts#L1028-L1049", "s8962294a7b79": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/executions/execution-data/execution-data-json-store.ts#L10-L19", "s78ef5d23bd7f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/fs-byte-store.service.ts#L7-L20", "sf529a633ce0f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/core/src/binary-data/binary-data.config.ts#L50-L59", "sa9987c029b65": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/commands/base-command.ts#L480-L510", "sf9d423712cd0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/commands/base-command.ts#L513-L541", "scd5311ed7200": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/security-audit/constants.ts#L45-L53", "s4fc1776a9824": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/security-audit/risk-reporters/nodes-risk-reporter.ts#L48-L58", "s38e482617b18": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.registry.ts#L55-L79", "s75b5a784cc07": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/services/auth-strategy.types.ts#L15-L18", "s476248ed159b": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/errors/rest-error-classifier.ts#L32-L75", "s7d111647d7fe": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/backend-services/src/errors/rest-error-response.ts#L18-L78", "s3381a592712c": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L172-L185", "s20542336aaa6": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts#L891-L929", "s0a78770474b6": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L339-L350", "seec15cd4adf0": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/public-api-controller.registry.ts#L126-L154", "s7a2fa5ad7d7f": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml#L415-L417", "s9a3b4f3740eb": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/inbound-auth-core.module.ts#L89-L95", "sa9a08d2fcfbd": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts#L49-L56", "s2a4d03bfa962": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-oauth.strategy.ts#L58-L74", "sb90b4f3417c4": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/cli/src/modules/inbound-auth-core/public-api-protected-resource.ts#L37-L39", "s80e1697ca951": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/public-api.config.ts#L79-L80", "sc92698ede384": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/index.ts#L148-L148", "s4b06cdbe5a52": "https://github.com/n8n-io/n8n/blob/626a58e55ccc2078dfc7a32486c09134fd059fa3/packages/@n8n/config/src/configs/public-api.config.ts#L6-L11" } }, "policy": { "name": "n8n environment policy minimum", "basis": "2.18.0", "sources": { "scb2b0578a5a2": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/manage-security-policies" } }, "ssrf": { "name": "n8n SSRF filter minimum", "basis": "2.12.0", "sources": { "s907260f38eee": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/enable-ssrf-protection" } }, "v2-default": { "name": "n8n 2.0 default exclusion source", "basis": "n8n@2.0.0", "sources": { "s0b6707ff8f67": "https://github.com/n8n-io/n8n/blob/n8n%402.0.0/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36", "sed9acc6f59f6": "https://github.com/n8n-io/n8n/blob/a8ecda44f7627630bc8b78cf671405157ad41c4f/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36" } }, "v1-default": { "name": "n8n 1.x default exclusion source", "basis": "n8n@1.123.85", "sources": { "s10f7d5ae15e5": "https://github.com/n8n-io/n8n/blob/n8n%401.123.85/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31", "scd888024007a": "https://github.com/n8n-io/n8n/blob/8ea1ccdf82fd040f2568a6255ed663f32f0fc41d/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31" } }, "docs": { "name": "n8n documentation", "basis": "unknown", "sources": { "s2f99613ed917": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/deployment", "s695f877f7b28": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/set-up-ssl", "se8f5861891dc": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/set-a-custom-encryption-key", "s290535cad2ea": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners", "se9dd39628347": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/configuration-examples/configure-webhook-urls-with-reverse-proxy", "s7ce8bf5b35e8": "https://docs.n8n.io/connect/n8n-api/authentication", "s7b31c553d69e": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook", "s2841cfa69312": "https://docs.n8n.io/integrations/builtin/credentials/webhook", "scb2b0578a5a2": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/manage-security-policies", "sbc0e98df861a": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/task-runners", "s2f18871e2911": "https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/harden-task-runners" } }, "v2-docs": { "name": "n8n 2.0 breaking changes", "basis": "unknown", "sources": { "s994d1a49cf93": "https://docs.n8n.io/changelog/v20-breaking-changes" } }, "v2-later-default": { "name": "n8n later 2.x default exclusion source", "basis": "n8n@2.43.8", "sources": { "sa172b5ee013d": "https://github.com/n8n-io/n8n/blob/bf8e523d409b74bceb990a37e9a2dc7ffe1507d7/packages/%40n8n/config/src/configs/nodes.config.ts#L38-L39" } }, "launcher": { "name": "n8n task-runner launcher", "basis": "1.5.1", "sources": { "s0354dcddb708": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/env/env.go#L13-L142", "s6a7cebb18624": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/config.go#L95-L99", "s50f317d2aaac": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/commands/launch.go#L91-L91", "sac2b82d94d46": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/commands/launch.go#L155-L185", "s7bae6b66b9ec": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/lookuper.go#L18-L31", "sbb34b35cbfd5": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/config.go#L34-L61", "s04ba03c3e301": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/config/config.go#L108-L110", "s939396bc8c2e": "https://github.com/n8n-io/task-runner-launcher/blob/871cb76bc35c990f368eaa788f0b4ce7b145e143/internal/commands/launch.go#L29-L51" } }, "v2-first-default": { "name": "n8n lowest-version 2.x tag default exclusion source", "basis": "n8n@2.0.0-rc.0", "sources": { "saa97fedeb022": "https://github.com/n8n-io/n8n/blob/n8n%402.0.0-rc.0/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36", "sad53656aa702": "https://github.com/n8n-io/n8n/blob/84148c1774ab85163465dbe062e2e304aa85edac/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36" } }, "v1-first-default": { "name": "n8n 1.123.0 default exclusion source", "basis": "n8n@1.123.0", "sources": { "sb99e9bb32834": "https://github.com/n8n-io/n8n/blob/n8n%401.123.0/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31", "s71dd7eaad4e5": "https://github.com/n8n-io/n8n/blob/6d67db4449db7ab8a5f8d413461ffc46f57d50bb/packages/%40n8n/config/src/configs/nodes.config.ts#L30-L31" } }, "v2-change": { "name": "n8n default exclusion change commit", "basis": "a4757cf009839a951e8cd5295cd9d549a21f5714", "sources": { "sd5273e65f9ae": "https://github.com/n8n-io/n8n/commit/a4757cf009839a951e8cd5295cd9d549a21f5714", "s32c46ff8799b": "https://github.com/n8n-io/n8n/blob/a4757cf009839a951e8cd5295cd9d549a21f5714/packages/%40n8n/config/src/configs/nodes.config.ts#L35-L36" } }, "v2-later-commits": { "name": "n8n later nodes config commits", "basis": "ea8417e29ad6f8b981d9a04b17283d5d77f2b5bb", "sources": { "s5781e44679c8": "https://github.com/n8n-io/n8n/commit/5ec61a6a1742208bf07e9f717ba2ff4888647d17", "s879c80d47bb5": "https://github.com/n8n-io/n8n/commit/9e4b4beed92e1df8ca79b9e285efe123e6d4dd31", "s031c835d5cd3": "https://github.com/n8n-io/n8n/commit/5e376b69edf3eb10dfa7f4a735ddf36a5cb027bf", "se440fd8c8db4": "https://github.com/n8n-io/n8n/commit/ea8417e29ad6f8b981d9a04b17283d5d77f2b5bb" } }, "v2-last-tag-default": { "name": "n8n 2.45.0 default exclusion source", "basis": "n8n@2.45.0", "sources": { "s0f6265beb7d9": "https://github.com/n8n-io/n8n/blob/7c79fd09a04e256a6b14bf29a40355a73a17d79d/packages/%40n8n/config/src/configs/nodes.config.ts#L38-L39" } }, "launcher-150": { "name": "n8n task-runner launcher, n8n 2.40.6 runners Dockerfile default", "basis": "1.5.0", "sources": { "sa6e9b2b5f671": "https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/env/env.go#L13-L142", "s7326f6981dd6": "https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/config/lookuper.go#L18-L31", "sedae49eaee7f": "https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/config/config.go#L34-L61", "s420a3a5ee58e": "https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/config/config.go#L108-L110", "s4933f9d7c726": "https://github.com/n8n-io/task-runner-launcher/blob/3620c3dc721b3538bd97904999ae1b320d81012d/internal/commands/launch.go#L29-L51" } }, "launcher-115": { "name": "n8n task-runner launcher, last tag without env-overrides", "basis": "1.1.5", "sources": { "s9887312755d4": "https://github.com/n8n-io/task-runner-launcher/blob/64bcb118752be41794efc609a85a0ba809fceca3/internal/config/config.go#L65-L80" } }, "launcher-120": { "name": "n8n task-runner launcher, first tag with env-overrides", "basis": "1.2.0", "sources": { "sb5f305c25a6a": "https://github.com/n8n-io/task-runner-launcher/blob/c0c861a1f34c81204bc72a372f7b22958490be5a/internal/config/config.go#L65-L83" } } }, "claims": { "bind-default": {"text": "n8n 2.47.2 defaults N8N_LISTEN_ADDRESS to :: and N8N_PORT to 5678, exposing all interfaces.", "components": ["server"], "sources": ["server:sce7cfcd58f89"], "status": "REASONED"}, "protocol-default": {"text": "N8N_PROTOCOL defaults http; private deployment sets listener, port and host explicitly.", "components": ["docs"], "sources": ["docs:s2f99613ed917"], "status": "REASONED"}, "container-bind": {"text": "Host loopback is for a same-host proxy; container loopback blocks sibling/published access, so use a private network without publication or host-loopback 127.0.0.1:5678:5678.", "components": ["docs"], "sources": ["docs:s2f99613ed917", "docs:s695f877f7b28"], "status": "REASONED"}, "other-ports": {"text": "Keep Postgres and task-runner broker ports unpublished and publish only the HTTPS proxy.", "components": ["docs"], "sources": ["docs:s695f877f7b28", "docs:s290535cad2ea"], "status": "REASONED"}, "public-url": {"text": "Set N8N_PROTOCOL=https and the full N8N_WEBHOOK_URL; WEBHOOK_URL is deprecated from 2.35.0 but still warns and works.", "components": ["docs"], "sources": ["docs:se9dd39628347"], "status": "REASONED"}, "secure-cookie": {"text": "Retain N8N_SECURE_COOKIE=true, its default.", "components": ["docs", "server"], "sources": ["docs:se9dd39628347", "server:s1be9673eb30e"], "status": "REASONED"}, "proxy-hops": {"text": "Set N8N_PROXY_HOPS to the trusted hop count for forwarded client IPs and rate limiting; public webhooks do not require a public editor.", "components": ["docs"], "sources": ["docs:se9dd39628347"], "status": "REASONED"}, "native-tls": {"text": "N8N_PROTOCOL=https with N8N_SSL_KEY/N8N_SSL_CERT enables native TLS.", "components": ["docs"], "sources": ["docs:s2f99613ed917", "docs:s695f877f7b28"], "status": "REASONED"}, "owner": {"text": "Finish first-run owner setup before publication. The pinned owner setup handler skips authentication and issues an owner session cookie.", "components": ["runtime"], "sources": ["runtime:s0b7d12bd42e8"], "status": "REASONED"}, "user-mfa": {"text": "Individual users can enable two-factor authentication, subject to version/licence availability.", "components": ["docs"], "sources": ["docs:scb2b0578a5a2"], "status": "REASONED"}, "mfa-enforcement": {"text": "Enforce MFA through Settings > Security or N8N_MFA_ENFORCED_ENABLED with N8N_SECURITY_POLICY_MANAGED_BY_ENV; env policy requires 2.18.0+ and self-hosted Business/Enterprise.", "components": ["policy"], "sources": ["policy:scb2b0578a5a2"], "status": "REASONED"}, "sso-mfa": {"text": "Instance MFA enforcement excludes SSO logins; enforce their MFA at the identity provider.", "components": ["docs"], "sources": ["docs:scb2b0578a5a2"], "status": "REASONED"}, "encryption": {"text": "Stored credentials are encrypted; unset N8N_ENCRYPTION_KEY generates a key under ~/.n8n, not plaintext; losing it makes retained credentials unrecoverable.", "components": ["docs"], "sources": ["docs:se8f5861891dc"], "status": "REASONED"}, "key-custody": {"text": "Set the same encryption key across workers/replicas, back it up separately from the database and exclude it from source and images.", "components": ["docs"], "sources": ["docs:se8f5861891dc"], "status": "REASONED"}, "webhook-auth": {"text": "Webhook None is open; controlled callers use per-node Basic, Header or JWT auth with the matching credential.", "components": ["docs"], "sources": ["docs:s7b31c553d69e", "docs:s2841cfa69312"], "status": "REASONED"}, "auth-boundaries": {"text": "Editor sessions, /api/v1 with X-N8N-API-KEY and per-node webhook auth are separate boundaries.", "components": ["docs"], "sources": ["docs:s7ce8bf5b35e8", "docs:s7b31c553d69e", "docs:s2841cfa69312"], "status": "REASONED"}, "signature": {"text": "A None webhook must validate the provider signature before downstream processing when callers cannot supply native webhook credentials.", "components": ["docs"], "sources": ["docs:s7b31c553d69e", "docs:s2841cfa69312"], "status": "REASONED"}, "code-execution": {"text": "Code runs JavaScript/Python and Execute Command a shell child of n8n; NODES_EXCLUDE turns Execute Command off by default at 2.47.2, and with Local File Trigger at 2.0.0 and 2.43.8, while 1.123.85 (1.x line) excludes none. The unversioned rolling 2.0 changes page also lists both as disabled by default in 2.0.", "components": ["docs", "v2-docs", "runtime", "v2-default", "v1-default", "v2-later-default"], "sources": ["docs:s290535cad2ea", "v2-docs:s994d1a49cf93", "runtime:s53dd1627dc36", "runtime:sc2cf4ed277af", "v2-default:s0b6707ff8f67", "v1-default:s10f7d5ae15e5", "v1-default:scd888024007a", "v2-default:sed9acc6f59f6", "v2-later-default:sa172b5ee013d"], "status": "REASONED"}, "code-execution-introduced": {"text": "Commit a4757cf0 (2025-12-01) changed the n8n NODES_EXCLUDE default in nodes.config.ts from none to Execute Command and Local File Trigger, and four later nodes.config.ts commits through ea8417e2 keep the default. The lowest-version 2.x tag, 2.0.0-rc.0, excludes both; 1.123.0 and 1.123.85 exclude none.", "components": ["v2-change", "v2-later-commits", "v2-first-default", "v1-first-default", "v1-default"], "sources": ["v2-change:sd5273e65f9ae", "v2-change:s32c46ff8799b", "v2-later-commits:s5781e44679c8", "v2-later-commits:s879c80d47bb5", "v2-later-commits:s031c835d5cd3", "v2-later-commits:se440fd8c8db4", "v2-first-default:saa97fedeb022", "v2-first-default:sad53656aa702", "v1-first-default:sb99e9bb32834", "v1-first-default:s71dd7eaad4e5", "v1-default:s10f7d5ae15e5", "v1-default:scd888024007a"], "status": "REASONED"}, "code-execution-tags": {"text": "This guide's assessment of n8n nodes.config.ts at each tag, as of October 2026: the NODES_EXCLUDE default is empty at every 1.123.x tag and excludes Execute Command and Local File Trigger at every 2.x tag from 2.0.0-rc.0 through 2.45.0. Releases after 2.45.0 are not covered, and no release was run.", "components": ["v1-first-default", "v1-default", "v2-first-default", "v2-last-tag-default"], "sources": ["v1-first-default:sb99e9bb32834", "v1-first-default:s71dd7eaad4e5", "v1-default:s10f7d5ae15e5", "v1-default:scd888024007a", "v2-first-default:saa97fedeb022", "v2-first-default:sad53656aa702", "v2-last-tag-default:s0f6265beb7d9"], "status": "REASONED"}, "runners": {"text": "Use hardened external task runners; vendor describes internal mode as insecure by design.", "components": ["docs"], "sources": ["docs:s290535cad2ea"], "status": "REASONED"}, "environment": {"text": "At n8n 2.47.2, keep workflow `$env` access blocked with `N8N_BLOCK_ENV_ACCESS_IN_NODE=true`; the proxy is blocked unless it is `false`. This guide's assessment is that this guard alone does not establish isolation of every node or Code `process.env`.", "components": ["runtime"], "sources": ["runtime:s0430b023e824", "runtime:s07a04227c94b"], "status": "REASONED"}, "environment-js-context": {"text": "At n8n 2.47.2, the JS runner's all-items and per-item context has module resolution, console, workflow static data, selected native globals, the data proxy, RPC and additional properties. This guide reasons that no direct `process` binding appears in the explicit native list or context; imported modules and injected properties prevent treating that omission as complete `process.env` isolation.", "components": ["runtime"], "sources": ["runtime:s2b71b1704ff8", "runtime:s6779b85cd510", "runtime:s84a55cb905d6", "runtime:sdc774735c216"], "status": "REASONED"}, "environment-js-keys": {"text": "From source reading at n8n 2.47.2, the JS data proxy exposes `$env`, `$input`, `$json`, `$node`, `$workflow`, `$evaluateExpression` and other `$` helpers, plus `getAdditionalKeys`'s `$execution`, `$evaluation`, `$vars`, `$secrets` and two deprecated keys. The JS runner calls `getAdditionalKeys` without its credential option, so `$secrets` is `undefined` there.", "components": ["runtime"], "sources": ["runtime:s0016b6e8f288", "runtime:s8338602d5089", "runtime:s2897bf01a11f", "runtime:s6779b85cd510"], "status": "REASONED"}, "environment-js-additional": {"text": "From source reading at n8n 2.47.2, the JS context spreads a task's `additionalProperties` after `require`, the data proxy and RPC, per-item runs then assign the data proxy and `item` over them, and a `runCode` context holds only a marker and them. The Code node's JS sandbox sends its constructor's `additionalProperties`, default `{}`.", "components": ["runtime"], "sources": ["runtime:s6779b85cd510", "runtime:s84a55cb905d6", "runtime:sdc774735c216", "runtime:s60ad947e9152", "runtime:s9d866fe09577", "runtime:s4a41a00b3275", "runtime:se83703c2694f"], "status": "REASONED"}, "environment-js-runcode": {"text": "From source reading at n8n 2.47.2, the Code and AI Transform nodes build that JS sandbox with the default `additionalProperties`, the Code Tool node with `{ query }` and the Sort and Item Lists v3 code sorts with `{ items }`. That only those two sorts call its `runCode`, wrapping their code in `return items.sort(...)`, rests on 2.40.6 searches of `packages` outside tests, not redone at 2.47.2.", "components": ["runtime"], "sources": ["runtime:s4a41a00b3275", "runtime:se83703c2694f", "runtime:s2454e1c86c5a", "runtime:s471de6f3ea8a", "runtime:s119ab4d1b086", "runtime:s881cd23ba891", "runtime:sddd38bc4cda2"], "status": "REASONED"}, "environment-js-rpc": {"text": "From source reading at n8n 2.47.2, the JS runner binds RPC functions for seven `helpers` methods, including `httpRequest` and `request`, in all-items and per-item contexts, not in `runCode` ones; the n8n-side requester, which also allows `logNodeOutput`, calls the named method on the task's `executeFunctions`. Other listed helpers throw; only `getBinaryDataBuffer`'s own read path was traced.", "components": ["runtime"], "sources": ["runtime:s9105cc29a5b2", "runtime:s8bfa639166a5", "runtime:s9d6e11b21275", "runtime:s6779b85cd510", "runtime:s84a55cb905d6", "runtime:sdc774735c216", "runtime:s9d866fe09577", "runtime:sb25df327fc95"], "status": "REASONED"}, "environment-js-rpc-transport": {"text": "From source reading at n8n 2.47.2, each bound RPC function's `makeRpcCall` sends a `runner:rpc` message over the runner's WebSocket to the task broker, which passes it to the task's requester, the in-process `LocalTaskRequester`, and returns its `requester:rpcresponse` to the runner. This guide reasons that the named helper runs in the n8n process that started the task, not in the runner.", "components": ["runtime"], "sources": ["runtime:s9105cc29a5b2", "runtime:s95825de534a8", "runtime:s2d059e7cde32", "runtime:sd132d68d7589", "runtime:sc2695c96fd27", "runtime:s610abbd0c894", "runtime:s62631393176b", "runtime:sa6a5ffb4a012", "runtime:seb57457c97ee", "runtime:s6557f83f4150", "runtime:sfa9f7ea80560", "runtime:sc1c1103895c1", "runtime:s9d6e11b21275"], "status": "REASONED"}, "environment-js-state": {"text": "At n8n 2.47.2, the JS data proxy receives `envProviderState`; if absent, its fallback is `env: {}`, `isEnvAccessBlocked: false` and `isProcessAvailable: true`. This guide reasons that the fallback avoids using the runner environment for `$env`; the cited consumer does not establish the contents or production of incoming state.", "components": ["runtime"], "sources": ["runtime:s2897bf01a11f"], "status": "REASONED"}, "environment-js-state-producer": {"text": "From source reading at n8n 2.47.2, `createEnvProviderState` sets `env` to `{}` unless `process` exists and `N8N_BLOCK_ENV_ACCESS_IN_NODE` is `false`, and otherwise to that process's `process.env`. The node context's `startJob` calls it and passes the result through `startRunnerTask` to `TaskRequester.startTask`, which stores it in the task data.", "components": ["runtime"], "sources": ["runtime:se73d24d3b24b", "runtime:sbaca5eebeeff", "runtime:sd6864439a6ce", "runtime:sef1cadf1315e"], "status": "REASONED"}, "environment-js-state-transport": {"text": "From source reading at n8n 2.47.2, the requester answers a runner's data request with that state, replaced by `env: {}` unless the request's `env` flag is set, and the broker forwards the answer to the runner. The JS runner sets the flag when its parser finds `$env` in the code or cannot parse the code.", "components": ["runtime"], "sources": ["runtime:sf35ab63c0569", "runtime:s9b79756c1b6f", "runtime:s5df2837b2c71", "runtime:sd7fd02b9050f", "runtime:s5a4f5003e7e8", "runtime:s8627029f0750", "runtime:sf41042683217"], "status": "REASONED"}, "environment-js-state-exposure": {"text": "This guide infers from the cited producer, requester and parser sources that, with `N8N_BLOCK_ENV_ACCESS_IN_NODE=false`, a JS Code task using `$env` receives the whole environment of the n8n process running the node, including any secrets set there, even on an external runner. With the variable unset or set to anything else, this guide infers the task gets `env: {}`; no run observed either case.", "components": ["runtime"], "sources": ["runtime:se73d24d3b24b", "runtime:sbaca5eebeeff", "runtime:s5df2837b2c71", "runtime:s5a4f5003e7e8", "runtime:s8627029f0750", "runtime:sd7fd02b9050f"], "status": "REASONED"}, "environment-js-execution": {"text": "At n8n 2.47.2, JS all-items and per-item execution use `runInContext` in secure mode and `runDirectly` in insecure mode. This guide reasons that the latter, using `new Function` with `with(context)`, can resolve ambient `process.env` unless shadowed; this is not a live demonstration or proof of complete isolation in secure mode.", "components": ["runtime"], "sources": ["runtime:s84a55cb905d6", "runtime:sdc774735c216", "runtime:s6c23c26c83c0", "runtime:s6779b85cd510"], "status": "REASONED"}, "runner-insecure-mode": {"text": "The task-runner environment-variable page, read 2026-10-24, says `N8N_RUNNERS_INSECURE_MODE` disables all task-runner security measures and `N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION` relaxes security; keep both `false`. By source reading at n8n 2.47.2, `insecureMode` in n8n (three JS spawn flags) and in the JS runner (prototype hardening) read that variable, default `false`.", "components": ["docs", "runtime"], "sources": ["docs:sbc0e98df861a", "runtime:scd0065a6c6a6", "runtime:s62603dec9463", "runtime:s6e2c643af01e", "runtime:s5ff71e7a416c"], "status": "REASONED"}, "runner-insecure-parsing": {"text": "From source reading at n8n 2.47.2, both `insecureMode` settings use the `@Env` decorator, which records `N8N_RUNNERS_INSECURE_MODE` as metadata; the `@Config` factory reads that variable or, if it is absent, the trimmed file that `N8N_RUNNERS_INSECURE_MODE_FILE` names.", "components": ["runtime"], "sources": ["runtime:s1953d3a9284e", "runtime:sb4435d40648d", "runtime:saac3b879ef72", "runtime:s62603dec9463", "runtime:s5ff71e7a416c"], "status": "REASONED"}, "runner-insecure-parsing-values": {"text": "From source reading at n8n 2.47.2, for both `insecureMode` settings the `@Config` factory accepts `true` or `1` and `false` or `0`, ignoring case; any other value, such as `yes` or a quoted or space-padded variable, logs a warning and leaves the default `false`.", "components": ["runtime"], "sources": ["runtime:s0010baf8a07d", "runtime:s62603dec9463", "runtime:s5ff71e7a416c"], "status": "REASONED"}, "environment-js-freeze": {"text": "From source reading at n8n 2.47.2, secure-mode `freezeGlobals` (skipped if `NODE_ENV` is `test`) freezes function-valued globals, their `prototype`s, some timer, generator and iterator prototypes and the objects on those prototype chains except `EventEmitter.prototype`, plus `Reflect`, `JSON` and `Math`, and locks six `EventEmitter.prototype` methods. This guide reasons `process` stays unfrozen.", "components": ["runtime"], "sources": ["runtime:s05fe702711de", "runtime:scd0065a6c6a6"], "status": "REASONED"}, "environment-js-require": {"text": "From source reading at n8n 2.47.2, JS Code's `require` is the runner's resolver, which loads a builtin or external module only if its exact name is in `NODE_FUNCTION_ALLOW_BUILTIN` or `NODE_FUNCTION_ALLOW_EXTERNAL`, both empty by default, or the list is `*`. This guide infers that allowing `process`, `child_process` or `*` lets user code reach the runner process's own environment.", "components": ["runtime"], "sources": ["runtime:sc29a229d2117", "runtime:s62603dec9463", "runtime:se7e3e9527fc3", "runtime:s6779b85cd510"], "status": "REASONED"}, "environment-python": {"text": "At n8n 2.47.2, Python all-items and per-item execution clear `os.environ` before user code only if `runner_env_deny` is true; the runner sets it from `N8N_BLOCK_RUNNER_ENV_ACCESS`, read with default `True`, so by default they clear it, and when false, they do not clear the task process environment. This guide did not establish effective module access.", "components": ["runtime"], "sources": ["runtime:s6ed7d26efcc8", "runtime:seb79704b4e15", "runtime:sc526b339a53b", "runtime:s567859bc372c", "runtime:s881c25c93ffa", "runtime:s7eddfc084e0b", "runtime:s8740a3d6d448"], "status": "REASONED"}, "environment-python-setting": {"text": "At n8n 2.47.2, Python `main.py` builds the runner configuration with `TaskRunnerConfig.from_env()`, which reads `N8N_BLOCK_RUNNER_ENV_ACCESS` with `read_bool_env`; that returns its default when `read_env` returns `None`, otherwise true only when the trimmed, lower-cased value is `true`. This guide reasons that any other set value, such as `1`, disables clearing `os.environ` before user code.", "components": ["runtime"], "sources": ["runtime:s7eddfc084e0b", "runtime:s567859bc372c", "runtime:sc526b339a53b", "runtime:s8740a3d6d448", "runtime:s6525473b7851", "runtime:s881c25c93ffa", "runtime:s6ed7d26efcc8", "runtime:seb79704b4e15"], "status": "REASONED"}, "environment-python-reader": {"text": "At n8n 2.47.2, Python `read_env` reads `os.environ` first, otherwise a file named by the corresponding `_FILE` variable, or returns `None`. This guide assesses that this configuration reader does not itself establish which environment values user code can access.", "components": ["runtime"], "sources": ["runtime:s6525473b7851"], "status": "REASONED"}, "runner-file-secrets": {"text": "The task-runner environment-variable page, read 2026-10-24, says variables with a `_FILE` suffix are not recognized in the task runner image, unlike the main n8n image, so do not rely on `_FILE` variables for runner-container secrets such as `N8N_RUNNERS_AUTH_TOKEN`. The next item reconciles that with the runners' own `_FILE` readers at n8n 2.47.2.", "components": ["docs", "runtime"], "sources": ["docs:sbc0e98df861a", "runtime:s6525473b7851"], "status": "REASONED"}, "runner-file-reconcile": {"text": "From source reading at n8n 2.47.2, both runners read a `_FILE` fallback for their own settings (Python `read_env`, JS through `@n8n/config`), but the runners image's shipped `n8n-task-runners.json` lists no `_FILE` name for either runner and neither internal spawn copies one. This guide reasons that for the runners' own settings, launch configuration, not runner code, matches the page.", "components": ["docs", "runtime", "launcher", "launcher-150"], "sources": ["docs:sbc0e98df861a", "runtime:s6525473b7851", "runtime:s1953d3a9284e", "runtime:s3a098c22f0ba", "runtime:s3445a2c54ba0", "runtime:s99a35d575995", "runtime:s6e2c643af01e", "runtime:sed0102bb2818", "launcher:s0354dcddb708", "launcher-150:sa6e9b2b5f671"], "status": "REASONED"}, "runner-file-launcher-reader": {"text": "From source reading of launcher 1.5.0 and 1.5.1, `LoadLauncherConfig` passes `LauncherLookuper` to go-envconfig for `BaseConfig`, which holds the required `N8N_RUNNERS_AUTH_TOKEN`; that each top-level setting is read through it is this guide's inference. `Lookup` prefers the file `KEY_FILE` names over `KEY`, strips trailing newlines and carriage returns and returns unset if the file is unreadable.", "components": ["launcher", "launcher-150"], "sources": ["launcher:s7bae6b66b9ec", "launcher:sbb34b35cbfd5", "launcher:s04ba03c3e301", "launcher-150:s7326f6981dd6", "launcher-150:sedae49eaee7f", "launcher-150:s420a3a5ee58e"], "status": "REASONED"}, "runner-file-launcher-scope": {"text": "From source reading of launcher 1.5.0 and 1.5.1, the 3 shutdown settings that `launch.go` reads with `os.Getenv` and the runner environment that `env.go` copies from the launcher's own environment do not use that lookuper. Of the values `env.go` writes, only the task broker URI and the 2 legacy timeouts come from that configuration, so only they can carry a value read through `_FILE`.", "components": ["launcher", "launcher-150"], "sources": ["launcher:s939396bc8c2e", "launcher:s0354dcddb708", "launcher:sbb34b35cbfd5", "launcher-150:s4933f9d7c726", "launcher-150:sa6e9b2b5f671", "launcher-150:sedae49eaee7f"], "status": "REASONED"}, "runner-file-launcher-page": {"text": "The environment-variable page's `_FILE` statement is reported above as read. This guide's assessment, not a vendor statement: for the launcher's own settings at 1.5.0, the n8n 2.40.6 runners Dockerfile default, source reading does not match the page; 1.6.0, the 2.47.2 default, was not read. Whether a published image's launcher reads `_FILE` this way needs a run.", "components": ["docs", "runtime", "launcher-150"], "sources": ["docs:sbc0e98df861a", "runtime:s3b6ead087ef0", "launcher-150:s7326f6981dd6", "launcher-150:sedae49eaee7f", "launcher-150:s420a3a5ee58e"], "status": "REASONED"}, "environment-python-allowlists": {"text": "From source reading at n8n 2.47.2, the Python runner reads `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` as comma lists that default to empty, so no module is allowed unless set, and `*` must stand alone; checks use the top-level module name. `N8N_RUNNERS_BUILTINS_DENY` defaults to a list including `eval`, `exec`, `open` and `getattr`.", "components": ["runtime"], "sources": ["runtime:sbc374d1bf221", "runtime:s53bdb6b203c3", "runtime:s233029920026", "runtime:s91344f3320aa", "runtime:s475ee655111c"], "status": "REASONED"}, "environment-python-enforcement": {"text": "From source reading at n8n 2.47.2, the Python runner checks user code's imports against those allowlists before execution unless both are `*`. The executor limits `sys.modules` to allowed modules, a fixed safe set and their submodules, removes denied builtins and replaces `__import__` and `importlib`'s import entry points with allowlist-checking callables; effective module access was not observed.", "components": ["runtime"], "sources": ["runtime:sedc883937cc8", "runtime:s56c695d025a6", "runtime:se466fb080e9e", "runtime:s475ee655111c", "runtime:s6ed7d26efcc8", "runtime:seb79704b4e15", "runtime:s68c66acfc2b2", "runtime:sd1ab0428b358", "runtime:sd962d3b4635d"], "status": "REASONED"}, "python-transitive-imports": {"text": "The task-runner environment-variable page, read 2026-10-24, says `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` (default `false`) lets an allowlisted package's own imports skip both Python allowlists, trusting all installed packages' imports; keep it `false` unless they are trusted. By source reading at n8n 2.47.2, the internal Python spawn copies it from n8n.", "components": ["docs", "runtime"], "sources": ["docs:sbc0e98df861a", "runtime:sed0102bb2818"], "status": "REASONED"}, "python-transitive-reader": {"text": "From source reading at n8n 2.47.2, the Python runner reads `N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS` with `read_bool_env`, default `False`, into `SecurityConfig`; the guards on `importlib.import_module` and `importlib.__import__` skip the allowlists when it is true and the importer is not user code. User code's own `__import__` guard always checks; `TaskAnalyzer` reads only the two allowlists.", "components": ["runtime"], "sources": ["runtime:s47849b58fbf4", "runtime:s87ee5631b9e7", "runtime:s8740a3d6d448", "runtime:s8b965034a8da", "runtime:sa9a576f7843c", "runtime:s59e578749894", "runtime:s1a7542cb5198", "runtime:sd962d3b4635d", "runtime:sedc883937cc8", "runtime:s56c695d025a6", "runtime:s475ee655111c"], "status": "REASONED"}, "python-transitive-package-imports": {"text": "This guide infers from Python import semantics, not cited here, that an allowlisted package's own `import` statements call the real `builtins.__import__`, which the cited executor does not replace, so neither allowlist checks them whatever this setting's value. No run observed this, and code outside the cited files could add a check.", "components": ["runtime"], "sources": ["runtime:s1a7542cb5198", "runtime:s68c66acfc2b2", "runtime:sd962d3b4635d", "runtime:s59e578749894"], "status": "REASONED"}, "environment-launcher": {"text": "Launcher 1.5.1 prepares the runner environment from its own `LANG`, `PATH`, `TZ` and `TERM` plus names in `allowed-env`; `env-overrides` sets values directly. This guide advises keeping secrets out of `allowed-env` and keeping that list narrow, because listed values can enter the runner process environment.", "components": ["launcher"], "sources": ["launcher:s0354dcddb708", "launcher:s6a7cebb18624"], "status": "REASONED"}, "environment-launcher-runtime": {"text": "Launcher 1.5.1 sets the task broker URI, health-check enable flag and port, and legacy auto-shutdown and task timeouts. It removes inherited required runtime keys, including the grant token, and ignores their `env-overrides`; other overrides replace prior values, while grant-token insertion is not shown by this preparation function.", "components": ["launcher"], "sources": ["launcher:s0354dcddb708"], "status": "REASONED"}, "environment-launcher-grant": {"text": "From source reading of launcher 1.5.1, the launch command builds the runner environment with `PrepareRunnerEnv`, fetches a grant token from the task broker, appends `N8N_RUNNERS_GRANT_TOKEN` to that environment and sets the result as the runner command's environment.", "components": ["launcher"], "sources": ["launcher:s50f317d2aaac", "launcher:sac2b82d94d46", "launcher:s0354dcddb708"], "status": "REASONED"}, "environment-launcher-tags": {"text": "From source reading of the 29 launcher tags fetched on 2026-10-26, `env.go` is identical from 1.3.1 to 1.5.1 and `launch.go` at 1.5.0 and 1.5.1, and `config.go` has `env-overrides` only from 1.2.0. This guide infers from decoding defaults, not cited here, that earlier launchers ignore that field, so `env-overrides` allowlists need 1.2.0 or later. Untagged commits and later tags are not covered.", "components": ["launcher", "launcher-150", "launcher-115", "launcher-120"], "sources": ["launcher:s0354dcddb708", "launcher:s6a7cebb18624", "launcher:sac2b82d94d46", "launcher-150:sa6e9b2b5f671", "launcher-115:s9887312755d4", "launcher-120:sb5f305c25a6a"], "status": "REASONED"}, "environment-image-launcher": {"text": "From source reading, the n8n 2.47.2 runners image Dockerfile defaults `LAUNCHER_VERSION` to 1.6.0, which this guide has not read, installs that release as the image entrypoint and copies `n8n-task-runners.json` to `/etc/n8n-task-runners.json`. This is image source; image reading found the 1.5.0 release binary in the 2.40.6 image of 2026-10-24, and no 2.47.2 image was read.", "components": ["runtime", "launcher-150"], "sources": ["runtime:s3b6ead087ef0", "runtime:s99a35d575995", "launcher-150:sa6e9b2b5f671"], "status": "REASONED"}, "environment-image-config": {"text": "From source reading, the n8n 2.47.2 runners image's `n8n-task-runners.json` lists `PATH` and timeout, concurrency, Sentry and version variables in both runners' `allowed-env`, plus `GENERIC_TIMEZONE`, `NODE_OPTIONS`, `NODE_PATH`, `HOME` and a shutdown margin for JS and the launcher log level for Python. This is image source, not a deployed configuration; no 2.47.2 image was read.", "components": ["runtime"], "sources": ["runtime:s3445a2c54ba0", "runtime:s99a35d575995"], "status": "REASONED"}, "environment-image-allowlists": {"text": "From source reading, the n8n 2.47.2 runners image's `n8n-task-runners.json` uses `env-overrides` to set `NODE_FUNCTION_ALLOW_BUILTIN` to `crypto` and `NODE_FUNCTION_ALLOW_EXTERNAL` to `moment` for the JS runner and `N8N_RUNNERS_STDLIB_ALLOW` and `N8N_RUNNERS_EXTERNAL_ALLOW` to empty for the Python runner. This is image source, not a deployed configuration; no 2.47.2 image was read.", "components": ["runtime"], "sources": ["runtime:s3445a2c54ba0", "runtime:s99a35d575995"], "status": "REASONED"}, "environment-internal-mode": {"text": "From source reading at n8n 2.47.2, `N8N_RUNNERS_MODE` defaults to `internal`, in which the task runner module starts a JS runner and, if the Python requirements check passes, a Python runner as child processes of n8n. Each spawn passes an explicit `env` object of named variables rather than n8n's whole `process.env`.", "components": ["runtime"], "sources": ["runtime:s6cb51109766e", "runtime:s2fc879d0d12e", "runtime:s9b16fd52ba50", "runtime:s6e2c643af01e", "runtime:sed0102bb2818"], "status": "REASONED"}, "environment-internal-env": {"text": "From source reading at n8n 2.47.2, the internal JS runner's `env` copies `PATH`, `HOME`, `NODE_PATH`, `GENERIC_TIMEZONE`, both `NODE_FUNCTION_ALLOW_*` lists, four Sentry names, `N8N_RUNNERS_INSECURE_MODE` and two shutdown settings from n8n. The internal Python runner's `env` copies `PATH`, `HOME`, its module settings and `N8N_BLOCK_RUNNER_ENV_ACCESS`; both add the grant token and runner settings.", "components": ["runtime"], "sources": ["runtime:s6e2c643af01e", "runtime:sed0102bb2818"], "status": "REASONED"}, "environment-internal-docs": {"text": "The n8n task runner set-up page, checked October 2026, documents that an internal-mode runner is a child process sharing n8n's uid and gid and that code escaping its sandbox has the same access as n8n, including to stored credentials. It also documents that n8n logs a deprecation warning for internal mode, deprecated from n8n 3.0, including when `N8N_RUNNERS_MODE` isn't set.", "components": ["docs"], "sources": ["docs:s290535cad2ea"], "status": "REASONED"}, "runners-hardening": {"text": "The hardening page, read 2026-10-24, recommends external-mode sidecars, the `-distroless` image, running as `nobody` (uid and gid 65532), a read-only root filesystem with a minimal `emptyDir` on `/tmp`, and the AppArmor rule `audit deny @{PROC}/[0-9]*/{environ,mounts} rwl,`. It says those `/proc` files can expose environment variables, including secrets and credentials, to code in the container.", "components": ["docs"], "sources": ["docs:s2f18871e2911"], "status": "REASONED"}, "runners-isolation-statements": {"text": "The hardening page, read 2026-10-24, also says Code node executions are secure and that external runners provide a fully isolated environment for the Code node's JavaScript, without defining that isolation. This guide's assessment is that the cited `$env` and `os.environ` controls do not establish that the `/proc` channel the page names is closed, so apply its AppArmor rule as well.", "components": ["docs", "runtime"], "sources": ["docs:s2f18871e2911", "runtime:s0430b023e824", "runtime:s6ed7d26efcc8", "runtime:seb79704b4e15"], "status": "REASONED"}, "environment-proc-primitive": {"text": "From source reading at n8n 2.47.2, under the default allowlists and settings neither runner gives Code user code a direct file-read primitive: the JS context binds no `fs` or `process` and `require` refuses unlisted modules, while Python removes `open` and refuses unlisted imports. The next 3 items cover indirect paths; no run tested a `/proc` read, so keep the AppArmor rule.", "components": ["docs", "runtime"], "sources": ["docs:s2f18871e2911", "runtime:s2b71b1704ff8", "runtime:s6779b85cd510", "runtime:sc29a229d2117", "runtime:s62603dec9463", "runtime:s6e2c643af01e", "runtime:sed0102bb2818", "runtime:s3445a2c54ba0", "runtime:s91344f3320aa", "runtime:s53bdb6b203c3", "runtime:s68c66acfc2b2", "runtime:s1a7542cb5198", "runtime:sd962d3b4635d", "runtime:s59e578749894", "runtime:s475ee655111c"], "status": "REASONED"}, "environment-binary-helper": {"text": "From source reading at n8n 2.47.2, the `getBinaryDataBuffer` helper accepts a caller-built `id`, but the filesystem store's path check rejects any key resolving outside its storage root, so through that store this helper cannot name a process's `/proc` `environ` file by path. The check is on path text and symlinks were not traced; the database, s3 and azure managers were not read.", "components": ["runtime"], "sources": ["runtime:s8bfa639166a5", "runtime:s9d6e11b21275", "runtime:sbe14c95909b9", "runtime:sb25df327fc95", "runtime:s72b8a4bcdca5", "runtime:sb6af873918b1", "runtime:s8e5e274c6fcc", "runtime:s37b8216b8aa7", "runtime:s1e0bfa18f4ed", "runtime:s3cd4ff3d13ef"], "status": "REASONED"}, "environment-python-kept-modules": {"text": "From source reading at n8n 2.47.2, the Python executor keeps `builtins`, `__main__`, `sys`, `traceback`, `linecache`, `importlib` and `importlib.machinery` and their submodules loaded whatever the allowlists. Its import check refuses every module name while both allowlists are empty, their default, so none of these is importable under the defaults; effective module access was not observed.", "components": ["runtime"], "sources": ["runtime:sd1ab0428b358", "runtime:s475ee655111c", "runtime:s53bdb6b203c3"], "status": "REASONED"}, "environment-indirect-open": {"text": "This guide has not traced, at n8n 2.47.2, the `httpRequest`, `request` and `binaryToString` helper bodies, data-proxy members such as `$evaluateExpression`, the allowed `crypto` and `moment` exports, JS host-realm objects, or Python routes other than import, such as attribute walks from objects user code is given and builtins not on the deny list, so indirect file reads through them remain open.", "components": ["runtime"], "sources": ["runtime:s8bfa639166a5", "runtime:s0016b6e8f288", "runtime:s3445a2c54ba0", "runtime:s6779b85cd510", "runtime:s6ed7d26efcc8", "runtime:seb79704b4e15", "runtime:s68c66acfc2b2", "runtime:s91344f3320aa"], "status": "REASONED"}, "environment-storage-modes": {"text": "From source reading at n8n 2.47.2, `N8N_EXECUTION_DATA_STORAGE_MODE` takes `database`, its default, `filesystem`, `s3` or `azure`. In `database` mode n8n writes an execution's data to the database with its row; in the other modes it writes the row to the database and the data to a byte store, and it reads each execution's data from the store its recorded `storedAt` names.", "components": ["runtime"], "sources": ["runtime:sdbeee23515b1", "runtime:sd1f628662db7", "runtime:sec7729d82157", "runtime:s63d7c293b079"], "status": "REASONED"}, "environment-storage-shared": {"text": "From source reading at n8n 2.47.2, in `filesystem` mode that byte store is rooted at the storage path (`N8N_STORAGE_PATH`), also binary data's filesystem root unless `N8N_BINARY_DATA_STORAGE_PATH` is set, and `StorageConfig.sanitize()` throws when both are set to different values. In `s3` and `azure` modes it uses the same S3 or Azure Blob service as binary data's s3 or azure manager.", "components": ["runtime"], "sources": ["runtime:sdbeee23515b1", "runtime:scfcdb24498c7", "runtime:s8962294a7b79", "runtime:s78ef5d23bd7f", "runtime:sf529a633ce0f", "runtime:s8e5e274c6fcc", "runtime:sa9987c029b65", "runtime:sf9d423712cd0"], "status": "REASONED"}, "environment-storage-unconfined": {"text": "This guide infers, with nothing run, that in the `filesystem`, `s3` and `azure` execution-data storage modes a Code task is not confined to its own execution's stored data. It reasons from the 2 storage items before this one, which alone do not establish it, and the `getBinaryDataBuffer` item above, whose caveat that the s3 and azure managers were not read applies. The next 3 items advise on it.", "components": ["runtime"], "sources": ["runtime:sdbeee23515b1", "runtime:sec7729d82157", "runtime:s8962294a7b79", "runtime:s78ef5d23bd7f", "runtime:sf529a633ce0f", "runtime:s8e5e274c6fcc", "runtime:sa9987c029b65", "runtime:sf9d423712cd0", "runtime:s8bfa639166a5", "runtime:s9d6e11b21275", "runtime:sbe14c95909b9", "runtime:sb25df327fc95", "runtime:s72b8a4bcdca5", "runtime:sb6af873918b1", "runtime:s37b8216b8aa7", "runtime:s1e0bfa18f4ed", "runtime:s3cd4ff3d13ef"], "status": "REASONED"}, "environment-storage-mode-advice": {"text": "This guide advises keeping `N8N_EXECUTION_DATA_STORAGE_MODE` at `database`, by its reading the only mode that keeps execution data out of the filesystem root and the S3 or Azure service that binary data's filesystem, s3 and azure managers use. Setting only `N8N_BINARY_DATA_STORAGE_PATH`, not traced, is not offered as a separation. It infers that switching back leaves written data in place.", "components": ["runtime"], "sources": ["runtime:sdbeee23515b1", "runtime:sd1f628662db7", "runtime:sec7729d82157", "runtime:s63d7c293b079", "runtime:sf529a633ce0f", "runtime:scfcdb24498c7"], "status": "REASONED"}, "environment-code-exclude-advice": {"text": "Where people you do not trust can edit workflows, exclude the Code node (`n8n-nodes-base.code`) with `NODES_EXCLUDE` or keep them from editing workflows; this guide read no n8n setting that limits who can edit Code nodes. At n8n 2.47.2 the security audit lists the Code node among n8n's risky node types and recommends not loading unneeded node types with `NODES_EXCLUDE`.", "components": ["runtime"], "sources": ["runtime:scd5311ed7200", "runtime:s4fc1776a9824"], "status": "REASONED"}, "environment-code-exclude-scope": {"text": "This guide infers from the sandbox items above that the AI Transform and Code Tool nodes, which construct the same JS sandbox as the Code node and which the audit's list omits, run workflow code the same way, so excluding the Code node alone may not be enough. Whether `NODES_EXCLUDE` can name them was not read.", "components": ["runtime"], "sources": ["runtime:s2454e1c86c5a", "runtime:s471de6f3ea8a", "runtime:s119ab4d1b086", "runtime:scd5311ed7200"], "status": "REASONED"}, "environment-exec-command": {"text": "From source reading at n8n 2.47.2, Execute Command spawns its command with `cwd`, `shell: true`, `detached` and `windowsHide` options and no `env` option. This guide infers from Node.js `child_process` semantics, not cited here, that the shell inherits the environment of the n8n process running the node; no run observed this.", "components": ["runtime"], "sources": ["runtime:s53dd1627dc36"], "status": "REASONED"}, "environment-nodes-search": {"text": "This guide read all but the `exec` matches of a `git grep -F` at n8n 2.40.6, not repeated at 2.47.2, for `startJob`, `child_process`, `spawn`, `exec`, `fork`, `new Function` and `vm.` in `packages/nodes-base/nodes` and `packages/@n8n/nodes-langchain/nodes`, excluding tests. There, no node besides those named here starts a process or runs workflow code; it bounds those spellings and paths only.", "components": ["runtime"], "sources": ["runtime:s97ce0f7cb1cc", "runtime:s0fa28d3516ff", "runtime:s53dd1627dc36", "runtime:sad1a3eaa47f3", "runtime:sb33857f11d87", "runtime:sa61820058b0e", "runtime:s528b9bf27ecb", "runtime:s1ea25634f7e1", "runtime:s8b35f79db6d8", "runtime:s4ad934dcc135", "runtime:s152b56143ff3", "runtime:s09355178ab7a", "runtime:sd96b966db541", "runtime:sa41f68745b3a", "runtime:s2cc6743995a8", "runtime:s3309e71a1bc9", "runtime:s83008292abbf", "runtime:s61757c2f52f2"], "status": "REASONED"}, "environment-nodes-search-exec": {"text": "For `exec`, this guide narrowed its node search at n8n 2.40.6, not repeated at 2.47.2, with `git grep -l -P -e 'exec(?!ut)'` in `packages/nodes-base/nodes` and `packages/@n8n/nodes-langchain/nodes` to 28 non-test files and read their matches. There, no node besides those named here starts a process or runs workflow code; it bounds that pattern and those paths only.", "components": ["runtime"], "sources": ["runtime:s53dd1627dc36", "runtime:s152b56143ff3", "runtime:s09355178ab7a", "runtime:sd96b966db541", "runtime:sa41f68745b3a", "runtime:s2cc6743995a8", "runtime:s3309e71a1bc9", "runtime:s83008292abbf", "runtime:s61757c2f52f2"], "status": "REASONED"}, "environment-nodes-vm2": {"text": "From source reading at n8n 2.47.2, the hidden Function and Function Item nodes and the LangChain Code node run workflow JavaScript in a vm2 `NodeVM` in the n8n process running the node, not on a task runner, with `require` limited by that process's `NODE_FUNCTION_ALLOW_BUILTIN` and `NODE_FUNCTION_ALLOW_EXTERNAL`. LangChain Code also allows LangChain packages; what vm2 exposes was not traced.", "components": ["runtime"], "sources": ["runtime:sad1a3eaa47f3", "runtime:sb33857f11d87", "runtime:sa61820058b0e", "runtime:s528b9bf27ecb", "runtime:s1ea25634f7e1", "runtime:s7788efc99cba", "runtime:s8b35f79db6d8", "runtime:s4ad934dcc135"], "status": "REASONED"}, "environment-nodes-processes": {"text": "From source reading at n8n 2.47.2, the Merge node's SQL mode runs its query with alasql in an `isolated-vm` isolate whose file and network sources throw, and the SSH node runs its command on the remote host with `node-ssh`. The Git node passes `simple-git` an `env` object of two variables; this guide infers from a source comment, not simple-git's code, that it starts a git process.", "components": ["runtime"], "sources": ["runtime:s152b56143ff3", "runtime:s09355178ab7a", "runtime:sd96b966db541", "runtime:sa41f68745b3a", "runtime:s83008292abbf", "runtime:s61757c2f52f2", "runtime:s2cc6743995a8", "runtime:s3309e71a1bc9"], "status": "REASONED"}, "environment-limits": {"text": "This guide's assessment is that the cited n8n 2.47.2 runner and launcher 1.5.1 sources describe specific environment controls, not complete host/container isolation. Internal-mode isolation beyond the spawn environment, nodes beyond the searched spellings and paths, other launcher versions, deployed configuration and complete Code `process.env` isolation remain unestablished.", "components": ["runtime", "launcher", "launcher-150"], "sources": ["runtime:s2b71b1704ff8", "runtime:s2897bf01a11f", "runtime:s6779b85cd510", "runtime:s6c23c26c83c0", "runtime:s6ed7d26efcc8", "runtime:seb79704b4e15", "launcher:s0354dcddb708", "runtime:s6e2c643af01e", "runtime:sed0102bb2818", "runtime:s6cb51109766e", "runtime:s99a35d575995", "runtime:s3445a2c54ba0", "launcher:sac2b82d94d46", "launcher-150:sa6e9b2b5f671"], "status": "REASONED"}, "js-modules": {"text": "Narrow NODE_FUNCTION_ALLOW_BUILTIN/NODE_FUNCTION_ALLOW_EXTERNAL in external launcher's /etc/n8n-task-runners.json env-overrides; main-container values are overridden.", "components": ["docs"], "sources": ["docs:s290535cad2ea"], "status": "REASONED"}, "python-modules": {"text": "Set N8N_RUNNERS_STDLIB_ALLOW/N8N_RUNNERS_EXTERNAL_ALLOW explicitly; wildcard grants broad module access and version-dependent defaults need confirmation.", "components": ["docs"], "sources": ["docs:s290535cad2ea"], "status": "REASONED"}, "ssrf": {"text": "Enable N8N_SSRF_PROTECTION_ENABLED from 2.12.0 and restrict network egress; the app filter does not contain arbitrary code/command networking.", "components": ["ssrf"], "sources": ["ssrf:s907260f38eee"], "status": "REASONED"}, "verify-inventory": {"text": "Confirm completed owner setup without creating an owner as a probe; the pinned setup handler skips authentication. ss is local inventory, not firewall/NAT proof; check actual public IPv4/IPv6 paths separately.", "components": ["docs", "runtime"], "sources": ["docs:s2f99613ed917", "docs:s290535cad2ea", "runtime:s0b7d12bd42e8"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "HTTPS reachability tests TLS, not authentication.", "components": ["docs"], "sources": ["docs:s695f877f7b28"], "status": "REASONED", "verify": [1]}, "public-controller-order": {"text": "At n8n 2.47.2, no OpenAPI validator is mounted: every public controller route gets auth middleware (401 Unauthorized if the auth registry does not authenticate), then rate limiters, and GET /workflows then requires API key scope workflow:list (403 Forbidden if no token grant satisfies it). Paths no controller serves get 404, and unserved methods on known paths 405.", "components": ["runtime"], "sources": ["runtime:sc06e41529ae9", "runtime:s13c1b523d30c", "runtime:s0a58e4df1f86", "runtime:se015f2b9f5e7", "runtime:s29efafd637a5", "runtime:s455132332043", "runtime:s7c8b15f5f85a", "runtime:s812db9936b8a", "runtime:s0a78770474b6", "runtime:seec15cd4adf0"], "status": "REASONED"}, "public-api-wiring": {"text": "At n8n 2.47.2, @PublicApiController records a controller's base path and public-API flag and registers it as a service; @Get and @ApiResponse record method, path, success status and response DTO in controller metadata, and public-api/index.ts imports the v1 controllers and builds a router per v* folder. The server mounts them under its publicApi.path setting, default api.", "components": ["runtime"], "sources": ["runtime:s2db228ed9d91", "runtime:s3120ef9b40d7", "runtime:s98282fefc3e0", "runtime:se07595583e0b", "runtime:s5bdd06141045", "runtime:s980f5507d016", "runtime:s4d030ebbb45d", "runtime:s8aed902477b4", "runtime:se015f2b9f5e7", "runtime:sc06e41529ae9", "runtime:s13c1b523d30c", "runtime:s80e1697ca951", "runtime:sc92698ede384"], "status": "REASONED"}, "public-api-strategies": {"text": "At n8n 2.47.2 the server registers ApiKeyAuthStrategy then SessionCookieAuthStrategy, token-exchange init adds ScopedJwtStrategy after its N8N_ENV_FEAT_TOKEN_EXCHANGE check and inbound-auth-core adds PublicApiOAuthStrategy on main. Each abstains without its credential, and the registry treats all abstaining as unauthenticated; other registrations are not established.", "components": ["runtime"], "sources": ["runtime:sb38f743672f7", "runtime:s173dc26bf847", "runtime:sa942759e2f6e", "runtime:s916bd471a6c1", "runtime:s19c2a58ebfc7", "runtime:s28f41de3e491", "runtime:saad65dc2e058", "runtime:s41cf0581c98c", "runtime:s85996228edd5", "runtime:s38e482617b18", "runtime:s75b5a784cc07", "runtime:s9a3b4f3740eb", "runtime:s2a4d03bfa962", "runtime:sb90b4f3417c4"], "status": "REASONED"}, "workflows-list-status": {"text": "At n8n 2.47.2, GET /workflows declares @ApiResponse(200, WorkflowListPublicDto); resolveSuccessStatus returns a route's declared status or throws if none is declared, and, when the handler returns and the DTO parse succeeds, the registry sends 200 with JSON parsed by that DTO (a data array and a nullable nextCursor). This is source reasoning; no response was observed.", "components": ["runtime"], "sources": ["runtime:s812db9936b8a", "runtime:s98282fefc3e0", "runtime:s34dbb90af772", "runtime:s896a2f1e9fe4", "runtime:s810404da5cd0", "runtime:sb92360d4e535"], "status": "REASONED"}, "verify-api": {"text": "At /api/v1/workflows, compare no X-N8N-API-KEY and a valid key (fixed proxy credentials, key on stdin); on success n8n 2.47.2 returns data and nextCursor JSON (spec: 200). This guide assesses 401 if no auth strategy authenticates a keyless call; rejection and 200 stay unobserved expectations; a rate limit (429) or a proxy can alter status.", "components": ["docs", "runtime"], "sources": ["docs:s7ce8bf5b35e8", "runtime:s2cf31e805899", "runtime:s1ee52140beb1", "runtime:s13c1b523d30c", "runtime:s0a58e4df1f86", "runtime:s896a2f1e9fe4", "runtime:s810404da5cd0", "runtime:s32ac844ff47b", "runtime:s72b17fb41881", "runtime:sde3ac5d0c393", "runtime:sd0ea03fb01f0", "runtime:sde09fc658883", "runtime:sc339b19aea76", "runtime:s38e482617b18", "runtime:s476248ed159b", "runtime:s7d111647d7fe", "runtime:s3381a592712c", "runtime:s20542336aaa6", "runtime:s0a78770474b6", "runtime:s7a2fa5ad7d7f", "runtime:sa9a08d2fcfbd", "runtime:s4b06cdbe5a52"], "status": "REASONED", "verify": [1]}, "verify-editor": {"text": "Confirm editor session login at /rest/login separately; the pinned login controller registers POST /login under the default rest endpoint, which is configurable. A protected public API does not prove editor protection.", "components": ["runtime"], "sources": ["runtime:sef93cf003a2e", "runtime:s9102860d8c00"], "status": "REASONED", "verify": [1]}, "verify-webhook": {"text": "Use each production /webhook/ method and valid payload with missing/wrong/valid credentials; the pinned Webhook handler returns 401 for missing Basic/JWT credentials and 403 for missing Header credentials when node credentials are configured. Verify actions, not status alone.", "components": ["docs", "runtime"], "sources": ["docs:s7b31c553d69e", "docs:s2841cfa69312", "runtime:s6ca45a2db9d7", "runtime:s3b9461ffe108", "runtime:s53b4ec59c7f9", "runtime:s08fc4f78d2a3", "runtime:sc1daa0909389"], "status": "REASONED", "verify": [1]}, "verify-signature": {"text": "For None plus workflow signature checks, unsigned calls must run no action; 404 from inactive/wrong path/method is not protection.", "components": ["docs"], "sources": ["docs:s7b31c553d69e", "docs:s2841cfa69312"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> code-server.md ====================================================================== # code-server: browser VS Code without giving away the machine code-server runs VS Code on a server for use in a browser, and its terminal runs commands on that server. This guide helps your AI assistant configure it securely, including SSH forwarding first, a tailnet or MFA-protected tunnel next, and otherwise a loopback bind behind a TLS proxy adding MFA. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/code-server.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against code-server v4.145.2. Metadata reviewed 2026-11-07; documentation checked November 2026. The guide records 23 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. code-server runs a terminal in the browser, so exposure equals remote code execution. Its own documentation is blunt: never expose it directly to the internet without authentication and encryption. ## 1. Prefer no exposure at all The code-server docs recommend SSH port forwarding first, which needs no additional setup: ```bash ssh -N -o ExitOnForwardFailure=yes -L 127.0.0.1:8080:127.0.0.1:8080 user@host # then open http://127.0.0.1:8080 locally ``` [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) (serve, tailnet-only) and [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) (tunnel plus Access with MFA) are the equivalents when SSH is unavailable. On the Cloudflare path, turn Rocket Loader off for the code-server hostname, scoped with a Configuration Rule rather than zone-wide: the v4.145.2 deployment guide says the scripts Rocket Loader re-injects lack the nonce in code-server's Content Security Policy, so the browser blocks them and the page loads blank (observed in source at v4.145.2; nothing was run). Do not loosen the policy to work around it; the same guide notes that adding `'unsafe-inline'` does not help while a nonce is present (observed in source at v4.145.2). ## 2. If it must be reachable: config.yaml Edit the generated `~/.config/code-server/config.yaml` in place; creating this file yourself before the first start removes the random password code-server would have generated, and password auth with neither `password` nor `hashed-password` fails to start. This is a partial configuration; keep the generated `password` entry (or set another supported credential): ```yaml bind-addr: 127.0.0.1:8080 # keep loopback behind a proxy or tunnel auth: password # default; keep the generated password in this file cert: false # TLS is off by default; terminate it at the proxy (below) ``` Password attempts are rate-limited (2 per minute plus 12 per hour). Replace the generated password with your own long random value, and treat the config file as a secret ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Know where the effective credential comes from: the `PASSWORD` and `HASHED_PASSWORD` environment variables override the file (the hashed form wins over plaintext), there is no `--password` command-line flag, and any flag passed to code-server overrides the file. The resulting step is to inspect the launcher (including `--bind-addr` and `--config`, and `CODE_SERVER_CONFIG`/`XDG_CONFIG_HOME` for the config path) rather than assuming the file is authoritative. The listen address has more launcher inputs: `CODE_SERVER_HOST` and `PORT` in the environment override the host and port of `bind-addr`, the `--host` and `--port` flags override those, and `--socket` (whose description says `bind-addr` is then ignored) or `--socket-fd` (described as a pre-bound listening socket to use) selects a different listener. `CS_DISABLE_PROXY` set to `1` or `true` turns on `--disable-proxy`, so check the launcher's environment as well as its flags. The code-server process does not terminate TLS by default (`cert: false`); a bare `--cert` (or `cert: true`) generates a self-signed certificate at `~/.local/share/code-server/self-signed.crt`, while `--cert` with `--cert-key` uses a certificate you supply. For public access, the docs' supported pattern is a reverse proxy with a real certificate: [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) or [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) with [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), with MFA added at that layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)) since the built-in login is a single factor. Never disable certificate validation with `-k`. code-server also proxies other local services through `/proxy//`, `/absproxy//` (which does not strip the path prefix), and per-port subdomains under `--proxy-domain`. These routes use code-server's own authentication, so `auth: none` exposes them too, and `--skip-auth-preflight` lets preflight requests through unauthenticated; if you do not need port forwarding, turn it off with `--disable-proxy`, and require your fronting auth and MFA on every enabled path, subdomain, and WebSocket route. Loopback binding only limits inbound reach: the terminal, extensions, and any service code-server proxies can still make outbound requests, so run the process with only the permissions and network it needs and keep it off cloud metadata and internal endpoints ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). With `--proxy-domain `, v4.145.2 sets the session cookie's Domain attribute to `` when you reach code-server on a host under it (observed in source; nothing was run); under browser cookie rules, not code-server source, browsers then send the cookie to hosts under that domain for requests within the cookie's path. At v4.137.0 the same check compared the host with the stored `{{port}}.` pattern, which an ordinary host never ends with, so this wider scope is new in v4.145.2 (inference from comparing the two tags). The cookie carries a hash derived from the password, and v4.145.2 authenticates requests by checking that cookie (observed in source). Any sibling host under that domain that receives the cookie, subject to its path and ordinary browser cookie rules, can therefore replay it as the code-server authentication cookie (inference from the source); whether the value is also accepted as a login password is not established. Use a proxy domain whose subdomains serve only code-server and its forwarded ports, never one shared with other sites. In v4.145.2 the session cookie is `httpOnly` (it was not at v4.137.0), a hardening that keeps page scripts from reading it; it keeps `sameSite: "lax"`, and `getCookieOptions` sets no `Secure` attribute at either tag (observed in source at v4.145.2 and v4.137.0; nothing was run). Whether anything else adds `Secure` is not established from the reviewed files, so serve code-server only over HTTPS and inspect the `Set-Cookie` header your browser receives. Behind a reverse proxy, code-server compares the browser's `Origin` with the forwarded host: in v4.145.2 the hostnames must still match exactly, a default port (`:80` or `:443`) counts as no port, a host taken from `X-Forwarded-Host` without a port gets `X-Forwarded-Port` appended, and the resulting ports must match (observed in source). If WebSocket connections through the proxy fail with 403 Forbidden, which is the origin check's response, set correct proxy headers (`Host`, `X-Forwarded-Host`, `X-Forwarded-Port`) rather than widening `--trusted-origins`: an entry of `"*"` turns the origin check off, and a `*.example.com` entry trusts every subdomain. v4.145.2 adds two boolean flags with no default assigned in the reviewed CLI source, so both are off unless set (inference). `--disable-file-cache` is described as keeping workspace files out of the browser cache; turn it on when people use shared or untrusted client machines. `--enable-remote-storage` is described as keeping workbench storage on the server instead of in the browser, which would bring that state under your server's data-at-rest controls (inference). Only the two declarations are in the reviewed files, so what either flag does beyond its description is not established, and neither was run. ## 3. Verify REASONED: following block; listener inventory and direct-origin reachability, per the guide's cited code-server v4.145.2 deployment guide and HTTP controls; no isolated network namespace or external probe host is available, so neither check has run against a live listener. On the code-server host, `ss -tlnp` is a listener inventory in THIS namespace, not a firewall, publication, or authentication check: expect 8080 on loopback or a private address. Then run the following block from an untrusted external host. Any HTTP response there, even a 401, means the origin is reachable, which is a finding on its own, and no HTTP response is inconclusive, so the block cannot exit 0: confirm closure from the host and network firewall policy. ```bash # REASONED: origin checks follow the cited code-server authentication and proxy # documentation; no isolated network namespace is available for authorized live listeners. # Shell syntax and guard checks do not demonstrate origin isolation. # Direct-origin reachability: connect straight to the public IP and published port while keeping the URL # hostname for TLS. From an untrusted external host, repeat for every public IPv4/IPv6 address and port. # This block cannot exit 0: it exits 1 for any HTTP response and 3 for none. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORIGIN_URL' 'REPLACE_WITH_PUBLIC_IP' 'REPLACE_WITH_PUBLISHED_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'exactly three values are required; not probing'; exit 2; } case "$1|$2|$3" in *REPLACE_WITH_*) echo 'substitute all values inside the quotes; not probing'; exit 2 ;; esac case "$1" in http://*|https://*) ;; *) echo 'an HTTP or HTTPS origin URL is required; not probing'; exit 2 ;; esac [ -n "$2" ] || { echo 'a public IP is required; not probing'; exit 2; } case "$3" in ''|*[!0123456789]*) echo 'a numeric published port is required; not probing'; exit 2 ;; esac # bracket an IPv6 literal in REPLACE_WITH_PUBLIC_IP. Any HTTP response (incl 401/403) = a finding; # http=000 is not a pass (a certificate, timeout, or local error is inconclusive). Never add -k. set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --connect-to "::$2:$3" -o /dev/null \ -w 'origin http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${4%%$'\n'*}" "${4##*$'\n'}" # curl's write-out line, then its exit status printf '%s\n' "$1" case "$1" in 'origin http=000 '*) ;; # no HTTP code, so curl's exit status below decides 'origin http='[0123456789][0123456789][0123456789]' '*) set -- "$1" 0 ;; # an HTTP code is a response even if the transfer then failed esac if [ "$2" = 0 ]; then echo "FINDING: the origin answered HTTP directly, so it is reachable"; set -- 1 else echo "curl exit $2: no HTTP response, which is not a pass; inconclusive"; set -- 3 fi echo "block exit $1; this block has no passing result, so confirm closure from the host and network firewall policy" exit "$1" ) ``` ```bash # REASONED: editor and proxy checks follow the cited code-server authentication and proxy # documentation; no isolated network namespace is available for authorized live listeners. # Shell syntax and guard checks do not demonstrate authentication. # Auth discrimination against the SAME editor URL: a proxy 401 proves nothing, so pair an unauthenticated # request with an authenticated one and confirm the editor in a fresh browser session too. # Fixed: unauthenticated lands on login or challenge and authenticated reaches the editor. For MFA, confirm # password-only access cannot open the editor and completing the second factor can. A transport, certificate, # or unsuccessful positive control is inconclusive, not a pass. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_EDITOR_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the editor URL; not probing'; exit 2 ;; https://*) ;; *) echo 'an HTTPS URL is required; not probing'; exit 2 ;; esac # 2) Authenticated positive control: paste a cookie from a valid browser session; it enters via stdin # (curl --header @-), never argv. The authenticated response must contain the editor. # The hidden prompt keeps it out of shell history and set +x keeps it out of set -x tracing. Stdin # closes the argv channel only: the account owner and root can still read it in the subshell and curl. # The cookie is read before either request, so a refused cookie stops the block before any probe. { unset -n code_server_cookie && unset -v code_server_cookie; } 2>/dev/null || { echo 'cannot initialize cookie input; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } IFS= read -r -s -p 'Cookie value (name=value; name=value), then Enter: ' code_server_cookie < /dev/tty || { printf '\n'; echo 'cookie input failed; not probing'; exit 3; } printf '\n' case "$code_server_cookie" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'a valid session cookie is required; not probing'; exit 2 ;; esac set -- "$1" 0 # the 2nd value is this block's status so far: 3 (inconclusive) outranks 0 # 1) Unauthenticated: follow redirects and inspect the FINAL body - login or challenge, never the editor. set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --location --max-redirs 5 --proto-redir '=https' \ -w '\nunauth http=%{http_code} url=%{url_effective} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the response and its write-out line, then curl's exit status printf '%s' "$3" [ "$4" = 0 ] || { echo "curl exit $4: the unauthenticated request failed, which is inconclusive, not a pass"; set -- "$1" 3; } # 2) The authenticated request, with the cookie read above. set -- "$1" "$2" "$(if printf 'Cookie: %s\n' "$code_server_cookie" | \ curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --location --max-redirs 5 --proto-redir '=https' --header @- \ -w '\nauth http=%{http_code} url=%{url_effective} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the response and its write-out line, then curl's exit status printf '%s' "$3" set -- "$1" "$2" "$3" "$4" "${3##*auth http=}" # the final HTTP code and the rest of the write-out line case "$4:${5%% *}" in 0:2??) ;; 0:*) echo "HTTP ${5%% *} with the cookie: the positive control did not succeed; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $4: the authenticated request failed, which is inconclusive, not a pass"; set -- "$1" 3 ;; esac echo "block exit $2; both final bodies are not classified, so judge them yourself: login or challenge without the cookie, the editor with it" exit "$2" ) ``` REASONED: preceding block; paired editor access and MFA, with the listener inventory and direct-origin block above, per the guide's cited code-server v4.145.2 deployment guide and HTTP controls. No editor, proxy, IdP or external probe host is available; listeners are denied locally. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics and config YAML parsed. Placeholder, missing-assignment, short, empty and embedded-placeholder inputs printed refusal messages without reaching curl stubs; substituted inputs reached the stubs; under a pseudo-terminal, empty, placeholder and control-character cookies exited 2 before the authenticated request, and a fixture cookie exited 0 and reached curl only on stdin as a `Cookie:` header. Those checks and runs used an earlier form: one fence whose exit status was the authenticated request's, with the cookie prompt after the unauthenticated request and a bare exit 2 when the prompt failed. The current direct-origin block exits 1 for any HTTP response and 3 for none. The current editor block reads the cookie before either request and exits 3 if that read fails, if a request fails or if the authenticated request ends on a non-2xx status; it exits 2 on a refused input and otherwise 0, leaving both final bodies for you to judge. On 2026-10-31 both current blocks passed bash -n and ran whole against stubs with no network; ShellCheck 0.11.0 runs on them in the repository's gate suite, not in that review. Live outcomes remain unobserved: exposed access opens the editor anonymously or bypasses MFA; fixed access isolates the origin, challenges anonymous users and requires the second factor while admitting the authenticated control. An unauthenticated editor in a private browser window means whoever finds the URL owns the host. Paste the cookie value alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ## Sources (checked November 2026) - code-server v4.145.2 deployment guide (exposure, TLS and self-signed certificates, reverse proxy, `/proxy`+`/absproxy`+`--proxy-domain` routes, `--skip-auth-preflight`, Cloudflare Rocket Loader and the nonce-based CSP): https://raw.githubusercontent.com/coder/code-server/v4.145.2/docs/guide.md - code-server v4.145.2 FAQ (config.yaml keys map to flags, `cert: false` default, `hashed-password` precedence, config path from `--config`/`CODE_SERVER_CONFIG`/`XDG_CONFIG_HOME`, flags override the file): https://raw.githubusercontent.com/coder/code-server/v4.145.2/docs/FAQ.md - code-server v4.145.2 CLI (credentials, defaults, config generation, proxy flags, `--password` rejected on the CLI, `CODE_SERVER_HOST`/`PORT`/`--host`/`--port`/`--socket`/`--socket-fd` listener inputs, `CS_DISABLE_PROXY`, `--trusted-origins`, `--disable-file-cache`, `--enable-remote-storage`): https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/cli.ts - code-server v4.145.2 HTTP controls (authentication, proxy auth, `--disable-proxy`, session cookie domain and attributes, origin check and trusted origins): https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/http.ts - code-server v4.145.2 path proxy (`/proxy`, `/absproxy` routing): https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/routes/pathProxy.ts - code-server v4.145.2 domain proxy (`--proxy-domain` subdomains): https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/routes/domainProxy.ts ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - server: code-server (v4.145.2) Claims (full source mapping in the JSON block below): - ssh (REASONED; server): Vendor prefers SSH forwarding; bind the local 8080 forwarding listener to loopback and open the local browser URL. - rocket-loader (REASONED; server): On the Cloudflare path, turn Rocket Loader off for the code-server hostname, because its re-injected scripts lack the CSP nonce; do not loosen the CSP, since 'unsafe-inline' does not help while a nonce is present (observed in source at v4.145.2, docs/guide.md). - config (REASONED; server): Edit generated config.yaml and retain a supported credential; precreating it suppresses random-password generation and password auth without either credential fails startup. - bind (REASONED; server): bind-addr 127.0.0.1:8080 keeps the editor private behind a proxy or tunnel. - auth-default (REASONED; server): auth defaults password; replace the generated value with a long random secret and protect config.yaml. - rate-limit (REASONED; server): Password attempts are limited to two per minute plus twelve per hour. - credential-precedence (REASONED; server): PASSWORD/HASHED_PASSWORD override file credentials and hashed form wins; --password is not a supported CLI flag. - config-precedence (REASONED; server): CLI flags override config; inspect --bind-addr, --config, CODE_SERVER_CONFIG and XDG_CONFIG_HOME for the effective launcher and file, plus CODE_SERVER_HOST, PORT, --host, --port, --socket and --socket-fd for the listener and CS_DISABLE_PROXY, which turns on --disable-proxy. - tls-default (REASONED; server): cert defaults false; public access needs authenticated encryption, normally a reverse proxy with a trusted certificate. - tls-native (REASONED; server): Bare --cert or cert:true generates ~/.local/share/code-server/self-signed.crt; --cert with --cert-key selects supplied material; retain certificate validation. - mfa (REASONED; server): Built-in login is single-factor; fronting MFA must cover every enabled path, subdomain and WebSocket. - path-proxy (REASONED; server): /proxy/<port>/ strips its prefix; /absproxy/<port>/ retains it; both use code-server authentication and auth:none exposes them. - domain-proxy (REASONED; server): --proxy-domain exposes per-port subdomains using code-server authentication; apply fronting policy to each. - proxy-domain-cookie (REASONED; server): Under --proxy-domain the v4.145.2 session cookie is scoped to that domain and carries a password-derived hash that code-server checks as authentication (observed in source), so a sibling host the browser sends it to, per its path and browser cookie rules, can replay it as the authentication cookie (inference); login-password acceptance is not established. Serve only code-server under that domain. - cookie-attributes (REASONED; server): The v4.145.2 session cookie is httpOnly with sameSite lax; getCookieOptions sets no Secure attribute at either tag (observed in source at v4.145.2 and v4.137.0), and whether anything else adds Secure is not established. - origin-check (REASONED; server): The v4.145.2 origin check needs an exact hostname match, treats default ports as none and may append X-Forwarded-Port; fix Host and X-Forwarded-Host/-Port at the proxy instead of widening --trusted-origins, where "*" turns the check off. - preflight (REASONED; server): --skip-auth-preflight allows unauthenticated preflight requests. - disable-proxy (REASONED; server): --disable-proxy turns off unneeded port forwarding. - client-storage-flags (REASONED; server): v4.145.2 declares --disable-file-cache and --enable-remote-storage with no default; use the first on shared or untrusted clients and weigh the second for data at rest (inference); behaviour beyond their descriptions is not established. - least-privilege (REASONED; server): Loopback limits inbound reach, not terminal/extension/proxied-service outbound access; minimize permissions and egress, including metadata/internal destinations. - verify-origin (REASONED; server): ss inventories only this namespace; probe each public IPv4/IPv6 and port directly with intended TLS hostname; any HTTP response is reachability, and http=000 is inconclusive. - verify-editor (REASONED; server): At the same editor URL, anonymous requests must reach login/challenge and valid-session requests the editor; a proxy 401 alone or failed positive control proves nothing. - verify-mfa (REASONED; server): Password-only access must not open the editor while completing the second factor must; also confirm in a fresh browser. ````json { "schema": 1, "checked": "2026-11-07", "documentation_checked": "2026-11", "body_sha256": "9b98e33f64642356b7ff76e3118b9bf636593a50b889e7cf3edc030618826da5", "components": { "server": { "name": "code-server", "basis": "v4.145.2", "sources": { "s6ab9e06105bb": "https://raw.githubusercontent.com/coder/code-server/v4.145.2/docs/guide.md", "s2e220d3db1d3": "https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/cli.ts", "saade6a4c91ce": "https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/http.ts", "s3dc3c79d0206": "https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/routes/pathProxy.ts", "safbb0af6c9bc": "https://raw.githubusercontent.com/coder/code-server/v4.145.2/src/node/routes/domainProxy.ts", "sd7cef350aeb5": "https://raw.githubusercontent.com/coder/code-server/v4.145.2/docs/FAQ.md" } } }, "claims": { "ssh": {"text": "Vendor prefers SSH forwarding; bind the local 8080 forwarding listener to loopback and open the local browser URL.", "components": ["server"], "sources": ["server:s6ab9e06105bb"], "status": "REASONED"}, "rocket-loader": {"text": "On the Cloudflare path, turn Rocket Loader off for the code-server hostname, because its re-injected scripts lack the CSP nonce; do not loosen the CSP, since 'unsafe-inline' does not help while a nonce is present (observed in source at v4.145.2, docs/guide.md).", "components": ["server"], "sources": ["server:s6ab9e06105bb"], "status": "REASONED"}, "config": {"text": "Edit generated config.yaml and retain a supported credential; precreating it suppresses random-password generation and password auth without either credential fails startup.", "components": ["server"], "sources": ["server:sd7cef350aeb5", "server:s2e220d3db1d3"], "status": "REASONED"}, "bind": {"text": "bind-addr 127.0.0.1:8080 keeps the editor private behind a proxy or tunnel.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:s2e220d3db1d3"], "status": "REASONED"}, "auth-default": {"text": "auth defaults password; replace the generated value with a long random secret and protect config.yaml.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:s2e220d3db1d3"], "status": "REASONED"}, "rate-limit": {"text": "Password attempts are limited to two per minute plus twelve per hour.", "components": ["server"], "sources": ["server:s6ab9e06105bb"], "status": "REASONED"}, "credential-precedence": {"text": "PASSWORD/HASHED_PASSWORD override file credentials and hashed form wins; --password is not a supported CLI flag.", "components": ["server"], "sources": ["server:sd7cef350aeb5", "server:s2e220d3db1d3"], "status": "REASONED"}, "config-precedence": {"text": "CLI flags override config; inspect --bind-addr, --config, CODE_SERVER_CONFIG and XDG_CONFIG_HOME for the effective launcher and file, plus CODE_SERVER_HOST, PORT, --host, --port, --socket and --socket-fd for the listener and CS_DISABLE_PROXY, which turns on --disable-proxy.", "components": ["server"], "sources": ["server:sd7cef350aeb5", "server:s2e220d3db1d3"], "status": "REASONED"}, "tls-default": {"text": "cert defaults false; public access needs authenticated encryption, normally a reverse proxy with a trusted certificate.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:sd7cef350aeb5", "server:s2e220d3db1d3"], "status": "REASONED"}, "tls-native": {"text": "Bare --cert or cert:true generates ~/.local/share/code-server/self-signed.crt; --cert with --cert-key selects supplied material; retain certificate validation.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:s2e220d3db1d3"], "status": "REASONED"}, "mfa": {"text": "Built-in login is single-factor; fronting MFA must cover every enabled path, subdomain and WebSocket.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:saade6a4c91ce"], "status": "REASONED"}, "path-proxy": {"text": "/proxy// strips its prefix; /absproxy// retains it; both use code-server authentication and auth:none exposes them.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:saade6a4c91ce", "server:s3dc3c79d0206"], "status": "REASONED"}, "domain-proxy": {"text": "--proxy-domain exposes per-port subdomains using code-server authentication; apply fronting policy to each.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:saade6a4c91ce", "server:safbb0af6c9bc"], "status": "REASONED"}, "proxy-domain-cookie": {"text": "Under --proxy-domain the v4.145.2 session cookie is scoped to that domain and carries a password-derived hash that code-server checks as authentication (observed in source), so a sibling host the browser sends it to, per its path and browser cookie rules, can replay it as the authentication cookie (inference); login-password acceptance is not established. Serve only code-server under that domain.", "components": ["server"], "sources": ["server:s2e220d3db1d3", "server:saade6a4c91ce", "server:safbb0af6c9bc"], "status": "REASONED"}, "cookie-attributes": {"text": "The v4.145.2 session cookie is httpOnly with sameSite lax; getCookieOptions sets no Secure attribute at either tag (observed in source at v4.145.2 and v4.137.0), and whether anything else adds Secure is not established.", "components": ["server"], "sources": ["server:saade6a4c91ce"], "status": "REASONED"}, "origin-check": {"text": "The v4.145.2 origin check needs an exact hostname match, treats default ports as none and may append X-Forwarded-Port; fix Host and X-Forwarded-Host/-Port at the proxy instead of widening --trusted-origins, where \"*\" turns the check off.", "components": ["server"], "sources": ["server:s2e220d3db1d3", "server:saade6a4c91ce"], "status": "REASONED"}, "preflight": {"text": "--skip-auth-preflight allows unauthenticated preflight requests.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:s2e220d3db1d3"], "status": "REASONED"}, "disable-proxy": {"text": "--disable-proxy turns off unneeded port forwarding.", "components": ["server"], "sources": ["server:s2e220d3db1d3", "server:saade6a4c91ce"], "status": "REASONED"}, "client-storage-flags": {"text": "v4.145.2 declares --disable-file-cache and --enable-remote-storage with no default; use the first on shared or untrusted clients and weigh the second for data at rest (inference); behaviour beyond their descriptions is not established.", "components": ["server"], "sources": ["server:s2e220d3db1d3"], "status": "REASONED"}, "least-privilege": {"text": "Loopback limits inbound reach, not terminal/extension/proxied-service outbound access; minimize permissions and egress, including metadata/internal destinations.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:saade6a4c91ce"], "status": "REASONED"}, "verify-origin": {"text": "ss inventories only this namespace; probe each public IPv4/IPv6 and port directly with intended TLS hostname; any HTTP response is reachability, and http=000 is inconclusive.", "components": ["server"], "sources": ["server:s6ab9e06105bb"], "status": "REASONED", "verify": [1]}, "verify-editor": {"text": "At the same editor URL, anonymous requests must reach login/challenge and valid-session requests the editor; a proxy 401 alone or failed positive control proves nothing.", "components": ["server"], "sources": ["server:s6ab9e06105bb", "server:saade6a4c91ce"], "status": "REASONED", "verify": [2]}, "verify-mfa": {"text": "Password-only access must not open the editor while completing the second factor must; also confirm in a fresh browser.", "components": ["server"], "sources": ["server:s6ab9e06105bb"], "status": "REASONED", "verify": [2]} } } ```` ====================================================================== ==> self-hosted-error-trackers.md ====================================================================== # Self-hosted error trackers: Sentry and GlitchTip Sentry and GlitchTip are error trackers: apps send them crash reports with stack traces and request details. This guide helps your AI assistant configure an instance securely, including a loopback bind behind a TLS proxy, open sign-up off (GlitchTip ships it on), required MFA, and unpublished backing stores. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/self-hosted-error-trackers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Sentry and self-hosted distribution 26.10.1, Sentry prior-release frontend-migrated reference 26.8.0, and GlitchTip backend v6.2.6; 3 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 23 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An error tracker exists to collect what your application would otherwise hide: stack traces, the source lines around each frame, request bodies and headers, environment variables, release and server names, and whatever user context the SDK attaches. That makes an exposed tracker one of the richest leaks in a deployment, because it aggregates secrets, tokens, and personal data that were never meant to leave the app, and it does so in a searchable UI. The two common self-hosted choices behave differently at their most important default, so the guide takes them one at a time. [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) is the reverse-proxy pattern both rely on for TLS, [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) covers the signing keys, and [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) is the account layer. Values below are illustrative; replace them. One thing both share: the DSN a client uses to send events is a public, client-side key by design, not a secret. It authorizes event submission and nothing else, so finding one in shipped JavaScript is expected and is not the exposure this guide is about. The exposure is the tracker's own web and API, and the data they hold. ## Sentry The `getsentry/self-hosted` Docker Compose distribution fronts the web and API with a bundled nginx. Its published port comes from `SENTRY_BIND` in `.env`, which ships as `SENTRY_BIND=9000` (a bare port), so nginx binds `0.0.0.0:9000` and the instance answers on every interface. Bind it to loopback and let your own proxy terminate TLS in front: ```ini # .env SENTRY_BIND=127.0.0.1:9000 ``` Everything else in the Compose file (the web and relay processes, PostgreSQL, Redis, Kafka, and the ClickHouse and Snuba services) is an internal service that the distribution does not publish; leave it that way, because those backing stores hold the same event data with no authentication of their own. Registration is off by default: `auth.allow-registration` defaults to `False`, so an exposed instance does not by itself let a stranger create an account. Do not turn it on for an internet-facing tracker, and create the first administrator with the superuser flag set explicitly (a plain `createuser` prompts for superuser and defaults to no): ```bash docker compose run --rm web createuser --superuser ``` Set the rest in `sentry/config.yml`, and generate the signing key rather than copying one: ```yaml system.url-prefix: "https://sentry.example.com" auth.allow-registration: false # system.secret-key: generate once with `sentry config generate-secret-key`, keep it out of version control ``` Because your proxy terminates TLS, Sentry has to be told it is behind HTTPS, or its session and CSRF cookies are never marked secure. The bundled `sentry/sentry.conf.example.py` ships the relevant settings commented out; enable them in `sentry/sentry.conf.py`: ```python SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") USE_X_FORWARDED_HOST = True SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True ``` One catch is specific to the bundled stack: its own nginx sets `proxy_set_header X-Forwarded-Proto $scheme`, which overwrites whatever an outer proxy sent, so if you add a TLS proxy in front of nginx on `9000` it forwards `http` and Sentry still sees plaintext. Either terminate TLS at the bundled nginx itself, or propagate the real scheme through it, and verify the scheme Sentry actually receives rather than assuming the header setting alone took effect. Enforce member MFA and data scrubbing at the organization level (an authenticated API call, or the same fields in the org settings UI): `require2FA`, `dataScrubber`, `dataScrubberDefaults`, and `scrubIPAddresses`. Scrubbing matters precisely because the tracker ingests request data, so redacting passwords, tokens, and IPs at intake reduces what a later exposure leaks. The file setting is authoritative for this option: `auth.allow-registration` is a prioritize-disk option, so the value in `config.yml` wins over any value stored in the database, not the other way around. Unverified until checked in your deployment: any SSO path that provisions accounts around registration, the full list of auxiliary listeners your compose revision publishes, and the effective scrubbing rules. ## GlitchTip GlitchTip is a single Django application, and its Compose distribution publishes it on port `8000`. Its default is the opposite of Sentry's on the setting that matters most: `ENABLE_USER_REGISTRATION` defaults to `True`, which means an exposed instance offers open self-signup to anyone who reaches it. Close it, and remember the value's own nuance: ```ini # When True (the default), anyone may self-register; when False, self-signup is disabled # after the first user exists. Set it False once your accounts are provisioned. ENABLE_USER_REGISTRATION=False ``` Bind the app to loopback or a private interface and terminate TLS at a reverse proxy, since GlitchTip serves no TLS of its own. Give it a unique `SECRET_KEY` (Django's session and CSRF signing key) and keep it out of version control. GlitchTip has supported two-factor authentication since v1.8; enable it and require it for your users. PostgreSQL and the optional Valkey or Redis are internal services; do not publish them. Unverified until checked: the exact host bind in your Compose sample, whether MFA is enforced rather than merely available in your version, and whether any single-sign-on you add carries its own registration path around `ENABLE_USER_REGISTRATION`. ## Shared exposures - **The event data itself.** Both tools store stack traces with source context, request payloads, and user identifiers. An exposed backing store is reachable with the credentials the compose ships and expects on its internal network, so publishing it is a direct disclosure of every event it holds; an exposed web or API discloses to the degree an attacker can authenticate to it, which is why the registration default and the intake scrubbing both matter. Scrub at intake and keep the stores private. - **Open registration.** GlitchTip ships it on; Sentry ships it off. What a self-registered stranger then reaches differs by tool. Through Sentry 26.8.0, single-organization mode added a new sign-up without a pending invitation to the default organization, and at 26.10.1 this moved to the frontend and is unverified here. At GlitchTip backend v6.2.6 [the login signal](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/users/signals.py#L16-45) adds a user with a linked social provider to an organization linked to that provider on login, with the first member of a memberless organization becoming owner. Confirm your own membership and project permissions rather than assuming a new account has none or all. Confirm the live setting rather than trusting the default. - **The public DSN misread as a secret.** Rotating a DSN does not protect the tracker; authenticating its web and API does. Do not treat a leaked DSN as the incident. - **Plaintext.** Neither serves TLS natively, so an instance reachable without a terminating proxy sends session cookies and the event UI in the clear. ## Verify (REASONED: listener and backing-store inventory, Sentry/GlitchTip registration, redirect handling, disposable signup, off-host backing-port reachability and HTTP-to-HTTPS routing comparisons follow the cited tracker sources; no live tracker, backing stores, authorized signup fixture or external host. Bash 5.3.9 bash -n accepted both Verify fences. With curl replaced by printf LOCAL_CURL in the complete registration subshell, https://fixture.internal/api/chat reached one marker and exit 0; empty input, original placeholders, set -- without arguments and bad value printed guard diagnostics with exit 2 and no marker. This tests those guards only, not registration, protocol reachability, redirects or TLS; these runs covered earlier forms of both fences, the gate suite's bash -n and ShellCheck 0.11.0 passes cover their current forms, and the stub runs recorded under Listener inventory also cover the inventory's current form) REASONED: every probe below is reasoned, not demonstrated: the authoring environment has no container runtime, so the outcomes are derived from the cited vendor sources rather than observed. A redirect to a login page, a 404, or a TLS error is inconclusive, never the fixed state. The bash -n and stub runs recorded in the heading covered both blocks in an earlier form: the inventory then ran `sudo ss -tlnp` at top level, and the registration block took one value and ended with curl's own status. The repository's gate suite checks the current form of both blocks with bash -n and ShellCheck 0.11.0, like every block. ### Listener inventory REASONED: following block; listener expectations follow the cited vendor sources and the guide's cited sudo sudoers manual, secure_path entry; no container runtime is available. The block exits 2 when ss is not installed, 3 when ss fails and otherwise 0, leaving the listeners it prints for you to judge. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. Stub runs of its current form, with stub `ss` and `sudo` commands, exited 0 when `ss` passed, 3 when it failed and 2 when it was missing, in a clean shell and under an inherited `readonly`, `declare -i` and `set -e`. ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 # this block's status: 2 if ss is not installed, 3 if ss fails, otherwise 0, because you judge the listeners it prints if sudo ss -tlnp; then :; else # the tracker's own port only, on a private address: 9000 for Sentry, 8000 for # GlitchTip; and no PostgreSQL, Redis, Kafka, or ClickHouse port published beside it echo "sudo ss -tlnp exit $?: the listener inventory failed; inconclusive"; set -- 3 fi echo "block exit $1; the listeners above are not classified, so judge them against the comments yourself" exit "$1" ) ``` ### Registration and backing-store probe (REASONED: registration and backing-port checks follow the cited Sentry and GlitchTip sources; no container runtime is available) ```bash # Registration discriminator against the plaintext listener, or a backing-store reachability probe. # Substitute the URL inside the single quotes on the set -- line, then web for the tracker's web # listener or store for a backing-store port probed from off-host, and paste the whole block so the # guard runs; with web, read the body, since an open form and a closed one can both return 200. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_REGISTER_URL' 'REPLACE_WITH_web_OR_store' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute a full URL on the set -- line above; not probing"; exit 2 ;; http://*|https://*) : ;; *) echo "expected an http:// or https:// URL; not probing"; exit 2 ;; esac case "$2" in web|store) : ;; *) echo "substitute web (the tracker's web listener) or store (a backing-store port, probed from off-host) as the second value on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$2" "${3##*$'\n'}" "${3%$'\n'*}" # web or store, then curl's exit status and its output printf '%s' "$3" set -- "$1" "$2" "${3%$'\n'}" set -- "$1" "$2" "${3##*$'\n'}" # web or store, curl's exit status, then its http= exit= err= line case "$1:$2:$3" in web:0:"http=3"??" "*|web:0:"http=404 "*) echo "for the registration check, a redirect or a 404 is inconclusive, never the fixed state (for Sentry, add -L to follow the redirect to the served login page); the HTTP-to-HTTPS routing check is read from the redirect target, not from this status"; set -- 3 ;; web:0:*) echo "unclassified observation: read the body above for a create-account path or signup form"; set -- 0 ;; store:7:*) echo "curl exit 7: the connection failed, which includes the refused connection; when err shows a refusal from this vantage, that is the closed, fixed state, and otherwise exit 7 is not proof"; set -- 0 ;; store:0:*|store:1:*HTTP/0.9*|store:8:*|store:52:*|store:56:*) echo "FINDING: curl exit $2 after the backing store accepted the connection, so its port is reachable from here"; set -- 1 ;; *) echo "curl exit $2: a transport failure or a TLS error is inconclusive; read the err field above"; set -- 3 ;; esac echo "block exit $1; the body and the err field above are not classified beyond this, so read them yourself" exit "$1" ) ``` Point that block, with `web`, at Sentry's `http://tracker.example.com:9000/`: through Sentry 26.8.0, because the bundled config sets `SENTRY_SINGLE_ORGANIZATION`, `/auth/register/` redirects to the organization login, so follow the redirect (add `-L`) to the served login page, where an instance with registration on presents a create-account path and a closed one (the default) does not. At 26.10.1 that page serves a React app and this redirect is unverified, so confirm the registration state from `auth.allow-registration` instead. For GlitchTip's `http://tracker.example.com:8000/`, an instance with `ENABLE_USER_REGISTRATION=True` accepts a new account through its signup, and one set `False` refuses after the first user exists; confirm by attempting a throwaway registration in an operator-controlled test, never against a shared instance. For each backing store, run the same guarded block with `store` against its port from off-host: those ports speak their own protocols, not HTTP, so read the connection outcome in the `exit` and `err` fields (a refused connection is the closed, fixed state; an accepted one is reachable) rather than any HTTP status. With `web`, the block exits 3 for a failed request, a redirect or a `404` and otherwise 0, leaving the body for you to read. With `store`, it exits 0 for curl exit 7, which includes the refused connection and is otherwise not proof. It exits 1 for curl exit 0, 8, 52 or 56, or for exit 1 with `HTTP/0.9` in `err`, curl's documented error for an HTTP/0.9 reply, one with no HTTP status line, such as a reachable Redis can give; curl reports each of these only after the connection was accepted. Any other failure, such as a timeout, which can come before or after the connection, gives 3. The `ss` inventory shows what Docker has published on the host, but a container port can be reachable through Docker's forwarding without a matching host socket, so the off-host probe is what proves it closed. Distinguish the public HTTPS proxy from the direct backend ports, and confirm plain HTTP to the web redirects to that proxy rather than serving the app. Read that routing check from the redirect target, such as the `Location` header that curl prints with `-i`, not from the block's status, which counts any redirect as inconclusive for the registration check. A reachable login page alone proves only that the port is open, not that registration or the backing stores are closed. ## Common mistakes - Publishing Sentry with the shipped `SENTRY_BIND=9000`, which binds every interface, instead of a loopback address behind a proxy. - Leaving GlitchTip's `ENABLE_USER_REGISTRATION` at its default `True` on an internet-facing instance, so anyone can self-register. - Treating a leaked client DSN as the exposure while the tracker's own web and API stay unauthenticated. - Publishing a backing service (PostgreSQL, Redis, Kafka, ClickHouse) beside the tracker, which holds the same event data with no auth of its own. - Skipping data scrubbing, so secrets and personal data captured in events sit in the tracker in the clear. ## Sources (checked September 2026) - Sentry self-hosted Docker Compose (`SENTRY_BIND`, the bundled nginx publication): https://github.com/getsentry/self-hosted/blob/26.10.1/docker-compose.yml - Sentry self-hosted `.env` (`SENTRY_BIND=9000` default): https://github.com/getsentry/self-hosted/blob/26.10.1/.env - Sentry `auth.allow-registration` default `False`: https://github.com/getsentry/sentry/blob/26.10.1/src/sentry/options/defaults.py - Sentry `createuser` (explicit `--superuser`): https://github.com/getsentry/sentry/blob/26.10.1/src/sentry/runner/commands/createuser.py - Sentry organization update API (`require2FA`, data scrubbing fields): https://docs.sentry.io/api/organizations/update-an-organization/ - Sentry bundled configuration (`SENTRY_SINGLE_ORGANIZATION`, the commented-out `SECURE_PROXY_SSL_HEADER`, `SESSION_COOKIE_SECURE`, `CSRF_COOKIE_SECURE`): https://github.com/getsentry/self-hosted/blob/26.10.1/sentry/sentry.conf.example.py - Sentry DSN explainer (a DSN is public and allows only event submission, not read access): https://docs.sentry.io/concepts/key-terms/dsn-explainer/ - GlitchTip installation (port `8000`, `ENABLE_USER_REGISTRATION` default and behavior): https://glitchtip.com/documentation/install/ - GlitchTip two-factor authentication (available since v1.8): https://glitchtip.com/blog/2021-09-17-glitchtip-1-8/ - [GlitchTip backend v6.2.6 organization membership on login for a user with a linked social account (checked October 2026)](https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/users/signals.py#L16-45). - [Sentry 26.10.1 secret-key generator command (checked October 2026)](https://raw.githubusercontent.com/getsentry/sentry/26.10.1/src/sentry/runner/commands/config.py). - [Sentry self-hosted 26.10.1 bundled nginx forwarded scheme (checked October 2026)](https://github.com/getsentry/self-hosted/blob/26.10.1/nginx.conf#L63). - [Sentry 26.8.0 registration and single-organization membership (checked October 2026)](https://raw.githubusercontent.com/getsentry/sentry/26.8.0/src/sentry/web/frontend/auth_login.py). - [sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026)](https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - sentry: Sentry and self-hosted distribution (26.10.1) - sentry-prev: Sentry prior-release frontend-migrated reference (26.8.0) - glitchtip-backend: GlitchTip backend (v6.2.6) - sentry-docs: Sentry documentation (unknown) - glitchtip: GlitchTip documentation (unknown) - glitchtip-mfa: GlitchTip MFA announcement (unknown) Claims (full source mapping in the JSON block below): - dsn (REASONED; sentry-docs): Client DSNs authorize event submission, not reading tracker data. - sentry-bind (REASONED; sentry): SENTRY_BIND=9000 publishes nginx on all interfaces; set 127.0.0.1:9000 behind a TLS proxy. - sentry-stores (REASONED; sentry): Leave web, relay and backing stores unpublished; their internal credentials are not a public boundary. - sentry-registration (REASONED; sentry): auth.allow-registration defaults False; keep it false on an internet-facing tracker. - sentry-admin (REASONED; sentry): createuser --superuser explicitly creates an administrator; plain createuser defaults its superuser prompt to no. - sentry-url (REASONED; sentry): Set system.url-prefix to the public HTTPS origin. - sentry-secret (REASONED; sentry): Generate system.secret-key once and keep it out of version control. - sentry-cookies (REASONED; sentry): Enable forwarded scheme/host recognition and secure session/CSRF cookies; bundled settings are commented out. - sentry-nginx (REASONED; sentry): Bundled nginx overwrites X-Forwarded-Proto with its scheme; terminate TLS there or propagate the real scheme. - sentry-mfa (REASONED; sentry-docs): Enforce organization member MFA with require2FA. - sentry-scrubbing (REASONED; sentry-docs): Use dataScrubber, dataScrubberDefaults and scrubIPAddresses to reduce sensitive intake. - sentry-precedence (REASONED; sentry, sentry-docs): auth.allow-registration prioritizes config.yml over the database; verify SSO provisioning and effective scrubbing separately. - glitchtip-port (REASONED; glitchtip): GlitchTip Compose publishes 8000; bind privately behind TLS and verify the actual sample host bind. - glitchtip-registration (REASONED; glitchtip): ENABLE_USER_REGISTRATION defaults True; False disables self-signup after the first user exists. - glitchtip-secret (REASONED; glitchtip): Use a unique SECRET_KEY for Django signing and keep it out of version control. - glitchtip-mfa (REASONED; glitchtip-mfa): Two-factor authentication is available since v1.8; confirm enforcement and SSO registration paths in the deployed version. - glitchtip-stores (REASONED; glitchtip): Keep PostgreSQL and optional Valkey or Redis internal. - membership (REASONED; sentry, sentry-prev, glitchtip-backend): Through Sentry 26.8.0, a single-organization signup without a pending invitation joined the default organization; its 26.10.1 behaviour is unverified since that code moved to the frontend. At GlitchTip backend v6.2.6, on login the signal adds a user with a linked social provider to an organization linked to that provider, with the first member of a memberless organization becoming owner. - verify-listeners (REASONED; sentry, glitchtip): Inventory private 9000/8000 and backing ports; Docker forwarding can expose ports without host sockets. - verify-sentry (REASONED; sentry, sentry-prev): Through Sentry 26.8.0, /auth/register/ redirects to the single-organization login; follow it and inspect the create-account path, since HTTP 200 alone cannot distinguish registration. At 26.10.1 that page serves a React app, so this redirect check is unverified; confirm registration state from the auth.allow-registration setting instead. - verify-glitchtip (REASONED; glitchtip): On an operator-controlled fixture, signup succeeds with registration True and is refused with False after the first user. - verify-stores (REASONED; sentry, glitchtip): Off-host backing-port probes judge TCP connection outcomes, not HTTP codes; distinguish the public HTTPS proxy and backend ports. - verify-https (REASONED; sentry, glitchtip): Confirm plaintext web requests redirect to the HTTPS proxy; login pages, 404s and TLS errors do not prove controls. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "44db64de9642a0f626974f6c1896e099069200efe0abd67eb0479e6effaab848", "components": { "sentry": { "name": "Sentry and self-hosted distribution", "basis": "26.10.1", "sources": { "s8c9ef6c47c63": "https://github.com/getsentry/self-hosted/blob/26.10.1/docker-compose.yml", "s6b3008b7ce70": "https://github.com/getsentry/self-hosted/blob/26.10.1/.env", "s8cfd1816bebc": "https://github.com/getsentry/sentry/blob/26.10.1/src/sentry/options/defaults.py", "s9dc4a5a38dd5": "https://github.com/getsentry/sentry/blob/26.10.1/src/sentry/runner/commands/createuser.py", "sffc6218f3e60": "https://github.com/getsentry/self-hosted/blob/26.10.1/sentry/sentry.conf.example.py", "s34e00f944b78": "https://raw.githubusercontent.com/getsentry/sentry/26.10.1/src/sentry/runner/commands/config.py", "s8448c8ef78cc": "https://github.com/getsentry/self-hosted/blob/26.10.1/nginx.conf#L63" } }, "sentry-prev": { "name": "Sentry prior-release frontend-migrated reference", "basis": "26.8.0", "sources": { "se4a92c571e6f": "https://raw.githubusercontent.com/getsentry/sentry/26.8.0/src/sentry/web/frontend/auth_login.py" } }, "glitchtip-backend": { "name": "GlitchTip backend", "basis": "v6.2.6", "sources": { "s32daf6ef6011": "https://gitlab.com/glitchtip/glitchtip-backend/-/blob/v6.2.6/apps/users/signals.py#L16-45" } }, "sentry-docs": { "name": "Sentry documentation", "basis": "unknown", "sources": { "see399d43779f": "https://docs.sentry.io/api/organizations/update-an-organization/", "s5e15f3959620": "https://docs.sentry.io/concepts/key-terms/dsn-explainer/" } }, "glitchtip": { "name": "GlitchTip documentation", "basis": "unknown", "sources": { "scaee058a19e4": "https://glitchtip.com/documentation/install/" } }, "glitchtip-mfa": { "name": "GlitchTip MFA announcement", "basis": "unknown", "sources": { "sb4f6be6e6b48": "https://glitchtip.com/blog/2021-09-17-glitchtip-1-8/" } } }, "claims": { "dsn": {"text": "Client DSNs authorize event submission, not reading tracker data.", "components": ["sentry-docs"], "sources": ["sentry-docs:s5e15f3959620"], "status": "REASONED"}, "sentry-bind": {"text": "SENTRY_BIND=9000 publishes nginx on all interfaces; set 127.0.0.1:9000 behind a TLS proxy.", "components": ["sentry"], "sources": ["sentry:s8c9ef6c47c63", "sentry:s6b3008b7ce70"], "status": "REASONED"}, "sentry-stores": {"text": "Leave web, relay and backing stores unpublished; their internal credentials are not a public boundary.", "components": ["sentry"], "sources": ["sentry:s8c9ef6c47c63"], "status": "REASONED"}, "sentry-registration": {"text": "auth.allow-registration defaults False; keep it false on an internet-facing tracker.", "components": ["sentry"], "sources": ["sentry:s8cfd1816bebc"], "status": "REASONED"}, "sentry-admin": {"text": "createuser --superuser explicitly creates an administrator; plain createuser defaults its superuser prompt to no.", "components": ["sentry"], "sources": ["sentry:s9dc4a5a38dd5"], "status": "REASONED"}, "sentry-url": {"text": "Set system.url-prefix to the public HTTPS origin.", "components": ["sentry"], "sources": ["sentry:sffc6218f3e60", "sentry:s8cfd1816bebc"], "status": "REASONED"}, "sentry-secret": {"text": "Generate system.secret-key once and keep it out of version control.", "components": ["sentry"], "sources": ["sentry:s8cfd1816bebc", "sentry:sffc6218f3e60", "sentry:s34e00f944b78"], "status": "REASONED"}, "sentry-cookies": {"text": "Enable forwarded scheme/host recognition and secure session/CSRF cookies; bundled settings are commented out.", "components": ["sentry"], "sources": ["sentry:sffc6218f3e60"], "status": "REASONED"}, "sentry-nginx": {"text": "Bundled nginx overwrites X-Forwarded-Proto with its scheme; terminate TLS there or propagate the real scheme.", "components": ["sentry"], "sources": ["sentry:s8c9ef6c47c63", "sentry:sffc6218f3e60", "sentry:s8448c8ef78cc"], "status": "REASONED"}, "sentry-mfa": {"text": "Enforce organization member MFA with require2FA.", "components": ["sentry-docs"], "sources": ["sentry-docs:see399d43779f"], "status": "REASONED"}, "sentry-scrubbing": {"text": "Use dataScrubber, dataScrubberDefaults and scrubIPAddresses to reduce sensitive intake.", "components": ["sentry-docs"], "sources": ["sentry-docs:see399d43779f"], "status": "REASONED"}, "sentry-precedence": {"text": "auth.allow-registration prioritizes config.yml over the database; verify SSO provisioning and effective scrubbing separately.", "components": ["sentry", "sentry-docs"], "sources": ["sentry:s8cfd1816bebc", "sentry-docs:see399d43779f"], "status": "REASONED"}, "glitchtip-port": {"text": "GlitchTip Compose publishes 8000; bind privately behind TLS and verify the actual sample host bind.", "components": ["glitchtip"], "sources": ["glitchtip:scaee058a19e4"], "status": "REASONED"}, "glitchtip-registration": {"text": "ENABLE_USER_REGISTRATION defaults True; False disables self-signup after the first user exists.", "components": ["glitchtip"], "sources": ["glitchtip:scaee058a19e4"], "status": "REASONED"}, "glitchtip-secret": {"text": "Use a unique SECRET_KEY for Django signing and keep it out of version control.", "components": ["glitchtip"], "sources": ["glitchtip:scaee058a19e4"], "status": "REASONED"}, "glitchtip-mfa": {"text": "Two-factor authentication is available since v1.8; confirm enforcement and SSO registration paths in the deployed version.", "components": ["glitchtip-mfa"], "sources": ["glitchtip-mfa:sb4f6be6e6b48"], "status": "REASONED"}, "glitchtip-stores": {"text": "Keep PostgreSQL and optional Valkey or Redis internal.", "components": ["glitchtip"], "sources": ["glitchtip:scaee058a19e4"], "status": "REASONED"}, "membership": {"text": "Through Sentry 26.8.0, a single-organization signup without a pending invitation joined the default organization; its 26.10.1 behaviour is unverified since that code moved to the frontend. At GlitchTip backend v6.2.6, on login the signal adds a user with a linked social provider to an organization linked to that provider, with the first member of a memberless organization becoming owner.", "components": ["sentry", "sentry-prev", "glitchtip-backend"], "sources": ["sentry:sffc6218f3e60", "sentry-prev:se4a92c571e6f", "glitchtip-backend:s32daf6ef6011"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory private 9000/8000 and backing ports; Docker forwarding can expose ports without host sockets.", "components": ["sentry", "glitchtip"], "sources": ["sentry:s8c9ef6c47c63", "glitchtip:scaee058a19e4"], "status": "REASONED", "verify": [1]}, "verify-sentry": {"text": "Through Sentry 26.8.0, /auth/register/ redirects to the single-organization login; follow it and inspect the create-account path, since HTTP 200 alone cannot distinguish registration. At 26.10.1 that page serves a React app, so this redirect check is unverified; confirm registration state from the auth.allow-registration setting instead.", "components": ["sentry", "sentry-prev"], "sources": ["sentry:s8cfd1816bebc", "sentry:sffc6218f3e60", "sentry-prev:se4a92c571e6f"], "status": "REASONED", "verify": [2]}, "verify-glitchtip": {"text": "On an operator-controlled fixture, signup succeeds with registration True and is refused with False after the first user.", "components": ["glitchtip"], "sources": ["glitchtip:scaee058a19e4"], "status": "REASONED", "verify": [2]}, "verify-stores": {"text": "Off-host backing-port probes judge TCP connection outcomes, not HTTP codes; distinguish the public HTTPS proxy and backend ports.", "components": ["sentry", "glitchtip"], "sources": ["sentry:s8c9ef6c47c63", "glitchtip:scaee058a19e4"], "status": "REASONED", "verify": [2]}, "verify-https": {"text": "Confirm plaintext web requests redirect to the HTTPS proxy; login pages, 404s and TLS errors do not prove controls.", "components": ["sentry", "glitchtip"], "sources": ["sentry:sffc6218f3e60", "glitchtip:scaee058a19e4"], "status": "REASONED", "verify": [2]} } } ```` ====================================================================== ==> vector-databases.md ====================================================================== # Vector databases: Qdrant, Weaviate, Milvus, Chroma, pgvector Qdrant, Weaviate, Milvus and Chroma are vector databases that find text by meaning; pgvector adds this to PostgreSQL. This guide helps your AI assistant configure each securely, including Weaviate's anonymous access off, Milvus's default root password changed, and Chroma behind an authenticated TLS proxy, Cloudflare Tunnel with Access or tailnet. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/vector-databases.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Qdrant source v1.20.0, Weaviate source v1.40.4, Milvus v2.6.26 and v3.0.5, Milvus Helm chart 5.0.30, Milvus Operator v1.3.11, Chroma source 1.5.9, Chroma migration documentation v1.0.0, and pgvector source v0.8.7, plus 4 more recorded bases; 3 further sources are pinned to a commit and 9 carry no recorded version. Metadata reviewed 2026-11-04; documentation checked October 2026. The guide records 83 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A RAG store holds every document the application was given, often including private data, and it answers similarity queries that reconstruct that text. Several of these servers ship with no authentication enabled and none of them serves TLS out of the box, so an exposed default install is a searchable copy of your corpus. Keep the store on a private interface, turn on the native key or account control where one exists, and put TLS in front or on the server before any client crosses a network. ## 1. Bind privately Each server listens on plain TCP; publish only a reverse proxy ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)), a tunnel ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)), or a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)). In Docker, map to loopback (`-p 127.0.0.1:6333:6333`), not `-p 6333:6333`, which binds every interface ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). Default ports, from the vendor pages in Sources: | Server | Default ports | |---|---| | Qdrant | 6333 (REST), 6334 (gRPC), 6335 (internal cluster gRPC, distributed mode only) | | Weaviate | 8080 (HTTP), 50051 (gRPC) | | Milvus | 19530 (gRPC), 9091 (management/metrics HTTP, optional WebUI); internal: 19529, 21123, 21124, 22125, 22222 | | Chroma | 8000 | | pgvector | 5432 (it is PostgreSQL) | Qdrant and Chroma bind every IPv4 interface by default in their official images, and Weaviate opens several listeners that ignore `--host` wherever it runs (as of Qdrant v1.20.0, Chroma 1.5.9 and Weaviate v1.40.4): - **Qdrant**'s compiled-in configuration sets `service.host: 0.0.0.0`. It then merges `config/config`, `config/$RUN_MODE` (`RUN_MODE` defaults to `development`) and `config/local` from the working directory, then, in a build with the `deb` feature, `/etc/qdrant/config`, then `--config-path`, then `QDRANT__`-prefixed environment variables; any later layer can set the host. The repository's `config/development.yaml` narrows it to `127.0.0.1`, so a binary started from a checkout's root with `RUN_MODE` unset or `development` listens on loopback unless a later layer sets it; the official image sets `RUN_MODE=production`, whose `config/production.yaml` keeps `0.0.0.0`, and its entrypoint starts `./qdrant` with no arguments. - **Chroma**'s `chroma run` without a config file binds `localhost`, its `--host` default. Given a config file, it binds the file's `listen_address`, or `0.0.0.0` when the file sets none, after merging `CHROMA_`-prefixed environment variables. The official `chromadb/chroma` image runs `chroma run /config.yaml` with a file that sets only `persist_path`, so the container listens on `0.0.0.0:8000` unless a `CHROMA_`-prefixed variable sets the address or port. - **Weaviate** reads most settings from a config file (`--config-file`), then environment variables, then command-line flags, each overriding the one before. Every port below, and every address that has a setting, is a default, but the environment step re-applies the default for the gRPC and Raft ports whenever their variable is unset or empty, and for the gossip and cluster-API ports whenever theirs is unset (an empty `CLUSTER_GOSSIP_BIND_PORT` or `CLUSTER_DATA_BIND_PORT` stops startup with a parse error). A port set only in the config file therefore does not take effect for the gRPC, Raft, gossip and cluster-API ports; set ports through their variables (or, for REST and Raft, flags) and confirm with `ss`. The gRPC, debug, Prometheus and cluster-API listeners have no address setting and bind every local address whenever they run. Its REST listener depends on `--scheme`. Without it, the API spec's default is `https`, which needs a certificate and key (`--tls-certificate` and `--tls-key`, or `TLS_CERTIFICATE` and `TLS_PRIVATE_KEY`) or Weaviate exits at startup; with them it binds `--tls-host` (`TLS_HOST`, falling back to `--host`) on a random `--tls-port`. With `--scheme http`, as the official `semitechnologies/weaviate` image passes (with `--host 0.0.0.0 --port 8080`), it binds `--host` (`HOST`; the default `localhost` applies only while `HOST` is unset, since an exported but empty `HOST` binds every local address) on `--port` (random by default); `unix` binds `--socket-path`. None of Weaviate's other listeners follows `--host`: gRPC always starts, on every local address, on 50051 unless `GRPC_PORT` sets another port. The debug HTTP listener (pprof and fgprof) binds 6060 on every local address unless `GO_PROFILING_DISABLE` is set to a true value, and answers 404 until debug endpoints are enabled. In v1.40.4, startup passes `http.DefaultServeMux`, the mux this gate wraps, to `setupSelfRecoveryDebugHandlers`, a function this guide has not read, with the self-recovery mode and a callback that calls `srhandlers.SetupHandlers` on the mux it receives. This guide infers that if the self-recovery debug routes are registered on that mux, they also answer 404 until debug endpoints are enabled; whether and where that function registers them is not established. This guide has not established which mux the Raft debug routes that v1.40.4 adds when self-recovery is not off use, or whether the MCP handlers open a listener of their own. The Prometheus listener (`/metrics` and `/tenant-activity`) binds every local address when monitoring is enabled, on 2112 when `PROMETHEUS_MONITORING_ENABLED` enables it. Even a single node opens memberlist gossip on 7946 (TCP and UDP) at `CLUSTER_BIND_ADDR`, or on every local address (`0.0.0.0`) when that is unset or empty. Even a single node also opens the internal cluster API on 7947 (the gossip port plus one by default) on every local address, and Raft on 8300 with its internal RPC on 8301 at a non-empty `CLUSTER_BIND_ADDR`, else a non-empty `CLUSTER_ADVERTISE_ADDR`, else the private address memberlist advertises. Keep 50051, 6060, 2112, 7946, 7947, 8300 and 8301 unpublished, opening the cluster ports only to peers and 2112 only to the metrics scraper. ## 2. Qdrant: API key and TLS Per the Qdrant security page, "all self-deployed Qdrant instances are not secure" by default and connections are unencrypted. Set an API key in the config file or through the environment, and add a read-only key for query-only clients: ```yaml service: api_key: REPLACE_WITH_LONG_RANDOM_VALUE read_only_api_key: REPLACE_WITH_ANOTHER_LONG_RANDOM_VALUE enable_tls: true tls: cert: ./tls/cert.pem key: ./tls/key.pem ``` Environment equivalents: `QDRANT__SERVICE__API_KEY` and `QDRANT__SERVICE__READ_ONLY_API_KEY`. Clients send the key in the `api-key` header (or `Authorization: Bearer`); the two are interchangeable for the client REST and gRPC API. Qdrant's own docs say that enabling the key without TLS is insecure; terminate TLS either in Qdrant as above or at a proxy in front. An API key protects the client API. Whether a key also protects the internal cluster port, 6335, depends on the Qdrant version and on `service.enforce_internal_auth`. The security page states that "Internal gRPC uses port 6335 by default if running in distributed mode" and that, in versions before v1.20.0, internal communication channels "are not protected by an API key nor bearer tokens by default". The page also says: "You must ensure that this port is not publicly reachable and can only be used for node communication." Through v1.17.x no key setting closes that gap. Qdrant v1.18.0 added `service.enforce_internal_auth`: with it set, the receiving peer verifies the forwarded `api_key` on internal p2p requests, so a key can gate 6335 on v1.18.0 and later. The security page says the setting is "Enabled by default as of v1.20.0", so the setting is off by default from v1.18.0 to v1.19.x and on by default in v1.20.0, the Qdrant release this guide's source basis records, and says "Enforcing authentication does not replace network isolation for the internal port", so do not rely on the setting in place of the network restriction below. On v1.20.0 the default enforcement has an effect only where an API key is configured: without an API key, the security page says, "Qdrant installs no authentication layer and the setting has no effect". As of v1.19.2 the internal API accepts only `api_key` or `alt_api_key` and rejects the read-only key and every JWT, so on v1.20.0 a cluster with only `read_only_api_key` set is still subject to enforcement but has no key the internal API accepts, and that cluster loses peer-to-peer communication unless `api_key` is set or `enforce_internal_auth` is set to `false`. Confirming that 6333 returns 401 without a key tells you nothing about 6335: in distributed mode 6335 answers a peer with no API key or bearer token wherever internal enforcement is not in effect (peer TLS, when enabled, authenticates the channel by certificate). Internal enforcement is not in effect with no API key configured, before v1.18.0, on v1.18.0 to v1.19.x without `enforce_internal_auth` set, or on v1.20.0 with `enforce_internal_auth` set to `false`. A reader who sets a key, sees 6333 return 401, and concludes that 6335 is authenticated is therefore wrong unless internal enforcement is in effect, and even with enforcement 6335 still has to be restricted to peers. Restrict 6335 to your cluster peers at the network layer, with a host firewall, a cloud security group, or by simply not publishing the container port to any address a non-peer can reach. Allow inbound 6335 only from your other peers' addresses and deny every other source, including other machines on the same private network. A node not running in cluster mode does not open 6335 at all; note that a single initial node started WITH cluster mode enabled does open the internal listener, so "single host" is not by itself a guarantee that 6335 is absent, which is why V1 and the 6335 probe still apply. In a cluster you cannot bind it to `127.0.0.1`, because peers on other hosts must reach it, so bind it to the private cluster interface and firewall it to peers. The internal listener binds to `service.host` unless `cluster.p2p.host` is set, a setting the security page documents as available as of v1.19.2. On v1.20.0, set `cluster.p2p.host` to the node's private cluster IP address, because the security page says "The value must be an IP address", and give the node a peer URI (`--uri`) on that same address, since the page says the peer URI "must point to the same address". For example, a node whose private cluster address is 10.0.0.5 sets `cluster.p2p.host: 10.0.0.5` and starts with `--uri http://10.0.0.5:6335`, and 6335 is still firewalled to peers. TLS on the peer channel does not remove this requirement; turn it on as well, but it authenticates and encrypts the channel rather than making an exposed port safe to reach: ```yaml cluster: p2p: enable_tls: true ``` `cluster.p2p.enable_tls` is separate from `service.enable_tls`: the first secures peer-to-peer traffic, the second the client API. Enabling `cluster.p2p.enable_tls` also requires a `ca_cert` in the `tls:` block, the CA that validates peer certificates; the security page marks `ca_cert: ./tls/cacert.pem` as required for peer TLS. Provision that CA and set the field before you enable peer TLS. Qdrant carries a compiled default path (`./tls/cacert.pem`), so a missing entry does not always fail at startup, but relying on an unprovisioned default is fragile and the vendor marks it required. Set both TLS settings in distributed mode, apply the configuration on every peer, and restart the peers one at a time. To rotate the client API key without downtime, set the new key as `service.alt_api_key` on each peer and restart one at a time; `alt_api_key` (available as of Qdrant v1.17.0) is an additional accepted client key and, like `api_key`, gates the client API. As of v1.19.2 a peer with `api_key` unset sends `alt_api_key` on internal requests, and the receiving peer checks that key only where internal enforcement is in effect (`enforce_internal_auth` set on v1.18.0 to v1.19.x, or left at its v1.20.0 default). The security page says that in a cluster with only `alt_api_key` set, v1.19.0 and v1.19.1 do not forward `alt_api_key` on internal requests, so v1.20.0 peers reject requests from those versions; such a cluster that runs v1.19.0 or v1.19.1 must, in the page's words, "Upgrade to v1.19.2 first, and then to v1.20.0". With enforcement on, the v1.20.0 default, set the new key as `alt_api_key` on every peer before promoting it to `api_key`, because the security page says every peer must accept the key its peers send. In the promoting rolling restart, set the new key as `api_key` and keep the old key as `alt_api_key` on each peer, since the security page says to "Keep the old key as alt_api_key until every peer uses the new key"; remove the old key only in a further rolling restart, after every peer uses the new key. The peer-channel and rotation settings can be version-dependent, so confirm them against the security page for the Qdrant version you run. ## 3. Weaviate: disable anonymous access, then authorize `AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED` defaults to `true`, which the Weaviate docs describe as strongly discouraged outside development. The secured Docker example: ```yaml environment: AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false' AUTHENTICATION_APIKEY_ENABLED: 'true' AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'REPLACE_WITH_ADMIN_KEY,REPLACE_WITH_APP_KEY' AUTHENTICATION_APIKEY_USERS: 'admin-user,app-user' AUTHORIZATION_RBAC_ENABLED: 'true' AUTHORIZATION_RBAC_ROOT_USERS: 'admin-user' ``` Keys map to users by position, so the first key belongs to `admin-user` and the second to `app-user`. Only `admin-user` is a root user with full access; give `app-user` a custom role limited to its collections, created with the admin key through the RBAC API as the [RBAC configuration page](https://docs.weaviate.io/deploy/configuration/configuring-rbac) describes, and keep the admin key off the application host. Authentication alone lets any key holder do anything; add authorization with either RBAC (above, generally available from v1.29 per the authorization page) or the simpler admin list (`AUTHORIZATION_ADMINLIST_ENABLED`, `AUTHORIZATION_ADMINLIST_USERS`, `AUTHORIZATION_ADMINLIST_READONLY_USERS`; it cannot be combined with RBAC). For human logins, `AUTHENTICATION_OIDC_ENABLED` with `AUTHENTICATION_OIDC_ISSUER` and `AUTHENTICATION_OIDC_CLIENT_ID` delegates to an identity provider, where MFA can be enforced ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). The documented deployment starts Weaviate with `--scheme http` and points to a reverse proxy for domain access, forwarding both 8080 and 50051; give the proxy the certificate ([free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md)) and do not expose the plain ports. The v1.40.4 release notes list 3 fixes (#13749, #13651 and #13679), which this guide describes as authorization fixes, that the v1.39.15 release notes named on 2026-11-04, so Weaviate v1.40.4 includes them; v1.40.3, published before v1.39.15, did not list them. This guide has not established which handlers v1.40.3's "fix(backups): add missing basic auth to remaining handlers" (#13350) fix covers; test custom roles against the release you run. ## 4. Milvus: enable authentication, change root, add TLS This section rests on Milvus v3.0.5, the latest release checked, and v2.6.26, the last 2.x release checked. Where a statement names or cites only v3.0.5, this guide compared the v2.6.26 source and found it the same, or differing only in logging calls with no change in behaviour, which this guide counts as the same. Where the 2 releases differ, both are named and cited. For standalone Docker Compose at v3.0.5, the image reads `/milvus/configs/milvus.yaml`, but the supplied Compose file mounts only data at `/var/lib/milvus`. Editing a host-side configuration file alone changes nothing. The loader documents this file precedence, lowest to highest: `milvus.yaml`, `_test.yaml`, `default.yaml`, `user.yaml`. Use a small `user.yaml` to override the needed keys while retaining the image's other settings. These paths assume the image's `/milvus` working directory and no `MILVUSCONF` override, which selects a different configuration directory. Create `./user.yaml` beside your Compose file with the authentication and TLS YAML below, and put the certificate files in `./tls/` before starting. Edit these mounts into the existing `standalone.volumes` list, keeping its data mount: ```yaml services: standalone: volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus - ./user.yaml:/milvus/configs/user.yaml:ro - ./tls:/milvus/tls:ro ``` Ensure the container account can read the configuration and certificate files and traverse their directories; keep the private key accessible only to that account and trusted administrators. The v3.0.5 Ubuntu image defaults to the `milvus` user (`USER ${MILVUS_RUNTIME_USER}`, default `milvus:milvus`), and the v3.0.5 Compose file adds a one-shot `milvus-volume-init` service, run as `user: "0:0"` with `network_mode: none`, that changes the data directory's owner to that user and must complete before `standalone` starts. The v2.6.26 Ubuntu Dockerfile has no `USER` instruction and copies the configuration with `--chown=root:root`; this guide infers that the v2.6.26 image runs as root unless the deployment selects another user. This guide has not established which user the published images the Compose files select, `milvusdb/milvus:v2.6.25` and `milvusdb/milvus:v3.0.4`, run as, or whether they carry their Dockerfiles' `USER` settings, since neither image's configuration nor its Dockerfile was read. Alternatively, edit a complete `milvus.yaml` from the exact image version and mount it at `/milvus/configs/milvus.yaml:ro`; `user.yaml` still takes precedence. Recreate `standalone` after changing mounts or environment, and restart it after configuration or certificate changes before checking authentication and TLS. Authentication is enabled by setting `common.security.authorizationEnabled: true` in `milvus.yaml` or the mounted `user.yaml` (or through the Helm `extraConfigFiles` / Operator `spec.config` equivalents). Once on, the built-in `root` user exists with the password `Milvus`; change it before exposure, since a documented default is a public credential ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)): ```python client = MilvusClient(uri="https://milvus.example.com:19530", token="root:Milvus") client.update_password(user_name="root", old_password="Milvus", new_password="REPLACE_WITH_LONG_RANDOM_VALUE") ``` Create a per-application user rather than handing `root` to the app. Put both authentication and TLS in the same `user.yaml`, keeping a single `common` mapping: ```yaml tls: serverPemPath: /milvus/tls/server.pem serverKeyPath: /milvus/tls/server.key caPemPath: /milvus/tls/ca.pem common: security: authorizationEnabled: true tlsMode: 1 # 1 = server certificate only; 2 = mutual TLS, clients present a certificate too ``` Clients then connect with `secure=True` and the server certificate path. TLS and authentication are independent in Milvus; enable both. The environment alternative at v3.0.5 is `COMMON_SECURITY_AUTHORIZATIONENABLED: "true"`, `COMMON_SECURITY_TLSMODE: "1"`, `TLS_SERVERPEMPATH: /milvus/tls/server.pem`, `TLS_SERVERKEYPATH: /milvus/tls/server.key` and `TLS_CAPEMPATH: /milvus/tls/ca.pem` under `standalone.environment`, retaining the certificate mount. The paramtable formatter lowercases names, strips a leading `milvus.`, then removes `/`, `_` and `.`; it does not strip a `MILVUS_` prefix. Per-key environment comments are not an allowlist. Choose one source for these settings and remove conflicting environment or remote configuration. The v2.6.26 Compose file selects `image: milvusdb/milvus:v2.6.25`, and the v3.0.5 file selects `milvusdb/milvus:v3.0.4` through its `x-milvus-image` anchor, so each selects an image one release older than its own tag; explicitly select the intended image version and recheck its configuration when upgrading. Milvus's internal component servers listen beyond 19530. In v3.0.5, the proxy's internal port (19529), the query node (21123), the data node (21124), the mix coordinator (22125, the `rootCoord` port) and the streaming node (22222) each bind every interface: the listener is given only the port. The v3.0.5 query node, data node and streaming node fall back to a random port, still on every interface, when theirs is taken, and each component's `ip` setting in that release is the address it advertises, not a bind address. Their gRPC interceptors check only routing metadata and pass a call that carries none, so these internal APIs, which include the query node's `Search` and `Query`, check no credentials; `authorizationEnabled` is enforced on the proxy's external client APIs, not on these internal ports. Internal TLS (`common.security.internaltlsEnabled`, off by default) is one-way, as Milvus's TLS documentation says: it encrypts the traffic but does not authenticate the caller. In v2.6.26, whose go.mod pins grpc-go v1.82.1 and Go 1.25.8, a component that cannot load its certificate or key logs a warning and serves plaintext. The v3.0.5 Milvus code for this differs from v2.6.26 only in logging calls, but its go.mod pins grpc-go v1.84.0 and Go 1.26.9, whose code this guide has not read, so the plaintext fallback is not established at v3.0.5. A standalone (or embedded) Milvus runs all five components, so keep every one of these ports reachable only by the Milvus deployment itself. The exposure rule for a Milvus deployment is to publish only 19530 (with one exception for 9091, below), and only as section 1 describes (to loopback, or behind a reverse proxy, tunnel or tailnet), to give Milvus a network of its own rather than attaching other workloads to it, and not to run the container with host networking or share its network namespace with other workloads. The containment step for every Milvus component is to confine its ports with a default-deny network policy (on Kubernetes) or a default-deny host firewall (for a native, embedded or single-host install too) that admits only the traffic you intend, admitting Milvus peers by source (by pod selector in a network policy, by address in a host firewall) rather than by port. Source-based admission means other hosts are refused on the random fallback ports as well as on these port numbers. Neither control reliably stops processes on the same machine: a local process can reach a native install over loopback and a container or pod directly at its own address, and a Kubernetes NetworkPolicy always allows traffic between a pod and the node it runs on. So keep untrusted workloads and users off every host that runs a Milvus component, Kubernetes nodes included. Milvus's standalone `docker-compose.yml` publishes more than Milvus itself: it maps its bundled MinIO object store to the host on `9000` (S3 API) and `9001` (console) on every interface, beside the Milvus ports above, while etcd stays on the internal network. Give those two ports the same treatment as Milvus's own: loopback-map or firewall them off the public mapping, and change MinIO's default login, since authenticating Milvus does nothing for a MinIO published next to it ([minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md)). Rotate the storage credentials on both services together. The v3.0.5 Compose file selects `image: milvusdb/minio:RELEASE.2024-12-18T13-15-44Z` and supplies `MINIO_ACCESS_KEY` and `MINIO_SECRET_KEY`, both `minioadmin`, to **MinIO**. **Milvus** reads `MINIO_ACCESS_KEY_ID` and `MINIO_SECRET_ACCESS_KEY`, mapping to `minio.accessKeyID` and `minio.secretAccessKey`, also defaulting to `minioadmin`. For the bundled login, the access-key values must match across services and the secret-key values must match; changing MinIO alone leaves Milvus using the old credentials. These are the names in this pinned Compose file, not a claim about newer MinIO images' `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` support. This guide has not established whether the `milvusdb/minio` image at that tag reads `MINIO_ACCESS_KEY` and `MINIO_SECRET_KEY`, since its Dockerfile and entrypoint were not read, so after rotating, confirm that MinIO refuses the `minioadmin` login. For a dedicated Milvus application key, use its own matching pair with access to Milvus's storage, following [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md). Keep the values in private, untracked env-files: `./.env.minio` with `MINIO_ACCESS_KEY` and `MINIO_SECRET_KEY`, and `./.env.milvus-storage` with `MINIO_ACCESS_KEY_ID` and `MINIO_SECRET_ACCESS_KEY`. Use `NAME=value` lines, single-quoting values where Compose interpolation would change a literal dollar sign. Make both files owner-only (mode `0600`) in a private directory and exclude them from version control before adding credentials ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Delete MinIO's two existing credential entries from `environment:` in the vendor Compose file, since `environment:` overrides `env_file`, then add this excerpt to the existing services: ```yaml services: minio: env_file: ./.env.minio standalone: env_file: ./.env.milvus-storage ``` Keep `standalone.environment`'s `ETCD_ENDPOINTS`, `MINIO_ADDRESS` and `MINIO_REGION`. Recreate both services after rotating the env-files; a restart alone does not replace container environment values. Env-files keep literal secrets out of Compose YAML, but the values remain in container environment data, visible to Docker administrators through inspection and potentially in diagnostic output. Treat rendered Compose configuration and inspection output as sensitive. Compose secrets require application support for reading mounted files; do not invent `MINIO_SECRET_ACCESS_KEY_FILE` for Milvus ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). In v2.6.26 and v3.0.5 with their default settings, the management/metrics HTTP listener (9091 by default) also serves control routes that check no credentials; `common.security.authorizationEnabled` covers only the proxy's `/api/v1/_*` console routes on the same port, which `proxy.http.enableV1` (default `true`) mounts. The routes named here are representative, not a full list. Every Milvus server process serves `/log/level`, which reads or changes the log level, and, after its components' startup calls return successfully, `/management/stop`, which stops a role running in that process. A process running the proxy adds routes that pause and resume garbage collection, suspend and resume query nodes and balancing, and transfer segments and channels, plus `/management/rootcoord/ez/backup`, which returns a named database's encryption-zone key backup (`ezk`) in its response where a cipher plugin supports it. In v3.0.5, but not v2.6.26, a process running the proxy also adds routes that clear queued reads and commit backfill results from a caller-supplied path. An activated mix coordinator adds its own garbage-collection, balancing, node-suspension and transfer routes, and routes that alter mutable configuration (`/management/config/alter`) and change the WAL backend. A standalone process runs both. With `proxy.http.enablePprof` at its default `true` at startup, the same listener also serves Go's `/debug/pprof/` profiling handlers. Milvus v3.0.5 registers `/debug/pprof/` and its `cmdline`, `profile`, `symbol` and `trace` routes explicitly; v2.6.26 serves `http.DefaultServeMux`, and this guide infers that the profiling handlers reach it through the blank import `_ "net/http/pprof"`, since the Go source is not cited here. In both, with pprof on and no opt-in key set, the listener also serves any other handler on `http.DefaultServeMux`; this guide has not established which linked packages register one. With the default settings, the first request to `/eventlog` that successfully creates it starts a further gRPC listener, with no authentication or TLS, on a random port on every interface of the process's network namespace. Setting `proxy.http.enableWebUI: false` before startup omits the UI but leaves every one of these controls, and setting `proxy.http.enablePprof: false` before startup leaves out the profiling handlers while the control routes are still registered. The listener binds every interface in its network namespace, with no bind-address flag, config key or environment variable. `METRICS_PORT` changes only its port: a value `strconv.Atoi` rejects (unset, empty, non-numeric, or an integer too large for it) falls back to 9091, `0` takes a random port, and any other parsed value outside 1 to 65535 makes the listen fail, which Milvus logs without stopping. The v2.6.26 and v3.0.5 standalone Compose files publish `9091:9091`, which binds every host interface under Docker's default publishing settings (the files select the v2.6.25 and v3.0.4 images respectively). Delete that mapping: the in-container `http://localhost:9091/healthz` probe does not need it, and the rule above is to publish only 19530. If an administrator on the Docker host needs the listener, the one exception is a loopback-only mapping, `127.0.0.1:9091:9091`, firewalled from everyone else. If you choose another fixed `METRICS_PORT`, set it under the `standalone` service's `environment:`, since the Compose files do not pass it through from your shell, and adjust the container port in any mapping and in the healthcheck. Retain the network and host isolation above: loopback publication does not isolate the container from same-network peers or local users; see [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md) for publication and firewall caveats. The 2 releases protect this listener with different opt-in keys, both off by default, and neither key appears in the other release's files this guide read. The v3.0.3 release notes say "Added optional root authentication for management endpoints on the metrics port (`common.security.adminAuthEnabled`, disabled by default)", so this guide reads that key as first shipped in v3.0.3; its `Version: "3.0.0"` field does not record when it shipped. This guide has not established which release first shipped `managementMetricsOnly`. In v2.6.26, `common.security.managementMetricsOnly: true` serves only `GET` and `HEAD` requests for `/metrics`, `/metrics_default`, `/healthz`, `/livez` and `/management/check/ready`, without authentication, and answers 404 to every other request. Its documentation says "All other management, WebUI, profiling, eventlog and legacy /api/v1 endpoints return 404", and the probes then return only their status code and that code's standard text. The source reads the key once, when the first route is registered, and `/management/config/alter` refuses it, so set it on every Milvus process and restart. Because `/eventlog` is not registered in that mode, this guide infers that its gRPC listener cannot then be started over HTTP. In v3.0.5, `common.security.adminAuthEnabled: true` requires HTTP Basic authentication as the `root` user for `/management/*` except `/management/check/ready`, and for `/log/level`, `/eventlog`, `/debug/pprof/*`, the WebUI, `/telemetry` and the `/api/v1/_*` console routes, while `/healthz`, `/livez`, `/metrics` and `/metrics_default` stay open. With the key on, the listener no longer consults `http.DefaultServeMux`, and the event-log gRPC listener binds `127.0.0.1` instead of every interface. The gate checks the request's context before it reads any credentials, and answers 403 at that step whatever credentials the request carries. It answers 403 to an `Origin` that is `null` or malformed, to a `Sec-Fetch-Site` of `cross-site` or `same-site` (except a top-level `GET` navigation to the WebUI or `/telemetry`) or of an unrecognized value, and, with no `Sec-Fetch-Site`, to an `Origin` naming another host. With neither header, it answers 403 unless the request sends `X-Milvus-Admin-Request: true`, wherever the request carries an `Authorization` header and, on the WebUI, `/telemetry` and the `/api/v1/_*` console routes, even without one, so a scripted client must send that header. Only then does it read credentials: it answers 401 to missing credentials or an empty user name or password before it checks for `root`, so a non-root user name with an empty password also gets 401. It answers 401 to a wrong `root` password, 403 to any other user name, whose password it does not check, and 503 when it cannot check the credential. The source reads the key on each request and re-applies the event-log binding when the key changes, and `/management/config/alter` refuses the key. Its documentation says "This port remains plaintext", so this guide infers that the `root` password crosses the network unencrypted unless a TLS proxy fronts the port. This guide has not established whether the gate can verify `root` while `authorizationEnabled` is off, which proxy code registers the credential verifier it uses, which code revokes a Proxy's cached `root` hash on a credential notification, or whether other code calls its privilege and cross-site checks. As this guide's reading of the source, to keep the management routes off the network: on v2.6.26, set `common.security.managementMetricsOnly: true` in `user.yaml` and restart every Milvus process. On v3.0.5, which has no metrics-only mode, turn authentication on and change `root`'s password as above, then set `common.security.adminAuthEnabled: true`. The key's documentation says "Root hashes are cached for 10 seconds; a failed refresh may reuse the last hash for up to 10 minutes after its last fetch, including a password rotated during an outage", and the v3.0.5 verifier compares a password with that last hash when a refresh fails. This guide infers that after you change `root`'s password, a node that has not received the credential notification can accept the old one for up to 10 seconds, or, while refreshes fail, for up to 10 minutes after its last fetch. On both, delete the `9091:9091` publication as above, because metrics and probes stay unauthenticated in either mode and the v3.0.5 gate's credentials cross plaintext HTTP. REASONED: the Service template in Milvus Helm chart 5.0.30 and the Service code in Milvus Operator v1.3.11 place management/metrics 9091 on the client gRPC Service (19530 by default), selecting standalone pods or cluster proxy pods. Both declare a `ClusterIP` default; the Service's port list carries both ports whatever its type, including `LoadBalancer` or `NodePort`, unless Helm's metrics port is disabled. With `LoadBalancer`, the chart's default `loadBalancerSourceRanges` is `0.0.0.0/0`. This does not establish internet reachability or mean a NodePort uses node port 9091. Keep the generated Service at `ClusterIP` and retain the network and host isolation above. In the chart's Service template, `metrics.enabled: false` removes 9091 from that Service while leaving the standalone and proxy container port declarations and their TCP liveness and HTTP `/healthz` readiness probes on 9091, so it is not a listener shutdown control. The Operator's Service code at v1.3.11 always adds 9091 and has no field to remove or rename it; `disableMetric` skips PodMonitor reconciliation, not the Service port. Chart 5.0.30 declares `appVersion: "3.0.1"`, so the chart claim concerns its Service wiring, not a verification of Milvus 3.0.1's control routes or compatibility with the v2.6.26 and v3.0.5 sources audited above. This review covers only the cited files: other chart templates (Ingress, ServiceMonitor and other Services), the Operator's `MergeServicePort` implementation and its generated CRD were not checked. ## 5. Chroma: no native authentication since 1.0 Chroma's migration notes for v1.0.0 state that "Chroma no longer provides built-in authentication implementations". In Chroma 1.5.9, `chroma run --path /db_path` (port 8000) starts with the unit authenticator, whose authentication and authorization methods return the default identity when the server calls them, and the older `CHROMA_SERVER_AUTHN_PROVIDER` / `CHROMA_SERVER_AUTHN_CREDENTIALS` variables from the 2024 auth overhaul no longer do anything; do not paste them from old tutorials and assume protection. Keep Chroma on loopback and expose it only through an authenticated TLS proxy (bearer-token or basic-auth block per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) / [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)), a Cloudflare Tunnel with Access ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)), or a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)). ## 6. pgvector: it is PostgreSQL pgvector is an extension (`CREATE EXTENSION vector;`, PostgreSQL 13 and later), so [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md) applies unchanged: `ssl = on`, `hostssl` lines with `scram-sha-256`, a least-privilege role per application, and `sslmode=verify-full` in every connection string. When several tenants share one embeddings table, add row-level security keyed on the tenant column so a query can only match rows the connected role may see. ## 7. Hosted services and MFA Qdrant Cloud documents API keys for requests; this guide's advice is to treat them as secrets under [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), use one per environment, never commit them, and rotate them on leak. Its account documentation source at commit 61a5b32a lists passwordless email, Google, GitHub and enterprise SSO registration; at the time of writing, it documents Premium-tier SSO for Cloud Console sign-in only, with cluster requests still using Database API keys. The same documentation file at commit be9bbd58 lists OpenID Connect and SAML as supported SSO protocols; to enable SSO, Premium customers open a ticket in the Qdrant support portal. When checked in October 2026, Qdrant Cloud's published account-setup page said enterprise SSO is for Premium Tier customers, covers Cloud Console sign-in only, leaves cluster requests on Database API keys and is turned on through a support-portal ticket. When fetched on 2026-11-04, that page's SSO section says SSO "covers Console sign-in only and is part of the Premium Tier" and refers to the Premium Tier page's Enterprise Single Sign-On section "for the supported identity providers and how to turn it on"; it does not mention Database API keys or a support-portal ticket. That Premium Tier section, fetched the same day, says "SSO covers Cloud Console sign-in only. Applications and users that send requests to a cluster still authenticate with Database API keys." and "To turn on SSO for your account, open a ticket in the Qdrant support portal." At the time of writing, Pinecone documents organization SSO through an organization's identity management solution on Standard and Enterprise plans; for SSO organizations, console sessions are re-authenticated at least every 24 hours. When checked in October 2026, Pinecone's security overview also said that once SSO is configured, an organization can require users from its domain to sign in through SSO. This guide's source assessment: these Pinecone statements do not establish that Pinecone enforces MFA, and the cited Qdrant documentation sources and published account-setup page document no console MFA control; their silence does not establish that no such control exists. This guide's source assessment: the Pinecone organizations overview, security overview and organization-members pages checked in October 2026 do not mention MFA, and a case-insensitive search of the Qdrant documentation source tree at commit be9bbd58 for multi-factor, two-factor, MFA, 2FA, authenticator, TOTP, passkey and WebAuthn found no match. This guide's source assessment: neither the Pinecone page check nor the Qdrant search result establishes that a console MFA control is absent. For Qdrant Cloud, the next paragraph cites the published user-profile page, which documents a console MFA control. When fetched on 2026-11-04, Qdrant Cloud's published user-profile page, which the account-setup page says documents user-level options, documents a console MFA control: "Use the Security page to protect your login with multi-factor authentication (MFA)." It says "Like the rest of your profile, MFA is a personal setting. It applies to your user wherever you sign in, across every account you belong to." It also says "MFA is optional. Until you set up a method, your login works the same as before. Once you enable a method, Qdrant Cloud asks you to complete it every time you log in." It says Qdrant Cloud "currently supports one MFA method: an authenticator app that generates one-time codes", and that after setup Qdrant Cloud shows a recovery code, which "is shown only once", for logging in after losing access to the app. This guide treats this as documentation of an optional, per-user console MFA control, not observed console behaviour. This guide's source assessment: the user-profile, account-setup and Premium Tier pages fetched that day document no account-level MFA enforcement, and the user-profile page does not mention SSO; that silence does not establish that Qdrant Cloud has no such enforcement. When checked in October 2026, Pinecone's Okta SSO page said that during SSO setup you choose whether to Enforce SSO for all users: if enabled, all members of the organization must use SSO to log in to Pinecone, and if disabled, members can choose to log in with SSO or with their Pinecone credentials. Pinecone's login-code page described a login code received by email that is valid for 10 hours. This guide's source assessment: neither page, nor the Okta role-management page, mentions MFA or a second factor or says whether MFA for SSO users is the identity provider's; that silence does not establish that Pinecone has no console MFA control. This guide's assessment: while SSO is not enforced for all users, members can sign in with Pinecone credentials, which an identity provider's MFA would not cover. When read on 2026-11-04, Pinecone's documentation indexes for Pinecone Database, its Guides and Reference sections (with the 2026-07 (latest) and 2026-04 Reference indexes), Pinecone Assistant and its Reference section, and its Okta SCIM provisioning page, contain none of the terms multi-factor, two-factor, MFA or 2FA. The SCIM page says SCIM provisioning "is available on the Enterprise plan and builds on SAML SSO", and tells readers to configure SSO with Okta and enforce SSO for the organization before they begin. This guide's source assessment: those pages document no Pinecone console MFA control or MFA enforcement, and because an index lists page titles and descriptions, not page content, that silence does not establish that Pinecone has neither. For Zilliz Cloud, which this guide treats as the hosted Milvus service, the published MFA page says at the time of writing that built-in MFA uses a TOTP authenticator app and is available only to accounts registered with an email address and password. For SSO organizations it says the identity provider manages MFA, and accounts linked to Google or GitHub use those providers' MFA. It also says Organization Owners can enforce MFA for all organization users, which needs a valid payment method, an Enterprise project and a Dedicated cluster; users who do not complete MFA setup cannot access the organization. This guide treats this as documentation, not observed console behaviour; it does not establish whether a deployed SSO identity provider enforces MFA. When checked in October 2026, the page also said that with enforcement on, all users in the organization are required to set up MFA to sign in and users who have not yet enabled it are prompted at their next login, and, answering how MFA is handled for SSO accounts, that in SSO organizations the identity provider manages MFA, not Zilliz Cloud. This guide's source assessment: the page does not say how organization-level enforcement applies to SSO users or to accounts linked to Google or GitHub. When checked in October 2026, Zilliz Cloud's SSO enforcement page told organizations that require MFA to configure it in their IdP and said "Any MFA enabled for your organization on Zilliz Cloud is automatically disabled when you enable SSO enforcement". When fetched on 2026-10-25, its opening paragraph also said that configuring SSO alone does not require sign-in through the IdP, because members "can still log in to Zilliz Cloud with email/password or third-party accounts such as Google and GitHub". The same page said that under SSO enforcement non-exempt members must use SSO, while its default Allow Owner exemption policy lets Organization Owners use email/password or third-party accounts, subject to their other organizations' policies. Zilliz Cloud's email-accounts page said switching from password to Google or GitHub login requires MFA to be disabled first, and the MFA page said that with organization MFA enforcement enabled, members cannot disable MFA for their own accounts. This guide's inference from those two statements: under MFA enforcement a password user who has set up MFA cannot complete that switch; neither page says so. Zilliz Cloud's registration page, fetched 2026-10-29, says to register and log in with one of a work email and password, a Google account or a GitHub account, and, if sign-up fails because of Google-managed or GitHub-managed MFA, to disable that MFA "temporarily and try again"; the registration page gives no step to turn that MFA back on. This guide's source assessment: these pages still do not say how organization-level MFA enforcement applies to SSO users in organizations that configure SSO without enforcing it, to Owners exempt from SSO enforcement, or to accounts already linked to Google or GitHub, nor whether the quoted sentence covers enforcement, members' own MFA or both. Nor do Zilliz Cloud's SSO overview and Okta (SAML 2.0) pages, fetched 2026-10-25. Nor does its Okta (OIDC) page, fetched 2026-11-04, whose body does not mention MFA; its optional SSO enforcement task says that when enabled, "members can no longer sign in using email/password or third-party accounts (Google, GitHub)". This guide's assessment: an exempt Owner's email/password sign-in does not pass through the SSO identity provider, so that provider's MFA does not cover it, and these pages do not say whether Zilliz Cloud MFA still applies to it. This guide's assessment: while SSO is configured but not enforced, a member's email/password, Google or GitHub sign-in likewise does not pass through the SSO identity provider, so that provider's MFA does not cover it, and a linked account whose Google or GitHub MFA was disabled to complete sign-up or linking has no second factor from that provider until it is turned back on. At the time of writing, Weaviate Cloud's documentation source says any member can enable MFA for their own account (off by default), and only organization Owners can control enforcement for all members (available to every organization, off by default). Its MFA documentation source says SSO setup turns MFA enforcement off and SSO sign-ins never ask for a Weaviate Cloud MFA code. The source also says that after a member's first SSO sign-in, SSO applies even when they open an MFA-enforcing organization, with the identity provider's own second factor applying instead. If SSO is later turned off, MFA enforcement stays off until an Owner turns it on again. This guide advises confirming MFA in the SSO identity provider. At the time of writing, Weaviate Cloud's SSO documentation source specifies SAML, with OIDC unsupported, for Managed organizations on a Weaviate-managed contract; only Owners can see and change SSO, which controls console sign-in and does not affect cluster API keys. It says that after a person's first SSO sign-in, email/password and Google sign-in stop working for that account across every organization the person belongs to. At the time of writing, Weaviate Cloud's published MFA and SSO pages say only Owners can see and change MFA enforcement, which is available to every organization and off by default. They say completing SSO setup turns MFA enforcement off, SSO sign-ins never ask for a Weaviate Cloud MFA code, and enforcement stays off after SSO is turned off; the MFA page recommends enforcing MFA in the identity provider. This guide treats these pages as documentation, not observed console behaviour; they do not establish publication or release timing. When checked in October 2026, Weaviate Cloud's published MFA page named the enforcement controls: in Organization settings, the `Users and Members` tab's `MFA enforcement` section has a `Require MFA for all members` switch, turned on through an "Enforce multi-factor authentication?" dialog and off through a "Stop enforcing multi-factor authentication?" dialog. This guide's source assessment: the pinned source's note that these labels need live-console checks is a source comment that the published page does not display, so the page does not resolve it; this guide treats the labels as documentation, not verified console labels. When fetched on 2026-11-04, the published MFA page still says each thing this paragraph and the one before it cite from the published pages and still names those labels and dialogs; it displays no update date and does not display the source's live-console note. The pinned Weaviate and Qdrant citations are documentation source files; this guide's source assessment is that they do not by themselves establish published-page content, publication or release timing, or live-console behaviour. The Weaviate MFA source's author flags its enforcement UI labels, dialog titles, tooltips and numbers as needing live-console checks; this guide does not treat them as verified. From source reading of the line history at the pinned commits (GitHub blame views), every cited Weaviate Cloud MFA and SSO source line except the MFA page's opening lines (L8-L9, last changed in 2025) was last changed in commit 9fbb108b, dated 2026-10-13, and the Qdrant sentences that open and close the cited SSO passage (L94 and L106) were last changed in commit 6efd6b1f, also dated 2026-10-13. This guide's source assessment: commit dates show when the text was committed, not when it was published or released. This guide's assessment: because Weaviate Cloud's documented SSO flow turns off Cloud MFA enforcement and omits Cloud MFA prompts, MFA with SSO depends on the identity provider. This guide's source assessment: these sources do not establish whether any deployed SSO identity provider enforces MFA. This guide's advice: check each vendor's account security settings and require MFA for human console access; where SSO is used, require an MFA-enforcing identity provider and confirm enforcement. This guide's assessment: the native API-key configurations described here do not establish human MFA. For the self-hosted deployments described here, this guide's advice is to delegate human authentication through self-hosted Weaviate's database OIDC settings (section 3) to an MFA-enforcing identity provider, or use an MFA-capable fronting layer (Access policy or Authelia-style portal) ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)); this is not Weaviate Cloud console SSO and does not establish a hosted-console control. ## Verify REASONED: following block; listener inventory, including container namespaces and publications. A live Qdrant stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No distributed Qdrant cluster or container publication inventory was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. The sudo stub printed `SUDO <-tulnp>` and exited 0; no real sudo or listener inventory ran. Those runs covered an earlier form of the block that ended with the inventory command's own status; the current form, which exits 2 when the `command -v ss` check finds no `ss`, 3 when the inventory command fails and otherwise 0, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Per the guide's cited sudo sudoers manual, secure_path entry, where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. Expected exposed listeners include wildcard/public backend addresses; fixed: no 6333/6334/6335 row on a wildcard or public address in the host table or the container namespace, and no wildcard publication. The exposed and fixed expectations follow the guide's cited Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity); Docker port publication: default host binding, explicit loopback mapping, same-network and host access, direct-routing and older-engine caveats entries. Inventory every listener without grep, because filtering for the ports you expect hides the listener you did not (a grep for `6333` also matches a port `63330` or a PID). Read the whole table: ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if sudo ss -tulnp; then # read every row; any 6333/6334/6335 on a wildcard # (0.0.0.0, *, [::]) or public address fails bind-privately. # In a container, also run it in the container netns and check # published ports; the host table alone does not show -p 6335:6335. echo "block exit 0; every row above is unclassified, so judge each bind address yourself, then the container namespace and published ports"; exit 0 else echo "sudo ss -tulnp exit $?: the listener inventory failed; inconclusive"; exit 3 fi ) ``` ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HOST_ADDRESS' 'REPLACE_WITH_PORT' 'REPLACE_WITH_outside_OR_allowed' # the third value is outside for a vantage that must not connect or allowed for one that must [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "substitute the values on the set -- line above; not probing"; exit 2; fi case "$1:$2" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute the values on the set -- line above; not probing"; exit 2 ;; esac case "$3" in outside|allowed) ;; *) echo "substitute outside (a vantage that must not connect) or allowed (a vantage that must connect) as the third value on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{http_code} %{time_total}' "http://$1:$2/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then its output set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # the output as curl printed it, then the HTTP code and elapsed time printf '%s\n' "$5" set -- "$1" "$2" "$3" "$4" "${6%% *}" "${6#* }" set -- "$1" "$2" "$3" "$4" "$5" "${6%%.*}" # whole seconds elapsed case "$4:$5" in 0:*|*:[123456789]??|52:*|56:*) set -- "$1" "$2" "$3" "$4" "$5" "$6" completed ;; 7:*) set -- "$1" "$2" "$3" "$4" "$5" "$6" refused ;; 28:*) case "$6" in ""|*[!0123456789]*) set -- "$1" "$2" "$3" "$4" "$5" "$6" unknown ;; *) if [ "$6" -lt 10 ]; then set -- "$1" "$2" "$3" "$4" "$5" "$6" refused; else set -- "$1" "$2" "$3" "$4" "$5" "$6" completed; fi ;; esac ;; 3:*|6:*) set -- "$1" "$2" "$3" "$4" "$5" "$6" input ;; *) set -- "$1" "$2" "$3" "$4" "$5" "$6" unknown ;; esac case "$3:$7" in outside:completed) echo "FINDING: curl exit $4 with http=$5: the connection to port $2 completed from a vantage that must not reach it"; exit 1 ;; outside:refused) echo "block exit 0; curl exit $4 after $6 seconds: the connection did not complete, as expected from this vantage, but that is not proof on its own, so corroborate it with the inventory and the filtering point's deny evidence"; exit 0 ;; allowed:completed) echo "block exit 0; curl exit $4 with http=$5: the connection completed, so the positive control passed"; exit 0 ;; allowed:refused) echo "curl exit $4 after $6 seconds: the connection did not complete from a vantage that must reach it, so the positive control failed; inconclusive"; exit 3 ;; *:input) echo "curl exit $4: a mistyped address or a malformed target, not a result; fix the value on the set -- line and re-run; inconclusive"; exit 3 ;; *) echo "curl exit $4 is not an outcome this block classifies; read the err= text above; inconclusive"; exit 3 ;; esac ) ``` REASONED: preceding block; non-peer probes, address substitution and curl reporting, the guarded backend probe and interpretation of connection outcomes. A live Qdrant stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No distributed cluster, outside-peer vantage or firewall evidence was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. Under Bash `-u`, placeholders, empty values and a tab in the host printed `substitute the values on the set -- line above; not probing`; one value printed `the set -- line needs exactly 2 values; not probing`; omitting `set --` printed `paste the whole block, including its set -- line; not probing`. These cases exited 0 without calling the curl stub; a valid fixture printed its curl arguments and exited 0. The bash -n, ShellCheck and stub runs recorded here covered the backend-port block in an earlier form, whose guard refusals left the subshell with status 0 and whose `set --` line took two values; the current form, which also takes the vantage and classifies the connection outcome, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. No curl exit classification was observed on a live connection. Expected exposed backend ports accept non-peer connections; fixed ports deny or filter non-peers while 6335 remains reachable by peers, with inventory and firewall corroboration. The exposed and fixed expectations follow the guide's cited Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity); curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0; rolling documentation, checked October 2026) entries. Probe each Qdrant backend port with the block above from a host **outside** the peer allowlist, against the node's real address, running it three times with `6333`, `6334`, then `6335` on the `set --` line, each time with `outside` as its third value. **These probes are reasoned, not demonstrated** (no distributed Qdrant cluster in the authoring environment; expected outcomes follow the cited Qdrant documentation). Bracket an IPv6 literal, for example `'[2001:db8::1]'`; `-g` keeps curl from globbing the brackets. The block prints the `exitcode` and `errormsg` write-out variables, which need curl 7.75.0 or newer. Read the exit code and the elapsed time together (curl's exit strings vary by version, so key on the code, not the message). A non-peer probe of a restricted 6335 should FAIL TO CONNECT: curl reports `exit=7` (the TCP connection did not complete, whether refused or reset), or `exit=28` at about 5 seconds (the connect timeout, consistent with a firewall that drops the packet). Neither proves the port is closed on its own, so corroborate with the inventory and the filtering point's own deny evidence. Two other exits are your own input error, not a result: `exit=6` (could not resolve host) means you mistyped the address, and `exit=3` (a malformed URL) means the target string is wrong; fix the value and re-run. Any sign the connection COMPLETED is a reachability fail for a backend port from a non-peer: a non-zero `http=` status, `exit=52` (empty reply) or `exit=56` (receive failure), or `exit=28` at about 20 seconds (the max-time, the connection opened and then stalled). Do not judge on `http=` alone: curl prints `http=000` both when nothing connected and when a connection completed with no HTTP response, so pair it with the exit code, since a `52` or `56` alongside `000` is still a completed connection and a fail. A key check or a TLS rejection after the connection opens does not un-expose the port. Plaintext `http://` is deliberate: the question is only whether the TCP connection completed, and a plaintext probe that reaches a TLS-only peer port still completes the TCP connection and surfaces as one of the reachable signals above. The block applies these rules for the vantage its `set --` line names: from `outside`, a completed connection exits 1 and a failure to connect exits 0 with a reminder to corroborate it; from `allowed`, a completed connection exits 0 and a failure to connect exits 3; a mistyped target and any exit these rules do not name exit 3. REASONED: peer and non-peer comparison. A live Qdrant stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No distributed cluster, peer/non-peer hosts or filtering evidence was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. Under Bash `-u`, placeholders, empty values and a tab in the host printed `substitute the values on the set -- line above; not probing`; one value printed `the set -- line needs exactly 2 values; not probing`; omitting `set --` printed `paste the whole block, including its set -- line; not probing`. These cases exited 0 without calling the curl stub; a valid fixture printed its curl arguments and exited 0. The bash -n, ShellCheck and stub runs recorded here covered the backend-port block in an earlier form, whose guard refusals left the subshell with status 0 and whose `set --` line took two values; the current form, which also takes the vantage and classifies the connection outcome, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Expected exposed backend ports accept non-peer connections; fixed ports deny or filter non-peers while 6335 remains reachable by peers, with inventory and firewall corroboration. The exposed and fixed expectations follow the guide's cited Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity); curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0; rolling documentation, checked October 2026) entries. For 6335 specifically, in distributed mode, require two results. From a non-peer: a failure to connect, `exit=7` or a 5-second `exit=28`, corroborated by the inventory and firewall evidence (a `exit=6` or `exit=3` is a mistyped target, not a result). From an allowed peer, with `allowed` as the block's third value: POSITIVE evidence that the connection completed, a non-zero `http=` status (not `http=000`, which curl prints when nothing was received), or a post-connect exit (`52`, `56`, or the 20-second `exit=28`), not merely the absence of a refusal, because a mistyped target also avoids `exit=7`. The pair separates "restricted to peers, cluster works" from both "exposed to everyone" and "blocked for everyone". 6335 absent from the inventory is consistent with single-node but does not prove it can never appear, so probe it anyway. REASONED: frontend key check and its inability to establish peer isolation. A live Qdrant stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No TLS frontend, distributed cluster, outside-peer vantage or valid Qdrant API key was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. Under Bash `-u`, placeholders, empty values and a tab in the host printed `substitute the values on the set -- line above; not probing`; one value printed `the set -- line needs exactly 2 values; not probing`; omitting `set --` printed `paste the whole block, including its set -- line; not probing`. These cases exited 0 without calling the curl stub; a valid fixture printed its curl arguments and exited 0. The valid key fixture printed two curl calls, with the keyed call using `-H @-`; these are argument observations only. The bash -n, ShellCheck and stub runs recorded here covered the frontend key block in an earlier form, whose guard refusals left the subshell with status 0; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The current form exits 1 when the request without a key answers 200, 0 when that request answers 401 or 403 and the keyed request answers 200, and 3 for any other result. Expected exposed anonymous access returns 200; fixed access returns 401/403 without a key and 200 with a valid key, while either state can leave 6335 exposed. The exposed and fixed expectations follow the guide's cited Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity); curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0; rolling documentation, checked October 2026) entries. Keep the existing client-API key check, hardened, against the frontend hostname over TLS (never a backend IP, never `curl -k`, which a gate rejects). It confirms the key is enforced, but it **cannot** distinguish a keyed single-node deployment from a keyed distributed one with 6335 exposed, because both answer 401 then 200; only the port probes above catch an exposed 6335: REASONED: following block; frontend key comparison, and this paragraph's combined verdict, whose backend peer restriction rests on the backend-port block above; client-key outcomes follow the cited Qdrant security documentation, and no live frontend outcome is recorded in this guide. A live Qdrant stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No TLS frontend, distributed cluster, peer/non-peer vantage or valid Qdrant API key was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. Under Bash `-u`, placeholders, empty values and a tab in the host printed `substitute the values on the set -- line above; not probing`; one value printed `the set -- line needs exactly 2 values; not probing`; omitting `set --` printed `paste the whole block, including its set -- line; not probing`. These cases exited 0 without calling the curl stub; a valid fixture printed its curl arguments and exited 0. The valid frontend fixture printed two curl calls, the keyed one with `-H @-`. The bash -n, ShellCheck and stub runs recorded here covered the frontend key block in an earlier form, whose guard refusals left the subshell with status 0; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The next two expected-outcome sentences follow the guide's cited Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity); curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0; rolling documentation, checked October 2026) entries. Expected exposed anonymous access returns 200; fixed access returns 401/403 without a key and 200 with a valid key. Fixed 6335 denies or filters non-peers while accepting peers. **Exposed:** without-key returns `http=200` (no key, or a proxy that does not enforce it), or a backend port is reachable from a non-peer. **Fixed:** without-key returns `401`/`403` and with-key returns `200`, and 6335 is refused from a non-peer while a peer connects. ```bash ( # a subshell, so your own script arguments are untouched trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The API key (REPLACE_WITH_LONG_RANDOM_VALUE) on the set -- line enters shell # history. Use a short-lived key or clear that history line afterward. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_FRONTEND_HOSTNAME' 'REPLACE_WITH_LONG_RANDOM_VALUE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "substitute the values on the set -- line above; not probing"; exit 2; fi case "$1:$2" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute the values on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 echo "without key (expect http=401 or 403):" set -- "$1" "$2" "$3" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1/collections"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then its http= line printf '%s\n' "$5" case "$4:$5" in 0:'http=401 '*|0:'http=403 '*) ;; 0:'http=200 '*) echo "FINDING: the request without a key answered http=200, so the frontend does not enforce the key"; set -- "$1" "$2" 1 ;; 0:*) echo "${5%% *} without a key is neither the 401/403 pass nor the http=200 finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request without a key failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" echo "with key (expect http=200):" set -- "$1" "$2" "$3" "$(if printf 'api-key: %s\n' "$2" | curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -H @- \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1/collections"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then its http= line printf '%s\n' "$5" case "$4:$5" in 0:'http=200 '*) ;; 0:*) echo "${5%% *} with the key is not the expected http=200, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request with the key failed, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac echo "block exit $3; this check cannot show whether backend port 6335 is reachable, which the backend-port block above decides" exit "$3" ) ``` REASONED: following block; the Weaviate and Chroma fronted checks, of which the Weaviate check follows the cited Weaviate authentication documentation (anonymous access, API key, OIDC variables); no live outcome for the Weaviate frontend is recorded. A live Weaviate or Chroma stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No authenticated TLS frontends or authorized identities were available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. The curl stub printed three argument lists and exited 0, including the unchanged placeholder header-file argument; this does not validate a real header file. Those runs covered the block in an earlier form that named weaviate.example.com and chroma.example.com in one fence. The Weaviate and Chroma checks are now separate fences whose guarded `set --` lines take their hosts and refuse example.com, example.net, example.org and their subdomains, and the Weaviate line also takes the header file path, which it refuses unless readable. Their forms before this change, which lowercased the host with `tr` before refusing a reserved name, are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of them is recorded here. Their current forms refuse a reserved name under bash's `nocasematch` option instead of through `tr`, and each refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash and busybox sh. They are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs on 2026-11-02 with no network. In those runs, bash 5.3.9 refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no request in both fences, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. In those runs a host name outside the reserved names reached both requests of the Weaviate fence, given a readable header file, and the request of the Chroma fence. Under dash and busybox sh, in both fences each host name those runs tried that passed the host-syntax check, reserved or not, was refused with 2 and the bash message before any request, and the shipped placeholders were refused with 2 and the host-syntax message. None of the 2026-11-02 runs used zsh, ksh or mksh. Each exits 1 when its anonymous request answers a 2xx; the Weaviate fence exits 0 when that request answers 401 or 403 and the header-file request answers 200, the Chroma fence exits 0 on 401 or 403, and any other result exits 3. Expected exposed Weaviate or unprotected Chroma accepts anonymous requests; fixed frontends return 401/403 anonymously and succeed for authorized users, per the guide's cited Weaviate authentication (anonymous access, API key, OIDC variables); Chroma migration notes (v1.0.0 removal of built-in authentication; 2024 auth overhaul variables) entries. For Weaviate and Chroma, the fronted client checks still apply: ```bash # --noproxy '*' so a forward proxy's CONNECT "200 Connection established" cannot masquerade as the # service status; -w prints the real code and exit, and -g keeps curl from globbing a substitution. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_WEAVIATE_HOST' 'REPLACE_WITH_AUTH_HEADER_FILE' # the public host name your proxy serves Weaviate on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the path of a file holding a valid Authorization header line: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*[[:cntrl:]]*) echo "substitute the path of the file holding your Authorization header line inside the quotes on the set -- line above; not probing"; exit 2 ;; esac [ -r "$2" ] || { echo "the header file on the set -- line is not readable; not probing"; exit 2; } set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1/v1/schema"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then its http= line printf '%s\n' "$5" # without a key: expect http=401 (Weaviate) or the fronting proxy's 401/403 case "$4:$5" in 0:'http=401 '*|0:'http=403 '*) ;; 0:'http=2'??' '*) echo "FINDING: ${5%% *} without a key, so anonymous access is open"; set -- "$1" "$2" 1 ;; 0:*) echo "${5%% *} without a key is neither the 401/403 pass nor a 2xx finding; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request without a key failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" # Positive control: a 401/403 above proves denial, not that authorized access works, so confirm an # authorized request succeeds (and satisfy any auth the fronting proxy also requires). set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ -H @"$2" "https://$1/v1/schema"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # curl's exit status, then its http= line printf '%s\n' "$5" # with a valid key (Authorization header read from a file): expect http=200 case "$4:$5" in 0:'http=200 '*) ;; 0:*) echo "${5%% *} with the header file is not the expected http=200, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "curl exit $4: the request with the header file failed, so the positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac echo "block exit $3; any further authentication the fronting proxy requires is not classified, so check it yourself" exit "$3" ) ``` ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_CHROMA_HOST' # the public host name your proxy serves Chroma on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # curl's exit status, then its http= line printf '%s\n' "$3" # Chroma has no native auth: expect the proxy's 401/403, never a Chroma http=200 # Positive control: Chroma has no native auth, so verify authorized access through the fronting proxy instead. case "$2:$3" in 0:'http=401 '*|0:'http=403 '*) echo "block exit 0; the proxy refused the anonymous request, and authorized access through the proxy, the positive control, is not part of this block, so confirm it yourself"; exit 0 ;; 0:'http=2'??' '*) echo "FINDING: ${3%% *} without credentials, so the proxy did not refuse the anonymous request"; exit 1 ;; 0:*) echo "${3%% *} without credentials is neither the proxy's 401/403 nor a 2xx finding; inconclusive"; exit 3 ;; *) echo "curl exit $2: the anonymous request failed; inconclusive"; exit 3 ;; esac ) ``` REASONED: preceding block; the Chroma fronted check, the Weaviate check before it and this Milvus client comparison. The Chroma check follows the cited Chroma migration notes, under which Chroma has had no native authentication since 1.0, so the fronting proxy must refuse the anonymous request; no live outcome for this frontend is recorded. The Chroma block has no positive control of its own, so confirm authorized access through the proxy yourself. A live Weaviate, Chroma or Milvus stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No authenticated TLS frontends, Milvus deployment or authorized identities were available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. The curl stub printed three argument lists and exited 0, including the placeholder header-file argument. Those bash -n, ShellCheck and stub runs covered the earlier form of the Weaviate and Chroma block, before its requests took guarded hosts and before it became two fences. The two fences' forms before this change, which lowercased the host with `tr` before refusing a reserved name, are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of them is recorded here; their current forms are covered by those passes and by the 2026-11-02 curl-stub runs recorded before the Weaviate block. Python AST parsing of `MilvusClient(uri=...)` passed; no SDK behavior ran. Expected exposed Weaviate or unprotected Chroma accepts anonymous requests; fixed frontends return 401/403 anonymously and succeed for authorized users, per the guide's cited Weaviate authentication (anonymous access, API key, OIDC variables); Chroma migration notes (v1.0.0 removal of built-in authentication; 2024 auth overhaul variables) entries. Expected exposed Milvus accepts an unauthenticated client; fixed Milvus rejects it while accepting the application credentials, per the guide's cited Milvus authentication (`common.security.authorizationEnabled`, default `root`/`Milvus`, `update_password`) entries. For Milvus, a `MilvusClient(uri=...)` call with no `token` must fail once `authorizationEnabled` is on, and the same call with the application user's credentials must succeed. REASONED: Milvus management and event-log reachability, container inventory and health controls. A live Milvus stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No Milvus deployment, container runtime, outside-administrator host or container namespace access was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output. Under Bash `-u`, placeholders, empty values and a tab in the host printed `substitute the values on the set -- line above; not probing`; one value printed `the set -- line needs exactly 2 values; not probing`; omitting `set --` printed `paste the whole block, including its set -- line; not probing`. These cases exited 0 without calling the curl stub; a valid fixture printed its curl arguments and exited 0. The bash -n, ShellCheck and curl stub runs recorded here covered the backend-port block in an earlier form, whose guard refusals left the subshell with status 0 and whose `set --` line took two values; the current form, which also takes the vantage and classifies the connection outcome, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The sudo stub printed `SUDO <-tulnp>`; separate docker and ss stubs printed `DOCKER ` and `SS <-tulnp>`, all exiting 0. The sudo stub run covered an earlier form of the listener inventory block; its current form, which exits 2 when the `command -v ss` check finds no `ss`, 3 when the inventory command fails and otherwise 0, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes. Expected exposed management/event-log ports accept outside connections; fixed publication and filtering deny or filter outsiders while the private health and process-owned listener controls succeed. The exposed and fixed expectations follow the guide's cited Milvus management HTTP listener, v3.0.5; Milvus management HTTP listener, v2.6.26 entries. The exposed and fixed expectations also follow the guide's cited Milvus encryption-zone key backup and event-log handler, v3.0.5; Milvus `/healthz` handler, v3.0.5 entries. The exposed and fixed expectations also follow the guide's cited Docker port publication: default host binding, explicit loopback mapping, same-network and host access, direct-routing and older-engine caveats entry. Client authentication does not cover Milvus's management listener (section 4), so probe it separately. Run the backend-port block above with `9091` and `outside` on its `set --` line, from a host outside your administrators' network, against the node's real address, and read it by the rules given for 6335: any sign the connection completed is a fail. Where you kept the loopback-only mapping, run it again on the Docker host against `127.0.0.1` with `allowed`: a completed connection there is the positive control for that mapping. With the mapping deleted, as recommended, the positive control has two parts, both inside the container. `docker compose ps` must show the `standalone` service as `healthy`, which means its in-container `curl -f http://localhost:9091/healthz` got a non-error answer. The `ss -tulnp` inventory, run in the container's network namespace, must also show that port held by the `milvus` process, because `healthy` alone says only that something answered there: another process in the namespace could hold the port. In v2.6.26 and v3.0.5, `/healthz` answers 500 until every component the process expects has registered, but it stops checking a role stopped through `/management/stop`, so this guide infers that `healthy` does not show that every role still runs. The Milvus v2.6.25 release notes list a bug fix: "Fixed an issue where a restarting node reported ready on `/healthz` before all of its components had registered, causing requests routed to it during startup to fail with connection refused" (#53736). A failed bind is only logged and Milvus keeps running, so a refused probe from outside proves nothing on its own. With a different `METRICS_PORT`, read that port in both checks. The `/eventlog` listener has no fixed port, so find any other port the Milvus process holds in the `ss -tulnp` inventory above, run inside the container's network namespace as well, and probe those ports the same way. A port missing from the inventory was only missing when you looked: the listener is created on the first request, so repeat the inventory after anything has called `/eventlog`. These probes are reasoned, not demonstrated (no Milvus deployment or container runtime in the authoring environment); expected outcomes follow the pinned v2.6.26 and v3.0.5 sources cited for the management listener. REASONED: Milvus management opt-in keys. A live Milvus stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available, so no request below was sent. The expected outcomes follow the guide's cited Milvus management HTTP listener, v2.6.26; Milvus management HTTP listener, v3.0.5 entries. From inside the container's network namespace, or through the loopback-only mapping on the Docker host, send a `GET` for `/log/level` on the listener with no credentials. Exposed, on either release without its opt-in key: the log level comes back with status 200. Fixed on v2.6.26 with `managementMetricsOnly: true`: 404, while `/healthz` still answers with a status. Fixed on v3.0.5 with `adminAuthEnabled: true`: 401, and the same request with `root`'s credentials and `X-Milvus-Admin-Request: true` answers 200, which is the positive control. Give curl those credentials on stdin through `--config -`, never in its arguments. REASONED: tenant isolation and bypass positive control. A live PostgreSQL with pgvector stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No live multi-tenant embeddings table, tenant roles or application similarity query was available. This paragraph provides no concrete SQL to parse or execute locally; no tenant result was observed. Expected exposed queries admit other tenants' rows; fixed tenant-B queries filter them while a bypass role sees tenant A's rows, per the guide's cited PostgreSQL row security policies (a superuser and a `BYPASSRLS` role always bypass; the table owner bypasses unless the table has `FORCE ROW LEVEL SECURITY`) entries. None of the checks above tests isolation between tenants. Where one embeddings table serves several users or tenants under the row-level security above, prove the boundary as well as the ports: connect as the role for tenant B, run the similarity search tenant A would run, and confirm only tenant B's rows come back. For the positive control, run the same query as a role that bypasses the policy (a superuser or a role with `BYPASSRLS`; the table owner also bypasses unless the table has `FORCE ROW LEVEL SECURITY`) and confirm tenant A's rows now appear. Count rows per tenant, or drop the `LIMIT`, rather than trusting the top-k result: the nearest neighbours of tenant A's query vector can legitimately all be tenant A's rows even with the policy off, so an empty other-tenant result there does not by itself prove the policy is filtering. A query that returns another tenant's vectors under tenant B's own role is a live cross-user leak, whatever the network posture. This isolation check is reasoned, not demonstrated: the authoring environment has no live multi-tenant pgvector table to run it against; expected outcomes follow the cited PostgreSQL row-security documentation. ## Sources (checked October 2026) The October 2026 date records the guide-wide documentation check. Items, or parts of items, pinned to a release or commit carry no check date unless the pinned part itself is marked "checked October 2026", and some of those items were first cited in October 2026. - Milvus Compose configuration and credentials (pinned tag v3.0.5, checked November 2026): image paths, the image's runtime user (`USER ${MILVUS_RUNTIME_USER}`, default `milvus:milvus`), the one-shot `milvus-volume-init` service, file precedence and `MILVUSCONF`, environment formatter, authentication/TLS keys, the bundled MinIO service and storage defaults. Milvus Compose configuration and credentials sources (pinned tag v3.0.5): https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L47-L58, https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L6-L28, https://github.com/milvus-io/milvus/blob/v3.0.5/build/docker/milvus/ubuntu22.04/Dockerfile#L39. Milvus Compose configuration and credentials sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/build/docker/milvus/ubuntu22.04/Dockerfile#L58-L59, https://github.com/milvus-io/milvus/blob/v3.0.5/build/docker/milvus/ubuntu22.04/Dockerfile#L16, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/base_table.go#L69-L72. Milvus Compose configuration and credentials sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/base_table.go#L145-L153, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/base_table.go#L256-L272, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/component_param.go#L1056-L1061. Milvus Compose configuration and credentials sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/grpc_param.go#L144-L175 and https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/service_param.go#L1902-L1930 - Milvus Compose and image at v2.6.26 (pinned tag v2.6.26, checked November 2026): the standalone service with `image: milvusdb/milvus:v2.6.25`, and the Ubuntu Dockerfile, which has no `USER` instruction and copies the configuration with `--chown=root:root`. The bundled MinIO service, the same as v3.0.5's, is cited above at v3.0.5 only. Milvus Compose and image sources (pinned tag v2.6.26): https://github.com/milvus-io/milvus/blob/v2.6.26/deployments/docker/standalone/docker-compose.yml#L39-L66 and https://github.com/milvus-io/milvus/blob/v2.6.26/build/docker/milvus/ubuntu22.04/Dockerfile - Docker Compose env-files and environment precedence, read-only mounts, and secrets as files requiring image support: https://docs.docker.com/reference/compose-file/services/#env_file, https://docs.docker.com/reference/compose-file/services/#volumes and https://docs.docker.com/compose/how-tos/use-secrets/ - Kubernetes NetworkPolicy, "traffic to and from the node where a Pod is running is always allowed, regardless of the IP address of the Pod or the node": https://kubernetes.io/docs/concepts/services-networking/network-policies/ - Milvus internal listeners (pinned tag v3.0.5, checked November 2026): the listener that binds only the port, each component's listener, routing-only interceptors and internal-TLS option (query node, data node, streaming node, mix coordinator, proxy internal port), the interceptors that pass calls without metadata, the default ports with `authorizationEnabled` and `internaltlsEnabled` off. Milvus internal listeners (pinned tag v3.0.5), continued: standalone enabling all five components, the query node's `Search` and `Query`, and the go.mod lines that pin Go 1.26.9 and grpc-go v1.84.0, whose code this guide has not read. Milvus internal listeners sources (pinned tag v3.0.5): https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/netutil/listener.go#L11-L35, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/netutil/listener.go#L92-L103, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/querynode/service.go#L123-L126. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/querynode/service.go#L246, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/querynode/service.go#L243-L253, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/querynode/service.go#L268. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/datanode/service.go#L82-L85, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/datanode/service.go#L127, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/datanode/service.go#L148. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/streamingnode/service.go#L106-L109, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/streamingnode/service.go#L345, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/streamingnode/service.go#L355. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/mixcoord/service.go#L107-L110, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/mixcoord/service.go#L225, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/mixcoord/service.go#L247. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/listener_manager.go#L56-L58, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/service.go#L494, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/service.go#L515. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/interceptor/cluster_interceptor.go#L34-L48, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/interceptor/server_id_interceptor.go#L36-L54, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/utils/util.go#L39-L63. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L411, https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L533, https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L534, https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L776. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1071, https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1276-L1298, https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1301, https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1579. Milvus internal listeners sources (pinned tag v3.0.5), continued: https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/milvus/util.go#L142-L147, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/proto/query_coord.proto#L109-L147, https://github.com/milvus-io/milvus/blob/v3.0.5/go.mod#L3 and https://github.com/milvus-io/milvus/blob/v3.0.5/go.mod#L50 - Milvus internal TLS fallback at v2.6.26 (pinned tag v2.6.26, checked November 2026): the internal-TLS option, which logs a certificate or key load error and passes the result to gRPC, and the go.mod lines that pin Go 1.25.8 and grpc-go v1.82.1. Milvus internal TLS fallback at v2.6.26, continued: `credentials.NewServerTLSFromFile` returning nil credentials with the load error and otherwise building a `tls.Config` that sets only the server certificate, requiring no client certificate (grpc-go v1.82.1). The built `tls.Config` requires no client certificate because `tls.Config.ClientAuth` defaults to `NoClientCert`, the zero value of `ClientAuthType` (Go standard library go1.25.8, the go directive in go.mod). Milvus internal TLS fallback at v2.6.26, continued: gRPC serving plaintext when the credentials are nil. Milvus internal TLS fallback sources (pinned tag v2.6.26): https://github.com/milvus-io/milvus/blob/v2.6.26/internal/distributed/utils/util.go#L41-L66, https://github.com/milvus-io/milvus/blob/v2.6.26/go.mod#L3, https://github.com/milvus-io/milvus/blob/v2.6.26/go.mod#L54. Milvus internal TLS fallback sources, continued: https://github.com/grpc/grpc-go/blob/v1.82.1/credentials/tls.go#L302-L308, https://github.com/grpc/grpc-go/blob/v1.82.1/credentials/tls.go#L222-L257, https://github.com/grpc/grpc-go/blob/v1.82.1/internal/transport/http2_server.go#L151-L167. Milvus internal TLS fallback sources, continued: https://github.com/golang/go/blob/go1.25.8/src/crypto/tls/common.go#L335-L341 and https://github.com/golang/go/blob/go1.25.8/src/crypto/tls/common.go#L681-L683 - Milvus TLS documentation, internal TLS "For now only one-way tls is supported" (milvus-docs pinned commit cee60f10e2b794fbaf9bd6a02d5f129335218dfa): https://github.com/milvus-io/milvus-docs/blob/cee60f10e2b794fbaf9bd6a02d5f129335218dfa/site/en/adminGuide/tls.md#L215 - Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity, `enforce_internal_auth` on by default as of v1.20.0, `cluster.p2p.host` as of v1.19.2, `alt_api_key` rotation): https://qdrant.tech/documentation/security/ - Qdrant compiled-in defaults and overlay order (`service.host`, `http_port`, `grpc_port`; `config/config`, `config/$RUN_MODE` defaulting to `development`, `config/local`, `/etc/qdrant/config` in `deb` builds, `--config-path`, `QDRANT__` variables), the development and production overlays, and the image's `RUN_MODE=production` with an entrypoint that runs `./qdrant $@`. Qdrant defaults, overlays, image and release workflow, continued: the release workflow that builds `qdrant/qdrant` from the root `Dockerfile` (pinned tag v1.20.0). Qdrant defaults, overlays, image and release workflow sources (pinned tag v1.20.0): https://github.com/qdrant/qdrant/blob/v1.20.0/src/settings.rs#L25, https://github.com/qdrant/qdrant/blob/v1.20.0/src/settings.rs#L318-L355, https://github.com/qdrant/qdrant/blob/v1.20.0/config/config.yaml#L390-L394. Qdrant defaults, overlays, image and release workflow sources (pinned tag v1.20.0), continued: https://github.com/qdrant/qdrant/blob/v1.20.0/config/config.yaml#L412, https://github.com/qdrant/qdrant/blob/v1.20.0/config/development.yaml#L13-L15, https://github.com/qdrant/qdrant/blob/v1.20.0/config/production.yaml#L3-L5, https://github.com/qdrant/qdrant/blob/v1.20.0/Dockerfile#L225-L246. Qdrant defaults, overlays, image and release workflow sources (pinned tag v1.20.0), continued: https://github.com/qdrant/qdrant/blob/v1.20.0/tools/entrypoint.sh#L21 and https://github.com/qdrant/qdrant/blob/v1.20.0/.github/workflows/docker-image.yml#L48-L56 - Weaviate authentication (anonymous access, API key, OIDC variables; checked November 2026): https://docs.weaviate.io/deploy/configuration/authentication - Weaviate authorization (admin list, RBAC availability): https://docs.weaviate.io/deploy/configuration/authorization and RBAC configuration (`AUTHORIZATION_RBAC_ENABLED`, `AUTHORIZATION_RBAC_ROOT_USERS`; both pages checked November 2026): https://docs.weaviate.io/deploy/configuration/configuring-rbac - Weaviate environment variables (`AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED` default, `GRPC_PORT` default; checked November 2026): https://docs.weaviate.io/deploy/configuration/env-vars - Weaviate Docker installation (ports 8080/50051, `--scheme http`, reverse proxy layout; checked November 2026): https://docs.weaviate.io/deploy/installation-guides/docker-installation - Weaviate REST `--host`/`--port` and their `HOST`/`PORT` variables, the REST listen call, the gRPC listener, started unconditionally, on `:` with no host field in its configuration and its 50051 default, the debug listener on `:` (default 6060) with its disable switch and 404 gate, and the Prometheus listener on `:` (2112 when enabled from the environment). Weaviate listeners and image, continued: the image's `--host 0.0.0.0 --port 8080`, built by `ci/push_docker.sh` from the root `Dockerfile`'s `weaviate` target (pinned tag v1.40.4, checked November 2026), with go-flags v1.6.1 reading `HOST` through `os.LookupEnv`, so an empty value replaces the default. Weaviate listeners and image, continued: the call that passes the default mux to `setupSelfRecoveryDebugHandlers`, whose body this guide has not read, the Raft debug routes added when self-recovery is not off, and the MCP handler setup call. Weaviate listeners and image sources (pinned tag v1.40.4): https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L89-L90, https://github.com/jessevdk/go-flags/blob/v1.6.1/option.go#L328-L343, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L593. Weaviate listeners and image sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L2673-L2717, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/handlers_debug_gate.go#L23-L33, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L334-L341. Weaviate listeners and image sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L507-L514, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L233-L285, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L96-L99. Weaviate listeners and image sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L377, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1668, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/grpc.go#L27-L33. Weaviate listeners and image sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/grpc/server.go#L480-L482, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/config_handler.go#L1054-L1062, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1056-L1060. Weaviate listeners and image sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L2073, https://github.com/weaviate/weaviate/blob/v1.40.4/Dockerfile#L54-L62, https://github.com/weaviate/weaviate/blob/v1.40.4/ci/push_docker.sh#L5, https://github.com/weaviate/weaviate/blob/v1.40.4/ci/push_docker.sh#L103. Weaviate listeners and image sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/ci/push_docker.sh#L120, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L756-L766 and https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1669 - Weaviate's configuration precedence (config file, then environment, then flags), REST schemes (`--scheme`, default from the API spec's `https`) and the fatal exit when `https` has no certificate, the Prometheus port variable, the TLS listener's `--tls-host`/`TLS_HOST` falling back to `--host` and `--tls-port`/`TLS_PORT`, and `--socket-path`. Weaviate's cluster listeners: gossip bind address and ports, the data port default, Raft and its RPC port defaults and bind address, the unconditional cluster init and cluster API server (pinned tag v1.40.4, checked November 2026), with memberlist v0.5.4's `0.0.0.0:7946` default, its TCP and UDP listeners and its private advertise address. Weaviate configuration and cluster listener sources (pinned tag v1.40.4): https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L47-L53, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L81, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L149-L160. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L86, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L97-L100, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L303-L310. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/embedded_spec.go#L39-L41, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/config_handler.go#L1412-L1447, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/config_handler.go#L1478-L1484. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L345-L349, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L38-L39, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1942-L1958. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1970-L1986, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L740-L743, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/config_handler.go#L1440-L1443. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1802-L1805, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L325-L331, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1661-L1671. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L2105-L2107, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L2191-L2234, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/cluster/state.go#L431-L446. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/cluster/state.go#L662-L673, https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/cluster/state.go#L695-L705, https://github.com/weaviate/weaviate/blob/v1.40.4/cluster/store.go#L692-L693. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/cluster/service.go#L154, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L661, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L858-L859. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1866, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/clusterapi/serve.go#L47-L49, https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/clusterapi/serve.go#L140. Weaviate configuration and cluster listener sources (pinned tag v1.40.4), continued: https://github.com/weaviate/weaviate/blob/v1.40.4/go.mod#L54, https://github.com/hashicorp/memberlist/blob/v0.5.4/config.go#L302-L307, https://github.com/hashicorp/memberlist/blob/v0.5.4/net_transport.go#L96-L110 and https://github.com/hashicorp/memberlist/blob/v0.5.4/net_transport.go#L140-L160 - Weaviate release notes for v1.40.4, v1.40.3 and v1.39.15 (checked November 2026): the v1.40.4 fix list adds the 3 fixes (#13749, #13651 and #13679), which this guide describes as authorization fixes, that the v1.39.15 release notes named on 2026-11-04. Version 1.40.4 therefore includes those 3 fixes, which v1.40.3, published before v1.39.15, did not list. v1.40.3 carried the "fix(backups): add missing basic auth to remaining handlers" fix (#13350), whose covered handlers this guide has not established. Weaviate release notes sources: https://github.com/weaviate/weaviate/releases/tag/v1.40.4 and https://github.com/weaviate/weaviate/releases/tag/v1.40.3 and https://github.com/weaviate/weaviate/releases/tag/v1.39.15 - Milvus Helm chart 5.0.30 (appVersion 3.0.1), checked October 2026: chart identity, Service type and ports, metrics default, and unchanged standalone/proxy ports and probes when metrics publication is disabled: https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/Chart.yaml#L1-L6. Milvus Helm chart 5.0.30, continued: https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/values.yaml#L115-L136, https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/values.yaml#L169-L194, https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/templates/service.yaml#L1-L60. Milvus Helm chart 5.0.30, continued: https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/templates/standalone-deployment.yaml#L83-L110, https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/templates/proxy-deployment.yaml#L98-L125 - Milvus Operator v1.3.11, checked October 2026: metrics constants, unconditional metrics Service port, Service type and reconciliation, declared ClusterIP default, and disableMetric skipping PodMonitor reconciliation: https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/components.go#L20-L69. Milvus Operator v1.3.11, continued: https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/components.go#L450-L487, https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/services.go#L15-L47, https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/services.go#L101-L107. Milvus Operator v1.3.11, continued: https://github.com/zilliztech/milvus-operator/blob/v1.3.11/apis/milvus.io/v1beta1/components_types.go#L500-L525, https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/podmonitor.go#L91-L100 - Milvus authentication (`common.security.authorizationEnabled`, default `root`/`Milvus`, `update_password`; checked November 2026, when the page served the v3.0.x documentation and the v2.6.x page was not read): https://milvus.io/docs/authenticate.md - Milvus TLS (`tls.*` paths, `common.security.tlsMode`, RESTful port note; checked November 2026, when the page served the v3.0.x documentation and the v2.6.x page was not read): https://milvus.io/docs/tls.md - Milvus management HTTP listener, v3.0.5 (pinned tag v3.0.5, checked November 2026): default port and `METRICS_PORT` parsing, the wildcard bind and logged listen error, the log-level, health, event-log, WebUI and stop registrations, the explicit profiling routes, and the private mux that consults `http.DefaultServeMux` only while profiling is on and the gate is off. Milvus management HTTP listener, v3.0.5, continued: process startup and stop callback, the open metrics routes, and `common.security.adminAuthEnabled` (default `false`), read on each request, with its documentation. Milvus management HTTP listener, v3.0.5, continued: the root-only gate with its status codes and cross-site checks, its refusal through `/management/config/alter`, the loopback event-log binding, and the `/api/v1/_*` console middleware. Milvus management HTTP listener, v3.0.5, continued: the WebUI and `/telemetry` registrations as browser documents, and the root-hash cache comparing a password with its last hash when a refresh fails. Milvus management HTTP listener v3.0.5 sources: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L40-L43, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L82-L100, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L145-L246. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L365-L427, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L445-L542, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/metrics/metrics.go#L23-L26. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/roles/roles.go#L379-L389, https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/roles/roles.go#L532-L533, https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/roles/roles.go#L607-L622. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/milvus/util.go#L123-L198, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/component_param.go#L1064-L1091, https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/component_param.go#L376. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1278-L1292, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/auth.go#L33-L37, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/auth.go#L114-L176. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/auth.go#L223-L293, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/rbac.go#L97-L135, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L1364-L1366. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/eventlog/grpc.go#L123-L135, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/service.go#L129-L208. Milvus management HTTP listener v3.0.5 sources, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L281-L300 and https://github.com/milvus-io/milvus/blob/v3.0.5/internal/util/adminauth/cache.go#L216-L225 - Milvus management HTTP listener, v2.6.26 (pinned tag v2.6.26, checked November 2026): the log-level, health, event-log, WebUI and stop registrations, and the mux that serves `http.DefaultServeMux` unless `managementMetricsOnly` is set or profiling is off. Milvus management HTTP listener, v2.6.26, continued: `common.security.managementMetricsOnly` (default `false`), read once at the first registration, with its documentation, its allowed paths and methods, status-only probes, 404 for every other request and its refusal through `/management/config/alter`. The default port and `METRICS_PORT` constants, the same as v3.0.5's, are cited above at v3.0.5 only. Milvus management HTTP listener v2.6.26 sources: https://github.com/milvus-io/milvus/blob/v2.6.26/internal/http/server.go#L77-L140 and https://github.com/milvus-io/milvus/blob/v2.6.26/internal/http/server.go#L215-L258. Milvus management HTTP listener v2.6.26 sources, continued: https://github.com/milvus-io/milvus/blob/v2.6.26/internal/http/management_mux.go#L21-L111, https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/util/paramtable/component_param.go#L1006-L1022, https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/util/paramtable/component_param.go#L362. Milvus management HTTP listener v2.6.26 sources, continued: https://github.com/milvus-io/milvus/blob/v2.6.26/configs/milvus.yaml#L1089-L1097 and https://github.com/milvus-io/milvus/blob/v2.6.26/internal/coordinator/restful_mgr_routes.go#L1384-L1385 - Milvus v3.0.3 release notes, from the release records fetched on 2026-11-04 and cited under the guide's v3.0.5 basis (checked November 2026): "Added optional root authentication for management endpoints on the metrics port (`common.security.adminAuthEnabled`, disabled by default)". Milvus v3.0.3 release notes source: https://github.com/milvus-io/milvus/releases/tag/v3.0.3 - Milvus control and profiling routes on the same listener, v2.6.26 and v3.0.5 (pinned tags v2.6.26 and v3.0.5, checked November 2026): the proxy's management routes, registered with no credential check at v2.6.26 and behind the opt-in gate at v3.0.5, the encryption-zone key backup among them, and the 2 routes only v3.0.5 adds. Milvus proxy management route sources, v2.6.26 and v3.0.5: https://github.com/milvus-io/milvus/blob/v2.6.26/internal/proxy/management.go#L42-L97, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L42-L76, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L169-L215. Milvus proxy management route sources, v2.6.26 and v3.0.5, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L398-L418 and https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L671-L697. The mix coordinator's routes, registered on activation, including the node-status `PUT` that suspends or resumes a query node. Milvus mix coordinator route sources, v2.6.26 and v3.0.5: https://github.com/milvus-io/milvus/blob/v2.6.26/internal/coordinator/restful_mgr_routes.go#L37-L75, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L34-L73. Milvus mix coordinator route sources, v2.6.26 and v3.0.5, continued: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L880-L889, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L942-L1009 and https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/mix_coord.go#L266-L269. `authorizationEnabled` applied to the proxy's `/api/v1/_*` group only, which `proxy.http.enableV1` mounts: https://github.com/milvus-io/milvus/blob/v2.6.26/internal/distributed/proxy/service.go#L130-L191, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/service.go#L129-L208. The `/api/v1/_*` group's switch, v2.6.26 and v3.0.5: https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/util/paramtable/http_param.go#L44-L55 and https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/http_param.go#L52-L62. The pprof import or explicit registration: https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/metrics/metrics.go#L21 and https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L145-L246. Milvus pprof default, v3.0.5, the same lines as v2.6.26's: https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/http_param.go#L131-L138. The event-log listener: https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/eventlog/grpc.go#L113-L146 and https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/eventlog/grpc.go#L170-L219; `strconv.Atoi` range errors: https://pkg.go.dev/strconv#Atoi - Milvus encryption-zone key backup and event-log handler, v3.0.5 (pinned tag v3.0.5, checked November 2026): the backup refuses a database that is not an encryption zone and returns the plugin's backup otherwise. Milvus encryption-zone key backup sources, v3.0.5: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/rootcoord/root_coord.go#L3465-L3504, https://github.com/milvus-io/milvus/blob/v3.0.5/internal/util/hookutil/cipher.go#L391-L402 and https://github.com/milvus-io/milvus/blob/v3.0.5/internal/util/hookutil/ez.go#L119-L131. The `/eventlog` handler creating the listener on request: https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/eventlog/handler.go#L54-L69. The Compose service's environment, which does not pass `METRICS_PORT`, and its healthcheck: https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L65-L96 - Milvus `/healthz` handler, v3.0.5 (pinned tag v3.0.5, checked November 2026): `if indicatorNum == 0 || registeredNum != indicatorNum {` makes it answer 500 until every component the process expects has registered. It also answers 500 while any registered component not stopped through the management API is neither healthy nor on standby, and 200 otherwise; the v2.6.26 handler differs only in a logging call, which this guide counts as the same. Milvus `/healthz` handler source: https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/healthz/healthz_handler.go#L91-L142. The curl `--fail` exit on an HTTP error (rolling documentation, checked October 2026): https://curl.se/docs/manpage.html#-f; Docker's health status from the check's exit code (rolling documentation, checked October 2026): https://docs.docker.com/reference/dockerfile/#healthcheck - Milvus v2.6.25 release notes, from the release records fetched on 2026-11-04 and cited under the guide's v2.6.26 basis (checked November 2026): "Fixed an issue where a restarting node reported ready on `/healthz` before all of its components had registered, causing requests routed to it during startup to fail with connection refused" (#53736). Milvus v2.6.25 release notes source: https://github.com/milvus-io/milvus/releases/tag/v2.6.25 - Milvus standalone Compose at v2.6.26 and v3.0.5 (pinned tags v2.6.26 and v3.0.5, checked November 2026): selected image tags (`milvusdb/milvus:v2.6.25`, and `milvusdb/milvus:v3.0.4` through `x-milvus-image`), standalone command, localhost healthcheck, explicit publications of 19530 and 9091, and default network. Milvus standalone Compose sources, v2.6.26 and v3.0.5: https://github.com/milvus-io/milvus/blob/v2.6.26/deployments/docker/standalone/docker-compose.yml#L39-L66, https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L3 and https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L65-L96 - Milvus log-level handler dependency (pinned tag v3.0.5, checked November 2026; zap v1.27.0, GET reads and PUT changes the level): https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/mlog/level.go#L72-L74, https://github.com/milvus-io/milvus/blob/v3.0.5/go.mod#L43 and https://github.com/uber-go/zap/blob/v1.27.0/http_handler.go#L69-L103 - Docker port publication: default host binding, explicit loopback mapping, same-network and host access, direct-routing and older-engine caveats: https://docs.docker.com/engine/network/port-publishing/ - Pinecone organization SSO (Standard and Enterprise plans; identity management solution; SSO console re-authentication at least every 24 hours), rolling page captured October 15, 2026 through the previously cited `/guides/organizations/understanding-organizations` path. The previously cited path redirected to the cited page when rechecked in October 2026. Pinecone organization SSO source, at the time of writing: https://docs.pinecone.io/admin/organizations/understanding-organizations - Qdrant Cloud API key per request, rolling page captured October 15, 2026: https://qdrant.tech/documentation/cloud/ - Qdrant Cloud account registration and Premium-tier console SSO, distinct from cluster API keys. Qdrant Cloud account registration and Premium-tier console SSO sources (documentation source commit 61a5b32ae31009bc49d3c8afa91eb9242e4368df; at the time of writing): https://github.com/qdrant/landing_page/blob/61a5b32ae31009bc49d3c8afa91eb9242e4368df/qdrant-landing/content/documentation/cloud-account-setup.md#L15-L20. Qdrant Cloud account registration and Premium-tier console SSO sources, continued: https://github.com/qdrant/landing_page/blob/61a5b32ae31009bc49d3c8afa91eb9242e4368df/qdrant-landing/content/documentation/cloud-account-setup.md#L94-L104 - Qdrant Cloud Database API key on every REST or gRPC request (documentation source commit 61a5b32ae31009bc49d3c8afa91eb9242e4368df): https://github.com/qdrant/landing_page/blob/61a5b32ae31009bc49d3c8afa91eb9242e4368df/qdrant-landing/content/documentation/cloud/authentication.md#L68-L69 - Weaviate Cloud per-account MFA and Owner enforcement for all members, both off by default; enforcement available to every organization. Weaviate Cloud per-account MFA and Owner enforcement sources (documentation source commit 5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9; at the time of writing): https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L8-L10. Weaviate Cloud per-account MFA and Owner enforcement sources, continued: https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L132 - Qdrant Cloud SSO protocols and support-ticket enablement (documentation source commit be9bbd5809c40707987d87f4a012c58088fc2ee6; at the time of writing): https://github.com/qdrant/landing_page/blob/be9bbd5809c40707987d87f4a012c58088fc2ee6/qdrant-landing/content/documentation/cloud-account-setup.md#L94-L106. The cited file at commit be9bbd58 is the same file as the 61a5b32a citations, cited here for the protocol list and support-ticket enablement. - Weaviate Cloud SAML SSO for Managed organizations, Owner control, console scope and account-wide password/Google sign-in replacement after first SSO sign-in. Weaviate Cloud SAML SSO sources (documentation source commit 5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9; at the time of writing): https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/single-sign-on.mdx#L9-L14. Weaviate Cloud SAML SSO sources, continued: https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/single-sign-on.mdx#L131 - Weaviate Cloud Owner-only MFA enforcement control and author warning that enforcement UI details need live-console checks. Weaviate Cloud Owner-only MFA enforcement control and author warning sources (documentation source commit 5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9): https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L13. Weaviate Cloud Owner-only MFA enforcement control and author warning sources, continued: https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L134-L135 - Weaviate Cloud SSO setup turns MFA enforcement off and SSO sign-ins never ask for a Cloud MFA code; after a member's first SSO sign-in, SSO applies even when they open an MFA-enforcing organization; disabling SSO leaves enforcement off until an Owner restores it. This guide assesses that these pages do not establish deployed IdP MFA enforcement. Weaviate Cloud SSO setup and MFA enforcement sources (documentation source commit 5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9; at the time of writing): https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L266-L272. Weaviate Cloud SSO setup and MFA enforcement sources, continued: https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L300 - Qdrant Cloud account setup, published page: enterprise SSO for Premium Tier customers, Cloud Console sign-in only, Database API keys for cluster requests, listed providers and enablement by support-portal ticket (rolling page, checked October 2026). When fetched on 2026-11-04, its SSO section says SSO lets a team sign in to the Cloud Console through its identity provider, covers Console sign-in only and is part of the Premium Tier, and refers to the Premium Tier page for providers and enablement, with no Database API key or support-ticket statement (checked November 2026): https://qdrant.tech/documentation/cloud-account-setup/ - Qdrant Cloud Premium Tier, Enterprise Single Sign-On (SSO) section (checked November 2026): https://qdrant.tech/documentation/cloud-premium/. When fetched on 2026-11-04, the section says SSO covers Cloud Console sign-in only, that applications and users sending requests to a cluster still authenticate with Database API keys, and to open a ticket in the Qdrant support portal to turn on SSO; the page does not mention MFA. - Qdrant Cloud user profile, Security section (checked November 2026): https://qdrant.tech/documentation/cloud-user-profile/. When fetched on 2026-11-04, the section says MFA is an optional, personal setting that applies across every account the user belongs to, with 1 method, an authenticator app that generates one-time codes, and a recovery code shown only once. The page displays no update date and does not mention account-level MFA enforcement or SSO. - Weaviate Cloud MFA and SSO, published pages: Owner-only MFA enforcement, available to every organization and off by default; completing SSO setup turns enforcement off; SSO sign-ins never ask for a Weaviate Cloud MFA code. Weaviate Cloud MFA and SSO, published pages, continued: enforcement stays off after SSO is turned off; recommendation to enforce MFA in the identity provider. Weaviate Cloud MFA and SSO sources (rolling pages, checked October 2026; at the time of writing): https://docs.weaviate.io/cloud/platform/multi-factor-auth, https://docs.weaviate.io/cloud/platform/single-sign-on - Zilliz Cloud MFA: built-in TOTP authenticator-app MFA only for email-and-password accounts; identity-provider-managed MFA for SSO organizations; Google- or GitHub-controlled MFA for linked accounts; Organization Owner enforcement for all organization users, with its payment-method, Enterprise-project and Dedicated-cluster requirements. Zilliz Cloud MFA, continued: enforcement requiring all organization users to set up MFA to sign in, with a prompt at the next login; and MFA in SSO organizations managed by the identity provider, not by Zilliz Cloud. Zilliz Cloud MFA source (rolling page, checked October 2026; at the time of writing): https://docs.zilliz.com/docs/multi-factor-auth - Pinecone security overview and organization members: once SSO is configured, an organization can require users from its domain to sign in through SSO. Neither Pinecone page, nor the organizations overview above, mentions MFA. Pinecone security overview and organization members sources (rolling pages, checked October 2026, reached from former `/guides/` paths that now redirect here; at the time of writing): https://docs.pinecone.io/admin/security/security-overview, https://docs.pinecone.io/admin/organizations/manage-organization-members - Pinecone Okta SSO, step 2 of section 4 (Complete SSO setup in Pinecone): Enforce SSO for all users; when enabled, all members must use SSO to log in, and when disabled, members can log in with SSO or with their Pinecone credentials; the page does not mention MFA (rolling page, checked October 2026; at the time of writing): https://docs.pinecone.io/admin/security/configure-single-sign-on/okta - Pinecone Okta role management (How it works; section 3, Enable SAML role management): SAML role management requires enforced SSO; the page does not mention MFA (rolling page, checked October 2026; at the time of writing): https://docs.pinecone.io/admin/security/configure-single-sign-on/okta-role-management - Pinecone login code issues (opening paragraph; The code has expired): a login code received by email, valid for 10 hours; the page does not mention MFA or a second factor (rolling page, checked October 2026; at the time of writing): https://docs.pinecone.io/admin/account/login-code-issues - Pinecone documentation indexes for Pinecone Database, its Guides and Reference sections and Pinecone Assistant (checked November 2026): https://docs.pinecone.io/_llms/pinecone-database.txt, https://docs.pinecone.io/_llms/pinecone-database/guides.txt, https://docs.pinecone.io/_llms/pinecone-database/reference.txt. Pinecone documentation indexes, continued: https://docs.pinecone.io/_llms/pinecone-database/reference/2026-07-latest.txt, https://docs.pinecone.io/_llms/pinecone-database/reference/2026-04.txt, https://docs.pinecone.io/_llms/pinecone-assistant.txt and https://docs.pinecone.io/_llms/pinecone-assistant/reference.txt. Read on 2026-11-04, none of these indexes contains multi-factor, two-factor, MFA or 2FA; an index lists page titles and descriptions, not page content, and does not establish what the pages themselves say. - Pinecone SCIM provisioning with Okta (checked November 2026): https://docs.pinecone.io/admin/security/configure-single-sign-on/okta-scim-provisioning.md. Read on 2026-11-04, the Markdown page says SCIM provisioning is available on the Enterprise plan and builds on SAML SSO, and to configure SSO with Okta and enforce SSO before beginning; it does not mention MFA. - Zilliz Cloud SSO enforcement (opening paragraphs; Before you start; Owner exemption): configuring SSO alone does not meet a requirement to sign in through the IdP, and members can still log in with email/password or Google and GitHub accounts; if you require MFA, configure it in your IdP. Any Zilliz Cloud MFA enabled for the organization is automatically disabled when SSO enforcement is enabled; Organization Owners are exempt from SSO enforcement by default (rolling page, checked October 2026, opening paragraphs fetched 2026-10-25; at the time of writing): https://docs.zilliz.com/docs/enforce-sso-in-your-organization - Zilliz Cloud email accounts (Switch from password login to third-party login): MFA must be disabled before switching to Google or GitHub login (rolling page, checked October 2026; at the time of writing): https://docs.zilliz.com/docs/email-accounts - Zilliz Cloud MFA (Disable MFA): with organization MFA enforcement enabled, members cannot disable MFA for their own accounts (rolling page, checked October 2026; at the time of writing): https://docs.zilliz.com/docs/multi-factor-auth - Zilliz Cloud registration (Choose a sign-up method; Sign up with Google and Sign up with GitHub notes): one of work email and password, Google or GitHub to register and log in. If sign-up fails because of Google- or GitHub-managed MFA, disable that MFA temporarily and try again; no step to turn it back on (rolling page, checked October 2026, fetched 2026-10-29; at the time of writing): https://docs.zilliz.com/docs/register-with-zilliz-cloud - Zilliz Cloud single sign-on overview: SSO through OIDC or SAML 2.0 at the organization level, on the Enterprise plan or higher and BYOC deployments; the page body does not mention MFA (rolling page, checked October 2026, fetched 2026-10-25; at the time of writing): https://docs.zilliz.com/docs/single-sign-on - Zilliz Cloud Okta (SAML 2.0), Task 3 (Optional) Enable SSO enforcement: when enabled, members can no longer sign in with email/password or Google or GitHub accounts; the page body does not mention MFA (rolling page, checked October 2026, fetched 2026-10-25; at the time of writing): https://docs.zilliz.com/docs/single-sign-on-with-okta - Zilliz Cloud Okta (OIDC), Task 3 (Optional) Enable SSO enforcement: when enabled, members can no longer sign in with email/password or Google or GitHub accounts; the page body does not mention MFA (rolling page, checked November 2026, fetched 2026-11-04; at the time of writing): https://docs.zilliz.com/docs/openid-connect - Weaviate Cloud MFA enforcement labels, published page: the `Users and Members` tab, the `MFA enforcement` section, the `Require MFA for all members` switch and the "Enforce multi-factor authentication?" and "Stop enforcing multi-factor authentication?" dialogs. The published page does not display the source's live-console verification note. When fetched on 2026-11-04, the page still names these labels, displays no update date and does not display that note. Weaviate Cloud MFA enforcement labels source (rolling page, checked November 2026; at the time of writing): https://docs.weaviate.io/cloud/platform/multi-factor-auth - Qdrant Cloud console MFA search: a case-insensitive search of the documentation source tree for multi-factor, two-factor, MFA, 2FA, authenticator, TOTP, passkey and WebAuthn found no match. The no-match result does not establish that no console MFA control exists. The published user-profile page, fetched 2026-11-04 and cited above, documents a per-user console MFA control. Qdrant Cloud console MFA search source (documentation source commit be9bbd5809c40707987d87f4a012c58088fc2ee6): https://github.com/qdrant/landing_page/tree/be9bbd5809c40707987d87f4a012c58088fc2ee6/qdrant-landing/content/documentation - Line history at the pins: the Weaviate Cloud MFA and SSO source lines cited above were last changed in commit 9fbb108b (2026-10-13), except MFA L8-L9 (2025); the Qdrant Cloud SSO sentences at L94 and L106 in commit 6efd6b1f (2026-10-13). Commit dates are not publication or release dates. Line history sources (documentation source commits 5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9 and be9bbd5809c40707987d87f4a012c58088fc2ee6): https://github.com/weaviate/docs/blame/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx. Line history sources, continued: https://github.com/weaviate/docs/blame/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/single-sign-on.mdx, https://github.com/qdrant/landing_page/blame/be9bbd5809c40707987d87f4a012c58088fc2ee6/qdrant-landing/content/documentation/cloud-account-setup.md - Chroma 1.5.9 `chroma run` unit authenticator, default-identity authentication and authorization, and server delegation: https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L139-L142, https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/auth/mod.rs#L102-L164 and https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/lib.rs#L60-L156. Chroma 1.5.9 `chroma run` unit authenticator, default-identity authentication and authorization, and server delegation, continued: https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/server.rs#L229-L255, https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/server.rs#L457-L486 - Chroma migration notes (v1.0.0 removal of built-in authentication; 2024 auth overhaul variables): https://docs.trychroma.com/docs/overview/migration ; client-server mode (`chroma run --path`, port 8000): https://docs.trychroma.com/docs/run-chroma/client-server - Chroma `chroma run`: the `--host` default `localhost` applied only without a config file, the config-file branch, the `listen_address` and `port` defaults `0.0.0.0` and 8000, `CHROMA_` variables merged when a file is loaded, and the server's bind of `listen_address:port`. Chroma `chroma run`, continued: the release image (`rust/Dockerfile` target `cli`, `chroma run /config.yaml` from `docker_single_node.yaml`) (pinned tag 1.5.9). Chroma `chroma run` sources (pinned tag 1.5.9): https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L39-L45, https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L60-L73. Chroma `chroma run` sources (pinned tag 1.5.9), continued: https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L114-L134, https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/config.rs#L140-L146, https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/config.rs#L176-L179. Chroma `chroma run` sources (pinned tag 1.5.9), continued: https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/config.rs#L216-L229, https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/server.rs#L415-L417, https://github.com/chroma-core/chroma/blob/1.5.9/rust/Dockerfile#L85-L93. Chroma `chroma run` sources (pinned tag 1.5.9), continued: https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/sample_configs/docker_single_node.yaml and https://github.com/chroma-core/chroma/blob/1.5.9/.github/workflows/_build_release_container.yml#L101-L106 - pgvector (`CREATE EXTENSION vector`, PostgreSQL 13 and later; v0.8.7 README): https://github.com/pgvector/pgvector/blob/v0.8.7/README.md - PostgreSQL row security policies (a superuser and a `BYPASSRLS` role always bypass; the table owner bypasses unless the table has `FORCE ROW LEVEL SECURITY`): https://www.postgresql.org/docs/current/ddl-rowsecurity.html - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0; rolling documentation, checked October 2026): https://curl.se/docs/manpage.html - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - q: Qdrant source (v1.20.0) - w: Weaviate source (v1.40.4) - m2: Milvus (v2.6.26) - m3: Milvus (v3.0.5) - milvus-helm: Milvus Helm chart (5.0.30) - milvus-operator: Milvus Operator (v1.3.11) - chroma: Chroma source (1.5.9) - cd: Chroma migration documentation (v1.0.0) - pgvector-source: pgvector source (v0.8.7) - qd: Qdrant security documentation (unknown) - qc: Qdrant Cloud documentation (unknown) - wd: Weaviate documentation (unknown) - pg: PostgreSQL documentation (unknown) - docker: Docker documentation (unknown) - k8s: Kubernetes documentation (unknown) - grpc2: grpc-go (Milvus v2.6.26 go.mod) (v1.82.1) - ml: memberlist (Weaviate v1.40.4 go.mod) (v0.5.4) - goflags: go-flags (v1.6.1) - gostd2: Go standard library (go1.25.8) - pc: Pinecone organization documentation (unknown) - curl-docs: curl documentation (rolling) (unknown) - qc-pinned: Qdrant Cloud documentation source (61a5b32ae31009bc49d3c8afa91eb9242e4368df) - wc: Weaviate Cloud MFA and SSO documentation source (5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9) - qc-source: Qdrant Cloud documentation source (be9bbd5809c40707987d87f4a012c58088fc2ee6) - zc: Zilliz Cloud documentation (unknown) Claims (full source mapping in the JSON block below): - private-publication (REASONED; docker): Bind privately and publish only a TLS proxy, tunnel or tailnet; Docker loopback mapping avoids default wildcard publication. - qdrant-ports (REASONED; qd): Qdrant REST 6333 and gRPC 6334 are client ports; internal gRPC 6335 opens only in distributed mode, including an initial single peer, and binds service.host unless cluster.p2p.host (v1.19.2 and later) is set. - qdrant-bind (REASONED; q): Compiled host is 0.0.0.0; config/config, RUN_MODE (development default), config/local, deb config, config-path and QDRANT__ variables overlay in that order. - qdrant-image (REASONED; q): Development overlay binds 127.0.0.1; official image sets RUN_MODE=production, keeps 0.0.0.0 and starts qdrant without arguments. - qdrant-client-auth (REASONED; qd): Self-deployed Qdrant defaults insecure; api_key and read_only_api_key gate client REST/gRPC through api-key or Bearer headers, with QDRANT__SERVICE__ environment equivalents. - qdrant-client-tls (REASONED; qd): service.enable_tls with tls.cert/key enables client TLS; keys without TLS are insecure. - qdrant-peer-auth (REASONED; qd): Through v1.17.x keys do not gate 6335; v1.18.0 adds enforce_internal_auth, default-on from v1.20.0 but without effect when no API key is configured, so under enforcement a cluster with only read_only_api_key loses peer traffic; enforcement does not replace the peer-only network restriction. - qdrant-peer-tls (REASONED; qd): cluster.p2p.enable_tls is separate from client TLS and needs a provisioned tls.ca_cert; compiled default path is not a substitute for provisioning. - qdrant-rotation (REASONED; qd): alt_api_key accepts an additional client key from v1.17.0 and is checked internally only under enforce_internal_auth from v1.18.0 (default-on from v1.20.0); rotate peer-by-peer, setting the new key on every peer before promoting it, keeping the old key as alt_api_key until every peer uses the new key, and upgrading an alt_api_key-only cluster on v1.19.0 or v1.19.1 to v1.19.2 first. - weaviate-precedence (REASONED; w): File, environment then flags override settings; environment reapplies gRPC/Raft port defaults when unset/empty and gossip/API defaults when unset; empty cluster port variables fail startup. - weaviate-rest (REASONED; w, goflags): Default scheme is HTTPS requiring certificates or startup fails; TLS host falls back to host and TLS port is random. HTTP host defaults localhost unless HOST is exported empty; port defaults random; unix uses socket-path. - weaviate-image (REASONED; w, wd): Official image selects HTTP on 0.0.0.0:8080; documented proxy forwards both 8080 and gRPC 50051 with TLS at the proxy. - weaviate-grpc (REASONED; w): gRPC always starts on a wildcard address with default port 50051 (configurable via GRPC_PORT) and ignores REST host; keep it unpublished except through the intended proxy. - weaviate-debug (REASONED; w): Debug HTTP defaults wildcard 6060 unless GO_PROFILING_DISABLE is true; debug endpoints return 404 until enabled. v1.40.4 passes the gated default mux to an unread self-recovery setup function; this guide infers the same 404 for those routes only if that function registers them there. - weaviate-metrics (REASONED; w): Enabled Prometheus listener exposes metrics and tenant-activity on wildcard, default 2112 when enabled through environment; admit only scraper traffic. - weaviate-gossip (REASONED; w, ml): Even one node opens TCP/UDP gossip 7946 at CLUSTER_BIND_ADDR, defaulting to wildcard when unset/empty; admit only peers. - weaviate-cluster-api (REASONED; w): Internal cluster API binds wildcard 7947, default gossip port plus one, independently of REST host. - weaviate-raft (REASONED; w, ml): Raft 8300 and RPC 8301 bind nonempty CLUSTER_BIND_ADDR, then CLUSTER_ADVERTISE_ADDR, then memberlist private advertise address; keep peer-only. - weaviate-open-questions (REASONED; w): At v1.40.4 this guide has not established whether and where the self-recovery debug routes are registered, which mux the Raft debug routes use, whether the MCP handlers open a listener, or which handlers the #13350 backups basic-auth fix covers; the v1.40.4 release notes list the 3 fixes #13749, #13651 and #13679 that v1.39.15 named and v1.40.3 did not, so v1.40.4 includes them. - weaviate-auth (REASONED; wd): Anonymous access defaults true; disable it and enable API keys with positional key-to-user mapping. - weaviate-rbac (REASONED; wd): Enable RBAC, root only the admin, and scope app roles to collections; RBAC is stated generally available from v1.29 and cannot combine with admin-list authorization. - weaviate-oidc (REASONED; wd): OIDC issuer/client settings delegate human authentication to the IdP, where MFA can be enforced; authentication alone is not authorization. - milvus-config (REASONED; m3): Compose mounts only data; mount user.yaml and TLS files read-only under /milvus, accounting for MILVUSCONF and milvus.yaml/_test.yaml/default.yaml/user.yaml precedence. - milvus-env (REASONED; m3): Environment formatter lowercases, strips leading milvus. and removes slash/underscore/dot; use COMMON_SECURITY_* and TLS_* names, remove conflicting sources and recreate changed mounts/environment. - milvus-user (REASONED; m2, m3): The v3.0.5 Ubuntu image runs as milvus and its Compose file adds an init service that changes the data directory's owner to that user; the v2.6.26 Dockerfile sets no USER. Make config/certificates readable and restrict the private key to the service and trusted administrators. - milvus-auth (REASONED; m3): Enable common.security.authorizationEnabled, replace built-in root/Milvus and create a per-app user; proxy auth does not protect internal services. - milvus-tls (REASONED; m3): tls server/key/CA paths and common.security.tlsMode 1 enable server TLS; mode 2 requires client certificates; enable authentication independently. - milvus-client (REASONED; m3): External client gRPC uses 19530; publish only that port privately, apart from the optional loopback management mapping. - milvus-internal (REASONED; m3): Internal proxy 19529, query 21123, data 21124, mix coordinator 22125 and streaming 22222 bind wildcard; node ports can fall back randomly and advertised ip is not a bind. - milvus-internal-auth (REASONED; m3): Internal routing interceptors pass calls without metadata and do not authenticate Search/Query; standalone runs all five components. - milvus-internal-tls (REASONED; m2, m3, grpc2, gostd2): internaltlsEnabled defaults off and internal TLS is one-way. At v2.6.26 (grpc-go v1.82.1, Go 1.25.8) failed certificate/key loading logs a warning then serves plaintext; at v3.0.5, whose go.mod pins grpc-go v1.84.0 and Go 1.26.9, that fallback is not established. - milvus-isolation (REASONED; docker, k8s): Use a dedicated network, no host/shared namespace, and default-deny rules admitting peers by source including random fallback ports; exclude untrusted workloads/users from component hosts. - milvus-minio-ports (REASONED; m3): Bundled MinIO publishes wildcard 9000 API and 9001 console while etcd remains internal; restrict both and replace default credentials. - milvus-storage-credentials (REASONED; m3): Compose at v3.0.5 selects milvusdb/minio RELEASE.2024-12-18T13-15-44Z with MINIO_ACCESS_KEY/SECRET_KEY=minioadmin, which that image is not established to read; Milvus uses MINIO_ACCESS_KEY_ID/SECRET_ACCESS_KEY; rotate matching pairs together and confirm minioadmin is refused. - milvus-env-files (REASONED; docker): Use protected untracked env-files, delete overriding Compose environment credential entries, retain ETCD_ENDPOINTS/MINIO_ADDRESS/MINIO_REGION and recreate both services; container environment remains inspectable. - milvus-compose-images (REASONED; m2, m3): Compose at v2.6.26 and v3.0.5 selects older Milvus images v2.6.25 and v3.0.4; explicitly choose and recheck the intended image. - milvus-management (REASONED; m3): Management HTTP defaults wildcard 9091 with no bind-address control; METRICS_PORT parse failures fall back, 0 chooses random and other out-of-range values fail listen without stopping Milvus. - milvus-control (REASONED; m2, m3): With default settings, management routes bypass proxy authorization: log-level changes, process-role stop, GC/balancing/node controls and transfers remain exposed even with WebUI disabled. - milvus-key-backup (REASONED; m2, m3): Management encryption-zone backup returns the plugin backup for eligible databases; v3.0.5, unlike v2.6.26, also adds queued-read clearing and path-supplied backfill commit. - milvus-coordinator (REASONED; m3): Activated mix coordinator adds mutable configuration and WAL changes alongside GC, balancing, suspension and transfer controls. - milvus-profiling (REASONED; m2, m3): proxy.http.enablePprof defaults true; v3.0.5 registers pprof routes explicitly and v2.6.26 serves the default mux, which this guide infers carries them; disabling profiling omits pprof but leaves control routes registered. - milvus-eventlog (REASONED; m2, m3): With default settings, the first successful eventlog request opens an unauthenticated, non-TLS wildcard gRPC listener on a random port; repeat inventory after requests. - milvus-health (REASONED; m3): Delete wildcard 9091 publication; in-container healthcheck needs none. Optional 127.0.0.1:9091 mapping still needs isolation; changed METRICS_PORT must update healthcheck and mappings. - milvus-metrics-only (REASONED; m2): On v2.6.26, common.security.managementMetricsOnly true, read once at startup, limits the management port to unauthenticated GET and HEAD on metrics and probe paths and answers 404 to log-level, eventlog, stop, WebUI, profiling and console routes. - milvus-admin-auth (REASONED; m3): On v3.0.5, common.security.adminAuthEnabled true, read per request, requires root HTTP Basic auth on management, log-level, eventlog, profiling, WebUI and console routes, after request-context checks that may answer 403 first; a failed refresh may reuse the root hash until 10 minutes after its last successful fetch. Probes and metrics stay open, eventlog binds loopback, the port stays plaintext. - milvus-optin-advice (REASONED; m2, m3): This guide advises managementMetricsOnly on v2.6.26, and on v3.0.5 adminAuthEnabled after enabling authentication and changing the root password, whose old value a node may accept for up to 10 minutes while refreshes fail, and deleting the wildcard 9091 publication on both, since metrics and probes stay unauthenticated in either mode. - milvus-open-questions (REASONED; m2, m3): Not established: the Go pprof patterns and other default-mux handlers served by default, whether the v3.0.5 gate can verify root with authorizationEnabled off, which proxy code registers its verifier, what revokes cached root hashes on notification, other callers of its checks, and managementMetricsOnly's first release; v3.0.3 notes add adminAuthEnabled. - chroma-bind (REASONED; chroma): Without a file chroma run defaults localhost; a config file defaults listen_address to 0.0.0.0 after CHROMA_ merges; official config sets only persist_path, yielding wildcard 8000. - chroma-auth (REASONED; cd, chroma): Built-in authentication was removed in v1.0.0; chroma run 1.5.9 uses the default identity for authentication and authorization. Obsolete AUTHN variables do not protect the server; use private binding and authenticated TLS fronting. - pgvector (REASONED; pgvector-source): pgvector is a PostgreSQL extension on 5432 for PostgreSQL 13+; use PostgreSQL TLS, hostssl/SCRAM, verify-full and least-privilege roles. - pgvector-rls (REASONED; pg): Tenant RLS filters shared embeddings; prove tenant-B isolation with known rows and bypass controls, counting per tenant rather than trusting top-k. - hosted-mfa (REASONED; qc, pc, wd, qc-pinned): This guide assesses that Qdrant Cloud's documented API keys and, at the time of writing, Pinecone SSO do not establish human MFA. It advises self-hosted Weaviate's database OIDC settings with an MFA-enforcing IdP, or MFA-capable fronting; this is not Weaviate Cloud console SSO and establishes no hosted-console control. Wherever SSO is used, require an MFA-enforcing IdP and confirm enforcement. - hosted-pinecone (REASONED; pc): At the time of writing, Pinecone documents organization SSO through an organization's identity management solution on Standard and Enterprise plans; SSO console sessions are re-authenticated at least every 24 hours. This guide assesses that this does not establish MFA enforcement and advises requiring and confirming MFA through the SSO identity provider. - hosted-qdrant (REASONED; qc-pinned): At the time of writing, Qdrant Cloud's documentation source lists passwordless email, Google, GitHub and enterprise SSO registration, with Premium SSO for console sign-in only and Database API keys for cluster requests. This guide assesses that deployed IdP MFA enforcement is unestablished and advises requiring an MFA-enforcing SSO IdP and confirming enforcement. - hosted-weaviate-mfa (REASONED; wc): At the time of writing, Weaviate Cloud's documentation source says any member can enable account MFA, only Owners control enforcement for all members, enforcement is available to every organization, and both default off. It says SSO setup turns enforcement off and SSO sign-ins never prompt for a Weaviate Cloud MFA code. This guide advises confirming MFA in the SSO identity provider. - hosted-weaviate-sso (REASONED; wc): At the time of writing, Weaviate Cloud's documentation source says that after a member's first SSO sign-in, SSO applies even in MFA-enforcing organizations; disabling SSO leaves enforcement off until an Owner restores it. This guide assesses that deployed IdP MFA enforcement is unestablished and advises requiring and confirming it. - hosted-qdrant-sso (REASONED; qc-source): At the time of writing, Qdrant Cloud's documentation source lists OpenID Connect and SAML for Premium-tier console SSO and says to enable it through a Qdrant support-portal ticket. This guide assesses that this does not establish MFA enforcement and advises requiring and confirming MFA through the SSO identity provider. - hosted-qdrant-mfa-gap (REASONED; qc-pinned, qc-source, qc): This guide's source assessment: the cited Qdrant sources and account-setup page document no console MFA control; the user-profile page, fetched 2026-11-04, documents optional per-user MFA, as documentation, not observed console behaviour. None shows account-level enforcement, which does not establish its absence. This guide advises requiring console MFA and, for SSO, a confirmed MFA-enforcing IdP. - hosted-weaviate-login (REASONED; wc): At the time of writing, Weaviate Cloud's documentation source specifies SAML, not OIDC, for Managed organizations on a Weaviate-managed contract; only Owners control console SSO, which does not affect cluster API keys. It says that after a person's first SSO sign-in, email/password and Google sign-in stop working for that account across every organization the person belongs to. - hosted-source-limits (REASONED; wc, qc-source): This guide assesses that the pinned Weaviate and Qdrant documentation sources do not by themselves establish published-page content, publication or release timing, or live-console behaviour. The Weaviate MFA source's author flags enforcement UI labels, dialog titles, tooltips and numbers as needing live-console checks; this guide does not treat them as verified. - hosted-sso-mfa-assessment (REASONED; wc): This guide infers that MFA with Weaviate Cloud SSO depends on the IdP because its documented flow disables Cloud MFA enforcement and omits Cloud MFA prompts. This guide advises requiring MFA for human console access and an MFA-enforcing IdP wherever SSO is used, and confirming enforcement; these sources do not establish deployed IdP MFA enforcement. - hosted-qdrant-published (REASONED; qc): When checked in October 2026, Qdrant Cloud's published account-setup page said Premium Tier SSO is console-only, leaves cluster requests on Database API keys and needs a support ticket; on 2026-11-04 it has the Premium Tier and console-only statements and its linked Premium Tier page the rest. This guide assesses that neither establishes MFA enforcement; require and confirm MFA in the SSO IdP. - hosted-weaviate-published (REASONED; wd): At the time of writing, Weaviate Cloud's published MFA and SSO pages say completing SSO setup turns MFA enforcement off, SSO sign-ins never ask for a Weaviate Cloud MFA code, and enforcement stays off after SSO is turned off. This guide infers that MFA for SSO sign-ins depends on the IdP; these pages do not establish publication or release timing or live-console behaviour. - hosted-weaviate-published-enforcement (REASONED; wd): At the time of writing, Weaviate Cloud's published MFA page says only Owners can see and change MFA enforcement, which is available to every organization and off by default, and recommends enforcing MFA in the identity provider. This guide treats this as documentation, not observed console behaviour. - hosted-zilliz-mfa (REASONED; zc): At the time of writing, Zilliz Cloud's published MFA page says built-in TOTP authenticator-app MFA is only for accounts registered with an email address and password; SSO organizations' identity provider manages MFA, and Google- or GitHub-linked accounts use those providers' MFA. This guide assesses that deployed IdP MFA enforcement is unestablished and advises requiring and confirming it. - hosted-zilliz-enforcement (REASONED; zc): At the time of writing, Zilliz Cloud's published MFA page says Organization Owners can enforce MFA for all organization users, given a valid payment method, an Enterprise project and a Dedicated cluster; users who do not complete MFA setup cannot access the organization. This guide treats this as documentation, not observed console behaviour. - hosted-pinecone-domain-sso (REASONED; pc): When checked in October 2026, Pinecone's security overview said that once SSO is configured, an organization can require users from its domain to sign in through SSO. This guide's source assessment: that page, the organizations and members pages checked then, and the indexes and Okta SCIM page read on 2026-11-04 do not mention MFA, which does not establish that Pinecone has no console MFA control. - hosted-qdrant-mfa-search (REASONED; qc-source, qc): This guide's assessment of a case-insensitive search of the Qdrant documentation source tree at commit be9bbd58 for multi-factor, two-factor, MFA, 2FA, authenticator, TOTP, passkey and WebAuthn: no file matched. A search that finds nothing does not establish that Qdrant Cloud has no console MFA control; its published user-profile page, fetched 2026-11-04, documents one. - hosted-zilliz-enforcement-scope (REASONED; zc): When checked in October 2026, Zilliz Cloud's MFA page said enforcement requires all organization users to set up MFA to sign in, prompting those without it at their next login, and that in SSO organizations the IdP, not Zilliz Cloud, manages MFA. This guide's source assessment: the page does not say how enforcement applies to SSO users or Google- or GitHub-linked accounts. - hosted-pinecone-login (REASONED; pc): When checked in October 2026, Pinecone's Okta SSO page said members can log in with SSO or with their Pinecone credentials unless SSO is enforced for all users, and Pinecone's login-code page described a login code received by email. This guide's source assessment: neither page nor the Okta role-management page mentions MFA, which does not establish that no Pinecone console MFA control exists. - hosted-zilliz-sso-enforcement (REASONED; zc): When checked in October 2026, Zilliz Cloud's SSO enforcement page said configured SSO alone leaves email/password, Google and GitHub sign-in open, enforcement disables any MFA enabled for the organization on Zilliz Cloud, and Owners are exempt by default. This guide's source assessment: it does not say whether Zilliz Cloud MFA covers exempt Owners' or unenforced organizations' non-SSO sign-ins. - hosted-zilliz-linked-accounts (REASONED; zc): When checked in October 2026, Zilliz Cloud's email-accounts page said switching to Google or GitHub login needs MFA off first, its MFA page that members cannot disable MFA under enforcement, and its registration page to disable provider MFA that blocks sign-up. This guide's source assessment: no page read says how enforcement applies to linked accounts or to SSO users without SSO enforcement. - hosted-weaviate-published-ui (REASONED; wd, wc): When checked in October 2026 and on 2026-11-04, Weaviate Cloud's published MFA page named a Require MFA for all members switch on the Users and Members tab, with dialogs to enforce and stop enforcing MFA. This guide's source assessment: the pinned source's note that these labels need live-console checks is a comment the page does not display; the labels are not verified console behaviour. - hosted-source-history (REASONED; wc, qc-source): From source reading of line history at the pins, the cited Weaviate Cloud MFA and SSO source lines, except the MFA page's opening lines, were last changed in commit 9fbb108b and the opening and closing lines (L94, L106) of Qdrant's cited SSO passage in commit 6efd6b1f, both dated 2026-10-13. This guide assesses that commit dates do not establish publication or release timing. - verify-inventory (REASONED; qd, docker): Read all listener rows, container namespaces and publications; expected-port grep and host ss alone can miss exposure. - verify-peer (REASONED; qd, curl-docs): Probe real backend 6333/6334/6335 from non-peers; require connection failure with filtering evidence and a positive peer control for 6335; auth/TLS rejection is still reachability. - verify-curl (REASONED; curl-docs): exit 3/6 is invalid input; 7 or a five-second 28 needs corroboration; nonzero HTTP, 52/56 or a twenty-second 28 indicates reachability, including HTTP 000 cases. - verify-qdrant-auth (REASONED; qd): Frontend collections check expects anonymous 401/403 and keyed 200 over verified TLS; both can pass while peer port 6335 is exposed. - verify-fronting (REASONED; wd, cd): Weaviate schema rejects anonymous access and allows a valid key; Chroma requires fronting denial and authorized success because it has no native auth. - verify-milvus-auth (REASONED; m3): MilvusClient without a token must fail after authorization is enabled, while the same operation with the application identity succeeds. - verify-milvus-management (REASONED; m2, m3, docker): Outside-admin reachability fails; private control needs healthy Compose status and the milvus-owned listener, since healthy does not identify the port's process or show roles stopped through the management API, and bind failure is nonfatal. Milvus v2.6.25 notes list a fix for a restarting node reporting ready on /healthz before all components registered (#53736). - verify-milvus-optin (REASONED; m2, m3): Without its opt-in key, either release answers an anonymous log-level request with 200; with v2.6.26 managementMetricsOnly it answers 404, and with v3.0.5 adminAuthEnabled 401, while root credentials with the admin request header get 200. - milvus-kubernetes-service (REASONED; milvus-helm, milvus-operator): Helm chart 5.0.30 and Operator v1.3.11 place metrics 9091 on the client Service, with a declared ClusterIP default; the port list includes both ports whatever the Service type unless Helm metrics.enabled is false. Helm removes only the Service metrics port; Operator disableMetric skips PodMonitor reconciliation, not the Service port. Chart appVersion 3.0.1 is separate from the audited Milvus versions. ````json { "schema": 1, "checked": "2026-11-04", "documentation_checked": "2026-10", "body_sha256": "04d1e9bf9356da7fe59f9e13c5de39812ac94cccb41efb77ac84d82b6f8e40ab", "components": { "q": { "name": "Qdrant source", "basis": "v1.20.0", "sources": { "s9ba3219e58b6": "https://github.com/qdrant/qdrant/blob/v1.20.0/src/settings.rs#L318-L355", "sd40ccfe472fa": "https://github.com/qdrant/qdrant/blob/v1.20.0/config/config.yaml#L390-L394", "s13127d2b209f": "https://github.com/qdrant/qdrant/blob/v1.20.0/config/development.yaml#L13-L15", "s1a27322fe3be": "https://github.com/qdrant/qdrant/blob/v1.20.0/config/production.yaml#L3-L5", "s44110a9ab1e1": "https://github.com/qdrant/qdrant/blob/v1.20.0/Dockerfile#L225-L246", "s207f4baf8240": "https://github.com/qdrant/qdrant/blob/v1.20.0/tools/entrypoint.sh#L21" } }, "w": { "name": "Weaviate source", "basis": "v1.40.4", "sources": { "s564b7c5d1d1e": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/config_handler.go#L1412-L1447", "s261677313014": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1942-L1958", "se47148d880d0": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1970-L1986", "s53481f7bd418": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L1056-L1060", "sa44c57b26642": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L2191-L2234", "scbc3996fddae": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/grpc/server.go#L480-L482", "sc225b95d2bfe": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L47-L53", "s1348f35433b2": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L89-L90", "s79ec0f2257bd": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L97-L100", "s5505b1ceb42b": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L303-L310", "s1b84d37336c0": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L345-L349", "s05626232c4d0": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/embedded_spec.go#L39-L41", "s3d8f171662c3": "https://github.com/weaviate/weaviate/blob/v1.40.4/Dockerfile#L54-L62", "s4108365a8200": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/handlers_debug_gate.go#L23-L33", "s828adabd1132": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L334-L341", "sccda241288a0": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L233-L285", "sce92f1964e9e": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L2673-L2717", "s2687ed3a976a": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L96-L99", "s8fab4ef2ba7b": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/cluster/state.go#L431-L446", "sfc359afe2359": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/clusterapi/serve.go#L47-L49", "sde3380b7e33a": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/clusterapi/serve.go#L140", "seaf03f32bfa1": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/cluster/state.go#L695-L705", "s06d4f12f3b27": "https://github.com/weaviate/weaviate/blob/v1.40.4/cluster/store.go#L692-L693", "se167960574cc": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L2105-L2107", "sb1e7f0e21b9d": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/cluster/state.go#L662-L673", "sa482d3446093": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1866", "sf47d791a63d7": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L858-L859", "sb10ea2405899": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L38-L39", "s7f9ddf8fdbd6": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L661", "sae0b7fbe4e92": "https://github.com/weaviate/weaviate/blob/v1.40.4/cluster/service.go#L154", "sd6c3451b60a5": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L2073", "sbec6a21a73b4": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1668", "scb13e309f474": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/grpc.go#L27-L33", "s41bb320a3003": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/server.go#L86", "sbe9515009883": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L507-L514", "s4cd68783de09": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/environment.go#L740-L743", "s76b747e1da69": "https://github.com/weaviate/weaviate/blob/v1.40.4/usecases/config/config_handler.go#L1440-L1443", "s5a4ccf91749b": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1802-L1805", "se2d336ec1e85": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L756-L766", "s99aebb8387e8": "https://github.com/weaviate/weaviate/blob/v1.40.4/adapters/handlers/rest/configure_api.go#L1669", "s0e63e7982457": "https://github.com/weaviate/weaviate/releases/tag/v1.40.4", "s8aca6ad316e3": "https://github.com/weaviate/weaviate/releases/tag/v1.39.15", "s4789e1a4da19": "https://github.com/weaviate/weaviate/releases/tag/v1.40.3" } }, "m2": { "name": "Milvus", "basis": "v2.6.26", "sources": { "s01c202300236": "https://github.com/milvus-io/milvus/blob/v2.6.26/build/docker/milvus/ubuntu22.04/Dockerfile", "sfe21bcc8ab06": "https://github.com/milvus-io/milvus/blob/v2.6.26/deployments/docker/standalone/docker-compose.yml#L39-L66", "s53fdb89eb68e": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/distributed/utils/util.go#L41-L66", "s1e927c8d4a62": "https://github.com/milvus-io/milvus/blob/v2.6.26/go.mod#L3", "sac9a6777dc2f": "https://github.com/milvus-io/milvus/blob/v2.6.26/go.mod#L54", "sb6990709043f": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/http/server.go#L77-L140", "s2de9c40a20d4": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/http/server.go#L215-L258", "s236dba1672cd": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/http/management_mux.go#L21-L111", "s558bd4e95221": "https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/util/paramtable/component_param.go#L1006-L1022", "se28a0f2e81f6": "https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/util/paramtable/component_param.go#L362", "s3b493b573112": "https://github.com/milvus-io/milvus/blob/v2.6.26/configs/milvus.yaml#L1089-L1097", "s2a343a370e3e": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/coordinator/restful_mgr_routes.go#L1384-L1385", "s5037adf4aadf": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/proxy/management.go#L42-L97", "s2b873c61b4cd": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/coordinator/restful_mgr_routes.go#L37-L75", "sd2b47a819a04": "https://github.com/milvus-io/milvus/blob/v2.6.26/internal/distributed/proxy/service.go#L130-L191", "s283bf4d45460": "https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/util/paramtable/http_param.go#L44-L55", "s83c53f3ea904": "https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/metrics/metrics.go#L21", "s9dc3b55136ce": "https://github.com/milvus-io/milvus/blob/v2.6.26/pkg/eventlog/grpc.go#L113-L146", "s75b53cd6717b": "https://github.com/milvus-io/milvus/releases/tag/v2.6.25" } }, "m3": { "name": "Milvus", "basis": "v3.0.5", "sources": { "sc5c7a40cbdf0": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/base_table.go#L69-L72", "sf65a8afe34ad": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/base_table.go#L256-L272", "s2407c5ccdbdc": "https://github.com/milvus-io/milvus/blob/v3.0.5/build/docker/milvus/ubuntu22.04/Dockerfile#L39", "s5a294e89d544": "https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L65-L96", "sb285f0159d45": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/base_table.go#L145-L153", "sa3b4435a0c26": "https://github.com/milvus-io/milvus/blob/v3.0.5/build/docker/milvus/ubuntu22.04/Dockerfile#L58-L59", "s0fc76cfd12d6": "https://github.com/milvus-io/milvus/blob/v3.0.5/build/docker/milvus/ubuntu22.04/Dockerfile#L16", "s8f60873e0428": "https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L6-L28", "sb56899fc45ba": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1276-L1298", "s47066fb5c3c4": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/grpc_param.go#L144-L175", "s0555f89b62a2": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/netutil/listener.go#L11-L35", "sd889b8c530a6": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/netutil/listener.go#L92-L103", "s1c3252837630": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L411", "s7df7aad8423a": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L534", "s24618eab857a": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L776", "s8b247866ea0c": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1071", "s495a9dbca908": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1579", "s353e67ab44d7": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/querynode/service.go#L123-L126", "s3fc8e813852a": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/datanode/service.go#L82-L85", "sc34ce1db6269": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/streamingnode/service.go#L106-L109", "s9669ecb0d050": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/mixcoord/service.go#L107-L110", "s864564cd48f9": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/listener_manager.go#L56-L58", "sc6203c40aebf": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/interceptor/cluster_interceptor.go#L34-L48", "s97937d52a21f": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/interceptor/server_id_interceptor.go#L36-L54", "s6067b4589b1f": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/querynode/service.go#L243-L253", "sed3174160514": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/proto/query_coord.proto#L109-L147", "sa2caeac48e35": "https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/milvus/util.go#L142-L147", "s289754aa1ee8": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/utils/util.go#L39-L63", "sa8e8b5a2eb14": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1301", "s0ef11d94f3d1": "https://github.com/milvus-io/milvus/blob/v3.0.5/go.mod#L3", "s25182f5d27dd": "https://github.com/milvus-io/milvus/blob/v3.0.5/go.mod#L50", "s8129a3a1ac96": "https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L47-L58", "s3e07ca99050b": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/service_param.go#L1902-L1930", "sf22822567b4c": "https://github.com/milvus-io/milvus/blob/v3.0.5/deployments/docker/standalone/docker-compose.yml#L3", "see857573ab82": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L40-L43", "scd4175e676e0": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L445-L542", "sc2acd0036b94": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L82-L100", "sc60a0333fb8c": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L145-L246", "s599ca2efbe5d": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L365-L427", "s94628c99d2ad": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L42-L76", "s2b6695735806": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L34-L73", "s522ed8fdaa1f": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/distributed/proxy/service.go#L129-L208", "s7fb08667d0a5": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/auth.go#L223-L293", "s76052118cc58": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L169-L215", "sadd70dbf8e7c": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L398-L418", "s0edc7eaf5bd4": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/proxy/management.go#L671-L697", "sd399c2cd69eb": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/rootcoord/root_coord.go#L3465-L3504", "s15d89258dff4": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/util/hookutil/cipher.go#L391-L402", "sb28a3a9ebee9": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/util/hookutil/ez.go#L119-L131", "sc8cd5203f059": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/mix_coord.go#L266-L269", "s4629ea15b1ac": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L880-L889", "s01eefc8f4f23": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L942-L1009", "s739205667c16": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/http_param.go#L131-L138", "s02f64a0d1975": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/metrics/metrics.go#L23-L26", "s4c82ce60440b": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/http_param.go#L52-L62", "s223d3c194d1b": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/eventlog/grpc.go#L123-L135", "s86f28d468834": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/eventlog/grpc.go#L170-L219", "se949c7efe55d": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/eventlog/handler.go#L54-L69", "s90be0eb93a49": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/component_param.go#L1064-L1091", "se60ba0898da2": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/component_param.go#L376", "sa889eb74b74e": "https://github.com/milvus-io/milvus/blob/v3.0.5/configs/milvus.yaml#L1278-L1292", "sa9b6da53e1ff": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/auth.go#L33-L37", "scecb75a7bc82": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/auth.go#L114-L176", "s6a00ac9c991b": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/rbac.go#L97-L135", "se9c13f3b1c11": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/coordinator/restful_mgr_routes.go#L1364-L1366", "s50d4c546779b": "https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/roles/roles.go#L379-L389", "s388b12b5a895": "https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/roles/roles.go#L532-L533", "s3c5f478158fb": "https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/roles/roles.go#L607-L622", "s235d67599933": "https://github.com/milvus-io/milvus/blob/v3.0.5/cmd/milvus/util.go#L123-L198", "sc110734f2f03": "https://github.com/milvus-io/milvus/blob/v3.0.5/pkg/util/paramtable/component_param.go#L1056-L1061", "sba92dc5b6642": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/healthz/healthz_handler.go#L91-L142", "sa040ef47f39f": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/http/server.go#L281-L300", "s5ce12da57df9": "https://github.com/milvus-io/milvus/blob/v3.0.5/internal/util/adminauth/cache.go#L216-L225", "s896663f32f5b": "https://github.com/milvus-io/milvus/releases/tag/v3.0.3" } }, "milvus-helm": { "name": "Milvus Helm chart", "basis": "5.0.30", "sources": { "s03d63e5e72b6": "https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/Chart.yaml#L1-L6", "s65da7040fa71": "https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/values.yaml#L115-L136", "sc9c86596c55c": "https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/values.yaml#L169-L194", "s5218b8a9a776": "https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/templates/service.yaml#L1-L60", "s533d9f94c237": "https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/templates/standalone-deployment.yaml#L83-L110", "s8b7f45ad4f54": "https://github.com/zilliztech/milvus-helm/blob/milvus-5.0.30/charts/milvus/templates/proxy-deployment.yaml#L98-L125" } }, "milvus-operator": { "name": "Milvus Operator", "basis": "v1.3.11", "sources": { "s11cd8aea4a46": "https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/components.go#L20-L69", "scaa2f4b8f5b3": "https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/components.go#L450-L487", "se5839563f89c": "https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/services.go#L15-L47", "s58da9e232a84": "https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/services.go#L101-L107", "s585ae37fd0ca": "https://github.com/zilliztech/milvus-operator/blob/v1.3.11/apis/milvus.io/v1beta1/components_types.go#L500-L525", "sca0e4a51219f": "https://github.com/zilliztech/milvus-operator/blob/v1.3.11/pkg/controllers/podmonitor.go#L91-L100" } }, "chroma": { "name": "Chroma source", "basis": "1.5.9", "sources": { "s7eb7610f68c1": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L60-L73", "s6d6708278bef": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/config.rs#L140-L146", "s59912a4c7e11": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/sample_configs/docker_single_node.yaml", "sed8aad199780": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L39-L45", "s3a1c76cdd7f8": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L114-L134", "s09652c32f56a": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/config.rs#L176-L179", "s784a0e779949": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/config.rs#L216-L229", "se936b62a1a3d": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/cli/src/commands/run.rs#L139-L142", "sbc1dd27a9bf2": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/auth/mod.rs#L102-L164", "s50b8158f3388": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/server.rs#L229-L255", "s6eb0ff350d15": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/lib.rs#L60-L156", "s62cd8db9e61a": "https://github.com/chroma-core/chroma/blob/1.5.9/rust/frontend/src/server.rs#L457-L486" } }, "cd": { "name": "Chroma migration documentation", "basis": "v1.0.0", "sources": { "s60ad33c4b715": "https://docs.trychroma.com/docs/overview/migration" } }, "pgvector-source": { "name": "pgvector source", "basis": "v0.8.7", "sources": { "s13e1ca059e33": "https://github.com/pgvector/pgvector/blob/v0.8.7/README.md" } }, "qd": { "name": "Qdrant security documentation", "basis": "unknown", "sources": { "s753402e46eed": "https://qdrant.tech/documentation/security/" } }, "qc": { "name": "Qdrant Cloud documentation", "basis": "unknown", "sources": { "sc72e09d8f7e6": "https://qdrant.tech/documentation/cloud/", "s83d796d79bed": "https://qdrant.tech/documentation/cloud-account-setup/", "seb1efb17f9fd": "https://qdrant.tech/documentation/cloud-user-profile/", "s420c86cd2402": "https://qdrant.tech/documentation/cloud-premium/" } }, "wd": { "name": "Weaviate documentation", "basis": "unknown", "sources": { "sa066265b9009": "https://docs.weaviate.io/deploy/installation-guides/docker-installation", "s51c9c4c6b4c0": "https://docs.weaviate.io/deploy/configuration/authentication", "se90546c311e1": "https://docs.weaviate.io/deploy/configuration/env-vars", "sb87f55ae205e": "https://docs.weaviate.io/deploy/configuration/authorization", "sd5185e505828": "https://docs.weaviate.io/deploy/configuration/configuring-rbac", "sbc37c5ecfb18": "https://docs.weaviate.io/cloud/platform/multi-factor-auth", "s5771a47941ca": "https://docs.weaviate.io/cloud/platform/single-sign-on" } }, "pg": { "name": "PostgreSQL documentation", "basis": "unknown", "sources": { "se9846041ce1f": "https://www.postgresql.org/docs/current/ddl-rowsecurity.html" } }, "docker": { "name": "Docker documentation", "basis": "unknown", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/", "s1dcef979ee57": "https://docs.docker.com/reference/compose-file/services/#env_file", "s8f518c10ba0d": "https://docs.docker.com/compose/how-tos/use-secrets/", "s7d4675617251": "https://docs.docker.com/reference/dockerfile/#healthcheck" } }, "k8s": { "name": "Kubernetes documentation", "basis": "unknown", "sources": { "s953450b4076f": "https://kubernetes.io/docs/concepts/services-networking/network-policies/" } }, "grpc2": { "name": "grpc-go (Milvus v2.6.26 go.mod)", "basis": "v1.82.1", "sources": { "sa23456a265f8": "https://github.com/grpc/grpc-go/blob/v1.82.1/internal/transport/http2_server.go#L151-L167", "s75406e3b5c45": "https://github.com/grpc/grpc-go/blob/v1.82.1/credentials/tls.go#L222-L257", "s4e9b30536409": "https://github.com/grpc/grpc-go/blob/v1.82.1/credentials/tls.go#L302-L308" } }, "ml": { "name": "memberlist (Weaviate v1.40.4 go.mod)", "basis": "v0.5.4", "sources": { "s06affccfd439": "https://github.com/hashicorp/memberlist/blob/v0.5.4/config.go#L302-L307", "s207b294bb8b4": "https://github.com/hashicorp/memberlist/blob/v0.5.4/net_transport.go#L96-L110", "s8b9a9e831368": "https://github.com/hashicorp/memberlist/blob/v0.5.4/net_transport.go#L140-L160" } }, "goflags": { "name": "go-flags", "basis": "v1.6.1", "sources": { "sbd8f9e746696": "https://github.com/jessevdk/go-flags/blob/v1.6.1/option.go#L328-L343" } }, "gostd2": { "name": "Go standard library", "basis": "go1.25.8", "sources": { "s2e6578103750": "https://github.com/golang/go/blob/go1.25.8/src/crypto/tls/common.go#L335-L341", "s0e02efe6f050": "https://github.com/golang/go/blob/go1.25.8/src/crypto/tls/common.go#L681-L683" } }, "pc": { "name": "Pinecone organization documentation", "basis": "unknown", "sources": { "s95422bfab587": "https://docs.pinecone.io/admin/organizations/understanding-organizations", "sb0fa7b7afc6f": "https://docs.pinecone.io/admin/security/security-overview", "s50c4ed3cc487": "https://docs.pinecone.io/admin/organizations/manage-organization-members", "sa7d03b94febb": "https://docs.pinecone.io/admin/account/login-code-issues", "se6661f61cd95": "https://docs.pinecone.io/admin/security/configure-single-sign-on/okta", "s2d4f3dc1136e": "https://docs.pinecone.io/admin/security/configure-single-sign-on/okta-role-management", "s7a0d3675305c": "https://docs.pinecone.io/_llms/pinecone-database.txt", "seaee0c38aa88": "https://docs.pinecone.io/_llms/pinecone-database/guides.txt", "scac2d35cedd4": "https://docs.pinecone.io/_llms/pinecone-database/reference.txt", "s898fdbbf3bf8": "https://docs.pinecone.io/_llms/pinecone-database/reference/2026-07-latest.txt", "saa2ef652673d": "https://docs.pinecone.io/_llms/pinecone-database/reference/2026-04.txt", "s517e8bbf92cb": "https://docs.pinecone.io/_llms/pinecone-assistant.txt", "s8dee47b6a4df": "https://docs.pinecone.io/_llms/pinecone-assistant/reference.txt", "se0694c4ae7b9": "https://docs.pinecone.io/admin/security/configure-single-sign-on/okta-scim-provisioning.md" } }, "curl-docs": { "name": "curl documentation (rolling)", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html", "s596c6f706038": "https://curl.se/docs/manpage.html#-f" } }, "qc-pinned": { "name": "Qdrant Cloud documentation source", "basis": "61a5b32ae31009bc49d3c8afa91eb9242e4368df", "sources": { "s19a1a255e9de": "https://github.com/qdrant/landing_page/blob/61a5b32ae31009bc49d3c8afa91eb9242e4368df/qdrant-landing/content/documentation/cloud-account-setup.md#L15-L20", "sdb4501285ace": "https://github.com/qdrant/landing_page/blob/61a5b32ae31009bc49d3c8afa91eb9242e4368df/qdrant-landing/content/documentation/cloud-account-setup.md#L94-L104", "sac497c4d41f5": "https://github.com/qdrant/landing_page/blob/61a5b32ae31009bc49d3c8afa91eb9242e4368df/qdrant-landing/content/documentation/cloud/authentication.md#L68-L69" } }, "wc": { "name": "Weaviate Cloud MFA and SSO documentation source", "basis": "5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9", "sources": { "sf898b01ade26": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L8-L10", "s9663f4d166ac": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L132", "s800400caba04": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L266-L272", "sd7dea65bb6d0": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L300", "s6f6eb09e2100": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/single-sign-on.mdx#L9-L14", "sb0789be8eb2e": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/single-sign-on.mdx#L131", "s2279ff6909dd": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L13", "s24059634e4c7": "https://github.com/weaviate/docs/blob/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx#L134-L135", "sc6b9969fb3d0": "https://github.com/weaviate/docs/blame/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/multi-factor-auth.mdx", "s202931b741a1": "https://github.com/weaviate/docs/blame/5dcf1d6dcd357a4cfaf613a63d052b932cd2f9c9/docs/cloud/platform/single-sign-on.mdx" } }, "qc-source": { "name": "Qdrant Cloud documentation source", "basis": "be9bbd5809c40707987d87f4a012c58088fc2ee6", "sources": { "sb5c169f992d9": "https://github.com/qdrant/landing_page/blob/be9bbd5809c40707987d87f4a012c58088fc2ee6/qdrant-landing/content/documentation/cloud-account-setup.md#L94-L106", "sf6beca47175e": "https://github.com/qdrant/landing_page/tree/be9bbd5809c40707987d87f4a012c58088fc2ee6/qdrant-landing/content/documentation", "sf6345ca76a7a": "https://github.com/qdrant/landing_page/blame/be9bbd5809c40707987d87f4a012c58088fc2ee6/qdrant-landing/content/documentation/cloud-account-setup.md" } }, "zc": { "name": "Zilliz Cloud documentation", "basis": "unknown", "sources": { "sbf5c52cae6f9": "https://docs.zilliz.com/docs/multi-factor-auth", "s8c72bbc1f8be": "https://docs.zilliz.com/docs/enforce-sso-in-your-organization", "s5c11c50ef262": "https://docs.zilliz.com/docs/email-accounts", "s4e00709d071c": "https://docs.zilliz.com/docs/register-with-zilliz-cloud", "s18913511c5c8": "https://docs.zilliz.com/docs/single-sign-on", "s264bb7816c73": "https://docs.zilliz.com/docs/single-sign-on-with-okta", "s6953cf1328c8": "https://docs.zilliz.com/docs/openid-connect" } } }, "claims": { "private-publication": {"text": "Bind privately and publish only a TLS proxy, tunnel or tailnet; Docker loopback mapping avoids default wildcard publication.", "components": ["docker"], "sources": ["docker:s1e53417c513d"], "status": "REASONED"}, "qdrant-ports": {"text": "Qdrant REST 6333 and gRPC 6334 are client ports; internal gRPC 6335 opens only in distributed mode, including an initial single peer, and binds service.host unless cluster.p2p.host (v1.19.2 and later) is set.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED"}, "qdrant-bind": {"text": "Compiled host is 0.0.0.0; config/config, RUN_MODE (development default), config/local, deb config, config-path and QDRANT__ variables overlay in that order.", "components": ["q"], "sources": ["q:s9ba3219e58b6", "q:sd40ccfe472fa"], "status": "REASONED"}, "qdrant-image": {"text": "Development overlay binds 127.0.0.1; official image sets RUN_MODE=production, keeps 0.0.0.0 and starts qdrant without arguments.", "components": ["q"], "sources": ["q:s13127d2b209f", "q:s1a27322fe3be", "q:s44110a9ab1e1", "q:s207f4baf8240"], "status": "REASONED"}, "qdrant-client-auth": {"text": "Self-deployed Qdrant defaults insecure; api_key and read_only_api_key gate client REST/gRPC through api-key or Bearer headers, with QDRANT__SERVICE__ environment equivalents.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED"}, "qdrant-client-tls": {"text": "service.enable_tls with tls.cert/key enables client TLS; keys without TLS are insecure.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED"}, "qdrant-peer-auth": {"text": "Through v1.17.x keys do not gate 6335; v1.18.0 adds enforce_internal_auth, default-on from v1.20.0 but without effect when no API key is configured, so under enforcement a cluster with only read_only_api_key loses peer traffic; enforcement does not replace the peer-only network restriction.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED"}, "qdrant-peer-tls": {"text": "cluster.p2p.enable_tls is separate from client TLS and needs a provisioned tls.ca_cert; compiled default path is not a substitute for provisioning.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED"}, "qdrant-rotation": {"text": "alt_api_key accepts an additional client key from v1.17.0 and is checked internally only under enforce_internal_auth from v1.18.0 (default-on from v1.20.0); rotate peer-by-peer, setting the new key on every peer before promoting it, keeping the old key as alt_api_key until every peer uses the new key, and upgrading an alt_api_key-only cluster on v1.19.0 or v1.19.1 to v1.19.2 first.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED"}, "weaviate-precedence": {"text": "File, environment then flags override settings; environment reapplies gRPC/Raft port defaults when unset/empty and gossip/API defaults when unset; empty cluster port variables fail startup.", "components": ["w"], "sources": ["w:s564b7c5d1d1e", "w:s261677313014", "w:se47148d880d0", "w:s53481f7bd418", "w:sa44c57b26642", "w:s4cd68783de09", "w:s76b747e1da69", "w:s5a4ccf91749b"], "status": "REASONED"}, "weaviate-rest": {"text": "Default scheme is HTTPS requiring certificates or startup fails; TLS host falls back to host and TLS port is random. HTTP host defaults localhost unless HOST is exported empty; port defaults random; unix uses socket-path.", "components": ["w", "goflags"], "sources": ["w:sc225b95d2bfe", "w:s1348f35433b2", "w:s79ec0f2257bd", "w:s5505b1ceb42b", "w:s1b84d37336c0", "w:s05626232c4d0", "w:s41bb320a3003", "goflags:sbd8f9e746696"], "status": "REASONED"}, "weaviate-image": {"text": "Official image selects HTTP on 0.0.0.0:8080; documented proxy forwards both 8080 and gRPC 50051 with TLS at the proxy.", "components": ["w", "wd"], "sources": ["w:s3d8f171662c3", "wd:sa066265b9009"], "status": "REASONED"}, "weaviate-grpc": {"text": "gRPC always starts on a wildcard address with default port 50051 (configurable via GRPC_PORT) and ignores REST host; keep it unpublished except through the intended proxy.", "components": ["w"], "sources": ["w:scbc3996fddae", "w:s53481f7bd418", "w:sd6c3451b60a5", "w:sbec6a21a73b4", "w:scb13e309f474"], "status": "REASONED"}, "weaviate-debug": {"text": "Debug HTTP defaults wildcard 6060 unless GO_PROFILING_DISABLE is true; debug endpoints return 404 until enabled. v1.40.4 passes the gated default mux to an unread self-recovery setup function; this guide infers the same 404 for those routes only if that function registers them there.", "components": ["w"], "sources": ["w:s4108365a8200", "w:s828adabd1132", "w:sce92f1964e9e", "w:sbe9515009883", "w:se2d336ec1e85"], "status": "REASONED"}, "weaviate-metrics": {"text": "Enabled Prometheus listener exposes metrics and tenant-activity on wildcard, default 2112 when enabled through environment; admit only scraper traffic.", "components": ["w"], "sources": ["w:s2687ed3a976a", "w:sccda241288a0"], "status": "REASONED"}, "weaviate-gossip": {"text": "Even one node opens TCP/UDP gossip 7946 at CLUSTER_BIND_ADDR, defaulting to wildcard when unset/empty; admit only peers.", "components": ["w", "ml"], "sources": ["w:sa44c57b26642", "w:seaf03f32bfa1", "w:se167960574cc", "w:sb1e7f0e21b9d", "w:sa482d3446093", "ml:s06affccfd439", "ml:s207b294bb8b4"], "status": "REASONED"}, "weaviate-cluster-api": {"text": "Internal cluster API binds wildcard 7947, default gossip port plus one, independently of REST host.", "components": ["w"], "sources": ["w:sfc359afe2359", "w:sde3380b7e33a", "w:sa44c57b26642", "w:se167960574cc", "w:sf47d791a63d7"], "status": "REASONED"}, "weaviate-raft": {"text": "Raft 8300 and RPC 8301 bind nonempty CLUSTER_BIND_ADDR, then CLUSTER_ADVERTISE_ADDR, then memberlist private advertise address; keep peer-only.", "components": ["w", "ml"], "sources": ["w:sa44c57b26642", "w:s06d4f12f3b27", "w:s8fab4ef2ba7b", "w:sb10ea2405899", "w:s7f9ddf8fdbd6", "w:sae0b7fbe4e92", "ml:s8b9a9e831368"], "status": "REASONED"}, "weaviate-open-questions": {"text": "At v1.40.4 this guide has not established whether and where the self-recovery debug routes are registered, which mux the Raft debug routes use, whether the MCP handlers open a listener, or which handlers the #13350 backups basic-auth fix covers; the v1.40.4 release notes list the 3 fixes #13749, #13651 and #13679 that v1.39.15 named and v1.40.3 did not, so v1.40.4 includes them.", "components": ["w"], "sources": ["w:se2d336ec1e85", "w:s99aebb8387e8", "w:s0e63e7982457", "w:s8aca6ad316e3", "w:s4789e1a4da19"], "status": "REASONED"}, "weaviate-auth": {"text": "Anonymous access defaults true; disable it and enable API keys with positional key-to-user mapping.", "components": ["wd"], "sources": ["wd:s51c9c4c6b4c0", "wd:se90546c311e1"], "status": "REASONED"}, "weaviate-rbac": {"text": "Enable RBAC, root only the admin, and scope app roles to collections; RBAC is stated generally available from v1.29 and cannot combine with admin-list authorization.", "components": ["wd"], "sources": ["wd:sb87f55ae205e", "wd:sd5185e505828"], "status": "REASONED"}, "weaviate-oidc": {"text": "OIDC issuer/client settings delegate human authentication to the IdP, where MFA can be enforced; authentication alone is not authorization.", "components": ["wd"], "sources": ["wd:s51c9c4c6b4c0"], "status": "REASONED"}, "milvus-config": {"text": "Compose mounts only data; mount user.yaml and TLS files read-only under /milvus, accounting for MILVUSCONF and milvus.yaml/_test.yaml/default.yaml/user.yaml precedence.", "components": ["m3"], "sources": ["m3:sc5c7a40cbdf0", "m3:sf65a8afe34ad", "m3:s2407c5ccdbdc", "m3:s5a294e89d544"], "status": "REASONED"}, "milvus-env": {"text": "Environment formatter lowercases, strips leading milvus. and removes slash/underscore/dot; use COMMON_SECURITY_* and TLS_* names, remove conflicting sources and recreate changed mounts/environment.", "components": ["m3"], "sources": ["m3:sb285f0159d45"], "status": "REASONED"}, "milvus-user": {"text": "The v3.0.5 Ubuntu image runs as milvus and its Compose file adds an init service that changes the data directory's owner to that user; the v2.6.26 Dockerfile sets no USER. Make config/certificates readable and restrict the private key to the service and trusted administrators.", "components": ["m2", "m3"], "sources": ["m3:s2407c5ccdbdc", "m3:sa3b4435a0c26", "m3:s0fc76cfd12d6", "m3:s8f60873e0428", "m2:s01c202300236"], "status": "REASONED"}, "milvus-auth": {"text": "Enable common.security.authorizationEnabled, replace built-in root/Milvus and create a per-app user; proxy auth does not protect internal services.", "components": ["m3"], "sources": ["m3:sb56899fc45ba"], "status": "REASONED"}, "milvus-tls": {"text": "tls server/key/CA paths and common.security.tlsMode 1 enable server TLS; mode 2 requires client certificates; enable authentication independently.", "components": ["m3"], "sources": ["m3:s47066fb5c3c4"], "status": "REASONED"}, "milvus-client": {"text": "External client gRPC uses 19530; publish only that port privately, apart from the optional loopback management mapping.", "components": ["m3"], "sources": ["m3:s5a294e89d544"], "status": "REASONED"}, "milvus-internal": {"text": "Internal proxy 19529, query 21123, data 21124, mix coordinator 22125 and streaming 22222 bind wildcard; node ports can fall back randomly and advertised ip is not a bind.", "components": ["m3"], "sources": ["m3:s0555f89b62a2", "m3:s7df7aad8423a", "m3:s24618eab857a", "m3:s8b247866ea0c", "m3:s1c3252837630", "m3:s495a9dbca908", "m3:s353e67ab44d7", "m3:s3fc8e813852a", "m3:sc34ce1db6269", "m3:s9669ecb0d050", "m3:s864564cd48f9", "m3:sd889b8c530a6"], "status": "REASONED"}, "milvus-internal-auth": {"text": "Internal routing interceptors pass calls without metadata and do not authenticate Search/Query; standalone runs all five components.", "components": ["m3"], "sources": ["m3:sc6203c40aebf", "m3:s97937d52a21f", "m3:s6067b4589b1f", "m3:sed3174160514", "m3:sa2caeac48e35"], "status": "REASONED"}, "milvus-internal-tls": {"text": "internaltlsEnabled defaults off and internal TLS is one-way. At v2.6.26 (grpc-go v1.82.1, Go 1.25.8) failed certificate/key loading logs a warning then serves plaintext; at v3.0.5, whose go.mod pins grpc-go v1.84.0 and Go 1.26.9, that fallback is not established.", "components": ["m2", "m3", "grpc2", "gostd2"], "sources": ["m3:s289754aa1ee8", "m3:sa8e8b5a2eb14", "m3:s0ef11d94f3d1", "m3:s25182f5d27dd", "m2:s53fdb89eb68e", "m2:s1e927c8d4a62", "m2:sac9a6777dc2f", "grpc2:sa23456a265f8", "grpc2:s4e9b30536409", "grpc2:s75406e3b5c45", "gostd2:s2e6578103750", "gostd2:s0e02efe6f050"], "status": "REASONED"}, "milvus-isolation": {"text": "Use a dedicated network, no host/shared namespace, and default-deny rules admitting peers by source including random fallback ports; exclude untrusted workloads/users from component hosts.", "components": ["docker", "k8s"], "sources": ["docker:s1e53417c513d", "k8s:s953450b4076f"], "status": "REASONED"}, "milvus-minio-ports": {"text": "Bundled MinIO publishes wildcard 9000 API and 9001 console while etcd remains internal; restrict both and replace default credentials.", "components": ["m3"], "sources": ["m3:s8129a3a1ac96"], "status": "REASONED"}, "milvus-storage-credentials": {"text": "Compose at v3.0.5 selects milvusdb/minio RELEASE.2024-12-18T13-15-44Z with MINIO_ACCESS_KEY/SECRET_KEY=minioadmin, which that image is not established to read; Milvus uses MINIO_ACCESS_KEY_ID/SECRET_ACCESS_KEY; rotate matching pairs together and confirm minioadmin is refused.", "components": ["m3"], "sources": ["m3:s8129a3a1ac96", "m3:s3e07ca99050b"], "status": "REASONED"}, "milvus-env-files": {"text": "Use protected untracked env-files, delete overriding Compose environment credential entries, retain ETCD_ENDPOINTS/MINIO_ADDRESS/MINIO_REGION and recreate both services; container environment remains inspectable.", "components": ["docker"], "sources": ["docker:s1dcef979ee57", "docker:s8f518c10ba0d"], "status": "REASONED"}, "milvus-compose-images": {"text": "Compose at v2.6.26 and v3.0.5 selects older Milvus images v2.6.25 and v3.0.4; explicitly choose and recheck the intended image.", "components": ["m2", "m3"], "sources": ["m2:sfe21bcc8ab06", "m3:sf22822567b4c", "m3:s5a294e89d544"], "status": "REASONED"}, "milvus-management": {"text": "Management HTTP defaults wildcard 9091 with no bind-address control; METRICS_PORT parse failures fall back, 0 chooses random and other out-of-range values fail listen without stopping Milvus.", "components": ["m3"], "sources": ["m3:see857573ab82", "m3:scd4175e676e0"], "status": "REASONED"}, "milvus-control": {"text": "With default settings, management routes bypass proxy authorization: log-level changes, process-role stop, GC/balancing/node controls and transfers remain exposed even with WebUI disabled.", "components": ["m2", "m3"], "sources": ["m2:s5037adf4aadf", "m2:sb6990709043f", "m2:s2de9c40a20d4", "m2:sd2b47a819a04", "m2:s2b873c61b4cd", "m2:s283bf4d45460", "m3:s94628c99d2ad", "m3:sc60a0333fb8c", "m3:s599ca2efbe5d", "m3:s522ed8fdaa1f", "m3:s2b6695735806", "m3:s7fb08667d0a5", "m3:s4c82ce60440b", "m3:s235d67599933", "m3:s388b12b5a895", "m3:s3c5f478158fb"], "status": "REASONED"}, "milvus-key-backup": {"text": "Management encryption-zone backup returns the plugin backup for eligible databases; v3.0.5, unlike v2.6.26, also adds queued-read clearing and path-supplied backfill commit.", "components": ["m2", "m3"], "sources": ["m2:s5037adf4aadf", "m3:s94628c99d2ad", "m3:s76052118cc58", "m3:sadd70dbf8e7c", "m3:s0edc7eaf5bd4", "m3:sd399c2cd69eb", "m3:s15d89258dff4", "m3:sb28a3a9ebee9"], "status": "REASONED"}, "milvus-coordinator": {"text": "Activated mix coordinator adds mutable configuration and WAL changes alongside GC, balancing, suspension and transfer controls.", "components": ["m3"], "sources": ["m3:s2b6695735806", "m3:sc8cd5203f059", "m3:s4629ea15b1ac", "m3:s01eefc8f4f23"], "status": "REASONED"}, "milvus-profiling": {"text": "proxy.http.enablePprof defaults true; v3.0.5 registers pprof routes explicitly and v2.6.26 serves the default mux, which this guide infers carries them; disabling profiling omits pprof but leaves control routes registered.", "components": ["m2", "m3"], "sources": ["m2:s236dba1672cd", "m2:s83c53f3ea904", "m3:s739205667c16", "m3:sc60a0333fb8c", "m3:s599ca2efbe5d", "m3:s02f64a0d1975"], "status": "REASONED"}, "milvus-eventlog": {"text": "With default settings, the first successful eventlog request opens an unauthenticated, non-TLS wildcard gRPC listener on a random port; repeat inventory after requests.", "components": ["m2", "m3"], "sources": ["m2:s9dc3b55136ce", "m3:s223d3c194d1b", "m3:s86f28d468834", "m3:se949c7efe55d"], "status": "REASONED"}, "milvus-health": {"text": "Delete wildcard 9091 publication; in-container healthcheck needs none. Optional 127.0.0.1:9091 mapping still needs isolation; changed METRICS_PORT must update healthcheck and mappings.", "components": ["m3"], "sources": ["m3:s5a294e89d544"], "status": "REASONED"}, "milvus-metrics-only": {"text": "On v2.6.26, common.security.managementMetricsOnly true, read once at startup, limits the management port to unauthenticated GET and HEAD on metrics and probe paths and answers 404 to log-level, eventlog, stop, WebUI, profiling and console routes.", "components": ["m2"], "sources": ["m2:s558bd4e95221", "m2:se28a0f2e81f6", "m2:s3b493b573112", "m2:s236dba1672cd", "m2:s2de9c40a20d4", "m2:s2a343a370e3e"], "status": "REASONED"}, "milvus-admin-auth": {"text": "On v3.0.5, common.security.adminAuthEnabled true, read per request, requires root HTTP Basic auth on management, log-level, eventlog, profiling, WebUI and console routes, after request-context checks that may answer 403 first; a failed refresh may reuse the root hash until 10 minutes after its last successful fetch. Probes and metrics stay open, eventlog binds loopback, the port stays plaintext.", "components": ["m3"], "sources": ["m3:s90be0eb93a49", "m3:se60ba0898da2", "m3:sa889eb74b74e", "m3:sc2acd0036b94", "m3:sc60a0333fb8c", "m3:s599ca2efbe5d", "m3:scd4175e676e0", "m3:sa9b6da53e1ff", "m3:scecb75a7bc82", "m3:s7fb08667d0a5", "m3:s6a00ac9c991b", "m3:s223d3c194d1b", "m3:s522ed8fdaa1f", "m3:s50d4c546779b", "m3:se9c13f3b1c11", "m3:sa040ef47f39f", "m3:s5ce12da57df9"], "status": "REASONED"}, "milvus-optin-advice": {"text": "This guide advises managementMetricsOnly on v2.6.26, and on v3.0.5 adminAuthEnabled after enabling authentication and changing the root password, whose old value a node may accept for up to 10 minutes while refreshes fail, and deleting the wildcard 9091 publication on both, since metrics and probes stay unauthenticated in either mode.", "components": ["m2", "m3"], "sources": ["m2:s558bd4e95221", "m3:s90be0eb93a49", "m3:s5ce12da57df9", "m3:s5a294e89d544"], "status": "REASONED"}, "milvus-open-questions": {"text": "Not established: the Go pprof patterns and other default-mux handlers served by default, whether the v3.0.5 gate can verify root with authorizationEnabled off, which proxy code registers its verifier, what revokes cached root hashes on notification, other callers of its checks, and managementMetricsOnly's first release; v3.0.3 notes add adminAuthEnabled.", "components": ["m2", "m3"], "sources": ["m2:s236dba1672cd", "m3:s599ca2efbe5d", "m3:s6a00ac9c991b", "m3:s90be0eb93a49", "m3:s896663f32f5b"], "status": "REASONED"}, "chroma-bind": {"text": "Without a file chroma run defaults localhost; a config file defaults listen_address to 0.0.0.0 after CHROMA_ merges; official config sets only persist_path, yielding wildcard 8000.", "components": ["chroma"], "sources": ["chroma:s7eb7610f68c1", "chroma:s6d6708278bef", "chroma:s59912a4c7e11", "chroma:sed8aad199780", "chroma:s3a1c76cdd7f8", "chroma:s09652c32f56a", "chroma:s784a0e779949"], "status": "REASONED"}, "chroma-auth": {"text": "Built-in authentication was removed in v1.0.0; chroma run 1.5.9 uses the default identity for authentication and authorization. Obsolete AUTHN variables do not protect the server; use private binding and authenticated TLS fronting.", "components": ["cd", "chroma"], "sources": ["cd:s60ad33c4b715", "chroma:se936b62a1a3d", "chroma:sbc1dd27a9bf2", "chroma:s62cd8db9e61a", "chroma:s50b8158f3388", "chroma:s6eb0ff350d15"], "status": "REASONED"}, "pgvector": {"text": "pgvector is a PostgreSQL extension on 5432 for PostgreSQL 13+; use PostgreSQL TLS, hostssl/SCRAM, verify-full and least-privilege roles.", "components": ["pgvector-source"], "sources": ["pgvector-source:s13e1ca059e33"], "status": "REASONED"}, "pgvector-rls": {"text": "Tenant RLS filters shared embeddings; prove tenant-B isolation with known rows and bypass controls, counting per tenant rather than trusting top-k.", "components": ["pg"], "sources": ["pg:se9846041ce1f"], "status": "REASONED"}, "hosted-mfa": {"text": "This guide assesses that Qdrant Cloud's documented API keys and, at the time of writing, Pinecone SSO do not establish human MFA. It advises self-hosted Weaviate's database OIDC settings with an MFA-enforcing IdP, or MFA-capable fronting; this is not Weaviate Cloud console SSO and establishes no hosted-console control. Wherever SSO is used, require an MFA-enforcing IdP and confirm enforcement.", "components": ["qc", "pc", "wd", "qc-pinned"], "sources": ["qc:sc72e09d8f7e6", "pc:s95422bfab587", "wd:s51c9c4c6b4c0", "qc-pinned:sac497c4d41f5"], "status": "REASONED"}, "hosted-pinecone": {"text": "At the time of writing, Pinecone documents organization SSO through an organization's identity management solution on Standard and Enterprise plans; SSO console sessions are re-authenticated at least every 24 hours. This guide assesses that this does not establish MFA enforcement and advises requiring and confirming MFA through the SSO identity provider.", "components": ["pc"], "sources": ["pc:s95422bfab587"], "status": "REASONED"}, "hosted-qdrant": {"text": "At the time of writing, Qdrant Cloud's documentation source lists passwordless email, Google, GitHub and enterprise SSO registration, with Premium SSO for console sign-in only and Database API keys for cluster requests. This guide assesses that deployed IdP MFA enforcement is unestablished and advises requiring an MFA-enforcing SSO IdP and confirming enforcement.", "components": ["qc-pinned"], "sources": ["qc-pinned:s19a1a255e9de", "qc-pinned:sdb4501285ace", "qc-pinned:sac497c4d41f5"], "status": "REASONED"}, "hosted-weaviate-mfa": {"text": "At the time of writing, Weaviate Cloud's documentation source says any member can enable account MFA, only Owners control enforcement for all members, enforcement is available to every organization, and both default off. It says SSO setup turns enforcement off and SSO sign-ins never prompt for a Weaviate Cloud MFA code. This guide advises confirming MFA in the SSO identity provider.", "components": ["wc"], "sources": ["wc:sf898b01ade26", "wc:s9663f4d166ac", "wc:s800400caba04", "wc:s2279ff6909dd"], "status": "REASONED"}, "hosted-weaviate-sso": {"text": "At the time of writing, Weaviate Cloud's documentation source says that after a member's first SSO sign-in, SSO applies even in MFA-enforcing organizations; disabling SSO leaves enforcement off until an Owner restores it. This guide assesses that deployed IdP MFA enforcement is unestablished and advises requiring and confirming it.", "components": ["wc"], "sources": ["wc:s800400caba04", "wc:sd7dea65bb6d0"], "status": "REASONED"}, "hosted-qdrant-sso": {"text": "At the time of writing, Qdrant Cloud's documentation source lists OpenID Connect and SAML for Premium-tier console SSO and says to enable it through a Qdrant support-portal ticket. This guide assesses that this does not establish MFA enforcement and advises requiring and confirming MFA through the SSO identity provider.", "components": ["qc-source"], "sources": ["qc-source:sb5c169f992d9"], "status": "REASONED"}, "hosted-qdrant-mfa-gap": {"text": "This guide's source assessment: the cited Qdrant sources and account-setup page document no console MFA control; the user-profile page, fetched 2026-11-04, documents optional per-user MFA, as documentation, not observed console behaviour. None shows account-level enforcement, which does not establish its absence. This guide advises requiring console MFA and, for SSO, a confirmed MFA-enforcing IdP.", "components": ["qc-pinned", "qc-source", "qc"], "sources": ["qc-pinned:s19a1a255e9de", "qc-pinned:sdb4501285ace", "qc-pinned:sac497c4d41f5", "qc-source:sb5c169f992d9", "qc:s83d796d79bed", "qc:seb1efb17f9fd", "qc:s420c86cd2402"], "status": "REASONED"}, "hosted-weaviate-login": {"text": "At the time of writing, Weaviate Cloud's documentation source specifies SAML, not OIDC, for Managed organizations on a Weaviate-managed contract; only Owners control console SSO, which does not affect cluster API keys. It says that after a person's first SSO sign-in, email/password and Google sign-in stop working for that account across every organization the person belongs to.", "components": ["wc"], "sources": ["wc:s6f6eb09e2100", "wc:sb0789be8eb2e"], "status": "REASONED"}, "hosted-source-limits": {"text": "This guide assesses that the pinned Weaviate and Qdrant documentation sources do not by themselves establish published-page content, publication or release timing, or live-console behaviour. The Weaviate MFA source's author flags enforcement UI labels, dialog titles, tooltips and numbers as needing live-console checks; this guide does not treat them as verified.", "components": ["wc", "qc-source"], "sources": ["wc:s24059634e4c7", "wc:s6f6eb09e2100", "qc-source:sb5c169f992d9"], "status": "REASONED"}, "hosted-sso-mfa-assessment": {"text": "This guide infers that MFA with Weaviate Cloud SSO depends on the IdP because its documented flow disables Cloud MFA enforcement and omits Cloud MFA prompts. This guide advises requiring MFA for human console access and an MFA-enforcing IdP wherever SSO is used, and confirming enforcement; these sources do not establish deployed IdP MFA enforcement.", "components": ["wc"], "sources": ["wc:s800400caba04"], "status": "REASONED"}, "hosted-qdrant-published": {"text": "When checked in October 2026, Qdrant Cloud's published account-setup page said Premium Tier SSO is console-only, leaves cluster requests on Database API keys and needs a support ticket; on 2026-11-04 it has the Premium Tier and console-only statements and its linked Premium Tier page the rest. This guide assesses that neither establishes MFA enforcement; require and confirm MFA in the SSO IdP.", "components": ["qc"], "sources": ["qc:s83d796d79bed", "qc:s420c86cd2402"], "status": "REASONED"}, "hosted-weaviate-published": {"text": "At the time of writing, Weaviate Cloud's published MFA and SSO pages say completing SSO setup turns MFA enforcement off, SSO sign-ins never ask for a Weaviate Cloud MFA code, and enforcement stays off after SSO is turned off. This guide infers that MFA for SSO sign-ins depends on the IdP; these pages do not establish publication or release timing or live-console behaviour.", "components": ["wd"], "sources": ["wd:sbc37c5ecfb18", "wd:s5771a47941ca"], "status": "REASONED"}, "hosted-weaviate-published-enforcement": {"text": "At the time of writing, Weaviate Cloud's published MFA page says only Owners can see and change MFA enforcement, which is available to every organization and off by default, and recommends enforcing MFA in the identity provider. This guide treats this as documentation, not observed console behaviour.", "components": ["wd"], "sources": ["wd:sbc37c5ecfb18"], "status": "REASONED"}, "hosted-zilliz-mfa": {"text": "At the time of writing, Zilliz Cloud's published MFA page says built-in TOTP authenticator-app MFA is only for accounts registered with an email address and password; SSO organizations' identity provider manages MFA, and Google- or GitHub-linked accounts use those providers' MFA. This guide assesses that deployed IdP MFA enforcement is unestablished and advises requiring and confirming it.", "components": ["zc"], "sources": ["zc:sbf5c52cae6f9"], "status": "REASONED"}, "hosted-zilliz-enforcement": {"text": "At the time of writing, Zilliz Cloud's published MFA page says Organization Owners can enforce MFA for all organization users, given a valid payment method, an Enterprise project and a Dedicated cluster; users who do not complete MFA setup cannot access the organization. This guide treats this as documentation, not observed console behaviour.", "components": ["zc"], "sources": ["zc:sbf5c52cae6f9"], "status": "REASONED"}, "hosted-pinecone-domain-sso": {"text": "When checked in October 2026, Pinecone's security overview said that once SSO is configured, an organization can require users from its domain to sign in through SSO. This guide's source assessment: that page, the organizations and members pages checked then, and the indexes and Okta SCIM page read on 2026-11-04 do not mention MFA, which does not establish that Pinecone has no console MFA control.", "components": ["pc"], "sources": ["pc:sb0fa7b7afc6f", "pc:s95422bfab587", "pc:s50c4ed3cc487", "pc:s7a0d3675305c", "pc:seaee0c38aa88", "pc:scac2d35cedd4", "pc:s898fdbbf3bf8", "pc:saa2ef652673d", "pc:s517e8bbf92cb", "pc:s8dee47b6a4df", "pc:se0694c4ae7b9"], "status": "REASONED"}, "hosted-qdrant-mfa-search": {"text": "This guide's assessment of a case-insensitive search of the Qdrant documentation source tree at commit be9bbd58 for multi-factor, two-factor, MFA, 2FA, authenticator, TOTP, passkey and WebAuthn: no file matched. A search that finds nothing does not establish that Qdrant Cloud has no console MFA control; its published user-profile page, fetched 2026-11-04, documents one.", "components": ["qc-source", "qc"], "sources": ["qc-source:sf6beca47175e", "qc:seb1efb17f9fd"], "status": "REASONED"}, "hosted-zilliz-enforcement-scope": {"text": "When checked in October 2026, Zilliz Cloud's MFA page said enforcement requires all organization users to set up MFA to sign in, prompting those without it at their next login, and that in SSO organizations the IdP, not Zilliz Cloud, manages MFA. This guide's source assessment: the page does not say how enforcement applies to SSO users or Google- or GitHub-linked accounts.", "components": ["zc"], "sources": ["zc:sbf5c52cae6f9"], "status": "REASONED"}, "hosted-pinecone-login": {"text": "When checked in October 2026, Pinecone's Okta SSO page said members can log in with SSO or with their Pinecone credentials unless SSO is enforced for all users, and Pinecone's login-code page described a login code received by email. This guide's source assessment: neither page nor the Okta role-management page mentions MFA, which does not establish that no Pinecone console MFA control exists.", "components": ["pc"], "sources": ["pc:se6661f61cd95", "pc:sa7d03b94febb", "pc:s2d4f3dc1136e"], "status": "REASONED"}, "hosted-zilliz-sso-enforcement": {"text": "When checked in October 2026, Zilliz Cloud's SSO enforcement page said configured SSO alone leaves email/password, Google and GitHub sign-in open, enforcement disables any MFA enabled for the organization on Zilliz Cloud, and Owners are exempt by default. This guide's source assessment: it does not say whether Zilliz Cloud MFA covers exempt Owners' or unenforced organizations' non-SSO sign-ins.", "components": ["zc"], "sources": ["zc:s8c72bbc1f8be"], "status": "REASONED"}, "hosted-zilliz-linked-accounts": {"text": "When checked in October 2026, Zilliz Cloud's email-accounts page said switching to Google or GitHub login needs MFA off first, its MFA page that members cannot disable MFA under enforcement, and its registration page to disable provider MFA that blocks sign-up. This guide's source assessment: no page read says how enforcement applies to linked accounts or to SSO users without SSO enforcement.", "components": ["zc"], "sources": ["zc:s5c11c50ef262", "zc:sbf5c52cae6f9", "zc:s8c72bbc1f8be", "zc:s4e00709d071c", "zc:s18913511c5c8", "zc:s264bb7816c73", "zc:s6953cf1328c8"], "status": "REASONED"}, "hosted-weaviate-published-ui": {"text": "When checked in October 2026 and on 2026-11-04, Weaviate Cloud's published MFA page named a Require MFA for all members switch on the Users and Members tab, with dialogs to enforce and stop enforcing MFA. This guide's source assessment: the pinned source's note that these labels need live-console checks is a comment the page does not display; the labels are not verified console behaviour.", "components": ["wd", "wc"], "sources": ["wd:sbc37c5ecfb18", "wc:s24059634e4c7"], "status": "REASONED"}, "hosted-source-history": {"text": "From source reading of line history at the pins, the cited Weaviate Cloud MFA and SSO source lines, except the MFA page's opening lines, were last changed in commit 9fbb108b and the opening and closing lines (L94, L106) of Qdrant's cited SSO passage in commit 6efd6b1f, both dated 2026-10-13. This guide assesses that commit dates do not establish publication or release timing.", "components": ["wc", "qc-source"], "sources": ["wc:sc6b9969fb3d0", "wc:s202931b741a1", "qc-source:sf6345ca76a7a"], "status": "REASONED"}, "verify-inventory": {"text": "Read all listener rows, container namespaces and publications; expected-port grep and host ss alone can miss exposure.", "components": ["qd", "docker"], "sources": ["qd:s753402e46eed", "docker:s1e53417c513d"], "status": "REASONED", "verify": [1]}, "verify-peer": {"text": "Probe real backend 6333/6334/6335 from non-peers; require connection failure with filtering evidence and a positive peer control for 6335; auth/TLS rejection is still reachability.", "components": ["qd", "curl-docs"], "sources": ["qd:s753402e46eed", "curl-docs:s2b2686afaf41", "curl-docs:s596c6f706038"], "status": "REASONED", "verify": [2]}, "verify-curl": {"text": "exit 3/6 is invalid input; 7 or a five-second 28 needs corroboration; nonzero HTTP, 52/56 or a twenty-second 28 indicates reachability, including HTTP 000 cases.", "components": ["curl-docs"], "sources": ["curl-docs:s2b2686afaf41", "curl-docs:s596c6f706038"], "status": "REASONED", "verify": [2]}, "verify-qdrant-auth": {"text": "Frontend collections check expects anonymous 401/403 and keyed 200 over verified TLS; both can pass while peer port 6335 is exposed.", "components": ["qd"], "sources": ["qd:s753402e46eed"], "status": "REASONED", "verify": [3]}, "verify-fronting": {"text": "Weaviate schema rejects anonymous access and allows a valid key; Chroma requires fronting denial and authorized success because it has no native auth.", "components": ["wd", "cd"], "sources": ["wd:s51c9c4c6b4c0", "cd:s60ad33c4b715"], "status": "REASONED", "verify": [4, 5]}, "verify-milvus-auth": {"text": "MilvusClient without a token must fail after authorization is enabled, while the same operation with the application identity succeeds.", "components": ["m3"], "sources": ["m3:sc110734f2f03"], "status": "REASONED"}, "verify-milvus-management": {"text": "Outside-admin reachability fails; private control needs healthy Compose status and the milvus-owned listener, since healthy does not identify the port's process or show roles stopped through the management API, and bind failure is nonfatal. Milvus v2.6.25 notes list a fix for a restarting node reporting ready on /healthz before all components registered (#53736).", "components": ["m2", "m3", "docker"], "sources": ["m2:s75b53cd6717b", "m3:sba92dc5b6642", "m3:scd4175e676e0", "docker:s7d4675617251"], "status": "REASONED", "verify": [1, 2]}, "verify-milvus-optin": {"text": "Without its opt-in key, either release answers an anonymous log-level request with 200; with v2.6.26 managementMetricsOnly it answers 404, and with v3.0.5 adminAuthEnabled 401, while root credentials with the admin request header get 200.", "components": ["m2", "m3"], "sources": ["m2:s236dba1672cd", "m3:s7fb08667d0a5", "m3:s6a00ac9c991b"], "status": "REASONED"}, "milvus-kubernetes-service": {"text": "Helm chart 5.0.30 and Operator v1.3.11 place metrics 9091 on the client Service, with a declared ClusterIP default; the port list includes both ports whatever the Service type unless Helm metrics.enabled is false. Helm removes only the Service metrics port; Operator disableMetric skips PodMonitor reconciliation, not the Service port. Chart appVersion 3.0.1 is separate from the audited Milvus versions.", "components": ["milvus-helm", "milvus-operator"], "sources": ["milvus-helm:s03d63e5e72b6", "milvus-helm:s65da7040fa71", "milvus-helm:sc9c86596c55c", "milvus-helm:s5218b8a9a776", "milvus-helm:s533d9f94c237", "milvus-helm:s8b7f45ad4f54", "milvus-operator:s11cd8aea4a46", "milvus-operator:scaa2f4b8f5b3", "milvus-operator:se5839563f89c", "milvus-operator:s58da9e232a84", "milvus-operator:s585ae37fd0ca", "milvus-operator:sca0e4a51219f"], "status": "REASONED"} } } ```` ====================================================================== ==> mcp-servers.md ====================================================================== # MCP servers: exposing Model Context Protocol servers safely MCP (Model Context Protocol) lets AI apps call outside tools; an MCP server provides them, locally or over the web. This guide helps your AI assistant expose one securely, including stdio for single-user workstation tools, narrow credentials, and for HTTP servers a loopback bind, Origin checks, TLS and authentication. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mcp-servers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MCP 2026-07-28, MCP legacy 2025-11-25, and curl minimum 7.75.0; 3 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 39 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An MCP server gives a model tools, and every tool it exposes runs with the credentials the server holds. The 2026-07-28 specification defines two transports. Over **stdio** the client launches the server as a subprocess and talks over stdin and stdout: there is no network listener, but the process runs as the launching user with every credential in its environment, so a malicious or careless server is a local compromise, not a network one. Over **Streamable HTTP** the server is a web service with a single endpoint accepting **POST**, and every rule in [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) applies to it. For a server supporting only this revision, the spec says GET or DELETE to that endpoint SHOULD receive `405 Method Not Allowed`; server-to-client change notifications now flow on a long-lived `subscriptions/listen` POST response stream rather than a standalone GET stream. The older HTTP with SSE transport (protocol version 2024-11-05) is deprecated; Streamable HTTP replaces it. See Streamable HTTP. The spec makes authorization OPTIONAL at the protocol level; this repository does not, so an HTTP MCP server reachable beyond loopback authenticates every request, either natively or through a fronting layer. See Authorization. **Spec revision.** This guide is written against MCP **2026-07-28**, the **Current** revision. Current means ready for use and still able to receive backwards-compatible changes; it does not mean Final. See MCP versioning. Two distinct changes matter: 1. The `initialize`/`notifications/initialized` handshake is removed, and MCP is stateless. Every request MUST carry `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` in `params._meta`; clients SHOULD also include `io.modelcontextprotocol/clientInfo`. This per-request metadata supplies the version and capabilities without prior connection state. See the Base protocol and Changelog. Every HTTP POST MUST include `MCP-Protocol-Version`, equal to the body's `_meta` protocol version. `Mcp-Method` MUST mirror `method` on all requests; `Mcp-Name` MUST mirror `params.name` or `params.uri` on `tools/call`, `resources/read`, and `prompts/get` only. Header names are case-insensitive. Missing or malformed required headers, or a header/body mismatch, require HTTP `400` with JSON-RPC `-32020 HeaderMismatch`. An unsupported protocol version requires HTTP `400` with `-32022 UnsupportedProtocolVersionError`. See Streamable HTTP; the Changelog records the error-code renumbering, including `HeaderMismatch` from the draft's `-32001` to `-32020`. 2. `server/discover` is added. Servers MUST implement it; clients MAY call it for up-front version selection or as a stdio compatibility probe. It does not replace negotiation and is not a mandatory first request: per-request `_meta` supplies the negotiation information, and a client can send another RPC directly and handle a version error. See Discovery. Protocol-level sessions and `Mcp-Session-Id` are removed, as are the standalone GET stream and stream resumability via `Last-Event-ID`. For older-client traffic, a server supporting only 2026-07-28 SHOULD ignore those removed legacy headers and return `405` for GET/DELETE requests. See the Changelog and Streamable HTTP. Readers maintaining older deployments can consult the explicitly **legacy** 2025-11-25 transport reference. MCP versioning calls 2025-11-25 and earlier handshake-based protocol revisions and, for interoperability with them, points to the Backward Compatibility section of the 2026-07-28 specification. It defines Draft, Current and Final only generically and does not name any revision as Final. ## 1. Prefer stdio, and keep HTTP on loopback - For a single-user tool on a workstation, use the stdio transport. The spec's own guidance for local servers is to "use the `stdio` transport to limit access to just the MCP client" and, if HTTP is used anyway, to require an authorization token or use a Unix domain socket with restricted access. See Security Best Practices. - Give a stdio server only the environment it needs: the API key for the one service it wraps, not a shell profile full of cloud credentials. Per the spec, stdio servers "retrieve credentials from the environment", which means that environment is the server's whole privilege set ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). See Authorization. - For Streamable HTTP, the spec's Security Warning says: "When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)". Bind to `127.0.0.1` and publish only a fronting layer, exactly as [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) does for a model server. Check the SDK or framework you use for its bind-address option; do not assume its default is loopback. See Streamable HTTP. ## 2. Validate `Origin` (DNS rebinding) The spec requires it: "Servers MUST validate the `Origin` header on all incoming connections to prevent DNS rebinding attacks. If the `Origin` header is present and invalid, servers MUST respond with HTTP 403 Forbidden." The response body MAY be a JSON-RPC error with no `id`. Without this, "attackers could use DNS rebinding to interact with local MCP servers from remote websites": a page in the user's browser resolves its own hostname to `127.0.0.1` and drives the local server. See Streamable HTTP. Do the check in the server (most SDKs have an allowed-origins option; confirm yours is on). A reverse proxy can add a second check in front, using the nginx rewrite module's `if` directive with the nginx core module's `$http_origin` variable: ```nginx location /mcp { if ($http_origin !~ "^https://mcp\.example\.com$") { return 403; } proxy_pass http://127.0.0.1:3000; } ``` Non-browser clients often send no `Origin` at all; the spec's requirement is about a present and invalid header, so decide deliberately whether a missing header is accepted (the nginx test above rejects it) and document the choice. To accept requests that carry no `Origin` while still rejecting a wrong one, use a `map` in the `http` block (an empty string key matches a missing header) and test the variable in the location: ```nginx map $http_origin $bad_origin { default 1; "" 0; # no Origin header: a non-browser MCP client "https://mcp.example.com" 0; } ``` ```nginx location /mcp { if ($bad_origin) { return 403; } proxy_pass http://127.0.0.1:3000; } ``` See the nginx map module for the mapping syntax. These proxy checks validate Origin only; the MCP server still performs the required mirror-header/body validation and returns `-32020` on a mismatch. A fronting proxy does not substitute for that validation. See Streamable HTTP, Server Validation. ## 3. TLS In this guide's deployment pattern, the MCP server itself speaks plain HTTP on loopback. Terminate TLS at the proxy per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), or publish through [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) or [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md). Bearer tokens cross the network with every request, so the spec requires HTTPS for all authorization server endpoints, and [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) requires it for every credential. See Authorization Security Considerations, Communication Security. ## 4. Authentication: native OAuth 2.1 or a fronting layer **Option A, spec authorization.** The MCP server acts as an OAuth 2.1 resource server. See Authorization. The spec's requirements, in its own terms: - MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728), and the metadata MUST include `authorization_servers` with at least one authorization server. Servers MUST implement at least one discovery mechanism: a `WWW-Authenticate` header carrying `resource_metadata` on `401 Unauthorized` responses, or a well-known URI. Clients MUST support both, following `resource_metadata` when present and otherwise trying the endpoint-path-suffixed well-known URI first, then the root `/.well-known/oauth-protected-resource`. See Authorization Server Discovery. - Clients "MUST implement PKCE", MUST use `S256` "when technically capable", and "MUST refuse to proceed" if the authorization server's metadata lacks `code_challenge_methods_supported`. See Authorization Security Considerations. Clients MUST send the RFC 8707 `resource` parameter in both authorization and token requests, naming the MCP server's canonical URI. See Authorization. - Servers "MUST validate that access tokens were issued specifically for them as the intended audience"; invalid or expired tokens get `401`, insufficient scope gets `403`. Servers "MUST NOT accept or transit any other tokens": the token the client presents never travels on to an upstream API. See Authorization. If the server calls upstream APIs, "the access token used at the upstream API is a separate token, issued by the upstream authorization server". See Authorization Security Considerations. - Tokens go in `Authorization: Bearer ...` on every request, never in the URL. See Authorization. The spec's Authorization, Access Token Usage example still shows a `GET /mcp` bearer request, which is stale under 2026-07-28 because the MCP endpoint is POST-only; this guide does not replicate it. - MCP 2026-07-28 is stateless: protocol sessions and `Mcp-Session-Id` are removed. See the Changelog. Servers needing cross-call state mint explicit handles. Servers MUST NOT treat possession of a state handle as authentication; servers implementing authorization MUST verify every inbound request. They SHOULD use secure, non-deterministic handles and bind them server-side to the authenticated user, for example by keying state as `:` with the user ID derived from the verified token. See Security Best Practices, State Handle Hijacking. - **Scope hierarchy:** servers "MUST account for scope hierarchies, where a broader scope implies narrower ones, when deciding whether a token is sufficient" for an operation. Apply that rule in server-side token acceptance. See Authorization, Step-Up Authorization Flow. - **Refresh tokens:** for public clients, the authorization server MUST rotate refresh tokens. This requirement already existed in 2025-11-25; it is not new in 2026-07-28. Treat it as an identity-provider selection criterion. The current requirement is in Authorization Security Considerations, Token Theft. The MCP server SHOULD NOT advertise `offline_access` in `WWW-Authenticate` scopes or Protected Resource Metadata `scopes_supported`, because refresh tokens are not a resource requirement. See Authorization, Refresh Tokens. Pick an identity provider from [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md) as the authorization server; the client-side hygiene in [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) applies. MCP 2026-07-28 deprecates Dynamic Client Registration in favour of Client ID Metadata Documents (HTTPS-URL client IDs); DCR remains available for compatibility, and clients using it MUST send an appropriate `application_type`. Check which mechanisms the identity provider supports. See Client Registration. Azure App Service can serve the RFC 9728 document for an app behind its built-in authentication: set the `WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES` application setting to a comma-separated list of the required scopes, and the 401 challenge then carries the metadata URL and scopes. Microsoft marks this **preview** at the time of writing and says the configuration may change ([cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md) covers the rest of Easy Auth). See Azure App Service authentication. Client-side authorization implementation details (authorization-response `iss` validation, discovery-metadata issuer identity, Client ID Metadata Documents, refresh-token confidentiality, and OAuth-discovery SSRF) are covered in [mcp-clients.md](https://github.com/jposluns/secureconfig/blob/main/mcp-clients.md). Section 6 covers proxying long-lived subscription streams and response caches, which the Origin examples above do not configure. **Option B, a fronting layer.** Keep the server on loopback and authenticate at the edge: a reverse proxy bearer-token check (the nginx `if ($http_authorization ...)` block in [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md)) or basic auth per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)/[caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md); Cloudflare Access with a service token for machine clients per [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md); or an identity-aware proxy per [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md). Generate and rotate the token per [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md). Be clear about what Option B is. A static bearer token at the proxy is a fronting control, not MCP authorization: it serves no Protected Resource Metadata and no `WWW-Authenticate` challenge with `resource_metadata`, so an MCP client that expects the OAuth discovery flow will not negotiate it. Such clients need the token pre-configured (most clients accept static headers for a server), or Option A. MFA: MCP has no login dialogue of its own. Under Option A, MFA is whatever the authorization server enforces; under Option B it comes from the identity provider behind Access or the identity-aware proxy. Enforce it there per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## 5. The credentials the server holds - Each tool's upstream API key is a secret the server holds on behalf of every caller. Load it from the environment or a secret manager, one key per server and environment, never from the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). - Least privilege per tool: a read-only tool gets a read-only key. A server that wraps a database, a cloud account, and a mail API with admin credentials turns every prompt injection into an administrator. - Never forward the client's token upstream (spec: "Token passthrough" is an anti-pattern and "explicitly forbidden"). Log tool calls with the authenticated identity so an incident can be traced. See Security Best Practices. ## 6. Proxy long-lived subscription streams and response caches The proxy configuration above handles short request/response calls. Two MCP surfaces need more from a fronting layer. **Subscriptions are a long-lived server-to-client stream.** A `subscriptions/listen` POST opens a `text/event-stream` (Server-Sent Events) response that "opens a long-lived notification stream" and "stays open and delivers notifications until the client cancels it". See Subscriptions. A reverse proxy can break it two ways, and a default nginx does: it buffers the response, so notifications arrive late or batched rather than as they happen, and it closes the upstream connection after an interval with no data. Caddy instead flushes a `text/event-stream` response immediately and sets no read timeout by default, so it needs neither adjustment. Under nginx, disable response buffering and raise the read timeout above the longest expected gap between events: ```nginx location /mcp { proxy_pass http://127.0.0.1:3000; proxy_buffering off; # deliver each event as it arrives, not held in a buffer proxy_read_timeout 3600s; # above the longest idle gap; the 60s default drops a quiet stream # keep the Origin, TLS, and Authorization handling from the sections above } ``` `proxy_buffering off` disables response buffering for the location, which nginx defaults to `on`. Alternatively an MCP server SHOULD send the `X-Accel-Buffering: no` response header, which nginx honours to disable buffering for that one response unless header processing is turned off with `proxy_ignore_headers X-Accel-Buffering;`. `proxy_read_timeout` defaults to 60 seconds and measures the gap between two successive reads, so a stream quieter than that is dropped; size it to the deployment's idle periods, and a server that sends frequent keep-alive comments can keep a shorter timeout. There is no required timeout value; match it to the traffic. Preserve the `text/event-stream` content type and event framing through every intermediary. See the nginx `proxy_buffering` and `proxy_read_timeout` reference, and MCP Streamable HTTP and Subscriptions. **A shared response cache can cross authorization contexts.** MCP's caching utility carries a `cacheScope`, and the spec is explicit that, for a private-scope response, "Caches MUST NOT be shared across authorization contexts" and that an implementer "MUST NOT rely on `cacheScope` alone to prevent unauthorized access to primitives". See Caching. A fronting cache keyed only on the request path, or on the method and parameters without the caller's identity, will hand one caller's private tool result or filtered list to another. Do not put a generic response cache in front of an MCP server unless it implements MCP's rules: separate private authorization contexts, distinguish methods and result-affecting parameters, and keep the primitive access check independent of any cache hint. A `public`-scope result may be shared even when first obtained through an authenticated endpoint, so the server must classify visibility correctly rather than the cache guessing. For nginx, keep proxy caching off for the MCP location (`proxy_cache off;`). nginx caches nothing by default and caches only `GET`/`HEAD` unless `proxy_cache_methods` adds `POST`, so this hazard appears only where an operator deliberately turns on shared or POST caching in front of the POST-only endpoint. See the nginx `proxy_cache` and `proxy_cache_methods` reference, and MCP Caching. ## Verify REASONED: following block; the ss listener inventory, per the guide's cited MCP specification 2026-07-28 Streamable HTTP entry (loopback binding). During marking review (October 2026), in the authoring environment, a real ss -tlnp reported `Cannot open netlink socket: Operation not permitted`; the listener inventory could not be established from that run. The inventory then shared one block with the direct-origin probe, and that block's bash -n, ShellCheck and stub runs are recorded with the probe below. The inventory now has its own block, run on the MCP host: it refuses with status 2 when `ss` is not installed, exits 3 if `ss -tlnp` fails and otherwise 0 with an unclassified-observation line, because you judge every listener it prints. Its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by drafting stub runs in October 2026 with no network, in which a stub ss that succeeded gave 0, one that failed gave 3 and a PATH without ss gave 2. ```bash ( # a subshell, so the exit below ends only the subshell; run it on the MCP host command -v ss >/dev/null 2>&1 || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tlnp; then # read every listener; 127.0.0.1:3000 only, never 0.0.0.0 or :: set -- 0 else echo "ss -tlnp exit $?: the listener table could not be read; inconclusive"; set -- 3 fi echo "block exit $1; every listener is an unclassified observation, so judge it yourself: the MCP HTTP transport belongs on 127.0.0.1:3000 only, never 0.0.0.0 or ::" exit "$1" ) ``` REASONED: the guarded direct-origin probe in the block below, run from another host, whose declaration is the Probe 1 paragraph after it, per the guide's cited MCP specification 2026-07-28 Streamable HTTP entry (loopback binding) and curl manual entry (exitcode and errormsg, both added in curl 7.75.0). The direct-origin comparison requires an MCP 2026-07-28 server, fronting layer and second probe host, unavailable in the authoring environment, so neither the exposed result (any HTTP code from your address) nor the fixed one (a refusal or timeout reaching your address, or an answer only from the fronting layer) was observed. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0, then ran with stub ss and curl and no network: unchanged, it printed its substitute-your-own-address refusal; without its set -- line, the paste-the-whole-block refusal; with the set -- line shortened, or given a second value under IFS=0, the exactly-1-value refusal. The stub curl in that marking-review run (October 2026), with no network and 198.51.100.7 substituted, received http://198.51.100.7:3000/mcp. Those runs covered an earlier form, in one block with the listener inventory, whose guard refusals left the subshell with status 0. The current form exits 1 when your address answers over HTTP, 0 with an unclassified-observation line when curl exits 7 or 28, so that you read err, and 3 for any other curl failure. That form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026 with no network. The direct-origin request below checks listener reachability, not MCP RPC success: any HTTP response, now typically `400`/`405`, proves that the port answered. Use curl 7.75.0 or newer for the `exitcode` and `errormsg` write-out variables. See the curl manual. ```bash # From another host: refused at the origin, or answered only by the fronting layer. # read err, not the number: it must name a refusal or timeout reaching YOUR address. An HTTP code # means the port answered. A resolver failure, a local socket error, or a timeout that did not come # from the remote address is inconclusive, never a pass. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' -X POST --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:3000/mcp"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%%$'\n'*}" "${2##*$'\n'}" # the address, curl's write-out line, then curl's exit status printf '%s\n' "$2" case "$3" in 0) echo "FINDING: http://$1:3000/mcp answered over HTTP, so the port is reachable from this host"; set -- 1 ;; 7|28) echo "unclassified observation: a refusal or timeout reaching your address is the pass, while a resolver failure, a local socket error or a timeout that did not come from the remote address is inconclusive"; set -- 0 ;; *) echo "curl exit $3: neither an HTTP answer nor a refusal or timeout; inconclusive"; set -- 3 ;; esac echo "block exit $1; curl's err text is not classified, so read it yourself" exit "$1" ) ``` REASONED: preceding block; this Probe 1 paragraph and the direct-origin block above it. Probe 1's expected 401 and exposed discovery result rest on the guide's cited MCP specification 2026-07-28 Authorization and Authorization Server Discovery entries, and the direct-origin block on the entries named in the paragraph before it, which also records that block's local runs; Probe 1's own local runs are recorded with the following block. **Probe 1 - REASONED, not demonstrated.** No live MCP 2026-07-28 server is available in the authoring environment. Fixed: the unauthenticated request receives the applicable `401` denial; exposed: it returns an actual discovery result without authentication. A login page, redirect, transport failure, or JSON-RPC error does not demonstrate an exposed MCP result. The expected denial and metadata-discovery alternatives follow Authorization and Authorization Server Discovery. REASONED: the header shape of the three RPC probe blocks, per the guide's cited MCP specification 2026-07-28 Streamable HTTP entry (request headers, header/body validation) and Discovery entry (request shape). No MCP server is available in the authoring environment to answer a mismatched header with HTTP 400 and -32020, or a matching one without it. During marking review (October 2026) the four server/discover bodies in those blocks parsed as JSON, and in each the MCP-Protocol-Version header equalled the protocol version 2026-07-28 in `params._meta`, Mcp-Method equalled the body's method, clientCapabilities and clientInfo were present, and no Mcp-Name header appeared. Those checks ran on earlier forms of the three blocks; their current forms send the same headers and bodies and are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026 with no network. For the well-known read in the block after the next paragraph, those runs covered earlier forms, which lowercased the host with `tr`, and the next paragraph records the runs of its current form. `Mcp-Name` is deliberately absent: it is required only for `tools/call`, `resources/read`, and `prompts/get`, not `server/discover`. The `MCP-Protocol-Version` header must equal the body's `_meta` version; all three probes satisfy that requirement by construction. See Streamable HTTP and the Discovery request shape. REASONED: following block; the unauthenticated server/discover probe and well-known metadata read in the following block, and the placeholder guards of all three RPC probe blocks, per the guide's cited MCP specification 2026-07-28 Authorization and Authorization Server Discovery entries. The authoring environment has no MCP server or authorization server, so neither the fixed 401 nor an exposed discovery result was observed. During marking review (October 2026) the three blocks passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 and ran with a stub curl and no network. Unchanged, each printed its substitute-the-MCP-HTTPS-URL refusal; without its set -- line, the paste-the-whole-block refusal; shortened, its exactly-N-values refusal; and an http:// URL drew the https:// refusal. In the following block an unsubstituted Origin drew the allowed-Origin refusal and a control character the control-character refusal, and with both values substituted the stub received one POST carrying the Origin, MCP-Protocol-Version, Mcp-Method, Content-Type and Accept headers. At the October 2026 marking review the block's well-known read sat outside the subshell, named mcp.example.com and reached the stub in every run. Those runs covered earlier forms of the three blocks, which did not classify their results. The following block now runs its two parts in one outer subshell with one exit status: 1 when the unauthenticated request returns a discovery result, 0 when it is refused with 401 and the well-known read succeeds, 3 for any other answer or a failed request, and 2 when a part refuses its input and nothing else was found or inconclusive. Its well-known read takes the MCP server's host name on its own guarded `set --` line, which refuses example.com, example.net, example.org and their subdomains. The current forms of the three blocks are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and those of the other two blocks by drafting stub runs in October 2026 with no network. Those runs also exercised the following block's host-name guard, in earlier forms of the block that lowercased the host with `tr` before refusing reserved names. The guard now refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. That current form is covered by curl-stub runs on 2026-11-01 with no network. In bash, the well-known read refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call to that host, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probe. Under dash, stub runs of the current form refused every host name in the well-known read with 2 and the bash message before its probe, while the server/discover request still ran and the block exited 3, as it did before this change. Replace each RPC probe's placeholders inside the single quotes on its `set --` line, keeping the quotes, and paste the whole block. ```bash # Unauthenticated server/discover: expect 401 (Option A: the WWW-Authenticate header MAY carry # resource_metadata; if absent, use the well-known URL below) or the proxy's 401 (Option B). # A 400 with JSON-RPC -32020 is a HEADER MISMATCH (missing/malformed Mcp-* headers or a # header/body version disagreement), not an authentication verdict: fix the probe, do not # read it as a pass or fail of the auth check. # --noproxy '*' so a client HTTPS_PROXY cannot answer with its own 401/407 in place of the target. ( # the whole block in one subshell, so one exit status covers both parts set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MCP_HTTPS_URL' 'REPLACE_WITH_ALLOWED_ORIGIN' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs exactly 2 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the MCP HTTPS URL; not probing'; exit 2 ;; esac case "$1" in https://*) ;; *) echo 'use an https:// URL; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*) echo 'substitute the allowed Origin; not probing'; exit 2 ;; esac case "$1$2" in *[[:cntrl:]]*) echo 'control character in input; not probing'; exit 2 ;; esac set -- "$1" "$2" "$(if curl -q -g -si --noproxy '*' -X POST --url "$1" -H "Origin: $2" \ -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -w '\nhttp=%{http_code}\n' \ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"check","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the response with its http= line, then curl's exit status printf '%s' "$3" set -- "$1" "$2" "$3" "$4" "$(if printf '%s' "$3" | tr -d ' \t\r\n'; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the response without white space, then tr's exit status case "${5##*$'\n'}" in 0) ;; *) echo "tr exit ${5##*$'\n'}: the response could not be processed; inconclusive"; exit 3 ;; esac set -- "$1" "$2" "${5%$'\n'*}" "$4" "${3##*http=}" # the response without white space, curl's exit status, then the HTTP code set -- "$1" "$2" "$3" "$4" "${5%%[!0123456789]*}" "" case "$3" in *'"resultType":"complete"'*) set -- "$1" "$2" "$3" "$4" "$5" "${6}r" ;; esac case "$3" in *'"supportedVersions"'*) set -- "$1" "$2" "$3" "$4" "$5" "${6}v" ;; esac case "$3" in *'"capabilities"'*) set -- "$1" "$2" "$3" "$4" "$5" "${6}c" ;; esac case "$4:$5:$6" in 0:401:*) echo "pass: the unauthenticated server/discover request was refused with 401"; exit 0 ;; 0:2??:rvc) echo "FINDING: the unauthenticated server/discover request returned a discovery result (resultType complete, supportedVersions and capabilities)"; exit 1 ;; 0:*) echo "HTTP $5 without a discovery result is neither the 401 pass nor an exposed result: a login page, redirect, JSON-RPC error or 400 -32020 header mismatch demonstrates neither; inconclusive"; exit 3 ;; *) echo "curl exit $4: the server/discover request failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # Option A only: FOLLOW the resource_metadata URL from the 401's WWW-Authenticate header WHEN PRESENT # (the spec permits well-known-only discovery; fall back to /.well-known/oauth-protected-resource/mcp, # then the root below) and confirm it names your authorization server. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_MCP_HOST' # the host name of the MCP HTTPS URL used above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again if curl -q -g -s --noproxy '*' "https://$1/.well-known/oauth-protected-resource"; then # JSON with "authorization_servers" echo echo "unclassified observation (Option A only): confirm that the JSON above names your authorization server in authorization_servers"; exit 0 else echo "curl exit $?: the well-known metadata read failed; inconclusive"; exit 3 fi ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the response headers and the well-known metadata JSON are not classified, so judge them yourself" exit "$1" ) ``` REASONED: following block; Probe 2, the header-file handling and the allowed and disallowed Origin pair in the following block, per the guide's cited curl manual entry (header files) and MCP specification 2026-07-28 Streamable HTTP (Origin validation), Discovery and Caching entries. **Probe 2 - REASONED, not demonstrated.** No live MCP 2026-07-28 server and valid deployment credential are available in the authoring environment, so neither the fixed 403 nor an exposed discovery result for the disallowed Origin was observed. Establish the allowed-Origin positive control before interpreting the negative request. Require a real `DiscoverResult`, including `resultType:"complete"`, `supportedVersions`, `capabilities`, and the `ttlMs`/`cacheScope` caching hints required for complete discovery results. See Discovery and Caching. Fixed: changing only Origin produces `403`; exposed: the disallowed Origin still receives a discovery result. A `403` counts as an Origin rejection only after the otherwise-identical positive control succeeds. See Streamable HTTP, Security & Endpoint. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 and ran with a stub curl and no network: unchanged, without its set -- line, shortened or with an http:// URL, it printed the matching refusal and exited 2, and an unsubstituted or unreadable header-file path drew its own refusal. With a mode-0600 fixture header file, the stub received -H with @ and the file's path in both calls, the Origin changed from the allowed value to https://attacker.example, and the fixture token appeared in neither argument list. Those runs covered an earlier form of the block, which did not classify its two requests. The current form exits 1 when the disallowed Origin still receives a discovery result, 0 when the positive control returns a complete discovery result and the disallowed Origin is refused with 403, 3 when the control fails or a request gives any other answer or fails, and 2 for a guard refusal. That form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026 with no network. Header files keep credential values out of curl's argv; this does not protect a secret copied into shell history, exposed through tracing, or readable by the account owner. See [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) and the curl manual. ```bash # Origin control (DNS rebinding): a wrong Origin must be rejected even WITH a valid credential. Run the # positive control first (ALLOWED Origin + a real server/discover body: require an actual discovery # result - resultType:"complete", supportedVersions, capabilities, and ttlMs/cacheScope - not merely a 2xx # status; a login page or a JSON-RPC error is not a positive control). Then the SAME request with only # the Origin changed to a disallowed value (expect 403), so the 403 is attributable to the Origin check. # A 401 here is an auth failure and a 400 -32020 is a header mismatch: neither is an Origin verdict. # Keep the credential in a mode-0600 header file (secrets.md), never on the command line: # REPLACE_WITH_AUTH_HEADER_FILE holds a line like "Authorization: Bearer ". ( set +e # errexit off here, so a request that ends nonzero is classified below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MCP_HTTPS_URL' 'REPLACE_WITH_ALLOWED_ORIGIN' 'REPLACE_WITH_AUTH_HEADER_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'the set -- line needs exactly 3 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the MCP HTTPS URL; not probing'; exit 2 ;; esac case "$1" in https://*) ;; *) echo 'use an https:// URL; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*) echo 'substitute the allowed Origin; not probing'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*) echo 'substitute the mode-0600 auth-header file path on the set -- line above; not probing'; exit 2 ;; esac [ -r "$3" ] || { echo 'header file not readable; not probing'; exit 2; } case "$1$2$3" in *[[:cntrl:]]*) echo 'control character in input; not probing'; exit 2 ;; esac set -- "$1" "$2" "$3" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 # Allowed Origin + valid credential: require a real DiscoverResult. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -si --noproxy '*' -X POST --url "$1" -H "Origin: $2" \ -H "@$3" \ -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -w '\nhttp=%{http_code}\n' \ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"check","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # the status, the response with its http= line, then curl's exit status printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "$(if printf '%s' "$5" | tr -d ' \t\r\n'; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$6" "${5##*http=}" # the response without white space and tr's exit status, curl's exit status, then the HTTP code case "${5##*$'\n'}" in 0) set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "$6" "${7%%[!0123456789]*}" "" ;; *) echo "tr exit ${5##*$'\n'}: the positive-control response could not be processed; inconclusive"; set -- "$1" "$2" "$3" 3 "" tr "" "" ;; esac case "$5" in *'"resultType":"complete"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}r" ;; esac case "$5" in *'"supportedVersions"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}v" ;; esac case "$5" in *'"capabilities"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}c" ;; esac case "$5" in *'"ttlMs"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}t" ;; esac case "$5" in *'"cacheScope"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}k" ;; esac case "$6:$7:$8" in tr:*) ;; # tr failed above, so this response is not classified 0:2??:rvctk) echo "positive control: the allowed Origin with your credential returned a complete discovery result" ;; 0:*) echo "HTTP $7 without a complete discovery result (resultType complete, supportedVersions, capabilities, ttlMs and cacheScope): the positive control failed, so a 403 below is not attributable to the Origin check; inconclusive"; set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $6: the positive-control request failed; inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac # Only the Origin changed to a disallowed value: expect 403. set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -si --noproxy '*' -X POST --url "$1" -H 'Origin: https://attacker.example' \ -H "@$3" \ -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -w '\nhttp=%{http_code}\n' \ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"check","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # the status, the response with its http= line, then curl's exit status printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "$(if printf '%s' "$5" | tr -d ' \t\r\n'; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$6" "${5##*http=}" # the response without white space and tr's exit status, curl's exit status, then the HTTP code case "${5##*$'\n'}" in 0) set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "$6" "${7%%[!0123456789]*}" "" ;; *) echo "tr exit ${5##*$'\n'}: the disallowed-Origin response could not be processed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3; set -- "$1" "$2" "$3" "$4" "" tr "" "" ;; esac case "$5" in *'"resultType":"complete"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}r" ;; esac case "$5" in *'"supportedVersions"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}v" ;; esac case "$5" in *'"capabilities"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8}c" ;; esac case "$6:$7:$8" in tr:*) ;; # tr failed above, so this response is not classified 0:2??:rvc) echo "FINDING: the disallowed Origin https://attacker.example still received a discovery result"; set -- "$1" "$2" "$3" 1 ;; 0:403:*) echo "the disallowed Origin was refused with 403, which counts as an Origin rejection only after the positive control above succeeded" ;; 0:*) echo "HTTP $7 for the disallowed Origin is neither the 403 pass nor a discovery result (a 401 is an auth failure and a 400 -32020 a header mismatch, neither an Origin verdict); inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $6: the disallowed-Origin request failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; the response headers are not classified, so judge them yourself" exit "$4" ) ``` REASONED: following block; the wrong-audience probe in the following block, per the guide's cited MCP specification 2026-07-28 Authorization entry (audience validation, error codes). During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 and ran with a stub curl and no network: unchanged, without its set -- line, shortened, with an http:// URL, and with an unsubstituted or unreadable header-file path, it printed the matching refusal and exited 2. The stub curl in that marking-review run (October 2026), with no network and a mode-0600 fixture header file, received -H with @ and the file's path, and the fixture token did not appear in its arguments. Those runs covered an earlier form of the block, which did not classify its request. The current form exits 0 for a 401, 1 for a complete discovery result, 3 for any other answer or a failed request, and 2 for a guard refusal; it is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by drafting stub runs in October 2026 with no network. **Probe 3 - REASONED, not demonstrated.** The authoring environment has no live authorization server with a second registered resource. Use an otherwise valid, unexpired token for that other resource, keeping the endpoint, allowed Origin, and request body unchanged. The audience-validation requirement and `401` outcome come from Authorization and Authorization Security Considerations. ```bash # Wrong audience (Option A): a token issued for a DIFFERENT resource must fail with 401. Use an # otherwise valid, unexpired token whose aud names another resource (a forged or expired token would # fail for the wrong reason), in its own mode-0600 header file. Fixed: 401, the audience is rejected. # Exposed: a real DiscoverResult returned for a token minted for another audience, which the spec's # audience-validation MUST forbids. Require resultType:"complete", supportedVersions, capabilities, # ttlMs, and cacheScope; a login page or JSON-RPC error, even with 2xx, is not acceptance. # A 400 -32020 is a header mismatch, not an audience verdict. # This step is reasoned, not demonstrated in the authoring environment: it needs a second resource # registered at your authorization server. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MCP_HTTPS_URL' 'REPLACE_WITH_ALLOWED_ORIGIN' 'REPLACE_WITH_WRONG_AUDIENCE_HEADER_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'the set -- line needs exactly 3 values; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*) echo 'substitute the MCP HTTPS URL; not probing'; exit 2 ;; esac case "$1" in https://*) ;; *) echo 'use an https:// URL; not probing'; exit 2 ;; esac case "$2" in ''|*REPLACE_WITH_*) echo 'substitute the allowed Origin; not probing'; exit 2 ;; esac case "$3" in ''|*REPLACE_WITH_*) echo 'substitute the mode-0600 wrong-audience file path on the set -- line above; not probing'; exit 2 ;; esac [ -r "$3" ] || { echo 'header file not readable; not probing'; exit 2; } case "$1$2$3" in *[[:cntrl:]]*) echo 'control character in input; not probing'; exit 2 ;; esac # Otherwise-valid token, wrong aud: require 401. set -- "$1" "$2" "$3" "$(if curl -q -g -si --noproxy '*' -X POST --url "$1" -H "Origin: $2" \ -H "@$3" \ -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -w '\nhttp=%{http_code}\n' \ -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"check","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # the response with its http= line, then curl's exit status printf '%s' "$4" set -- "$1" "$2" "$3" "$(if printf '%s' "$4" | tr -d ' \t\r\n'; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$5" "${4##*http=}" # the response without white space and tr's exit status, curl's exit status, then the HTTP code case "${4##*$'\n'}" in 0) set -- "$1" "$2" "$3" "${4%$'\n'*}" "$5" "${6%%[!0123456789]*}" "" ;; *) echo "tr exit ${4##*$'\n'}: the response could not be processed; inconclusive"; set -- "$1" "$2" "$3" "" tr "" "" ;; esac case "$4" in *'"resultType":"complete"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7}r" ;; esac case "$4" in *'"supportedVersions"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7}v" ;; esac case "$4" in *'"capabilities"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7}c" ;; esac case "$4" in *'"ttlMs"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7}t" ;; esac case "$4" in *'"cacheScope"'*) set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7}k" ;; esac case "$5:$6:$7" in tr:*) set -- 3 ;; # tr failed above, so the response is not classified 0:401:*) echo "pass: the token for another resource was refused with 401, so the audience is rejected"; set -- 0 ;; 0:2??:rvctk) echo "FINDING: a token minted for another audience received a complete discovery result"; set -- 1 ;; 0:*) echo "HTTP $6 is neither the 401 pass nor a complete discovery result (a login page or JSON-RPC error, even with 2xx, is not acceptance, and a 400 -32020 is a header mismatch); inconclusive"; set -- 3 ;; *) echo "curl exit $5: the wrong-audience request failed; inconclusive"; set -- 3 ;; esac echo "block exit $1; the response headers are not classified, so judge them yourself" exit "$1" ) ``` ## Common mistakes - Binding the HTTP transport to `0.0.0.0` "for Docker" and leaving it there. Publish the container port on `127.0.0.1` only ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). - Treating stdio as safe because it has no port: the server runs as you, with your environment. - Accepting any token from your identity provider without checking the audience, so a token issued for another API also opens the MCP server. - Passing the caller's token to the upstream API, which makes the upstream trust a token it never issued and loses the audit trail. - Assuming the MCP endpoint still answers `GET` or carries an `Mcp-Session-Id`: under 2026-07-28 the endpoint is POST-only and protocol sessions are removed; a server supporting only this revision SHOULD return `405` for GET/DELETE. See Streamable HTTP. - Treating possession of a state handle as authentication: the spec forbids it; verify every inbound request. See Security Best Practices. ## Sources (checked September 2026) - MCP specification 2026-07-28, Transports, Streamable HTTP (POST endpoint, Origin validation, loopback binding, request headers, header/body validation, removed legacy mechanisms): https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http - MCP specification 2026-07-28, Base protocol (per-request `_meta`, protocol version, client capabilities and identity): https://modelcontextprotocol.io/specification/2026-07-28/basic/index - MCP specification 2026-07-28, Discovery, `server/discover` (server implementation requirement, optional client call, request and response shape): https://modelcontextprotocol.io/specification/2026-07-28/server/discover - MCP specification 2026-07-28, Changelog (handshake and session removal, subscription streams, error-code renumbering): https://modelcontextprotocol.io/specification/2026-07-28/changelog - MCP specification 2026-07-28, Authorization (OPTIONAL, OAuth resource server, credentials, `resource`, audience validation, error codes, scope hierarchy, `offline_access`): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization - MCP specification 2026-07-28, Authorization Server Discovery (RFC 9728 metadata, `authorization_servers`, challenge and well-known discovery): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery - MCP specification 2026-07-28, Authorization Security Considerations (PKCE, refresh-token rotation, HTTPS, separate upstream tokens): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations - MCP specification 2026-07-28, Client Registration (DCR deprecation, Client ID Metadata Documents, `application_type`): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration - MCP specification 2026-07-28, Security Best Practices (local-server controls, state-handle hijacking, forbidden token passthrough): https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices - MCP specification 2026-07-28, Caching (`ttlMs` and `cacheScope` required on complete discovery results): https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching - MCP specification 2026-07-28, Subscriptions (`subscriptions/listen` long-lived SSE notification stream): https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions - MCP specification 2025-11-25, Transports - **legacy** handshake/session shape, for deployments not yet on 2026-07-28: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports - Azure App Service authentication (protected resource metadata preview, `WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES`, comma-separated scopes): https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization - nginx rewrite module (`if` and `return` directives): https://nginx.org/en/docs/http/ngx_http_rewrite_module.html - nginx core module (embedded `$http_name` variables): https://nginx.org/en/docs/http/ngx_http_core_module.html - nginx map module (Origin allowlist map): https://nginx.org/en/docs/http/ngx_http_map_module.html - nginx proxy module (`proxy_buffering`, `proxy_read_timeout`, `X-Accel-Buffering`, `proxy_cache`, `proxy_cache_methods`): https://nginx.org/en/docs/http/ngx_http_proxy_module.html - Caddy reverse_proxy directive (streaming: a `text/event-stream` response is flushed immediately; the HTTP transport `read_timeout` default is no timeout): https://caddyserver.com/docs/caddyfile/directives/reverse_proxy - MCP versioning, 2026-07-28 documentation (2026-07-28 is the current protocol version; Draft, Current and Final are defined generically, with no revision named as Final; 2025-11-25 and earlier are called handshake-based protocol revisions; checked October 2026): https://modelcontextprotocol.io/docs/2026-07-28/learn/versioning - curl manual (leading `-q`, header files, `exitcode` and `errormsg`, both added in curl 7.75.0): https://curl.se/docs/manpage.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - mcp: MCP (2026-07-28) - legacy: MCP legacy (2025-11-25) - azure: Azure App Service (unknown) - nginx: nginx (unknown) - caddy: Caddy (unknown) - curl: curl minimum (7.75.0) Claims (full source mapping in the JSON block below): - revision (REASONED; mcp, legacy): 2026-07-28 is the Current revision and may still receive backwards-compatible changes; 2025-11-25 and earlier are handshake-based protocol revisions. - stdio (REASONED; mcp): Prefer local stdio; subprocess inherits user privileges and supplied environment credentials; restrict that environment. - http (REASONED; mcp): Streamable HTTP uses one POST endpoint; 2024-11-05 HTTP+SSE is deprecated. - metadata (REASONED; mcp): No initialize handshake; each request needs protocolVersion and clientCapabilities in params._meta; clientInfo is recommended. - headers (REASONED; mcp): MCP-Protocol-Version mirrors body version; Mcp-Method mirrors method; Mcp-Name is required only for tools/call, resources/read and prompts/get. - header-error (REASONED; mcp): Missing/malformed required headers or body mismatch require HTTP 400 and -32020; unsupported version requires 400 and -32022. - discover (REASONED; mcp): Servers MUST implement server/discover; clients MAY call it, without a mandatory first-call requirement. - legacy-removal (REASONED; mcp): Sessions, Mcp-Session-Id, GET stream and Last-Event-ID resumability are removed; revision-only servers SHOULD ignore legacy headers and return GET/DELETE 405. - bind (REASONED; mcp): Local HTTP SHOULD bind localhost; explicitly bind 127.0.0.1 and expose only the fronting layer, checking framework options. - origin (REASONED; mcp): Validate Origin; a present invalid value requires 403, optionally a JSON-RPC error without id. - origin-proxy (REASONED; nginx): nginx /mcp forwards to 127.0.0.1:3000; the if rule rejects missing Origin, while the map variant allows it and rejects wrong origins. - origin-limits (REASONED; mcp): Proxy Origin checks do not replace server mirror-header/body validation. - tls (REASONED; mcp): Use loopback HTTP with TLS ingress; authorization-server endpoints require HTTPS. - auth (REASONED; mcp): Protocol authorization is optional; this guide requires authentication beyond loopback, with OAuth 2.1 resource-server or fronting controls. - resource-metadata (REASONED; mcp): RFC 9728 metadata must name at least one authorization server; server supports challenge or well-known discovery, client both, path-suffixed then root fallback. - pkce (REASONED; mcp): Clients require PKCE, S256 when capable, and refuse metadata lacking code_challenge_methods_supported. - resource (REASONED; mcp): Clients send canonical MCP resource URI in authorization and token requests. - audience (REASONED; mcp): Validate intended audience; invalid/expired tokens receive 401 and insufficient scope 403. - upstream (REASONED; mcp): Never pass caller tokens upstream; use separate upstream tokens, least-privilege keys and identity-attributed tool logs. - bearer (REASONED; mcp): Bearer token goes in Authorization on every request, never the URL; the authorization GET /mcp example is identified as stale. - handles (REASONED; mcp): State handles are not authentication; verify every request, use unpredictable handles and bind them to the verified user. - scopes (REASONED; mcp): Server token acceptance MUST account for broader scopes implying narrower ones. - refresh (REASONED; mcp): Public-client refresh tokens MUST rotate; servers SHOULD NOT advertise offline_access as a resource scope. - registration (REASONED; mcp): CIMD is preferred over deprecated DCR; DCR remains compatible and requires appropriate application_type. - azure-prm (REASONED; azure): Preview WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES takes comma-separated scopes and adds metadata URL/scopes to the 401 challenge. - fronting (REASONED; mcp): Static proxy bearer auth requires preconfigured client headers; it does not implement MCP OAuth metadata negotiation. - mfa (REASONED; mcp): Enforce human MFA at the authorization server or fronting identity provider; MCP has no login dialogue. - secrets (REASONED; mcp): Supply separate upstream keys per server/environment from environment or secret storage, with least privilege. - subscriptions (REASONED; mcp): subscriptions/listen POST opens a long-lived text/event-stream until cancelled; preserve framing and content type. - nginx-buffer (REASONED; nginx, mcp): nginx buffers by default; proxy_buffering off or honored X-Accel-Buffering: no permits immediate events. - nginx-timeout (REASONED; nginx, mcp): proxy_read_timeout defaults to 60s between reads; example 3600s must exceed expected idle gaps, with keep-alives as an alternative. - caddy-stream (REASONED; caddy): Caddy flushes text/event-stream immediately and defaults to no read timeout. - cache (REASONED; mcp): Private caches must separate authorization contexts and result-affecting inputs; cacheScope never replaces access checks; public results may be shared. - nginx-cache (REASONED; nginx): proxy_cache off keeps caching disabled; default methods are GET/HEAD, and POST caching needs explicit configuration. - verify-network (REASONED; mcp, curl): Inventory listeners and probe actual direct origin; any HTTP response proves reachability, local errors and unexplained timeouts are inconclusive. - verify-anonymous (REASONED; mcp): Unauthenticated discovery should get 401; actual discovery data is exposure, while redirects, login pages and RPC/transport errors are inconclusive. - verify-origin (REASONED; mcp): Require valid-credential allowed-Origin DiscoverResult, including complete/version/capabilities/cache hints; changing only Origin should yield 403. - verify-audience (REASONED; mcp): Otherwise-valid token for another resource should get 401, not DiscoverResult; no second-resource authorization server was available. - verify-credential-file (REASONED; curl): Mode-0600 header files keep credentials out of curl argv, without protecting history, tracing or account-owner access. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "581d8af4352565f05f8821de257a81cf4df59b44433db76859b2c67bb70a729b", "components": { "mcp": { "name": "MCP", "basis": "2026-07-28", "sources": { "s6e1c66cfb825": "https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http", "se8d957787d4b": "https://modelcontextprotocol.io/specification/2026-07-28/basic/index", "s1d962c091f09": "https://modelcontextprotocol.io/specification/2026-07-28/server/discover", "sa739f7343f18": "https://modelcontextprotocol.io/specification/2026-07-28/changelog", "s34fabf499cb7": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization", "s117c3edbfcd7": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery", "s4b9b953ca683": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations", "s5655f61af2a0": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration", "s4903497743e8": "https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices", "s047fe3fd0dc0": "https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching", "s7345b33e447b": "https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions", "s8bc6dbb1e097": "https://modelcontextprotocol.io/docs/2026-07-28/learn/versioning" } }, "legacy": { "name": "MCP legacy", "basis": "2025-11-25", "sources": { "s937fae6437b3": "https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" } }, "azure": { "name": "Azure App Service", "basis": "unknown", "sources": { "s5c4dc1862d97": "https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization" } }, "nginx": { "name": "nginx", "basis": "unknown", "sources": { "sf0bc2adae8cb": "https://nginx.org/en/docs/http/ngx_http_rewrite_module.html", "s40bdf1af1596": "https://nginx.org/en/docs/http/ngx_http_core_module.html", "s75350e9e4e15": "https://nginx.org/en/docs/http/ngx_http_map_module.html", "sf3430c5a0b22": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html" } }, "caddy": { "name": "Caddy", "basis": "unknown", "sources": { "s3865a73ccda3": "https://caddyserver.com/docs/caddyfile/directives/reverse_proxy" } }, "curl": { "name": "curl minimum", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "revision": {"text": "2026-07-28 is the Current revision and may still receive backwards-compatible changes; 2025-11-25 and earlier are handshake-based protocol revisions.", "components": ["mcp", "legacy"], "sources": ["mcp:s8bc6dbb1e097", "legacy:s937fae6437b3"], "status": "REASONED"}, "stdio": {"text": "Prefer local stdio; subprocess inherits user privileges and supplied environment credentials; restrict that environment.", "components": ["mcp"], "sources": ["mcp:s4903497743e8", "mcp:s34fabf499cb7"], "status": "REASONED"}, "http": {"text": "Streamable HTTP uses one POST endpoint; 2024-11-05 HTTP+SSE is deprecated.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825"], "status": "REASONED"}, "metadata": {"text": "No initialize handshake; each request needs protocolVersion and clientCapabilities in params._meta; clientInfo is recommended.", "components": ["mcp"], "sources": ["mcp:se8d957787d4b", "mcp:sa739f7343f18"], "status": "REASONED"}, "headers": {"text": "MCP-Protocol-Version mirrors body version; Mcp-Method mirrors method; Mcp-Name is required only for tools/call, resources/read and prompts/get.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825"], "status": "REASONED"}, "header-error": {"text": "Missing/malformed required headers or body mismatch require HTTP 400 and -32020; unsupported version requires 400 and -32022.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825", "mcp:sa739f7343f18"], "status": "REASONED"}, "discover": {"text": "Servers MUST implement server/discover; clients MAY call it, without a mandatory first-call requirement.", "components": ["mcp"], "sources": ["mcp:s1d962c091f09"], "status": "REASONED"}, "legacy-removal": {"text": "Sessions, Mcp-Session-Id, GET stream and Last-Event-ID resumability are removed; revision-only servers SHOULD ignore legacy headers and return GET/DELETE 405.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825", "mcp:sa739f7343f18"], "status": "REASONED"}, "bind": {"text": "Local HTTP SHOULD bind localhost; explicitly bind 127.0.0.1 and expose only the fronting layer, checking framework options.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825", "mcp:s4903497743e8"], "status": "REASONED"}, "origin": {"text": "Validate Origin; a present invalid value requires 403, optionally a JSON-RPC error without id.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825"], "status": "REASONED"}, "origin-proxy": {"text": "nginx /mcp forwards to 127.0.0.1:3000; the if rule rejects missing Origin, while the map variant allows it and rejects wrong origins.", "components": ["nginx"], "sources": ["nginx:sf0bc2adae8cb", "nginx:s40bdf1af1596", "nginx:s75350e9e4e15"], "status": "REASONED"}, "origin-limits": {"text": "Proxy Origin checks do not replace server mirror-header/body validation.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825"], "status": "REASONED"}, "tls": {"text": "Use loopback HTTP with TLS ingress; authorization-server endpoints require HTTPS.", "components": ["mcp"], "sources": ["mcp:s4b9b953ca683"], "status": "REASONED"}, "auth": {"text": "Protocol authorization is optional; this guide requires authentication beyond loopback, with OAuth 2.1 resource-server or fronting controls.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s4903497743e8"], "status": "REASONED"}, "resource-metadata": {"text": "RFC 9728 metadata must name at least one authorization server; server supports challenge or well-known discovery, client both, path-suffixed then root fallback.", "components": ["mcp"], "sources": ["mcp:s117c3edbfcd7"], "status": "REASONED"}, "pkce": {"text": "Clients require PKCE, S256 when capable, and refuse metadata lacking code_challenge_methods_supported.", "components": ["mcp"], "sources": ["mcp:s4b9b953ca683"], "status": "REASONED"}, "resource": {"text": "Clients send canonical MCP resource URI in authorization and token requests.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7"], "status": "REASONED"}, "audience": {"text": "Validate intended audience; invalid/expired tokens receive 401 and insufficient scope 403.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7"], "status": "REASONED"}, "upstream": {"text": "Never pass caller tokens upstream; use separate upstream tokens, least-privilege keys and identity-attributed tool logs.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s4b9b953ca683", "mcp:s4903497743e8"], "status": "REASONED"}, "bearer": {"text": "Bearer token goes in Authorization on every request, never the URL; the authorization GET /mcp example is identified as stale.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s6e1c66cfb825"], "status": "REASONED"}, "handles": {"text": "State handles are not authentication; verify every request, use unpredictable handles and bind them to the verified user.", "components": ["mcp"], "sources": ["mcp:s4903497743e8"], "status": "REASONED"}, "scopes": {"text": "Server token acceptance MUST account for broader scopes implying narrower ones.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7"], "status": "REASONED"}, "refresh": {"text": "Public-client refresh tokens MUST rotate; servers SHOULD NOT advertise offline_access as a resource scope.", "components": ["mcp"], "sources": ["mcp:s4b9b953ca683", "mcp:s34fabf499cb7"], "status": "REASONED"}, "registration": {"text": "CIMD is preferred over deprecated DCR; DCR remains compatible and requires appropriate application_type.", "components": ["mcp"], "sources": ["mcp:s5655f61af2a0"], "status": "REASONED"}, "azure-prm": {"text": "Preview WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES takes comma-separated scopes and adds metadata URL/scopes to the 401 challenge.", "components": ["azure"], "sources": ["azure:s5c4dc1862d97"], "status": "REASONED"}, "fronting": {"text": "Static proxy bearer auth requires preconfigured client headers; it does not implement MCP OAuth metadata negotiation.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s117c3edbfcd7"], "status": "REASONED"}, "mfa": {"text": "Enforce human MFA at the authorization server or fronting identity provider; MCP has no login dialogue.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7"], "status": "REASONED"}, "secrets": {"text": "Supply separate upstream keys per server/environment from environment or secret storage, with least privilege.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s4903497743e8"], "status": "REASONED"}, "subscriptions": {"text": "subscriptions/listen POST opens a long-lived text/event-stream until cancelled; preserve framing and content type.", "components": ["mcp"], "sources": ["mcp:s7345b33e447b", "mcp:s6e1c66cfb825"], "status": "REASONED"}, "nginx-buffer": {"text": "nginx buffers by default; proxy_buffering off or honored X-Accel-Buffering: no permits immediate events.", "components": ["nginx", "mcp"], "sources": ["nginx:sf3430c5a0b22", "mcp:s6e1c66cfb825"], "status": "REASONED"}, "nginx-timeout": {"text": "proxy_read_timeout defaults to 60s between reads; example 3600s must exceed expected idle gaps, with keep-alives as an alternative.", "components": ["nginx", "mcp"], "sources": ["nginx:sf3430c5a0b22", "mcp:s6e1c66cfb825"], "status": "REASONED"}, "caddy-stream": {"text": "Caddy flushes text/event-stream immediately and defaults to no read timeout.", "components": ["caddy"], "sources": ["caddy:s3865a73ccda3"], "status": "REASONED"}, "cache": {"text": "Private caches must separate authorization contexts and result-affecting inputs; cacheScope never replaces access checks; public results may be shared.", "components": ["mcp"], "sources": ["mcp:s047fe3fd0dc0"], "status": "REASONED"}, "nginx-cache": {"text": "proxy_cache off keeps caching disabled; default methods are GET/HEAD, and POST caching needs explicit configuration.", "components": ["nginx"], "sources": ["nginx:sf3430c5a0b22"], "status": "REASONED"}, "verify-network": {"text": "Inventory listeners and probe actual direct origin; any HTTP response proves reachability, local errors and unexplained timeouts are inconclusive.", "components": ["mcp", "curl"], "sources": ["mcp:s6e1c66cfb825", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1, 2]}, "verify-anonymous": {"text": "Unauthenticated discovery should get 401; actual discovery data is exposure, while redirects, login pages and RPC/transport errors are inconclusive.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s117c3edbfcd7", "mcp:s1d962c091f09"], "status": "REASONED", "verify": [3]}, "verify-origin": {"text": "Require valid-credential allowed-Origin DiscoverResult, including complete/version/capabilities/cache hints; changing only Origin should yield 403.", "components": ["mcp"], "sources": ["mcp:s6e1c66cfb825", "mcp:s1d962c091f09", "mcp:s047fe3fd0dc0"], "status": "REASONED", "verify": [4]}, "verify-audience": {"text": "Otherwise-valid token for another resource should get 401, not DiscoverResult; no second-resource authorization server was available.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7", "mcp:s4b9b953ca683", "mcp:s1d962c091f09", "mcp:s047fe3fd0dc0"], "status": "REASONED", "verify": [5]}, "verify-credential-file": {"text": "Mode-0600 header files keep credentials out of curl argv, without protecting history, tracing or account-owner access.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [4, 5]} } } ```` ====================================================================== ==> mcp-clients.md ====================================================================== # MCP clients: authorizing to Model Context Protocol servers safely An MCP client is the program, such as a desktop app or CLI, that talks to Model Context Protocol servers. This guide helps your AI assistant secure OAuth sign-in to remote servers, including the response issuer checked when present and required when advertised, confidential refresh tokens, and SSRF-safe discovery requests. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mcp-clients.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MCP 2026-07-28, OAuth 2.1 draft draft-ietf-oauth-v2-1-14, CIMD draft draft-ietf-oauth-client-id-metadata-document-00, and OpenID Connect 1.0 errata 2; 2 further sources are pinned to a commit and 2 carry no recorded version. Metadata reviewed 2026-10-17; documentation checked September 2026. The guide records 29 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. An MCP client authorizes to a remote server on a user's behalf, and a malicious or confused server can steer that authorization: it can name an attacker's authorization server, hand back a discovery document that points credentials the wrong way, or supply OAuth URLs that make the client reach inside its own network. The [mcp-servers.md](https://github.com/jposluns/secureconfig/blob/main/mcp-servers.md) guide covers the server operator's side and its fronting proxy; this guide covers the **client's** obligations under the MCP **2026-07-28** authorization flow, where a skipped check is not a style lapse but the difference between an authorization code reaching the honest token endpoint and reaching an attacker's. Everything here is a client-side control: it runs in the MCP client (a desktop app, a CLI, or a client deployed on a server), not in the server it talks to. **Spec revision.** This guide is written against MCP **2026-07-28**, the **Current** revision, and every "MCP requires" below is quoted from that revision's authorization and security pages. Requirement levels (MUST, SHOULD, MAY) are reproduced as the source states them; where a control is a SHOULD in one document and a MUST in another for a narrower case, both are given rather than the stronger one alone. See MCP Authorization. ## 1. Validate the authorization-response `iss` before redeeming the code When the authorization server redirects back with a code, an `iss` parameter may accompany it. RFC 9207 section 2.4 requires a client that receives `iss` to decode it and compare it with the issuer it expected: "This comparison MUST use simple string comparison", and on a mismatch "clients MUST reject the authorization response and MUST NOT proceed with the authorization grant." MCP makes this validation apply **before the code is redeemed**: the client "MUST apply the validation" "before transmitting the authorization code to any token endpoint". A check performed on an ID token *after* the code is exchanged is too late, because the code has already left for a possibly attacker-controlled token endpoint, and for a public client the PKCE verifier can leave with it. The handling is not "reject every response that has no `iss`". It depends on whether the authorization server advertised `authorization_response_iss_parameter_supported` and whether `iss` is present: | AS advertises `authorization_response_iss_parameter_supported: true` | Response `iss` | Required handling | |---|---|---| | Yes | Present | Compare with the recorded issuer; reject on mismatch | | Yes | Absent | Reject the response | | No or omitted | Present | Compare with the recorded issuer; reject on mismatch | | No or omitted | Absent | Proceed under this table (a multi-AS client still needs an `iss`-independent mix-up defence) | Record the issuer you selected from validated metadata (step 2) in the authorization transaction, bound to the same `state`/PKCE record, and compare `iss` against it with exact string comparison: do not normalize hostname case, port, a trailing slash, or percent-encoding, because RFC 9207's "simple string comparison" is defeated by any normalization an attacker can exploit. This defends the mix-up attack of RFC 9700 section 4.4.2, where a client that trusts more than one authorization server is tricked into sending an honest server's code to an attacker's token endpoint; RFC 9700 requires that "clients MUST prevent mix-up attacks". Where `iss` is genuinely unavailable (the last table row), a client that talks to more than one authorization server still needs an applicable mix-up defence and cannot treat the absence as safe. ## 2. Validate discovery metadata identity: `issuer` (not `resource`) Two different identifiers are validated in the discovery flow, and confusing them leaves a hole: - **Authorization-server metadata** carries an `issuer`, and RFC 8414 section 3.3 requires that "The \"issuer\" value returned MUST be identical" to the issuer identifier used to build the well-known URL (which includes any issuer path component, not merely the hostname, and is not the full metadata URL). "If these values are not identical, the data contained in the response MUST NOT be used." MCP restates it: "the `issuer` value in the document MUST be identical to the issuer identifier used to construct the well-known URL." Preserve the issuer you used to derive the discovery URL and compare the returned `issuer` to it exactly, before you use any endpoint from the document. When you discover through OpenID Connect instead, apply the OpenID Connect Discovery section 4.3 provider-configuration validation of `issuer` (this guide's instruction). OpenID Connect Discovery section 4.3 states: "The `issuer` value returned MUST be identical to the Issuer URL that was used as the prefix to `/.well-known/openid-configuration` to retrieve the configuration information. This MUST also be identical to the `iss` Claim value in ID Tokens issued from this Issuer." If validation fails, "any operations requiring the information that failed to correctly validate MUST be aborted and the information that failed to validate MUST NOT be used." - **Protected-resource metadata** carries a `resource`, and RFC 9728 section 3.3 validates *that* against the resource identifier you intended to reach. This is a separate check from the `issuer` check above; RFC 9728 section 3.3 is about `resource`, not `issuer`, so do both. Skipping the `issuer` check lets an attacker serve metadata from a location it controls while naming an honest issuer, associating that honest issuer with attacker-chosen endpoints and undermining the expected-issuer record that step 1's mix-up defence relies on. ## 3. Present the client identity safely: Client ID Metadata Documents, then DCR with an explicit `application_type` MCP 2026-07-28 prefers **Client ID Metadata Documents** (CIMD, `draft-ietf-oauth-client-id-metadata-document-00`, dated 8 October 2025): the client uses an HTTPS URL as its `client_id`, and the authorization server fetches JSON client metadata from that URL instead of requiring prior registration. The draft requires that "Client identifier URLs MUST have an \"https\" scheme, MUST contain a path component", and that the document's own `client_id` "value MUST match the URL of the document" by simple string comparison. MCP states clients "SHOULD support OAuth Client ID Metadata Documents", and a client that supports several mechanisms SHOULD prefer an available pre-registration, then supported CIMD, then Dynamic Client Registration. Dynamic Client Registration (DCR) remains a **MAY**, deprecated but kept for compatibility. When you fall back to it, MCP requires an explicit application type: "MCP clients MUST specify an appropriate `application_type` during Dynamic Client Registration." MCP's guidance is to use `native` (a SHOULD) for desktop, mobile, CLI, and localhost-hosted applications, and `web` (a SHOULD) for remotely hosted browser applications. This matters because the default is not neutral: OpenID Connect registration says "The default, if omitted, is `web`", and `web` implies redirect-URI constraints that can conflict with a native client's loopback or custom-scheme redirect, so under a provider policy that enforces them an omitted `application_type` can be rejected. Supply the type explicitly and handle a rejection by fixing the registration, never by loosening redirect validation to make it pass. (Version boundary: MCP 2025-06-18 said authorization servers and MCP clients "**SHOULD** support the OAuth 2.0 Dynamic Client Registration Protocol" (https://github.com/modelcontextprotocol/modelcontextprotocol/blob/86f6e0c675afea9a8f233e09b6faf566b126086c/docs/specification/2025-06-18/basic/authorization.mdx#L58-L59). MCP 2025-11-25 already set a **SHOULD** priority order for clients supporting all options. The 2025-11-25 priority order is: available pre-registration, then CIMD when advertised, then DCR as a fallback when supported, then asking the user to enter client information if no other option is available (https://github.com/modelcontextprotocol/modelcontextprotocol/blob/86f6e0c675afea9a8f233e09b6faf566b126086c/docs/specification/2025-11-25/basic/authorization.mdx#L198-L209). That order is therefore not new in 2026-07-28. This guide advises carrying the revision label when citing that history.) ## 4. Keep refresh tokens confidential, and request `offline_access` correctly A refresh token outlives the short-lived access token, so a stolen one buys continued access. OAuth 2.1 (draft-ietf-oauth-v2-1-14 section 4.3) requires that "Refresh tokens MUST be kept confidential in transit and storage" and restricts sharing to the issuing authorization server and the client that received the token; this binds **public** clients as much as confidential ones. In practice: - When refreshing access, send a refresh token only to the issuing authorization server's token endpoint over authenticated TLS; to revoke one, send it only to that same server's revocation endpoint (RFC 7009 section 2) over authenticated TLS. Never send a refresh token to the MCP resource server. - Keep it out of logs, URLs, source repositories, and any published client metadata. - Store it in protected server-side storage or a native platform credential store; those are ways to satisfy the confidentiality requirement, not a specific product the spec mandates. To obtain one, MCP says a client that wants refresh tokens "SHOULD include" `refresh_token` in `grant_types`, "MAY add" `offline_access` to its requested scopes when the authorization server advertises it in `scopes_supported`, and "MUST NOT assume" a refresh token will be issued. For an OpenID Connect authorization server, requesting `offline_access` has conditions: OIDC Core section 11 says "a `prompt` parameter value of `consent` MUST be used" (unless another condition already permits offline access), and the server "MUST ignore the `offline_access` request unless the Client is using a `response_type`" that returns an authorization code. So request `offline_access` with the code flow and normally `prompt=consent`, and handle the case where no refresh token comes back, since `offline_access` neither guarantees issuance nor is the only way a refresh token can be issued. ## 5. Defend OAuth discovery fetches against SSRF A client that fetches OAuth-related URLs a server named (protected-resource metadata, authorization-server metadata, and every endpoint reached afterward) is fetching attacker-influenced URLs. RFC 9728 section 7.7 identifies exactly this surface and says "Clients SHOULD take appropriate precautions against SSRF attacks, such as blocking requests to internal IP address ranges." For a client that runs **on a server**, MCP raises it to a MUST: "MCP clients deployed to a server MUST consider SSRF risks and implement appropriate mitigations when fetching OAuth-related URLs." The attack is a malicious server handing back discovery URLs aimed at a cloud metadata service, a loopback daemon, or a private administration endpoint; a redirect or DNS rebinding can turn an allowed destination into an internal one after the check, and later token-endpoint POSTs can reach internal services too. Apply destination controls to every fetched URL, not just the first: - Restrict destinations to production HTTPS and block private/reserved address ranges (loopback, link-local including the `169.254.169.254` metadata address, and RFC 1918). - Re-validate the target of every redirect to the same standard, and address the gap between validating a hostname and connecting to it (DNS can change in between): resolve-then-pin, or route through an egress proxy. - Configure any genuinely required private-network exception explicitly rather than disabling the control. For a deployment with a known set of providers, an issuer allowlist and an approved-destination list narrow the surface further. OWASP's SSRF guidance for this shape is to "Do not accept complete URLs from the user" and instead "Match the host against an allowlist, and build the request yourself." Treat those as sound deployment practice rather than a spec requirement: RFC 9728 deliberately accommodates previously unknown authorization and resource servers, so a dynamic client still needs the runtime SSRF controls above, and issuer-equality validation (step 2) alone does not establish that a destination is safe to fetch. ## 6. Carry the baseline OAuth 2.1 client hygiene through The controls above are the MCP-specific additions; the client still owes the baseline that [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md) covers, which MCP also mandates: - **PKCE.** Clients "MUST implement PKCE", MUST use `S256` "when technically capable", and "MUST refuse to proceed" if the authorization server's metadata lacks `code_challenge_methods_supported`. See MCP Authorization Security Considerations. - **Resource parameter.** Clients MUST send the RFC 8707 `resource` parameter in both the authorization and token requests, naming the MCP server's canonical URI, which requests an access token scoped to that resource. Preventing cross-resource replay then depends on the authorization server supporting audience binding and issuing an audience-restricted token, and on each resource server validating that it is the intended audience; sending the parameter alone does not guarantee it. See MCP Authorization. - **HTTPS everywhere.** Every authorization-server endpoint is contacted over HTTPS, and every redirect URI is `localhost` or HTTPS (MCP Authorization Security Considerations). Bearer and access tokens never travel in a URL query string; they go in the `Authorization` header (MCP Authorization). The authorization code, by contrast, is returned in the registered redirect URI's query by the flow itself (OAuth 2.1 section 4.1.2), so it is protected instead by PKCE and the step 1 `iss` check. The client, for the same reason, keeps callback URLs out of referrer headers (RFC 9700 section 4.2, with referrer suppression at section 4.2.4) and out of stored or logged URLs (RFC 9700 section 3 lists URLs stored or logged by browsers, proxy servers, and operating systems among an attacker's sources). ## Verify (REASONED: issuer/callback, metadata, SSRF, token-storage and registration comparisons follow the cited MCP and RFC passages; no controllable authorization/discovery server, target client or live DCR capture. Local fixture observations below do not demonstrate client integration or protected credential storage) These checks are client-behaviour checks. Standing up a full malicious-authorization-server harness to drive a specific MCP client is infrastructure this repository's authoring environment does not have. That missing infrastructure is why the behavioural steps below are marked **reasoned**: each names the prerequisite that is unavailable, gives the concrete request or command, and states the exposed and fixed outcomes and the source passage that distinguishes them, per the contributing rule on Verify steps. The locally feasible checks (the SSRF address test and the config grep) are run as written. 1. **`iss` mismatch is rejected before redemption (reasoned).** Prerequisite unavailable: a controllable authorization server and a target MCP client. Run two authorization-callback requests to the client's redirect URI that are identical except for `iss`, and run each from the SAME freshly restored pre-callback snapshot of the client and the authorization transaction (an unconsumed `state`, a valid unredeemed `code`, and the matching PKCE context), so that neither a consumed `state` nor a spent code, but only `iss`, can cause a rejection. This isolation matters because RFC 9700 section 4.2.4 says the `state` value "SHOULD be invalidated by the client after its first use at the redirection endpoint", so replaying the second callback against the same live transaction would let a client with no issuer validation reject it as a replay and mimic the fixed outcome. Restore the snapshot before each request. Control (must be accepted), carrying the recorded issuer: `GET /callback?code=SPLICED_AUTH_CODE&state=THE_RECORDED_STATE&iss=https%3A%2F%2Fas.example.com`. Test (must be rejected), differing only in `iss`: the same request with `iss=https%3A%2F%2Fevil.example.net`. Exposed (vulnerable) client: it proceeds to `POST` the code to a token endpoint in the test case. Fixed client: it accepts the control and rejects the test, sending no token request for the test. The distinguishing source is RFC 9207 section 2.4 ("clients MUST reject ... and MUST NOT proceed") applied before redemption per MCP Authorization ("before transmitting the authorization code to any token endpoint"). Observe the difference on the wire (a token request appears for the control but not for the test). 2. **Metadata `issuer` mismatch is rejected (reasoned).** Prerequisite unavailable: a controllable metadata endpoint. Serve two authorization-server metadata documents at the well-known URL built from the issuer `https://as.example.com` (that is, `https://as.example.com/.well-known/oauth-authorization-server`), identical except for `issuer`. Control (accepted): `{"issuer":"https://as.example.com","authorization_endpoint":"https://as.example.com/authorize","token_endpoint":"https://as.example.com/token","response_types_supported":["code"],"code_challenge_methods_supported":["S256"]}`. That is a metadata baseline complete enough for the client to proceed (RFC 8414 section 2 marks `response_types_supported` REQUIRED, and MCP refuses when `code_challenge_methods_supported` is absent), so a rejection is attributable to `issuer` rather than a missing required field. Test (rejected): the same body with only `"issuer":"https://evil.example.net"` changed. Run each from a fresh, cache-cleared pre-discovery state so a cached control document cannot satisfy the test run (RFC 9111 section 4.2). A further instruction is to make the client's other well-known discovery locations, including the OpenID configuration, return a fixed failure in both runs, so any endpoint the client contacts can only have come from the document under test rather than from independently validated metadata. Exposed: the client uses the endpoints from the test document. Fixed: the client accepts the control and rejects the test, attributing the rejection to the `issuer` mismatch and contacting no endpoint obtained from the rejected document. Require evidence that the test document was actually fetched, so a mere absence of endpoint traffic, which a cache hit or a blocked alternative would also produce, is not read as success. Distinguishing source: RFC 8414 section 3.3 ("the data contained in the response MUST NOT be used"). 3. **SSRF destinations are blocked (partially runnable).** The address-classification half is runnable now; the client-integration half is reasoned. Runnable: confirm the deny-list rejects the reserved and internal ranges a discovery URL might target, namely the cloud metadata address, loopback, and RFC 1918 space: Address classification follows the cited RFC 9728 and MCP SSRF guidance, while no target client or discovery fixture is available. Python 3.14.4 ran the printed expression: 169.254.169.254, 127.0.0.1, 10.0.0.1, 192.168.0.1 and 172.16.0.1 each printed BLOCK; the same expression with 8.8.8.8 printed ALLOW. Every invocation exited 0. This classifies six literal addresses only; it does not test DNS, fetching or SSRF enforcement. ```bash ( # a subshell, so the exit below ends only the subshell set -- 0 169.254.169.254 127.0.0.1 10.0.0.1 192.168.0.1 172.16.0.1 # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the addresses still to classify while [ "$#" -gt 1 ]; do set -- "$(if python3 -c 'import ipaddress,sys; a=ipaddress.ip_address(sys.argv[1]); print(sys.argv[1], "BLOCK" if (a.is_private or a.is_loopback or a.is_link_local or a.is_reserved) else "ALLOW")' "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1##*$'\n'}" "${1%$'\n'*}" "${@:2}" # python's exit status, its line, the status so far, then the addresses printf '%s' "$2" case "$1:$2" in 0:"$4 BLOCK"$'\n') set -- "$3" "${@:5}" ;; 0:"$4 ALLOW"$'\n') echo "FINDING: $4 printed ALLOW, an SSRF hole"; set -- 1 "${@:5}" ;; *) echo "python3 exit $1: $4 was not classified; inconclusive"; if [ "$3" = 1 ]; then set -- 1 "${@:5}"; else set -- 3 "${@:5}"; fi ;; esac done echo "block exit $1; the five addresses are the whole check, so beyond any error above it leaves nothing for you to judge" exit "$1" ) ``` Every line must print `BLOCK`; a printed `ALLOW` for any of these is an SSRF hole. Reasoned (prerequisite unavailable: a malicious server, the target client, and a controllable HTTPS discovery host that carries a valid, trusted TLS certificate and whose name resolves to an address you choose): keep the scheme HTTPS with valid TLS throughout and vary only the resolved address, so a refusal is attributable to the address policy rather than to HTTPS or TLS enforcement. Run the public-address control and the private-address test from the same fresh pre-discovery state, clearing any cached discovery metadata, DNS answers, and open connections between them. The reason for that fresh state and clearing is that otherwise a cached response would satisfy discovery with no fetch at all (RFC 9728 section 7.10 notes "Normal HTTP caching behaviors apply", and RFC 9111 section 4.2 has a fresh cached response served without contacting the origin). Establish the public-address control first: have the malicious server advertise a discovery URL on a host you control, for example `https://discovery.example.com/.well-known/oauth-protected-resource`. A further instruction for the public-address control is to resolve `discovery.example.com` to a routable public address and confirm the client fetches the discovery URL, which the client requests with `GET /.well-known/oauth-protected-resource HTTP/1.1` and `Host: discovery.example.com` (RFC 9728 section 3.1 requires the metadata "MUST be queried using an HTTP GET request"). Then, from that cleared state and changing only the resolved address, point `discovery.example.com` at the cloud metadata address (`169.254.169.254`) or another blocked range. Exposed (vulnerable) client: it fetches the URL and reaches the internal service. Fixed client: it resolves the test to the blocked address and refuses that destination before connecting. Require positive evidence of that resolve-and-reject, not the mere absence of a request, since a cache hit also produces no request. Distinguishing source: RFC 9728 section 7.7 and, for server-deployed clients, the MCP MUST in Security Best Practices. 4. **No refresh token or long-lived bearer token stored in cleartext (runnable, bounded heuristic).** This is a heuristic scan, not a proof: it searches the client's config and log paths for a refresh-token or bearer-token value in the clear, lists only the filenames (never the secret itself), and separates a clean scan from a scan error. Substitute the client's own path for the placeholder, inside the single quotes on the `set --` line: The bounded scan follows the cited OAuth confidentiality requirements; no actual client configuration or protected credential store is available. Bash 5.3.9 and GNU grep 3.12 ran the complete block with a disposable directory containing only config.json. With {"refresh_token":"DUMMY_NOT_A_SECRET"}, it printed that filename and FOUND; replacing the content with {"grant_types":["refresh_token"]} printed clean. Both exited 0. Empty and unchanged-placeholder paths printed the substitution diagnostic; an absent path and a regular JSON file printed not a directory; all four also exited 0. These skips are not clean scans, and the synthetic clean result proves neither secret absence nor storage protection. All three Verify shell fences passed bash -n. The bash -n and fixture runs recorded here covered the token-scan block in an earlier form, whose guard refusals left the subshell with status 0 and whose result arms ended with status 0, and the fixture runs were not repeated. On the current form, whose guard refusals leave the subshell with status 2, whose FOUND arm exits 1 and whose scan-error arm exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. That bash -n run also covered earlier forms of the address-classification and application_type blocks, which ended with the status of their last command; on their current forms, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLIENT_CONFIG_DIR' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the client's config/log directory inside the quotes above; not probing"; exit 2 ;; esac [ -d "$1" ] || { echo "not a directory: $1; not probing"; exit 2; } if env LC_ALL=C grep -D skip -RlaiE '"refresh_token"[[:space:]]*:[[:space:]]*"[^"]|refresh_token=[[:print:]]+|Bearer[[:space:]]+[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/-]{20,}' -- "$1/"; then echo "FOUND: the file(s) listed above hold a token value in cleartext; move it to a protected credential store"; exit 1 else case $? in 1) echo "clean: no cleartext token value found under $1" ;; *) echo "scan error: grep could not read part of $1; re-run with access before trusting a clean result"; exit 3 ;; esac fi ) ``` A listed file flags a possible cleartext refresh or bearer token value where OAuth 2.1 requires confidentiality (section 4.3 for the refresh token, section 1.4 for the bearer access token); the fixed state keeps it in a protected credential store and the scan reports `clean`. The pattern matches a token value (a JSON `"refresh_token": "..."` pair, a `refresh_token=` assignment with any nonempty printable value, or a `Bearer ` header), so the bare word `refresh_token` inside a `grant_types` list does not trip it. The assignment match is deliberately broad, so it can also flag a benign empty quoted assignment, which is the safe direction for a secret scan, and the scan lists filenames rather than printing the secret. It cannot see a token that is encrypted, wrapped, or split across fields, so read a `clean` result as "nothing obvious", not "nothing present". 5. **`application_type` is present on DCR (reasoned).** Prerequisite unavailable: the client's live DCR request. Capture the registration request body the client `POST`s to the DCR endpoint and confirm it includes `application_type` (`native` or `web` per step 3). A fixed native client's captured body looks like this (an illustrative shape, not a live capture): Illustrative native registration follows MCP Client Registration and OIDC Registration; no live DCR request is available. ```json {"redirect_uris":["http://localhost:3000/callback"],"grant_types":["authorization_code"],"response_types":["code"],"token_endpoint_auth_method":"none","application_type":"native"} ``` Save the actual captured body as `dcr-request.json` and inspect it (this native client checks for `native`; a remotely-hosted browser client checks for `web` instead): The application_type inspection follows the cited MCP and OIDC registration requirements; no live DCR request is available. Python 3.14.4 ran the exact printed json.load/get expression with {}, {"application_type":"web"} and {"application_type":"native"} on stdin: exits 1, 1 and 0 respectively, with no output. json.loads also accepted the illustrative registration and metadata documents. These are local JSON controls, not a captured registration or server acceptance. ```bash ( # a subshell, so the exits below end only the subshell [ -f dcr-request.json ] || { echo "no dcr-request.json in this directory: save the captured registration body there first; not checking"; exit 2; } [ -r dcr-request.json ] || { echo "dcr-request.json is unreadable; not checking"; exit 2; } set -- "$(if python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("application_type") == "native" else 1)' < dcr-request.json 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%$'\n'*}" # python's exit status, then anything it printed printf '%s' "$2" case "$1:$2" in 0:) echo "pass: application_type has the value the expression checks; block exit 0"; exit 0 ;; 1:) echo "FINDING: application_type is absent, which the server defaults to web, or is not the value the expression checks; block exit 1"; exit 1 ;; *) echo "python3 exit $1: python3 failed or the body was not read as a JSON object (read any error above); block exit 3, inconclusive"; exit 3 ;; esac ) ``` Exposed: the field is absent and the server defaults it to `web` (OIDC Registration section 2), which under a provider policy that enforces the web redirect-URI constraints gets a native client's loopback or custom-scheme redirect rejected, so the command exits non-zero. Fixed: the field is present and matches the client's deployment, so the command exits zero. Distinguishing source: MCP's "MUST specify an appropriate `application_type`". ## Sources (checked September 2026) - MCP Authorization, 2026-07-28 (`iss` validation timing, access-token query-string prohibition, `resource` parameter, refresh-token scope guidance): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization - MCP Authorization Security Considerations, 2026-07-28 (PKCE MUSTs, HTTPS endpoints and redirect URIs): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations - MCP Authorization Server Discovery, 2026-07-28 (`issuer` identity requirement): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery - MCP Client Registration, 2026-07-28 (CIMD SHOULD, mechanism preference, DCR `application_type` MUST): https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration - MCP Authorization, 2025-06-18 (authorization servers and MCP clients SHOULD support DCR; checked October 2026): https://github.com/modelcontextprotocol/modelcontextprotocol/blob/86f6e0c675afea9a8f233e09b6faf566b126086c/docs/specification/2025-06-18/basic/authorization.mdx#L58-L59 - MCP Authorization, 2025-11-25 (registration mechanisms and their SHOULD priority order; checked October 2026): https://github.com/modelcontextprotocol/modelcontextprotocol/blob/86f6e0c675afea9a8f233e09b6faf566b126086c/docs/specification/2025-11-25/basic/authorization.mdx#L198-L209 - MCP Security Best Practices, 2026-07-28 (SSRF MUST for server-deployed clients): https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices - RFC 9207 section 2.4, OAuth 2.0 Authorization Server Issuer Identification (`iss` simple string comparison, reject on mismatch): https://www.rfc-editor.org/rfc/rfc9207.html#section-2.4 - RFC 9700 section 3, section 4.2, section 4.2.4, and section 4.4.2, OAuth 2.0 Security Best Current Practice (attacker-accessible stored or logged URLs; Referer leakage and its suppression; single-use `state`; mix-up attacks): https://www.rfc-editor.org/rfc/rfc9700.html#section-4.2.4 - RFC 8414 section 3.3, OAuth 2.0 Authorization Server Metadata (`issuer` identity): https://www.rfc-editor.org/rfc/rfc8414.html#section-3.3 - RFC 9728 section 3.1, section 3.3, section 7.7, and section 7.10, OAuth 2.0 Protected Resource Metadata (metadata GET request; `resource` validation; SSRF precautions; metadata caching): https://www.rfc-editor.org/rfc/rfc9728.html#section-7.7 - RFC 9111 section 4.2, HTTP Caching (a fresh cached response is served without contacting the origin): https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2 - RFC 8707, Resource Indicators for OAuth 2.0 (`resource` parameter): https://www.rfc-editor.org/rfc/rfc8707.html - RFC 7009 section 2, OAuth 2.0 Token Revocation (refresh-token revocation endpoint): https://www.rfc-editor.org/rfc/rfc7009.html#section-2 - OAuth 2.1 draft-ietf-oauth-v2-1-14 section 1.4, section 4.1.2, and section 4.3 (access-token confidentiality; authorization code in the redirect query; refresh-token confidentiality): https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-14#section-4.3 - OAuth Client ID Metadata Document draft-ietf-oauth-client-id-metadata-document-00 (HTTPS client-id URL, matching `client_id`): https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00 - OpenID Connect Core 1.0 errata 2 section 11, Offline Access (`prompt=consent`, `response_type` condition): https://openid.net/specs/openid-connect-core-1_0-errata2.html#OfflineAccess - OpenID Connect Dynamic Client Registration 1.0 errata 2 section 2 (`application_type` default is `web`): https://openid.net/specs/openid-connect-registration-1_0-errata2.html#ClientMetadata - OpenID Connect Discovery 1.0 errata 2 section 4.3 (`issuer` identity, ID Token `iss`, abort and non-use on failure; checked October 2026): https://openid.net/specs/openid-connect-discovery-1_0-errata2.html#ProviderConfigurationValidation - OWASP Server Side Request Forgery Prevention Cheat Sheet (host allowlist, build the request yourself): https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - mcp: MCP (2026-07-28) - mcp-2025-06-18: MCP 2025-06-18 authorization (86f6e0c675afea9a8f233e09b6faf566b126086c) - mcp-2025-11-25: MCP 2025-11-25 authorization (86f6e0c675afea9a8f233e09b6faf566b126086c) - rfc: OAuth and HTTP RFCs (unknown) - oauth: OAuth 2.1 draft (draft-ietf-oauth-v2-1-14) - cimd: CIMD draft (draft-ietf-oauth-client-id-metadata-document-00) - oidc: OpenID Connect (1.0 errata 2) - owasp: OWASP SSRF guidance (unknown) Claims (full source mapping in the JSON block below): - iss (REASONED; mcp, rfc): Compare received iss exactly with the transaction issuer before sending a code to any token endpoint; reject mismatches without normalization. - iss-required (REASONED; rfc): If issuer-response support is advertised, missing iss is rejected; otherwise present iss is validated and absent iss still needs multi-AS mix-up protection. - transaction (REASONED; rfc, mcp): Bind the validated expected issuer to the state/PKCE transaction; multi-AS clients MUST prevent mix-up attacks. - issuer (REASONED; mcp, rfc): Authorization metadata issuer must exactly equal the issuer used to construct discovery, including its path; reject the document before using endpoints. - resource-metadata (REASONED; rfc): Validate protected-resource resource separately from authorization-server issuer; issuer equality is not SSRF protection. - oidc-discovery (REASONED; oidc, mcp): Apply OpenID Connect Discovery section 4.3 issuer validation to OIDC discovery (this guide's instruction): returned issuer MUST be identical to the Issuer URL used as the well-known prefix and to the iss Claim in ID Tokens issued from this Issuer; on failure, operations requiring that information MUST be aborted and the information MUST NOT be used. - cimd (REASONED; cimd): CIMD client_id uses HTTPS with a path; document client_id must exactly match its URL. - registration-order (REASONED; mcp): Clients SHOULD support CIMD and prefer available preregistration, then CIMD, then optional deprecated DCR. - application-type (REASONED; mcp): DCR MUST specify appropriate application_type: native SHOULD cover desktop/mobile/CLI/localhost apps; web SHOULD cover remote browser apps. - web-default (REASONED; oidc): Omitted application_type defaults to web; incompatible native redirects can be rejected under provider policy, without justification for weakening validation. - registration-history (REASONED; mcp, mcp-2025-06-18, mcp-2025-11-25): MCP 2025-06-18 said authorization servers and MCP clients SHOULD support DCR; MCP 2025-11-25 said clients supporting all options SHOULD use available pre-registration, then advertised CIMD, then supported DCR, then ask the user if no other option is available. Carrying the revision label when citing that MCP registration history is this guide's advice. - refresh-confidential (REASONED; oauth, rfc): Keep refresh tokens confidential in transit/storage, including public clients; send only to issuing-server token/revocation endpoints over authenticated TLS. - refresh-storage (REASONED; oauth): Keep refresh tokens out of logs, URLs, repositories and public metadata; use protected storage or native credential storage. - refresh-request (REASONED; mcp): SHOULD include refresh_token grant type; MAY request advertised offline_access; MUST NOT assume refresh-token issuance. - offline-access (REASONED; oidc): OIDC offline_access requires code-returning response_type and normally prompt=consent; another permitting condition can waive consent prompting. - ssrf (REASONED; rfc, mcp): RFC 9728 recommends SSRF precautions; server-deployed MCP clients MUST mitigate every OAuth-related fetch. - ssrf-destinations (REASONED; mcp, rfc, owasp): Use HTTPS destination controls blocking private/reserved, loopback, link-local and metadata addresses; configure necessary private exceptions explicitly. - ssrf-redirects (REASONED; mcp, rfc, owasp): Revalidate redirects and prevent DNS check/connect races with pinned resolution or controlled egress. - ssrf-allowlist (REASONED; owasp, rfc): Known-provider issuer/destination allowlists narrow exposure; dynamic clients still need runtime controls, beyond issuer equality. - pkce (REASONED; mcp): Require PKCE, S256 when capable, and refuse absent code_challenge_methods_supported. - resource (REASONED; mcp, rfc): Send canonical resource URI in both authorization/token requests; replay protection also needs audience-bound issuance and server audience validation. - https (REASONED; mcp): Use HTTPS authorization endpoints and localhost or HTTPS redirect URIs. - token-url (REASONED; mcp, oauth, rfc): Access tokens go in Authorization, never URL queries; authorization codes use callback queries with PKCE/iss and referrer/log suppression. - verify-iss (REASONED; rfc, mcp): Restore the same fresh transaction before matched callbacks; control redeems code, mismatched iss must cause no token request, independently of state replay. - verify-metadata (REASONED; rfc, mcp): Fresh uncached complete metadata pair differs only in issuer; prove test fetch, disable alternative discovery, and reject endpoints from mismatched metadata. - verify-address (REASONED; rfc, mcp): Local classifier should print BLOCK for all five internal addresses; guide says it ran but records no observed output, so no demonstration is established. - verify-ssrf (REASONED; rfc, mcp): Trusted HTTPS public-address control must fetch; cleared-state private-address test must resolve and reject before connecting, with positive evidence beyond absent traffic. - verify-secrets (REASONED; oauth): Filename-only token scan distinguishes findings, clean and errors; grant_types alone does not match, empty quoted assignments may, and clean is a bounded heuristic. - verify-dcr (REASONED; mcp, oidc): Captured native DCR body must contain application_type native; omission/mismatch exits nonzero, correct value zero; illustrative JSON is not a live capture. ````json { "schema": 1, "checked": "2026-10-17", "documentation_checked": "2026-09", "body_sha256": "cc910598b718d48835c0e1ae7c32a916940e0f59156a8d4733ae02ad0066c5d1", "components": { "mcp": { "name": "MCP", "basis": "2026-07-28", "sources": { "s34fabf499cb7": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization", "s4b9b953ca683": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations", "s117c3edbfcd7": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery", "s5655f61af2a0": "https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration", "s4903497743e8": "https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices" } }, "mcp-2025-06-18": { "name": "MCP 2025-06-18 authorization", "basis": "86f6e0c675afea9a8f233e09b6faf566b126086c", "sources": { "sf1432398aebf": "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/86f6e0c675afea9a8f233e09b6faf566b126086c/docs/specification/2025-06-18/basic/authorization.mdx#L58-L59" } }, "mcp-2025-11-25": { "name": "MCP 2025-11-25 authorization", "basis": "86f6e0c675afea9a8f233e09b6faf566b126086c", "sources": { "s9d3e60a1183b": "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/86f6e0c675afea9a8f233e09b6faf566b126086c/docs/specification/2025-11-25/basic/authorization.mdx#L198-L209" } }, "rfc": { "name": "OAuth and HTTP RFCs", "basis": "unknown", "sources": { "s93d8089abc7b": "https://www.rfc-editor.org/rfc/rfc9207.html#section-2.4", "s232609abe426": "https://www.rfc-editor.org/rfc/rfc9700.html#section-4.2.4", "sc1f199fd57ff": "https://www.rfc-editor.org/rfc/rfc8414.html#section-3.3", "s612208d893d8": "https://www.rfc-editor.org/rfc/rfc9728.html#section-7.7", "sb90ea9a3bb09": "https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2", "s5cb9d8ac9518": "https://www.rfc-editor.org/rfc/rfc8707.html", "s45a67943085b": "https://www.rfc-editor.org/rfc/rfc7009.html#section-2" } }, "oauth": { "name": "OAuth 2.1 draft", "basis": "draft-ietf-oauth-v2-1-14", "sources": { "sd99e74137366": "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-14#section-4.3" } }, "cimd": { "name": "CIMD draft", "basis": "draft-ietf-oauth-client-id-metadata-document-00", "sources": { "sc6ba78bdbb90": "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00" } }, "oidc": { "name": "OpenID Connect", "basis": "1.0 errata 2", "sources": { "s707464abc09b": "https://openid.net/specs/openid-connect-core-1_0-errata2.html#OfflineAccess", "s06760d0c8dc0": "https://openid.net/specs/openid-connect-registration-1_0-errata2.html#ClientMetadata", "sd8cd68f5f528": "https://openid.net/specs/openid-connect-discovery-1_0-errata2.html#ProviderConfigurationValidation" } }, "owasp": { "name": "OWASP SSRF guidance", "basis": "unknown", "sources": { "s7eb820e1e53b": "https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html" } } }, "claims": { "iss": {"text": "Compare received iss exactly with the transaction issuer before sending a code to any token endpoint; reject mismatches without normalization.", "components": ["mcp", "rfc"], "sources": ["mcp:s34fabf499cb7", "rfc:s93d8089abc7b"], "status": "REASONED"}, "iss-required": {"text": "If issuer-response support is advertised, missing iss is rejected; otherwise present iss is validated and absent iss still needs multi-AS mix-up protection.", "components": ["rfc"], "sources": ["rfc:s93d8089abc7b", "rfc:s232609abe426"], "status": "REASONED"}, "transaction": {"text": "Bind the validated expected issuer to the state/PKCE transaction; multi-AS clients MUST prevent mix-up attacks.", "components": ["rfc", "mcp"], "sources": ["rfc:s232609abe426", "mcp:s34fabf499cb7"], "status": "REASONED"}, "issuer": {"text": "Authorization metadata issuer must exactly equal the issuer used to construct discovery, including its path; reject the document before using endpoints.", "components": ["mcp", "rfc"], "sources": ["mcp:s117c3edbfcd7", "rfc:sc1f199fd57ff"], "status": "REASONED"}, "resource-metadata": {"text": "Validate protected-resource resource separately from authorization-server issuer; issuer equality is not SSRF protection.", "components": ["rfc"], "sources": ["rfc:s612208d893d8", "rfc:sc1f199fd57ff"], "status": "REASONED"}, "oidc-discovery": {"text": "Apply OpenID Connect Discovery section 4.3 issuer validation to OIDC discovery (this guide's instruction): returned issuer MUST be identical to the Issuer URL used as the well-known prefix and to the iss Claim in ID Tokens issued from this Issuer; on failure, operations requiring that information MUST be aborted and the information MUST NOT be used.", "components": ["oidc", "mcp"], "sources": ["oidc:s707464abc09b", "mcp:s117c3edbfcd7", "oidc:sd8cd68f5f528"], "status": "REASONED"}, "cimd": {"text": "CIMD client_id uses HTTPS with a path; document client_id must exactly match its URL.", "components": ["cimd"], "sources": ["cimd:sc6ba78bdbb90"], "status": "REASONED"}, "registration-order": {"text": "Clients SHOULD support CIMD and prefer available preregistration, then CIMD, then optional deprecated DCR.", "components": ["mcp"], "sources": ["mcp:s5655f61af2a0"], "status": "REASONED"}, "application-type": {"text": "DCR MUST specify appropriate application_type: native SHOULD cover desktop/mobile/CLI/localhost apps; web SHOULD cover remote browser apps.", "components": ["mcp"], "sources": ["mcp:s5655f61af2a0"], "status": "REASONED"}, "web-default": {"text": "Omitted application_type defaults to web; incompatible native redirects can be rejected under provider policy, without justification for weakening validation.", "components": ["oidc"], "sources": ["oidc:s06760d0c8dc0"], "status": "REASONED"}, "registration-history": {"text": "MCP 2025-06-18 said authorization servers and MCP clients SHOULD support DCR; MCP 2025-11-25 said clients supporting all options SHOULD use available pre-registration, then advertised CIMD, then supported DCR, then ask the user if no other option is available. Carrying the revision label when citing that MCP registration history is this guide's advice.", "components": ["mcp", "mcp-2025-06-18", "mcp-2025-11-25"], "sources": ["mcp:s5655f61af2a0", "mcp-2025-06-18:sf1432398aebf", "mcp-2025-11-25:s9d3e60a1183b"], "status": "REASONED"}, "refresh-confidential": {"text": "Keep refresh tokens confidential in transit/storage, including public clients; send only to issuing-server token/revocation endpoints over authenticated TLS.", "components": ["oauth", "rfc"], "sources": ["oauth:sd99e74137366", "rfc:s45a67943085b"], "status": "REASONED"}, "refresh-storage": {"text": "Keep refresh tokens out of logs, URLs, repositories and public metadata; use protected storage or native credential storage.", "components": ["oauth"], "sources": ["oauth:sd99e74137366"], "status": "REASONED"}, "refresh-request": {"text": "SHOULD include refresh_token grant type; MAY request advertised offline_access; MUST NOT assume refresh-token issuance.", "components": ["mcp"], "sources": ["mcp:s34fabf499cb7"], "status": "REASONED"}, "offline-access": {"text": "OIDC offline_access requires code-returning response_type and normally prompt=consent; another permitting condition can waive consent prompting.", "components": ["oidc"], "sources": ["oidc:s707464abc09b"], "status": "REASONED"}, "ssrf": {"text": "RFC 9728 recommends SSRF precautions; server-deployed MCP clients MUST mitigate every OAuth-related fetch.", "components": ["rfc", "mcp"], "sources": ["rfc:s612208d893d8", "mcp:s4903497743e8"], "status": "REASONED"}, "ssrf-destinations": {"text": "Use HTTPS destination controls blocking private/reserved, loopback, link-local and metadata addresses; configure necessary private exceptions explicitly.", "components": ["mcp", "rfc", "owasp"], "sources": ["mcp:s4903497743e8", "rfc:s612208d893d8", "owasp:s7eb820e1e53b"], "status": "REASONED"}, "ssrf-redirects": {"text": "Revalidate redirects and prevent DNS check/connect races with pinned resolution or controlled egress.", "components": ["mcp", "rfc", "owasp"], "sources": ["mcp:s4903497743e8", "rfc:s612208d893d8", "owasp:s7eb820e1e53b"], "status": "REASONED"}, "ssrf-allowlist": {"text": "Known-provider issuer/destination allowlists narrow exposure; dynamic clients still need runtime controls, beyond issuer equality.", "components": ["owasp", "rfc"], "sources": ["owasp:s7eb820e1e53b", "rfc:s612208d893d8"], "status": "REASONED"}, "pkce": {"text": "Require PKCE, S256 when capable, and refuse absent code_challenge_methods_supported.", "components": ["mcp"], "sources": ["mcp:s4b9b953ca683"], "status": "REASONED"}, "resource": {"text": "Send canonical resource URI in both authorization/token requests; replay protection also needs audience-bound issuance and server audience validation.", "components": ["mcp", "rfc"], "sources": ["mcp:s34fabf499cb7", "rfc:s5cb9d8ac9518"], "status": "REASONED"}, "https": {"text": "Use HTTPS authorization endpoints and localhost or HTTPS redirect URIs.", "components": ["mcp"], "sources": ["mcp:s4b9b953ca683"], "status": "REASONED"}, "token-url": {"text": "Access tokens go in Authorization, never URL queries; authorization codes use callback queries with PKCE/iss and referrer/log suppression.", "components": ["mcp", "oauth", "rfc"], "sources": ["mcp:s34fabf499cb7", "oauth:sd99e74137366", "rfc:s232609abe426"], "status": "REASONED"}, "verify-iss": {"text": "Restore the same fresh transaction before matched callbacks; control redeems code, mismatched iss must cause no token request, independently of state replay.", "components": ["rfc", "mcp"], "sources": ["rfc:s93d8089abc7b", "mcp:s34fabf499cb7", "rfc:s232609abe426"], "status": "REASONED"}, "verify-metadata": {"text": "Fresh uncached complete metadata pair differs only in issuer; prove test fetch, disable alternative discovery, and reject endpoints from mismatched metadata.", "components": ["rfc", "mcp"], "sources": ["rfc:sc1f199fd57ff", "rfc:sb90ea9a3bb09", "mcp:s4b9b953ca683"], "status": "REASONED"}, "verify-address": {"text": "Local classifier should print BLOCK for all five internal addresses; guide says it ran but records no observed output, so no demonstration is established.", "components": ["rfc", "mcp"], "sources": ["rfc:s612208d893d8", "mcp:s4903497743e8"], "status": "REASONED", "verify": [1]}, "verify-ssrf": {"text": "Trusted HTTPS public-address control must fetch; cleared-state private-address test must resolve and reject before connecting, with positive evidence beyond absent traffic.", "components": ["rfc", "mcp"], "sources": ["rfc:s612208d893d8", "rfc:sb90ea9a3bb09", "mcp:s4903497743e8"], "status": "REASONED"}, "verify-secrets": {"text": "Filename-only token scan distinguishes findings, clean and errors; grant_types alone does not match, empty quoted assignments may, and clean is a bounded heuristic.", "components": ["oauth"], "sources": ["oauth:sd99e74137366"], "status": "REASONED", "verify": [2]}, "verify-dcr": {"text": "Captured native DCR body must contain application_type native; omission/mismatch exits nonzero, correct value zero; illustrative JSON is not a live capture.", "components": ["mcp", "oidc"], "sources": ["mcp:s5655f61af2a0", "oidc:s06760d0c8dc0"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> ray.md ====================================================================== # Ray: dashboard, Jobs, and Client ports execute code Ray is a framework for spreading Python work across machines. This guide helps your AI assistant configure a cluster securely, including dashboard access over SSH, a tailnet or an authenticating TLS proxy, network isolation as the main boundary, and token authentication on Ray 2.52.0 and later as extra protection. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/ray.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Ray pinned source ray-2.58.0, Ray token authentication introduction 2.52.0, KubeRay chart v1.7.0, KubeRay authentication source v1.6.0, KubeRay prior RayCluster types v1.5.0, CPython WSGI server v3.11.0, and curl minimum write-out version 7.75.0; 1 further source is pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 46 claims: 1 demonstrated by a check that ran and 45 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Ray's own security page is blunt: if you expose the Ray Dashboard, Ray Jobs, or Ray Client services, "anybody who can access the associated ports can execute arbitrary code on your Ray Cluster", explicitly by submitting a Job or connecting a Client, indirectly through the Dashboard REST API, and implicitly because Ray deserializes arbitrary Python objects with cloudpickle. Ray "doesn't implement access controls for developers interacting with a given cluster"; security and isolation "must be enforced outside of the Ray Cluster". The head ports in question are the dashboard (and Jobs API) on `8265`, the Ray Client server on `10001`, and the head node port `6379`, all plain HTTP or gRPC with no login of their own unless you enable token authentication (step 3). The per-node agent listeners in step 2 add independent paths to job submission, logs, profiling, runtime environments and metrics. ## 1. Keep the dashboard on loopback `ray start --dashboard-host` defaults to localhost (as of Ray 2.58.0, the first address `localhost` resolves to, IPv4 before IPv6, else `127.0.0.1`), and `--dashboard-port` defaults to `8265`. Leave both alone and say so explicitly, because most tutorials and container entrypoints override the host to make the UI reachable: ```bash ray start --head --dashboard-host 127.0.0.1 --dashboard-port 8265 ``` Never pass `--dashboard-host 0.0.0.0` (or `::`) on a machine with a public interface. In Docker the two binds are different things: the host publishes on loopback only (`-p 127.0.0.1:8265:8265`, which Docker's port-publishing docs say only the Docker host can reach, assuming a normal bridge-network NAT setup and a Docker version at or after 28.0.0). The [Docker 28.0.0 release notes](https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/release-notes/28.md#L768-L768) record a fix for neighbour hosts reaching loopback-mapped ports, so this guide assesses earlier Docker releases as potentially allowing hosts on the same layer-2 network to reach a loopback-published port. The other bind is inside the container, where the dashboard must listen on the container's own interface (`--dashboard-host 0.0.0.0` there, private to the Compose network and the host), because a dashboard bound to the container's `127.0.0.1` is not reachable through the published port at all. See [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md). Reach the dashboard and the Jobs API through a channel that already authenticates you: ```bash ssh -o ExitOnForwardFailure=yes -L 127.0.0.1:8265:127.0.0.1:8265 user@203.0.113.10 # bind the LOCAL end to loopback explicitly (a bare -L 8265: follows your ssh GatewayPorts); then open http://127.0.0.1:8265 ray job submit --address http://127.0.0.1:8265 -- python script.py ray dashboard cluster.yaml # cluster launcher: sets up the same SSH forwarding kubectl port-forward svc/"$HEAD_SERVICE" 8265:8265 # KubeRay: the RayCluster head service ``` A tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)) is the other clean option: the dashboard binds to the tailnet address, and only enrolled devices can route to it. If a browser-facing hostname is unavoidable, put an authenticating TLS proxy in front ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), or [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) with Access) and keep the origin on loopback; Ray's docs themselves list "deploy a TLS proxy in front of your Ray cluster" as the pattern. ## 2. Network isolation is the primary boundary Ray expects "a controlled, isolated network" between all its components. Beyond `8265`, the head node listens on `6379` (the GCS cluster-metadata server), `10001` (Ray Client), and every node opens worker ports `10002` to `19999` by default plus several randomized ports. Do not rely on Ray's bind addresses to keep these private. - In Ray 2.58.0's source, the core gRPC servers (the GCS, the raylet, the object manager and every worker) listen on every interface whenever the node's address is anything other than `127.0.0.1`, `::1` or `localhost` ([`grpc_server.cc`](https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/rpc/grpc_server.cc#L67-L68), [`network_util.h`](https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.h#L111-L113)). On an ordinary cluster with a private node address, `6379` therefore listens on all interfaces. - Ray Serve replicas open an inter-deployment gRPC server on `[::]` with an OS-chosen port ([`replica.py`](https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/serve/_private/replica.py#L1778)). Those two points are read from the source and were not run. **Observed on 2026-09-24 with Ray 2.58.0:** in each of four runs using `ray start --head --node-ip-address=127.0.0.1 --port=6379 --dashboard-host=127.0.0.1`, `ss` still showed the GCS listener as `*:6379`. The cause was not established. Do not rely on `--node-ip-address` alone to keep the GCS off other interfaces; restrict access with a host firewall or network namespace, and verify the listeners with `ss`. Put every node of a cluster in one private network or security group that admits only the cluster's own members ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md), [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md)), and expose nothing from that group to the internet. The Ray Client port in particular is a remote code execution endpoint by design; use Ray Jobs over the forwarded dashboard port instead of publishing `10001`. Every node also runs two agent processes, the dashboard agent and the runtime env agent, and `--include-dashboard=false` stops neither: that flag only decides whether the head's dashboard process serves its API and UI (the process itself still starts, reduced to a usage-stats module), while normal head and worker startup supplies both agent commands to the raylet. Their four listeners, read from the Ray 2.58.0 source and not run, are the agent HTTP server with a default port below and three of the "several randomized ports" above. Minimal mode is selected when the optional dashboard dependencies cannot be imported, not by which install command was used: a base installation with those dependencies already present can run the full agent. In minimal mode the dashboard agent has no HTTP or gRPC server, and its reporter module and metrics exporter are absent; only the runtime env agent HTTP listener remains among these four. With the optional dependencies present, all four normally run, but disabling metrics collection skips the metrics exporter (collection is enabled by default). - The dashboard agent serves HTTP on port `52365` by default on every non-minimal head and worker (`--dashboard-agent-listen-port` changes it); this default also applies to local clusters started by `ray.init()`. It binds the effective node IP and adds a listener on resolved localhost when `is_localhost(node_ip)` is false, meaning the address is not exactly `127.0.0.1`, `::1` or `localhost`. It serves the Jobs agent API, whose `POST /api/job_agent/jobs/` runs an arbitrary entrypoint command on the cluster, with stop, delete, logs and log-tail routes beside it, and it serves the node's whole Ray log directory as static files under `/logs`, directory index included. The head dashboard's Jobs API on `8265` forwards job submission to the head node's agent, so a request straight to the head node's agent reaches the same job manager while skipping anything placed in front of `8265`. Each worker agent has its own job manager and also accepts cluster job submissions; the driver normally runs on the head, but resource requests, a label selector or `RAY_JOB_ALLOW_DRIVER_ON_WORKER_NODES=1` can allow worker placement. A network-reachable agent therefore remains a code-execution path even while step 1 keeps `8265` on loopback; direct access does not bypass the agent's own token check when enabled. Without token authentication its only filter is a heuristic that turns away browser-looking requests (a `Mozilla` User-Agent or browser-typical headers); a plain HTTP client without those browser markers passes it. With `RAY_AUTH_MODE=token` (step 3), every route on it requires the token except `/api/healthz` and `/api/local_raylet_healthz`. - The same agent opens a gRPC listener on an OS-assigned port by default (`--dashboard-agent-grpc-port` fixes it), on resolved localhost when the effective node address is exactly `127.0.0.1`, `::1` or `localhost`, and otherwise on the all-interfaces address. That gRPC listener serves, among its registered services, log listing and streaming plus CPU, GPU and memory profiling services that attach profilers to processes on the node; token authentication, when enabled, adds an authentication interceptor here too. - The runtime env agent serves HTTP on an OS-assigned port by default (`--runtime-env-agent-port` fixes it), bound to the effective node IP, with routes that create and delete runtime environments, which is package installation on the node (step 5); its token middleware exempts no path, and without token authentication it answers any caller. - The non-minimal dashboard agent's reporter module, when metrics collection is enabled, serves Prometheus metrics on an OS-assigned port by default (`--metrics-export-port` fixes it). It passes resolved localhost when the effective node address is exactly `127.0.0.1`, `::1` or `localhost`, and otherwise an empty address, to a plain WSGI server with no authentication under any setting: the step 3 token never covers this endpoint, which is a separate server outside the agent's middleware. What it exposes is read-only cluster and host telemetry. Ray uses `wsgiref.simple_server.make_server` with its default `AF_INET` server: the empty address binds all IPv4 interfaces, and a resolved localhost of `::1` cannot bind that IPv4 socket. This IPv6 limitation is read from the source, not demonstrated with a Ray exporter. There is no independent bind-host setting for these listeners; their bind decisions use Ray's effective node address. `ray start` passes a supplied `--node-ip-address` through `resolve_ip_for_localhost`, which replaces `127.0.0.1`, `::1` and `localhost` with `get_node_ip_address()`; with cluster mode enabled this normally discovers a network address. Passing `--node-ip-address=127.0.0.1` therefore does not, by itself, give loopback agent binds. The `ray.init()` startup path performs the same normalization. `RAY_ENABLE_WINDOWS_OR_OSX_CLUSTER` controls whole-node address selection (disabled cluster mode selects resolved localhost), not an independent agent bind. These source paths do not establish the cause of the earlier GCS observations. Keep the network boundary and the step 3 token even when the dashboard is on loopback. For the agent gRPC listener, `GetAllInterfacesIP` selects `0.0.0.0`, or `::` when resolved localhost is IPv6, independently of the node address's family; the metrics server remains IPv4 as described above. Ray does not isolate jobs from each other. Workloads that must not see each other's data or credentials go on separate clusters. The head's `6379` is the GCS (cluster-metadata) server, not a Redis instance: Ray stopped launching Redis by default in Ray 1.11. What guards it is the network boundary here plus token authentication (step 3) and the gRPC TLS of step 4, not a database password. External Redis is only an opt-in backend for GCS fault tolerance; if you configure it (`ray start --redis-password` / `--redis-username`, or KubeRay's `gcsFaultToleranceOptions.redisPassword` sourced from a Secret), that credential authenticates the separate Redis connection and does not authenticate GCS clients. Do not `export RAY_REDIS_PASSWORD` as a substitute for `--redis-password` on the head; Ray does not read it as one. ## 3. Token authentication (Ray 2.52.0 and later) Starting in Ray 2.52.0 the cluster can require a shared-secret token on its control-plane API and internal connections. Per the Ray docs it is still disabled by default as of Ray 2.58.0 (September 2026; a plan to enable it by default remains for a future release) and is "not an alternative to deploying Ray clusters in a controlled network environment", only defence in depth. It authenticates Ray's own control plane, not requests to a Ray Serve application (step 6). ```bash export RAY_AUTH_MODE=token ray get-auth-token --generate # writes ~/.ray/auth_token and prints it RAY_AUTH_MODE=token ray start --head ``` Every node and every client needs the same token. Ray reads it from `RAY_AUTH_TOKEN`, then from the file named by `RAY_AUTH_TOKEN_PATH`, then from `~/.ray/auth_token`; the docs recommend the file paths over the environment variable so other code that reads the environment cannot see it. Copy the file to each node before `ray start`, keep its permissions tight, and never commit it: tokens do not expire and are stored in plaintext ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). The token travels as an HTTP header, so over plain HTTP it is visible to the network; only send it inside the SSH tunnel, tailnet, or TLS proxy from step 1. On Kubernetes, [KubeRay v1.6.0](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/apis/ray/v1/raycluster_types.go#L17-L19) defines the `authOptions` field of a `RayCluster`; the [v1.5.0 types file](https://github.com/kuberay-project/kuberay/blob/v1.5.0/ray-operator/apis/ray/v1/raycluster_types.go#L12-L47) has no `AuthOptions` in `RayClusterSpec`. This guide infers support in v1.6.0 and later from those tags; later releases have not been checked. With token mode enabled, the [v1.6.0 controller](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/raycluster_controller.go#L369-L433) creates a Secret with a random token only when Kubernetes ServiceAccount token authentication is off, `SecretName` is null or empty, and the generated-name Secret does not already exist; if that Secret exists, it creates none. A nonempty `SecretName` selects the named Secret without creating one, and ServiceAccount token authentication creates no Secret. The [head pod call](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L234-L236) and [worker pod call](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L460-L462) both use the [pod builder](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L253-L271), which sets `RAY_AUTH_MODE` if unset on the Ray containers of head and worker pods and on the `wait-gcs-ready` init container when present. The [head autoscaler call](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L194-L220) also sets it on the autoscaler container when autoscaling is enabled. Outside ServiceAccount token authentication, the builder adds `RAY_AUTH_TOKEN` from the selected Secret if unset on those containers; in ServiceAccount mode it does not add `RAY_AUTH_TOKEN` and instead adds `RAY_ENABLE_K8S_TOKEN_AUTH` and mounts a token ([environment-variable code](https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L296-L336)). For Secret-based token authentication, clients read the token with `kubectl get secrets 'REPLACE_WITH_SECRET_NAME' --template='{{.data.auth_token}}' | base64 -d` (quote the name, or the shell reads the unquoted `` as input redirection, and quote the template so its braces are not globbed). Without the token, `ray job submit` fails with `401 Unauthorized`. MFA: Ray has no user accounts, so a second factor can only come from the path to the cluster: the SSH login, the tailnet, or an identity-aware proxy in front of the dashboard ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). ## 4. TLS for the gRPC traffic Ray can encrypt and mutually authenticate its internal gRPC connections. Export these in the environment of every node, head and workers alike, before Ray starts there; Ray reads them at startup (`RAY_USE_TLS` defaults to `0`), so a plain assignment without `export`, or a variable set after the node started, never reaches the Ray processes: ```bash export RAY_USE_TLS=1 # default 0 export RAY_TLS_SERVER_CERT=/etc/ray/tls/tls.crt # presented to other endpoints export RAY_TLS_SERVER_KEY=/etc/ray/tls/tls.key export RAY_TLS_CA_CERT=/etc/ray/tls/ca.crt # CA that signs every node's certificate ``` Ray warns that this costs performance (large for small workloads, smaller for large ones) and that it "is not a replacement for network isolation". The docs describe it for the gRPC traffic; for the dashboard and Jobs HTTP API they point to a TLS proxy in front, which is the fronting proxy in step 1. ## 5. Job dependencies and setup hooks run as code For a job submitted through the Jobs API, Ray installs its `runtime_env` before the entrypoint runs (a `runtime_env` passed to `ray.init` instead applies to the child tasks and actors it spawns), so submitting a job, or connecting a Client, is permission to execute code on the cluster, not just to run a fixed program. `ray job submit --runtime-env env.yaml` (or `--runtime-env-json`) and the `runtime_env=` argument to `ray.init` accept `pip`/`conda` (which download and install packages, and package installation can run distribution-provided code) and `working_dir`/`py_modules` (pulled from a local path or a remote archive URI). The same `ray job submit` options and `ray.init` `runtime_env=` argument also accept a `py_executable` command and an experimental `worker_process_setup_hook` that runs on every worker before your tasks. Ray ships no built-in policy that disables runtime environments or allowlists their packages, URIs, or hooks: `RuntimeEnvConfig`'s `eager_install` and `setup_timeout_seconds` only change when and how long installation runs, never whether a dependency is trusted. So the boundary is the same token and network boundary as everything else here, plus the operational practice of prebuilding reviewed dependencies into the image ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)) rather than resolving them from an untrusted submission at run time. Treat anyone who can submit a job or reach the Client port as able to run arbitrary code. ## 6. Keep Ray Serve application endpoints private Ray Serve runs your Python deployment code behind an HTTP proxy on port `8000`, and its bind default depends on how you start it: `serve.start(http_options=...)` and `HTTPOptions` in Python default to `127.0.0.1`, but a Serve config file's `http_options` defaults to `0.0.0.0`, every interface. `proxy_location` defaults to `EveryNode`, a proxy on each node that holds a replica. Serve has no built-in application authentication: the cluster's `RAY_AUTH_MODE=token` protects Ray's own control plane, not requests to your Serve endpoints, so a reachable Serve proxy answers anyone. Keep the proxy origin on loopback or a private interface and put an authenticating TLS proxy in front (step 1), or add authentication inside the app through Serve's FastAPI integration. `HTTPOptions` does expose `ssl_keyfile`, `ssl_certfile`, and `ssl_ca_certs` (all `None` by default) if you terminate TLS at Serve itself, but presenting a CA there does not by itself require a client certificate. The optional gRPC proxy listens on `9000` only when you configure `grpc_options` with servicer functions; treat `9000` like the other ports only when you have configured it. ## 7. Run Ray processes with restricted privileges The official `rayproject/ray` image already runs as the non-root user `ray` (UID 1000, GID 100), but its base image grants that user passwordless `sudo`, so a compromised worker can regain root inside the container: "starts non-root" is not "stays unprivileged". On Kubernetes, KubeRay's ray-cluster Helm chart (v1.7.0) exposes `podSecurityContext` and `securityContext` on both the head and every worker group (each defaults to `{}`); on a plain `RayCluster` manifest the same settings live on the pod template and container. Set an explicit container security context on the Ray containers: ```yaml securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 100 allowPrivilegeEscalation: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault ``` For a plain `RayCluster`, the containers are at `spec.headGroupSpec.template.spec.containers[*]` and `spec.workerGroupSpecs[*].template.spec.containers[*]`; for a bare Docker run, apply the same non-root, dropped-capabilities, read-only-where-possible posture from [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md) and [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md). This only limits what already-executing code can reach; it does not stop the cloudpickle deserialization or job-submission paths above, so it is a layer on top of the network and token boundaries, never a replacement. ## Verify REASONED: mixed historical listener observations and unrun service comparisons from the cited Ray 2.58.0 sources; the four watcher-stopped attempts and local ss observations below are historical, were not repeated here and did not establish exposed/fixed behavior; no isolated cluster is available for the service comparisons. Service behaviour is not demonstrated here. A watcher stopped each of four loopback runs of Ray 2.58.0 on recording the GCS listening on every interface (`*:6379`), which the authoring host forbids. No isolated network namespace was available to contain the runs, and no isolated multi-node Ray cluster is available in the authoring environment. The `ss` command was run locally in the earlier attempts; the per-node agent inventory, external-reachability probes (including all four agent listeners), direct `/logs/` token controls, dashboard job-submission controls and gRPC-TLS health-check are **REASONED**, with expected exposed and fixed outcomes derived from the cited Ray 2.58.0 sources and vendor documentation. REASONED: following block; the per-node listener inventory, and in the four blocks after it the external and agent-port probes, the successful token-authenticated job, the rejected token-free job and the correct/mismatched-CA gRPC health checks, follow the cited Ray sources; no isolated cluster, second host or deployed TLS fixture. In an earlier form those five blocks were one fence, and Bash 5.3.9 bash -n accepted both Verify fences of that form. Extracted address subshells with curl replaced by printf LOCAL_CURL accepted 192.168.50.7 and bad value; empty input, original placeholders and set -- without arguments skipped. All exited 0; skips are not reachability results. The bash -n run and the address-subshell runs recorded here covered that earlier form of both fences, whose guard refusals in the address, agent-port and direct-agent subshells ended with status 0. In the token-file subshell, replacing only the home token path with a disposable path and ray with printf LOCAL_RAY rejected absent, empty, directory and symlink fixtures with exit 2; a regular file containing DUMMY_NOT_A_SECRET plus LF reached the marker, exited 0 and changed mode 0644 to 0600. That run covered an earlier form of the token-file subshell, which also exited 2 when chmod failed. This demonstrates local preparation only, not either job outcome or TLS. The current forms of the five blocks exit 2 on a guard refusal and otherwise with one status for each block, 1 for a finding, 3 for a failed or inconclusive check and 0 for a pass or an unclassified observation, and are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes and by the stub runs recorded next. The listener inventory refuses with status 2, printing `ss is not installed here; not listing sockets`, when `ss` is missing, and exits 3 when `ss` fails. The port probes read curl's `time_connect` first and record 3 when it is missing or malformed. With no TCP connection, a curl exit 7 or connect timeout records 0 whatever its `err` text, with a message that from another host this is consistent with isolation but not proof and should be corroborated, and any other curl error records 3. In stub runs of the five current blocks, with ss, curl and ray replaced by stubs, the inventory exited 2 without `ss`, 3 when `ss` failed and 0 otherwise. Each port probe exited 3 for a failure with no write-out line and for a name-resolution failure, 1 for an HTTP response with a non-zero `time_connect`, and 0 for a refusal, a connect timeout and an exit 7 with the generic `Could not connect to server` text, each with zero timing, and the port loop kept 1 when a later port was inconclusive or formed no connection. The token-authenticated submit exited 0 when the stub submit succeeded, 3 when it failed and 2 without a token file, and the token-free submit exited 0 on an Unauthorized rejection, 1 when accepted, 3 on a connection error and 2 on a client holding a token. **Per-node listener inventory, REASONED:** no isolated multi-node Ray cluster in the authoring environment; expected outcomes follow the cited Ray 2.58.0 sources. Inventory listeners on **every head and worker**, in the network namespace where Ray runs. Use `sudo ss -tlnp` if needed to see their owning processes. Record the dashboard agent HTTP port (default 52365, or the configured `--dashboard-agent-listen-port`) and the actual dashboard agent gRPC, runtime env agent HTTP and metrics export ports from that inventory. The last three are OS-assigned by default, so repeat the inventory after a restart. Attribute each port to its agent process; do not guess from the worker-port range. In minimal mode only runtime env HTTP remains among these four; with metrics collection disabled the metrics exporter is absent. An expected listener missing without an established reason is inconclusive. ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null 2>&1 || { echo 'ss is not installed here; not listing sockets'; exit 2; } if ss -tlnp; then # 8265: 127.0.0.1 (or the tailnet IP), never wildcard; agent gRPC and metrics can show wildcard binds (step 2) # read every listener; per the source, 10001 shows the node IP address (private in step 2's layout) plus a loopback listener, but 6379 can show every # interface (see step 2), so only the network boundary keeps it private # if you run Ray Serve, keep 8000 (HTTP proxy) private too, and 9000 (gRPC) when configured echo "unclassified observation: read every listener above against step 2, and record the agent ports for the agent-port block below" else echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; exit 3 fi ) ``` ```bash # REASONED: no isolated multi-node Ray cluster in the authoring environment; expected outcomes follow the cited Ray 2.58.0 sources. # From another network, repeat against every public IPv4 and IPv6 address of every head and worker # (enclose an IPv6 literal in square brackets). Check 8265 dashboard, 6379 head, 10001 Client and # 8000 if Ray Serve is deployed. The separate block below adds all four agent listeners. # Keep all these origins off untrusted networks, including when token authentication is enabled; # it does not cover Serve or metrics. A port records 0 when # no TCP connection formed: curl exits 7 or hits the connect timeout (28) and time_connect stays 0.000000. # From another host that is consistent with isolation but not proof, because curl also exits 7 for a local # socket error, so corroborate each such port, for example with a deny record at the enforcement point # or the err text. A non-zero time_connect (even if the port then speaks gRPC # not HTTP, so http is 000, or HTTP returns 401 or 403) means exposure, regardless of authentication. A # name-resolution or other curl error with no TCP connection, or a missing time_connect, is inconclusive. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_SERVER_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the server's public address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 8265 6379 10001 8000 # the address, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports; 8000 is Ray Serve, if deployed; 9000 too when its gRPC proxy is configured while [ "$#" -gt 2 ]; do set -- "$(if curl -q -g -sI -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w "port=$3 http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n" "http://$1:$3/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1%%$'\n'*}" "${1##*$'\n'}" "${@:2}" # curl's line, its exit status, the address, the status so far, then the ports left printf '%s\n' "$1" case "$1" in *' time_connect='*) set -- "$1" "$2" "${1#* time_connect=}" "${@:3}" ;; *) set -- "$1" "$2" '' "${@:3}" ;; esac set -- "$1" "$2" "${3%% *}" "${@:4}" # curl's line, its exit status, its time_connect value (empty if curl wrote none), the address, the status so far, then the ports left case "$3" in *[!0123456789.]*|*.*.*) set -- "$1" "$2" '' "${@:4}" ;; # anything but digits around one dot is not a timing value esac case "$3" in 0.000000) case "$2" in 7|28) echo "curl exit $2 on port $6: no TCP connection from this vantage, consistent with isolation but not proof; corroborate it, for example with a deny record at the enforcement point or the err text above" ;; *) echo "curl exit $2 on port $6 with no TCP connection: a name-resolution or local socket error is inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" ;; esac ;; [0123456789]*.*[0123456789]) echo "FINDING: port $6 completed a TCP connection, which means exposure regardless of authentication"; set -- "$1" "$2" "$3" "$4" 1 "${@:6}" ;; *) echo "curl wrote no valid time_connect for port $6, so whether a TCP connection formed is unknown; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" ;; esac set -- "$4" "$5" "${@:7}" # the address, the status so far, then the ports not yet probed done echo "block exit $2; corroborate each port that formed no TCP connection, as its line above says; this covers 8265, 6379, 10001 and 8000 at this address only, so repeat it for every other public address" exit "$2" ) ``` ```bash # REASONED: no isolated multi-node Ray cluster in the authoring environment; expected outcomes follow the cited Ray 2.58.0 sources. # On EVERY head and worker, probe the default 52365 or its configured replacement first. Then run # this whole block again for EACH of the three actual agent ports recorded from ss above, where # present. Also repeat it for other inventoried listeners, including worker and Serve replica ports. # Use every externally routed node/container address, IPv4 and IPv6. Apply the reading rules above: # exposed means a completed TCP connection (even 401/403); fixed means no TCP connection formed, which # from another host is consistent with isolation but not proof, so corroborate it as the block's message says. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_NODE_PUBLIC_IP' '52365' # replace inside the quotes [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the node's public address; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|""|*[!0123456789]*) echo "supply the actual numeric listener port; not probing"; exit 2 ;; esac if [ "${#2}" -gt 5 ] || [ "$2" -lt 1 ] || [ "$2" -gt 65535 ]; then echo "port must be 1 to 65535; not probing"; exit 2 fi set -- "$1" "$2" "$(if curl -q -g -sI -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w "port=$2 http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n" "http://$1:$2/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%%$'\n'*}" "${3##*$'\n'}" # the address, the port, curl's line, then its exit status printf '%s\n' "$3" case "$3" in *' time_connect='*) set -- "$1" "$2" "$3" "$4" "${3#* time_connect=}" ;; *) set -- "$1" "$2" "$3" "$4" '' ;; esac set -- "$1" "$2" "$3" "$4" "${5%% *}" # the address, the port, curl's line, its exit status, then its time_connect value (empty if curl wrote none) case "$5" in *[!0123456789.]*|*.*.*) set -- "$1" "$2" "$3" "$4" '' ;; # anything but digits around one dot is not a timing value esac case "$5" in 0.000000) ;; [0123456789]*.*[0123456789]) echo "FINDING: port $2 completed a TCP connection, which means exposure regardless of authentication"; exit 1 ;; *) echo "curl wrote no valid time_connect for port $2, so whether a TCP connection formed is unknown; inconclusive"; exit 3 ;; esac case "$4" in 7|28) echo "curl exit $4 on port $2: no TCP connection from this vantage, consistent with isolation but not proof; corroborate it, for example with a deny record at the enforcement point or the err text above, then repeat for every other agent port and address"; exit 0 ;; *) echo "curl exit $4 on port $2 with no TCP connection: a name-resolution or local socket error is inconclusive"; exit 3 ;; esac ) ``` ```bash # REASONED: the token-authenticated job submission follows the cited Ray 2.58.0 sources; no isolated cluster or cluster token is available. # Through the SSH tunnel of step 1, with RAY_AUTH_MODE=token on the cluster. # POSITIVE control first: WITH the token the submit succeeds, proving the endpoint is live and reachable: # Assumes a clean Bash shell with trusted startup files, and a token file owned by this account # under ~/.ray in directories no other account can write to or replace. Ray reads the file itself. ( trap - DEBUG RETURN ERR set +x +a +e { unset -n RAY_AUTH_TOKEN RAY_AUTH_TOKEN_PATH RAY_AUTH_MODE && unset -v RAY_AUTH_TOKEN RAY_AUTH_TOKEN_PATH RAY_AUTH_MODE; } 2>/dev/null || { echo 'cannot clear Ray authentication variables; not submitting'; exit 2; } if [ ! -f "$HOME/.ray/auth_token" ] || [ -L "$HOME/.ray/auth_token" ] || [ ! -r "$HOME/.ray/auth_token" ] || [ ! -s "$HOME/.ray/auth_token" ]; then echo 'need a readable, non-empty regular token file, not a symlink; not submitting'; exit 2 fi chmod 600 -- "$HOME/.ray/auth_token" || { echo 'cannot protect the token file; not submitting'; exit 3; } if RAY_AUTH_MODE=token RAY_AUTH_TOKEN_PATH="$HOME/.ray/auth_token" \ ray job submit --address http://127.0.0.1:8265 -- python -c "print(1)"; then echo "the token-authenticated submit succeeded, so the endpoint is live and nothing here is left to judge; run the token-free block below from a client with no token" else echo "ray job submit exit $?: the token-authenticated positive control failed, so a token-free refusal would prove nothing; inconclusive"; exit 3 fi ) # Only the path is in the environment; the token stays in the file and Ray's memory, readable by # this account and root. Clearing variables does not erase a token previously exported or logged. ``` ```bash # REASONED: the token-free job submission and the gRPC TLS checks follow the cited Ray 2.58.0 sources; no isolated cluster is available. # NEGATIVE: from a client with NO token available (no RAY_AUTH_TOKEN or RAY_AUTH_TOKEN_PATH set and no # ~/.ray/auth_token file), the same submit must be refused for AUTHENTICATION (HTTP 401); a connection # error (tunnel down, wrong address) is INCONCLUSIVE, not a pass: ( # a subshell, so the exits below end only the subshell if [ -n "${RAY_AUTH_TOKEN+set}" ] || [ -n "${RAY_AUTH_TOKEN_PATH+set}" ] || [ -e "${HOME-}/.ray/auth_token" ] || [ -L "${HOME-}/.ray/auth_token" ]; then echo 'this client has a token (RAY_AUTH_TOKEN, RAY_AUTH_TOKEN_PATH or ~/.ray/auth_token); run this from a client with none; not submitting'; exit 2 fi set -- "$(if ray job submit --address http://127.0.0.1:8265 -- python -c "print(1)" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # must fail with an auth rejection, e.g. "Unauthorized: Missing authentication token" (the block matches the word unauthorized in any letter case) set -- "${1%$'\n'*}" "${1##*$'\n'}" # ray's output and errors, then its exit status printf '%s' "$1" case "$2" in 0) echo "FINDING: the token-free submit was accepted"; exit 1 ;; esac case "$(printf '%s' "$1" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')" in # letter case ignored *unauthorized*) echo "ray job submit exit $2 with an Unauthorized rejection: refused for authentication; nothing in this submit is left to judge, but if you set RAY_USE_TLS=1, run the gRPC TLS health-check pair below by hand"; exit 0 ;; *) echo "ray job submit exit $2 without an Unauthorized rejection: a connection or other error is inconclusive; read it above"; exit 3 ;; esac ) # gRPC TLS (only if you set RAY_USE_TLS=1 in step 4): the socket checks above do not prove the gRPC layer is # encrypted. Confirm it the way Ray's docs show, running `ray health-check` once with the correct # RAY_TLS_CA_CERT (succeeds) and once with a MISMATCHED CA or server name (Ray demonstrates a # certificate-name mismatch), which must fail verification; a local configuration error is inconclusive. ``` ### Direct agent token control REASONED: direct-agent target selection from the cited static-route and token middleware sources; no live head/worker agents or trusted forwarding path. **Direct agent token control, REASONED:** no isolated multi-node Ray cluster in the authoring environment; expected outcomes follow the cited Ray 2.58.0 sources. On every non-minimal head and worker, use a trusted path directly to that node's agent HTTP port, not the head dashboard or its proxy. Run on the node against its resolved localhost, or use a dedicated SSH forward to that agent; the step 1 forward for 8265 does not forward 52365. Substitute an origin such as `http://127.0.0.1:52365` (or its actual address and configured port), without a trailing slash. Send a token only over the trusted path. REASONED: paired exposed anonymous, fixed authenticated and fixed anonymous expectations from the cited static-route and token middleware sources; no live agent or cluster token. In an isolated exposed state with token mode off, select `anonymous`: a non-browser GET to `/logs/` should return 200 and the log directory index. With token mode on, first select `token` and supply the valid cluster token: expect the same access. Then select `anonymous` against that same live agent: expect 401. A connection error, 404, redirect, unexpected body or failed positive control is inconclusive; 403 is not the expected missing-token result. `/api/healthz` and `/api/local_raylet_healthz` are exempt and cannot discriminate token enforcement. These expectations follow the pinned static route and app-wide token middleware cited in Sources. REASONED: following block; direct-agent anonymous/token request preparation and the paired /logs/ controls follow the cited Ray sources; no live agent or cluster token. Bash 5.3.9 ran the complete subshell with curl replaced by printf LOCAL_CURL. With origin http://127.0.0.1:52365 and token mode, stdin containing an empty line, REPLACE_WITH_TOKEN or bad plus byte 0x01 plus value printed the token diagnostic without a marker; DUMMY_NOT_A_SECRET plus LF reached one marker. All exited 0. The anonymous branch accepted both that substituted origin and bad value; empty/original-placeholder origins and set -- without arguments skipped. These are local guard observations, not URL validation, HTTP status or authorization evidence. The Bash runs recorded here covered the direct-agent block in an earlier form, in which every guard refusal apart from the three token-preparation refusals ended with status 0. The current form exits 2 on a guard refusal and 3 when the token read or the request fails; with `anonymous` it exits 0 on 401, in either mode it exits 0 on 200 with an unclassified-observation line, and any other response exits 3. The block does not match a 200's body, because the form of the log directory index that the pinned static route returns was not read from that source for this check, so read the body yourself: for `anonymous` the index is the exposure, for `token` it is the expected access, and any other body makes the result inconclusive. The current form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs with curl replaced by a stub, in which an anonymous 401 and a 200 in either mode exited 0, an anonymous 403, a 404 and a failed request exited 3, and an empty token exited 2. This block assumes a clean Bash shell with trusted startup files. The hidden prompt and stdin header keep the token out of curl's argv and the pasted command history, and the block's `set +x` keeps it out of `set -x` tracing; none of these hides it from the account owner or root. Do not put the token in the pasted text. Paste the token alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( trap - DEBUG RETURN ERR set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_TRUSTED_AGENT_ORIGIN' 'anonymous' # replace inside the quotes; mode: anonymous or token [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the trusted agent origin; not probing"; exit 2 ;; esac case "$2" in anonymous) set -- "$1" "$2" "$(if curl -q -g -s --noproxy '*' --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/logs/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; token) { unset -n RAY_AGENT_PROBE_TOKEN && unset -v RAY_AGENT_PROBE_TOKEN; } 2>/dev/null || { echo 'cannot clear token variable; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'cannot clear IFS attributes; not probing'; exit 2; } IFS= read -r -s -p 'Cluster token: ' RAY_AGENT_PROBE_TOKEN || { echo 'token read failed; not probing'; exit 3; } printf '\n' case "$RAY_AGENT_PROBE_TOKEN" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo 'empty, placeholder or control character in token; not probing'; exit 2 ;; esac set -- "$1" "$2" "$(if printf 'Authorization: Bearer %s\n' "$RAY_AGENT_PROBE_TOKEN" | curl -q -g -s --noproxy '*' \ --connect-timeout 5 --max-time 10 --header @- \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/logs/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; *) echo 'mode must be anonymous or token; not probing'; exit 2 ;; esac set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the origin, the mode, the response and curl's line, then curl's exit status printf '%s' "$3" set -- "$1" "$2" "$3" "$4" "${3%$'\n'}" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" # curl's write-out line alone [ "$4" = 0 ] || { echo "curl exit $4: the request failed; inconclusive"; exit 3; } case "$2:$5" in 'anonymous:http=401 '*) echo "the anonymous request was refused with 401; this is the pass only after the token run against this same agent answered 200"; exit 0 ;; 'anonymous:http=200 '*) echo "unclassified observation: the anonymous request answered 200, and this block does not match the body, because the form of the log directory index was not read from the pinned Ray source; read the body above: a log directory index is the exposure, and any other body makes this inconclusive"; exit 0 ;; 'token:http=200 '*) echo "unclassified observation: the token request answered 200, and this block does not match the body, because the form of the log directory index was not read from the pinned Ray source; read the body above, which must be the log directory index, then run anonymous against this same agent"; exit 0 ;; 'anonymous:http=403 '*) echo "HTTP 403 is not the expected missing-token result; inconclusive"; exit 3 ;; *) echo "this response is not a result this check classifies for $2: a 404, redirect, other status or failed positive control is inconclusive"; exit 3 ;; esac ) ``` ## Common mistakes - `--dashboard-host 0.0.0.0` copied from a quickstart so the UI "works" from a laptop; forward the port instead. - Treating token authentication as permission to expose `8265` to the internet. Ray says the opposite. - Publishing `10001` for Ray Client convenience. It is unauthenticated code execution unless the token and isolation above are both in place. - Putting the head node in a security group that also hosts unrelated services; any compromise there reaches every Ray worker. - Securing the head port `6379` with a "Redis password". It is the GCS metadata server, not Redis; isolate the network and enable token authentication (step 3) instead. - Assuming `RAY_AUTH_MODE=token` also protects Ray Serve endpoints. It protects Ray's control plane, not your Serve app; put authentication in front of the Serve proxy (step 6). - Trusting the official image because it "runs as non-root", while its `ray` user holds passwordless sudo (step 7). ## Sources (checked September 2026) - Ray security guidelines (arbitrary code execution, network isolation, TLS is not a replacement, token auth from 2.52.0): https://docs.ray.io/en/latest/ray-security/index.html - Ray token authentication (`RAY_AUTH_MODE`, `RAY_AUTH_TOKEN`, `RAY_AUTH_TOKEN_PATH`, `ray get-auth-token`, plaintext-header caveat): https://docs.ray.io/en/latest/ray-security/token-auth.html - Ray 2.58.0 token input precedence and file-based job submission: https://github.com/ray-project/ray/blob/ray-2.58.0/doc/source/ray-security/token-auth.md - `ray start` CLI reference (`--dashboard-host` default, `--dashboard-port` 8265, `--port` 6379, `--ray-client-server-port` 10001): https://docs.ray.io/en/latest/cluster/cli.html - Configuring Ray (TLS environment variables, ports opened by nodes): https://docs.ray.io/en/latest/ray-core/configure.html - Configure Ray clusters to use token authentication (KubeRay `authOptions`, 401 without token): https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/kuberay-auth.html - KubeRay v1.6.0 `RayClusterSpec.authOptions` and token options (checked October 2026): https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/apis/ray/v1/raycluster_types.go#L17-L19, https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/apis/ray/v1/raycluster_types.go#L81-L107 - KubeRay v1.5.0 `RayClusterSpec` comparison (checked October 2026): https://github.com/kuberay-project/kuberay/blob/v1.5.0/ray-operator/apis/ray/v1/raycluster_types.go#L12-L47 - KubeRay v1.6.0 token Secret reconciliation and auth-mode predicate (checked October 2026): https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/raycluster_controller.go#L369-L433, https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/utils/util.go#L760-L766 - KubeRay v1.6.0 head and worker pod token calls, head autoscaler token call, and Ray-container and init-container token setup (checked October 2026). KubeRay v1.6.0 token call and setup sources (checked October 2026): https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L194-L220, https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L234-L236 and https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L460-L462. KubeRay v1.6.0 token call and setup sources (checked October 2026), continued: https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L253-L271 and https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L296-L336 - Docker, port publishing (loopback publishing): https://docs.docker.com/engine/network/port-publishing/ - Docker Engine v28.0.0 release notes, neighbour-host loopback-publishing fix (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/release-notes/28.md#L768-L768 - curl manual (`--connect-timeout` bounds the connection phase only; the `time_connect`, `exitcode`, and `errormsg` write-out variables, the last two added in curl 7.75.0): https://curl.se/docs/manpage.html - Ray runtime environments (`runtime_env`, pip/conda/working_dir/py_modules/setup hook, no admission-control policy): https://docs.ray.io/en/latest/ray-core/handling-dependencies.html - Ray RuntimeEnv and RuntimeEnvConfig API (`eager_install`, `setup_timeout_seconds`): https://docs.ray.io/en/latest/ray-core/api/doc/ray.runtime_env.RuntimeEnv.html - Ray Serve HTTPOptions (Python host default 127.0.0.1, port 8000, ssl_keyfile/ssl_certfile/ssl_ca_certs): https://docs.ray.io/en/latest/serve/api/doc/ray.serve.config.HTTPOptions.html - Ray Serve config schema HTTPOptionsSchema (config-file http_options 0.0.0.0 host default): https://docs.ray.io/en/latest/serve/api/doc/ray.serve.schema.HTTPOptionsSchema.html - Ray Serve deploy schema (proxy_location EveryNode default): https://docs.ray.io/en/latest/serve/api/doc/ray.serve.schema.ServeDeploySchema.html - Ray Serve HTTP guide (FastAPI integration for app-level auth): https://docs.ray.io/en/latest/serve/http-guide.html - GCS fault tolerance with external Redis (KubeRay `gcsFaultToleranceOptions.redisPassword`): https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/kuberay-gcs-ft.html - Redis in Ray, past and future (Redis dropped as the default in Ray 1.11): https://www.anyscale.com/blog/redis-in-ray-past-and-future - Ray base image user and passwordless sudo (pinned tag): https://raw.githubusercontent.com/ray-project/ray/ray-2.58.0/docker/base-deps/Dockerfile - Ray core gRPC server bind address (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/rpc/grpc_server.cc#L67-L68 - Ray `IsLocalhost` (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.h#L111-L113 - Ray `ray start` `--dashboard-host` default `get_localhost_ip()` and `--dashboard-port` default `DEFAULT_DASHBOARD_PORT` (8265), with `GetLocalhostIP()` resolving `localhost` as IPv4 first, then IPv6, else `127.0.0.1` (pinned tag). Ray `ray start` dashboard host and port default sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L601-L617, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/ray_constants.py#L185 and https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.cc#L257-L278 - Ray Serve replica inter-deployment gRPC server (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/serve/_private/replica.py#L1778 - Ray Client server listeners, `--host` address plus loopback (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/util/client/server/server.py#L799-L801 - Ray Client proxier listeners, `--host` address plus loopback (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/util/client/server/proxier.py#L929-L931 - Ray Client server started with the node IP address (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L1572-L1575 - Ray Client server `--host` set from that address (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L2464-L2471 - Ray dashboard agent HTTP listener (pinned tag): default `52365`, the `ray start` flags for the four agent ports, the node-IP-plus-loopback bind, the token middleware with its two exempt health paths and its no-op without token auth, and the browser-request heuristic. Ray dashboard agent HTTP listener sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/ray_constants.py#L189, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L618-L635 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L687-L692. Ray dashboard agent HTTP listener sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L20-L23, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L54-L66 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L107-L113. Ray dashboard agent HTTP listener sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/authentication/http_token_authentication.py#L28-L80 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/optional_utils.py#L132-L209 - Ray Jobs agent routes and the head's forwarding to them (pinned tag): submit, stop, delete, logs and log tail on the agent; the head's submission client, its head-node agent resolution and its token header. Ray Jobs agent route and head forwarding sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_agent.py#L32-L196, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_head.py#L123-L144 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_head.py#L267-L280 - Ray log directory served by the agent, and its log and profiling gRPC services (pinned tag). Ray agent-served log directory and gRPC service sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L245-L249 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L264-L307. Ray agent-served log directory and gRPC service sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L345-L414 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/reporter/reporter_agent.py#L570-L638 - Ray dashboard agent gRPC bind and token interceptor, and the minimal-install case (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L144-L169, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L182-L187 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L100-L117 - Ray agent ports defaulting to OS-assigned, and both agents launched by the raylet regardless of `--include-dashboard` (pinned tag). Ray agent port and launch sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L372-L377, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L1660-L1669 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L1768. Ray agent port and launch sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1381-L1387 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1862-L1883. Ray agent port and launch sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1918-L1929 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L2022-L2031 - Ray Prometheus metrics endpoint bind and its authentication-free WSGI server (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/reporter/reporter_agent.py#L504-L516 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/prometheus_exporter.py#L326-L334 - Ray runtime env agent bind, routes and token middleware (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/runtime_env/agent/main.py#L218-L224 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/runtime_env/agent/main.py#L243-L248 - Ray all-interfaces address (pinned tag): `get_all_interfaces_ip()` calls `GetAllInterfacesIP`, which returns `0.0.0.0`, or `::` for an IPv6 localhost: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/includes/network_util.pxi#L103-L110 and https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.cc#L280-L289 - Ray effective node address, CLI and Python localhost normalization, and whole-node cluster-mode setting (pinned tag). Ray node address and cluster-mode sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L850-L854 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L786-L821. Ray node address and cluster-mode sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/worker.py#L1835-L1836 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/ray_constants.py#L507-L514 - Ray local `ray.init()` cluster agent HTTP default, inherited from `RayParams` (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/parameter.py#L165-L167 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/worker.py#L1884-L1912 - Ray minimal mode dependency test, module filtering, absent reporter/exporter and runtime env agent's vendored aiohttp (pinned tag). Ray minimal mode sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/utils.py#L1128-L1142, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1909-L1913 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/utils.py#L322-L351. Ray minimal mode sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L100-L117, https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/reporter/reporter_agent.py#L2099-L2101 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/runtime_env/agent/main.py#L23-L33 - Ray metrics collection enabled by default and raylet propagation of its disable option (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/common/ray_config_def.h#L618-L619 and https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/raylet/node_manager.cc#L3527-L3530 - Ray agent-local job managers and default head placement, with resource, label-selector and worker-placement overrides (pinned tag). Ray job manager and placement sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_agent.py#L198-L203 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_manager.py#L421-L463. Ray job manager and placement sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_manager.py#L565-L609 - Ray metrics imports the standard WSGI server (pinned tag), whose default server inherits `AF_INET` (CPython v3.11.0). That WSGI import and `AF_INET` default are the source basis for the IPv4-only bind and `::1` limitation. Ray metrics WSGI server sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/prometheus_exporter.py#L10 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/prometheus_exporter.py#L326-L334. CPython `AF_INET` default sources (CPython v3.11.0): https://github.com/python/cpython/blob/v3.11.0/Lib/wsgiref/simple_server.py#L1-L160, https://github.com/python/cpython/blob/v3.11.0/Lib/http/server.py#L120-L145 and https://github.com/python/cpython/blob/v3.11.0/Lib/socketserver.py#L400-L480 - Ray direct `/logs/` Verify discriminator: static directory index inside the agent middleware, disabled token bypass, 401 for missing credentials, valid-token access and exact health exemptions (pinned tag). Ray direct `/logs/` Verify discriminator sources (pinned tag): https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L245-L249 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L20-L23. Ray direct `/logs/` Verify discriminator sources (pinned tag), continued: https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L102-L114 and https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/authentication/http_token_authentication.py#L28-L80 - Kubernetes security contexts (runAsNonRoot, drop capabilities, seccomp): https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ - KubeRay ray-cluster Helm chart values (head/worker podSecurityContext and securityContext, default {}): https://github.com/kuberay-project/kuberay/blob/v1.7.0/helm-chart/ray-cluster/values.yaml - [Ray ray-2.58.0 Serve gRPC default port and servicer-function requirement (checked October 2026)](https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/serve/config.py#L955-L963). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - ray: Ray pinned source (ray-2.58.0) - token-min: Ray token authentication introduction (2.52.0) - docs: Ray documentation (unknown) - redis-history: Ray Redis history announcement (unknown) - kuberay-auth: KubeRay authentication documentation (unknown) - kuberay: KubeRay chart (v1.7.0) - kuberay-auth-source: KubeRay authentication source (v1.6.0) - kuberay-auth-prior: KubeRay prior RayCluster types (v1.5.0) - docker: Docker publishing documentation (unknown) - kubernetes: Kubernetes security contexts (unknown) - docker-release: Docker Engine release notes (4e9a5751518ed8223a8dcde53693badddd72604f) - python: CPython WSGI server (v3.11.0) - curl: curl minimum write-out version (7.75.0) Claims (full source mapping in the JSON block below): - boundary (REASONED; docs): Dashboard, Jobs and Client access permits arbitrary code execution; use an isolated cluster network and separate clusters for mutually untrusted jobs. - dashboard (REASONED; ray): Dashboard defaults to resolved localhost, IPv4 before IPv6 then 127.0.0.1, on port 8265; state loopback explicitly. - docker (REASONED; docker, docker-release): Bridge publishing uses host loopback and container-interface bind; this guide assesses Docker 28.0.0 or later with normal NAT as the loopback-isolation minimum because the 28.0.0 release notes record a fix for neighbour hosts reaching loopback-mapped ports. - access (REASONED; docs, kuberay-auth): Reach Dashboard/Jobs through loopback SSH forwarding, launcher forwarding, KubeRay forwarding or a private tailnet; public browser access needs an authenticating TLS proxy. - head-ports (REASONED; docs): Head GCS uses 6379 and Client 10001; worker ports default 10002-19999 plus randomized listeners. - core-bind (REASONED; ray): Core gRPC source binds all interfaces unless node address is exactly 127.0.0.1, ::1 or localhost; private node IP does not imply private bind. - gcs-observation (DEMONSTRATED; ray): Four attempted Ray 2.58.0 loopback starts still showed GCS *:6379; the cause was not established and the watcher stopped the runs. - client-bind (REASONED; ray): Ray Client server/proxier listen on the supplied node address plus loopback; restrict access and prefer Jobs over publishing Client. - serve-replica (REASONED; ray): Serve inter-deployment gRPC binds [::] on an OS-chosen port; this is source reasoning, not a run. - agents (REASONED; ray): --include-dashboard=false does not stop dashboard/runtime-env agents; head dashboard itself is reduced to usage stats. - minimal (REASONED; ray): Missing optional imports select minimal mode: no agent HTTP/gRPC or metrics reporter; runtime-env HTTP remains. Base installs may have full dependencies. - agent-http (REASONED; ray): Non-minimal agent HTTP defaults 52365, including ray.init; --dashboard-agent-listen-port changes it, with node-IP plus conditional localhost binds. - agent-jobs (REASONED; ray): Direct /api/job_agent/jobs/ submission and stop/delete/log routes bypass a proxy on 8265; head forwarding reaches its agent job manager. - agent-placement (REASONED; ray): Worker agents accept cluster submissions with their own managers; drivers normally use the head unless resources, labels or worker-placement override permit workers. - agent-logs (REASONED; ray): Agent static /logs exposes the node log directory and index. - agent-http-auth (REASONED; ray): Without token mode a browser heuristic is the only filter; token mode guards all agent HTTP paths except two health endpoints. - agent-grpc (REASONED; ray): Agent gRPC defaults OS-assigned with --dashboard-agent-grpc-port override; localhost or wildcard bind serves log/profiling RPCs with optional token interception. - runtime-agent (REASONED; ray): Runtime-env HTTP defaults OS-assigned on node IP with --runtime-env-agent-port override; create/delete installs packages, and token middleware exempts no route. - metrics-agent (REASONED; ray): Metrics default enabled; non-minimal reporter uses an OS-assigned port or --metrics-export-port, outside all token middleware. - metrics-ipv4 (REASONED; ray, python): WSGI metrics uses AF_INET: empty address is IPv4 wildcard, localhost is used for local node addresses, and resolved ::1 cannot bind this socket. - node-normalization (REASONED; ray): Both CLI and ray.init normalize localhost node arguments to a discovered address; cluster-mode setting changes whole-node selection, not independent agent binds. - agent-family (REASONED; ray): Agent gRPC wildcard selection is 0.0.0.0 or :: based on resolved localhost, independently of node address family. - gcs-not-redis (REASONED; redis-history, docs): 6379 is GCS, not Redis; Redis stopped being launched by default in 1.11. - redis-backend (REASONED; docs): External Redis is opt-in GCS fault-tolerance storage; Redis credentials do not authenticate GCS clients and RAY_REDIS_PASSWORD is not a head flag substitute. - token-default (REASONED; token-min, ray): Shared token authentication exists since 2.52.0 but is disabled by default as of 2.58.0; it supplements network isolation and does not protect Serve apps. - token-input (REASONED; ray): Enable RAY_AUTH_MODE=token; precedence is RAY_AUTH_TOKEN, RAY_AUTH_TOKEN_PATH then ~/.ray/auth_token, shared by every node/client. - token-storage (REASONED; ray): Generate/protect token files and copy before startup; tokens are plaintext, do not expire and must never be committed. - token-transport (REASONED; docs, ray): Token HTTP headers require a trusted tunnel or TLS; plaintext HTTP exposes them on the network. - kuberay-token (REASONED; kuberay-auth, kuberay-auth-source, kuberay-auth-prior): KubeRay v1.6.0 defines authOptions; v1.5.0 RayClusterSpec lacks it. With token mode enabled, the controller creates a random-token Secret when Kubernetes ServiceAccount token authentication is off, SecretName is null or empty, and the generated-name Secret does not already exist; it leaves an existing generated-name Secret or a supplied named Secret in place and creates no Secret in ServiceAccount mode. The pod builder sets RAY_AUTH_MODE when unset on head and worker Ray containers, on the wait-gcs-ready init container when present, and on the head autoscaler when autoscaling is enabled; outside ServiceAccount mode it adds RAY_AUTH_TOKEN from the selected Secret when unset, and in ServiceAccount mode it does not add RAY_AUTH_TOKEN. This guide infers support in v1.6.0 and later from the compared tags; later releases have not been checked. - mfa (REASONED; docs): Ray has no user accounts; MFA belongs on SSH, tailnet or identity-aware proxy access. - grpc-tls (REASONED; docs): RAY_USE_TLS defaults 0; export it and server certificate/key/CA variables on every node before startup for internal gRPC mutual TLS. - tls-scope (REASONED; docs): gRPC TLS can cost performance and does not replace isolation or the dashboard/Jobs HTTP TLS proxy. - runtime-code (REASONED; docs): Job runtime_env installs before entrypoint; ray.init environments apply to child tasks/actors. Packages, archives, py_executable and setup hooks execute submitted code. - runtime-policy (REASONED; docs): No built-in package/URI/hook admission policy; eager_install and setup_timeout_seconds control timing, not trust. Prebuild reviewed dependencies. - serve-bind (REASONED; docs): Serve HTTP defaults port 8000, Python host 127.0.0.1 but config-file host 0.0.0.0; EveryNode puts proxies on nodes with replicas. - serve-auth (REASONED; docs): Serve apps have no built-in application auth and are outside cluster token mode; restrict origins and add proxy or FastAPI authentication. - serve-tls (REASONED; docs): Serve ssl_keyfile/ssl_certfile/ssl_ca_certs default None; supplying a CA does not itself require client certificates. - serve-grpc (REASONED; docs, ray): Optional Serve gRPC uses 9000 only with configured servicer functions. - image-privilege (REASONED; ray): Official image ray UID 1000/GID 100 has passwordless sudo; non-root startup alone does not prevent root escalation. - pod-security (REASONED; kuberay, kubernetes): KubeRay v1.7.0 head/worker pod/container contexts default empty; enforce non-root UID/GID, no privilege escalation, dropped capabilities and RuntimeDefault seccomp. - verify-inventory (REASONED; ray): Inventory every head/worker and actual randomized agent ports after restarts; expected missing listeners need explanation. No isolated multi-node cluster was available. - verify-external (REASONED; docs, curl): Probe every routed IPv4/IPv6 origin and inventoried port; completed TCP, even HTTP 401 or non-HTTP, proves exposure. Resolver/local errors are inconclusive. - verify-jobs (REASONED; ray, kuberay-auth): Through the trusted tunnel valid-token submission must succeed and token-free submission must fail authentication with 401, not a connection error. - verify-grpc-tls (REASONED; docs): ray health-check must pass with the correct CA and fail certificate verification with a wrong CA/name; local config errors are inconclusive. - verify-agent (REASONED; ray): Direct non-browser /logs/ must expose an index with token mode off, allow a valid token with it on and reject absent tokens with 401; health exemptions cannot discriminate. - verify-token-secret (REASONED; ray, curl): Trusted-path hidden prompt and stdin header avoid new token argv/history exposure and set +x avoids tracing; account owner/root can still read memory. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "20627523910cb5b9e66607932b0490ab17d6041e9c4ad3639fb8e919c294110b", "components": { "ray": { "name": "Ray pinned source", "basis": "ray-2.58.0", "sources": { "sed3f197cc1ef": "https://github.com/ray-project/ray/blob/ray-2.58.0/doc/source/ray-security/token-auth.md", "s08d31327265a": "https://raw.githubusercontent.com/ray-project/ray/ray-2.58.0/docker/base-deps/Dockerfile", "s2740a1880e2b": "https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/rpc/grpc_server.cc#L67-L68", "s958054ab801b": "https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.h#L111-L113", "sa3d2966f536b": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L601-L617", "s050b349f5bbd": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/ray_constants.py#L185", "sd7d2d0d963d0": "https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.cc#L257-L278", "s329ab4e0ed63": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/serve/_private/replica.py#L1778", "s3eb2c2cb2cbb": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/util/client/server/server.py#L799-L801", "sae1bb5af6e35": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/util/client/server/proxier.py#L929-L931", "s207ba00c4e86": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L1572-L1575", "s3efda42d7913": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L2464-L2471", "s9a060fe08a53": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/ray_constants.py#L189", "sb1935b32e677": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L618-L635", "se9af8ec90932": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L687-L692", "s17bc66b5543e": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L20-L23", "s7df4fce455ca": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L54-L66", "sc96f32b8a459": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L107-L113", "sbca5ef4de0bb": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/authentication/http_token_authentication.py#L28-L80", "scdfff3ba79f6": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/optional_utils.py#L132-L209", "s528944bdec23": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_agent.py#L32-L196", "s4796e2ca38da": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_head.py#L123-L144", "s1d86b2f31870": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_head.py#L267-L280", "s9976ecf5b1f1": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L245-L249", "sa542601368d1": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L264-L307", "sd12bacfbc3c7": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/log/log_agent.py#L345-L414", "s7ef1edbcda5c": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/reporter/reporter_agent.py#L570-L638", "s6f5310eb313d": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L144-L169", "s8f508c7e2c94": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L182-L187", "s48b3469099a3": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/agent.py#L100-L117", "s532a98227d6d": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L372-L377", "s33a5ff7c987b": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L1660-L1669", "s1a19fd793b8f": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/node.py#L1768", "s8f1ebe4bd911": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1381-L1387", "s7682a13e83a2": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1862-L1883", "s9237e74ea054": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1918-L1929", "s4ce6fe077ba9": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L2022-L2031", "s7f27125f73b7": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/reporter/reporter_agent.py#L504-L516", "sf921a5d6a898": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/prometheus_exporter.py#L326-L334", "s434c7b4364dc": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/runtime_env/agent/main.py#L218-L224", "s08aa128e5d70": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/runtime_env/agent/main.py#L243-L248", "sdd8960062a9c": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/includes/network_util.pxi#L103-L110", "s51476b743c19": "https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/util/network_util.cc#L280-L289", "s7451c8e00be3": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/scripts/scripts.py#L850-L854", "s6c7faadf88a4": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L786-L821", "s3625ec280c5e": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/worker.py#L1835-L1836", "sd3a18ab2a112": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/ray_constants.py#L507-L514", "sba3f67581471": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/parameter.py#L165-L167", "s994d0ddb6a7d": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/worker.py#L1884-L1912", "sd4d44da36278": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/utils.py#L1128-L1142", "s5a9d9c171f49": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/services.py#L1909-L1913", "s9cd95d66d32a": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/utils.py#L322-L351", "s762672f680e9": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/reporter/reporter_agent.py#L2099-L2101", "s28e3a1076d01": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/runtime_env/agent/main.py#L23-L33", "s883e3e00e4df": "https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/common/ray_config_def.h#L618-L619", "sdb1c4564137b": "https://github.com/ray-project/ray/blob/ray-2.58.0/src/ray/raylet/node_manager.cc#L3527-L3530", "sa7d4f677aadb": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_agent.py#L198-L203", "sa5873642c8a2": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_manager.py#L421-L463", "s2d562f0e7567": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/modules/job/job_manager.py#L565-L609", "s3a079f7e3d82": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/_private/prometheus_exporter.py#L10", "sefae1a66c0dd": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/dashboard/http_server_agent.py#L102-L114", "scc7af6be456e": "https://github.com/ray-project/ray/blob/ray-2.58.0/python/ray/serve/config.py#L955-L963" } }, "token-min": { "name": "Ray token authentication introduction", "basis": "2.52.0", "sources": { "sa5e2c2d58c12": "https://docs.ray.io/en/latest/ray-security/index.html" } }, "docs": { "name": "Ray documentation", "basis": "unknown", "sources": { "sa5e2c2d58c12": "https://docs.ray.io/en/latest/ray-security/index.html", "s6f0aa11f549a": "https://docs.ray.io/en/latest/ray-security/token-auth.html", "s56c7c98ab722": "https://docs.ray.io/en/latest/cluster/cli.html", "s261570b2672b": "https://docs.ray.io/en/latest/ray-core/configure.html", "sc375ff2ca01f": "https://docs.ray.io/en/latest/ray-core/handling-dependencies.html", "sb7a1d0b7dfb3": "https://docs.ray.io/en/latest/ray-core/api/doc/ray.runtime_env.RuntimeEnv.html", "scd3f7d8a9b02": "https://docs.ray.io/en/latest/serve/api/doc/ray.serve.config.HTTPOptions.html", "s15aa6ec80fb8": "https://docs.ray.io/en/latest/serve/api/doc/ray.serve.schema.HTTPOptionsSchema.html", "s045d75fbdecc": "https://docs.ray.io/en/latest/serve/api/doc/ray.serve.schema.ServeDeploySchema.html", "sc98a37c2b157": "https://docs.ray.io/en/latest/serve/http-guide.html", "s54ed4b890b65": "https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/kuberay-gcs-ft.html" } }, "redis-history": { "name": "Ray Redis history announcement", "basis": "unknown", "sources": { "sb5afda59324d": "https://www.anyscale.com/blog/redis-in-ray-past-and-future" } }, "kuberay-auth": { "name": "KubeRay authentication documentation", "basis": "unknown", "sources": { "saf5247262bc7": "https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/kuberay-auth.html" } }, "kuberay": { "name": "KubeRay chart", "basis": "v1.7.0", "sources": { "s0a2d3e6aaea9": "https://github.com/kuberay-project/kuberay/blob/v1.7.0/helm-chart/ray-cluster/values.yaml" } }, "kuberay-auth-source": { "name": "KubeRay authentication source", "basis": "v1.6.0", "sources": { "s417d19011876": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/apis/ray/v1/raycluster_types.go#L17-L19", "s458847583318": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/apis/ray/v1/raycluster_types.go#L81-L107", "sb656a53eb388": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/raycluster_controller.go#L369-L433", "s950487dca90c": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L194-L220", "s75fe88e2e7f7": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L234-L236", "s0c69263cf3da": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L253-L271", "sa247fb19c9a7": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L296-L336", "se2297a39bafa": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/common/pod.go#L460-L462", "s98da5c4b5deb": "https://github.com/kuberay-project/kuberay/blob/v1.6.0/ray-operator/controllers/ray/utils/util.go#L760-L766" } }, "kuberay-auth-prior": { "name": "KubeRay prior RayCluster types", "basis": "v1.5.0", "sources": { "s5c1c11450a05": "https://github.com/kuberay-project/kuberay/blob/v1.5.0/ray-operator/apis/ray/v1/raycluster_types.go#L12-L47" } }, "docker": { "name": "Docker publishing documentation", "basis": "unknown", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } }, "kubernetes": { "name": "Kubernetes security contexts", "basis": "unknown", "sources": { "sb78ea91d0308": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" } }, "docker-release": { "name": "Docker Engine release notes", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sd733f57ded23": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/release-notes/28.md#L768-L768" } }, "python": { "name": "CPython WSGI server", "basis": "v3.11.0", "sources": { "sdd5a8e2ab59c": "https://github.com/python/cpython/blob/v3.11.0/Lib/wsgiref/simple_server.py#L1-L160", "se4933ce3da65": "https://github.com/python/cpython/blob/v3.11.0/Lib/http/server.py#L120-L145", "s0020052d7267": "https://github.com/python/cpython/blob/v3.11.0/Lib/socketserver.py#L400-L480" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "boundary": {"text": "Dashboard, Jobs and Client access permits arbitrary code execution; use an isolated cluster network and separate clusters for mutually untrusted jobs.", "components": ["docs"], "sources": ["docs:sa5e2c2d58c12"], "status": "REASONED"}, "dashboard": {"text": "Dashboard defaults to resolved localhost, IPv4 before IPv6 then 127.0.0.1, on port 8265; state loopback explicitly.", "components": ["ray"], "sources": ["ray:sa3d2966f536b", "ray:s050b349f5bbd", "ray:sd7d2d0d963d0"], "status": "REASONED"}, "docker": {"text": "Bridge publishing uses host loopback and container-interface bind; this guide assesses Docker 28.0.0 or later with normal NAT as the loopback-isolation minimum because the 28.0.0 release notes record a fix for neighbour hosts reaching loopback-mapped ports.", "components": ["docker", "docker-release"], "sources": ["docker:s1e53417c513d", "docker-release:sd733f57ded23"], "status": "REASONED"}, "access": {"text": "Reach Dashboard/Jobs through loopback SSH forwarding, launcher forwarding, KubeRay forwarding or a private tailnet; public browser access needs an authenticating TLS proxy.", "components": ["docs", "kuberay-auth"], "sources": ["docs:sa5e2c2d58c12", "docs:s56c7c98ab722", "kuberay-auth:saf5247262bc7"], "status": "REASONED"}, "head-ports": {"text": "Head GCS uses 6379 and Client 10001; worker ports default 10002-19999 plus randomized listeners.", "components": ["docs"], "sources": ["docs:s56c7c98ab722", "docs:s261570b2672b"], "status": "REASONED"}, "core-bind": {"text": "Core gRPC source binds all interfaces unless node address is exactly 127.0.0.1, ::1 or localhost; private node IP does not imply private bind.", "components": ["ray"], "sources": ["ray:s2740a1880e2b", "ray:s958054ab801b"], "status": "REASONED"}, "gcs-observation": {"text": "Four attempted Ray 2.58.0 loopback starts still showed GCS *:6379; the cause was not established and the watcher stopped the runs.", "components": ["ray"], "sources": ["ray:s2740a1880e2b", "ray:s958054ab801b"], "status": "DEMONSTRATED", "evidence": "in each of four runs using `ray start --head --node-ip-address=127.0.0.1 --port=6379 --dashboard-host=127.0.0.1`, `ss` still showed the GCS listener as `*:6379`."}, "client-bind": {"text": "Ray Client server/proxier listen on the supplied node address plus loopback; restrict access and prefer Jobs over publishing Client.", "components": ["ray"], "sources": ["ray:s3eb2c2cb2cbb", "ray:sae1bb5af6e35", "ray:s207ba00c4e86", "ray:s3efda42d7913"], "status": "REASONED"}, "serve-replica": {"text": "Serve inter-deployment gRPC binds [::] on an OS-chosen port; this is source reasoning, not a run.", "components": ["ray"], "sources": ["ray:s329ab4e0ed63"], "status": "REASONED"}, "agents": {"text": "--include-dashboard=false does not stop dashboard/runtime-env agents; head dashboard itself is reduced to usage stats.", "components": ["ray"], "sources": ["ray:s33a5ff7c987b", "ray:s1a19fd793b8f", "ray:s8f1ebe4bd911", "ray:s7682a13e83a2", "ray:s9237e74ea054", "ray:s4ce6fe077ba9"], "status": "REASONED"}, "minimal": {"text": "Missing optional imports select minimal mode: no agent HTTP/gRPC or metrics reporter; runtime-env HTTP remains. Base installs may have full dependencies.", "components": ["ray"], "sources": ["ray:s48b3469099a3", "ray:sd4d44da36278", "ray:s5a9d9c171f49", "ray:s9cd95d66d32a", "ray:s762672f680e9", "ray:s28e3a1076d01"], "status": "REASONED"}, "agent-http": {"text": "Non-minimal agent HTTP defaults 52365, including ray.init; --dashboard-agent-listen-port changes it, with node-IP plus conditional localhost binds.", "components": ["ray"], "sources": ["ray:s9a060fe08a53", "ray:sb1935b32e677", "ray:s7df4fce455ca", "ray:sba3f67581471", "ray:s994d0ddb6a7d"], "status": "REASONED"}, "agent-jobs": {"text": "Direct /api/job_agent/jobs/ submission and stop/delete/log routes bypass a proxy on 8265; head forwarding reaches its agent job manager.", "components": ["ray"], "sources": ["ray:s528944bdec23", "ray:s4796e2ca38da", "ray:s1d86b2f31870"], "status": "REASONED"}, "agent-placement": {"text": "Worker agents accept cluster submissions with their own managers; drivers normally use the head unless resources, labels or worker-placement override permit workers.", "components": ["ray"], "sources": ["ray:sa7d4f677aadb", "ray:sa5873642c8a2", "ray:s2d562f0e7567"], "status": "REASONED"}, "agent-logs": {"text": "Agent static /logs exposes the node log directory and index.", "components": ["ray"], "sources": ["ray:s9976ecf5b1f1"], "status": "REASONED"}, "agent-http-auth": {"text": "Without token mode a browser heuristic is the only filter; token mode guards all agent HTTP paths except two health endpoints.", "components": ["ray"], "sources": ["ray:s17bc66b5543e", "ray:sc96f32b8a459", "ray:sbca5ef4de0bb", "ray:scdfff3ba79f6"], "status": "REASONED"}, "agent-grpc": {"text": "Agent gRPC defaults OS-assigned with --dashboard-agent-grpc-port override; localhost or wildcard bind serves log/profiling RPCs with optional token interception.", "components": ["ray"], "sources": ["ray:sb1935b32e677", "ray:sa542601368d1", "ray:sd12bacfbc3c7", "ray:s7ef1edbcda5c", "ray:s6f5310eb313d", "ray:s8f508c7e2c94", "ray:s532a98227d6d"], "status": "REASONED"}, "runtime-agent": {"text": "Runtime-env HTTP defaults OS-assigned on node IP with --runtime-env-agent-port override; create/delete installs packages, and token middleware exempts no route.", "components": ["ray"], "sources": ["ray:s532a98227d6d", "ray:s434c7b4364dc", "ray:s08aa128e5d70", "ray:sb1935b32e677"], "status": "REASONED"}, "metrics-agent": {"text": "Metrics default enabled; non-minimal reporter uses an OS-assigned port or --metrics-export-port, outside all token middleware.", "components": ["ray"], "sources": ["ray:s532a98227d6d", "ray:s7f27125f73b7", "ray:sf921a5d6a898", "ray:s883e3e00e4df", "ray:sdb1c4564137b", "ray:se9af8ec90932"], "status": "REASONED"}, "metrics-ipv4": {"text": "WSGI metrics uses AF_INET: empty address is IPv4 wildcard, localhost is used for local node addresses, and resolved ::1 cannot bind this socket.", "components": ["ray", "python"], "sources": ["ray:s7f27125f73b7", "ray:sf921a5d6a898", "ray:s3a079f7e3d82", "python:sdd5a8e2ab59c", "python:se4933ce3da65", "python:s0020052d7267"], "status": "REASONED"}, "node-normalization": {"text": "Both CLI and ray.init normalize localhost node arguments to a discovered address; cluster-mode setting changes whole-node selection, not independent agent binds.", "components": ["ray"], "sources": ["ray:s7451c8e00be3", "ray:s6c7faadf88a4", "ray:s3625ec280c5e", "ray:sd3a18ab2a112"], "status": "REASONED"}, "agent-family": {"text": "Agent gRPC wildcard selection is 0.0.0.0 or :: based on resolved localhost, independently of node address family.", "components": ["ray"], "sources": ["ray:sdd8960062a9c", "ray:s51476b743c19"], "status": "REASONED"}, "gcs-not-redis": {"text": "6379 is GCS, not Redis; Redis stopped being launched by default in 1.11.", "components": ["redis-history", "docs"], "sources": ["redis-history:sb5afda59324d", "docs:s56c7c98ab722"], "status": "REASONED"}, "redis-backend": {"text": "External Redis is opt-in GCS fault-tolerance storage; Redis credentials do not authenticate GCS clients and RAY_REDIS_PASSWORD is not a head flag substitute.", "components": ["docs"], "sources": ["docs:s56c7c98ab722", "docs:s54ed4b890b65"], "status": "REASONED"}, "token-default": {"text": "Shared token authentication exists since 2.52.0 but is disabled by default as of 2.58.0; it supplements network isolation and does not protect Serve apps.", "components": ["token-min", "ray"], "sources": ["token-min:sa5e2c2d58c12", "ray:sed3f197cc1ef"], "status": "REASONED"}, "token-input": {"text": "Enable RAY_AUTH_MODE=token; precedence is RAY_AUTH_TOKEN, RAY_AUTH_TOKEN_PATH then ~/.ray/auth_token, shared by every node/client.", "components": ["ray"], "sources": ["ray:sed3f197cc1ef"], "status": "REASONED"}, "token-storage": {"text": "Generate/protect token files and copy before startup; tokens are plaintext, do not expire and must never be committed.", "components": ["ray"], "sources": ["ray:sed3f197cc1ef"], "status": "REASONED"}, "token-transport": {"text": "Token HTTP headers require a trusted tunnel or TLS; plaintext HTTP exposes them on the network.", "components": ["docs", "ray"], "sources": ["docs:s6f0aa11f549a", "ray:sed3f197cc1ef"], "status": "REASONED"}, "kuberay-token": {"text": "KubeRay v1.6.0 defines authOptions; v1.5.0 RayClusterSpec lacks it. With token mode enabled, the controller creates a random-token Secret when Kubernetes ServiceAccount token authentication is off, SecretName is null or empty, and the generated-name Secret does not already exist; it leaves an existing generated-name Secret or a supplied named Secret in place and creates no Secret in ServiceAccount mode. The pod builder sets RAY_AUTH_MODE when unset on head and worker Ray containers, on the wait-gcs-ready init container when present, and on the head autoscaler when autoscaling is enabled; outside ServiceAccount mode it adds RAY_AUTH_TOKEN from the selected Secret when unset, and in ServiceAccount mode it does not add RAY_AUTH_TOKEN. This guide infers support in v1.6.0 and later from the compared tags; later releases have not been checked.", "components": ["kuberay-auth", "kuberay-auth-source", "kuberay-auth-prior"], "sources": ["kuberay-auth:saf5247262bc7", "kuberay-auth-source:s417d19011876", "kuberay-auth-source:s458847583318", "kuberay-auth-source:sb656a53eb388", "kuberay-auth-source:s950487dca90c", "kuberay-auth-source:s75fe88e2e7f7", "kuberay-auth-source:s0c69263cf3da", "kuberay-auth-source:sa247fb19c9a7", "kuberay-auth-source:se2297a39bafa", "kuberay-auth-source:s98da5c4b5deb", "kuberay-auth-prior:s5c1c11450a05"], "status": "REASONED"}, "mfa": {"text": "Ray has no user accounts; MFA belongs on SSH, tailnet or identity-aware proxy access.", "components": ["docs"], "sources": ["docs:sa5e2c2d58c12"], "status": "REASONED"}, "grpc-tls": {"text": "RAY_USE_TLS defaults 0; export it and server certificate/key/CA variables on every node before startup for internal gRPC mutual TLS.", "components": ["docs"], "sources": ["docs:s261570b2672b"], "status": "REASONED"}, "tls-scope": {"text": "gRPC TLS can cost performance and does not replace isolation or the dashboard/Jobs HTTP TLS proxy.", "components": ["docs"], "sources": ["docs:sa5e2c2d58c12", "docs:s261570b2672b"], "status": "REASONED"}, "runtime-code": {"text": "Job runtime_env installs before entrypoint; ray.init environments apply to child tasks/actors. Packages, archives, py_executable and setup hooks execute submitted code.", "components": ["docs"], "sources": ["docs:sc375ff2ca01f"], "status": "REASONED"}, "runtime-policy": {"text": "No built-in package/URI/hook admission policy; eager_install and setup_timeout_seconds control timing, not trust. Prebuild reviewed dependencies.", "components": ["docs"], "sources": ["docs:sc375ff2ca01f", "docs:sb7a1d0b7dfb3"], "status": "REASONED"}, "serve-bind": {"text": "Serve HTTP defaults port 8000, Python host 127.0.0.1 but config-file host 0.0.0.0; EveryNode puts proxies on nodes with replicas.", "components": ["docs"], "sources": ["docs:scd3f7d8a9b02", "docs:s15aa6ec80fb8", "docs:s045d75fbdecc"], "status": "REASONED"}, "serve-auth": {"text": "Serve apps have no built-in application auth and are outside cluster token mode; restrict origins and add proxy or FastAPI authentication.", "components": ["docs"], "sources": ["docs:sa5e2c2d58c12", "docs:sc98a37c2b157"], "status": "REASONED"}, "serve-tls": {"text": "Serve ssl_keyfile/ssl_certfile/ssl_ca_certs default None; supplying a CA does not itself require client certificates.", "components": ["docs"], "sources": ["docs:scd3f7d8a9b02"], "status": "REASONED"}, "serve-grpc": {"text": "Optional Serve gRPC uses 9000 only with configured servicer functions.", "components": ["docs", "ray"], "sources": ["docs:s045d75fbdecc", "ray:scc7af6be456e"], "status": "REASONED"}, "image-privilege": {"text": "Official image ray UID 1000/GID 100 has passwordless sudo; non-root startup alone does not prevent root escalation.", "components": ["ray"], "sources": ["ray:s08d31327265a"], "status": "REASONED"}, "pod-security": {"text": "KubeRay v1.7.0 head/worker pod/container contexts default empty; enforce non-root UID/GID, no privilege escalation, dropped capabilities and RuntimeDefault seccomp.", "components": ["kuberay", "kubernetes"], "sources": ["kuberay:s0a2d3e6aaea9", "kubernetes:sb78ea91d0308"], "status": "REASONED"}, "verify-inventory": {"text": "Inventory every head/worker and actual randomized agent ports after restarts; expected missing listeners need explanation. No isolated multi-node cluster was available.", "components": ["ray"], "sources": ["ray:s9a060fe08a53", "ray:s6f5310eb313d", "ray:s532a98227d6d", "ray:s7f27125f73b7", "ray:s434c7b4364dc"], "status": "REASONED", "verify": [1]}, "verify-external": {"text": "Probe every routed IPv4/IPv6 origin and inventoried port; completed TCP, even HTTP 401 or non-HTTP, proves exposure. Resolver/local errors are inconclusive.", "components": ["docs", "curl"], "sources": ["docs:sa5e2c2d58c12", "docs:s261570b2672b", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [2, 3]}, "verify-jobs": {"text": "Through the trusted tunnel valid-token submission must succeed and token-free submission must fail authentication with 401, not a connection error.", "components": ["ray", "kuberay-auth"], "sources": ["ray:sed3f197cc1ef", "kuberay-auth:saf5247262bc7"], "status": "REASONED", "verify": [4, 5]}, "verify-grpc-tls": {"text": "ray health-check must pass with the correct CA and fail certificate verification with a wrong CA/name; local config errors are inconclusive.", "components": ["docs"], "sources": ["docs:s261570b2672b"], "status": "REASONED", "verify": [5]}, "verify-agent": {"text": "Direct non-browser /logs/ must expose an index with token mode off, allow a valid token with it on and reject absent tokens with 401; health exemptions cannot discriminate.", "components": ["ray"], "sources": ["ray:s9976ecf5b1f1", "ray:s17bc66b5543e", "ray:sbca5ef4de0bb", "ray:sefae1a66c0dd"], "status": "REASONED", "verify": [6]}, "verify-token-secret": {"text": "Trusted-path hidden prompt and stdin header avoid new token argv/history exposure and set +x avoids tracing; account owner/root can still read memory.", "components": ["ray", "curl"], "sources": ["ray:sed3f197cc1ef", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [6]} } } ```` ====================================================================== ==> mlflow.md ====================================================================== # MLflow tracking server: no authentication by default MLflow tracks machine-learning work: its tracking server records experiments, runs and registered models and shows them in a web UI. This guide helps your AI assistant configure it securely, including a private bind, TLS from a fronting proxy, MLflow's built-in basic authentication turned on, and protected artifact storage credentials. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/mlflow.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MLflow pinned source v3.16.1, MLflow security middleware minimum 3.5.0, Docker Engine loopback publication minimum 28.0.0, nginx proxy implementation release-1.28.0, Caddy reverse proxy parser v2.11.4, oauth2-proxy documentation v7.15.6, and Authelia source v4.39.28, plus 3 more recorded bases; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-11-06; documentation checked September 2026. The guide records 32 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. `mlflow server` serves the tracking UI and REST API at `http://127.0.0.1:5000` (as of v3.16.1, unless `MLFLOW_HOST` or `MLFLOW_PORT` is set) and performs no authentication: anyone who can reach the port can read, alter, and delete experiments, runs, registered models, and (with artifact proxying on) the artifacts themselves. Authentication is opt-in through a separate app, and the server has no dedicated TLS flags (though `mlflow server --uvicorn-opts` can forward `--ssl-keyfile`/`--ssl-certfile` to the default Uvicorn server); MLflow's tracking-server documentation recommends a reverse proxy or VPN for both. ## 1. Bind privately The defaults are already loopback: ```bash mlflow server --host 127.0.0.1 --port 5000 ``` The CLI help for `--host` says it plainly: "This is NOT a security setting". Do not switch to `--host 0.0.0.0` on a machine with a public interface. In Docker the two binds are different things: the host publishes on loopback (`-p 127.0.0.1:5000:5000`, which Docker's port-publishing docs say only the Docker host can reach, assuming a normal bridge-network NAT setup and a Docker version at or after 28.0.0; earlier releases could let hosts on the same layer-2 network reach a loopback-published port). The second Docker bind is inside the container, where the server must listen on the container's own interface (`--host 0.0.0.0` there, private to the Compose network and the host), because a process bound to the container's `127.0.0.1` is not reachable through the published port at all. See [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md). If the server must listen beyond loopback for a proxy on another host, set `--allowed-hosts mlflow.example.com` and `--cors-allowed-origins https://mlflow.example.com`, and never use `--disable-security-middleware` outside a test. The security-middleware flags need MLflow 3.5.0 or newer and the default Uvicorn server, not `--gunicorn-opts`/`--waitress-opts`. The `--allowed-hosts` default validates the request's Host header against localhost, `127.0.0.1`, `[::1]`, `0.0.0.0`, and private ranges, which is a Host-header check, not a client-source firewall. ## 2. TLS from a fronting proxy `mlflow server` has no dedicated certificate flags (though in MLflow v3.16.1 `--uvicorn-opts` forwards its options to the default Uvicorn server, for which Uvicorn 0.54.0 documents `--ssl-keyfile`/`--ssl-certfile`; MLflow rejects `--uvicorn-opts` with `--dev`, `--gunicorn-opts` or `--waitress-opts`, and `--gunicorn-opts` or `--waitress-opts` runs gunicorn or waitress instead of Uvicorn). Terminate TLS in nginx or Caddy per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)/[caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) with `proxy_pass http://127.0.0.1:5000` or `reverse_proxy 127.0.0.1:5000`, a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), and `--allowed-hosts` set to the public hostname; or use a tunnel or tailnet ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)). Point clients at `MLFLOW_TRACKING_URI=https://mlflow.example.com`, and never set `MLFLOW_TRACKING_INSECURE_TLS=true` in production (MLflow's own docs say the same). ## 3. Turn on the built-in basic auth MLflow ships an HTTP basic-auth app that stores users and per-resource permissions in a database (as of September 2026 the current documentation page carries no experimental label; verify before relying on it). The client-side `MLFLOW_TRACKING_USERNAME`/`MLFLOW_TRACKING_PASSWORD` variables do nothing on their own; the server must run this app. Provision `/etc/mlflow/server.env` through your secret manager as a regular file owned by the server account, mode `0600`, in a directory other accounts cannot traverse, write to or replace, with no ACL granting them access. It must contain a dotenv assignment for `MLFLOW_FLASK_SERVER_SECRET_KEY` with a long random value, identical on every replica. Keep the file and its backups out of source control. The block assumes this file and the auth configuration below are provisioned before startup and a fresh, trusted shell. The global `mlflow --env-file` option loads the file before executing the server command and does not override existing environment values. Clear inherited values first so the file supplies the key: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n MLFLOW_FLASK_SERVER_SECRET_KEY MLFLOW_AUTH_CONFIG_PATH && unset -v MLFLOW_FLASK_SERVER_SECRET_KEY MLFLOW_AUTH_CONFIG_PATH; } 2>/dev/null || { echo 'cannot clear MLflow launch variables in this shell; not starting'; exit 2; } if [ ! -f /etc/mlflow/server.env ] || [ -L /etc/mlflow/server.env ] || [ ! -r /etc/mlflow/server.env ]; then echo 'need a readable regular /etc/mlflow/server.env file, not a symlink; not starting'; exit 2 fi pip install 'mlflow[auth]' || exit 2 MLFLOW_AUTH_CONFIG_PATH=/etc/mlflow/basic_auth.ini \ mlflow --env-file /etc/mlflow/server.env server --app-name basic-auth ) ``` Only the file path reaches the launch command; the shell never reads or exports the key. MLflow loads it into its own environment and forwards it to workers (traced at v3.16.1 in the sources below), so it remains exposed to the same account and root in process memory and, for inheriting processes, `/proc//environ` throughout their lifetimes. A protected file does not remove that server-side exposure. Current MLflow has no default admin password: the first start requires an admin password of at least 12 characters, supplied as `MLFLOW_AUTH_ADMIN_PASSWORD` or as `admin_password` in the configuration file, and it rejects the legacy `password1234`, so startup fails without one. Set a strong password before that first start (an already-bootstrapped server does not need it re-supplied on later restarts): ```ini # /etc/mlflow/basic_auth.ini [mlflow] # default is READ on every resource default_permission = NO_PERMISSIONS database_uri = postgresql://mlflow_auth:REPLACE_WITH_LONG_RANDOM_VALUE@127.0.0.1:5432/mlflow_auth admin_username = admin admin_password = REPLACE_WITH_LONG_RANDOM_VALUE ``` `database_uri` defaults to a SQLite file `basic_auth.db` in the working directory; MLflow recommends a central database for multi-node deployments. The same file can name an `authorization_function` (`module:function`) for a custom scheme, but the shipped one is basic auth. To rotate the admin password on a running server: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # Both passwords you substitute on the set -- line enter shell history. # Clear that history line afterward. # Stdin closes the argv channel only: it does not hide the passwords from # set -x tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CURRENT_ADMIN_PASSWORD' 'REPLACE_WITH_LONG_RANDOM_VALUE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the current admin password on the set -- line above; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the new password on the set -- line above; not probing"; exit 2 ;; esac # Both passwords reach curl on stdin via a config file (curl --config -), never # argv: -u admin:PASSWORD and the -d body are world-readable in /proc//cmdline. # Escape backslashes then quotes for the config; the new password is JSON-escaped # first, then the whole JSON body is config-escaped. set -- "${1//\\/\\\\}" "${2//\\/\\\\}" set -- "${1//\"/\\\"}" "${2//\"/\\\"}" set -- "$1" "{\"username\":\"admin\",\"password\":\"$2\"}" set -- "$1" "${2//\\/\\\\}" set -- "$1" "${2//\"/\\\"}" printf 'user = "admin:%s"\ndata-binary = "%s"\n' "$1" "$2" | curl -q -sS --config - -X PATCH -H 'Content-Type: application/json' https://mlflow.example.com/api/2.0/mlflow/users/update-password ) ``` Creating users requires admin credentials (UI at `/signup`, or `POST /api/2.0/mlflow/users/create`). Give humans individual accounts and CI its own low-permission user per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md); `~/.mlflow/credentials` stores passwords unencrypted, so prefer the environment variables injected at runtime. The documentation notes that the UI has no limit on login attempts, and basic auth is checked on every protected API request, so rate-limit the authentication-bearing routes at the proxy (not only the login path) per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md); and because basic auth sends the password with every request ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)), step 2 comes first. MFA: this guide's cited MLflow material does not establish MFA support in the basic-auth app. Guide advice: put an identity-aware layer in front (Cloudflare Access, Authelia, oauth2-proxy per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)) and require MFA for human access. Cloudflare Access at c896795b8e8bab68d05ae78d64c9cbcda1fdb104 supports IdP-based MFA requirements for Okta, Microsoft Entra ID, Generic OIDC and Generic SAML 2.0, rejecting a user who does not present the required method, or independent MFA with methods including TOTP and WebAuthn security keys. Independent MFA in Cloudflare Access at c896795b8e8bab68d05ae78d64c9cbcda1fdb104 must first be enabled at organization level, and the effective independent MFA requirement follows policy over application over organization. Authelia v4.39.28 applies the first matching access-control rule, or default_policy if none matches; its two_factor policy requires successful 2FA. oauth2-proxy v7.15.6 documents an OAuth provider and upstream endpoints, not second-factor enforcement in these passages; guide advice: require MFA at the identity provider for this login and test enforcement there. These sources do not establish MFA enforcement in a deployed MLflow combination. MLflow v3.16.1 declares MLFLOW_TRACKING_USERNAME, MLFLOW_TRACKING_PASSWORD and MLFLOW_TRACKING_TOKEN as tracking authentication inputs; in the cited HTTP request-construction path, nonempty username and password credentials select an Authorization: Basic header unless the auth mode is kubernetes or kubernetes-namespaced, otherwise a nonempty token selects Authorization: Bearer. The token variable's comment claims precedence, but that request code selects Basic first under those conditions; AWS SigV4 overwrites Authorization, and a configured auth handler is applied separately, so this is not a guarantee for every authentication mode. Cloudflare Access service tokens at c896795b8e8bab68d05ae78d64c9cbcda1fdb104 use CF-Access-Client-Id and CF-Access-Client-Secret headers, with a Service Auth policy required to avoid an IdP login prompt; a self-hosted Access application can alternatively be configured to accept the service token in one header. Guide advice: confirm that the chosen client and proxy combination preserves both proxy authentication and MLflow authentication; these citations do not establish that MLFLOW_TRACKING_TOKEN alone supplies Cloudflare service-token headers or that both layers work together. ## 4. Artifact store credentials With `--serve-artifacts` (the default) and `--artifacts-destination s3://bucket`, the server proxies every artifact read and write for experiments recorded under the proxied store, so it holds the storage credentials and clients need none. Experiments created before proxying, or with an explicit direct artifact location, keep that location and stay reachable independently of the tracking server's permissions, so apply storage IAM directly and inspect existing experiment locations rather than assuming proxying covers them. Supply them through the environment or an instance role, never in a compose file or the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), [object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md)). With `--no-serve-artifacts`, every client needs its own storage credentials and the tracking server's permissions no longer gate the artifacts. ## Verify ```bash # REASONED: listener and authentication checks follow the cited MLflow documentation; no running basic-auth server is available. # Run this block on the MLflow host: its backend checks reach 127.0.0.1:5000 and its listener list reads that host. # experiments/search is a POST endpoint, so each check posts a minimal body. --noproxy so no client # proxy answers. A 401 at your public MLflow host can come from the fronting proxy OR from MLflow's own basic # auth, so the backend check below proves MLflow itself enforces it, not just the proxy. Each command prints # its HTTP status and the block classifies it against the comment beside it; curl exits 0 # for 401/403/404 too, so shell success is NOT a pass, and an open MLflow backend answers the anonymous # search with 200. If you front MLflow with an identity-aware proxy (Cloudflare Access etc., the MFA # option in step 3) instead of plain basic auth, the public https probes also need that proxy's own # credentials (its service-token headers), and a denial from that proxy is distinct from MLflow's own # backend 401/403. Behind such a proxy the three public requests, which send no service-token headers, get # that proxy's redirect or denial, so this block cannot exit 0 there; its four backend requests still show # MLflow's own enforcement. The credentials below reach curl through a config file on stdin (curl --config -), # never argv, because -u user:password is world-readable via ps / /proc//cmdline; stdin protects # the argv channel only, not shell history, set -x tracing, or your own # account's view of the process. Substitute each password INSIDE the # single quotes on the set -- line; a password containing a literal apostrophe must be written '\'' there. # The block exits 1 for a finding (a 2xx where a refusal is expected), 3 if ss or a request fails or a request # answers anything else the comments do not expect, 2 if ss is not installed or the credential part refuses its # input, and otherwise 0. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (ss is not installed, or a part refused its input), which outranks 0 if command -v ss >/dev/null; then ss -tlnp || { echo "ss -tlnp exit $?: the listener list failed; inconclusive"; set -- 3; } # read every listener; 5000: 127.0.0.1 only set -- "$1" listed # the 2nd value says whether ss ran, so the last line names the listeners only when it did else echo "ss is not installed here; not listing sockets" case "$1" in 0) set -- 2 ;; esac set -- "$1" unlisted fi ( # Each credential reaches curl on stdin via a config file (curl --config -), never # argv; -u user:password is world-readable in /proc//cmdline on a shared host. trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_MLFLOW_HOST' 'REPLACE_WITH_ADMIN_PASSWORD' 'REPLACE_WITH_LOWPERM_USER' 'REPLACE_WITH_LOWPERM_PASSWORD' 'REPLACE_WITH_A_REAL_EXPERIMENT_ID' # first the public host name your proxy serves MLflow on, which is also a Host that --allowed-hosts admits, with no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace each value inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "the set -- line needs exactly 5 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the admin password on the set -- line above; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the low-permission user on the set -- line above; not probing"; exit 2 ;; esac case "$4" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the low-permission password on the set -- line above; not probing"; exit 2 ;; esac case "$5" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute a real experiment id on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "${2//\\/\\\\}" "${3//\\/\\\\}" "${4//\\/\\\\}" "$5" set -- "$1" "${2//\"/\\\"}" "${3//\"/\\\"}" "${4//\"/\\\"}" "$5" set -- "$1" "$2" "$3" "$4" "$5" 0 # the 6th value is this part's status so far, ranked as above set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"max_results":1}' \ "https://$1/api/2.0/mlflow/experiments/search"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$8" # 401: no credentials case "$7:$8" in 0:401) ;; 0:2??) echo "FINDING: the public search answered $8 with no credentials"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:*) echo "HTTP $8 from the public search with no credentials is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the public search with no credentials failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"max_results":1}' \ --config - "https://$1/api/2.0/mlflow/experiments/search"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$8" # positive control: 200 with the admin password case "$7:$8" in 0:200) ;; 0:*) echo "HTTP $8 from the public search with the admin password is not the 200 positive control; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the public search with the admin password failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if printf 'user = "admin:not-the-real-password"\n' | curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"max_results":1}' \ --config - "https://$1/api/2.0/mlflow/experiments/search"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$8" # 401: a wrong password is refused (MLflow rejects the legacy password1234 by design, so there is no default to "remove") case "$7:$8" in 0:401) ;; 0:2??) echo "FINDING: the public search answered $8 to a wrong admin password"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:*) echo "HTTP $8 from the public search with a wrong password is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the public search with a wrong password failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # On the MLflow host, the same no-credential POST must ALSO be 401 - a 401 only at the proxy would leave # MLflow open behind it. On the host, --allowed-hosts rejects a bare 127.0.0.1 Host with 403 before auth, # so send the allowed Host header, your MLflow host name from the set -- line: set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -X POST -H "Host: $1" -H 'Content-Type: application/json' -d '{"max_results":1}' \ http://127.0.0.1:5000/api/2.0/mlflow/experiments/search; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$8" # 401: no credentials, on the backend case "$7:$8" in 0:401) ;; 0:2??) echo "FINDING: the backend search answered $8 with no credentials, so MLflow itself does not enforce authentication"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; 0:403) echo "HTTP 403 from the backend search: --allowed-hosts refused the Host header before authentication ran; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; 0:*) echo "HTTP $8 from the backend search with no credentials is neither the 401 pass nor a 2xx finding; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the backend search with no credentials failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -X POST -H "Host: $1" -H 'Content-Type: application/json' -d '{"max_results":1}' \ --config - http://127.0.0.1:5000/api/2.0/mlflow/experiments/search; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$8" # backend positive control: 200 with the admin password case "$7:$8" in 0:200) ;; 0:*) echo "HTTP $8 from the backend search with the admin password is not the 200 positive control; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; *) echo "curl exit $7: the backend search with the admin password failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac # Authorization, not just authentication: experiments/search only returns results the caller may see, so # it tests authn, not authz. Pick a REAL experiment id (not the placeholder), confirm admin can GET it, # then confirm a low-permission user is refused THERE - a 403 from MLflow on the backend, not the proxy: set -- "$1" "$2" "$3" "$4" "$5" "$6" "$(if printf 'user = "admin:%s"\n' "$2" | curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -H "Host: $1" \ --config - "http://127.0.0.1:5000/api/2.0/mlflow/experiments/get?experiment_id=$5"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" "${7%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$8" # do this admin check FIRST: 200 means the experiment exists and admin may read it; a 401/403/404 here means a wrong id or wrong admin credentials, so fix that before trusting the next line case "$7:$8" in 0:200) set -- "$1" "$2" "$3" "$4" "$5" "$6" admin-ok ;; 0:*) echo "HTTP $8 from the admin experiment check is not 200: a wrong id or wrong admin credentials; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3; set -- "$1" "$2" "$3" "$4" "$5" "$6" admin-failed ;; *) echo "curl exit $7: the admin experiment check failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3; set -- "$1" "$2" "$3" "$4" "$5" "$6" admin-failed ;; esac set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$(if printf 'user = "%s:%s"\n' "$3" "$4" | curl -q -g -sS -o /dev/null --noproxy '*' -w '%{http_code}\n' -H "Host: $1" \ --config - "http://127.0.0.1:5000/api/2.0/mlflow/experiments/get?experiment_id=$5"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8##*$'\n'}" "${8%%$'\n'*}" # the admin check's result, curl's exit status, then the HTTP code printf '%s\n' "$9" # 403: authenticated but not permitted, same real experiment from MLflow (a 401 here means the low-permission credentials are wrong, not authz) case "$7:$8:$9" in admin-ok:0:403) ;; admin-ok:0:2??) echo "FINDING: the low-permission user read the experiment ($9), so MLflow did not refuse it"; set -- "$1" "$2" "$3" "$4" "$5" 1 ;; admin-ok:0:401) echo "HTTP 401 for the low-permission user: its credentials are wrong, so authorization was not tested; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; admin-ok:0:*) echo "HTTP $9 for the low-permission user is neither the 403 pass nor a 2xx finding; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; admin-failed:0:*) echo "HTTP $9 for the low-permission user is not classified, because the admin check above failed" ;; *) echo "curl exit $8: the low-permission experiment check failed; inconclusive"; [ "$6" = 1 ] || set -- "$1" "$2" "$3" "$4" "$5" 3 ;; esac exit "$6" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 "$2" ;; 2:0) set -- 2 "$2" ;; 2:*|*:1) ;; *) set -- 3 "$2" ;; esac # fold that part's status into the block's, ranked as above case "$2" in listed) echo "block exit $1; the listeners are not classified, so read every one against the comment on the ss line" ;; *) echo "block exit $1; ss did not run, so there is no listener list to read" ;; esac exit "$1" ) ``` ```bash # Run this block from another machine, checking the port is unreachable from outside. The pass is that no TCP # connection formed: time_connect stays 0.000000 and err names a connection-level failure (refused, no # route, or a filtered-port connect timeout). A non-zero time_connect, or any http code, means the # handshake completed and the port answered. A name-resolution or local socket error is inconclusive. # The block exits 0 for curl exit 7 or 28 with time_connect 0.000000 and an err that opens "Failed to connect to" # or "Connection timed out" (curl 8.18.0's connection-level wording; other wording reads as 3), 1 for a handshake # or an http code, 3 for any other failure, a resolver timeout included, and 2 if it refuses its input. A local # socket error can also exit 7 with "Failed to connect to", so read err before taking 0 as the pass. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_SERVER_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the server's public address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sI -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ -w 'http=%{http_code} time_connect=%{time_connect} exit=%{exitcode} err=%{errormsg}\n' "http://$1:5000/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # curl's exit status, then its write-out line printf '%s' "$3" case "$2:$3" in 7:'http=000 time_connect=0.000000 '*' err=Failed to connect to '*|28:'http=000 time_connect=0.000000 '*' err=Failed to connect to '*|28:'http=000 time_connect=0.000000 '*' err=Connection timed out '*) echo "no TCP connection formed (curl exit $2); the pass, if err names a refusal, no route or a connect timeout"; set -- 0 ;; *:'http=000 time_connect=0.000000 '*) echo "curl exit $2 with no connection: a name-resolution or local error; inconclusive"; set -- 3 ;; *:http=*) echo "FINDING: the handshake completed or the port answered (a non-zero time_connect or an http code), so port 5000 is reachable from this machine"; set -- 1 ;; *) echo "curl exit $2 printed no write-out line; inconclusive"; set -- 3 ;; esac echo "block exit $1; the err text is not classified, so confirm it names a connection-level failure before reading 0 as the pass" exit "$1" ) ``` REASONED: preceding block; external TCP reachability follows the cited MLflow host/binding documentation and curl connection/error write-out reference; no external test path is available in the authoring environment, so no reachability result is recorded. The first block's listener inventory, proxy/backend authentication and experiment authorization are declared by its own leading comment and follow the cited MLflow host/binding and authentication documentation; no deployed server, credentials or experiment-permission fixture is available for them. An authenticated user without permission on a resource gets `403`; a missing or wrong credential gets `401`. The backend authentication and authorization checks above ship marked reasoned, not demonstrated: the authoring environment has no running MLflow basic-auth server, so the exposed and fixed states (anonymous `experiments/search` open versus `401`, and the admin `200` versus low-permission `403` on a real experiment) are not observed here. The expected outcomes are REASONED from the cited MLflow authentication documentation. The public checks and the backend Host header take the MLflow host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains. The second block's current form is covered only by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs while it was drafted. Those curl-stub runs covered an earlier form of the first block, that guard included, before its `command -v ss` check. Stub runs with `ss` present and passing, present and failing, and missing covered a later form, whose last line named the listeners even when `ss` was missing. Stub runs under the same three `ss` conditions, in which its last line named the listeners only when `ss` ran, covered the form after that, whose guard lowercased the host with `tr` before refusing those names. The first block's current form is covered only by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n, ShellCheck 0.11.0 and stub runs on 2026-11-01, in which the guard refused those names in lower, upper and mixed case, also with `tr` absent and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and under dash refused every host with status 2. ## Common mistakes - Running `--host 0.0.0.0` "for the team" with no `--app-name basic-auth` and no proxy: the whole experiment history is world-writable. - Bootstrapping the admin account with a weak or shared password (MLflow now requires at least 12 characters and rejects `password1234`, but a guessable value is still a risk). - Setting `MLFLOW_TRACKING_USERNAME` in CI and assuming the server checks it; without the auth app it is ignored. - Committing `basic_auth.ini` with `admin_password` or a database password inside it. ## Sources (checked September 2026) - MLflow CSRF-key file input: [global `--env-file` option and existing-environment precedence](https://mlflow.org/docs/latest/api_reference/cli.html#mlflow), [v3.16.1 auth factory](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/auth/__init__.py#L6008-L6021), and [v3.16.1 worker environment](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L401-L402). - MLflow authentication with username and password (`--app-name basic-auth`, default admin credentials, `basic_auth.ini` keys, `MLFLOW_AUTH_CONFIG_PATH`, `MLFLOW_FLASK_SERVER_SECRET_KEY`, client variables, 403 on missing permission): https://mlflow.org/docs/latest/self-hosting/security/basic-http-auth/ - MLflow authentication REST API (`2.0/mlflow/users/update-password` request fields): https://mlflow.org/docs/latest/api_reference/auth/rest-api.html - `mlflow server` CLI reference (`--host` default 127.0.0.1, `--port` 5000, `--app-name`, `--allowed-hosts`, `--cors-allowed-origins`, `--serve-artifacts`): https://mlflow.org/docs/latest/api_reference/cli.html - MLflow tracking server (default address, reverse proxy or VPN for TLS and auth, `MLFLOW_TRACKING_TOKEN`, `MLFLOW_TRACKING_INSECURE_TLS`, artifact proxying) (security-middleware flags need MLflow 3.5.0 or newer): https://mlflow.org/docs/latest/self-hosting/architecture/tracking-server - MLflow REST API, Search Experiments (`POST 2.0/mlflow/experiments/search`): https://mlflow.org/docs/latest/api_reference/rest-api.html - Docker, port publishing (loopback publishing) (loopback-only reach assumes Docker 28.0.0 or later): https://docs.docker.com/engine/network/port-publishing/ - curl manual (`--connect-timeout` bounds the connection phase only; the `time_connect`, `exitcode`, and `errormsg` write-out variables, the last two added in curl 7.75.0): https://curl.se/docs/manpage.html - MLflow `--host` option, default `127.0.0.1`, and `--port`, default `5000`, which the `MLFLOW_HOST` and `MLFLOW_PORT` environment variables override (pinned tag v3.16.1): https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/utils/cli_args.py#L162-L180 - MLflow's tracking-server command, `def server`, takes those `--host` and `--port` options (pinned tag v3.16.1): - MLflow v3.16.1 `--uvicorn-opts` forwarding to the default Uvicorn server (checked October 2026): [`--uvicorn-opts` option and `MLFLOW_UVICORN_OPTS`](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L454-L459), [only one of `--gunicorn-opts`, `--waitress-opts` or `--uvicorn-opts`](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L317-L325), [`--dev` refuses `--uvicorn-opts`](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L602-L608), [Uvicorn unless gunicorn or waitress options are given](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L409-L411), [Uvicorn command selection](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L437-L441) and [`shlex.split` options placed after `-m uvicorn`](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L303-L327). - Uvicorn 0.54.0 `--ssl-keyfile` and `--ssl-certfile` HTTPS options (checked October 2026): [settings documentation](https://github.com/encode/uvicorn/blob/0.54.0/docs/settings.md#L128-L134) and [command-line option definitions](https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/main.py#L310-L317). - nginx release-1.28.0 proxy directive registration (checked October 2026; `proxy_pass` takes one upstream argument): https://github.com/nginx/nginx/blob/release-1.28.0/src/http/modules/ngx_http_proxy_module.c#L305-L310 - Caddy v2.11.4 `reverse_proxy` parser (checked October 2026): [directive registration](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go#L40) and [upstream syntax](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go#L58-L62). - iproute2 v6.12.0 `ss` manual (checked October 2026): [numeric output](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L33-L34), [listening sockets](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L43-L44), [process display](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L162-L163), and [TCP sockets](https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L374-L375). - MLflow v3.16.1 dotenv loader (checked October 2026): [existing-environment precedence](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L85-L86) and [global eager `--env-file` option](https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L94-L105). - MLflow v3.16.1 tracking credential variables and the token comment; request-code precedence below qualifies that comment (checked October 2026): https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/environment_variables.py#L493-L503 - MLflow v3.16.1 HTTP Authorization construction, Kubernetes exception and additional auth handlers (checked October 2026): https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/utils/rest_utils.py#L254-L292 - Cloudflare Access MFA requirements and independent MFA prerequisites (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85 - Cloudflare independent MFA, including TOTP and WebAuthn security keys (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24 - Cloudflare service-token Service Auth requirement, request headers and configurable single-header alternative (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104, checked October 2026). Cloudflare service-token sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/service-credentials/service-tokens.mdx#L33. More Cloudflare service-token sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/service-credentials/service-tokens.mdx#L45-L49. More Cloudflare service-token sources: https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/service-credentials/service-tokens.mdx#L59 - oauth2-proxy v7.15.6 provider and upstream configuration; these entries do not establish MFA enforcement (checked November 2026): https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L94-L108, https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L305 - Authelia v4.39.28 first matching rule, default policy and two_factor requirement (checked October 2026): https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L506-L507, https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L536-L537 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - source: MLflow pinned source (v3.16.1) - middleware-min: MLflow security middleware minimum (3.5.0) - docs: MLflow documentation (unknown) - docker: Docker Engine loopback publication minimum (28.0.0) - nginx: nginx proxy implementation (release-1.28.0) - caddy: Caddy reverse proxy parser (v2.11.4) - cf-docs: Cloudflare documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - oauth2-proxy-source: oauth2-proxy documentation (v7.15.6) - authelia-source: Authelia source (v4.39.28) - curl: curl minimum write-out version (7.75.0) - iproute2: iproute2 ss manual (v6.12.0) - uvicorn: Uvicorn source (0.54.0) Claims (full source mapping in the JSON block below): - default-host (REASONED; source): v3.16.1 defaults to 127.0.0.1 unless MLFLOW_HOST overrides it; explicitly bind privately and do not treat --host as an authentication setting. - default-port (REASONED; source): v3.16.1 serves the tracking UI and REST API on port 5000 unless MLFLOW_PORT overrides it. - default-auth (REASONED; docs): Authentication is opt-in; a reachable server without the auth app permits reading, changing and deleting tracking resources and proxied artifacts. - container-bind (REASONED; docker, docs): Normal bridge NAT requires host loopback publication 127.0.0.1:5000:5000 and --host 0.0.0.0 inside the container; container loopback cannot serve the publication. Before Docker 28.0.0, same-layer-2 hosts could reach loopback-published ports. - middleware-scope (REASONED; middleware-min, docs): Security middleware requires MLflow 3.5.0 or newer and the default Uvicorn server; --gunicorn-opts and --waitress-opts do not use it. - host-allowlist (REASONED; docs): Set --allowed-hosts mlflow.example.com for access beyond loopback; defaults admit localhost, 127.0.0.1, [::1], 0.0.0.0 and private ranges as Host headers, not client-source firewall rules. - cors (REASONED; docs): Set --cors-allowed-origins https://mlflow.example.com for the intended browser origin. - middleware-disable (REASONED; docs): Do not use --disable-security-middleware outside a test. - tls (REASONED; docs, source, uvicorn, nginx, caddy): There are no dedicated server TLS flags; MLflow v3.16.1 --uvicorn-opts can forward Uvicorn 0.54.0 --ssl-keyfile/--ssl-certfile to the default Uvicorn server, not with --dev, --gunicorn-opts or --waitress-opts. Use a TLS proxy to 127.0.0.1:5000 or a tunnel/tailnet and set the allowed public hostname. - client-tls (REASONED; docs): Use MLFLOW_TRACKING_URI=https://mlflow.example.com and never enable MLFLOW_TRACKING_INSECURE_TLS in production. - auth-app (REASONED; docs): Install mlflow[auth] and run --app-name basic-auth; client username/password variables alone enable no server authentication. The September 2026 documentation has no experimental label. - csrf-key (REASONED; docs): Provision a long random MLFLOW_FLASK_SERVER_SECRET_KEY identical on every replica through a protected server.env file, mode 0600, with protected directories and no other-account ACL access; keep file and backups out of source control. - env-file (REASONED; docs, source): Global --env-file loads dotenv before the server command without overriding existing environment; clear inherited key/config-path variables and require a readable regular non-symlink file before launching. - key-exposure (REASONED; source, docs): Only the file path reaches the launch command; MLflow v3.16.1 loads the key into its environment and forwards it to workers, retaining same-account/root memory and inheriting-process environment exposure. - admin-bootstrap (REASONED; docs): No default admin password: first start requires at least 12 characters from MLFLOW_AUTH_ADMIN_PASSWORD or admin_password, rejects password1234 and fails without a password; an already-bootstrapped admin needs no resupply. - default-permission (REASONED; docs): default_permission is READ on every resource; set NO_PERMISSIONS in the auth configuration to remove that default grant. - auth-database (REASONED; docs): database_uri defaults to basic_auth.db in the working directory; use a central database for multiple nodes, as in the PostgreSQL auth-database example. - auth-config (REASONED; docs): MLFLOW_AUTH_CONFIG_PATH selects basic_auth.ini; authorization_function accepts module:function for custom authentication, while the shipped scheme is HTTP Basic. - password-rotation (REASONED; docs, curl): PATCH /api/2.0/mlflow/users/update-password takes username and password; the example feeds current and new passwords through curl config stdin with JSON/config escaping. - user-creation (REASONED; docs): User creation requires admin credentials at /signup or POST /api/2.0/mlflow/users/create; give humans individual accounts and CI a separate low-permission user. - client-credentials (REASONED; docs): ~/.mlflow/credentials stores passwords unencrypted; prefer runtime-injected MLFLOW_TRACKING_USERNAME/MLFLOW_TRACKING_PASSWORD. - rate-limit (REASONED; docs): The UI has no login-attempt limit and Basic auth is checked on every protected API request; rate-limit all authentication-bearing routes at the proxy and protect password transport with TLS. - mfa (REASONED; docs, cf-docs, oauth2-proxy-source, authelia-source): MFA: the cited MLflow material does not establish basic-auth MFA support; guide advice: front MLflow with Cloudflare Access, Authelia or oauth2-proxy per mfa.md and require MFA for human access. Cloudflare Access (pinned commit) and Authelia v4.39.28 document MFA-requiring policies, cited oauth2-proxy v7.15.6 passages do not, and none establishes MFA enforcement in a deployed MLflow combination. - proxy-token (REASONED; docs, source, cf-docs): Cited MLflow v3.16.1 request code sends MLFLOW_TRACKING_TOKEN as Authorization: Bearer only when no username and password select Basic, not in every auth mode. Cloudflare Access service tokens (pinned commit) use CF-Access-Client-Id and CF-Access-Client-Secret headers; guide advice: confirm the client and proxy keep both proxy and MLflow authentication, which the citations do not establish. - artifact-proxy (REASONED; docs): --serve-artifacts defaults on; --artifacts-destination s3://bucket lets the server proxy reads/writes for proxied experiments, holding storage credentials so clients need none. - artifact-existing (REASONED; docs): Pre-existing experiments and explicit direct artifact locations retain their locations outside tracking-server permission enforcement; inspect locations and apply storage IAM directly. - artifact-direct (REASONED; docs): With --no-serve-artifacts every client needs storage credentials and tracking-server permissions no longer gate artifacts; use environment or instance-role credentials, not repository/Compose secrets. - verify-bind (REASONED; source, iproute2): Read every listener and require 5000 on 127.0.0.1. - verify-network (REASONED; curl, docs): Outside :5000 must form no TCP connection: time_connect stays 0.000000 with a connection-level failure. A handshake or HTTP response means exposure; DNS/local socket errors are inconclusive. - verify-public-auth (REASONED; docs, curl): POST experiments/search with a minimal body: anonymous and wrong-password requests should return 401, valid admin 200; an open backend returns anonymous 200. Proxy credentials may also be needed; shell success is not a pass. - verify-backend-auth (REASONED; docs): Repeat anonymous 401/admin 200 directly on loopback with the public Host header that --allowed-hosts admits; an allowlist Host rejection is 403 before auth, and a proxy-only denial proves no backend enforcement. - verify-authorization (REASONED; docs): Search filters visible experiments and does not prove authorization denial. First GET a real experiment as admin for 200, then the same backend resource as a low-permission user for 403; 401 means bad credentials and a failed admin control is inconclusive. ````json { "schema": 1, "checked": "2026-11-06", "documentation_checked": "2026-09", "body_sha256": "772772a2da80d16cdaf3f8173ef1ec7e8a0f1bd4862124eddb23bbbaf3bde338", "components": { "source": { "name": "MLflow pinned source", "basis": "v3.16.1", "sources": { "sf7285d252e29": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/utils/cli_args.py#L162-L180", "sa3993a9b625f": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L369-L541", "sd775f10c4d07": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/auth/__init__.py#L6008-L6021", "s349a81b9478e": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L401-L402", "s99d30948902f": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L85-L86", "s687f1c9977cd": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L94-L105", "s1661739df39d": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/environment_variables.py#L493-L503", "s5801c6ac1ac2": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/utils/rest_utils.py#L254-L292", "s4a930b35f97e": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L454-L459", "s3df936d1e2ff": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L317-L325", "sea0054295867": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/cli/__init__.py#L602-L608", "sf711a9bb7d38": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L409-L411", "s25614af41a95": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L437-L441", "sa1678567912a": "https://github.com/mlflow/mlflow/blob/v3.16.1/mlflow/server/__init__.py#L303-L327" } }, "middleware-min": { "name": "MLflow security middleware minimum", "basis": "3.5.0", "sources": { "s2517159af609": "https://mlflow.org/docs/latest/self-hosting/architecture/tracking-server" } }, "docs": { "name": "MLflow documentation", "basis": "unknown", "sources": { "scbbc07258943": "https://mlflow.org/docs/latest/self-hosting/security/basic-http-auth/", "sc3a2d70cabf9": "https://mlflow.org/docs/latest/api_reference/auth/rest-api.html", "s5b79d1871e0b": "https://mlflow.org/docs/latest/api_reference/cli.html", "s2517159af609": "https://mlflow.org/docs/latest/self-hosting/architecture/tracking-server", "s996e587e947a": "https://mlflow.org/docs/latest/api_reference/rest-api.html", "s559e7a8271b9": "https://mlflow.org/docs/latest/api_reference/cli.html#mlflow" } }, "docker": { "name": "Docker Engine loopback publication minimum", "basis": "28.0.0", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } }, "nginx": { "name": "nginx proxy implementation", "basis": "release-1.28.0", "sources": { "s6dfa81e36717": "https://github.com/nginx/nginx/blob/release-1.28.0/src/http/modules/ngx_http_proxy_module.c#L305-L310" } }, "caddy": { "name": "Caddy reverse proxy parser", "basis": "v2.11.4", "sources": { "s52039756d4f4": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go#L40", "s9dde4a2bda4c": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/reverseproxy/caddyfile.go#L58-L62" } }, "cf-docs": { "name": "Cloudflare documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s6f6104362214": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/policies/mfa-requirements.mdx#L17-L85", "s7e793d13cf31": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/access-settings/independent-mfa.mdx#L15-L24", "sbec4996a7089": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/service-credentials/service-tokens.mdx#L33", "s543b1f2eb29c": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/service-credentials/service-tokens.mdx#L45-L49", "sc565f2d80e6e": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/cloudflare-one/access-controls/service-credentials/service-tokens.mdx#L59" } }, "oauth2-proxy-source": { "name": "oauth2-proxy documentation", "basis": "v7.15.6", "sources": { "s523e9ec2bd77": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L94-L108", "sa2319a734772": "https://github.com/oauth2-proxy/oauth2-proxy/blob/v7.15.6/docs/docs/configuration/overview.md#L305" } }, "authelia-source": { "name": "Authelia source", "basis": "v4.39.28", "sources": { "s768b165b44a0": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L506-L507", "sd4dd76e70475": "https://github.com/authelia/authelia/blob/v4.39.28/docs/content/configuration/security/access-control.md#L536-L537" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "iproute2": { "name": "iproute2 ss manual", "basis": "v6.12.0", "sources": { "s2afc4764d5bb": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L33-L34", "sfaa932c2ca13": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L43-L44", "sd295f82d643f": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L162-L163", "s65278ee45850": "https://github.com/iproute2/iproute2/blob/v6.12.0/man/man8/ss.8#L374-L375" } }, "uvicorn": { "name": "Uvicorn source", "basis": "0.54.0", "sources": { "s1a2858bce328": "https://github.com/encode/uvicorn/blob/0.54.0/docs/settings.md#L128-L134", "s6ad833b4b583": "https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/main.py#L310-L317" } } }, "claims": { "default-host": {"text": "v3.16.1 defaults to 127.0.0.1 unless MLFLOW_HOST overrides it; explicitly bind privately and do not treat --host as an authentication setting.", "components": ["source"], "sources": ["source:sf7285d252e29", "source:sa3993a9b625f"], "status": "REASONED"}, "default-port": {"text": "v3.16.1 serves the tracking UI and REST API on port 5000 unless MLFLOW_PORT overrides it.", "components": ["source"], "sources": ["source:sf7285d252e29", "source:sa3993a9b625f"], "status": "REASONED"}, "default-auth": {"text": "Authentication is opt-in; a reachable server without the auth app permits reading, changing and deleting tracking resources and proxied artifacts.", "components": ["docs"], "sources": ["docs:scbbc07258943", "docs:s2517159af609"], "status": "REASONED"}, "container-bind": {"text": "Normal bridge NAT requires host loopback publication 127.0.0.1:5000:5000 and --host 0.0.0.0 inside the container; container loopback cannot serve the publication. Before Docker 28.0.0, same-layer-2 hosts could reach loopback-published ports.", "components": ["docker", "docs"], "sources": ["docker:s1e53417c513d", "docs:s5b79d1871e0b"], "status": "REASONED"}, "middleware-scope": {"text": "Security middleware requires MLflow 3.5.0 or newer and the default Uvicorn server; --gunicorn-opts and --waitress-opts do not use it.", "components": ["middleware-min", "docs"], "sources": ["middleware-min:s2517159af609", "docs:s5b79d1871e0b", "docs:s2517159af609"], "status": "REASONED"}, "host-allowlist": {"text": "Set --allowed-hosts mlflow.example.com for access beyond loopback; defaults admit localhost, 127.0.0.1, [::1], 0.0.0.0 and private ranges as Host headers, not client-source firewall rules.", "components": ["docs"], "sources": ["docs:s5b79d1871e0b", "docs:s2517159af609"], "status": "REASONED"}, "cors": {"text": "Set --cors-allowed-origins https://mlflow.example.com for the intended browser origin.", "components": ["docs"], "sources": ["docs:s5b79d1871e0b", "docs:s2517159af609"], "status": "REASONED"}, "middleware-disable": {"text": "Do not use --disable-security-middleware outside a test.", "components": ["docs"], "sources": ["docs:s5b79d1871e0b", "docs:s2517159af609"], "status": "REASONED"}, "tls": {"text": "There are no dedicated server TLS flags; MLflow v3.16.1 --uvicorn-opts can forward Uvicorn 0.54.0 --ssl-keyfile/--ssl-certfile to the default Uvicorn server, not with --dev, --gunicorn-opts or --waitress-opts. Use a TLS proxy to 127.0.0.1:5000 or a tunnel/tailnet and set the allowed public hostname.", "components": ["docs", "source", "uvicorn", "nginx", "caddy"], "sources": ["docs:s5b79d1871e0b", "docs:s2517159af609", "source:s4a930b35f97e", "source:s3df936d1e2ff", "source:sea0054295867", "source:sf711a9bb7d38", "source:s25614af41a95", "source:sa1678567912a", "uvicorn:s1a2858bce328", "uvicorn:s6ad833b4b583", "nginx:s6dfa81e36717", "caddy:s52039756d4f4", "caddy:s9dde4a2bda4c"], "status": "REASONED"}, "client-tls": {"text": "Use MLFLOW_TRACKING_URI=https://mlflow.example.com and never enable MLFLOW_TRACKING_INSECURE_TLS in production.", "components": ["docs"], "sources": ["docs:s2517159af609"], "status": "REASONED"}, "auth-app": {"text": "Install mlflow[auth] and run --app-name basic-auth; client username/password variables alone enable no server authentication. The September 2026 documentation has no experimental label.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "csrf-key": {"text": "Provision a long random MLFLOW_FLASK_SERVER_SECRET_KEY identical on every replica through a protected server.env file, mode 0600, with protected directories and no other-account ACL access; keep file and backups out of source control.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "env-file": {"text": "Global --env-file loads dotenv before the server command without overriding existing environment; clear inherited key/config-path variables and require a readable regular non-symlink file before launching.", "components": ["docs", "source"], "sources": ["docs:s559e7a8271b9", "source:s99d30948902f", "source:s687f1c9977cd"], "status": "REASONED"}, "key-exposure": {"text": "Only the file path reaches the launch command; MLflow v3.16.1 loads the key into its environment and forwards it to workers, retaining same-account/root memory and inheriting-process environment exposure.", "components": ["source", "docs"], "sources": ["source:sd775f10c4d07", "source:s349a81b9478e", "docs:s559e7a8271b9", "source:s99d30948902f", "source:s687f1c9977cd"], "status": "REASONED"}, "admin-bootstrap": {"text": "No default admin password: first start requires at least 12 characters from MLFLOW_AUTH_ADMIN_PASSWORD or admin_password, rejects password1234 and fails without a password; an already-bootstrapped admin needs no resupply.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "default-permission": {"text": "default_permission is READ on every resource; set NO_PERMISSIONS in the auth configuration to remove that default grant.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "auth-database": {"text": "database_uri defaults to basic_auth.db in the working directory; use a central database for multiple nodes, as in the PostgreSQL auth-database example.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "auth-config": {"text": "MLFLOW_AUTH_CONFIG_PATH selects basic_auth.ini; authorization_function accepts module:function for custom authentication, while the shipped scheme is HTTP Basic.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "password-rotation": {"text": "PATCH /api/2.0/mlflow/users/update-password takes username and password; the example feeds current and new passwords through curl config stdin with JSON/config escaping.", "components": ["docs", "curl"], "sources": ["docs:sc3a2d70cabf9", "curl:s2b2686afaf41"], "status": "REASONED"}, "user-creation": {"text": "User creation requires admin credentials at /signup or POST /api/2.0/mlflow/users/create; give humans individual accounts and CI a separate low-permission user.", "components": ["docs"], "sources": ["docs:scbbc07258943", "docs:sc3a2d70cabf9"], "status": "REASONED"}, "client-credentials": {"text": "~/.mlflow/credentials stores passwords unencrypted; prefer runtime-injected MLFLOW_TRACKING_USERNAME/MLFLOW_TRACKING_PASSWORD.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "rate-limit": {"text": "The UI has no login-attempt limit and Basic auth is checked on every protected API request; rate-limit all authentication-bearing routes at the proxy and protect password transport with TLS.", "components": ["docs"], "sources": ["docs:scbbc07258943"], "status": "REASONED"}, "mfa": {"text": "MFA: the cited MLflow material does not establish basic-auth MFA support; guide advice: front MLflow with Cloudflare Access, Authelia or oauth2-proxy per mfa.md and require MFA for human access. Cloudflare Access (pinned commit) and Authelia v4.39.28 document MFA-requiring policies, cited oauth2-proxy v7.15.6 passages do not, and none establishes MFA enforcement in a deployed MLflow combination.", "components": ["docs", "cf-docs", "oauth2-proxy-source", "authelia-source"], "sources": ["docs:scbbc07258943", "cf-docs:s6f6104362214", "cf-docs:s7e793d13cf31", "oauth2-proxy-source:s523e9ec2bd77", "oauth2-proxy-source:sa2319a734772", "authelia-source:s768b165b44a0", "authelia-source:sd4dd76e70475"], "status": "REASONED"}, "proxy-token": {"text": "Cited MLflow v3.16.1 request code sends MLFLOW_TRACKING_TOKEN as Authorization: Bearer only when no username and password select Basic, not in every auth mode. Cloudflare Access service tokens (pinned commit) use CF-Access-Client-Id and CF-Access-Client-Secret headers; guide advice: confirm the client and proxy keep both proxy and MLflow authentication, which the citations do not establish.", "components": ["docs", "source", "cf-docs"], "sources": ["docs:s2517159af609", "source:s1661739df39d", "source:s5801c6ac1ac2", "cf-docs:sbec4996a7089", "cf-docs:s543b1f2eb29c", "cf-docs:sc565f2d80e6e"], "status": "REASONED"}, "artifact-proxy": {"text": "--serve-artifacts defaults on; --artifacts-destination s3://bucket lets the server proxy reads/writes for proxied experiments, holding storage credentials so clients need none.", "components": ["docs"], "sources": ["docs:s5b79d1871e0b", "docs:s2517159af609"], "status": "REASONED"}, "artifact-existing": {"text": "Pre-existing experiments and explicit direct artifact locations retain their locations outside tracking-server permission enforcement; inspect locations and apply storage IAM directly.", "components": ["docs"], "sources": ["docs:s2517159af609"], "status": "REASONED"}, "artifact-direct": {"text": "With --no-serve-artifacts every client needs storage credentials and tracking-server permissions no longer gate artifacts; use environment or instance-role credentials, not repository/Compose secrets.", "components": ["docs"], "sources": ["docs:s2517159af609"], "status": "REASONED"}, "verify-bind": {"text": "Read every listener and require 5000 on 127.0.0.1.", "components": ["source", "iproute2"], "sources": ["source:sf7285d252e29", "source:sa3993a9b625f", "iproute2:s2afc4764d5bb", "iproute2:sfaa932c2ca13", "iproute2:sd295f82d643f", "iproute2:s65278ee45850"], "status": "REASONED", "verify": [1]}, "verify-network": {"text": "Outside :5000 must form no TCP connection: time_connect stays 0.000000 with a connection-level failure. A handshake or HTTP response means exposure; DNS/local socket errors are inconclusive.", "components": ["curl", "docs"], "sources": ["curl:s2b2686afaf41", "docs:s5b79d1871e0b"], "status": "REASONED", "verify": [2]}, "verify-public-auth": {"text": "POST experiments/search with a minimal body: anonymous and wrong-password requests should return 401, valid admin 200; an open backend returns anonymous 200. Proxy credentials may also be needed; shell success is not a pass.", "components": ["docs", "curl"], "sources": ["docs:scbbc07258943", "docs:s996e587e947a", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-backend-auth": {"text": "Repeat anonymous 401/admin 200 directly on loopback with the public Host header that --allowed-hosts admits; an allowlist Host rejection is 403 before auth, and a proxy-only denial proves no backend enforcement.", "components": ["docs"], "sources": ["docs:scbbc07258943", "docs:s5b79d1871e0b", "docs:s996e587e947a"], "status": "REASONED", "verify": [1]}, "verify-authorization": {"text": "Search filters visible experiments and does not prove authorization denial. First GET a real experiment as admin for 200, then the same backend resource as a low-permission user for 403; 401 means bad credentials and a failed admin control is inconclusive.", "components": ["docs"], "sources": ["docs:scbbc07258943", "docs:s996e587e947a"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> agent-builders.md ====================================================================== # Agent and workflow builders: Dify, Flowise, Langflow, LibreChat Dify, Flowise, Langflow and LibreChat are tools for building AI chatbots, agents and workflows whose flows can run code and call other services. This guide helps your AI assistant configure each securely, including private binds behind TLS, default secrets replaced, MFA from OIDC or a fronting proxy, and contained flows. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/agent-builders.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Dify plugin daemon 0.6.10, Flowise account minimum v3.0.1, Langflow settings source v1.12.6, LibreChat changelog v0.7.7, Docker Compose go.mod v2.24.3, Docker Compose release source v2.24.4, compose-go loader v2.0.0-rc.2, and compose-go reset loader v2.0.0-rc.3; 8 further sources are pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-10-22; documentation checked September 2026. The guide records 81 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Each of these tools stores your provider API keys (OpenAI, Anthropic, and the rest) and exposes both an editor UI and callable APIs, so an open instance is a secrets vault plus free compute for whoever finds it. All four ship with login of some kind; the exposure comes from skipping the first-run setup, leaving default secrets in place, and publishing the container port on every interface over plain HTTP. And because each one runs user-authored flows with custom-code and HTTP-request/tool nodes, the editor is effectively code-execution and server-side-request authority on the host, so authentication decides who gets in but does not contain what a flow can then run or reach (section 8, [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). None of them offers a native second factor that this guide can rely on, so MFA comes from an OIDC provider (where the tool supports OIDC) or from the fronting layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). ## 1. Bind privately This guide's advice: Publish the container on loopback and let a proxy or tunnel be the only public listener ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). This belongs in the service's own Compose file. If you put it in an override file beside a vendor Compose file instead, it will not replace what that file publishes: an override `ports` list merges with the base list under the [pinned merge rule](https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L103-L115), so use the `!reset` form shown for Dify below ([pinned reset rule](https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L142-L151)). ```yaml ports: - "127.0.0.1:3000:3000" # Flowise (PORT defaults to 3000) - "127.0.0.1:7860:7860" # Langflow (LANGFLOW_PORT defaults to 7860 in 1.12.6 settings) - "127.0.0.1:3080:3080" # LibreChat (PORT defaults to 3080) ``` Those three also ship an official Compose file that publishes the app port on all interfaces by default (`${PORT}:${PORT}` for Flowise and LibreChat, `7860:7860` for Langflow), and LibreChat's is meant to be extended through a `docker-compose.override.yaml` rather than edited; LibreChat's file also publishes an `admin-panel` on `${ADMIN_PANEL_PORT:-3000}:3000`, so treat that mapping the same way. An override `ports` list merges with the base list. A loopback entry added beside a base `${PORT}:${PORT}` therefore leaves the base publication in place; move a port to loopback by replacing the list with `ports: !override ["127.0.0.1:3000:3000"]` (using the tool's own port), which supersedes the base (this guide's version assessment: require Docker Compose 2.24.4 or newer for both tags; check `docker compose version`. The pinned [v2.24.3 go.mod](https://github.com/docker/compose/blob/v2.24.3/go.mod#L9) uses compose-go rc.2, whose [loader](https://github.com/compose-spec/compose-go/blob/v2.0.0-rc.2/loader/null.go#L41-L46) handles `!reset`; the pinned [v2.24.4 go.mod](https://github.com/docker/compose/blob/v2.24.4/go.mod#L9) uses rc.3, whose [loader](https://github.com/compose-spec/compose-go/blob/v2.0.0-rc.3/loader/reset.go#L41-L50) handles both `!reset` and `!override`. The [v2.24.4 release note](https://github.com/docker/compose/releases/tag/v2.24.4) calls the rc.3 bump a fix for multiple-file merge issues. These files show what each tag's pinned loader handles, not that all later releases behave identically; the [pinned merge page](https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L184-L220) documents replacement with `!override`). The `!reset []` form used for Dify below does a different job: it clears a publication a service should not have at all, rather than moving one to loopback. Langflow's example Compose also publishes PostgreSQL on `5432:5432` beside the app; leave that backing store unpublished on the Compose network or loopback-scope it, as shown in [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md). Dify is different: its Compose file publishes nginx on `EXPOSE_NGINX_PORT=80` and `EXPOSE_NGINX_SSL_PORT=443` from `docker/.env`, plus the plugin daemon's `EXPOSE_PLUGIN_DEBUGGING_PORT=5003` (optional vector store profiles publish more). Do not hide a published port with the host firewall: Docker's NAT rules divert the traffic before it reaches the chains UFW uses, so a UFW deny on a published port does nothing ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). The plugin daemon's debugging port is only needed for remote plugin debugging. The checked Compose file pins `langgenius/dify-plugin-daemon:0.6.10-local` and unconditionally publishes `${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}`, with no host address by default, so Docker publishes port 5003 on every host interface. `EXPOSE_PLUGIN_DEBUGGING_HOST=localhost` does not restrict that bind; it only tells the plugin client where to connect. Remove the publication with an override file (see below). Leave the backend services unpublished on the Compose network, and make Dify's nginx the only service with a public port: either as the TLS edge (section 2) or on loopback (`EXPOSE_NGINX_PORT=127.0.0.1:8080`) behind your own proxy. The Compose file publishes `EXPOSE_NGINX_SSL_PORT` as well, and unconditionally, so give that the same host address too. Turning `NGINX_HTTPS_ENABLED` off does not help: it stops nginx serving TLS, it does not remove Docker's publication, which is the same trap as the plugin daemon's port above. The same shape appears in other AI-infrastructure deployments, where an authenticated front door sits beside a published backing store; [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) documents it. At daemon 0.6.10, this is a plain TCP channel carrying newline-delimited JSON, with no TLS. Compose maps `PLUGIN_DEBUGGING_HOST` (default `0.0.0.0`) and `PLUGIN_DEBUGGING_PORT` (default `5003`) to the daemon's `PLUGIN_REMOTE_INSTALLING_HOST` and `PLUGIN_REMOTE_INSTALLING_PORT`; these control the listener inside the container. Its handshake authenticates a per-tenant debugging key, generated with `uuid.New().String()` and stored in Redis as tenant-to-key and key-to-tenant mappings. Both mappings expire after two hours; retrieving an existing key returns the same key and refreshes both expiries, rather than rotating it. Expiry does not disconnect an already authenticated runtime. A wrong key gets `handshake failed, invalid key` and the connection closes. The key is checked by Redis lookup, not a constant-time secret comparison, and this TCP path has no rate limiter for key guesses. However, `onMessage` does not require a successful handshake before dispatching non-handshake messages: an unauthenticated connection can have plugin declarations parsed and stored in its runtime, and asset chunks buffered. The per-connection asset check rejects buffered decoded bytes plus the incoming chunk's base64 string length above `50 * 1024 * 1024` bytes (50 MiB); accepted chunks add their decoded length to the counter. The key gates registration into its tenant, not message processing on this listener, so an exposed port 5003 is not inert to a keyless client. A key holder can register a remote plugin runtime into the key's tenant by supplying its declarations and assets. When Dify invokes that runtime, it receives the tool parameters or model prompts and any credentials included in those requests, and supplies the results. Treat the key as authority to supply remotely executed plugin code to the tenant's workflows; the plugin code runs at the connector, and this channel alone does not demonstrate shell execution on the Dify host. Keep the channel private even though its handshake uses a key. Who can obtain a key: after instance setup, any initialized member with an active workspace and a valid console session can retrieve the current workspace's key, with the debugging host and port, through `GET /console/api/workspaces/current/plugin/debugging-key` at Dify commit `d39d9ddb7430522e0c828c6953afdf773ba6e675` under the defaults below. Enterprise RBAC (`RBAC_ENABLED`) defaults to false, which skips its check. The plugin-permission check then allows every member when the workspace has no plugin-permission record, and refuses only when the workspace's debug permission is set to nobody, or to admins for an account that is neither admin nor owner. The model's default `debug_permission` is `noone`, but it applies only once a row exists; with no row, everyone is allowed. By default, therefore, a member meeting those session and workspace prerequisites can retrieve the key and register a remote runtime. Where plugin debugging is needed, an admin or owner can set `debug_permission` to `admins` through the API `POST /console/api/workspaces/current/plugin/permission/change`, preserving the current `install_permission` in the request as well (omitting it defaults it to `everyone`). The server change endpoint requires `is_admin_or_owner`; the account model returns true for this property when `RBAC_ENABLED` is enabled, otherwise it requires a privileged role (admin or owner). This is an API remedy that the console UI code also carries at the same commit: `usePluginPermissionSettings` GETs `/workspaces/current/plugin/permission/fetch` typed as `Permissions`, which declares `install_permission` and `debug_permission`. This compile-time type does not prove the runtime fields. The console service layer writes both permissions through the same permission-change endpoint, with a payload type that requires `install_permission` and `debug_permission` together and allows `everyone`, `admins` or `noone` for each. The plugin settings-access hook returns the mutation without conditioning it on `canSetPermissions`; that flag and the others it returns are client-side UI flags, not server enforcement. Its `canSetPermissions` flag requires enterprise RBAC off plus the `plugin.plugin_preferences` workspace permission key. The same hook exposes `canSetPluginPreferences` from that key check without the RBAC-off condition. With enterprise RBAC enabled, the console UI's `canDebugPlugin` flag checks `plugin.debug`; with it disabled, it uses the legacy `debug_permission` path. With RBAC explicitly false, the legacy helper permits `PermissionType.everyone`, permits `PermissionType.admin` only when the hook's manager-or-owner flag is true, and otherwise refuses, including when the UI permission value is missing. A missing UI value is distinct from a missing database row: the permission-fetch endpoint returns `everyone` for both permissions when no row exists. The permission-key list labels `plugin.plugin_preferences` as "Configure auto updates"; it does not assign that key to roles. At the same Dify commit, the fallback workspace permission lists and role mapping in `rbac_service.py` include `plugin.plugin_preferences` for owner and admin, but not editor, normal or dataset_operator; `MyPermissions.get` uses the legacy snapshots when external RBAC is disabled (`RBAC_ENABLED=false`). In `category-marketplace-panel.tsx`, `CategoryMarketplacePanel` imports `usePluginSettingsAccess` and reads `canInstallPlugin`; when the panel is expanded and neither loading nor showing an installed-ID error, it renders `List` with `showInstallButton={canInstallPlugin}`. This establishes the install-button prop wiring, not a plugin-permission or plugin-preferences editor. From source reading at the same commit, the console's plugin-permission editor is `PermissionQuickPanel`, which offers the `everyone`, `admins` and `noone` permission values for `install_permission` and `debug_permission`. `IntegrationSidebarUtilityActions` renders it in a popover only when `showPermissionQuickPanel` and the fetched permission are both set, and `useIntegrationPermissions` sets that flag to `canSetPermissions && !!permission`; because `canSetPermissions` requires enterprise RBAC off, this code does not render the permission editor with enterprise RBAC enabled. `IntegrationsPage` renders `IntegrationSidebarUtilityActions` when `canDebugger` or `showPermissionQuickPanel` is true, passing a change handler that copies the fetched permissions and replaces only the edited key. The plugin auto-update preferences control is `UpdateSettingDialog`, the plugin auto-update settings dialog, which reads the auto-upgrade settings and, from its Save button's handler, saves through a mutation that posts to `/workspaces/current/plugin/auto-upgrade/change`. `DataSourcePage` and `ModelProviderPage` render it only when `canSetPluginPreferences` is true. `IntegrationsPage` builds it as the plugin-category toolbar action only when `showPluginCategorySetting && pluginSettingCategory` is truthy. That condition rests on `useIntegrationPermissions`, which sets `showPluginCategorySetting` on the `builtin`, `trigger`, `agent-strategy` and `extension` sections with `canSetPluginPreferences` true, and `getPluginCategoryBySection` returns a category for those same four sections. `IntegrationsPage` passes that action to `IntegrationSectionRenderer` as `pluginCategoryToolbarAction` in both of its layouts, and `ProviderList`, which the renderer uses for the `mcp`, `custom-tool` and `workflow-tool` sections, declares no such prop. From source reading of `section-renderer.tsx`, the renderer passes the action on only to `PluginCategoryPage`, which it renders for the same four sections, and `PluginCategoryPage` hands it to `PluginsPanel`, which places it in its toolbar as `FilterManagement`'s right-hand slot, shown when the action is set. On the `builtin` section the action's wrapper carries the step-by-step tour's update-settings target, and the tour's `step-by-step-tour/mount.tsx` uses `plugin.plugin_preferences` only to drop guide steps aimed at that target when the key is absent. A second hook, `useWorkspacePluginInstallPermission` in `install-plugin/hooks/use-workspace-plugin-install-permission.ts`, also computes `canSetPluginPreferences` from that key with no RBAC-off condition; the consumer search below found that none of its 11 non-test consumers reads that flag. `ToolProviderToolbar` renders its own `UpdateSettingDialog` only when passed no toolbar action and `showToolsUpdateSetting`, which `ProviderList` sets for the built-in tab with `canSetPluginPreferences` true, but `IntegrationSectionRenderer` renders `ProviderList` only for the `mcp`, `custom-tool` and `workflow-tool` sections, so no route reaching that built-in tab was found there. The same hook flag also gates `ModelProviderPage`'s system model selector, which renders nothing without it, and provider cards gate their show-models buttons, model-list query and expanded model list on a local `canSetPluginPreferences` computed from the same `plugin.plugin_preferences` key. `ModelProviderPage` also passes that flag as `enabled` to its five default-model queries and requires it for its not-configured warning. This guide's assessment: without the key, these gates hide the workspace's default models and each provider's model list, which are reads. From source reading, the gated components also write: `SystemModelSelector` saves all five default models by posting to `/workspaces/current/default-model`, and `ModelList`'s rows switch a model on or off through the provider's `models/enable` and `models/disable` routes. Both also check the `plugin.model_config` key: without it the selector's Save button is disabled, and `ModelList` hides its custom-model actions and, on rows for models that are not deprecated, the switch and load-balancing control. In this guide's assessment both therefore count as plugin-preferences controls, since the server checks the default-model, enable and disable routes against `PLUGIN_PREFERENCES` (below). On the server, the `api` search below finds checks naming `RBACPermission.PLUGIN_PREFERENCES` on the auto-upgrade change and exclude routes in `plugin.py` and also on routes in `models.py` and `trigger_providers.py`. From source reading, `rbac_permission_required` is defined in `controllers/common/wraps.py`, which the Sources cite for the debugging-key route, and `controllers.console.wraps` imports and re-exports it; it calls the view directly when `RBAC_ENABLED` is false and otherwise enforces the checks. With RBAC off, the `models.py` default-model save and model save and delete routes also require `is_admin_or_owner_required` (owner or admin), and its model enable and disable routes have no role decorator or body check. In `trigger_providers.py`, seven of its `PLUGIN_PREFERENCES` routes also require `edit_permission_required` (owner, admin or editor) and four, the subscription delete and the three OAuth-client routes, `is_admin_or_owner_required`. From source reading of `plugin.py` at the same commit, both routes apply `rbac_permission_required` with that permission after the setup, login and account-initialization decorators, and the change handler then raises `Forbidden` when `RBAC_ENABLED` is false and the account is neither admin nor owner. The exclude handler has no check in its body, so, in this guide's assessment, with RBAC off nothing in `plugin.py` limits that route to admins or owners, and with RBAC enabled the decorator is the only permission check that `plugin.py` applies to either route. `plugin.py` imports that decorator from `controllers.console.wraps`, so, in this guide's assessment, with RBAC off the exclude route admits any account that passes the setup, login and account-initialization decorators, as do the model enable and disable routes unless the model service, which was not read, refuses. The search `git grep -n -E "update-setting-dialog'|permission-quick-panel'" -- web ':!*__tests__*' ':!*.spec.*'`, run on a clean checkout of the pinned commit, found lines that contain those import-path patterns and name `UpdateSettingDialog` only in `IntegrationsPage`, `ToolProviderToolbar`, `DataSourcePage` and `ModelProviderPage`, and `PermissionQuickPanel` only in `sidebar-actions.tsx`. That search's other two hits name only the type `PermissionSettingKey`, in `sidebar-actions.tsx` and `useIntegrationPermissions`. That search excludes `__tests__` directories and `.spec.` files and does not cover other spellings or constructed module paths, other access forms such as re-exports or dynamic imports, code outside this repository, or other releases. The search `git grep -n -E "setPluginPermissionSettings|setReferenceSettings" -- web ':!*__tests__*' ':!*.spec.*'` on the same checkout found, outside the hooks that define them, only `useIntegrationPermissions` using the settings-access hook's `setPluginPermissionSettings` and no use of `useReferenceSetting`'s `setReferenceSettings`. That search excludes the same test files and does not cover uses that never spell either name, such as passing a hook's whole returned object on, code outside this repository, or other releases. A search of the pinned source archive's `web` tree with the authoring environment's ripgrep-based search tool, for the pattern `useWorkspacePluginInstallPermission|use-workspace-plugin-install-permission`, found 11 non-test files importing that hook, each destructuring only some of `canInstallPlugin`, `canUpdatePlugin` and `currentDifyVersion` from it. Hits in `__tests__` directories and `.spec.` files were set aside; the search was not a `git grep` of a checkout and does not cover other spellings, constructed module paths, re-exports, hidden paths, files the tool's ignore rules may skip, code outside this repository or other releases. From source reading of `update-plugin/from-market-place.tsx`, the exclude-and-download handler awaits the exclude mutation only when the dialog has a `pluginId`. Inference: it reaches the confirm handler when no `pluginId` is supplied or the awaited mutation resolves; rejection prevents that call, which starts the update if it has not started. The dialog passes that handler only to `DowngradeWarningModal`, as `onExcludeAndDowngrade`, and renders that modal only while `isShowDowngradeWarningModal` is set and the update has not started; the dialog reads no permission flag. `DowngradeWarningModal` shows that handler as its exclude button, beside cancel and plain-downgrade buttons. Of the dialog's two non-test importers in the pinned tree, `UpdatePlugin` passes neither a `pluginId` nor the warning flag, and the plugin detail header's `HeaderModals` passes the plugin's ID and sets the flag to `isDowngrade && isAutoUpgradeEnabled`, so, within that search, only the detail header offers the action. There `isAutoUpgradeEnabled` requires an enabled marketplace, a marketplace plugin and an auto-upgrade strategy that is not disabled and covers the plugin, and the header's update handler first requires `canUpdatePlugin`. On the server, the exclude service adds the plugin to its category's exclude list, removes it from the include list, or switches an update-all strategy to exclude only it, creating a fix-only exclude strategy if none exists, so it writes the same auto-upgrade strategy record as the change route. This guide's assessment is that the exclude action is a plugin-preferences change, since it edits that record and the server checks its exclude route against the same `PLUGIN_PREFERENCES` permission as the auto-upgrade change route. With `RBAC_ENABLED` true, `MyPermissions.get` requests `/rbac/my-permissions` through `EnterpriseRequest.send_inner_rbac_request`, which targets `ENTERPRISE_RBAC_API_URL` (falling back to `ENTERPRISE_API_URL`) with an `Enterprise-Api-Secret-Key` header and which the code says calls dify-enterprise, and the workspace permission keys come from that response. In the same file, `Roles.list` reads enterprise role definitions, each with its `permission_keys`, from `/rbac/roles` through the same inner call, and only the legacy owner and admin lists assign `plugin.plugin_preferences`; in this guide's assessment, enterprise role assignments for the key are therefore held outside this repository. The search `git grep -n -e plugin_preferences -e PLUGIN_PREFERENCES -- api ':!api/tests'` on the same checkout found 21 hits: the `RBACPermission.PLUGIN_PREFERENCES` enum member, 18 route checks (5 in `models.py`, 2 in `plugin.py` and 11 in `trigger_providers.py`) and the legacy owner and admin fallback lists, and no other role list assigning the key. That search excludes `api/tests` and does not cover other spellings or constructed keys, other access forms such as iterating the enum, role data outside this repository (such as langgenius/rbac or dify-enterprise), or other releases. A source comment above those lists says to keep them aligned with langgenius/rbac's built-in workspace roles, which does not establish which roles hold the key under enterprise RBAC. This guide's assessment: these cited files do not establish role assignments under external or enterprise RBAC, which plugin-permission or plugin-preferences controls a given role sees in a running console, or any other rendering location beyond the reported import search; the rendering conditions above are client-side conditions read from source, not observed in a running console. This guide's advice remains to make the permission change through the API call when debugging is needed. Otherwise disable the listener as described below. Set `PLUGIN_REMOTE_INSTALLING_ENABLED=false` in the `plugin_daemon` container environment when debugging is unused. This setting defaults to `true`; `false` prevents the TCP debugging server from starting and removes the daemon's debugging-key API route. It does not remove Docker's port publication. The override below disables the server and removes the publication together. ```yaml # docker-compose.override.yaml, beside docker-compose.yaml; plain `docker compose up -d` picks it up services: plugin_daemon: environment: PLUGIN_REMOTE_INSTALLING_ENABLED: "false" ports: !reset [] # an ordinary ports list would merge with the base file's list, not replace it ``` **REASONED, not demonstrated:** the authoring host forbids opening listeners without an isolated network namespace, and has none; Docker is unavailable. Confirm the result against the merged model with `docker compose config`: without the override, expect the base host publication on port 5003 and no `PLUGIN_REMOTE_INSTALLING_ENABLED` entry (the daemon defaults it to `true`); with the override, expect `PLUGIN_REMOTE_INSTALLING_ENABLED: "false"` and no published port for `plugin_daemon`. Recreate the service with `docker compose up -d --force-recreate plugin_daemon`: the default starts the TCP listener, whereas `false` prevents its startup and removes the daemon debugging-key route. The switch alone leaves the Compose publication in place. These expectations follow the pinned Compose file, daemon startup and route conditions, and Docker [reset semantics](https://docs.docker.com/reference/compose-file/merge/#reset-value), cited in Sources. ## 2. TLS Flowise and LibreChat document no TLS of their own; their deployment guides put nginx with certbot in front (`proxy_pass http://localhost:3000` and `http://localhost:3080` respectively). Use [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) or [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) with a certificate from [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), or [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) / [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) with no public port at all. Set `NUMBER_OF_PROXIES` (Flowise) and `TRUST_PROXY` (LibreChat, default `1`) to the number of proxy hops so rate limiting sees client addresses. Langflow 1.12.6 settings define `LANGFLOW_SSL_CERT_FILE` and `LANGFLOW_SSL_KEY_FILE`, which its server passes to Gunicorn on Linux and other platforms, and to Uvicorn on Windows and macOS for native TLS; a fronting proxy remains the simpler place to add login and MFA (this guide's assessment). Behind HTTPS, also set `LANGFLOW_ACCESS_SECURE=true` and `LANGFLOW_REFRESH_SECURE=true`: both default to `false` in 1.12.6, so the access and refresh session cookies are issued without the `Secure` flag until you do. Dify's bundled nginx can terminate TLS. In `docker/.env`, per the certbot README in the Dify repository: set `NGINX_ENABLE_CERTBOT_CHALLENGE=true`, `CERTBOT_DOMAIN`, `CERTBOT_EMAIL`, `NGINX_SSL_CERT_FILENAME=fullchain.pem`, `NGINX_SSL_CERT_KEY_FILENAME=privkey.pem`. The Dify certbot README then gives the next steps: run `docker compose --profile certbot up --force-recreate -d` and `docker compose exec -it certbot /bin/sh /update-cert.sh`; then, in `docker/.env`, set `NGINX_HTTPS_ENABLED=true` (default `false`) and recreate nginx with `docker compose --profile certbot up -d --no-deps --force-recreate nginx`. `NGINX_SSL_PROTOCOLS` defaults to `TLSv1.2 TLSv1.3`. Set `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, and `APP_WEB_URL` to the public `https://` URLs; per the Dify reference, `CONSOLE_API_URL` decides whether cookies are marked HTTPS-only. ## 3. Dify ```bash cd dify/docker && cp .env.example .env # edit .env now: INIT_PASSWORD, SECRET_KEY, the default service credentials (next bullet), the EXPOSE_* bindings (section 1), the public URLs (section 2) docker compose up -d ``` - `INIT_PASSWORD=REPLACE_WITH_LONG_RANDOM_VALUE` goes into `.env` before the first `up`. It is empty by default; when set, the `/install` page demands it before anyone can create the admin account. Once the stack is up, open `https://dify.example.com/install` yourself, immediately. - Set `SECRET_KEY` from `openssl rand -base64 42`. It signs session cookies and JWTs and encrypts stored OAuth credentials (left empty, Dify auto-generates one in its storage directory, per `.env.example`). - Console self-registration is off by default (`ALLOW_REGISTER=false`); leave it false for an invite-only console and add members by invitation. This governs ordinary self-registration AFTER setup: workspace invitations and the `/install` setup wizard always work regardless, so the protection for an unclaimed install is still `INIT_PASSWORD` plus claiming `/install` privately (above), not this switch. - Replace the shipped SERVICE credentials in `.env`, not just `SECRET_KEY`: `docker/.env.example` ships working defaults that authenticate Dify's internal services, and `SECRET_KEY` (empty, auto-generated) does not cover them. Change `DB_PASSWORD` and `REDIS_PASSWORD` (both `difyai123456`; `CELERY_BROKER_URL` embeds the Redis password, so update it in lockstep), the sandbox pair `CODE_EXECUTION_API_KEY`/`SANDBOX_API_KEY` (both `dify-sandbox`, keep them equal), `PLUGIN_DAEMON_KEY` and `PLUGIN_DIFY_INNER_API_KEY`. The agent keys `DIFY_AGENT_API_TOKEN` and `DIFY_AGENT_SERVER_SECRET_KEY` need the same change (the last one's known default lets anyone forge agent tokens; replace it with unpadded base64url of 32 random bytes). If you enable the Weaviate vector store, also rotate `WEAVIATE_API_KEY` together with `WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS` and turn off `WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED`. - App API keys are created inside each app and sent as `Authorization: Bearer ` to the service API. They are not console accounts, so tightening console login does nothing for a leaked key. Dify's guidance: call the API from your backend only; a key in frontend code can be extracted. ## 4. Flowise - From v3.0.1 onwards Flowise uses email-and-password accounts with JWTs in HTTP-only cookies. `FLOWISE_USERNAME` / `FLOWISE_PASSWORD` are documented as deprecated; the docs use them only to migrate an older instance into a new admin account. Register the admin account before exposing the instance. - Set random values for `JWT_AUTH_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET`, `EXPRESS_SESSION_SECRET` (default `flowise`), and `TOKEN_HASH_SECRET`; set `APP_URL` to the public URL (default `http://localhost:3000`). `FLOWISE_SECRETKEY_OVERWRITE` sets the key that encrypts stored credentials; without it the key lives in a file under `SECRETKEY_PATH`. - Prediction endpoints: a chatflow with no API key assigned is public to anyone who knows the chatflow ID. Create keys under **API Keys** (a `DefaultKey` is pre-created), assign one per chatflow, and clients send `Authorization: Bearer `; the prediction API answers `401` without it. ## 5. Langflow ``` LANGFLOW_AUTO_LOGIN=false LANGFLOW_SUPERUSER=REPLACE_WITH_ADMIN_USERNAME LANGFLOW_SUPERUSER_PASSWORD=REPLACE_WITH_LONG_RANDOM_VALUE LANGFLOW_SECRET_KEY=REPLACE_WITH_LONG_RANDOM_VALUE ``` - `LANGFLOW_AUTO_LOGIN` defaults to `True` in the Langflow 1.12.6 application (the pinned official image Dockerfiles set it to `false`), which bypasses authentication; set it to `false` explicitly. The superuser password defaults to empty and is then required; set it to a long random value other than the rejected legacy default `langflow`. The username defaults to `langflow`. - Generate the key with `python3 -c "from secrets import token_urlsafe; print(f'LANGFLOW_SECRET_KEY={token_urlsafe(32)}')"`. An auto-generated key is documented as unsuitable for production. - `LANGFLOW_NEW_USER_IS_ACTIVE` defaults to `False`, so a new account waits for superuser activation, but public signup is still OPEN by default (`ENABLE_SIGNUP` is `True`), letting inactive accounts accumulate; set `LANGFLOW_ENABLE_SIGNUP=false` to close registration outright and keep `LANGFLOW_NEW_USER_IS_ACTIVE=False` as the backstop. - With auto-login off, API calls (`POST /api/v1/run/`) need a Langflow API key in the `x-api-key` header, created under **Settings > Langflow API Keys** or with `langflow api-key`. `LANGFLOW_SKIP_AUTH_AUTO_LOGIN` (default `false`) only applies when auto-login is on and is slated for removal; leave it alone. - `LANGFLOW_HOST` defaults to `localhost`, which is right only when Langflow runs directly on the host behind a same-host proxy; INSIDE a bridged Docker container `localhost` binds the container's own loopback, so a host proxy hitting the published port reaches nothing. In a container set `LANGFLOW_HOST=0.0.0.0` and publish to host loopback (`127.0.0.1:7860:7860`, section 1). These `LANGFLOW_*` settings must also be injected into the container through Compose (`env_file:` or `environment:`), not merely written to a `.env` the app never reads. Version note: checked against Langflow 1.12.6 settings source; the container and Compose instructions are this guide's assessment, and the rolling documentation remains unversioned. ## 6. LibreChat - The first registered account becomes the admin. Register it, then set `ALLOW_REGISTRATION=false` so nobody else can create an email account. For SSO-only operation, also set `ALLOW_EMAIL_LOGIN=false` and enable `ALLOW_SOCIAL_REGISTRATION=true` deliberately, with the provider's allowlist deciding who may exist. - `ALLOW_SOCIAL_LOGIN=true` enables the OAuth2 providers (Apple, Discord, Facebook, GitHub, Google) and OIDC through `OPENID_ISSUER`, `OPENID_CLIENT_ID`, `OPENID_CLIENT_SECRET`, `OPENID_SESSION_SECRET`, `OPENID_SCOPE="openid profile email"`, `OPENID_CALLBACK_URL=/oauth/openid/callback`, optionally `OPENID_REQUIRED_ROLE`. The docs cover Keycloak, Authentik, Authelia, Auth0, Cognito, and Entra; with OIDC in place, set `ALLOW_EMAIL_LOGIN=false` and enforce MFA at the provider ([identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md), [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md)). - `CREDS_KEY` is a 32-byte key (64 hexadecimal characters) and `CREDS_IV` a 16-byte IV (32 hexadecimal characters); `JWT_SECRET` and `JWT_REFRESH_SECRET` are unique random values of at least 32 bytes each. The docs point to the Credentials Generator. The current `.env.example` leaves these blank (a missing value makes LibreChat bootstrap a random key, persisted only if it can write and keep its credentials file, otherwise process-local and lost on restart). LibreChat refuses to start if you paste a retired published `JWT_SECRET`/`JWT_REFRESH_SECRET` default; set your own permanent production values so the secrets are known, backed up, and stable across restarts. - Set `DOMAIN_CLIENT` and `DOMAIN_SERVER` to the public `https://` URL. TLS comes from the fronting proxy (section 2). - The v0.7.7 changelog lists two-factor authentication with backup codes and QR enrolment, but the authentication documentation we checked does not describe it, so do not count on it as the enforced control; MFA at the OIDC provider is the documented path. ## 7. MFA and stored secrets None of the four documents instance-wide MFA enforcement. Where OIDC exists (LibreChat), enforce MFA at the provider; for Dify, Flowise, and Langflow put the editor behind Cloudflare Access or an identity layer per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Flowise's native SSO is Enterprise-plan only and Langflow documents external JWT/JWKS identity integration, but on the open-source editions the fronting layer is the dependable path. Every provider key pasted into these tools is a secret held by the tool; rotate any key that lived on an instance that was ever open ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ## 8. Contain what the builder can execute and reach Authentication decides who gets in; it does not limit what a flow does once inside. All four run custom code and outbound HTTP from the flow itself, so treat the editor as code-execution and SSRF authority and contain it at the infrastructure layer, not with login alone: - **Isolate the runtime**: one container or VM per builder, a non-root user, a read-only root filesystem where the tool supports it, and no host mount it does not need ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). - **Default-deny egress**: permit only DNS and the provider APIs the flows actually call, and block the cloud metadata address so a flow cannot mint the instance's cloud credentials ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). - **Keep each tool's own guards on**: Flowise's `HTTP_SECURITY_CHECK` and `CUSTOM_MCP_SECURITY_CHECK` (the docs warn that disabling the MCP check "allows arbitrary command execution"); LibreChat Actions' domain allowlist, which you must CONFIGURE (`actions.allowedDomains` in `librechat.yaml`); Dify's SSRF proxy in front of the code-sandbox and HTTP-request nodes. LibreChat Actions with `actions.allowedDomains` left unset block private targets through built-in SSRF checks but allow every other domain, and only once you set the allowlist are unlisted domains denied (listing a private destination grants it an exception). An HTTP allowlist is not containment of an arbitrary local tool, so pair it with the egress and isolation controls above. ## Verify REASONED: following block; listener inventory, Dify publication and TCP reachability, editor/setup state, proxy transport and application authentication follow the cited Compose/daemon sources, Dify setup and API-key documentation, Flowise app/chatflow authentication, Langflow authentication and LibreChat authentication references. No container runtime, isolated network namespace, deployed editors, application keys or test identities are available. **REASONED Dify publication and port 5003 checks, not demonstrated:** the authoring host forbids opening listeners without an isolated network namespace, and has none; Docker is unavailable. With the default listener enabled and published, expect a successful outside TCP connection; after the section 1 override and recreation, expect no host publication and a refusal or timeout, with a successful same-host positive control as described below. These outcomes follow the pinned Compose publication and daemon startup condition, Docker [reset semantics](https://docs.docker.com/reference/compose-file/merge/#reset-value), and the [Publishers fields](https://docs.docker.com/reference/cli/docker/compose/ps/). The port 5003 probe tests TCP reachability, not debugging-key authentication. A successful connection proves exposure even if a later handshake would reject a key. Disabling the listener alone also cannot prove that Docker's publication was removed; check the merged Compose model and Publishers entries as well. The builder transport-reachability curl takes the builder's host name on a guarded `set --` line and refuses example.com, example.net, example.org and their subdomains; no live builder has answered it. The checks are now four fences, each ending with its own exit status: the host inventory (which refuses with status 2 when `ss` is not installed), the outside TCP check of port 5003 with a positive control on a port you name, the builder transport note and the Dify key pair. The gate suite's bash -n and ShellCheck 0.11.0 passes cover their current form, and so do stub runs, with no network reached, in which each guard refusal exited 2, except that for the builder transport note those runs covered its earlier form, which lowercased the host with `tr` before refusing reserved names. The transport note's current form, which matches reserved names in any letter case through bash's `nocasematch` option instead of `tr`, is covered by stub runs on 2026-10-31 with curl stubs and no network. Those runs refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case, each with no curl call, also with `tr` absent from PATH and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and did not refuse a host name outside those domains. Under dash, which lacks bash's `shopt`, stub runs of the transport note's current form refused reserved and other host names alike with status 2 and its message that the block needs bash, with no curl call. ```bash ( # the whole block in one subshell, so one exit status covers every part of it command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 # the block's status so far: 3 (a command failed, inconclusive) outranks 0 ss -tlnp || # read the whole list: app ports on 127.0.0.1; 80/443 # public only where Dify's own nginx is the TLS edge. # A container port published by DNAT need not appear # here at all, so this list cannot clear 5003 by itself { echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; set -- 3; } # REASONED Dify publication check: the authoring host forbids opening listeners without an isolated # network namespace, and has none; Docker is unavailable. Base: expect PublishedPort 5003 on # plugin_daemon; after !reset and recreation: no host mapping. See the Compose and ps sources. docker compose ps --format json || # run in dify/docker: no Publishers entry on the # plugin_daemon service may map it to a host port. # Read the entries rather than the array's length: # a merely exposed container port can appear too. A grep for # "published" cannot say which service published it { echo "docker compose ps exit $?: the Compose service list failed (run it in dify/docker); inconclusive"; set -- 3; } echo "block exit $1; unclassified observation: read every listener and every Publishers entry above yourself, because this block classifies neither" exit "$1" ) ``` ```bash # REASONED: Dify TCP check: the authoring host forbids opening listeners without an isolated network # namespace, and has none; Docker is unavailable. Default enabled/published: expect connection success; # section 1 override and recreation: refusal/timeout with the positive control succeeding. # See the pinned Compose publication and daemon startup sources. # Run this block from an outside network. Give it your address and a port you know is open there, such as 443 # where your TLS edge is this address. It exits 1 if port 5003 accepts a connection, 3 if that positive control # fails or nc fails on 5003 with no output, and otherwise 0, leaving nc's 5003 line to you. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' 'REPLACE_WITH_AN_OPEN_PORT' # your address, then a port you know is open on it: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*[!0123456789]*) echo "substitute a port you know is open on your address, in digits, as the second value on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if nc -vz -w 3 "$1" 5003 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # from an outside network, and the authority here: # EXPOSE_PLUGIN_DEBUGGING_PORT can move it, so the ps output above is the authority on which port to probe set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # your address, the control port, nc's exit status, then what it printed printf '%s' "$4" # a refusal or timeout from YOUR address is the pass ONLY if your packets reach the host at all: as a # positive control, this block runs nc -vz -w 3 against the same address on the port you named as open # (443 only where your TLS edge is this address), which must succeed. If the control also times out the path is filtered and the 5003 result is # inconclusive (the port could still be open from another network). A local error, an unsupported # option (BusyBox netcat rejects -v), or exit 1 with no output is inconclusive: nothing reached the network set -- "$1" "$2" "$3" "$4" "$(if nc -vz -w 3 "$1" "$2" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # then the positive control's exit status and what it printed printf '%s' "$6" case "$3:$5" in 0:*) echo "FINDING: port 5003 accepted a TCP connection from here, which proves exposure; block exit 1"; exit 1 ;; *:0) ;; *) echo "nc exit $5 on the port $2 positive control, so the path may be filtered and the 5003 result is inconclusive; block exit 3"; exit 3 ;; esac if [ -n "$4" ]; then echo "block exit 0; the 5003 line above passes only if it reports a refusal or timeout reaching your address, so read it yourself" exit 0 fi echo "nc exit $3 with no output for 5003: nothing reached the network; inconclusive; block exit 3" exit 3 ) ``` ```bash # REASONED: builder transport reachability follows the cited Dify setup, Flowise, Langflow and LibreChat # authentication references; no deployed editors are available here. # The curls below observe TRANSPORT and status; they cannot by themselves prove APPLICATION auth, # because a reverse proxy in front of the app can return the same codes (or forward to an # unauthenticated backend once its own check passes), and curl follows HTTP redirects but does not run a # SPA's client-side login routing. Attribute an app-layer result by probing the BACKEND directly on its # loopback address (section 1; the separate fronting proxy is out of the path, but see the Dify nginx caveat below) and confirm the editor/setup state in a browser. # Each curl disables client proxies (--noproxy '*'), is bounded, -g-guards substituted values, and # prints the exit code. Run each before and after locking down. # # Editor/console and Dify /install: confirm in a FRESH, UNAUTHENTICATED BROWSER SESSION, not by status # code - all four are SPAs serving the same 200 + HTML shell logged in or not, so a curl of / cannot # tell an open editor from a login page and it discards the /install form you must read. In the browser, # an unauthenticated editor visit must land on a login route, and /install on your Dify host must # show "already set up" or redirect to login, NEVER an open create-admin form (an open form means the # first visitor owns the instance). The curl below is only a transport-reachability note, so this # block exits 3 if it fails and otherwise 0: ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_BUILDER_HOST' # the builder's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again if curl -q -g -sS -L --proto-redir '=https' --noproxy '*' --connect-timeout 5 --max-time 15 \ -o /dev/null -w 'builder final=%{http_code} url=%{url_effective} exit=%{exitcode}\n' "https://$1/"; then echo "block exit 0; unclassified observation: the final code and URL are a transport note only, so judge the editor and /install in a fresh, unauthenticated browser session yourself" exit 0 else echo "curl exit $?: the builder did not answer over HTTPS; inconclusive; block exit 3" exit 3 fi ) ``` ```bash # REASONED: application API authentication follows the cited Dify API-key, Flowise and Langflow # authentication references; no deployed apps or application keys are available here. # App API auth, tested against the app on its LOOPBACK address so your fronting reverse proxy (section 2) # is out of the path, one MATCHED pair per app: WITHOUT the key expect that app's OWN rejection, WITH a # valid key expect a 2xx returning real output (the positive control). Note for Dify: 127.0.0.1:8080 is # Dify's OWN bundled nginx, which forwards /v1 to the api service - part of Dify, not a separate fronting # proxy, but to attribute a result strictly to the api service, probe it on the Compose network instead # (docker compose exec api curl http://localhost:5001/v1/parameters ...). The pair below shows the body # so you can read the evidence, and the guard refuses while a placeholder remains. WITHOUT the key Dify # returns 401 with a message like "Authorization header must be provided and start with 'Bearer'"; a 400 # app_unavailable instead means the app is unavailable or misconfigured (the token identifies the app), # not that the endpoint is open. WITH a valid app key, 200 returning the app's parameters JSON. # This block exits 1 for a 2xx without the key, 0 for a 401 without the key and a 2xx with it, and # otherwise 3. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The app key you substitute on the set -- line enters shell history. # Use a short-lived key or clear that history line afterward. # Stdin closes the argv channel only: it does not hide the key from set -x # tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DIFY_APP_KEY' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the Dify app key on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sS -w '\ndify no-key=%{http_code} exit=%{exitcode}\n' --noproxy '*' --connect-timeout 5 --max-time 15 http://127.0.0.1:8080/v1/parameters; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the key, curl's exit status, then the body and its no-key= line printf '%s' "$3" set -- "$1" "$2" "${3##*no-key=}" # the key, curl's exit status, then the text after no-key= set -- "$1" "$2" "${3%% *}" # the key, curl's exit status, then the HTTP code set -- "$1" "$2" "$3" "$(if printf 'Authorization: Bearer %s\n' "$1" | curl -q -g -sS -w '\ndify with-key=%{http_code} exit=%{exitcode}\n' --noproxy '*' --connect-timeout 5 --max-time 15 -H @- http://127.0.0.1:8080/v1/parameters; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # then curl's exit status with the key, and the body and its with-key= line printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "${5##*with-key=}" set -- "$1" "$2" "$3" "$4" "${5%% *}" # the key, then the exit status and HTTP code without the key and with it case "$2:$3" in 0:2??) echo "FINDING: /v1/parameters answered $3 without the key, so the endpoint is open; block exit 1; read the two bodies above yourself"; exit 1 ;; esac case "$2:$3:$4:$5" in 0:401:0:2??) echo "block exit 0; Dify refused the request without the key and answered it with the key, so read the two bodies above yourself"; exit 0 ;; esac case "$2:$3" in 0:400) echo "HTTP 400 without the key, which with app_unavailable means the app is unavailable or misconfigured, not that the endpoint is open; inconclusive" ;; 0:401) ;; 0:*) echo "HTTP $3 without the key is neither Dify's 401 nor a 2xx finding; inconclusive" ;; *) echo "curl exit $2: the request without the key failed; inconclusive" ;; esac case "$4:$5" in 0:2??) ;; 0:*) echo "HTTP $5 with the key, so the positive control failed; inconclusive" ;; *) echo "curl exit $4: the request with the key failed, so the positive control failed; inconclusive" ;; esac echo "block exit 3; read the two bodies above yourself" exit 3 ) # Repeat the same guarded, paired pattern for Flowise (POST http://127.0.0.1:3000/api/v1/prediction/ # on a chatflow you ASSIGNED a key to - a keyless chatflow is public by design - key in an # 'Authorization: Bearer' header) and Langflow (POST http://127.0.0.1:7860/api/v1/run/, key in the # 'x-api-key' header), each with a JSON body, expecting the app's own rejection without the key and a # real 2xx with it. A transport error (exit != 0), a validation error, or a redirect is inconclusive, # never a pass. ``` ## Common mistakes - Starting Dify without `INIT_PASSWORD` on a reachable host: the first visitor to `/install` owns the instance. - Running Langflow with `LANGFLOW_AUTO_LOGIN=True` (the application default in 1.12.6), which bypasses authentication; the pinned official image Dockerfiles set it to `false`. - A Flowise chatflow with no API key assigned: the prediction API is public to anyone with the ID. - Leaving LibreChat registration open after the admin exists, or running with unset or placeholder `CREDS_KEY`/`JWT_SECRET` instead of your own permanent values. ## Sources (checked September 2026) - Dify Docker Compose deployment (setup at `/install`): https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose - Dify environment variables (`SECRET_KEY`, `INIT_PASSWORD`, `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `APP_WEB_URL`): https://docs.dify.ai/en/self-host/deploy/configuration/environments - Dify `docker/.env.example` (`EXPOSE_NGINX_PORT`, `NGINX_HTTPS_ENABLED`, certificate variables): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/docker/.env.example ; `docker-compose.yaml` (which services publish ports): https://github.com/langgenius/dify/blob/8387590ace4a094de812b7847fc6a4c3a27cd52b/docker/docker-compose.yaml - Docker packet filtering and firewalls (published ports bypass UFW): https://docs.docker.com/engine/network/packet-filtering-firewalls/ - Dify certbot README (HTTPS steps): https://github.com/langgenius/dify/blob/4c1ad40f8e8a6ee58a958330558f2178b7e47fa7/docker/certbot/README.md - Dify API keys (Bearer, backend-only): https://docs.dify.ai/en/api-reference/guides/get-started - Flowise app-level authentication (v3.0.1 accounts, deprecated username/password, JWT secrets): https://docs.flowiseai.com/configuration/authorization/app-level - Flowise chatflow-level API keys: https://docs.flowiseai.com/configuration/authorization/chatflow-level - Flowise environment variables (`PORT`, `NUMBER_OF_PROXIES`, `FLOWISE_SECRETKEY_OVERWRITE`): https://docs.flowiseai.com/configuration/environment-variables - Flowise SSO (Enterprise-plan only): https://docs.flowiseai.com/configuration/sso - Flowise prediction API (401 without key): https://docs.flowiseai.com/api-reference/prediction - Flowise deployment with nginx and certbot: https://docs.flowiseai.com/configuration/deployment/digital-ocean - Flowise Compose (publishes `${PORT}:${PORT}`): https://github.com/FlowiseAI/Flowise/blob/4ea391204a499fb6d19747104502362295b4dde3/docker/docker-compose.yml - Langflow example Compose (publishes `7860:7860` and PostgreSQL `5432:5432`): https://github.com/langflow-ai/langflow/blob/c6dbca308dc85526d5cecb31211821ec4f5e1d05/docker_example/docker-compose.yml - LibreChat Compose (publishes `${PORT}:${PORT}`; extended via a docker-compose.override.yaml): https://github.com/LibreChat-AI/LibreChat/blob/1596df724a840f894831fc74f21de8d8df72fcb1/docker-compose.yml - Langflow API keys and authentication: https://docs.langflow.org/api-keys-and-authentication - Langflow environment variables (`LANGFLOW_HOST`, `LANGFLOW_PORT`, SSL files): https://docs.langflow.org/environment-variables - Langflow 1.12.6 server defaults (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/groups/server.py#L21-L24 - Langflow 1.12.6 server tls (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/groups/server.py#L45-L48 - Langflow 1.12.6 auth auto (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L96-L104 - Langflow 1.12.6 auth cookies (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L135-L148 - Langflow 1.12.6 constants (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/constants.py#L3-L6 - Langflow 1.12.6 image (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/docker/build_and_push.Dockerfile#L234-L235 - Langflow 1.12.6 backend image (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/docker/build_and_push_backend.Dockerfile#L124-L128 - Langflow 1.12.6 ep image (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/docker/build_and_push_ep.Dockerfile#L164-L169 - Langflow 1.12.6 prefix (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/base.py#L89-L117 - Langflow 1.12.6 tls wiring (checked October 2026): - Langflow 1.12.6 tls uvicorn (checked October 2026): - Langflow 1.12.6 tls gunicorn (checked October 2026): - Langflow 1.12.6 password (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/services/utils.py#L216-L230 - Langflow 1.12.6 auth prefix (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L344 - Langflow 1.12.6 auth settings class (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L48 - Langflow 1.12.6 platform (checked October 2026): - Langflow 1.12.6 uvicorn branch (checked October 2026): - Langflow 1.12.6 auto-login branch (checked October 2026): https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/services/utils.py#L142-L213 - Langflow production best practices (`LANGFLOW_SECRET_KEY` preflight): https://docs.langflow.org/deployment-prod-best-practices - Langflow security model (the editor runs arbitrary Python with host/filesystem/network access): https://docs.langflow.org/security - Langflow authentication overview (external identity, JWT/JWKS validation): https://docs.langflow.org/authentication-overview - LibreChat `.env` reference: https://www.librechat.ai/docs/configuration/dotenv - LibreChat authentication system: https://www.librechat.ai/docs/configuration/authentication - LibreChat OAuth2 and OIDC overview: https://www.librechat.ai/docs/configuration/authentication/OAuth2-OIDC - LibreChat Keycloak setup (`OPENID_*` variables): https://www.librechat.ai/docs/configuration/authentication/OAuth2-OIDC/keycloak - LibreChat Docker install (port 3080, first account is admin): https://www.librechat.ai/docs/local/docker - LibreChat nginx and TLS: https://www.librechat.ai/docs/remote/nginx - LibreChat Actions (domain allowlist, built-in SSRF checks): https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/actions - LibreChat v0.7.7 changelog (two-factor authentication): https://www.librechat.ai/changelog/v0.7.7 - Docker Compose merge rules (sequences merge rather than replace; the `!reset` tag): https://docs.docker.com/reference/compose-file/merge/ - Docker Compose pinned merge source (ports list merge; checked October 2026): https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L103-L115 - Docker Compose pinned merge source (`!reset`; checked October 2026): https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L142-L151 - Docker Compose pinned merge source (`!override`; checked October 2026): https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L184-L220 - Docker Compose v2.24.3 go.mod (compose-go rc.2; checked October 2026): https://github.com/docker/compose/blob/v2.24.3/go.mod#L9 - Docker Compose v2.24.4 go.mod (compose-go rc.3; checked October 2026): https://github.com/docker/compose/blob/v2.24.4/go.mod#L9 - Docker Compose v2.24.4 release note (compose-go bump for merge issues; checked October 2026): https://github.com/docker/compose/releases/tag/v2.24.4 - compose-go v2.0.0-rc.2 loader (`!reset`; checked October 2026): https://github.com/compose-spec/compose-go/blob/v2.0.0-rc.2/loader/null.go#L41-L46 - compose-go v2.0.0-rc.3 loader (`!reset` and `!override`; checked October 2026): https://github.com/compose-spec/compose-go/blob/v2.0.0-rc.3/loader/reset.go#L41-L50 - Dify Compose at d39d9ddb7430522e0c828c6953afdf773ba6e675 (daemon image, container bind and host publication): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/docker/docker-compose.yaml#L573-L644 - Dify plugin daemon 0.6.10, commit 1310a18b2f6bc6f18768a0a6265484830891433c (TCP listener and newline framing). Dify plugin daemon 0.6.10 TCP listener and newline framing sources: https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/server.go#L109-L134 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/codec.go#L14-L49. Dify plugin daemon 0.6.10 source for each decoded line passed to the message handler: https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/hooks.go#L132-L153 - Dify plugin daemon 0.6.10 (handshake lookup, invalid-key rejection and connection handling). Dify plugin daemon 0.6.10 sources (1 of 2): https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/runtime_intialization_handlers.go#L18-L42 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/hooks.go#L51-L90. Dify plugin daemon 0.6.10 sources (2 of 2): https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/hooks.go#L158-L240 - Dify plugin daemon 0.6.10 (pre-handshake dispatch, declaration storage and exact asset accounting). Dify plugin daemon 0.6.10 sources (1 of 2): https://github.com/langgenius/dify-plugin-daemon/blob/1310a18b2f6bc6f18768a0a6265484830891433c/internal/core/debugging_runtime/hooks.go#L158-L237 ; https://github.com/langgenius/dify-plugin-daemon/blob/1310a18b2f6bc6f18768a0a6265484830891433c/internal/core/debugging_runtime/runtime_intialization_handlers.go#L44-L75. Dify plugin daemon 0.6.10 sources (2 of 2): https://github.com/langgenius/dify-plugin-daemon/blob/1310a18b2f6bc6f18768a0a6265484830891433c/internal/core/debugging_runtime/runtime_intialization_handlers.go#L139-L183 - Dify plugin daemon 0.6.10 (key generation, Redis mappings, expiry refresh and lookup): https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/service/debugging_service/connection_key.go#L23-L123 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/pkg/utils/cache/redis.go#L208-L230 - Dify plugin daemon 0.6.10 (remote installation, invocation transport and request data). Dify plugin daemon 0.6.10 sources (1 of 2): https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/service/install_service/controlpanel.go#L50-L85 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/io.go#L18-L68 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/pkg/entities/requests/tool.go#L27-L36. Dify plugin daemon 0.6.10 sources (2 of 2): https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/pkg/entities/requests/model.go#L9-L34 - Dify plugin daemon 0.6.10 (enable switch, server startup and debugging-key route): https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/types/app/config.go#L101-L106 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/control_panel/watch_dog.go#L32-L46 ; https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/server/http_server.go#L130-L134 - Dify console at d39d9ddb7430522e0c828c6953afdf773ba6e675 (debugging-key route and its decorators; plugin-permission default; RBAC check and its default). Dify console sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L567-L585 ; . Dify console sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/common/wraps.py#L11-L32 ; - Dify console prerequisites (instance setup, account initialization, active workspace and authenticated session). Dify console prerequisites sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L308-L321 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L113-L125. Dify console prerequisites sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/libs/login.py#L47-L59 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/libs/login.py#L152-L169 - Dify plugin permission model (row default `noone`, unlike the no-row allowance above): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L402-L407 - Dify permission-change API (`is_admin_or_owner` check: true with RBAC enabled, otherwise a privileged role, admin or owner; request defaults; create or update the permission row). Dify permission-change API sources (1 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1086-L1106 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L170-L172. Dify permission-change API sources (2 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/plugin/plugin_permission_service.py#L14-L36 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L192-L195. Dify permission-change API sources (3 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L40-L44 - Dify console UI at d39d9ddb7430522e0c828c6953afdf773ba6e675 (permission type and choices; UI read and write of both plugin permissions; client-side settings flags and unconditional mutation return; permission-fetch endpoint defaults to everyone with no row; checked October 2026). Dify console UI sources (1 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/types.ts#L267-L276 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1151-L1156. Dify console UI sources (2 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1178-L1188 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L28-L43. Dify console UI sources (3 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L60-L100 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1109-L1131 - Dify console client-side UI flags at d39d9ddb7430522e0c828c6953afdf773ba6e675 (RBAC-enabled plugin.debug check and legacy debug_permission path; legacy helper refuses a missing UI value with RBAC off; permission-key labels, not role assignments; checked October 2026). Dify console client-side UI flags sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L69-L88 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-permissions.ts#L9-L23. Dify console client-side UI flags sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/i18n/locales/en-US/permission-keys.json#L59-L63 - Dify legacy fallback workspace permissions at d39d9ddb7430522e0c828c6953afdf773ba6e675 (complete owner, admin, editor, normal and dataset_operator lists; role mapping; snapshot construction; external-RBAC-disabled selection; checked October 2026). Dify legacy fallback workspace permissions sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L428-L538 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L673-L703. Dify legacy fallback workspace permissions sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L745-L772 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L2272-L2302 - Dify console hook consumer at d39d9ddb7430522e0c828c6953afdf773ba6e675 (CategoryMarketplacePanel imports and calls usePluginSettingsAccess; conditional List rendering with the canInstallPlugin prop, not a permission or preferences editor; checked October 2026). Dify console hook consumer sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L17 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L36. Dify console hook consumer sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L134-L144 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L118 - Dify console plugin-permission and plugin-preferences editors at d39d9ddb7430522e0c828c6953afdf773ba6e675 (checked October 2026). Cited parts of the Dify console plugin-permission and plugin-preferences editors (1 of 2): PermissionQuickPanel choices, rendering and change wiring; popover render condition; integration flags and change handler; plugin-category sections; plugin-category mapping; IntegrationsPage hook use and sidebar render; plugin-category toolbar action. Cited parts of the Dify console plugin-permission and plugin-preferences editors (2 of 2): ProviderList flag and toolbar props; ToolProviderToolbar render; data-source and model-provider flag and render; auto-update dialog hooks, save handler and Save button; change request; update-from-marketplace exclude call. Dify console plugin-permission and plugin-preferences editor sources (1 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/permission-quick-panel.tsx#L12-L86 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/sidebar-actions.tsx#L166-L186. Dify console plugin-permission and plugin-preferences editor sources (2 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L29-L39 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L6-L13. Dify console plugin-permission and plugin-preferences editor sources (3 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-nav.ts#L13-L18 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L133-L148. Dify console plugin-permission and plugin-preferences editor sources (4 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L339-L346 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L181-L191. Dify console plugin-permission and plugin-preferences editor sources (5 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-list.tsx#L55-L65 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-list.tsx#L154-L175. Dify console plugin-permission and plugin-preferences editor sources (6 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-toolbar.tsx#L70-L73 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/data-source-page-new/index.tsx#L60. Dify console plugin-permission and plugin-preferences editor sources (7 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/data-source-page-new/index.tsx#L119 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L58. Dify console plugin-permission and plugin-preferences editor sources (8 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L249 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/update-setting-dialog.tsx#L51-L63. Dify console plugin-permission and plugin-preferences editor sources (9 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/update-setting-dialog.tsx#L204-L210. Dify console plugin-permission and plugin-preferences editor sources (10 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/update-setting-dialog.tsx#L316-L323 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1211-L1228. Dify console plugin-permission and plugin-preferences editor sources (11 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/from-market-place.tsx#L134-L142 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1357-L1363 - Dify enterprise RBAC request path at d39d9ddb7430522e0c828c6953afdf773ba6e675 (MyPermissionsResponse workspace keys; inner RBAC prefix and call wrapper; EnterpriseRequest base URLs, secret-key header attachment and inner RBAC request to dify-enterprise; checked October 2026). Dify enterprise RBAC request path sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L397-L418 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L875-L915. Dify enterprise RBAC request path sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/base.py#L156-L217 - Dify console controls also gated on the plugin.plugin_preferences key at d39d9ddb7430522e0c828c6953afdf773ba6e675 (ModelProviderPage system model selector and its gate; provider-card local flag from the same key, model-list query and collapsed-section condition; grid and list show-models buttons and expanded model lists; checked October 2026). Dify console gated-control sources (1 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L163-L185 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx#L71-L90. Dify console gated-control sources (2 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx#L192-L240. Dify console gated-control sources (3 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx#L275-L313 - Dify git grep hit lines at d39d9ddb7430522e0c828c6953afdf773ba6e675 (checked October 2026). Dify git grep hit lines from the web import search: the UpdateSettingDialog and PermissionQuickPanel imports and the two type-only PermissionSettingKey imports. Dify git grep hit lines from the api search: the RBACPermission.PLUGIN_PREFERENCES enum member and the route checks, including the auto-upgrade change and exclude routes, with the fallback-list hits inside the legacy lists cited above. Dify git grep hit-line sources (1 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/sidebar-actions.tsx#L19 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/sidebar-actions.tsx#L4. Dify git grep hit-line sources (2 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L1 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L17. Dify git grep hit-line sources (3 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-toolbar.tsx#L8 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/data-source-page-new/index.tsx#L16. Dify git grep hit-line sources (4 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L17 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/core/rbac/entities.py#L87. Dify git grep hit-line sources (5 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L230 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L279. Dify git grep hit-line sources (6 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L328 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L529. Dify git grep hit-line sources (7 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L551 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1197-L1204. Dify git grep hit-line sources (8 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1252-L1259 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L202. Dify git grep hit-line sources (9 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L238 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L274. Dify git grep hit-line sources (10 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L392 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L423. Dify git grep hit-line sources (11 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L467 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L527. Dify git grep hit-line sources (12 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L719 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L766. Dify git grep hit-line sources (13 of 13): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L795 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L829 - Dify setter-search git grep hit lines at d39d9ddb7430522e0c828c6953afdf773ba6e675 (setter search: the settings-access hook's setPluginPermissionSettings mutation binding and return entry, useReferenceSetting's setReferenceSettings return entry, and useIntegrationPermissions' destructuring and call of setPluginPermissionSettings; checked October 2026). Dify setter-search hit-line sources (1 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L63 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L92. Dify setter-search hit-line sources (2 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L136 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L26. Dify setter-search hit-line sources (3 of 3): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L35 - Dify plugin-preferences server routes at d39d9ddb7430522e0c828c6953afdf773ba6e675 (decorator import from controllers.console.wraps; auto-upgrade change route with its RBAC-off admin-or-owner check; exclude route with no check in its body; p181a source reading, nothing run; checked October 2026). Dify plugin-preferences server route sources (1 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L23-L33 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1197-L1226. Dify plugin-preferences server route sources (2 of 2): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1252-L1271 - Dify enterprise RBAC role definitions at d39d9ddb7430522e0c828c6953afdf773ba6e675 (RBACRole permission keys; Roles.list reading /rbac/roles through the inner RBAC call; p181a source reading, nothing run; checked October 2026). Dify enterprise RBAC role definition sources: https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L103-L112 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L1313-L1338 - Dify console plugin-preferences rendering at d39d9ddb7430522e0c828c6953afdf773ba6e675 (IntegrationsPage toolbar-action prop in both layouts; ProviderList props; update-from-marketplace confirm handler and downgrade-warning branch; ModelProviderPage default-model queries; tour filter and four call sites; second permission hook; p181a source reading, nothing run; checked October 2026). Dify console plugin-preferences rendering sources (1 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L349-L388 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-list.tsx#L42-L46. Dify console plugin-preferences rendering sources (2 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/from-market-place.tsx#L82-L123 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/from-market-place.tsx#L144-L170. Dify console plugin-preferences rendering sources (3 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L58-L73 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L97-L98. Dify console plugin-preferences rendering sources (4 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L198-L201 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L239-L244. Dify console plugin-preferences rendering sources (5 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L539-L545 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L554-L560. Dify console plugin-preferences rendering sources (6 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L693-L696 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission.ts#L31-L33 - Dify plugin-preferences server checks at d39d9ddb7430522e0c828c6953afdf773ba6e675 (console re-export of the common decorator; RBAC-off role decorators; model routes; trigger-provider role decorators; account editing role and edit permission; auto-upgrade strategy write and exclude service; p192a source reading, nothing run; checked October 2026). Dify plugin-preferences server check sources (1 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L15 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L36-L38. Dify plugin-preferences server check sources (2 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L402-L431 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L225-L256. Dify plugin-preferences server check sources (3 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L274-L339 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L519-L560. Dify plugin-preferences server check sources (4 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L201 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L237. Dify plugin-preferences server check sources (5 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L273 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L391. Dify plugin-preferences server check sources (6 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L422 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L466. Dify plugin-preferences server check sources (7 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L526 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L718. Dify plugin-preferences server check sources (8 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L765 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L794. Dify plugin-preferences server check sources (9 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L828 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L64-L67. Dify plugin-preferences server check sources (10 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L218-L228 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/plugin/plugin_auto_upgrade_service.py#L264-L292. Dify plugin-preferences server check sources (11 of 11): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/plugin/plugin_auto_upgrade_service.py#L320-L374 - Dify console preferences placement and model controls at d39d9ddb7430522e0c828c6953afdf773ba6e675 (renderer sections; category page, panel, filter-slot and header toolbar; system model selector permission, save and Save button; model calls; model list, row controls; downgrade buttons; update-dialog importers; auto-upgrade coverage; update gate; p192a reading, nothing run; checked October 2026). Dify console preferences placement and model control sources (1 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/section-renderer.tsx#L79-L90 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/section-renderer.tsx#L114-L142. Dify console preferences placement and model control sources (2 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/plugin-category-page.tsx#L77-L86 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/plugins-panel.tsx#L240-L245. Dify console preferences placement and model control sources (3 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/plugins-panel.tsx#L313-L316 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/filter-management/index.tsx#L29. Dify console preferences placement and model control sources (4 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/filter-management/index.tsx#L52 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/page-header.tsx#L22. Dify console preferences placement and model control sources (5 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/page-header.tsx#L47 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx#L75. Dify console preferences placement and model control sources (6 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx#L152-L172. Dify console preferences placement and model control sources (7 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx#L357-L364 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/common.ts#L159-L181. Dify console preferences placement and model control sources (8 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx#L72-L73. Dify console preferences placement and model control sources (9 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx#L144-L148. Dify console preferences placement and model control sources (10 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx#L54. Dify console preferences placement and model control sources (11 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx#L66-L86. Dify console preferences placement and model control sources (12 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx#L138-L177. Dify console preferences placement and model control sources (13 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/downgrade-warning.tsx#L11-L37 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/index.tsx#L19. Dify console preferences placement and model control sources (14 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx#L98-L116. Dify console preferences placement and model control sources (15 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts#L85-L100. Dify console preferences placement and model control sources (16 of 16): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts#L59-L67 - Dify second permission hook consumers at d39d9ddb7430522e0c828c6953afdf773ba6e675 (the 11 non-test destructuring lines the p192a search found; nothing run; checked October 2026). Dify second permission hook consumer sources (1 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx#L26 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx#L27. Dify second permission hook consumer sources (2 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/featured-triggers.tsx#L261 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/featured-tools.tsx#L260. Dify second permission hook consumer sources (3 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/hooks/use-node-plugin-installation.ts#L206 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx#L117-L118. Dify second permission hook consumer sources (4 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx#L109. Dify second permission hook consumer sources (5 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx#L30 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/goto-anything/index.tsx#L244. Dify second permission hook consumer sources (6 of 6): https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx#L36 ; https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx#L21 - `docker compose ps` output fields (`Service`, `Publishers`, `PublishedPort`): https://docs.docker.com/reference/cli/docker/compose/ps/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - daemon: Dify plugin daemon (0.6.10) - flowise-auth: Flowise account minimum (v3.0.1) - langflow-1126: Langflow settings source (v1.12.6) - librechat-mfa: LibreChat changelog (v0.7.7) - dify: Dify documentation (unknown) - dify-console: Dify console/config source (d39d9ddb7430522e0c828c6953afdf773ba6e675) - dify-compose: Dify Compose source (8387590ace4a094de812b7847fc6a4c3a27cd52b) - certbot: Dify certbot source (4c1ad40f8e8a6ee58a958330558f2178b7e47fa7) - daemon-source: Dify plugin daemon source (1310a18b2f6bc6f18768a0a6265484830891433c) - flowise: Flowise documentation (unknown) - flowise-compose: Flowise Compose (4ea391204a499fb6d19747104502362295b4dde3) - langflow-compose: Langflow Compose (c6dbca308dc85526d5cecb31211821ec4f5e1d05) - langflow: Langflow documentation (unknown) - librechat-compose: LibreChat Compose (1596df724a840f894831fc74f21de8d8df72fcb1) - librechat: LibreChat documentation (unknown) - docker: Docker (unknown) - docker-merge: Docker Compose merge source (31d174d090fd7111ca2199ed54a3978e0d4ac74d) - compose-243: Docker Compose go.mod (v2.24.3) - compose-244: Docker Compose release source (v2.24.4) - compose-go-rc2: compose-go loader (v2.0.0-rc.2) - compose-go-rc3: compose-go reset loader (v2.0.0-rc.3) Claims (full source mapping in the JSON block below): - flowise-port (REASONED; flowise, flowise-compose): Flowise PORT defaults 3000; Compose publishes ${PORT}:${PORT}; replace with host-loopback mapping. - langflow-port (REASONED; langflow, langflow-compose, langflow-1126): Langflow 1.12.6 settings default port 7860; example Compose publishes 7860:7860 and Postgres 5432:5432; keep database unpublished or private. - librechat-port (REASONED; librechat, librechat-compose): LibreChat defaults 3080; Compose publishes ${PORT}:${PORT} and admin-panel ${ADMIN_PANEL_PORT:-3000}:3000; restrict both. - compose-merge (REASONED; docker, docker-merge, compose-243, compose-244, compose-go-rc2, compose-go-rc3): Override ports lists merge; !override replaces and !reset clears. Guide assessment: require Docker Compose 2.24.4+ for both; pinned v2.24.3 uses compose-go rc.2, whose loader handles !reset, while pinned v2.24.4 uses rc.3 with both tags. Later-release behaviour is not established by these pins. - docker-firewall (REASONED; docker): Docker published traffic can bypass UFW; removing or narrowing publications is required. - dify-ports (REASONED; dify-console, dify-compose): Dify publishes nginx 80/443 and plugin debugging 5003, with more vector-profile ports; EXPOSE_NGINX_SSL_PORT remains published when HTTPS serving is off. - dify-debug-bind (REASONED; dify-console): Compose pins daemon 0.6.10-local, publishes 5003 on all host interfaces; EXPOSE_PLUGIN_DEBUGGING_HOST is client destination, not bind control. - daemon-listener (REASONED; dify-console, daemon): Daemon 0.6.10 uses plaintext newline-delimited JSON TCP; PLUGIN_DEBUGGING_HOST/PORT map to remote-installing listener, default 0.0.0.0:5003. - daemon-key (REASONED; daemon): Per-tenant UUID debugging key has Redis bidirectional mappings with two-hour expiry; retrieval refreshes expiry, not key, and expiry leaves authenticated runtime connected. - daemon-rejection (REASONED; daemon): Wrong key yields handshake failed, invalid key and closes; lookup is Redis-based and TCP key guessing has no rate limiter. - daemon-preauth (REASONED; daemon-source): Non-handshake messages can parse/store declarations and buffer assets before authentication. - daemon-assets (REASONED; daemon-source): Asset limit compares decoded buffered bytes plus incoming base64 string length against 50 MiB; accepted chunks increment decoded length. - daemon-authority (REASONED; daemon): Key holder registers tenant runtime receiving invocation parameters/prompts/credentials and returning results; this alone does not demonstrate host shell execution. - debug-key-access (REASONED; dify-console): Initialized authenticated member with active workspace after setup can GET current workspace debugging-key under documented default permissions. - rbac-default (REASONED; dify-console): RBAC_ENABLED defaults false and skips enterprise RBAC checks. - plugin-permission (REASONED; dify-console): No plugin-permission row allows every member; existing row default noone differs, and admins permits only admin/owner. - debug-remedy (REASONED; dify-console): If debugging is needed, this guide advises an admin or owner to POST /console/api/workspaces/current/plugin/permission/change with debug_permission=admins and preserve install_permission (omitting it defaults to everyone). The server requires is_admin_or_owner, which returns true with RBAC enabled and otherwise requires admin or owner. - debug-permission-read (REASONED; dify-console): At the pinned Dify commit, usePluginPermissionSettings GETs /workspaces/current/plugin/permission/fetch typed as Permissions, which declares install_permission and debug_permission. This compile-time type does not prove the runtime fields. - debug-ui-flags (REASONED; dify-console): Dify's console canSetPermissions requires enterprise RBAC off and plugin.plugin_preferences (labelled "Configure auto updates"); canSetPluginPreferences checks that key without the RBAC-off condition. These are client-side UI flags, not server enforcement; the settings-access hook returns the mutation unconditionally. - debug-ui-debug (REASONED; dify-console): Dify's client-side console UI flag canDebugPlugin checks plugin.debug with enterprise RBAC enabled; otherwise it uses debug_permission. With RBAC explicitly false, the legacy helper permits the everyone value, permits admins only for a manager or owner, and otherwise refuses, including a missing UI value; permission fetch returns everyone when no row exists. - debug-fallback-roles (REASONED; dify-console): At the pinned Dify commit, fallback workspace lists and their role mapping include plugin.plugin_preferences for owner and admin, but not editor, normal or dataset_operator. MyPermissions.get uses those legacy snapshots when external RBAC is disabled (RBAC_ENABLED=false). - debug-ui-render (REASONED; dify-console): At the pinned Dify commit, CategoryMarketplacePanel imports usePluginSettingsAccess and reads canInstallPlugin. When the panel is expanded and neither loading nor showing an installed-ID error, it renders List with showInstallButton={canInstallPlugin}; this establishes that prop wiring, not a permission or preferences editor. - debug-ui-permission-editor (REASONED; dify-console): From source reading at the pinned Dify commit, PermissionQuickPanel is the console's plugin-permission editor, offering everyone, admins and noone for install_permission and debug_permission. IntegrationSidebarUtilityActions renders it in a popover only when showPermissionQuickPanel and the fetched permission are set; useIntegrationPermissions sets that flag to canSetPermissions && !!permission. - debug-ui-permission-page (REASONED; dify-console): From source reading at the pinned Dify commit, IntegrationsPage renders IntegrationSidebarUtilityActions when canDebugger or showPermissionQuickPanel is true, passing a change handler that copies the fetched permissions and replaces only the edited key. As canSetPermissions requires enterprise RBAC off, this code does not render PermissionQuickPanel with enterprise RBAC enabled. - debug-ui-preferences (REASONED; dify-console): From source reading at the pinned Dify commit, UpdateSettingDialog is the plugin auto-update preferences dialog: it reads the auto-upgrade settings and, from its Save button's handler, saves through a mutation posting to /workspaces/current/plugin/auto-upgrade/change. DataSourcePage and ModelProviderPage render it only when canSetPluginPreferences is true. - debug-ui-preferences-render (REASONED; dify-console): From source reading at the pinned Dify commit, IntegrationsPage builds UpdateSettingDialog as the plugin-category toolbar action only when showPluginCategorySetting && pluginSettingCategory is truthy, which holds only with canSetPluginPreferences true on the builtin, trigger, agent-strategy or extension section. The section renderer hands it via PluginCategoryPage to PluginsPanel's toolbar. - debug-ui-preferences-toolbar (REASONED; dify-console): From source reading at the pinned Dify commit, ToolProviderToolbar renders its own UpdateSettingDialog only when passed no toolbar action and showToolsUpdateSetting, which ProviderList sets for the built-in tab with canSetPluginPreferences true. - debug-ui-preferences-scope (REASONED; dify-console): From pinned Dify source, plugin.plugin_preferences gates ModelProviderPage's system model selector, warning and default-model queries, and provider cards' show-models buttons, model-list query and expanded list. SystemModelSelector saves default models and ModelList enables or disables models, both also checking plugin.model_config; this guide's assessment: both are plugin-preferences controls. - debug-ui-preferences-hooks (REASONED; dify-console): From source reading at the pinned Dify commit, the step-by-step tour in mount.tsx uses plugin.plugin_preferences only to drop guide steps aimed at the builtin toolbar action's update-settings target. The hook useWorkspacePluginInstallPermission also computes canSetPluginPreferences without an RBAC-off condition; a bounded search found 11 non-test consumers, none reading it. - debug-ui-search (REASONED; dify-console): This guide's assessment: a git grep of the pinned Dify web tree, tests excluded, for the import-path pattern update-setting-dialog' found lines naming UpdateSettingDialog only in IntegrationsPage, ToolProviderToolbar, DataSourcePage and ModelProviderPage. Other spellings, constructed paths, other access forms, out-of-repo code and other releases are not covered; no running console was observed. - debug-ui-search-panel (REASONED; dify-console): This guide's assessment: a git grep of the pinned Dify web tree, tests excluded, for the import-path pattern permission-quick-panel' found lines naming PermissionQuickPanel only in sidebar-actions.tsx; the rest name only the PermissionSettingKey type. Other spellings, constructed paths, other access forms, out-of-repo code and other releases are not covered; no running console was observed. - debug-ui-search-setters (REASONED; dify-console): This guide's assessment: a git grep of the pinned Dify web tree, tests excluded, for setPluginPermissionSettings and setReferenceSettings found, outside the hooks that define them, only useIntegrationPermissions using setPluginPermissionSettings and no use of setReferenceSettings. Uses that never spell either name, out-of-repo code and other releases are not covered. - debug-ui-exclude (REASONED; dify-console): From source reading at the pinned Dify commit, the marketplace update dialog's exclude handler posts to /workspaces/current/plugin/auto-upgrade/exclude given a pluginId, from DowngradeWarningModal's exclude button. Of two non-test importers only HeaderModals supplies a pluginId and shows that modal, for a downgrade under auto-upgrade; the server removes the plugin from automatic updates. - debug-preferences-routes (REASONED; dify-console): From pinned Dify source, plugin.py's auto-upgrade change and exclude routes apply rbac_permission_required with PLUGIN_PREFERENCES; common/wraps.py defines it, console wraps re-exports it, and it calls the view directly with RBAC_ENABLED false. With RBAC_ENABLED false the change handler then raises Forbidden for an account neither admin nor owner, and the exclude handler has no body check. - debug-preferences-model-routes (REASONED; dify-console): From pinned Dify source, with RBAC_ENABLED false, models.py's default-model save and model save and delete routes require admin or owner, its model enable and disable routes have no role decorator, and trigger_providers.py's PLUGIN_PREFERENCES routes require owner, admin or editor for seven and admin or owner for four. - debug-rbac-source (REASONED; dify-console): From source reading at the pinned Dify commit, with RBAC_ENABLED true MyPermissions.get requests /rbac/my-permissions through EnterpriseRequest.send_inner_rbac_request. That method targets ENTERPRISE_RBAC_API_URL (falling back to ENTERPRISE_API_URL) with an Enterprise-Api-Secret-Key header, and the code says that method calls dify-enterprise; the workspace permission keys come from its response. - debug-rbac-roles (REASONED; dify-console): From source reading at the pinned Dify commit, rbac_service.py's Roles.list reads enterprise role definitions, each with permission_keys, from /rbac/roles through the inner RBAC call, and only its legacy owner and admin lists assign plugin.plugin_preferences. This guide's assessment: enterprise role assignments for that key are held outside this repository. - debug-rbac-search (REASONED; dify-console): This guide's assessment from a git grep of the pinned Dify api tree, tests excluded, for plugin_preferences and PLUGIN_PREFERENCES: hits are the permission enum, route checks and the legacy owner and admin fallback lists. Other spellings, constructed keys, other access forms, role data outside this repository (such as langgenius/rbac or dify-enterprise) and other releases are not covered. - debug-rbac-comment (REASONED; dify-console): This guide's assessment: at the pinned Dify commit, a source comment above the legacy owner and admin fallback lists says to keep them aligned with langgenius/rbac's built-in workspace roles; that comment does not establish which roles hold plugin.plugin_preferences under enterprise RBAC. - debug-ui-limits (REASONED; dify-console): This guide's assessment: the cited Dify files do not establish plugin.plugin_preferences role assignments under external or enterprise RBAC, or which permission or preferences controls a given role sees in a running console; the render conditions are client-side source readings. This guide still advises using the permission-change API when debugging is needed, or disabling the listener otherwise. - debug-disable (REASONED; daemon, docker): PLUGIN_REMOTE_INSTALLING_ENABLED defaults true; false prevents TCP startup and removes debugging-key route, while ports: !reset [] separately removes publication. - verify-debug-config (REASONED; dify-console, daemon, docker): Merged Compose should change from published 5003/default-enabled to no publication and explicit false after override and recreation; no Docker/listener demonstration. - flowise-tls (REASONED; flowise): Flowise documented TLS uses nginx/certbot upstream localhost:3000; configure NUMBER_OF_PROXIES for real client addresses. - librechat-tls (REASONED; librechat): LibreChat documented TLS uses nginx upstream localhost:3080; TRUST_PROXY defaults 1 and must match proxy hops. - langflow-tls (REASONED; langflow, langflow-1126): Langflow 1.12.6 LANGFLOW_SSL_CERT_FILE/KEY_FILE settings feed native TLS; guide assessment: a proxy remains a place for login/MFA. - langflow-cookies (REASONED; langflow, langflow-1126): Langflow 1.12.6 LANGFLOW_ACCESS_SECURE and LANGFLOW_REFRESH_SECURE default false; set both true behind HTTPS. - dify-tls (REASONED; certbot, dify-console): Certbot challenge/domain/email and fullchain.pem/privkey.pem precede certificate update; enable HTTPS and recreate nginx; default HTTPS false, protocols TLSv1.2 TLSv1.3. - dify-urls (REASONED; dify): Set CONSOLE_API_URL, CONSOLE_WEB_URL and APP_WEB_URL to public HTTPS; CONSOLE_API_URL controls HTTPS-only cookies. - dify-bootstrap (REASONED; dify, dify-console): INIT_PASSWORD defaults empty; set it before first up to gate /install and claim admin privately. - dify-secret (REASONED; dify, dify-console): SECRET_KEY signs session/JWT and encrypts stored OAuth credentials; generate random value, or empty auto-generates in storage. - dify-registration (REASONED; dify-console): ALLOW_REGISTER=false default closes ordinary self-registration, but not invitations or /install bootstrap. - dify-db-redis (REASONED; dify-console): Replace DB_PASSWORD and REDIS_PASSWORD defaults difyai123456 and update embedded CELERY_BROKER_URL password. - dify-sandbox (REASONED; dify-console): Replace CODE_EXECUTION_API_KEY/SANDBOX_API_KEY default dify-sandbox together. - dify-plugin-keys (REASONED; dify-console): Replace PLUGIN_DAEMON_KEY and PLUGIN_DIFY_INNER_API_KEY example service credentials. - dify-agent-keys (REASONED; dify-console): Replace DIFY_AGENT_API_TOKEN and DIFY_AGENT_SERVER_SECRET_KEY; signing key needs unpadded base64url of 32 random bytes. - dify-weaviate (REASONED; dify-console): If enabled, rotate WEAVIATE_API_KEY and matching allowed keys; disable anonymous access. - dify-api (REASONED; dify): App Bearer keys are separate from console accounts; create per app and keep calls/keys on the backend. - flowise-accounts (REASONED; flowise-auth): From v3.0.1, email/password accounts use JWT HTTP-only cookies; old FLOWISE_USERNAME/PASSWORD are deprecated migration settings; claim admin privately. - flowise-secrets (REASONED; flowise-auth, flowise): Randomize JWT_AUTH_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET, EXPRESS_SESSION_SECRET default flowise, and TOKEN_HASH_SECRET; APP_URL defaults localhost:3000. - flowise-encryption (REASONED; flowise): FLOWISE_SECRETKEY_OVERWRITE supplies stored-credential encryption key; otherwise it lives under SECRETKEY_PATH. - flowise-prediction (REASONED; flowise): Chatflow without assigned key is public by ID; assign per-chatflow API key (DefaultKey is precreated), Bearer requests reject missing key with 401. - langflow-auto (REASONED; langflow, langflow-1126): Langflow 1.12.6 application AUTO_LOGIN defaults True; pinned official image Dockerfiles set false; explicitly disable it and set a superuser password other than legacy langflow; username defaults langflow and password defaults empty. - langflow-secret (REASONED; langflow): Set permanent random LANGFLOW_SECRET_KEY; documented auto-generated key is unsuitable for production. - langflow-signup (REASONED; langflow): NEW_USER_IS_ACTIVE defaults False but ENABLE_SIGNUP True; disable signup and keep activation requirement. - langflow-api (REASONED; langflow): With auto-login off, POST /api/v1/run/<flow-id> needs x-api-key; create via settings or CLI. SKIP_AUTH_AUTO_LOGIN defaults false, applies only with auto-login and is slated for removal. - langflow-host (REASONED; langflow, langflow-compose, langflow-1126): Langflow 1.12.6 settings default HOST localhost; guide assessment: bridged containers need 0.0.0.0 plus host-loopback publication and injected env_file/environment; rolling docs remain unversioned. - librechat-bootstrap (REASONED; librechat): First registered user becomes admin; close ALLOW_REGISTRATION after claiming it. - librechat-sso (REASONED; librechat): For SSO-only, disable email login and deliberately enable social registration/login; configure OPENID_* and optional required role with provider allowlist and MFA. - librechat-secrets (REASONED; librechat): CREDS_KEY is 32 bytes, CREDS_IV 16; JWT secrets at least 32 bytes. Blank values bootstrap keys with persistence caveats; retired JWT defaults are refused. - librechat-urls (REASONED; librechat): DOMAIN_CLIENT and DOMAIN_SERVER must use public HTTPS URL. - mfa (REASONED; flowise, langflow, librechat): Instance-wide native MFA enforcement is not documented; use provider/fronting MFA. Flowise SSO is Enterprise; Langflow offers external JWT/JWKS. - librechat-mfa (REASONED; librechat-mfa, librechat): v0.7.7 changelog lists two-factor enrolment/backup codes, but checked authentication docs do not establish enforced MFA. - containment (REASONED; langflow, flowise, librechat, dify-console): Treat editor as code/SSRF authority; isolate runtime, limit mounts/privileges and default-deny egress including metadata; rotate exposed provider keys. - flowise-guards (REASONED; flowise): Keep HTTP_SECURITY_CHECK and CUSTOM_MCP_SECURITY_CHECK enabled; disabling MCP check permits arbitrary command execution. - librechat-actions (REASONED; librechat): Unset actions.allowedDomains allows public domains with private-target SSRF checks; configured allowlist denies others, and listed private destination grants exception. - dify-ssrf (REASONED; dify-console, dify-compose): Keep Dify SSRF proxy for sandbox and HTTP-request nodes; HTTP allowlists do not contain arbitrary local tools. - verify-network (REASONED; dify-console, daemon, docker): Inventory all listeners and actual Publishers; direct TCP 5003 success proves exposure, refusal/timeout needs same-host reachable control and merged-model evidence. - verify-editor (REASONED; dify, flowise-auth, langflow, librechat): Curl checks transport only; fresh unauthenticated browser must show login, and /install must be already claimed rather than open admin setup. - verify-dify (REASONED; dify): Direct /v1/parameters matched pair expects missing-Bearer 401 and valid-key parameters 200; app_unavailable 400 is inconclusive; bundled nginx is not strict api-service attribution. - verify-flowise (REASONED; flowise): Assigned-key chatflow prediction must reject missing key and return real output with valid Bearer key; keyless flow is public. - verify-langflow (REASONED; langflow): Run API must reject absent x-api-key and return real output with valid key; routing, transport or validation errors are inconclusive. ````json { "schema": 1, "checked": "2026-10-22", "documentation_checked": "2026-09", "body_sha256": "ad4bf14391a7edb1b32661a6f999d7ca6ded71c2ebf139f7bf6c40b6b41d9605", "components": { "daemon": { "name": "Dify plugin daemon", "basis": "0.6.10", "sources": { "scb80ef9b341b": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/server.go#L109-L134", "sbb1558888c1a": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/codec.go#L14-L49", "s1d0863ded6ed": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/runtime_intialization_handlers.go#L18-L42", "sfabe22af22d6": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/hooks.go#L51-L90", "sbccc30db5bfe": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/hooks.go#L158-L240", "s6490d756c639": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/service/debugging_service/connection_key.go#L23-L123", "sa0aa7e953ee0": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/pkg/utils/cache/redis.go#L208-L230", "sa3ee5e4e8a84": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/service/install_service/controlpanel.go#L50-L85", "s14d062f60b1b": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/io.go#L18-L68", "sa3fa1b50ea09": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/pkg/entities/requests/tool.go#L27-L36", "s8e20b988ea0b": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/pkg/entities/requests/model.go#L9-L34", "s191317bdf3a7": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/types/app/config.go#L101-L106", "s32b7d021da01": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/control_panel/watch_dog.go#L32-L46", "sb19f8717ed1f": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/server/http_server.go#L130-L134", "s2efeb062db27": "https://github.com/langgenius/dify-plugin-daemon/blob/0.6.10/internal/core/debugging_runtime/hooks.go#L132-L153" } }, "flowise-auth": { "name": "Flowise account minimum", "basis": "v3.0.1", "sources": { "s6fd9f4266a98": "https://docs.flowiseai.com/configuration/authorization/app-level" } }, "langflow-1126": { "name": "Langflow settings source", "basis": "v1.12.6", "sources": { "s8049fb15747e": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/groups/server.py#L21-L24", "s46241ad0c9d0": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/groups/server.py#L45-L48", "sd7fd176a7394": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L96-L104", "s400ca0f879b4": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L135-L148", "sb7ab44ba0da0": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/constants.py#L3-L6", "s8e8d73b2b8bd": "https://github.com/langflow-ai/langflow/blob/v1.12.6/docker/build_and_push.Dockerfile#L234-L235", "s419881904281": "https://github.com/langflow-ai/langflow/blob/v1.12.6/docker/build_and_push_backend.Dockerfile#L124-L128", "s57f0e83270f9": "https://github.com/langflow-ai/langflow/blob/v1.12.6/docker/build_and_push_ep.Dockerfile#L164-L169", "s44bfa70223f9": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/base.py#L89-L117", "sf521d52020be": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/__main__.py#L553-L556", "s6cf1768228ea": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/__main__.py#L225-L245", "s1ee798ffcdbe": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/__main__.py#L670-L690", "s6485ba67d66f": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/services/utils.py#L216-L230", "s842c850d80b0": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L344", "s253a7815151a": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/lfx/src/lfx/services/settings/auth.py#L48", "s9701f0527287": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/__main__.py#L173-L178", "sb2a88f59cc75": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/__main__.py#L619-L669", "s3ff960d5d18a": "https://github.com/langflow-ai/langflow/blob/v1.12.6/src/backend/base/langflow/services/utils.py#L142-L213" } }, "librechat-mfa": { "name": "LibreChat changelog", "basis": "v0.7.7", "sources": { "s3909883e4681": "https://www.librechat.ai/changelog/v0.7.7" } }, "dify": { "name": "Dify documentation", "basis": "unknown", "sources": { "sc63a91c72255": "https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose", "s144cc3f563a1": "https://docs.dify.ai/en/self-host/deploy/configuration/environments", "sd5ae6b9492f0": "https://docs.dify.ai/en/api-reference/guides/get-started" } }, "dify-console": { "name": "Dify console/config source", "basis": "d39d9ddb7430522e0c828c6953afdf773ba6e675", "sources": { "sb28809d177bf": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/docker/.env.example", "s1e4c822a36b1": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/docker/docker-compose.yaml#L573-L644", "s1cd4f464c880": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L567-L585", "saa9ceb383427": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/__init__.py#L14-L61", "s3998f01569b8": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/common/wraps.py#L11-L32", "s39e0b615af71": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/configs/enterprise/__init__.py#L38-L41", "s5384bb9d1e75": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L308-L321", "s7cdbcd9957ac": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L113-L125", "s182d16f6e475": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/libs/login.py#L47-L59", "sf9235e345b51": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/libs/login.py#L152-L169", "s1edf3fa3d667": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L402-L407", "s09ea6abe9b9c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1086-L1106", "s662ba88445cd": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L170-L172", "s4b8c82f515c6": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/plugin/plugin_permission_service.py#L14-L36", "s19d7f56161fe": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/types.ts#L267-L276", "s2a08cae5a24f": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1151-L1156", "s934c0d2260fe": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1178-L1188", "s951644d96965": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L28-L43", "s6bd02c94df1a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L60-L100", "s044852759e49": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1109-L1131", "s76d59859d714": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L69-L88", "sa048a543f4e6": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-permissions.ts#L9-L23", "scc3b84b78c0d": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/i18n/locales/en-US/permission-keys.json#L59-L63", "s399ff5d5aeda": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L192-L195", "s77b47ac9fa00": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L40-L44", "s208c1c06d22a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L428-L538", "s518259c2480c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L673-L703", "s3e6f88e1a57a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L745-L772", "sb23bb894ddfc": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L2272-L2302", "s9cd485b828ea": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L17", "sb3bc0d1425d8": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L36", "sb90e0fb1e302": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L134-L144", "sc795f9b2ed97": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/category-marketplace-panel.tsx#L118", "s575627ec8c3e": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/permission-quick-panel.tsx#L12-L86", "s7dc13aef5fad": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/sidebar-actions.tsx#L166-L186", "s779d35e1fc6a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L29-L39", "sb17d290f2d56": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L133-L148", "se02bce2f6852": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L339-L346", "sd2d90d42f30e": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L181-L191", "sbd1ca72bd374": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-list.tsx#L55-L65", "sf5c86e049d2e": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-list.tsx#L154-L175", "s06ba07d7da66": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-toolbar.tsx#L70-L73", "sbc685dcf7003": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/data-source-page-new/index.tsx#L60", "sb8f86bc4f4bb": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/data-source-page-new/index.tsx#L119", "s05472aad90f4": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L58", "se2de0855cb5c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L249", "s0f3d19e35818": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/update-setting-dialog.tsx#L51-L63", "sf9038e18b7b7": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1211-L1228", "s1c0e372be589": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/from-market-place.tsx#L134-L142", "s7829fba7e674": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/use-plugins.ts#L1357-L1363", "sd883fffdf223": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L397-L418", "s014e4df1afa1": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L875-L915", "sb7ca3bb6fae0": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/base.py#L156-L217", "sd3fa73d1027a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/update-setting-dialog.tsx#L204-L210", "s876dfc473a9a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/update-setting-dialog.tsx#L316-L323", "s2112b388c56f": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L6-L13", "sbe901a6a7f0f": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-nav.ts#L13-L18", "sbda13d664203": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L163-L185", "scac289555b3a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx#L71-L90", "sfc9de05a028a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx#L192-L240", "s28cfb7bf00e7": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx#L275-L313", "s94cb731c0870": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/sidebar-actions.tsx#L4", "s639011087db7": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/sidebar-actions.tsx#L19", "s105343711203": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L1", "sad9867601cae": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L17", "sd1ccd5c1f14e": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-toolbar.tsx#L8", "s8150a2c00c24": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/data-source-page-new/index.tsx#L16", "sd57be73324f5": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L17", "sda012ab5d7e9": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/core/rbac/entities.py#L87", "s2af89a7038dc": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L230", "sa2918cacd1a3": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L279", "s1673e386a765": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L328", "s8d5f79d0ec26": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L529", "s4bb0a0eeb2a0": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L551", "sb51b1499f921": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1197-L1204", "sbfcbae529e29": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1252-L1259", "s6c476bf132ca": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L202", "s94b83b2d3cee": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L238", "s7fc640d4ccb9": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L274", "s367962f4d79c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L392", "s6da4c319106d": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L423", "se3b28934c4ab": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L467", "sec2d764f91e7": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L527", "s8da45f9dd494": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L719", "sd10985ef9729": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L766", "s85a542e32b74": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L795", "s327574b2d9cb": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L829", "sc5e7961007dd": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L63", "sa87241614900": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L92", "sc9b72b868ecf": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/use-reference-setting.ts#L136", "s8d2f67ff9555": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L26", "s3bf45f5a91a3": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/hooks/use-integration-permissions.ts#L35", "sc62bdba064a2": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L23-L33", "s68d054d9ebf4": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1197-L1226", "s450bad47112a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/plugin.py#L1252-L1271", "s1862570b347e": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L103-L112", "s7e29bbf50ff2": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/enterprise/rbac_service.py#L1313-L1338", "s0e9483720cd2": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/index.tsx#L349-L388", "s8e2e80d03eee": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/tool-provider-list.tsx#L42-L46", "s29b9cc634e1d": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/from-market-place.tsx#L82-L123", "sfc379be2c044": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/from-market-place.tsx#L144-L170", "sfa219026ee75": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/index.tsx#L58-L73", "scf5e33e1e95c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L97-L98", "s8ae5919f70b4": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L198-L201", "sf0fc667b31e6": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L239-L244", "s94389798da1c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L539-L545", "sca5961dbc1aa": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L554-L560", "s8d763bacab74": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/step-by-step-tour/mount.tsx#L693-L696", "s5c694d22de03": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission.ts#L31-L33", "s1a5a437e0484": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L15", "sf9bd8659a8cd": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L36-L38", "sf2ae29ee5c47": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/wraps.py#L402-L431", "s439984655581": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L225-L256", "s307a2371e4e8": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L274-L339", "s3cdacfb1771f": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/models.py#L519-L560", "s86b35372c7f6": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L201", "s7603cedbcb3f": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L237", "s4b59b4de9402": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L273", "sdb790150ee3c": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L391", "sc2b4709eac12": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L422", "s92e997729f73": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L466", "s222726205b90": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L526", "s86efe3a26494": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L718", "s96b8fa35f541": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L765", "sdccd2c887736": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L794", "s4a30b77c1d1d": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/controllers/console/workspace/trigger_providers.py#L828", "s680d91cba5e7": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L64-L67", "s7b99e6c596aa": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/models/account.py#L218-L228", "sf54cd6706956": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/plugin/plugin_auto_upgrade_service.py#L264-L292", "sac4ac0700c45": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/api/services/plugin/plugin_auto_upgrade_service.py#L320-L374", "sd829381c4cc8": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/section-renderer.tsx#L79-L90", "s1be3b28bced1": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/section-renderer.tsx#L114-L142", "sbeb1dfd9dc22": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/plugin-category-page.tsx#L77-L86", "s8deb661dd291": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/plugins-panel.tsx#L240-L245", "sb21d74d5242e": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/plugins-panel.tsx#L313-L316", "s9120eb67bf27": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/filter-management/index.tsx#L29", "sf3053982deb5": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/filter-management/index.tsx#L52", "s583fe8e50483": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/page-header.tsx#L22", "se5da68cbe3ed": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/integrations/page-header.tsx#L47", "s91c1c3f70a6b": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx#L75", "scf7ab1e5f2bd": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx#L152-L172", "s36b585b52065": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx#L357-L364", "s6a7a838cdd95": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/service/common.ts#L159-L181", "s57a75b737921": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx#L72-L73", "s014eb452b618": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx#L144-L148", "sc3f1d34092e9": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx#L54", "sb249d98e9f38": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx#L66-L86", "s07afe941f336": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx#L138-L177", "s813074684a55": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/downgrade-warning.tsx#L11-L37", "s77f103a8f405": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/update-plugin/index.tsx#L19", "sc62845327ce8": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx#L98-L116", "saa9feed8fad1": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts#L85-L100", "seb5dd87245cd": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts#L59-L67", "sc7329c1e6a78": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/rag-tool-recommendations/uninstalled-item.tsx#L26", "sa25c3c12a7f1": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/marketplace-plugin/item.tsx#L27", "s464c875d758a": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/featured-triggers.tsx#L261", "s0c4f29c820b8": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/block-selector/featured-tools.tsx#L260", "sfed383e68a69": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/hooks/use-node-plugin-installation.ts#L206", "sb0179b200196": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx#L117-L118", "s0b0659a01890": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx#L109", "s2e0bdffe3847": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/plugins/plugin-page/plugin-tasks/components/error-plugin-item.tsx#L30", "sefcd2a70d853": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/goto-anything/index.tsx#L244", "se9f48a8f0648": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx#L36", "s63e59e6829fc": "https://github.com/langgenius/dify/blob/d39d9ddb7430522e0c828c6953afdf773ba6e675/web/app/components/workflow/nodes/_base/components/install-plugin-button.tsx#L21" } }, "dify-compose": { "name": "Dify Compose source", "basis": "8387590ace4a094de812b7847fc6a4c3a27cd52b", "sources": { "s517af9c43635": "https://github.com/langgenius/dify/blob/8387590ace4a094de812b7847fc6a4c3a27cd52b/docker/docker-compose.yaml" } }, "certbot": { "name": "Dify certbot source", "basis": "4c1ad40f8e8a6ee58a958330558f2178b7e47fa7", "sources": { "sba8e43d56b94": "https://github.com/langgenius/dify/blob/4c1ad40f8e8a6ee58a958330558f2178b7e47fa7/docker/certbot/README.md" } }, "daemon-source": { "name": "Dify plugin daemon source", "basis": "1310a18b2f6bc6f18768a0a6265484830891433c", "sources": { "s629f3bfbae72": "https://github.com/langgenius/dify-plugin-daemon/blob/1310a18b2f6bc6f18768a0a6265484830891433c/internal/core/debugging_runtime/hooks.go#L158-L237", "s07045a0003da": "https://github.com/langgenius/dify-plugin-daemon/blob/1310a18b2f6bc6f18768a0a6265484830891433c/internal/core/debugging_runtime/runtime_intialization_handlers.go#L44-L75", "s2b36558b0078": "https://github.com/langgenius/dify-plugin-daemon/blob/1310a18b2f6bc6f18768a0a6265484830891433c/internal/core/debugging_runtime/runtime_intialization_handlers.go#L139-L183" } }, "flowise": { "name": "Flowise documentation", "basis": "unknown", "sources": { "s546a18b65918": "https://docs.flowiseai.com/configuration/authorization/chatflow-level", "sfee1ae75d51a": "https://docs.flowiseai.com/configuration/environment-variables", "scabc8335d0c7": "https://docs.flowiseai.com/configuration/sso", "s23c31010d23d": "https://docs.flowiseai.com/api-reference/prediction", "s7b675aa70798": "https://docs.flowiseai.com/configuration/deployment/digital-ocean" } }, "flowise-compose": { "name": "Flowise Compose", "basis": "4ea391204a499fb6d19747104502362295b4dde3", "sources": { "s81474794a92d": "https://github.com/FlowiseAI/Flowise/blob/4ea391204a499fb6d19747104502362295b4dde3/docker/docker-compose.yml" } }, "langflow-compose": { "name": "Langflow Compose", "basis": "c6dbca308dc85526d5cecb31211821ec4f5e1d05", "sources": { "s7f4564c949cb": "https://github.com/langflow-ai/langflow/blob/c6dbca308dc85526d5cecb31211821ec4f5e1d05/docker_example/docker-compose.yml" } }, "langflow": { "name": "Langflow documentation", "basis": "unknown", "sources": { "s5cfeeba4d9b3": "https://docs.langflow.org/api-keys-and-authentication", "s885461210e4e": "https://docs.langflow.org/environment-variables", "s5a55bdef1f33": "https://docs.langflow.org/deployment-prod-best-practices", "sca4ee617805d": "https://docs.langflow.org/security", "sec83eaf2b841": "https://docs.langflow.org/authentication-overview" } }, "librechat-compose": { "name": "LibreChat Compose", "basis": "1596df724a840f894831fc74f21de8d8df72fcb1", "sources": { "sb9bc9a9d7f0f": "https://github.com/LibreChat-AI/LibreChat/blob/1596df724a840f894831fc74f21de8d8df72fcb1/docker-compose.yml" } }, "librechat": { "name": "LibreChat documentation", "basis": "unknown", "sources": { "s41811a0d442f": "https://www.librechat.ai/docs/configuration/dotenv", "s70ac8e7c9504": "https://www.librechat.ai/docs/configuration/authentication", "s7896961bc713": "https://www.librechat.ai/docs/configuration/authentication/OAuth2-OIDC", "s43fbe74e3c42": "https://www.librechat.ai/docs/configuration/authentication/OAuth2-OIDC/keycloak", "s3f1c594392bc": "https://www.librechat.ai/docs/local/docker", "s432d72870381": "https://www.librechat.ai/docs/remote/nginx", "s9f9e35619ce7": "https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/actions" } }, "docker": { "name": "Docker", "basis": "unknown", "sources": { "s351180c6678f": "https://docs.docker.com/engine/network/packet-filtering-firewalls/", "se716ad33db1f": "https://docs.docker.com/reference/compose-file/merge/", "s3e97c2c250b6": "https://docs.docker.com/reference/cli/docker/compose/ps/" } }, "docker-merge": { "name": "Docker Compose merge source", "basis": "31d174d090fd7111ca2199ed54a3978e0d4ac74d", "sources": { "s72ea21230137": "https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L103-L115", "sc9bb17bb3323": "https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L142-L151", "s9d483ca7e4f4": "https://github.com/docker/docs/blob/31d174d090fd7111ca2199ed54a3978e0d4ac74d/content/reference/compose-file/merge.md#L184-L220" } }, "compose-243": { "name": "Docker Compose go.mod", "basis": "v2.24.3", "sources": { "s025e0d398762": "https://github.com/docker/compose/blob/v2.24.3/go.mod#L9" } }, "compose-244": { "name": "Docker Compose release source", "basis": "v2.24.4", "sources": { "s31edecddf3be": "https://github.com/docker/compose/blob/v2.24.4/go.mod#L9", "s6fc4eeeedfbd": "https://github.com/docker/compose/releases/tag/v2.24.4" } }, "compose-go-rc2": { "name": "compose-go loader", "basis": "v2.0.0-rc.2", "sources": { "sc2bf2c1e7297": "https://github.com/compose-spec/compose-go/blob/v2.0.0-rc.2/loader/null.go#L41-L46" } }, "compose-go-rc3": { "name": "compose-go reset loader", "basis": "v2.0.0-rc.3", "sources": { "s8d6dab78abf1": "https://github.com/compose-spec/compose-go/blob/v2.0.0-rc.3/loader/reset.go#L41-L50" } } }, "claims": { "flowise-port": {"text": "Flowise PORT defaults 3000; Compose publishes ${PORT}:${PORT}; replace with host-loopback mapping.", "components": ["flowise", "flowise-compose"], "sources": ["flowise:sfee1ae75d51a", "flowise-compose:s81474794a92d"], "status": "REASONED"}, "langflow-port": {"text": "Langflow 1.12.6 settings default port 7860; example Compose publishes 7860:7860 and Postgres 5432:5432; keep database unpublished or private.", "components": ["langflow", "langflow-compose", "langflow-1126"], "sources": ["langflow:s885461210e4e", "langflow-compose:s7f4564c949cb", "langflow-1126:s8049fb15747e", "langflow-1126:s44bfa70223f9"], "status": "REASONED"}, "librechat-port": {"text": "LibreChat defaults 3080; Compose publishes ${PORT}:${PORT} and admin-panel ${ADMIN_PANEL_PORT:-3000}:3000; restrict both.", "components": ["librechat", "librechat-compose"], "sources": ["librechat:s3f1c594392bc", "librechat-compose:sb9bc9a9d7f0f"], "status": "REASONED"}, "compose-merge": {"text": "Override ports lists merge; !override replaces and !reset clears. Guide assessment: require Docker Compose 2.24.4+ for both; pinned v2.24.3 uses compose-go rc.2, whose loader handles !reset, while pinned v2.24.4 uses rc.3 with both tags. Later-release behaviour is not established by these pins.", "components": ["docker", "docker-merge", "compose-243", "compose-244", "compose-go-rc2", "compose-go-rc3"], "sources": ["docker:se716ad33db1f", "docker-merge:s72ea21230137", "docker-merge:sc9bb17bb3323", "docker-merge:s9d483ca7e4f4", "compose-243:s025e0d398762", "compose-244:s31edecddf3be", "compose-244:s6fc4eeeedfbd", "compose-go-rc2:sc2bf2c1e7297", "compose-go-rc3:s8d6dab78abf1"], "status": "REASONED"}, "docker-firewall": {"text": "Docker published traffic can bypass UFW; removing or narrowing publications is required.", "components": ["docker"], "sources": ["docker:s351180c6678f"], "status": "REASONED"}, "dify-ports": {"text": "Dify publishes nginx 80/443 and plugin debugging 5003, with more vector-profile ports; EXPOSE_NGINX_SSL_PORT remains published when HTTPS serving is off.", "components": ["dify-console", "dify-compose"], "sources": ["dify-console:sb28809d177bf", "dify-compose:s517af9c43635", "dify-console:s1e4c822a36b1"], "status": "REASONED"}, "dify-debug-bind": {"text": "Compose pins daemon 0.6.10-local, publishes 5003 on all host interfaces; EXPOSE_PLUGIN_DEBUGGING_HOST is client destination, not bind control.", "components": ["dify-console"], "sources": ["dify-console:s1e4c822a36b1", "dify-console:sb28809d177bf"], "status": "REASONED"}, "daemon-listener": {"text": "Daemon 0.6.10 uses plaintext newline-delimited JSON TCP; PLUGIN_DEBUGGING_HOST/PORT map to remote-installing listener, default 0.0.0.0:5003.", "components": ["dify-console", "daemon"], "sources": ["dify-console:s1e4c822a36b1", "daemon:scb80ef9b341b", "daemon:sbb1558888c1a", "daemon:sbccc30db5bfe", "daemon:s2efeb062db27"], "status": "REASONED"}, "daemon-key": {"text": "Per-tenant UUID debugging key has Redis bidirectional mappings with two-hour expiry; retrieval refreshes expiry, not key, and expiry leaves authenticated runtime connected.", "components": ["daemon"], "sources": ["daemon:s6490d756c639", "daemon:sa0aa7e953ee0", "daemon:sfabe22af22d6"], "status": "REASONED"}, "daemon-rejection": {"text": "Wrong key yields handshake failed, invalid key and closes; lookup is Redis-based and TCP key guessing has no rate limiter.", "components": ["daemon"], "sources": ["daemon:s1d0863ded6ed", "daemon:sfabe22af22d6", "daemon:sbccc30db5bfe"], "status": "REASONED"}, "daemon-preauth": {"text": "Non-handshake messages can parse/store declarations and buffer assets before authentication.", "components": ["daemon-source"], "sources": ["daemon-source:s629f3bfbae72", "daemon-source:s07045a0003da", "daemon-source:s2b36558b0078"], "status": "REASONED"}, "daemon-assets": {"text": "Asset limit compares decoded buffered bytes plus incoming base64 string length against 50 MiB; accepted chunks increment decoded length.", "components": ["daemon-source"], "sources": ["daemon-source:s07045a0003da"], "status": "REASONED"}, "daemon-authority": {"text": "Key holder registers tenant runtime receiving invocation parameters/prompts/credentials and returning results; this alone does not demonstrate host shell execution.", "components": ["daemon"], "sources": ["daemon:sa3ee5e4e8a84", "daemon:s14d062f60b1b", "daemon:sa3fa1b50ea09", "daemon:s8e20b988ea0b"], "status": "REASONED"}, "debug-key-access": {"text": "Initialized authenticated member with active workspace after setup can GET current workspace debugging-key under documented default permissions.", "components": ["dify-console"], "sources": ["dify-console:s1cd4f464c880", "dify-console:s5384bb9d1e75", "dify-console:s7cdbcd9957ac", "dify-console:s182d16f6e475", "dify-console:sf9235e345b51", "dify-console:saa9ceb383427", "dify-console:s3998f01569b8", "dify-console:s39e0b615af71"], "status": "REASONED"}, "rbac-default": {"text": "RBAC_ENABLED defaults false and skips enterprise RBAC checks.", "components": ["dify-console"], "sources": ["dify-console:s3998f01569b8", "dify-console:s39e0b615af71"], "status": "REASONED"}, "plugin-permission": {"text": "No plugin-permission row allows every member; existing row default noone differs, and admins permits only admin/owner.", "components": ["dify-console"], "sources": ["dify-console:saa9ceb383427", "dify-console:s1edf3fa3d667"], "status": "REASONED"}, "debug-remedy": {"text": "If debugging is needed, this guide advises an admin or owner to POST /console/api/workspaces/current/plugin/permission/change with debug_permission=admins and preserve install_permission (omitting it defaults to everyone). The server requires is_admin_or_owner, which returns true with RBAC enabled and otherwise requires admin or owner.", "components": ["dify-console"], "sources": ["dify-console:s09ea6abe9b9c", "dify-console:s662ba88445cd", "dify-console:s4b8c82f515c6", "dify-console:s399ff5d5aeda", "dify-console:s77b47ac9fa00", "dify-console:s19d7f56161fe", "dify-console:s2a08cae5a24f", "dify-console:s934c0d2260fe"], "status": "REASONED"}, "debug-permission-read": {"text": "At the pinned Dify commit, usePluginPermissionSettings GETs /workspaces/current/plugin/permission/fetch typed as Permissions, which declares install_permission and debug_permission. This compile-time type does not prove the runtime fields.", "components": ["dify-console"], "sources": ["dify-console:s2a08cae5a24f", "dify-console:s19d7f56161fe"], "status": "REASONED"}, "debug-ui-flags": {"text": "Dify's console canSetPermissions requires enterprise RBAC off and plugin.plugin_preferences (labelled \"Configure auto updates\"); canSetPluginPreferences checks that key without the RBAC-off condition. These are client-side UI flags, not server enforcement; the settings-access hook returns the mutation unconditionally.", "components": ["dify-console"], "sources": ["dify-console:s951644d96965", "dify-console:s6bd02c94df1a", "dify-console:scc3b84b78c0d"], "status": "REASONED"}, "debug-ui-debug": {"text": "Dify's client-side console UI flag canDebugPlugin checks plugin.debug with enterprise RBAC enabled; otherwise it uses debug_permission. With RBAC explicitly false, the legacy helper permits the everyone value, permits admins only for a manager or owner, and otherwise refuses, including a missing UI value; permission fetch returns everyone when no row exists.", "components": ["dify-console"], "sources": ["dify-console:s76d59859d714", "dify-console:sa048a543f4e6", "dify-console:s044852759e49"], "status": "REASONED"}, "debug-fallback-roles": {"text": "At the pinned Dify commit, fallback workspace lists and their role mapping include plugin.plugin_preferences for owner and admin, but not editor, normal or dataset_operator. MyPermissions.get uses those legacy snapshots when external RBAC is disabled (RBAC_ENABLED=false).", "components": ["dify-console"], "sources": ["dify-console:s208c1c06d22a", "dify-console:s518259c2480c", "dify-console:s3e6f88e1a57a", "dify-console:sb23bb894ddfc"], "status": "REASONED"}, "debug-ui-render": {"text": "At the pinned Dify commit, CategoryMarketplacePanel imports usePluginSettingsAccess and reads canInstallPlugin. When the panel is expanded and neither loading nor showing an installed-ID error, it renders List with showInstallButton={canInstallPlugin}; this establishes that prop wiring, not a permission or preferences editor.", "components": ["dify-console"], "sources": ["dify-console:s9cd485b828ea", "dify-console:sb3bc0d1425d8", "dify-console:sb90e0fb1e302", "dify-console:sc795f9b2ed97"], "status": "REASONED"}, "debug-ui-permission-editor": {"text": "From source reading at the pinned Dify commit, PermissionQuickPanel is the console's plugin-permission editor, offering everyone, admins and noone for install_permission and debug_permission. IntegrationSidebarUtilityActions renders it in a popover only when showPermissionQuickPanel and the fetched permission are set; useIntegrationPermissions sets that flag to canSetPermissions && !!permission.", "components": ["dify-console"], "sources": ["dify-console:s575627ec8c3e", "dify-console:s19d7f56161fe", "dify-console:s7dc13aef5fad", "dify-console:s779d35e1fc6a"], "status": "REASONED"}, "debug-ui-permission-page": {"text": "From source reading at the pinned Dify commit, IntegrationsPage renders IntegrationSidebarUtilityActions when canDebugger or showPermissionQuickPanel is true, passing a change handler that copies the fetched permissions and replaces only the edited key. As canSetPermissions requires enterprise RBAC off, this code does not render PermissionQuickPanel with enterprise RBAC enabled.", "components": ["dify-console"], "sources": ["dify-console:sb17d290f2d56", "dify-console:se02bce2f6852", "dify-console:s779d35e1fc6a", "dify-console:s951644d96965"], "status": "REASONED"}, "debug-ui-preferences": {"text": "From source reading at the pinned Dify commit, UpdateSettingDialog is the plugin auto-update preferences dialog: it reads the auto-upgrade settings and, from its Save button's handler, saves through a mutation posting to /workspaces/current/plugin/auto-upgrade/change. DataSourcePage and ModelProviderPage render it only when canSetPluginPreferences is true.", "components": ["dify-console"], "sources": ["dify-console:s0f3d19e35818", "dify-console:sd3fa73d1027a", "dify-console:s876dfc473a9a", "dify-console:sf9038e18b7b7", "dify-console:sbc685dcf7003", "dify-console:sb8f86bc4f4bb", "dify-console:s05472aad90f4", "dify-console:se2de0855cb5c"], "status": "REASONED"}, "debug-ui-preferences-render": {"text": "From source reading at the pinned Dify commit, IntegrationsPage builds UpdateSettingDialog as the plugin-category toolbar action only when showPluginCategorySetting && pluginSettingCategory is truthy, which holds only with canSetPluginPreferences true on the builtin, trigger, agent-strategy or extension section. The section renderer hands it via PluginCategoryPage to PluginsPanel's toolbar.", "components": ["dify-console"], "sources": ["dify-console:sd2d90d42f30e", "dify-console:s779d35e1fc6a", "dify-console:s2112b388c56f", "dify-console:sbe901a6a7f0f", "dify-console:s0e9483720cd2", "dify-console:s8e2e80d03eee", "dify-console:sd829381c4cc8", "dify-console:s1be3b28bced1", "dify-console:sbeb1dfd9dc22", "dify-console:s8deb661dd291", "dify-console:sb21d74d5242e", "dify-console:s9120eb67bf27", "dify-console:sf3053982deb5", "dify-console:s583fe8e50483", "dify-console:se5da68cbe3ed"], "status": "REASONED"}, "debug-ui-preferences-toolbar": {"text": "From source reading at the pinned Dify commit, ToolProviderToolbar renders its own UpdateSettingDialog only when passed no toolbar action and showToolsUpdateSetting, which ProviderList sets for the built-in tab with canSetPluginPreferences true.", "components": ["dify-console"], "sources": ["dify-console:sbd1ca72bd374", "dify-console:sf5c86e049d2e", "dify-console:s06ba07d7da66"], "status": "REASONED"}, "debug-ui-preferences-scope": {"text": "From pinned Dify source, plugin.plugin_preferences gates ModelProviderPage's system model selector, warning and default-model queries, and provider cards' show-models buttons, model-list query and expanded list. SystemModelSelector saves default models and ModelList enables or disables models, both also checking plugin.model_config; this guide's assessment: both are plugin-preferences controls.", "components": ["dify-console"], "sources": ["dify-console:s951644d96965", "dify-console:s6bd02c94df1a", "dify-console:s05472aad90f4", "dify-console:sbda13d664203", "dify-console:scac289555b3a", "dify-console:sfc9de05a028a", "dify-console:s28cfb7bf00e7", "dify-console:sfa219026ee75", "dify-console:s91c1c3f70a6b", "dify-console:scf7ab1e5f2bd", "dify-console:s36b585b52065", "dify-console:s6a7a838cdd95", "dify-console:s57a75b737921", "dify-console:s014eb452b618", "dify-console:sc3f1d34092e9", "dify-console:sb249d98e9f38", "dify-console:s07afe941f336", "dify-console:s439984655581", "dify-console:s3cdacfb1771f"], "status": "REASONED"}, "debug-ui-preferences-hooks": {"text": "From source reading at the pinned Dify commit, the step-by-step tour in mount.tsx uses plugin.plugin_preferences only to drop guide steps aimed at the builtin toolbar action's update-settings target. The hook useWorkspacePluginInstallPermission also computes canSetPluginPreferences without an RBAC-off condition; a bounded search found 11 non-test consumers, none reading it.", "components": ["dify-console"], "sources": ["dify-console:sd2d90d42f30e", "dify-console:scf5e33e1e95c", "dify-console:s8ae5919f70b4", "dify-console:sf0fc667b31e6", "dify-console:s94389798da1c", "dify-console:sca5961dbc1aa", "dify-console:s8d763bacab74", "dify-console:s5c694d22de03", "dify-console:sc7329c1e6a78", "dify-console:sa25c3c12a7f1", "dify-console:s464c875d758a", "dify-console:s0c4f29c820b8", "dify-console:sfed383e68a69", "dify-console:sb0179b200196", "dify-console:s0b0659a01890", "dify-console:s2e0bdffe3847", "dify-console:sefcd2a70d853", "dify-console:se9f48a8f0648", "dify-console:s63e59e6829fc"], "status": "REASONED"}, "debug-ui-search": {"text": "This guide's assessment: a git grep of the pinned Dify web tree, tests excluded, for the import-path pattern update-setting-dialog' found lines naming UpdateSettingDialog only in IntegrationsPage, ToolProviderToolbar, DataSourcePage and ModelProviderPage. Other spellings, constructed paths, other access forms, out-of-repo code and other releases are not covered; no running console was observed.", "components": ["dify-console"], "sources": ["dify-console:sad9867601cae", "dify-console:sd1ccd5c1f14e", "dify-console:s8150a2c00c24", "dify-console:sd57be73324f5"], "status": "REASONED"}, "debug-ui-search-panel": {"text": "This guide's assessment: a git grep of the pinned Dify web tree, tests excluded, for the import-path pattern permission-quick-panel' found lines naming PermissionQuickPanel only in sidebar-actions.tsx; the rest name only the PermissionSettingKey type. Other spellings, constructed paths, other access forms, out-of-repo code and other releases are not covered; no running console was observed.", "components": ["dify-console"], "sources": ["dify-console:s639011087db7", "dify-console:s94cb731c0870", "dify-console:s105343711203"], "status": "REASONED"}, "debug-ui-search-setters": {"text": "This guide's assessment: a git grep of the pinned Dify web tree, tests excluded, for setPluginPermissionSettings and setReferenceSettings found, outside the hooks that define them, only useIntegrationPermissions using setPluginPermissionSettings and no use of setReferenceSettings. Uses that never spell either name, out-of-repo code and other releases are not covered.", "components": ["dify-console"], "sources": ["dify-console:sc5e7961007dd", "dify-console:sa87241614900", "dify-console:sc9b72b868ecf", "dify-console:s8d2f67ff9555", "dify-console:s3bf45f5a91a3"], "status": "REASONED"}, "debug-ui-exclude": {"text": "From source reading at the pinned Dify commit, the marketplace update dialog's exclude handler posts to /workspaces/current/plugin/auto-upgrade/exclude given a pluginId, from DowngradeWarningModal's exclude button. Of two non-test importers only HeaderModals supplies a pluginId and shows that modal, for a downgrade under auto-upgrade; the server removes the plugin from automatic updates.", "components": ["dify-console"], "sources": ["dify-console:s1c0e372be589", "dify-console:s7829fba7e674", "dify-console:s29b9cc634e1d", "dify-console:sfc379be2c044", "dify-console:s813074684a55", "dify-console:s77f103a8f405", "dify-console:sc62845327ce8", "dify-console:saa9feed8fad1", "dify-console:seb5dd87245cd", "dify-console:sf54cd6706956", "dify-console:sac4ac0700c45"], "status": "REASONED"}, "debug-preferences-routes": {"text": "From pinned Dify source, plugin.py's auto-upgrade change and exclude routes apply rbac_permission_required with PLUGIN_PREFERENCES; common/wraps.py defines it, console wraps re-exports it, and it calls the view directly with RBAC_ENABLED false. With RBAC_ENABLED false the change handler then raises Forbidden for an account neither admin nor owner, and the exclude handler has no body check.", "components": ["dify-console"], "sources": ["dify-console:sc62bdba064a2", "dify-console:s68d054d9ebf4", "dify-console:s450bad47112a", "dify-console:s1a5a437e0484", "dify-console:sf9bd8659a8cd", "dify-console:s3998f01569b8"], "status": "REASONED"}, "debug-preferences-model-routes": {"text": "From pinned Dify source, with RBAC_ENABLED false, models.py's default-model save and model save and delete routes require admin or owner, its model enable and disable routes have no role decorator, and trigger_providers.py's PLUGIN_PREFERENCES routes require owner, admin or editor for seven and admin or owner for four.", "components": ["dify-console"], "sources": ["dify-console:sf2ae29ee5c47", "dify-console:s439984655581", "dify-console:s307a2371e4e8", "dify-console:s3cdacfb1771f", "dify-console:s86b35372c7f6", "dify-console:s7603cedbcb3f", "dify-console:s4b59b4de9402", "dify-console:sdb790150ee3c", "dify-console:sc2b4709eac12", "dify-console:s92e997729f73", "dify-console:s222726205b90", "dify-console:s86efe3a26494", "dify-console:s96b8fa35f541", "dify-console:sdccd2c887736", "dify-console:s4a30b77c1d1d", "dify-console:s680d91cba5e7", "dify-console:s7b99e6c596aa", "dify-console:s399ff5d5aeda", "dify-console:s77b47ac9fa00", "dify-console:s3998f01569b8"], "status": "REASONED"}, "debug-rbac-source": {"text": "From source reading at the pinned Dify commit, with RBAC_ENABLED true MyPermissions.get requests /rbac/my-permissions through EnterpriseRequest.send_inner_rbac_request. That method targets ENTERPRISE_RBAC_API_URL (falling back to ENTERPRISE_API_URL) with an Enterprise-Api-Secret-Key header, and the code says that method calls dify-enterprise; the workspace permission keys come from its response.", "components": ["dify-console"], "sources": ["dify-console:sb23bb894ddfc", "dify-console:sd883fffdf223", "dify-console:s014e4df1afa1", "dify-console:sb7ca3bb6fae0", "dify-console:s39e0b615af71"], "status": "REASONED"}, "debug-rbac-roles": {"text": "From source reading at the pinned Dify commit, rbac_service.py's Roles.list reads enterprise role definitions, each with permission_keys, from /rbac/roles through the inner RBAC call, and only its legacy owner and admin lists assign plugin.plugin_preferences. This guide's assessment: enterprise role assignments for that key are held outside this repository.", "components": ["dify-console"], "sources": ["dify-console:s1862570b347e", "dify-console:s7e29bbf50ff2", "dify-console:s014e4df1afa1", "dify-console:s208c1c06d22a"], "status": "REASONED"}, "debug-rbac-search": {"text": "This guide's assessment from a git grep of the pinned Dify api tree, tests excluded, for plugin_preferences and PLUGIN_PREFERENCES: hits are the permission enum, route checks and the legacy owner and admin fallback lists. Other spellings, constructed keys, other access forms, role data outside this repository (such as langgenius/rbac or dify-enterprise) and other releases are not covered.", "components": ["dify-console"], "sources": ["dify-console:sda012ab5d7e9", "dify-console:s2af89a7038dc", "dify-console:sa2918cacd1a3", "dify-console:s1673e386a765", "dify-console:s8d5f79d0ec26", "dify-console:s4bb0a0eeb2a0", "dify-console:sb51b1499f921", "dify-console:sbfcbae529e29", "dify-console:s6c476bf132ca", "dify-console:s94b83b2d3cee", "dify-console:s7fc640d4ccb9", "dify-console:s367962f4d79c", "dify-console:s6da4c319106d", "dify-console:se3b28934c4ab", "dify-console:sec2d764f91e7", "dify-console:s8da45f9dd494", "dify-console:sd10985ef9729", "dify-console:s85a542e32b74", "dify-console:s327574b2d9cb", "dify-console:s208c1c06d22a"], "status": "REASONED"}, "debug-rbac-comment": {"text": "This guide's assessment: at the pinned Dify commit, a source comment above the legacy owner and admin fallback lists says to keep them aligned with langgenius/rbac's built-in workspace roles; that comment does not establish which roles hold plugin.plugin_preferences under enterprise RBAC.", "components": ["dify-console"], "sources": ["dify-console:s208c1c06d22a"], "status": "REASONED"}, "debug-ui-limits": {"text": "This guide's assessment: the cited Dify files do not establish plugin.plugin_preferences role assignments under external or enterprise RBAC, or which permission or preferences controls a given role sees in a running console; the render conditions are client-side source readings. This guide still advises using the permission-change API when debugging is needed, or disabling the listener otherwise.", "components": ["dify-console"], "sources": ["dify-console:sb23bb894ddfc", "dify-console:s9cd485b828ea", "dify-console:sb3bc0d1425d8", "dify-console:sb90e0fb1e302", "dify-console:s09ea6abe9b9c", "dify-console:s7dc13aef5fad", "dify-console:sb7ca3bb6fae0"], "status": "REASONED"}, "debug-disable": {"text": "PLUGIN_REMOTE_INSTALLING_ENABLED defaults true; false prevents TCP startup and removes debugging-key route, while ports: !reset [] separately removes publication.", "components": ["daemon", "docker"], "sources": ["daemon:s191317bdf3a7", "daemon:s32b7d021da01", "daemon:sb19f8717ed1f", "docker:se716ad33db1f"], "status": "REASONED"}, "verify-debug-config": {"text": "Merged Compose should change from published 5003/default-enabled to no publication and explicit false after override and recreation; no Docker/listener demonstration.", "components": ["dify-console", "daemon", "docker"], "sources": ["dify-console:s1e4c822a36b1", "daemon:s191317bdf3a7", "daemon:s32b7d021da01", "daemon:sb19f8717ed1f", "docker:se716ad33db1f"], "status": "REASONED"}, "flowise-tls": {"text": "Flowise documented TLS uses nginx/certbot upstream localhost:3000; configure NUMBER_OF_PROXIES for real client addresses.", "components": ["flowise"], "sources": ["flowise:s7b675aa70798", "flowise:sfee1ae75d51a"], "status": "REASONED"}, "librechat-tls": {"text": "LibreChat documented TLS uses nginx upstream localhost:3080; TRUST_PROXY defaults 1 and must match proxy hops.", "components": ["librechat"], "sources": ["librechat:s432d72870381", "librechat:s41811a0d442f"], "status": "REASONED"}, "langflow-tls": {"text": "Langflow 1.12.6 LANGFLOW_SSL_CERT_FILE/KEY_FILE settings feed native TLS; guide assessment: a proxy remains a place for login/MFA.", "components": ["langflow", "langflow-1126"], "sources": ["langflow:s885461210e4e", "langflow-1126:s46241ad0c9d0", "langflow-1126:s44bfa70223f9", "langflow-1126:sf521d52020be", "langflow-1126:s6cf1768228ea", "langflow-1126:s1ee798ffcdbe", "langflow-1126:s9701f0527287", "langflow-1126:sb2a88f59cc75"], "status": "REASONED"}, "langflow-cookies": {"text": "Langflow 1.12.6 LANGFLOW_ACCESS_SECURE and LANGFLOW_REFRESH_SECURE default false; set both true behind HTTPS.", "components": ["langflow", "langflow-1126"], "sources": ["langflow:s885461210e4e", "langflow-1126:s400ca0f879b4", "langflow-1126:s842c850d80b0", "langflow-1126:s253a7815151a"], "status": "REASONED"}, "dify-tls": {"text": "Certbot challenge/domain/email and fullchain.pem/privkey.pem precede certificate update; enable HTTPS and recreate nginx; default HTTPS false, protocols TLSv1.2 TLSv1.3.", "components": ["certbot", "dify-console"], "sources": ["certbot:sba8e43d56b94", "dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-urls": {"text": "Set CONSOLE_API_URL, CONSOLE_WEB_URL and APP_WEB_URL to public HTTPS; CONSOLE_API_URL controls HTTPS-only cookies.", "components": ["dify"], "sources": ["dify:s144cc3f563a1"], "status": "REASONED"}, "dify-bootstrap": {"text": "INIT_PASSWORD defaults empty; set it before first up to gate /install and claim admin privately.", "components": ["dify", "dify-console"], "sources": ["dify:s144cc3f563a1", "dify:sc63a91c72255", "dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-secret": {"text": "SECRET_KEY signs session/JWT and encrypts stored OAuth credentials; generate random value, or empty auto-generates in storage.", "components": ["dify", "dify-console"], "sources": ["dify:s144cc3f563a1", "dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-registration": {"text": "ALLOW_REGISTER=false default closes ordinary self-registration, but not invitations or /install bootstrap.", "components": ["dify-console"], "sources": ["dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-db-redis": {"text": "Replace DB_PASSWORD and REDIS_PASSWORD defaults difyai123456 and update embedded CELERY_BROKER_URL password.", "components": ["dify-console"], "sources": ["dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-sandbox": {"text": "Replace CODE_EXECUTION_API_KEY/SANDBOX_API_KEY default dify-sandbox together.", "components": ["dify-console"], "sources": ["dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-plugin-keys": {"text": "Replace PLUGIN_DAEMON_KEY and PLUGIN_DIFY_INNER_API_KEY example service credentials.", "components": ["dify-console"], "sources": ["dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-agent-keys": {"text": "Replace DIFY_AGENT_API_TOKEN and DIFY_AGENT_SERVER_SECRET_KEY; signing key needs unpadded base64url of 32 random bytes.", "components": ["dify-console"], "sources": ["dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-weaviate": {"text": "If enabled, rotate WEAVIATE_API_KEY and matching allowed keys; disable anonymous access.", "components": ["dify-console"], "sources": ["dify-console:sb28809d177bf"], "status": "REASONED"}, "dify-api": {"text": "App Bearer keys are separate from console accounts; create per app and keep calls/keys on the backend.", "components": ["dify"], "sources": ["dify:sd5ae6b9492f0"], "status": "REASONED"}, "flowise-accounts": {"text": "From v3.0.1, email/password accounts use JWT HTTP-only cookies; old FLOWISE_USERNAME/PASSWORD are deprecated migration settings; claim admin privately.", "components": ["flowise-auth"], "sources": ["flowise-auth:s6fd9f4266a98"], "status": "REASONED"}, "flowise-secrets": {"text": "Randomize JWT_AUTH_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET, EXPRESS_SESSION_SECRET default flowise, and TOKEN_HASH_SECRET; APP_URL defaults localhost:3000.", "components": ["flowise-auth", "flowise"], "sources": ["flowise-auth:s6fd9f4266a98", "flowise:sfee1ae75d51a"], "status": "REASONED"}, "flowise-encryption": {"text": "FLOWISE_SECRETKEY_OVERWRITE supplies stored-credential encryption key; otherwise it lives under SECRETKEY_PATH.", "components": ["flowise"], "sources": ["flowise:sfee1ae75d51a"], "status": "REASONED"}, "flowise-prediction": {"text": "Chatflow without assigned key is public by ID; assign per-chatflow API key (DefaultKey is precreated), Bearer requests reject missing key with 401.", "components": ["flowise"], "sources": ["flowise:s546a18b65918", "flowise:s23c31010d23d"], "status": "REASONED"}, "langflow-auto": {"text": "Langflow 1.12.6 application AUTO_LOGIN defaults True; pinned official image Dockerfiles set false; explicitly disable it and set a superuser password other than legacy langflow; username defaults langflow and password defaults empty.", "components": ["langflow", "langflow-1126"], "sources": ["langflow:s5cfeeba4d9b3", "langflow:s885461210e4e", "langflow-1126:sd7fd176a7394", "langflow-1126:sb7ab44ba0da0", "langflow-1126:s8e8d73b2b8bd", "langflow-1126:s57f0e83270f9", "langflow-1126:s419881904281", "langflow-1126:s6485ba67d66f", "langflow-1126:s3ff960d5d18a", "langflow-1126:s842c850d80b0", "langflow-1126:s253a7815151a"], "status": "REASONED"}, "langflow-secret": {"text": "Set permanent random LANGFLOW_SECRET_KEY; documented auto-generated key is unsuitable for production.", "components": ["langflow"], "sources": ["langflow:s5a55bdef1f33"], "status": "REASONED"}, "langflow-signup": {"text": "NEW_USER_IS_ACTIVE defaults False but ENABLE_SIGNUP True; disable signup and keep activation requirement.", "components": ["langflow"], "sources": ["langflow:s885461210e4e"], "status": "REASONED"}, "langflow-api": {"text": "With auto-login off, POST /api/v1/run/ needs x-api-key; create via settings or CLI. SKIP_AUTH_AUTO_LOGIN defaults false, applies only with auto-login and is slated for removal.", "components": ["langflow"], "sources": ["langflow:s5cfeeba4d9b3", "langflow:s885461210e4e"], "status": "REASONED"}, "langflow-host": {"text": "Langflow 1.12.6 settings default HOST localhost; guide assessment: bridged containers need 0.0.0.0 plus host-loopback publication and injected env_file/environment; rolling docs remain unversioned.", "components": ["langflow", "langflow-compose", "langflow-1126"], "sources": ["langflow:s885461210e4e", "langflow-compose:s7f4564c949cb", "langflow-1126:s8049fb15747e", "langflow-1126:s44bfa70223f9"], "status": "REASONED"}, "librechat-bootstrap": {"text": "First registered user becomes admin; close ALLOW_REGISTRATION after claiming it.", "components": ["librechat"], "sources": ["librechat:s3f1c594392bc", "librechat:s41811a0d442f"], "status": "REASONED"}, "librechat-sso": {"text": "For SSO-only, disable email login and deliberately enable social registration/login; configure OPENID_* and optional required role with provider allowlist and MFA.", "components": ["librechat"], "sources": ["librechat:s41811a0d442f", "librechat:s70ac8e7c9504", "librechat:s7896961bc713", "librechat:s43fbe74e3c42"], "status": "REASONED"}, "librechat-secrets": {"text": "CREDS_KEY is 32 bytes, CREDS_IV 16; JWT secrets at least 32 bytes. Blank values bootstrap keys with persistence caveats; retired JWT defaults are refused.", "components": ["librechat"], "sources": ["librechat:s41811a0d442f"], "status": "REASONED"}, "librechat-urls": {"text": "DOMAIN_CLIENT and DOMAIN_SERVER must use public HTTPS URL.", "components": ["librechat"], "sources": ["librechat:s41811a0d442f", "librechat:s432d72870381"], "status": "REASONED"}, "mfa": {"text": "Instance-wide native MFA enforcement is not documented; use provider/fronting MFA. Flowise SSO is Enterprise; Langflow offers external JWT/JWKS.", "components": ["flowise", "langflow", "librechat"], "sources": ["flowise:scabc8335d0c7", "langflow:sec83eaf2b841", "librechat:s70ac8e7c9504"], "status": "REASONED"}, "librechat-mfa": {"text": "v0.7.7 changelog lists two-factor enrolment/backup codes, but checked authentication docs do not establish enforced MFA.", "components": ["librechat-mfa", "librechat"], "sources": ["librechat-mfa:s3909883e4681", "librechat:s70ac8e7c9504"], "status": "REASONED"}, "containment": {"text": "Treat editor as code/SSRF authority; isolate runtime, limit mounts/privileges and default-deny egress including metadata; rotate exposed provider keys.", "components": ["langflow", "flowise", "librechat", "dify-console"], "sources": ["langflow:sca4ee617805d", "flowise:sfee1ae75d51a", "librechat:s9f9e35619ce7", "dify-console:sb28809d177bf"], "status": "REASONED"}, "flowise-guards": {"text": "Keep HTTP_SECURITY_CHECK and CUSTOM_MCP_SECURITY_CHECK enabled; disabling MCP check permits arbitrary command execution.", "components": ["flowise"], "sources": ["flowise:sfee1ae75d51a"], "status": "REASONED"}, "librechat-actions": {"text": "Unset actions.allowedDomains allows public domains with private-target SSRF checks; configured allowlist denies others, and listed private destination grants exception.", "components": ["librechat"], "sources": ["librechat:s9f9e35619ce7"], "status": "REASONED"}, "dify-ssrf": {"text": "Keep Dify SSRF proxy for sandbox and HTTP-request nodes; HTTP allowlists do not contain arbitrary local tools.", "components": ["dify-console", "dify-compose"], "sources": ["dify-console:sb28809d177bf", "dify-compose:s517af9c43635"], "status": "REASONED"}, "verify-network": {"text": "Inventory all listeners and actual Publishers; direct TCP 5003 success proves exposure, refusal/timeout needs same-host reachable control and merged-model evidence.", "components": ["dify-console", "daemon", "docker"], "sources": ["dify-console:s1e4c822a36b1", "daemon:s32b7d021da01", "docker:s3e97c2c250b6", "docker:s351180c6678f"], "status": "REASONED", "verify": [1, 2]}, "verify-editor": {"text": "Curl checks transport only; fresh unauthenticated browser must show login, and /install must be already claimed rather than open admin setup.", "components": ["dify", "flowise-auth", "langflow", "librechat"], "sources": ["dify:sc63a91c72255", "flowise-auth:s6fd9f4266a98", "langflow:s5cfeeba4d9b3", "librechat:s70ac8e7c9504"], "status": "REASONED", "verify": [3]}, "verify-dify": {"text": "Direct /v1/parameters matched pair expects missing-Bearer 401 and valid-key parameters 200; app_unavailable 400 is inconclusive; bundled nginx is not strict api-service attribution.", "components": ["dify"], "sources": ["dify:sd5ae6b9492f0"], "status": "REASONED", "verify": [4]}, "verify-flowise": {"text": "Assigned-key chatflow prediction must reject missing key and return real output with valid Bearer key; keyless flow is public.", "components": ["flowise"], "sources": ["flowise:s546a18b65918", "flowise:s23c31010d23d"], "status": "REASONED", "verify": [4]}, "verify-langflow": {"text": "Run API must reject absent x-api-key and return real output with valid key; routing, transport or validation errors are inconclusive.", "components": ["langflow"], "sources": ["langflow:s5cfeeba4d9b3"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> ai-infra-services.md ====================================================================== # AI infrastructure services: SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0, and Onyx SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 and Onyx are building blocks for AI apps, from web search to agent memory. This guide helps your AI assistant configure each securely, including authenticated TLS ingress, built-in login or keys enabled where available, and unpublished backing services, apart from deliberate loopback access. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/ai-infra-services.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against SearxNG documentation (no recorded version), TEI HTTP source v1.9.0, Onyx v4.3 source v4.3.13, Onyx v4.4 source v4.4.0, Onyx v4.5 source v4.5.0, Onyx v4.9 source v4.9.2 and v4.9.4, and GNU grep 3.12; 7 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-10-20; documentation checked September 2026. The guide records 53 claims: 1 demonstrated by a check that ran and 52 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These services expose search, inference, embeddings, document access, or an agent's memory. Their authentication stories differ: SearxNG and LangServe require a fronting layer or application authentication, LocalAI and Text Embeddings Inference each have a key that is unset by default, and Mem0 and Onyx ship authentication on. The deployment command decides what the network sees. Mem0's Compose file and Onyx's development Compose publish backing services beside the authenticated application, where application login cannot protect them. Keep the application and its backing services private, publish only an authenticated TLS ingress, and keep or enable native authentication wherever it exists. ## Deployment forms and controls Defaults below describe the vendor examples checked on 2026-09-13. A **host publication** is Docker's mapping, not the application's own listening address. The listed ports are an inventory of those examples; inspect the running deployment for additions and changed host ports. | Tool and launch form | Default listener or host publication | Authentication by default | Also published beside it | Bind or publication control | Authentication control | | --- | --- | --- | --- | --- | --- | | SearxNG, direct | Application: `127.0.0.1:8888` | None | No container publication in this form | `server.bind_address` and `server.port` in `settings.yml` | [Front it](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) | | SearxNG, documented Docker run | All host interfaces, `8888:8080` | None | No other mapping in the cited run | Replace with `127.0.0.1:8888:8080`, or publish nothing | [Front it](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) | | LocalAI, documented Docker run | All host interfaces, `8080:8080` | Off: native key is unset | No additional publications established by the verified record | Replace with `127.0.0.1:8080:8080`, or publish nothing | Set `LOCALAI_API_KEY`; `LOCALAI_AUTH=true` enables multi-user OAuth with per-user keys | | Text Embeddings Inference, documented Docker run | All host interfaces, `8080:80` | Partial: `--api-key` / `API_KEY` is unset by default, and even when set it covers the inference routes only | On the same port, outside the key: `/`, `/health`, `/ping`, `/metrics`, `/docs`, `/api-doc/openapi.json` (in the documented HTTP build `/metrics` is served on this main port; `--prometheus-port`, default `9000`, starts a separate exporter only in the gRPC build) | Replace with `127.0.0.1:8080:80`, or publish nothing | Set `--api-key` or `API_KEY`, and front the service, because the key alone leaves the routes above open | | LangServe, quickstart | Application: `localhost:8000` | None supplied; the application author provides authentication | No container publication in this form | `host=` in `uvicorn.run` | FastAPI dependencies, or [front it](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) | | Mem0, server Compose | Application: `0.0.0.0:8000` inside the container; all host interfaces, `8888:8000` | On: bearer JWT, per-user `X-API-Key`, or legacy admin key | PostgreSQL `8432:5432`; dashboard `3000:3000`, both on all host interfaces | Remove PostgreSQL's publication; remove or loopback-scope API and dashboard mappings | Keep authentication enabled; do not set `AUTH_DISABLED=true` | | Onyx, production Compose | nginx host publications `80:80` and `443:443` | On: email/password | Backing services have no host publications | Keep backing services unpublished; configure the intended TLS ingress | Keep the built-in login; this guide infers from the v4.3.13 and v4.4.0 tag trees that `AUTH_TYPE` no longer selects runtime authentication in v4.4.0, apart from two one-time legacy imports (the SSO provider migration's Google or OIDC row and the startup SAML import) | | Onyx, development Compose | nginx `80:80` and `3000:80` from the base file, plus API `8080:8080` from the override; all on every host interface | On at the application | PostgreSQL `5432:5432`, OpenSearch `9200:9200`, inference `9000:9000`, Redis `6379:6379`, MinIO `9004:9000` and `9005:9001`, code interpreter `8000:8000` | Use production Compose, or remove every unnecessary publication from the effective configuration | Application authentication is on; it does not reach the backing ports | ## 1. Bind privately, including every container publication For a process running directly on the host, bind it to loopback explicitly. SearxNG already defaults to `127.0.0.1`, but LocalAI defaults to `:8080` and Text Embeddings Inference to `0.0.0.0:3000`, so bind those yourself: LocalAI `LOCALAI_ADDRESS=127.0.0.1:8080` (or `--address`), TEI `--hostname 127.0.0.1` with the intended `--port` (its CLI port default `3000` is distinct from the container port `80`). SearxNG's settings belong under `server:` in `settings.yml`: ```yaml server: bind_address: "127.0.0.1" port: 8888 ``` LangServe's quickstart uses: ```python uvicorn.run(app, host="localhost", port=8000) ``` For containers, remove host publications when the proxy reaches the application over a private container network. Where a proxy running on the host needs a published backend port, use the loopback mapping in the table. Replace the existing mapping; adding another mapping does not remove the original. A container's loopback address belongs to that container. Do not change its application bind to `127.0.0.1` and expect another container to reach it. Restrict the host publication, or leave the service unpublished on the network shared with its proxy. Docker publications without a host IP expose the port on all host interfaces. On Linux, Docker's forwarding rules can bypass the chains used by UFW; a UFW deny is not a substitute for removing the publication ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). Compose overrides need particular care. An ordinary `ports:` list can merge with the base list and retain the public mapping. Edit the base mapping, or use Compose's documented `!reset` mechanism to remove the list. For example, an override for Mem0 can remove PostgreSQL's publication: ```yaml services: postgres: ports: !reset [] ``` This removes only PostgreSQL's publication. The API and dashboard still need the treatment described below. Include the override in the actual deployment invocation, and inspect the merged configuration and running publications using Verify check 2. If the installed Compose rejects `!reset`, edit the base file instead; do not replace it with an ordinary empty list and assume the publication disappeared. ## 2. Put TLS and authentication at the ingress Use [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), or [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md) for TLS. The proxy reaches loopback when it runs on the host, or the application's service address when both run on a private container network. Only the intended ingress receives public traffic. For human access, add the login and identity policy in [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). For machine clients, enforce a credential at the proxy, such as the bearer-token pattern in [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md). Protect every route that reaches the service's capabilities; a login page on the UI does not establish protection of the API. A private route through [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) or an authenticated tunnel through [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) is another option. A tunnel still needs an access policy. Onyx's production Compose publishes only nginx. That establishes the intended publication boundary; it does not, by itself, demonstrate a working certificate or the deployed access policy. ## 3. Auth absent or unset ### SearxNG The direct configuration binds loopback, but the documented Docker command publishes `8888:8080` without a host IP. Use `127.0.0.1:8888:8080` behind a host proxy, or remove the publication behind a containerized proxy. SearxNG has no authentication in the verified configuration. Put access control in front of it. An open search service lets other people make search requests through your deployment. Set a unique `server.secret_key` (environment `SEARXNG_SECRET`): it is a cryptographic secret, not user authentication, and SearxNG exits at startup in production rather than run with the shipped `ultrasecretkey` placeholder. SearxNG also fetches remote URLs server-side and its image proxy follows redirects, so it is an SSRF vector: keep it behind the fronting auth and restrict its outbound reach so a search or image fetch cannot be steered at your internal services or the cloud metadata address ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). ### LocalAI LocalAI has native authentication; the documented deployment leaves it off unless configured. Set a strong, unique value in the service's runtime environment: ```dotenv LOCALAI_API_KEY=REPLACE_WITH_LONG_RANDOM_KEY ``` For multi-user OAuth and per-user keys, the documented control is: ```dotenv LOCALAI_AUTH=true ``` Use the vendor's multi-user setup instructions when choosing that mode. Keep authentication at the ingress as well, and replace the documented `8080:8080` publication with the private arrangement in section 1. ### Text Embeddings Inference The documented Docker run publishes host port 8080 to container port 80. Replace `8080:80` with `127.0.0.1:8080:80` behind a host proxy, or publish nothing behind a containerized proxy. There is a native inbound control, and it is off until you set it. The CLI reference documents `--api-key`, with the environment variable `API_KEY`. With no key set the server responds to every request; with one set, a request must carry the key as a bearer token in the `Authorization` header. Set it, and front the service as well, because one static key is not a user model. The key does not cover the whole port. In the HTTP server the key middleware is applied to the inference routes alone, and the health routes `/`, `/health` and `/ping`, the Prometheus route `/metrics`, and the OpenAPI surface `/docs` and `/api-doc/openapi.json` are merged into the same application beside that layer rather than beneath it. They answer anonymously on the published port with a key set. Read `/metrics` yourself before deciding that is acceptable: it reports model identity and request volumes. Checked against v1.9.0; treat it as version-specific and re-read for your own version. Two defaults compound it. `--hostname` defaults to `0.0.0.0`, so the process listens on every interface inside its namespace and the publication alone decides what reaches it. In the documented HTTP build `/metrics` is served on the main application port, not on a separate listener (the `--prometheus-port` default `9000` starts a standalone exporter only in the gRPC build), so publishing the main port publishes `/metrics` with it; do not rely on leaving `9000` unpublished to hide it. Confirm which build you run before deciding either is closed. Do not assume a token used to download a model authenticates incoming embedding requests. ### LangServe Lifecycle note, as of September 2026: LangServe was deprecated on 2024-11-18. Its README recommends LangGraph Platform for new projects. Existing deployments still need these controls while they remain in service. Keep the quickstart's loopback bind. LangServe makes the application author responsible for authentication, with FastAPI dependencies as the documented integration point. Implement that authentication or put the complete service behind an authenticating proxy, including callable routes. ## 4. A guarded front door with the backing store published beside it Mem0 and Onyx's development Compose share this shape. The application has a real authentication gate, but a connection to the database, index, cache, or object-store port never passes through that gate. A PostgreSQL password prompt does not fix this deployment boundary. The database remains reachable independently of the application. Remove its host publication and retain the database's own authentication as a separate control. ### Mem0 Keep authentication on. Protected API endpoints accept a bearer JWT from the dashboard login flow or an `X-API-Key` header. Per-user keys have the `m0sk_` prefix; the legacy `ADMIN_API_KEY` is also supported. Do not leave `AUTH_DISABLED=true` in a deployed environment. The server logs a warning at startup when it is enabled, and a different one when authentication is on and a NON-EMPTY `ADMIN_API_KEY` is shorter than 16 characters; an unset key takes a third branch of its own. The two are alternatives on one `if`/`elif`, so enabling `AUTH_DISABLED` suppresses the short-key warning rather than adding to it, and neither stops the start. `JWT_SECRET` is not optional, and the server enforces it: with authentication on and no secret set it raises at startup and refuses to run. The documented `server/.env` sets it beside `OPENAI_API_KEY`, generated with `openssl rand -base64 48`, and the JWTs that carry a dashboard session are signed with it. Note what that enforcement implies: the only way to start this server without a signing secret is to turn authentication off altogether. Register the first admin before the deployment is reachable from anywhere but the host. `POST /auth/register` succeeds only while no user exists and returns `403` afterwards, so an instance published before that call hands admin to whoever finds it first. Bootstrap it on the host, either with `make bootstrap` from `server/`, which starts Compose, creates the admin, and issues the first API key, or through the setup wizard while the dashboard is still host-only. The `/` redirect, `/docs`, and `/openapi.json` stay open in every configuration, so reaching one of those proves nothing about the protected endpoints. The server Compose publishes three services: - API: host 8888 to container 8000. - PostgreSQL: host 8432 to container 5432. - Dashboard: host 3000 to container 3000. Remove PostgreSQL's host publication. For a host proxy, replace the API and dashboard mappings with `127.0.0.1:8888:8000` and `127.0.0.1:3000:3000`. For a containerized proxy, leave both unpublished and connect the proxy to their private network. The dashboard's own authentication coverage is **unverified here**. Give it its own protected ingress route; the API's authentication does not establish the dashboard's policy. The cited Compose command runs uvicorn with `--host 0.0.0.0 --port 8000 --reload`. `--reload` is a development-server flag. Remove it from a production invocation and retain the network restrictions above. ### Onyx Use `deployment/docker_compose/docker-compose.prod.yml` as the production deployment basis. In the verified file, only nginx publishes host ports; the backing services stay on the Docker network. The first user to sign up becomes an admin. Complete that sign-up while the deployment is still reachable only from the host, because until it happens the login page is an admin-enrolment form for whoever reaches it first. `docker-compose.dev.yml` is an override, not a standalone file. Its own header gives the launch form as `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait`, so the effective configuration is the base file plus the override, and the base file publishes nginx on `${HOST_PORT_80:-80}:80` and `${HOST_PORT:-3000}:80`. Enumerate the merged result, not the override alone: an inventory taken from the override misses two published ports. If the development file is running, remove the API publication and every backing publication listed in the table, except any deliberately retained loopback access. The six backing services account for seven host ports because MinIO publishes both its API and console. The API's 8080 publication is additional, and nginx's two come from the base file. The development mappings include expressions such as `${POSTGRES_HOST_PORT:-5432}:5432`. Setting `POSTGRES_HOST_PORT` changes the host port. Leaving it empty selects the default. Neither action removes the publication. The same trap appears in Dify's plugin-daemon mapping in [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md). Email/password authentication is already enabled. This guide infers from the v4.3.13 and v4.4.0 tag trees that `AUTH_TYPE` no longer selects runtime authentication in v4.4.0, apart from two one-time legacy imports that can add login providers; v4.4.0 still reads legacy values for migrations, a one-time legacy SAML import at startup, and a stale-value warning. Read as source, the SSO provider migration can insert an enabled Google or OIDC provider row, and the startup SAML import can add a SAML provider row, enabled by the model's default; at v4.4.0 `main.py` always mounts the OIDC and SAML routers that serve enabled provider rows. The version identity rests on the tag names, since neither cited web package manifest records its tag's release version. The basic authentication page, fetched on 2026-11-04, says: "The `AUTH_TYPE` environment variable is no longer used. Onyx ignores it and logs a warning at startup." It goes on: "Remove it from your deployment. SSO is configured in the admin panel, not with `AUTH_TYPE`." The page shows no date and names no version. As fetched in October 2026, the page said instead that since `v4.4.0` the variable "is inert and it is planned for full removal in `v4.5`". Because the page names no version, this guide does not apply "Onyx ignores it" to the v4.5.0 and v4.9.2 tags, where, read as source below, the two helpers and the two legacy migrations still read `AUTH_TYPE`. At v4.5.0 and at v4.9.2, the production environment template heads its commented legacy pre-v4.4 SSO block, which lists `AUTH_TYPE`, with "Planned for removal in v4.5." The v4.4.0 release notes list `feat(auth): remove AUTH_TYPE and the AuthType enum, serve multi_tenant`. None of these records that the full removal planned for v4.5 was carried out. Nor do the release notes of the 48 stable releases from v4.5.0 to v5.0.0 that the GitHub release list fetched on 2026-10-25 names: read as documentation, none of them records `AUTH_TYPE` or a legacy SSO variable as removed, so a removal record is not found in that scope on 2026-10-25. The v4.5.0 notes list `chore(deployment): stop advertising env-based SSO setup in templates and installers`, which this guide reads as a template and installer change, not a removal record. The documentation site's index pages, the other part of that scope, were searched on 2026-11-04 for `AUTH_TYPE`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OPENID_CONFIG_URL` and `SAML_CONF_DIR`. The pages searched were `llms.txt`, the sitemap, the changelog page titled "Release Notes" that the v5.0.0 GitHub release notes link, and the release process page that both index pages list. The `llms-full.txt` export fetched that day holds the 3 migration pages that `llms.txt` lists, on the document index, the object store and permissions, and none of them names any of those variables. Of the pages searched, only the changelog's v4.4.0 entry, dated July 21st, 2026, says "`AUTH_TYPE` has been removed", adding that `AUTH_TYPE` and the legacy SSO variables "are now inert and only emit a startup warning". No changelog entry from v4.5.0, dated August 4th, 2026, to v5.0.0, dated October 23rd, 2026, names any of those variables or records the full removal planned for v4.5. Nor do `llms.txt`, the sitemap and the release process page record a removal, so a documentation index record of the full removal planned for v4.5 is not found in that scope on 2026-11-04. In the same export, the configuration page calls the variable inert and says "Planned for full removal in `v4.5`.", and the upgrade sections of the Google OAuth, OIDC and SAML pages tell readers to remove `AUTH_TYPE` from their deployment. Those sections say that "Onyx logs a warning while `AUTH_TYPE` is still set", and this guide reads them, with the configuration page, as a plan and deployment steps, not a removal record. At v4.5.0 and v4.9.2, `main.py` still calls `verify_auth_setting()` under a comment describing a warning on stale `AUTH_TYPE` values, and calls `seed_saml_provider_from_conf_dir(db_session)` at startup inside `if not MULTI_TENANT`; its comment describes a legacy SAML import that is a no-op unless `AUTH_TYPE=saml` with no SAML row yet. Read as source, `main.py` is byte-identical at v4.9.2, v4.9.3 and v4.9.4, so both calls, their comments and the router mounting cited below are unchanged at v4.9.4, the newest stable release tag reviewed here. This guide therefore assesses that those legacy startup calls were not removed in any of v4.5.0, v4.9.2, v4.9.3 and v4.9.4; the call sites and comments do not establish runtime authentication selection or the helpers' complete behaviour. These citations are pinned to the commits that the v4.5.0, v4.9.2 and v4.9.4 tags resolved to. The tag list fetched on 2026-10-24 has no stable tag after v4.9.4; it has v5.0.0 pre-release tags up to v5.0.0-beta.3 and no v5.0.0. The GitHub release list fetched on 2026-10-25 names a stable v5.0.0, published at 2026-10-24T06:46:01Z, whose source this guide has not reviewed. This guide does not treat a beta as a release, so v4.9.4 stays its newest reviewed release. Read as source for comparison only, v5.0.0-beta.3 keeps the same warning call, the same startup SAML import call and comment inside `if not MULTI_TENANT`, the same two helper bodies and the same cited router mounting, at shifted lines in `main.py` and `users.py`. Inside that `if not MULTI_TENANT` block it drops only a telemetry comment and a `get_or_generate_uuid()` call. After that block, where v4.9.4 has a further `if not MULTI_TENANT` block calling `optional_telemetry`, it calls `start_telemetry("api")` instead, and just before the cited routers it adds two `/oauth-provider` routers gated on `OAUTH_PROVIDER_SETTINGS`, which this guide has not reviewed. This guide therefore assesses that the beta changes nothing stated here about the cited calls, helpers and routers. The helper bodies at the v4.5.0 and v4.9.2 commits, read as source without an Onyx run, behave as follows. `verify_auth_setting()` logs a warning when `AUTH_TYPE` is `disabled`, another when it is `google_oauth`, `oidc` or `saml`, and then a notice naming the mode as `cloud` or `basic` from `MULTI_TENANT`; it contains no `raise` and returns no value. `seed_saml_provider_from_conf_dir` returns early in multi-tenant mode, when `AUTH_TYPE` is not `saml`, when a SAML provider row already exists, and when `SAML_CONF_DIR/settings.json` cannot be read, is not valid JSON or lacks the required IdP values or the SP entity ID. Otherwise it creates one SAML provider row from that file, adding `certs/sp.key` when it can be read, and catches only `IntegrityError` from that call. In the fetched repository, both function bodies are identical at v4.4.0, v4.5.0 and v4.9.2, and in a later fetch `users.py` and `sso_provider.py` are byte-identical at v4.9.2, v4.9.3 and v4.9.4. Read as source at v4.9.2 to v4.9.4 without an Onyx run, the seed's row creation validates the provider name, which the seed fixes as the valid slug `saml`, and validates the config against the SAML config model, raising `ValueError` when either check fails. The seed has already required that model's four required fields as nonempty strings, and beside them it passes only the model's optional `sp_x509_cert`, when `settings.json` gives it as a nonempty string, and `sp_private_key`, when `certs/sp.key` is read and nonempty; whether any value it passes can still fail the model's string validation depends on pydantic, whose source was not read. The seed catches neither that `ValueError` nor any database error other than `IntegrityError`, and its `certs/sp.key` read, which runs before the required-value check, catches only `OSError`, so a key file that is not UTF-8 raises `UnicodeDecodeError` there. Any of these exceptions passes out of the startup database session block and ends the base `lifespan` startup hook in `main.py` before it yields. What the ASGI server then does, and whether a given legacy file stops a deployment's startup, has not been observed. With EE on, `main.py` can take `get_application` from `ee.onyx.main`, whose `main.py` the paragraph below reads as source at v4.5.0 and v4.9.2. Read as source, EE is on by default: `utils/variable_functionality.py` turns it on when `LICENSE_ENFORCEMENT_ENABLED` is unset, which it treats as `true`, or is `true` in any letter case. This guide therefore assesses that a deployment that keeps that default may run the EE path. Read as source at v4.9.2 to v4.9.4, where `backend/ee/onyx/main.py` is byte-identical in the fetched files, the EE `lifespan` enters the base `lifespan` before its own `yield`, so in this guide's inference these seed errors end the EE startup hook too. Extending the v4.4.0 inference to these tags, as source reading and this guide's inference rather than an observed deployment: a case-sensitive search for `AUTH_TYPE` in the `backend` and `web` trees at the v4.5.0 and v4.9.2 commits finds it read only by the two helpers and the two legacy migrations, and otherwise only in comments, tests and a test README. In the fetched repository, the two migration files are byte-identical to the v4.4.0 files cited above. At both tags `main.py` mounts the password login routers "in every deployment mode", gates only the env-credential Google login routers, web and mobile, on configured OAuth credentials, and always mounts the SAML and OIDC routers that serve provider rows. Read as source, `AUTH_TYPE` can still add login providers through two one-time legacy imports. When the SSO provider migration creates the provider table in single-tenant mode, it inserts an enabled Google or OIDC provider row if `AUTH_TYPE` is `google_oauth` (or `oidc` with `OPENID_CONFIG_URL` set), OAuth credentials are configured and the new table is still empty. The always-mounted OIDC router serves that row. The startup SAML import can add a SAML provider row, enabled by the model's default, that the always-mounted SAML router serves. Read as source at both commits, `backend/ee/onyx/main.py`, which both recursive trees list, contains no `AUTH_TYPE`; its `lifespan` runs the base hook through `async with lifespan_base(app):` before its own `seed_db()` and `yield`, and its `get_application` calls `get_application_base(lifespan_override=lifespan)`. It mounts a Google OAuth login router at `/auth/oauth`, built from `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET`, only when `MULTI_TENANT` is set; the other routers it mounts, including `ee_oauth_router` and the SCIM router, were not read. A case-sensitive string search of every file in `backend/ee` at both commits, 208 files at v4.5.0 and 239 at v4.9.2, finds no `AUTH_TYPE`; it is a string search, not a reading of each file. A case-insensitive search of the same files finds one match, line 90 of `backend/ee/onyx/server/scim/api.py` at v4.9.2, which is an unrelated `auth_type` keyword argument to an audit actor, not the environment variable. The case-sensitive `backend` search cited above therefore has the same result whether or not it covered `backend/ee`. Read as source, `main.py`, whose cited v4.9.4 factory uses `lifespan_override` when a caller passes it, and `backend/ee/onyx/main.py` are each byte-identical in the fetched files at v4.9.2, v4.9.3 and v4.9.4. In this guide's inference, an EE deployment at those three tags therefore runs the base startup calls and router mounting cited here, and an exception that ends the base `lifespan` before it yields ends the EE one too, which narrows runtime selection without settling it. The v4.5.0 base factory is not cited, so this inference is not extended to v4.5.0. Apart from these imports, `AUTH_TYPE` does not select runtime authentication at v4.5.0 or v4.9.2; that conclusion is this guide's inference, and deployed selection at those tags has not been observed. This guide's advice: do not set `AUTH_TYPE` expecting to change authentication. The Docker documentation also describes access at `localhost:3000`. That is now established from the base Compose file rather than inferred: host 3000 maps to nginx's container port 80, and `web_server` carries no host publication of its own. So `localhost:3000` reaches nginx, not the web process directly, and the web process is not separately published. For backing-service controls, see [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md), [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md), and the OpenSearch material in [elasticsearch.md](https://github.com/jposluns/secureconfig/blob/main/elasticsearch.md). Those controls complement removing unnecessary publications. Onyx also stores the credentials for each document-source connector you add through its UI (a retained `credential_json` per connector), so those are protected stored data, not just runtime configuration: give each connector the least privilege its source allows, protect the database and its backups where the credentials live, and rotate a connector's credential if the instance was ever exposed. ## 5. MFA and secrets Enforce MFA for human access through the identity provider or fronting layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md)). Native MFA coverage for these six services is **unverified here**; this guide relies on the fronting policy. Machine clients need separate credentials, with the narrowest permissions the application or gateway supports. Keep provider keys, service keys, and signing secrets out of source control and images; supply them at runtime per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). A provider credential used by the application is not an inbound access control. ## Verify REASONED: Verify overview and the historical run record, per the guide's cited SearxNG server settings, LocalAI getting started, Text Embeddings Inference CLI arguments, LangServe README at commit `27e57af`, Mem0 REST API, Onyx basic authentication, Docker packet filtering and firewalls, Docker Compose ps, curl manual and GNU grep 3.12 manual, exit status (checked October 2026). The GNU manual documents statuses 0 for a selected line, 1 for no selected lines and 2 for an error; the house runs observed statuses in Bash, dash and BusyBox ash and did not record which grep implementation or version ran. In local marking review (October 2026, Bash 5.3.9), bash -n and ShellCheck 0.11.0 on check 1 exited 0 without output; a placeholder fixture was recorded with `grep_exit=0`, a clean fixture printed `grep_exit=1`, and a missing fixture printed `grep_exit=2`. Bash, dash and BusyBox ash, with and without `set -u`, printed the token and `grep_exit=0` for the exposed fixture and `grep_exit=1` for the fixed fixture. Check 2 bash -n and ShellCheck 0.11.0 exited 0 without output; stub ss printed `-tlnp`, and stub docker printed `compose config` and `compose ps --format json`. Those check 2 runs covered an earlier form of that block, which ran its three commands at top level and ended with the last command's status; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, which runs them in a subshell and exits with status 3 when one fails or the running inventory lists no service, otherwise 2 when `ss` is not installed, else 0. On 2026-10-31 the current form of check 2 also passed bash -n and ShellCheck 0.11.0 and ran whole against stub `ss` and `docker` with no network, with `ss` present and passing, present and failing, and missing; no live run of it is recorded. In dash and BusyBox ash, checks 6 and 7 printed `run this block in bash; not probing` with and without exported `BASH_VERSION`, with trap diagnostics on stderr and no curl stub call. Stubbed checks 3 to 7 printed `not probing` for empty or placeholder values and `STUB curl` or `STUB nc` for substituted values. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expected exposed outcomes are an anonymous protected response or a reachable direct port; fixed outcomes are anonymous rejection or direct refusal, with application output through a configured authorized ingress when valid credentials are used. Bash check 1 wrong-marker and short-count fixtures printed `paste the whole block, including its set -- line; not checking` and `the set -- line needs exactly 1 value; not checking`; its filename-placeholder fixture printed `substitute the file name on the set -- line above; not checking` and exited 1. A direct grep of a fixture printed `:2:REPLACE_WITH_TOKEN`. These local runs establish guard and placeholder-scan behaviour only; none establishes live service behaviour. An external vantage, TLS exchange, configured authorized ingress and valid credentials were not tested; their availability is unknown, so the positive control remains an expectation, not an observation. **Demonstration status:** the placeholder guards and the placeholder-scan discrimination were exercised locally, in `bash`, `dash` and BusyBox `ash`, with and without `set -u`. Check 7's five-value guard, changed in #475, was exercised in `bash` on 2026-10-13 (UTC) against a stub curl: one and two headers reached curl only on stdin, and a line feed, carriage return or placeholder in either header was refused before any curl call; it was not re-run in `dash` or BusyBox `ash`. Check 6's slice guard, added in #478, was exercised on 2026-10-13 (UTC) against a stub curl: `bash` reached the stub unchanged, and `dash` and BusyBox `ash` printed "run this block in bash; not probing" before any curl call, with and without `BASH_VERSION` exported; in those shells checks 6 and 7 now stop at that guard before their placeholder guards run. Every bash -n, ShellCheck, fixture and stub run recorded in this overview for checks 1 and 3 to 7 covered an earlier form of those blocks, in which every guard refusal ended with status 0 except the placeholder refusals of checks 1 and 3, which exited with status 1. The gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, in which every guard refusal exits with status 2, and no fixture, stub or live run of it is recorded. That earlier form also ended with status 0 after check 1's scan and check 4's probe, whatever they found; the current form exits with status 1 after a check 1 placeholder match or a check 4 completed connection, and check 1 exits with status 3 after a read error. Checks 2 to 7 below are **reasoned rather than demonstrated** against deployments. Historical authoring note: the authoring environment had no container runtime, and with it no live service, no external network vantage, no TLS exchange and no browser authentication flow. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The maintainer's verified vendor record supplies the deployment facts, and each check below states the outcome it expects in both the exposed and the fixed state. REASONED: Complete-block copy and guard guidance, per the guide's cited curl manual and Docker packet filtering and firewalls. In local marking review (October 2026, Bash 5.3.9), the single-quoted `$(printf SHOULD_NOT_RUN)` value appeared literally in the stub curl URL; the empty, placeholder, wrong-marker and short-count cases in checks 3 to 7 printed `not probing` before any stub call, while substituted values printed `STUB curl` or `STUB nc`. The stub runs recorded here covered checks 3 to 7 in an earlier form, in which every guard refusal ended with status 0 except the check 3 placeholder refusal, which exited with status 1; none was re-run on the current form, in which every guard refusal exits with status 2. The run did not test a deployed service or a pasted fragment below the guard. An external vantage, TLS exchange, configured authorized ingress and valid credentials were not tested; their availability is unknown. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. An exposed direct port is expected to answer, while a fixed direct port is expected to refuse; the authorized ingress control requires a configured route and valid credentials and was not run. Use Bash for these blocks and curl 7.75.0 or newer for `exitcode` and `errormsg`. Copy each complete block, including the enclosing parentheses. The values go on the `set --` line, so no named variable is exposed to attributes or values the reader's shell already holds, and every probe sits behind a guard that leaves the subshell rather than running on an unsubstituted value. Substitute inside the single quotes and leave them in place. They are what keeps a URL's `&` from backgrounding the line and a `$(...)` or a backtick in a pasted value from running: the shell evaluates that value before any guard in the block can see it. Keep the quotes even for an empty value, and paste every block whole. Each block opens with a fixed marker on its `set --` line, and the guard lines below check that marker and count the values, so a fragment that keeps those lines but drops or shortens the `set --` line stops instead of running on whatever arguments your own shell already held. A fragment pasted from below the guard lines is not guarded at all, which is why the instruction is to paste whole blocks. One case is beyond a pasted block's reach: a shell that already holds the marker and the right number of plausible values is indistinguishable from the block having set them, so paste whole blocks rather than relying on the check to catch every way of not doing so. ### 1. Reject unfinished configuration and probe values REASONED: For each configuration or environment file you edited, the expected matched, clean and error statuses follow GNU grep 3.12 manual, exit status (checked October 2026); the local marking review (October 2026, Bash 5.3.9) recorded the check 1 fixture outputs in the Verify overview. The recorded runs used an earlier form of check 1, whose marker and count refusals and completed scans ended with status 0 and whose filename-placeholder refusal exited with status 1. The current form, whose guard refusals all exit with status 2 and which exits with status 1 after a placeholder match, 0 after a clean scan and 3 after a read error, has not been run. The cited manual documents GNU grep; the house runs did not record which grep implementation or version ran. Any other status (2 in GNU grep; greater than 2 in other implementations, per the same entry) is inconclusive. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Run this for each configuration or environment file you edited: DEMONSTRATED: following block; placeholder-scan expectations follow GNU grep 3.12 manual, exit status (checked October 2026), and the recorded local exercise. Local marking review (October 2026, Bash 5.3.9) output is recorded in the Verify overview; the grep version in those house runs remains unknown. In a renewed local marking review (October 2026), Bash 5.3.9, dash and BusyBox ash 1.37.0 ran the whole block against a stub grep and fixtures. In each shell, wrong-marker and missing-set-line cases printed `paste the whole block, including its set -- line; not checking` and exited 0; short-count and long-count cases printed `the set -- line needs exactly 1 value; not checking` and exited 0; placeholder and empty filename cases printed `substitute the file name on the set -- line above; not checking` and exited 1. The renewed runs covered check 1 in an earlier form, whose marker and count refusals and completed scans ended with status 0 and whose filename-placeholder refusal exited with status 1. None was re-run on the current form, whose guard refusals all exit with status 2 and which exits with status 1 after a placeholder match, 0 after a clean scan and 3 after a read error. In each shell, the exposed fixture printed `exposed.conf:2:REPLACE_WITH_TOKEN` then `grep_exit=0` and exited 0; the clean fixture printed only `grep_exit=1` and exited 0; the missing-file fixture printed `grep: missing.conf: No such file or directory` then `grep_exit=2` and exited 0. The stub supplied the grep outputs and statuses; these renewed runs establish shell control flow, while the earlier recorded fixture runs establish the exposed and clean scan observations in all three shells and the missing-file status in Bash. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_EDITED_CONFIG_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not checking"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not checking"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the file name on the set -- line above; not checking"; exit 2 ;; *) if grep -nH -o 'REPLACE_WITH_[[:alnum:]_]*' -- "$1"; then set -- "$1" 0; else set -- "$1" "$?"; fi # grep's status, held as the 2nd value rather than in a named variable printf 'grep_exit=%s\n' "$2" # Status: 1 when a placeholder matched, 0 for a clean scan, 3 for a read error. case "$2" in 0) exit 1 ;; 1) exit 0 ;; *) exit 3 ;; esac ;; esac ) ``` DEMONSTRATED: Check 1 exposed fixture and unchanged filename guard, per the recorded local marking review (October 2026, Bash 5.3.9) in the Verify overview and renewed local marking review (October 2026, Bash 5.3.9, dash and BusyBox ash 1.37.0) (stub grep; shell control flow only): Bash, dash and BusyBox ash each printed the token and `grep_exit=0` for an exposed fixture. The same local marking reviews (stub grep; shell control flow only) record that a direct grep of a fixture printed `:2:REPLACE_WITH_TOKEN`; the filename and line in dash and BusyBox ash came only from the stub grep. The same local marking reviews (stub grep; shell control flow only) record that Bash, dash and BusyBox ash each printed `substitute the file name on the set -- line above; not checking` for the unchanged filename. The recorded runs used an earlier form of check 1, whose marker and count refusals and completed scans ended with status 0 and whose filename-placeholder refusal exited with status 1. The current form, whose guard refusals all exit with status 2 and which exits with status 1 after a placeholder match, 0 after a clean scan and 3 after a read error, has not been run. GNU grep 3.12 manual, exit status (checked October 2026) documents the match status. The house runs did not record which grep implementation or version ran. **Broken:** unresolved values print their filename, line number, and placeholder token, followed by `grep_exit=0`, and the block exits with status 1. Stop and replace them. An unchanged filename placeholder prints `substitute the file name on the set -- line above; not checking`. DEMONSTRATED: Check 1 fixed and unreadable fixture outcomes, per the recorded local marking review (October 2026, Bash 5.3.9) in the Verify overview and renewed local marking review (October 2026, Bash 5.3.9, dash and BusyBox ash 1.37.0) (stub grep; shell control flow only): Bash, dash and BusyBox ash each printed only `grep_exit=1` for a clean fixture. The same local marking reviews (stub grep; shell control flow only) record that Bash printed `grep_exit=2` for a missing fixture, and dash and BusyBox ash reached the same `grep_exit=2` path only with a stub grep. The recorded runs used an earlier form of check 1, whose marker and count refusals and completed scans ended with status 0 and whose filename-placeholder refusal exited with status 1. The current form, whose guard refusals all exit with status 2 and which exits with status 1 after a placeholder match, 0 after a clean scan and 3 after a read error, has not been run. GNU grep 3.12 manual, exit status (checked October 2026) documents no-match and error statuses. The house runs did not record which grep implementation or version ran. **Fixed:** no placeholder matches print, followed by `grep_exit=1`, and the block exits with status 0. A file-read error is inconclusive, not a clean scan, and the block exits with status 3. This check prints only the matched placeholder tokens, rather than complete secret-bearing lines. REASONED: Address and request guard behaviour in checks 3 to 7, per the guide's cited curl manual and Docker packet filtering and firewalls. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics for each of checks 3 to 7. In local marking review (October 2026, Bash 5.3.9), runs of each block printed `not probing` for empty and `REPLACE_WITH_` values before any stub call; substituted values printed `STUB curl` in checks 3, 5, 6 and 7 and `STUB nc` in check 4. Wrong markers printed `paste the whole block, including its set -- line; not probing`; short counts printed `the set -- line needs exactly 2 values; not probing` in checks 3 and 4, with 3, 4 or 5 values respectively in checks 5, 6 and 7. The bash -n, ShellCheck and stub runs recorded here covered checks 3 to 7 in an earlier form, in which every guard refusal ended with status 0 except the check 3 placeholder refusal, which exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, in which every guard refusal exits with status 2, and no fixture, stub or live run of it is recorded. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expected exposure is a direct answer; a fixed direct path refuses, per the guide's cited Docker packet filtering and firewalls. An external vantage, TLS exchange, authorized ingress and valid credentials were not checked; their availability is unknown. These guards are prerequisites, not deployment evidence. The address guards below likewise print `not probing` when required values are empty or still contain `REPLACE_WITH_`. Substituted values enter the probe arm. This is a prerequisite, not evidence that the deployment is private. ### 2. Inventory listeners, the merged Compose model, and running publications (REASONED: Listener and publication inventory, per the guide's cited Docker packet filtering and firewalls, Compose networking, Compose merge rules and Docker Compose ps. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Stub ss and docker printed their invocations; no live inventory or real Compose JSON shape was observed. Those runs covered an earlier form that ran the three commands at top level; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, which exits with status 3 when one fails or the running inventory lists no service, otherwise 2 when `ss` is not installed, else 0. On 2026-10-31 the current form also passed bash -n and ShellCheck 0.11.0 and ran whole against stub `ss` and `docker` with no network, with `ss` present and passing, present and failing, and missing; no live run of it is recorded. Historical authoring note: no container runtime was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect unintended listeners or publications when exposed, and only intended ingress or loopback publications when fixed; errors and empty inventories remain inconclusive.) **Reasoned, not demonstrated** (historical authoring note: no container runtime in the authoring environment). Run on the deployment host. For Compose, use the exact project, file selection, overrides, environment, and profiles used to start the deployment. Add those same options to these commands where needed. What makes an inventory readable rather than guesswork is documented: Compose ps reports the running publication fields, Compose merge rules govern how an override's port list combines with the base, and Compose networking establishes that services reach each other with no host publication at all; all three are in Sources: ```bash ( # a subshell, so the exit below ends only the subshell set -- 0 # the block's status so far: 3 (a command failed or the inventory is empty), else 2 (ss is not installed), else 0 if command -v ss >/dev/null; then # a missing ss refuses this part with status 2 if ss -tlnp; then :; else echo "ss -tlnp exit $?: the listener list is missing or incomplete; inconclusive"; set -- 3; fi else echo "ss is not installed here; not listing sockets"; set -- 2 fi if docker compose config; then :; else echo "docker compose config exit $?: no merged model to compare; inconclusive"; set -- 3; fi set -- "$1" "$(if docker compose ps --format json; then printf ' 0'; else printf ' %s' "$?"; fi)" # the status so far, then the running publications, a space and ps's exit status printf '%s' "${2% *}" # Status: 3 when a command fails or the running inventory lists no service (no JSON object); otherwise 2 when ss is not installed, else 0. case "${2##* }:${2% *}" in 0:*'{'*) ;; 0:*) echo "docker compose ps listed no running service, and an empty inventory is not a pass; inconclusive"; set -- 3 ;; *) echo "docker compose ps exit ${2##* }: no running inventory; inconclusive"; set -- 3 ;; esac echo "block exit $1; the listeners, the merged model and the running publications above are not classified, so judge them against Broken and Fixed below" exit "$1" ) ``` **Broken:** `ss` shows an application or backing listener on a wildcard or non-loopback host address; the merged Compose model retains an unwanted host publication; or the running service has a `Publishers` entry mapping it to an unintended host address and port. **Fixed:** host application listeners are loopback-only; backing services have no host publications; any retained application publications are loopback-only; only the intended ingress is public. The running publications agree with the merged model. Read the whole listener list, including IPv6 and listeners bound to a specific LAN address. Public SSH or other unrelated services are outside this guide's acceptance decision. A Docker publication implemented through DNAT may not appear in host `ss` output. Read the actual host mappings in `Publishers`, including `PublishedPort`, rather than counting entries: a container port listed without a host publication is not the same thing. The real JSON output shape was not demonstrated here. A Compose error, missing service, stopped application, or empty inventory is not a pass. `docker compose config` describes the intended configuration; it does not prove running containers were recreated with it. Its output can contain secrets, so inspect it privately. ### 3. Probe the application's direct ports from another host (REASONED: Direct HTTP reachability, per the guide's cited curl manual and Docker packet filtering and firewalls. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Local placeholder, empty-value, wrong-marker and short-count fixtures printed not probing before any stub call; substituted values reached the stub. The bash -n, ShellCheck and fixture runs recorded here covered check 3 in an earlier form, whose marker and count refusals ended with status 0 and whose placeholder refusal exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals all exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime or second host was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect an HTTP response or completed connection when exposed, and refusal corroborated by inventory and working ingress when fixed; timeout or http=000 alone is inconclusive.) **Reasoned, not demonstrated, including the cross-host vantage** (historical authoring note: no container runtime and no second host in the authoring environment). Probe every application or dashboard port found in check 2, using its actual host port. Include reachable LAN addresses and public IPv4/IPv6 addresses; bracket an IPv6 literal used in the HTTP URL. Exclude the intentionally public TLS ingress. The outcomes below rest on two sources: the curl manual for what its exit status, error text and timing actually report, and Docker's packet-filtering page for how a published port interacts with a host firewall, which is what makes a refusal meaningful rather than incidental. ```bash # Direct HTTP reachability, per the guide's cited curl manual and Docker packet filtering and firewalls. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Local placeholder, empty-value, wrong-marker and short-count fixtures printed not probing before any stub call; substituted values reached the stub. The bash -n, ShellCheck and fixture runs recorded here covered check 3 in an earlier form, whose marker and count refusals ended with status 0 and whose placeholder refusal exited with status 1; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals all exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime or second host was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect an HTTP response or completed connection when exposed, and refusal corroborated by inventory and working ingress when fixed; timeout or http=000 alone is inconclusive. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HOST_ADDRESS' 'REPLACE_WITH_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1:$2" in *REPLACE_WITH_*|:*|*:) echo "substitute the address and port on the set -- line above; not probing"; exit 2 ;; esac set -- "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w 'http=%{http_code} exit=%{exitcode} remote=%{remote_ip} time_connect=%{time_connect} err=%{errormsg}\n' \ "http://$1:$2/"; then printf ' 0'; else printf ' %s' "$?"; fi)" set -- "${1% *}" "${1##* }" # curl's write-out line, then its exit status, held as values rather than in named variables printf '%s' "$1" # Status: 1 for any HTTP answer or a completed TCP connection; 0 when the connection failed before TCP # completed (curl exit 7), which check 2 and the working ingress in check 7 must still corroborate; otherwise 3. case "$1" in "http=000 "*" time_connect=0.000000 "*) ;; "http=000 "*) echo "FINDING: a TCP connection completed without an HTTP answer (an empty reply, reset or timeout), so the direct path exists"; exit 1 ;; "http="???" "*) echo "FINDING: the direct port answered over HTTP, so the direct path exists"; exit 1 ;; *) echo "curl exit $2 printed no write-out line; inconclusive"; exit 3 ;; esac case "$2" in 7) echo "the connection failed before TCP completed (curl exit 7), consistent with a refusal; corroborate it with check 2 and the working ingress in check 7"; exit 0 ;; *) echo "curl exit $2 before any TCP connection formed (a timeout, or a name or local error); inconclusive"; exit 3 ;; esac ) ``` **Broken:** any HTTP response, including `401`, proves the direct path exists. A completed TCP connection followed by an empty response, reset, or timeout also fails the private-network requirement. **Fixed:** the connection to your actual backend address and port is refused, corroborated by check 2 and the working ingress in check 7. A timeout alone is inconclusive. `http=000` can occur after a server accepts and stalls. Read `err`, `remote`, and `time_connect`; corroborate a connection timeout with the running publication inventory and evidence at the filtering point. DNS failures, local socket errors, or a test aimed at the wrong address never establish a block. Use check 4 as well to test TCP reachability independently of HTTP. ### 4. Probe every backing port (REASONED: Backing-port reachability and the example publication inventory, per the guide's cited Docker packet filtering and firewalls, Docker Compose ps, Mem0 server Compose, Onyx development Compose and Onyx base Compose. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Local placeholder, empty-value, wrong-marker and short-count fixtures printed not probing before any stub call; substituted values reached the stub. The successful nc stub printed its arguments and exit=0. The bash -n, ShellCheck, fixture and stub runs recorded here covered check 4 in an earlier form, whose guard refusals and completed connections ended with status 0; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2 and which exits with status 1 after a completed connection, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime or external vantage was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect a completed connection when exposed and corroborated refusal when fixed; option errors, timeout and silence are inconclusive.) **Reasoned, not demonstrated** (historical authoring note: no container runtime in the authoring environment). From another host, run this for every private port found in check 2, including the application's ports from check 3. The distinguishing authority here is the publication state, read together with the firewall configuration, per Docker's packet-filtering page and Compose ps in Sources: under Docker's default firewall integration a backing service with no host publication is not reachable from another host, so a refusal corroborated by check 2 means the boundary holds. That page also states the limit of the guarantee: with Docker's firewalling disabled and no replacement rules, every container port becomes accessible, published or not, so an unpublished port is evidence of the boundary only while that integration is in place. An answer from any of these ports means the boundary does not hold. For the unchanged vendor examples, Mem0 adds 8432 and 3000 beside API port 8888. Onyx development adds 5432, 9200, 9000, 6379, 9004, 9005, and 8000 beside API port 8080, and nginx contributes 80 and 3000 from the base file. Environment variables can move these ports; the running inventory is the authority. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HOST_ADDRESS' 'REPLACE_WITH_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "substitute the address and port on the set -- line above; not probing"; exit 2; fi case "$1:$2" in *REPLACE_WITH_*) echo "substitute the address and port on the set -- line above; not probing"; exit 2 ;; esac set -- "$(if nc -vz -w 5 "$1" "$2" 2>&1; then printf ' 0'; else printf ' %s' "$?"; fi)" # netcat's messages, then its exit status set -- "${1% *}" "${1##* }" printf '%s' "$1" printf 'exit=%s\n' "$2" # Status: 1 when the connection completed; 3 when netcat reports a timeout for any address; 0 when it reports # a refused connection, which check 2 must still corroborate; otherwise 3, as for a name or option error or silence. case "$2:$1" in 0:*) echo "FINDING: the connection completed, so this port is reachable from here"; exit 1 ;; *:*"timed out"*) echo "netcat exit $2 after a timeout, which is not a refusal; inconclusive"; exit 3 ;; *:*"onnection refused"*) echo "netcat reported the connection refused; confirm the private publication state with check 2"; exit 0 ;; *) echo "netcat exit $2 without a reported refusal (a timeout, a name or option error, or silence); inconclusive"; exit 3 ;; esac ) ``` **Broken:** netcat reports a successful connection and `exit=0`, and the block exits with status 1. A database password prompt or a service that sends nothing after accepting does not change that result. REASONED: netcat status classification, from the messages netcat prints, which the block expects to contain the words connection refused for a refusal and timed out for a timeout; that wording was not checked against a netcat manual or run here, so a refusal worded differently gets status 3, never 0, and a timeout worded differently gets 3 only when no address reports a refusal. **Fixed:** netcat reports connection refused to your actual address and port, with a nonzero exit status, and check 2 confirms the private publication state. The block exits with status 0 only when netcat's output names a refused connection and no timeout, and with status 3 for any other nonzero netcat status, including the inconclusive cases below, so a status of 0 still needs check 2's corroboration. A timeout remains inconclusive without corroborating configuration and filtering evidence. Unsupported options, name-resolution failures, local errors, or unexplained silence are not passes. In particular, BusyBox netcat may reject `-v`; use an implementation supporting these options. ### 5. Require authentication at the ingress (REASONED: Ingress authentication and route coverage, per the guide's cited curl manual, LocalAI getting started, Text Embeddings Inference CLI arguments, Text Embeddings Inference HTTP server at v1.9.0, Mem0 REST API and Onyx basic authentication. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Local placeholder, empty-value, wrong-marker and short-count fixtures printed not probing before any stub call; substituted values reached the stub. Wrong schemes printed use a https:// URL; not probing. Body and no-body requests reached stub curl; Python parsed the probe JSON as an inputs string. The bash -n, ShellCheck, fixture and stub runs recorded here covered check 5 in an earlier form, whose guard refusals ended with status 0; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime or live ingress was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect protected output for an exposed anonymous caller, and rejection or the configured sign-in redirect when fixed, paired with authorized application output; errors and generic pages are inconclusive.) **Reasoned, not demonstrated** (historical authoring note: no container runtime in the authoring environment). Select a harmless request to a real protected capability. Its path, method, request body, and exact response for each installed tool are **unverified here**. Obtain them from that version's documentation and use the same request in check 7. What separates the exposed outcome from the fixed one is documented per service in Sources: Text Embeddings Inference answers every request until `--api-key` is set, LocalAI's key is unset by default, and Mem0 and Onyx both ship authentication on, so an anonymous `2xx` from any of them is the exposed state and a `401` or `403` is the fixed one. For example, Text Embeddings Inference's protected capability is `POST /embed` with the harmless body `{"inputs":"probe"}`: expect a `401` without the key and a `2xx` embedding with it (check 7). Do not choose a health endpoint, documentation page, login page, or an invented path. A `404` or request-validation error cannot demonstrate authentication. Test the ingress and the native layer as SEPARATE controls. Where a service's native key does not cover every route (Text Embeddings Inference leaves `/metrics`, `/docs` and the health routes outside it), also probe one of those uncovered routes anonymously THROUGH the public ingress: the fronting auth must gate it, so an anonymous `2xx` there is a finding even when the capability route is protected. Where the ingress and the application use different credentials, send the application's own credential WITHOUT the ingress credential and confirm the ingress still rejects it, so a keyed backend behind a TLS-only proxy cannot pass this check while the ingress itself is open. Set the second value on the `set --` line to the actual method, such as `GET` or `POST`. Keep the third value's empty quotes for a request without a body; otherwise put a harmless JSON test body there. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PROTECTED_URL' 'REPLACE_WITH_HTTP_METHOD' '' # $3 is the harmless JSON request body. Keep the empty quotes if the request needs none. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "fill in the URL and method on the set -- line above; not probing"; exit 2 fi case "$1$2$3" in *REPLACE_WITH_*) echo "substitute the request values on the set -- line above; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use a https:// URL; not probing"; exit 2 ;; esac if [ -n "$3" ]; then set -- -H 'Content-Type: application/json' --data-binary "$3" -X "$2" "$1" else set -- -X "$2" "$1" fi set -- "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -D - -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$@"; then printf ' 0'; else printf ' %s' "$?"; fi)" set -- "${1% *}" "${1##* }" # the headers, body and write-out line, then curl's exit status printf '%s' "$1" set -- "$1" "$2" "${1##*http=}" # the text after the last http=, which starts with the HTTP code # Status: 1 for an anonymous 2xx; 0 for a 401 or 403, and 0 for a redirect you judge from its Location # header; otherwise 3, as for a TLS, connection, 5xx, routing or validation error. case "$3" in 2??" "*) echo "FINDING: the anonymous request got HTTP ${3%% *} through the ingress"; exit 1 ;; 401" "*|403" "*) echo "the anonymous request was rejected with HTTP ${3%% *}; check 7 must still succeed for the authorized caller"; exit 0 ;; 3??" "*) echo "unclassified observation: HTTP ${3%% *} is a redirect, which passes only when the Location header above names your configured identity service"; exit 0 ;; *) echo "curl exit $2 with HTTP ${3%% *} is not an authentication result; inconclusive"; exit 3 ;; esac ) ``` **Broken:** the anonymous request returns application data or performs the capability, typically with a `2xx` status. **Fixed:** the request receives an authentication rejection, normally `401` or `403`, or an interactive sign-in redirect whose `Location` identifies the configured identity service. Check 7 must then succeed for the authorized caller. A redirect alone is insufficient: inspect its destination. Do not follow it automatically and mistake a `200` login page for application access. TLS, connection, `5xx`, routing, and validation errors are inconclusive. Certificate verification stays enabled. ### 6. Exercise the capability without native credentials (REASONED: Native authentication, its limits and the private positive control, per the guide's cited curl manual, LocalAI getting started, Text Embeddings Inference CLI arguments, Text Embeddings Inference HTTP server at v1.9.0, Mem0 REST API and Onyx basic authentication, SearxNG server settings and LangServe README at commit `27e57af`. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Local placeholder, empty-value, wrong-marker and short-count fixtures printed not probing before any stub call; substituted values reached the stub. Body and no-body requests reached stub curl; anonymous requests omitted credentials and wrong or fixture headers reached only stdin. LF and CR headers printed put the credential header on one line; not probing; invalid schemes were refused. The bash -n, ShellCheck, fixture and stub runs recorded here covered check 6 in an earlier form, whose guard refusals ended with status 0; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime or live service was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect protected output without credentials when exposed; when fixed, the same request rejects empty and wrong credentials and returns real output with valid credentials. Stub calls do not establish authentication.) **Reasoned, not demonstrated** (historical authoring note: no container runtime in the authoring environment). For LocalAI or Text Embeddings Inference with a key set, Mem0, Onyx, or LangServe with application authentication, run the same harmless protected request against the private upstream from a trusted location that can reach it. Do not publish a port to perform this check. The same per-service passages in Sources supply the expected outcomes as in check 5, and one of them bounds this check rather than supporting it: the Text Embeddings Inference server source shows its key middleware covering the inference routes only, so an anonymous answer from `/metrics`, `/health` or `/docs` is not evidence that the key is unset. For SearxNG, for LangServe relying entirely on its proxy, or for any service you have deliberately left unauthenticated on the private side, run this capability check through the authenticated HTTPS ingress. An intentionally unauthenticated private upstream cannot demonstrate native authentication. ```bash ( # a subshell, so your own script arguments are untouched trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias ( set -- a b c d e f g; [ "$(printf '%s,' "${@:5}")" = 'e,f,g,' ] ) 2>/dev/null || { echo "run this block in bash; not probing"; exit 2; } # dash and ash mishandle "${@:5}" # When you repeat this block with a valid credential header in $4, the secret # on the set -- line enters shell history; clear that history line afterward. # Stdin closes the argv channel only: it does not hide the header from set -x # tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PROTECTED_URL' 'REPLACE_WITH_HTTP_METHOD' '' '' 'REPLACE_WITH_anonymous_wrong_OR_valid' # $3 is the same harmless JSON body as checks 5 and 7 (keep the empty quotes if none is needed). # $4 is a credential header: leave it EMPTY for the anonymous run, put a WRONG credential for the # wrong-key run, and the VALID one for the positive control. Run this block three times against the # SAME private URL; the native layer must reject the first two and serve the third. # $5 names the run: anonymous, wrong or valid. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "the set -- line needs exactly 5 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "fill in the URL and method on the set -- line above; not probing"; exit 2 fi case "$1$2$3$4" in *REPLACE_WITH_*) echo "substitute the request values on the set -- line above; not probing"; exit 2 ;; esac case "$1" in http://*|https://*) ;; *) echo "use an http:// or https:// URL; not probing"; exit 2 ;; esac case "$4" in *[[:cntrl:]]*) echo "put the credential header on one line; not probing"; exit 2 ;; esac case "$5:$4" in anonymous:|wrong:?*|valid:?*) ;; *) echo "set the fifth value to anonymous with an empty header, or to wrong or valid with a header; not probing"; exit 2 ;; esac # Body options, if any, go after the five values. The credential header ($4) reaches curl on stdin # (--header @-), never in its argv, where ps and /proc//cmdline would show it. if [ -n "$3" ]; then set -- "$@" -H 'Content-Type: application/json' --data-binary "$3"; fi if [ -n "$4" ]; then set -- "$5" "$(if printf '%s\n' "$4" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --header @- \ -D - -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${@:6}" -X "$2" "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" else set -- "$5" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -D - -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${@:6}" -X "$2" "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" fi set -- "$1" "${2% *}" "${2##* }" # the run, the response with its write-out line, then curl's exit status printf '%s' "$2" set -- "$1" "$3" "${2##*http=}" # the run, curl's exit status, then the text after the last http=, which starts with the HTTP code # Status: for the anonymous and wrong runs, 1 for a 2xx and 0 for a 401 or 403; for the valid run, 0 for a # 2xx whose body you still read; otherwise 3, because an unreachable upstream, unknown path, malformed # request or unavailable model tests nothing. case "$1:${3%% *}" in anonymous:2??|wrong:2??) echo "FINDING: the $1 run got HTTP ${3%% *}, so the native layer served it"; exit 1 ;; anonymous:401|anonymous:403|wrong:401|wrong:403) echo "the $1 run was rejected with HTTP ${3%% *}; the valid run must still get a 2xx"; exit 0 ;; valid:2??) echo "the valid run got HTTP ${3%% *}; read the body above to confirm it is real output, not a generic page"; exit 0 ;; *) echo "curl exit $2 with HTTP ${3%% *} is not a result the $1 run classifies; inconclusive"; exit 3 ;; esac ) ``` **Broken:** the unauthenticated caller receives protected data or successfully invokes the capability. **Fixed:** the configured authentication layer rejects that same valid request, while check 7 succeeds with credentials. REASONED: native-gate run sequence and statuses, per the guide's cited LocalAI getting started, Text Embeddings Inference CLI arguments, Mem0 REST API and Onyx basic authentication; not run against a deployment. An unreachable upstream means native authentication was not tested; it does not prove that authentication works. Likewise, an unknown path, malformed request, or unavailable model does not demonstrate rejection of an anonymous caller. Run the block three times against the SAME private URL, varying only `$4` and the run name in `$5`: empty with `anonymous`, a deliberately WRONG credential with `wrong`, then the VALID one with `valid`. The native layer must reject the first two and serve the third; that valid-credential run is the positive control on the private backend, so the three together (anonymous reject, wrong-key reject, valid-key accept) establish the native gate rather than a coincidental error. The block exits with status 1 when the anonymous or wrong run gets a `2xx`, with 0 when either gets `401` or `403` or when the valid run gets a `2xx` whose body you still read, and with 3 otherwise. Check 7 is the separate success test through the HTTPS ingress. Mem0 and Onyx ship authentication on, but the verified record does not establish the exact response code for a selected endpoint. Investigate a successful anonymous Mem0 request for `AUTH_DISABLED=true`, route coverage, and the running version; the response alone does not identify its cause. ### 7. Prove the protected service still works REASONED: Credentialed HTTPS success and header transport, per the guide's cited curl manual, LocalAI getting started, Text Embeddings Inference CLI arguments, Text Embeddings Inference HTTP server at v1.9.0, Mem0 REST API and Onyx basic authentication. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Placeholder, empty-value, marker, count and scheme fixtures stopped before stub curl; body and no-body requests reached it. One and two fixture headers appeared only on stdin; LF, CR and placeholders in either header were refused before a stub call. The bash -n, ShellCheck, fixture and stub runs recorded here covered check 7 in an earlier form, whose guard refusals ended with status 0; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime, TLS exchange or browser flow was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect anonymous rejection paired with authorized application output when fixed; success alone, errors and login pages cannot establish protection. Stub calls do not establish authentication. **Reasoned, not demonstrated** (historical authoring note: no container runtime in the authoring environment). Repeat the capability request through the HTTPS ingress with valid credentials. Keep its method, path, and body the same as the anonymous test, allowing only the expected upstream-to-ingress URL change. The credential shapes this check needs are documented in Sources: Mem0 accepts a JWT or an `X-API-Key`, Onyx uses its email and password session, LocalAI and Text Embeddings Inference take their configured key, and a guide that cannot show this check succeeding has not established that the restriction left a working service behind. REASONED: Credentialed HTTPS success and header transport, per the guide's cited curl manual, LocalAI getting started, Text Embeddings Inference CLI arguments, Text Embeddings Inference HTTP server at v1.9.0, Mem0 REST API and Onyx basic authentication. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Placeholder, empty-value, marker, count and scheme fixtures stopped before stub curl; body and no-body requests reached it. One and two fixture headers appeared only on stdin; LF, CR and placeholders in either header were refused before a stub call. The bash -n, ShellCheck, fixture and stub runs recorded here covered check 7 in an earlier form, whose guard refusals ended with status 0; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime, TLS exchange or browser flow was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect anonymous rejection paired with authorized application output when fixed; success alone, errors and login pages cannot establish protection. Stub calls do not establish authentication. The header below is reader-supplied. For a configured bearer-token proxy it is `Authorization: Bearer` followed by the token. Mem0 accepts its native per-user key through `X-API-Key`, or a JWT through `Authorization: Bearer`. Other native credential transports are **unverified in the supplied record**. Where ingress and application require separate credentials, send both headers on curl's stdin: `--header @-` reads one header per line, so pipe both lines to it under the same control-character guard, and never pass a credential header with `-H`, which puts it in curl's argv. REASONED: following block; Credentialed HTTPS success and header transport, per the guide's cited curl manual, LocalAI getting started, Text Embeddings Inference CLI arguments, Text Embeddings Inference HTTP server at v1.9.0, Mem0 REST API and Onyx basic authentication. Bash 5.3.9 bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Placeholder, empty-value, marker, count and scheme fixtures stopped before stub curl; body and no-body requests reached it. One and two fixture headers appeared only on stdin; LF, CR and placeholders in either header were refused before a stub call. The bash -n, ShellCheck, fixture and stub runs recorded here covered check 7 in an earlier form, whose guard refusals ended with status 0; the gate suite's bash -n and ShellCheck 0.11.0 passes cover the current form, whose guard refusals exit with status 2, and no fixture, stub or live run of it is recorded. Historical authoring note: no container runtime, TLS exchange or browser flow was available. A live SearxNG, LocalAI, Text Embeddings Inference, LangServe, Mem0 or Onyx stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect anonymous rejection paired with authorized application output when fixed; success alone, errors and login pages cannot establish protection. Stub calls do not establish authentication. **Broken:** both anonymous and credentialed requests return protected data, or the credentialed request cannot reach a working application after the restrictions were applied. Connection failures, `401`/`403`, `5xx`, or a login page in place of the expected result do not establish a working protected service. **Fixed:** the anonymous request is rejected and the following block's credentialed request returns the expected application result, normally `2xx`. A `2xx` alone is insufficient; inspect the body to distinguish application output from a generic page. Credentialed success can also occur on an exposed service. It only completes the verification when the negative checks above pass. ```bash ( # a subshell, so your own script arguments are untouched trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias ( set -- a b c d e f g; [ "$(printf '%s,' "${@:6}")" = 'f,g,' ] ) 2>/dev/null || { echo "run this block in bash; not probing"; exit 2; } # dash and ash mishandle "${@:6}" # The credentials you substitute on the set -- line enter shell history. # Clear that history line afterward. # Stdin closes the argv channel only: it does not hide the credentials from # set -x tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PROTECTED_URL' 'REPLACE_WITH_HTTP_METHOD' '' 'REPLACE_WITH_HEADER_NAME: REPLACE_WITH_CREDENTIAL' '' # $3 is the same harmless JSON body as the anonymous check. Keep the empty quotes if none is needed. # $5 is an optional second credential header, for an ingress and application that need separate # credentials. Keep the empty quotes if one header is enough. [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 5 ] || { echo "the set -- line needs exactly 5 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ] || [ -z "$4" ]; then echo "fill in the URL, method, and header on the set -- line above; not probing"; exit 2 fi case "$1$2$3$4$5" in *REPLACE_WITH_*) echo "substitute the request values on the set -- line above; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use a https:// URL; not probing"; exit 2 ;; esac case "$4" in *[[:cntrl:]]*) echo "put the credential header on one line, and a second header in the fifth value; not probing"; exit 2 ;; esac case "$5" in *[[:cntrl:]]*) echo "put the second credential header on one line; not probing"; exit 2 ;; esac # Body options, if any, go after the five values. The credential headers ($4, and $5 if set) reach # curl on stdin (--header @-, one per line), never in its argv, where ps and /proc//cmdline # would show them. if [ -n "$3" ]; then set -- "$@" -H 'Content-Type: application/json' --data-binary "$3"; fi if [ -n "$5" ]; then set -- "$(if printf '%s\n' "$4" "$5" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --header @- \ -D - -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${@:6}" -X "$2" "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" else set -- "$(if printf '%s\n' "$4" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --header @- \ -D - -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${@:6}" -X "$2" "$1"; then printf ' 0'; else printf ' %s' "$?"; fi)" fi set -- "${1% *}" "${1##* }" # the response with its write-out line, then curl's exit status printf '%s' "$1" set -- "$1" "$2" "${1##*http=}" # the text after the last http=, which starts with the HTTP code # Status: 0 for a 2xx, whose body you still read; otherwise 3, because a connection failure, 401, 403, # 5xx or login redirect does not establish a working protected service. case "$3" in 2??" "*) echo "unclassified observation: HTTP ${3%% *} with credentials; read the body above, which passes only as the expected application output, not a generic page"; exit 0 ;; *) echo "curl exit $2 with HTTP ${3%% *}: the credentialed request did not succeed; inconclusive"; exit 3 ;; esac ) ``` For browser access, follow the MFA enforcement check in [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) against a protected operation. **Broken:** the protected operation succeeds with only a password when policy requires a second factor. **Fixed:** the password-only attempt is refused and completing the second factor allows the operation. This browser outcome is reasoned rather than demonstrated here. If the deployment trusts identity headers from a proxy, follow the direct-origin, header-overwrite and authenticated-through-proxy checks in [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). **Broken:** a direct forged identity header reaches the app, or a forged trusted header sent through the proxy appears as the forged identity in the app log. **Fixed:** the direct forged-header request is refused at the network boundary; a valid authenticated session reaches the app through the proxy, and the app log shows the real identity after a forged trusted header is sent through it. These identity-header outcomes are reasoned rather than demonstrated here. ## Common mistakes - Running Onyx's development Compose on an exposed host because its login page works. - Assuming Mem0's API authentication also protects PostgreSQL or establishes the dashboard's policy. - Changing a `${VAR:-default}` host port and believing the publication disappeared. - Adding a loopback mapping through a Compose override while retaining the original public mapping. - Leaving `AUTH_DISABLED=true` in Mem0 after debugging. - Setting Onyx's `AUTH_TYPE` and expecting an authentication change; this guide infers from the v4.3.13 and v4.4.0 tag trees that it no longer selects runtime authentication in v4.4.0, apart from two one-time legacy imports (the SSO provider migration's Google or OIDC row and the startup SAML import). - Leaving LocalAI's native key unset, or mistaking an upstream provider token for inbound authentication. - Inspecting only `ss`, only IPv4, or only the deployment host's own view. - Accepting a timeout, an unknown endpoint's `404`, or a credentialed `200` as proof of protection. ## Sources (checked September 2026) The service facts come from the maintainer's vendor-source verification record dated 2026-09-13. This authoring environment had no network; the pages were not reopened here. Docker and probe syntax also follow the cited sources already recorded in the repository's existing guides. **Citation pinning:** the LangServe README, Mem0 server, Onyx Compose and Onyx data-model citations below use commit pins, and each commit was opened and confirmed to contain the fact it is cited for. The Onyx v4.5.0, v4.9.2 and v4.9.4 source citations are pinned to the commits those tags resolved to (`055b0c5`, `df49072` and `4cfda3c`) and the Onyx v5.0.0-beta.3 pre-release citations to `55df913`, while the Onyx v4.3.13 and v4.4.0 citations and the Onyx v4.5.0 and v5.0.0 release notes use tags, which are not commit pins, and the Onyx release list is not pinned. - [SearxNG server settings](https://docs.searxng.org/admin/settings/settings_server.html): `server.bind_address`, `server.port`, and direct defaults. - [SearxNG Docker installation](https://docs.searxng.org/admin/installation-docker.html): documented `8888:8080` publication. - [LocalAI getting started](https://localai.io/docs/basics/getting_started/): port 8080, Docker publication, `LOCALAI_API_KEY`, and `LOCALAI_AUTH`. - [Text Embeddings Inference quick tour](https://huggingface.co/docs/text-embeddings-inference/en/quick_tour): container port 80 and host publication 8080. - [Text Embeddings Inference CLI arguments](https://huggingface.co/docs/text-embeddings-inference/en/cli_arguments): `--api-key` and `API_KEY`, the default-open behaviour without one, `--hostname` defaulting to `0.0.0.0`, and `--prometheus-port` defaulting to `9000`. - [Text Embeddings Inference HTTP server at v1.9.0](https://github.com/huggingface/text-embeddings-inference/blob/v1.9.0/router/src/http/server.rs): the key middleware applied to the inference routes only, with the health, `/metrics` and OpenAPI routes merged beside it. - [LangServe README at commit `27e57af`](https://github.com/langchain-ai/langserve/blob/27e57afeda13007a7f4e007c5d1f5e8489963aa4/README.md): quickstart bind, application authentication responsibility, deprecation on 2024-11-18, and successor. - [Mem0 REST API](https://docs.mem0.ai/open-source/features/rest-api): authentication enabled by default, JWTs, `X-API-Key`, `m0sk_` keys, `ADMIN_API_KEY`, `AUTH_DISABLED` and its startup warning, `JWT_SECRET`, `make bootstrap`, the first-admin `POST /auth/register`, and the routes that stay open. - [Mem0 server Compose](https://github.com/mem0ai/mem0/blob/c7ee362aff94a369af70f13f2b4f853f6793ff4c/server/docker-compose.yaml): uvicorn command and API, PostgreSQL, and dashboard publications. - [Mem0 server `main.py`](https://github.com/mem0ai/mem0/blob/c7ee362aff94a369af70f13f2b4f853f6793ff4c/server/main.py): the startup `raise RuntimeError` when authentication is on and `JWT_SECRET` is unset, and the `if`/`elif` warning branches for `AUTH_DISABLED` and a short `ADMIN_API_KEY`. The REST API page describes the request-time responses; this file is the startup behaviour. - [Onyx basic authentication](https://docs.onyx.app/deployment/authentication/basic.md): email/password authentication, the first user to sign up becoming an admin, and `AUTH_TYPE`, which the undated page, checked November 2026 (fetched 2026-11-04), says "is no longer used", adding "Onyx ignores it and logs a warning at startup". Rechecked in October 2026, the page said instead that the variable "is inert" and is planned for full removal in `v4.5`. - [Onyx v4.3.13 authentication configuration](https://github.com/onyx-dot-app/onyx/blob/v4.3.13/backend/onyx/configs/app_configs.py#L133-L142) (checked October 2026): legacy environment selection of authentication. - [Onyx v4.3.13 authentication routes](https://github.com/onyx-dot-app/onyx/blob/v4.3.13/backend/onyx/main.py#L581-L608) (checked October 2026): legacy environment selection of authentication. - [Onyx v4.4.0 startup warning](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/main.py#L371-L372) (checked October 2026): startup warning handling of stale `AUTH_TYPE`. - [Onyx v4.4.0 startup SAML import](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/main.py#L392-L397) (checked October 2026): startup call to import a legacy SAML provider when `AUTH_TYPE=saml`. - [Onyx v4.4.0 authentication warning](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/auth/users.py#L183-L200) (checked October 2026): legacy migration or startup warning handling of `AUTH_TYPE`. - [Onyx v4.4.0 SAML provider import](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/db/sso_provider.py#L197-L295) (checked October 2026): one-time legacy SAML import at startup reads `AUTH_TYPE=saml` and can create one SAML provider row from `SAML_CONF_DIR`. - [Onyx v4.4.0 SSO provider migration](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/alembic/versions/1fc2904131a3_add_sso_provider_table_and_seed_from_env.py#L60-L129) (checked October 2026): legacy migration or startup warning handling of `AUTH_TYPE`, including the one-time legacy provider import, run by `upgrade()` after it creates the table, that can insert an enabled Google or OIDC provider row from `AUTH_TYPE` in single-tenant mode. - [Onyx v4.4.0 disabled authentication migration](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/alembic/versions/f7ca3e2f45d9_migrate_no_auth_data_to_placeholder.py#L129-L193) (checked October 2026): legacy migration or startup warning handling of `AUTH_TYPE`, including the inactive placeholder-user insert. Its `upgrade()` returns first when `MULTI_TENANT` is set (lines 139 to 140), so the migration inserts only in single-tenant mode. - [Onyx v4.4.0 authentication routers](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/main.py#L589-L681) (checked October 2026): password login routers mounted in every deployment mode, the env-credential Google login routers, web and mobile, gated on OAuth credentials, and the SAML and OIDC provider-row routers always mounted; source reading, which does not establish deployed authentication selection. - [Onyx v4.4.0 SSO provider model](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/db/models.py#L6618-L6645) (checked October 2026): the `SSOProvider` model's `enabled` column, which defaults to `True`, so a provider row created without an explicit value is enabled. - [Onyx v4.4.0 SAML router provider resolution](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/server/saml_multi.py#L102-L140) (checked October 2026): SAML login resolving a provider row by name or by assertion issuer with `enabled_only=True` and accepting only SAML rows; source reading, which does not establish deployed authentication selection. - [Onyx v4.4.0 OIDC router provider resolution](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/server/oidc_multi.py#L74-L97) (checked October 2026): OAuth and OIDC login resolving a provider row by name with `enabled_only=True` and accepting only `GOOGLE_OAUTH` and `OIDC` rows; source reading, which does not establish deployed authentication selection. - [Onyx v4.3.13 web package manifest](https://github.com/onyx-dot-app/onyx/blob/v4.3.13/web/package.json#L3) (checked October 2026): `1.0.0-dev` rather than the tag version. - [Onyx v4.4.0 web package manifest](https://github.com/onyx-dot-app/onyx/blob/v4.4.0/web/package.json#L3) (checked October 2026): `1.0.0-dev` rather than the tag version. - [Onyx v4.4.0 release notes](https://github.com/onyx-dot-app/onyx/releases/tag/v4.4.0) (checked October 2026): the entry `feat(auth): remove AUTH_TYPE and the AuthType enum, serve multi_tenant`, read from the GitHub API copy of the release. - [Onyx v4.5.0 release notes](https://github.com/onyx-dot-app/onyx/releases/tag/v4.5.0) (checked October 2026): the entry `chore(deployment): stop advertising env-based SSO setup in templates and installers`, read from the GitHub API copy of the release, published 2026-08-04; no entry records `AUTH_TYPE` or a legacy SSO variable as removed. - Onyx GitHub release list, [page 1](https://api.github.com/repos/onyx-dot-app/onyx/releases?per_page=100) and [page 2](https://api.github.com/repos/onyx-dot-app/onyx/releases?per_page=100&page=2) (checked October 2026, fetched 2026-10-25): the 48 stable releases from v4.5.0 to v5.0.0, none of whose notes records `AUTH_TYPE` or a legacy SSO variable as removed, and the publication of v5.0.0 at 2026-10-24T06:46:01Z; the list has no release object for v4.6.13, v4.7.12, v4.8.7 or v4.9.3. - [Onyx v5.0.0 release notes](https://github.com/onyx-dot-app/onyx/releases/tag/v5.0.0) (checked November 2026): a release body, read from the GitHub API copy of the release, that opens with a "Docs" heading whose one line is the address of the Onyx changelog page cited below. - [Onyx documentation index](https://docs.onyx.app/llms.txt) (checked November 2026, fetched 2026-11-04): lists the changelog as "Release Notes", the release process page and 3 migration pages, on the document index, the object store and permissions. It names none of `AUTH_TYPE`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OPENID_CONFIG_URL` and `SAML_CONF_DIR`; its one case-insensitive `auth_type` match is the "Get Auth Type" API reference entry. - [Onyx documentation sitemap](https://docs.onyx.app/sitemap.xml) (checked November 2026, fetched 2026-11-04): lists the changelog, the release process page and the same 3 migration pages. It names none of those 5 variables, and its one case-insensitive `auth_type` match is the Get Auth Type API reference address. - [Onyx changelog](https://docs.onyx.app/changelog) (checked November 2026, fetched 2026-11-04): the "Release Notes" page, read as served and as its Markdown export. Its v4.4.0 entry, dated July 21st, 2026, says "`AUTH_TYPE` has been removed" and that `AUTH_TYPE` and the legacy SSO variables "are now inert and only emit a startup warning"; no entry from v4.5.0 (August 4th, 2026) to v5.0.0 (October 23rd, 2026) names any of those 5 variables. - [Onyx release process](https://docs.onyx.app/deployment/miscellaneous/release_process.md) (checked November 2026, fetched 2026-11-04): the release, versioning and update page that `llms.txt` and the sitemap list; it names none of those 5 variables. - [Onyx full documentation export](https://docs.onyx.app/llms-full.txt) (checked November 2026, fetched 2026-11-04): its copies of the 3 migration pages name none of those 5 variables. Its configuration page says `AUTH_TYPE` is inert and "Planned for full removal in `v4.5`.", and the upgrade sections of its Google OAuth, OIDC and SAML pages say to remove `AUTH_TYPE` from a deployment and that "Onyx logs a warning while `AUTH_TYPE` is still set". - [Onyx v4.5.0 startup warning](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/main.py#L378-L379) (checked October 2026): `verify_auth_setting()` call with a comment describing stale `AUTH_TYPE` warnings; this call site does not establish runtime authentication selection. - [Onyx v4.5.0 startup SAML import](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/main.py#L398-L414) (checked October 2026): non-multitenant startup calls the legacy SAML import; its comment says it is a no-op unless `AUTH_TYPE=saml` with no SAML row yet, without establishing the helper's complete behaviour. - [Onyx v4.9.2 startup warning](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/main.py#L394-L395) (checked October 2026): `verify_auth_setting()` call with a comment describing stale `AUTH_TYPE` warnings; this call site does not establish runtime authentication selection. - [Onyx v4.9.2 startup SAML import](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/main.py#L414-L432) (checked October 2026): non-multitenant startup calls the legacy SAML import; its comment says it is a no-op unless `AUTH_TYPE=saml` with no SAML row yet, without establishing the helper's complete behaviour. - [Onyx v4.5.0 production environment template](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/deployment/docker_compose/env.prod.template#L24-L26) (checked October 2026): the commented legacy pre-v4.4 SSO block, starting with `#AUTH_TYPE=`, headed "Planned for removal in v4.5."; the template carries no date beyond its tag commit. - [Onyx v4.9.2 production environment template](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/deployment/docker_compose/env.prod.template#L24-L26) (checked October 2026): the same commented legacy SSO block and "Planned for removal in v4.5." heading, unchanged at the v4.9.2 commit. - [Onyx v4.5.0 authentication routers](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/main.py#L611-L703) (checked October 2026): password login routers mounted in every deployment mode, the env-credential Google login routers, web and mobile, gated on OAuth credentials, and the SAML and OIDC provider-row routers always mounted; source reading, which does not establish deployed authentication selection. - [Onyx v4.9.2 authentication routers](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/main.py#L651-L750) (checked October 2026): the same router mounting as v4.5.0, including the env-credential Google login routers, web and mobile, gated on OAuth credentials, plus an unconditionally mounted SSO discovery router; source reading, which does not establish deployed authentication selection. - [Onyx v4.5.0 startup warning helper](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/auth/users.py#L184-L203) (checked October 2026): `verify_auth_setting()` logging stale `AUTH_TYPE` warnings and a mode notice from `MULTI_TENANT`, with no `raise` and no return value. - [Onyx v4.9.2 startup warning helper](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/auth/users.py#L200-L219) (checked October 2026): the same `verify_auth_setting()` body as v4.5.0. - [Onyx v4.5.0 legacy SAML import helper](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/db/sso_provider.py#L247-L345) (checked October 2026): `seed_saml_provider_from_conf_dir` early returns, the one SAML provider row it creates from `SAML_CONF_DIR`, and its handling of `IntegrityError` only. - [Onyx v4.9.2 legacy SAML import helper](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/db/sso_provider.py#L300-L398) (checked October 2026): the same `seed_saml_provider_from_conf_dir` body as v4.5.0. - [Onyx v4.5.0 SSO provider migration](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/alembic/versions/1fc2904131a3_add_sso_provider_table_and_seed_from_env.py#L60-L129) (checked October 2026): the one-time legacy provider import reading `AUTH_TYPE` from `os.environ` at line 79 and, after `upgrade()` creates the table, inserting in single-tenant mode an enabled Google or OIDC provider row when `AUTH_TYPE` is `google_oauth` (or `oidc` with `OPENID_CONFIG_URL` set), OAuth credentials are configured and the table is empty. The migration file, in the fetched repository, is byte-identical at v4.4.0, v4.5.0 and v4.9.2 (checked October 2026). - [Onyx v4.5.0 disabled authentication migration](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/alembic/versions/f7ca3e2f45d9_migrate_no_auth_data_to_placeholder.py#L129-L193) (checked October 2026): the placeholder-user migration reading `AUTH_TYPE` from `os.environ` at line 144, skipping unless it is `disabled`, `none` or empty, and otherwise, when records without an owning user exist, inserting an inactive placeholder user with an empty password, which its comments say cannot log in. The migration file, in the fetched repository, is byte-identical at v4.4.0, v4.5.0 and v4.9.2 (checked October 2026). Its `upgrade()` returns first when `MULTI_TENANT` is set (lines 139 to 140), before that `AUTH_TYPE` read, so the migration inserts only in single-tenant mode. - [Onyx v4.9.2 SSO provider migration](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/alembic/versions/1fc2904131a3_add_sso_provider_table_and_seed_from_env.py#L60-L129) (checked October 2026): the same migration file as v4.5.0, with the same `AUTH_TYPE` read at line 79 and the same enabled provider-row insert. - [Onyx v4.9.2 disabled authentication migration](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/alembic/versions/f7ca3e2f45d9_migrate_no_auth_data_to_placeholder.py#L129-L193) (checked October 2026): the same migration file as v4.5.0, with the same `AUTH_TYPE` read at line 144 and the same inactive placeholder-user insert. Its `upgrade()` also returns first when `MULTI_TENANT` is set (lines 139 to 140), so at v4.9.2 too the migration inserts only in single-tenant mode. - [Onyx v4.5.0 SSO provider model](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/db/models.py#L6873-L6900) (checked October 2026): the `SSOProvider` model's `enabled` column, which defaults to `True`, so a provider row created without an explicit value is enabled. - [Onyx v4.9.2 SSO provider model](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/db/models.py#L7490-L7517) (checked October 2026): the same `enabled` default of `True` as v4.5.0. - [Onyx v4.5.0 SAML router provider resolution](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/server/saml_multi.py#L103-L141) (checked October 2026): SAML login resolving a provider row by name or by assertion issuer with `enabled_only=True` and accepting only SAML rows; source reading, which does not establish deployed authentication selection. - [Onyx v4.9.2 SAML router provider resolution](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/server/saml_multi.py#L40-L156) (checked October 2026): the same enabled-only SAML provider resolution as v4.5.0, behind a router-wide dependency whose comment says the router is closed on deployments with more than one workspace; source reading, which does not establish deployed authentication selection. - [Onyx v4.5.0 OIDC router provider resolution](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/server/oidc_multi.py#L74-L97) (checked October 2026): OAuth and OIDC login resolving a provider row by name with `enabled_only=True` and accepting only `GOOGLE_OAUTH` and `OIDC` rows; source reading, which does not establish deployed authentication selection. - [Onyx v4.9.2 OIDC router provider resolution](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/server/oidc_multi.py#L161-L184) (checked October 2026): the same enabled-only resolution of `GOOGLE_OAUTH` and `OIDC` rows as v4.5.0; source reading, which does not establish deployed authentication selection. - [Onyx v4.5.0 EE application](https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/ee/onyx/main.py#L42-L129) (checked October 2026): a file with no `AUTH_TYPE`, whose `lifespan` enters the base one with `async with lifespan_base(app):` before `seed_db()` and `yield`, and whose `get_application` passes `lifespan_override=lifespan` to the base factory. It mounts a Google OAuth router at `/auth/oauth` only when `MULTI_TENANT` is set; source reading without an Onyx run. - [Onyx v4.9.2 EE application](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/ee/onyx/main.py#L43-L130) (checked October 2026): the same `lifespan`, factory call and `MULTI_TENANT`-only Google OAuth router as v4.5.0, in a file that also has no `AUTH_TYPE` and also mounts an LLM gateway router; source reading without an Onyx run. - [Onyx v4.9.2 SCIM audit actor](https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/ee/onyx/server/scim/api.py#L90) (checked November 2026): `return AuditActor(api_key_id=f"scim_token:{token.id}", auth_type="scim")`, the one case-insensitive `auth_type` match in the v4.9.2 `backend/ee` tree, a keyword argument and not the `AUTH_TYPE` environment variable; source reading without an Onyx run. - [Onyx v4.9.4 startup warning](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L394-L395) (checked October 2026): the same `verify_auth_setting()` call and comment as v4.9.2; in the fetched repository `main.py` is byte-identical at v4.9.2, v4.9.3 and v4.9.4. - [Onyx v4.9.4 startup SAML import](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L414-L432) (checked October 2026): the same non-multitenant legacy SAML import call and comment as v4.9.2. - [Onyx v4.9.4 startup hook yield](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L452-L455) (checked October 2026): the `lifespan` startup hook's `try: yield`, which follows the startup SAML import, so an exception the import does not catch ends the hook before it yields; source reading without an Onyx run. - [Onyx v4.9.4 authentication routers](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L651-L750) (checked October 2026): the same router mounting as v4.9.2; source reading, which does not establish deployed authentication selection. - [Onyx v4.9.4 startup warning helper](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/auth/users.py#L200-L219) (checked October 2026): the same `verify_auth_setting()` body as v4.9.2; in the fetched repository `users.py` is byte-identical at v4.9.2, v4.9.3 and v4.9.4. - [Onyx v4.9.4 legacy SAML import helper](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/sso_provider.py#L300-L398) (checked October 2026): the same `seed_saml_provider_from_conf_dir` body as v4.9.2, whose `certs/sp.key` read catches only `OSError` and whose row creation catches only `IntegrityError`; in the fetched repository `sso_provider.py` is byte-identical at v4.9.2, v4.9.3, v4.9.4 and v5.0.0-beta.3. - [Onyx v4.9.4 SSO provider config models](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/sso_provider.py#L28-L82) (checked October 2026): config models that reject unknown keys, and the SAML model's four required string fields (`idp_entity_id`, `idp_sso_url`, `idp_x509_cert` and `sp_entity_id`) beside optional SP and email-attribute fields. - [Onyx v4.9.4 SSO provider row creation](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/sso_provider.py#L132-L256) (checked October 2026): `validate_sso_config` re-raising a pydantic `ValidationError` as `ValueError`, `validate_sso_provider_name` raising `ValueError` for a name that is not a lowercase slug, and `create_sso_provider` running both before it adds and commits the row; source reading without an Onyx run. - [Onyx v4.9.4 database session helpers](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/engine/sql_engine.py#L441-L549) (checked October 2026): `get_session_with_current_tenant` and `get_session_with_tenant` closing the session in `finally` with no `except`, so an exception raised in the startup session block propagates; source reading without an Onyx run. - [Onyx v4.9.4 startup telemetry](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L434-L438) (checked October 2026): an `if not MULTI_TENANT` block calling `optional_telemetry` with a version record; source reading without an Onyx run. - [Onyx v4.9.4 application factory](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L534-L549) (checked October 2026): `get_application` creating the FastAPI application with `lifespan=lifespan_override or lifespan`, so its startup hook is `lifespan` unless the caller passes an override; source reading without an Onyx run. - [Onyx v4.9.4 application export](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L812-L815) (checked October 2026): a module-level `set_is_ee_based_on_env_variable()` call, then `app` taken from `fetch_versioned_implementation(module="onyx.main", attribute="get_application")`; source reading without an Onyx run. - [Onyx v4.9.4 Enterprise Edition switch](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/utils/variable_functionality.py#L42-L72) (checked October 2026): `LICENSE_ENFORCEMENT_ENABLED` read from the environment with a default of `true`, compared ignoring case. `set_is_ee_based_on_env_variable` then turns Enterprise Edition on when `ENTERPRISE_EDITION_ENABLED` is true or, failing that, when that license value is true; source reading without an Onyx run. - [Onyx v4.9.4 versioned implementation lookup](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/utils/variable_functionality.py#L100-L127) (checked October 2026): `fetch_versioned_implementation` importing from the `ee.`-prefixed module when Enterprise Edition is on. When that import raises a `ModuleNotFoundError` whose message contains `ee.onyx`, it falls back to the unprefixed module; source reading without an Onyx run. - [Onyx v4.9.4 EE application lifespan](https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/ee/onyx/main.py#L58-L78) (checked October 2026): `from onyx.main import lifespan as lifespan_base` at line 58, and an EE `lifespan` whose `async with lifespan_base(app):` at line 73 encloses `seed_db()` and its own `yield` at line 78; in the fetched files `backend/ee/onyx/main.py` is byte-identical at v4.9.2, v4.9.3 and v4.9.4. Source reading without an Onyx run. - [Onyx v5.0.0-beta.3 startup calls](https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/main.py#L395-L431) (checked October 2026): a pre-release read for comparison only, with the same `verify_auth_setting()` call and the same non-multitenant legacy SAML import call and comment as v4.9.2, at shifted lines, and without the telemetry comment and `get_or_generate_uuid()` call that precede the import at v4.9.2. - [Onyx v5.0.0-beta.3 authentication routers](https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/main.py#L659-L758) (checked October 2026): a pre-release read for comparison only, with the v4.9.2 router mounting verbatim. - [Onyx v5.0.0-beta.3 startup warning helper](https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/auth/users.py#L204-L223) (checked October 2026): a pre-release read for comparison only, with the v4.9.2 `verify_auth_setting()` body verbatim. - [Onyx v5.0.0-beta.3 legacy SAML import helper](https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/db/sso_provider.py#L300-L398) (checked October 2026): a pre-release read for comparison only, with the v4.9.2 `seed_saml_provider_from_conf_dir` body in a byte-identical file. - [Onyx v5.0.0-beta.3 startup telemetry](https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/main.py#L433) (checked October 2026): a pre-release read for comparison only, with a `start_telemetry("api")` call. - [Onyx v5.0.0-beta.3 OAuth provider routers](https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/main.py#L646-L657) (checked October 2026): a pre-release read for comparison only, mounting the `onyx.server.oauth_provider` protocol and user routers only when `OAUTH_PROVIDER_SETTINGS` is not `None`; the routers themselves were not reviewed. - [Onyx local Docker deployment](https://docs.onyx.app/deployment/local/docker.md): documented access at `localhost:3000`. - [Onyx production Compose](https://github.com/onyx-dot-app/onyx/blob/a0370f232ba4e4625131fae518b86e5530e98ec5/deployment/docker_compose/docker-compose.prod.yml): nginx-only host publications. - [Onyx development Compose](https://github.com/onyx-dot-app/onyx/blob/a0370f232ba4e4625131fae518b86e5530e98ec5/deployment/docker_compose/docker-compose.dev.yml): API and backing-service publications, and its own header naming the two-file launch form. - [Onyx base Compose](https://github.com/onyx-dot-app/onyx/blob/a0370f232ba4e4625131fae518b86e5530e98ec5/deployment/docker_compose/docker-compose.yml): nginx publishing `${HOST_PORT_80:-80}:80` and `${HOST_PORT:-3000}:80`, and `web_server` carrying no host publication. - [Docker packet filtering and firewalls](https://docs.docker.com/engine/network/packet-filtering-firewalls/): published ports and firewall interaction. - [Compose networking](https://docs.docker.com/compose/how-tos/networking/): communication between services without host publication. - [Compose merge rules](https://docs.docker.com/reference/compose-file/merge/): merged port lists and `!reset`. - [Docker Compose ps](https://docs.docker.com/reference/cli/docker/compose/ps/): running service information and publication fields. - [curl manual](https://curl.se/docs/manpage.html): request options, TLS verification, proxy bypass, timing, and error reporting. - GNU grep 3.12 manual, exit status (checked October 2026): https://www.gnu.org/software/grep/manual/grep.html#Exit-Status - [LocalAI CLI reference](https://localai.io/docs/reference/cli-reference/): the `--address` flag and `LOCALAI_ADDRESS` bind, default `:8080`. - [SearxNG webapp implementation](https://raw.githubusercontent.com/searxng/searxng/d4ce87c23431f607162fc5c39ce52c538d64588f/searx/webapp.py): the production startup rejection of the `ultrasecretkey` placeholder and the image proxy following redirects. - [Onyx data model](https://raw.githubusercontent.com/onyx-dot-app/onyx/15ead4ca364e654d40dac842ee9d0bb9e0fe5471/backend/onyx/db/models.py): the retained per-connector `Credential.credential_json` storage. - [Text Embeddings Inference gRPC server](https://raw.githubusercontent.com/huggingface/text-embeddings-inference/1bb59202500e5f69dd8be63dd1604f7625124fbe/router/src/grpc/server.rs): the standalone Prometheus exporter (`prom_builder.install()`) of the gRPC build, distinct from the HTTP build's main-port `/metrics`. ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - searx: SearxNG documentation (unknown) - tei: TEI HTTP source (v1.9.0) - onyx-v43: Onyx v4.3 source (v4.3.13) - onyx-v44: Onyx v4.4 source (v4.4.0) - onyx-v45: Onyx v4.5 source (v4.5.0) - onyx-v49: Onyx v4.9 source (v4.9.2) - onyx-v494: Onyx v4.9 source (v4.9.4) - onyx-v50-beta: Onyx v5.0 pre-release source (55df913b37cb9c001a77dd010ff8c6331e70447b) - searx-source: SearxNG source (d4ce87c23431f607162fc5c39ce52c538d64588f) - localai: LocalAI documentation (unknown) - tei-docs: TEI documentation (unknown) - tei-grpc: TEI gRPC source (1bb59202500e5f69dd8be63dd1604f7625124fbe) - langserve: LangServe source (27e57afeda13007a7f4e007c5d1f5e8489963aa4) - mem0: Mem0 documentation (unknown) - mem0-source: Mem0 server source (c7ee362aff94a369af70f13f2b4f853f6793ff4c) - onyx: Onyx documentation (unknown) - onyx-compose: Onyx Compose (a0370f232ba4e4625131fae518b86e5530e98ec5) - onyx-data: Onyx data model (15ead4ca364e654d40dac842ee9d0bb9e0fe5471) - docker: Docker (unknown) - grep: GNU grep (3.12) - curl: curl documentation (unknown) Claims (full source mapping in the JSON block below): - searx-bind (REASONED; searx): Direct SearxNG defaults 127.0.0.1:8888 via server.bind_address/port; documented Docker wildcard 8888:8080 needs loopback replacement or removal. - searx-auth (REASONED; searx): Verified SearxNG configuration has no authentication; protect search with authenticated ingress. - searx-secret (REASONED; searx, searx-source): Set unique server.secret_key/SEARXNG_SECRET; production startup refuses shipped ultrasecretkey, which is not user authentication. - searx-egress (REASONED; searx-source): Server-side search/image fetches, including image redirects, need outbound restrictions against internal/metadata destinations. - localai-bind (REASONED; localai): LocalAI defaults :8080; set LOCALAI_ADDRESS or --address for host loopback; documented 8080:8080 publication needs restriction. - localai-key (REASONED; localai): LOCALAI_API_KEY is unset by default; set unique native key and retain ingress authentication. - localai-oauth (REASONED; localai): LOCALAI_AUTH=true enables multi-user OAuth with per-user keys. - tei-bind (REASONED; tei-docs): TEI CLI defaults 0.0.0.0:3000, distinct from documented container 80 mapped to host 8080; use hostname/port or private publication. - tei-key (REASONED; tei-docs, tei): API_KEY/--api-key is unset by default; configured key requires Authorization Bearer on inference routes. - tei-exempt (REASONED; tei): HTTP /, /health, /ping, /metrics, /docs and /api-doc/openapi.json remain outside inference auth on the main port. - tei-metrics (REASONED; tei-docs, tei, tei-grpc): HTTP metrics share main port and expose model/request information; prometheus-port default 9000 starts standalone exporter only in gRPC build. - langserve (REASONED; langserve): Deprecated 2024-11-18; README recommends LangGraph Platform. Existing quickstart localhost:8000 needs FastAPI application auth or full authenticated proxy. - private-publication (REASONED; docker): Container loopback is not host/proxy reachability; retain container-network access and remove or host-loopback-scope publications. - firewall (REASONED; docker): No-host-IP Docker publications bind all host interfaces; Linux forwarding can bypass UFW. - compose-reset (REASONED; docker, mem0-source): Ordinary override ports merge; use !reset [] to remove Mem0 Postgres publication, inspect effective invocation/model, or edit base if unsupported. - ingress (REASONED; onyx-compose, docker): Publish only authenticated TLS ingress and gate every capability route; production Onyx publication boundary alone proves no certificate or access policy. - mem0-auth (REASONED; mem0): Mem0 auth defaults on: dashboard JWT, per-user X-API-Key m0sk_ or legacy ADMIN_API_KEY; do not deploy AUTH_DISABLED=true. - mem0-warnings (REASONED; mem0-source): AUTH_DISABLED warning suppresses short nonempty ADMIN_API_KEY (<16) warning via if/elif; unset key has separate branch; warnings do not stop startup. - mem0-jwt (REASONED; mem0, mem0-source): With auth on, missing JWT_SECRET raises at startup; generate with openssl rand -base64 48 and keep signing secret private. - mem0-bootstrap (REASONED; mem0): POST /auth/register grants first admin only while no user exists, then returns 403; bootstrap privately with make bootstrap or host-only wizard. - mem0-open (REASONED; mem0): Root redirect, /docs and /openapi.json stay open and cannot prove protected-route authentication. - mem0-api-port (REASONED; mem0-source): Compose runs API 0.0.0.0:8000 and wildcard host 8888:8000; remove publication or replace with host loopback. - mem0-db-port (REASONED; mem0-source): Compose publishes Postgres 8432:5432 independently of application auth; remove host mapping and retain database auth. - mem0-dashboard (REASONED; mem0-source): Compose dashboard 3000:3000 needs private publication and protected ingress; dashboard authentication coverage is unverified. - mem0-reload (REASONED; mem0-source): Compose uvicorn --reload is a development setting; remove for production. - onyx-prod (REASONED; onyx-compose): Production Compose publishes only nginx 80:80 and 443:443; backing services remain unpublished. - onyx-bootstrap (REASONED; onyx): Email/password auth is on and first signup becomes admin; claim privately before exposure. - onyx-auth-type (REASONED; onyx, onyx-v43, onyx-v44): Guide inference from v4.3.13/v4.4.0 tags: beyond two one-time imports (migration Google/OIDC row, startup SAML row), AUTH_TYPE no longer selects v4.4.0 runtime auth; legacy values remain for migrations, a one-time legacy SAML startup import and a stale-value warning. Version identity rests on tags; cited web manifests lack release versions. Guide advice: do not set AUTH_TYPE to change auth. - onyx-auth-type-legacy-startup (REASONED; onyx-v45, onyx-v49, onyx-v494, onyx-v50-beta, onyx): Guide assessment: v4.5.0, v4.9.2 to v4.9.4 (newest stable reviewed) and pre-release v5.0.0-beta.3 retain the AUTH_TYPE stale-value warning call and, under if not MULTI_TENANT, the legacy SAML startup call (commented no-op unless AUTH_TYPE=saml with no SAML row). Runtime auth selection, full helper behaviour and any docs-site v4.5 removal record remain unverified (docs index: not found 2026-11-04). - onyx-auth-type-plan-record (REASONED; onyx, onyx-v44, onyx-v45, onyx-v49): Documented: undated Onyx basic auth page said (Oct 2026) AUTH_TYPE "is inert" since v4.4.0, full removal planned in v4.5, and (2026-11-04) that it "is no longer used"; v4.4.0 release notes list removing AUTH_TYPE and AuthType enum. Source reading: v4.5.0/v4.9.2 env.prod.template heads its legacy SSO block (with AUTH_TYPE) "Planned for removal in v4.5." None records the full v4.5 removal as done. - onyx-auth-type-later-selection (REASONED; onyx-v45, onyx-v49): Source reading and guide inference: a case-sensitive AUTH_TYPE search of Onyx v4.5.0/v4.9.2 backend/web finds reads only in two migrations and two helpers; main.py always mounts password login, gating only env-credential Google login on OAuth credentials. Beyond two one-time imports (migration Google/OIDC row, startup SAML row), AUTH_TYPE selects no runtime auth; deployed selection is unobserved. - onyx-auth-type-helpers (REASONED; onyx-v45, onyx-v49, onyx-v494): Source reading, v4.5.0, v4.9.2-v4.9.4: verify_auth_setting only logs AUTH_TYPE disabled/google_oauth/oidc/saml warnings, then MULTI_TENANT mode notice. SAML seed acts only if single-tenant, AUTH_TYPE=saml, no SAML row, valid SAML_CONF_DIR settings.json, adding one row, catching only IntegrityError; at v4.9.2-v4.9.4 other errors end base and (guide inference) outer EE lifespans pre-yield (not run). - onyx-auth-type-removal-search (REASONED; onyx, onyx-v45): Documentation reading: notes of the 48 stable Onyx releases v4.5.0 to v5.0.0 in the GitHub release list fetched 2026-10-25 record no removal of AUTH_TYPE or legacy SSO variables. On 2026-11-04 docs llms.txt, sitemap, changelog, release process and migration pages record no full v4.5 removal (the v4.4.0 changelog entry says AUTH_TYPE "has been removed", calling it inert). Not found in either scope. - onyx-auth-type-ee-main (REASONED; onyx-v45, onyx-v49, onyx-v494): Source reading: backend/ee/onyx/main.py at v4.5.0/v4.9.2 nests base lifespan in its own, passed to base get_application, adds a Google OAuth router only under MULTI_TENANT; no backend/ee file at either has AUTH_TYPE (string search; others unread). Guide inference, v4.9.2-v4.9.4 only (v4.5.0 base factory uncited): EE runs the base startup, narrowing, not settling, runtime selection; nothing run. - onyx-base (REASONED; onyx-compose, onyx): Development merges base plus override; base nginx publishes 80:80 and 3000:80, while web_server has no independent host publication. - onyx-api (REASONED; onyx-compose): Development override adds API 8080:8080; remove unless deliberately restricted for access. - onyx-backing (REASONED; onyx-compose): Development adds Postgres 5432, OpenSearch 9200, inference 9000, Redis 6379, MinIO 9004/9005 and interpreter 8000; application auth does not protect these. - onyx-port-env (REASONED; onyx-compose): ${VAR:-default} changes or restores host port; an empty variable does not remove publication. - onyx-secrets (REASONED; onyx-data): Connector credential_json persists in database; use least privilege, protect storage/backups and rotate after exposure. - mfa (REASONED; mem0, onyx): Native MFA coverage remains unverified; guide relies on identity/fronting MFA and separate machine credentials. - secrets (REASONED; localai, tei-docs, mem0, onyx-data): Runtime provider/signing/service keys stay out of source/images; outbound provider credentials are not inbound authentication. - verify-placeholder (DEMONSTRATED; curl, grep): Placeholder scan should print matched tokens with grep_exit=0, clean with 1; read errors are inconclusive. Local guard outcomes and a direct grep match are recorded in the Verify overview; the deployed-state outcomes remain unobserved. The guard and scan runs covered an earlier form whose placeholder refusal exited with status 1 and whose scans exited 0; the current form was not re-run. - verify-guards (REASONED; curl): Whole-block marker/count and empty/placeholder guards are prerequisites, not exposure proof; omitted guards and matching inherited positional values remain limits. - verify-inventory (REASONED; docker): Compare full IPv4/IPv6 socket, merged model and running Publishers inventory; DNAT may escape ss, exposed-only entries are not publications, and empty/error inventories do not pass. - verify-direct (REASONED; curl, docker): Any direct HTTP response or completed TCP connection proves reachability; refused actual backend plus inventory/working ingress supports isolation, timeout or http=000 alone does not. - verify-backing (REASONED; docker): Probe every actual private application/backing TCP port; connection success is exposure, refusal needs configuration evidence, timeout/local/netcat-option errors are inconclusive. - verify-ingress (REASONED; localai, tei-docs, tei, mem0, onyx, curl): Harmless protected request should reject anonymous caller and admit authorized control; inspect redirect destination/body, and test routes outside native auth through ingress too. - verify-tei (REASONED; tei): POST /embed with harmless inputs should reject missing key 401 and return embeddings with valid key; anonymous health/docs/metrics cannot test native auth. - verify-native (REASONED; localai, tei-docs, mem0, onyx, langserve): From trusted private position vary only empty/wrong/valid credential for same request; first two reject, third returns real output, without publishing a test port. - verify-native-limits (REASONED; searx, langserve, mem0, onyx): Intentionally anonymous private SearxNG/LangServe instead use authenticated ingress; exact selected Mem0/Onyx response codes and dashboard policy remain unverified. - verify-positive (REASONED; curl, localai, tei-docs, mem0, onyx): Authorized ingress request must return expected application body, paired with negative checks; 2xx alone, login page, errors or unreachable service cannot establish success. - verify-headers (REASONED; curl): Credential headers go on curl stdin under control-character guard; separate ingress/native credentials require both, not argv. - verify-browser (REASONED; mem0, onyx): An MFA-protected operation must be refused on a password-only attempt and succeed after the second factor; a direct forged identity header must be refused at the network boundary, a valid authenticated session must reach the app through the proxy, and a forged trusted header sent through it must not change the identity in the app log. Neither the MFA nor the header outcomes were demonstrated. ````json { "schema": 1, "checked": "2026-10-20", "documentation_checked": "2026-09", "body_sha256": "b84a2f89263762162f1107da1188e16398936985d705a41591d16d079c2b00ff", "components": { "searx": { "name": "SearxNG documentation", "basis": "unknown", "sources": { "s99812c56f9f8": "https://docs.searxng.org/admin/settings/settings_server.html", "sb89a5ded36cc": "https://docs.searxng.org/admin/installation-docker.html" } }, "tei": { "name": "TEI HTTP source", "basis": "v1.9.0", "sources": { "s2b649738d265": "https://github.com/huggingface/text-embeddings-inference/blob/v1.9.0/router/src/http/server.rs" } }, "onyx-v43": { "name": "Onyx v4.3 source", "basis": "v4.3.13", "sources": { "s279734512e1b": "https://github.com/onyx-dot-app/onyx/blob/v4.3.13/backend/onyx/configs/app_configs.py#L133-L142", "sb03484e56906": "https://github.com/onyx-dot-app/onyx/blob/v4.3.13/backend/onyx/main.py#L581-L608", "seb430888edc4": "https://github.com/onyx-dot-app/onyx/blob/v4.3.13/web/package.json#L3" } }, "onyx-v44": { "name": "Onyx v4.4 source", "basis": "v4.4.0", "sources": { "s799b0a246cf5": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/main.py#L371-L372", "sac84754b3dd2": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/main.py#L392-L397", "s9275371ceeec": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/auth/users.py#L183-L200", "sc9b10b6185fd": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/db/sso_provider.py#L197-L295", "s5c46d1afde57": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/alembic/versions/1fc2904131a3_add_sso_provider_table_and_seed_from_env.py#L60-L129", "s75cc112525f7": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/alembic/versions/f7ca3e2f45d9_migrate_no_auth_data_to_placeholder.py#L129-L193", "s54e8bbf4f896": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/main.py#L589-L681", "s6602dda5fa9c": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/db/models.py#L6618-L6645", "s170322fcd248": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/server/saml_multi.py#L102-L140", "s74923f110244": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/backend/onyx/server/oidc_multi.py#L74-L97", "sbc5f578e1c7e": "https://github.com/onyx-dot-app/onyx/blob/v4.4.0/web/package.json#L3", "sa49753aa8478": "https://github.com/onyx-dot-app/onyx/releases/tag/v4.4.0" } }, "onyx-v45": { "name": "Onyx v4.5 source", "basis": "v4.5.0", "sources": { "s50116ebcab47": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/main.py#L378-L379", "sab806f01b340": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/main.py#L398-L414", "s7ebeb588576d": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/deployment/docker_compose/env.prod.template#L24-L26", "sbc16f4507870": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/main.py#L611-L703", "sc23bf087d9f8": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/auth/users.py#L184-L203", "sd046764eadb4": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/db/sso_provider.py#L247-L345", "sb5ac094a2533": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/alembic/versions/1fc2904131a3_add_sso_provider_table_and_seed_from_env.py#L60-L129", "s1f8204533a74": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/alembic/versions/f7ca3e2f45d9_migrate_no_auth_data_to_placeholder.py#L129-L193", "sb0b19e5482b8": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/db/models.py#L6873-L6900", "s20888688ad03": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/server/saml_multi.py#L103-L141", "s0945580d24f5": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/onyx/server/oidc_multi.py#L74-L97", "scfaeea113df6": "https://github.com/onyx-dot-app/onyx/releases/tag/v4.5.0", "s7f4227427c7d": "https://github.com/onyx-dot-app/onyx/blob/055b0c5d0e0a1187ef64d109d700ef4e1bcc5796/backend/ee/onyx/main.py#L42-L129" } }, "onyx-v49": { "name": "Onyx v4.9 source", "basis": "v4.9.2", "sources": { "sba8318be94ea": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/main.py#L394-L395", "sab9b9d5d76ac": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/main.py#L414-L432", "sb19e8617a53f": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/deployment/docker_compose/env.prod.template#L24-L26", "sc82872ea4ee8": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/main.py#L651-L750", "s85054eff3304": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/auth/users.py#L200-L219", "sad04863d1458": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/db/sso_provider.py#L300-L398", "s589436426362": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/alembic/versions/1fc2904131a3_add_sso_provider_table_and_seed_from_env.py#L60-L129", "sed2172013ac6": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/alembic/versions/f7ca3e2f45d9_migrate_no_auth_data_to_placeholder.py#L129-L193", "secbe5662c7eb": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/db/models.py#L7490-L7517", "se9a7d604174b": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/server/saml_multi.py#L40-L156", "sf74066095373": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/onyx/server/oidc_multi.py#L161-L184", "sd4abc0fb7b2c": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/ee/onyx/main.py#L43-L130", "sb57e6f0a50cc": "https://github.com/onyx-dot-app/onyx/blob/df49072cabd57a37c75143f78910023bf1cc4dcb/backend/ee/onyx/server/scim/api.py#L90" } }, "onyx-v494": { "name": "Onyx v4.9 source", "basis": "v4.9.4", "sources": { "s93073617917f": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L394-L395", "saa2935bc2cc9": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L414-L432", "sa1ae7eae7541": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L452-L455", "sa460f7210ca4": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/auth/users.py#L200-L219", "sdf15a9263d34": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/sso_provider.py#L300-L398", "s56d064c896d9": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/sso_provider.py#L28-L82", "s55b9746616f4": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/sso_provider.py#L132-L256", "s8a5f96230d05": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/db/engine/sql_engine.py#L441-L549", "s19ba2d899d29": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L534-L549", "s8e0f5703a244": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/main.py#L812-L815", "s1230ebe5af1d": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/onyx/utils/variable_functionality.py#L100-L127", "s0bd0b2d82697": "https://github.com/onyx-dot-app/onyx/blob/4cfda3c38c7f25e190d735b52d6c8e75522d99f5/backend/ee/onyx/main.py#L58-L78" } }, "onyx-v50-beta": { "name": "Onyx v5.0 pre-release source", "basis": "55df913b37cb9c001a77dd010ff8c6331e70447b", "sources": { "sb6a38c2222ba": "https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/main.py#L395-L431", "s8bbce4b47acc": "https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/auth/users.py#L204-L223", "s8843778589c8": "https://github.com/onyx-dot-app/onyx/blob/55df913b37cb9c001a77dd010ff8c6331e70447b/backend/onyx/db/sso_provider.py#L300-L398" } }, "searx-source": { "name": "SearxNG source", "basis": "d4ce87c23431f607162fc5c39ce52c538d64588f", "sources": { "s1669cd3902ff": "https://raw.githubusercontent.com/searxng/searxng/d4ce87c23431f607162fc5c39ce52c538d64588f/searx/webapp.py" } }, "localai": { "name": "LocalAI documentation", "basis": "unknown", "sources": { "sac7ce142c7f6": "https://localai.io/docs/basics/getting_started/", "sc3fe058c79a4": "https://localai.io/docs/reference/cli-reference/" } }, "tei-docs": { "name": "TEI documentation", "basis": "unknown", "sources": { "s7d188dacbcfc": "https://huggingface.co/docs/text-embeddings-inference/en/quick_tour", "s013618141267": "https://huggingface.co/docs/text-embeddings-inference/en/cli_arguments" } }, "tei-grpc": { "name": "TEI gRPC source", "basis": "1bb59202500e5f69dd8be63dd1604f7625124fbe", "sources": { "s509a423cecd0": "https://raw.githubusercontent.com/huggingface/text-embeddings-inference/1bb59202500e5f69dd8be63dd1604f7625124fbe/router/src/grpc/server.rs" } }, "langserve": { "name": "LangServe source", "basis": "27e57afeda13007a7f4e007c5d1f5e8489963aa4", "sources": { "sa9fa1381b09a": "https://github.com/langchain-ai/langserve/blob/27e57afeda13007a7f4e007c5d1f5e8489963aa4/README.md" } }, "mem0": { "name": "Mem0 documentation", "basis": "unknown", "sources": { "s37e3935419ed": "https://docs.mem0.ai/open-source/features/rest-api" } }, "mem0-source": { "name": "Mem0 server source", "basis": "c7ee362aff94a369af70f13f2b4f853f6793ff4c", "sources": { "sa73e09554796": "https://github.com/mem0ai/mem0/blob/c7ee362aff94a369af70f13f2b4f853f6793ff4c/server/docker-compose.yaml", "s491ae4b02d2d": "https://github.com/mem0ai/mem0/blob/c7ee362aff94a369af70f13f2b4f853f6793ff4c/server/main.py" } }, "onyx": { "name": "Onyx documentation", "basis": "unknown", "sources": { "sc7649811ee67": "https://docs.onyx.app/deployment/authentication/basic.md", "scb3a0b1624de": "https://docs.onyx.app/deployment/local/docker.md", "s340e025f4f48": "https://api.github.com/repos/onyx-dot-app/onyx/releases?per_page=100", "s685daf37d278": "https://api.github.com/repos/onyx-dot-app/onyx/releases?per_page=100&page=2", "s651883102d82": "https://docs.onyx.app/llms.txt", "s6681a93c4f99": "https://docs.onyx.app/sitemap.xml", "sa7d9d07745ec": "https://docs.onyx.app/changelog", "sa9d706f7a521": "https://docs.onyx.app/deployment/miscellaneous/release_process.md", "sd804b75439bd": "https://docs.onyx.app/llms-full.txt" } }, "onyx-compose": { "name": "Onyx Compose", "basis": "a0370f232ba4e4625131fae518b86e5530e98ec5", "sources": { "sc35a382f123e": "https://github.com/onyx-dot-app/onyx/blob/a0370f232ba4e4625131fae518b86e5530e98ec5/deployment/docker_compose/docker-compose.prod.yml", "sb82664aa67ff": "https://github.com/onyx-dot-app/onyx/blob/a0370f232ba4e4625131fae518b86e5530e98ec5/deployment/docker_compose/docker-compose.dev.yml", "s1b87311a0e14": "https://github.com/onyx-dot-app/onyx/blob/a0370f232ba4e4625131fae518b86e5530e98ec5/deployment/docker_compose/docker-compose.yml" } }, "onyx-data": { "name": "Onyx data model", "basis": "15ead4ca364e654d40dac842ee9d0bb9e0fe5471", "sources": { "s991ce173678a": "https://raw.githubusercontent.com/onyx-dot-app/onyx/15ead4ca364e654d40dac842ee9d0bb9e0fe5471/backend/onyx/db/models.py" } }, "docker": { "name": "Docker", "basis": "unknown", "sources": { "s351180c6678f": "https://docs.docker.com/engine/network/packet-filtering-firewalls/", "sae565a19136c": "https://docs.docker.com/compose/how-tos/networking/", "se716ad33db1f": "https://docs.docker.com/reference/compose-file/merge/", "s3e97c2c250b6": "https://docs.docker.com/reference/cli/docker/compose/ps/" } }, "grep": { "name": "GNU grep", "basis": "3.12", "sources": { "sa8d467168870": "https://www.gnu.org/software/grep/manual/grep.html#Exit-Status" } }, "curl": { "name": "curl documentation", "basis": "unknown", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "searx-bind": {"text": "Direct SearxNG defaults 127.0.0.1:8888 via server.bind_address/port; documented Docker wildcard 8888:8080 needs loopback replacement or removal.", "components": ["searx"], "sources": ["searx:s99812c56f9f8", "searx:sb89a5ded36cc"], "status": "REASONED"}, "searx-auth": {"text": "Verified SearxNG configuration has no authentication; protect search with authenticated ingress.", "components": ["searx"], "sources": ["searx:s99812c56f9f8"], "status": "REASONED"}, "searx-secret": {"text": "Set unique server.secret_key/SEARXNG_SECRET; production startup refuses shipped ultrasecretkey, which is not user authentication.", "components": ["searx", "searx-source"], "sources": ["searx:s99812c56f9f8", "searx-source:s1669cd3902ff"], "status": "REASONED"}, "searx-egress": {"text": "Server-side search/image fetches, including image redirects, need outbound restrictions against internal/metadata destinations.", "components": ["searx-source"], "sources": ["searx-source:s1669cd3902ff"], "status": "REASONED"}, "localai-bind": {"text": "LocalAI defaults :8080; set LOCALAI_ADDRESS or --address for host loopback; documented 8080:8080 publication needs restriction.", "components": ["localai"], "sources": ["localai:sc3fe058c79a4", "localai:sac7ce142c7f6"], "status": "REASONED"}, "localai-key": {"text": "LOCALAI_API_KEY is unset by default; set unique native key and retain ingress authentication.", "components": ["localai"], "sources": ["localai:sac7ce142c7f6"], "status": "REASONED"}, "localai-oauth": {"text": "LOCALAI_AUTH=true enables multi-user OAuth with per-user keys.", "components": ["localai"], "sources": ["localai:sac7ce142c7f6"], "status": "REASONED"}, "tei-bind": {"text": "TEI CLI defaults 0.0.0.0:3000, distinct from documented container 80 mapped to host 8080; use hostname/port or private publication.", "components": ["tei-docs"], "sources": ["tei-docs:s013618141267", "tei-docs:s7d188dacbcfc"], "status": "REASONED"}, "tei-key": {"text": "API_KEY/--api-key is unset by default; configured key requires Authorization Bearer on inference routes.", "components": ["tei-docs", "tei"], "sources": ["tei-docs:s013618141267", "tei:s2b649738d265"], "status": "REASONED"}, "tei-exempt": {"text": "HTTP /, /health, /ping, /metrics, /docs and /api-doc/openapi.json remain outside inference auth on the main port.", "components": ["tei"], "sources": ["tei:s2b649738d265"], "status": "REASONED"}, "tei-metrics": {"text": "HTTP metrics share main port and expose model/request information; prometheus-port default 9000 starts standalone exporter only in gRPC build.", "components": ["tei-docs", "tei", "tei-grpc"], "sources": ["tei-docs:s013618141267", "tei:s2b649738d265", "tei-grpc:s509a423cecd0"], "status": "REASONED"}, "langserve": {"text": "Deprecated 2024-11-18; README recommends LangGraph Platform. Existing quickstart localhost:8000 needs FastAPI application auth or full authenticated proxy.", "components": ["langserve"], "sources": ["langserve:sa9fa1381b09a"], "status": "REASONED"}, "private-publication": {"text": "Container loopback is not host/proxy reachability; retain container-network access and remove or host-loopback-scope publications.", "components": ["docker"], "sources": ["docker:sae565a19136c", "docker:s351180c6678f"], "status": "REASONED"}, "firewall": {"text": "No-host-IP Docker publications bind all host interfaces; Linux forwarding can bypass UFW.", "components": ["docker"], "sources": ["docker:s351180c6678f"], "status": "REASONED"}, "compose-reset": {"text": "Ordinary override ports merge; use !reset [] to remove Mem0 Postgres publication, inspect effective invocation/model, or edit base if unsupported.", "components": ["docker", "mem0-source"], "sources": ["docker:se716ad33db1f", "mem0-source:sa73e09554796"], "status": "REASONED"}, "ingress": {"text": "Publish only authenticated TLS ingress and gate every capability route; production Onyx publication boundary alone proves no certificate or access policy.", "components": ["onyx-compose", "docker"], "sources": ["onyx-compose:sc35a382f123e", "docker:s351180c6678f"], "status": "REASONED"}, "mem0-auth": {"text": "Mem0 auth defaults on: dashboard JWT, per-user X-API-Key m0sk_ or legacy ADMIN_API_KEY; do not deploy AUTH_DISABLED=true.", "components": ["mem0"], "sources": ["mem0:s37e3935419ed"], "status": "REASONED"}, "mem0-warnings": {"text": "AUTH_DISABLED warning suppresses short nonempty ADMIN_API_KEY (<16) warning via if/elif; unset key has separate branch; warnings do not stop startup.", "components": ["mem0-source"], "sources": ["mem0-source:s491ae4b02d2d"], "status": "REASONED"}, "mem0-jwt": {"text": "With auth on, missing JWT_SECRET raises at startup; generate with openssl rand -base64 48 and keep signing secret private.", "components": ["mem0", "mem0-source"], "sources": ["mem0:s37e3935419ed", "mem0-source:s491ae4b02d2d"], "status": "REASONED"}, "mem0-bootstrap": {"text": "POST /auth/register grants first admin only while no user exists, then returns 403; bootstrap privately with make bootstrap or host-only wizard.", "components": ["mem0"], "sources": ["mem0:s37e3935419ed"], "status": "REASONED"}, "mem0-open": {"text": "Root redirect, /docs and /openapi.json stay open and cannot prove protected-route authentication.", "components": ["mem0"], "sources": ["mem0:s37e3935419ed"], "status": "REASONED"}, "mem0-api-port": {"text": "Compose runs API 0.0.0.0:8000 and wildcard host 8888:8000; remove publication or replace with host loopback.", "components": ["mem0-source"], "sources": ["mem0-source:sa73e09554796"], "status": "REASONED"}, "mem0-db-port": {"text": "Compose publishes Postgres 8432:5432 independently of application auth; remove host mapping and retain database auth.", "components": ["mem0-source"], "sources": ["mem0-source:sa73e09554796"], "status": "REASONED"}, "mem0-dashboard": {"text": "Compose dashboard 3000:3000 needs private publication and protected ingress; dashboard authentication coverage is unverified.", "components": ["mem0-source"], "sources": ["mem0-source:sa73e09554796"], "status": "REASONED"}, "mem0-reload": {"text": "Compose uvicorn --reload is a development setting; remove for production.", "components": ["mem0-source"], "sources": ["mem0-source:sa73e09554796"], "status": "REASONED"}, "onyx-prod": {"text": "Production Compose publishes only nginx 80:80 and 443:443; backing services remain unpublished.", "components": ["onyx-compose"], "sources": ["onyx-compose:sc35a382f123e"], "status": "REASONED"}, "onyx-bootstrap": {"text": "Email/password auth is on and first signup becomes admin; claim privately before exposure.", "components": ["onyx"], "sources": ["onyx:sc7649811ee67"], "status": "REASONED"}, "onyx-auth-type": {"text": "Guide inference from v4.3.13/v4.4.0 tags: beyond two one-time imports (migration Google/OIDC row, startup SAML row), AUTH_TYPE no longer selects v4.4.0 runtime auth; legacy values remain for migrations, a one-time legacy SAML startup import and a stale-value warning. Version identity rests on tags; cited web manifests lack release versions. Guide advice: do not set AUTH_TYPE to change auth.", "components": ["onyx", "onyx-v43", "onyx-v44"], "sources": ["onyx:sc7649811ee67", "onyx-v43:s279734512e1b", "onyx-v43:sb03484e56906", "onyx-v44:s799b0a246cf5", "onyx-v44:sac84754b3dd2", "onyx-v44:s9275371ceeec", "onyx-v44:sc9b10b6185fd", "onyx-v44:s5c46d1afde57", "onyx-v44:s75cc112525f7", "onyx-v44:s54e8bbf4f896", "onyx-v44:s6602dda5fa9c", "onyx-v44:s170322fcd248", "onyx-v44:s74923f110244", "onyx-v43:seb430888edc4", "onyx-v44:sbc5f578e1c7e"], "status": "REASONED"}, "onyx-auth-type-legacy-startup": {"text": "Guide assessment: v4.5.0, v4.9.2 to v4.9.4 (newest stable reviewed) and pre-release v5.0.0-beta.3 retain the AUTH_TYPE stale-value warning call and, under if not MULTI_TENANT, the legacy SAML startup call (commented no-op unless AUTH_TYPE=saml with no SAML row). Runtime auth selection, full helper behaviour and any docs-site v4.5 removal record remain unverified (docs index: not found 2026-11-04).", "components": ["onyx-v45", "onyx-v49", "onyx-v494", "onyx-v50-beta", "onyx"], "sources": ["onyx-v45:s50116ebcab47", "onyx-v45:sab806f01b340", "onyx-v49:sba8318be94ea", "onyx-v49:sab9b9d5d76ac", "onyx-v494:s93073617917f", "onyx-v494:saa2935bc2cc9", "onyx-v50-beta:sb6a38c2222ba", "onyx-v50-beta:s8bbce4b47acc", "onyx-v50-beta:s8843778589c8", "onyx:s651883102d82", "onyx:s6681a93c4f99", "onyx:sa7d9d07745ec", "onyx:sa9d706f7a521", "onyx:sd804b75439bd"], "status": "REASONED"}, "onyx-auth-type-plan-record": {"text": "Documented: undated Onyx basic auth page said (Oct 2026) AUTH_TYPE \"is inert\" since v4.4.0, full removal planned in v4.5, and (2026-11-04) that it \"is no longer used\"; v4.4.0 release notes list removing AUTH_TYPE and AuthType enum. Source reading: v4.5.0/v4.9.2 env.prod.template heads its legacy SSO block (with AUTH_TYPE) \"Planned for removal in v4.5.\" None records the full v4.5 removal as done.", "components": ["onyx", "onyx-v44", "onyx-v45", "onyx-v49"], "sources": ["onyx:sc7649811ee67", "onyx-v44:sa49753aa8478", "onyx-v45:s7ebeb588576d", "onyx-v49:sb19e8617a53f"], "status": "REASONED"}, "onyx-auth-type-later-selection": {"text": "Source reading and guide inference: a case-sensitive AUTH_TYPE search of Onyx v4.5.0/v4.9.2 backend/web finds reads only in two migrations and two helpers; main.py always mounts password login, gating only env-credential Google login on OAuth credentials. Beyond two one-time imports (migration Google/OIDC row, startup SAML row), AUTH_TYPE selects no runtime auth; deployed selection is unobserved.", "components": ["onyx-v45", "onyx-v49"], "sources": ["onyx-v45:sbc16f4507870", "onyx-v45:sc23bf087d9f8", "onyx-v45:sd046764eadb4", "onyx-v45:sb5ac094a2533", "onyx-v45:s1f8204533a74", "onyx-v45:sb0b19e5482b8", "onyx-v45:s20888688ad03", "onyx-v45:s0945580d24f5", "onyx-v49:sc82872ea4ee8", "onyx-v49:s85054eff3304", "onyx-v49:sad04863d1458", "onyx-v49:s589436426362", "onyx-v49:sed2172013ac6", "onyx-v49:secbe5662c7eb", "onyx-v49:se9a7d604174b", "onyx-v49:sf74066095373"], "status": "REASONED"}, "onyx-auth-type-helpers": {"text": "Source reading, v4.5.0, v4.9.2-v4.9.4: verify_auth_setting only logs AUTH_TYPE disabled/google_oauth/oidc/saml warnings, then MULTI_TENANT mode notice. SAML seed acts only if single-tenant, AUTH_TYPE=saml, no SAML row, valid SAML_CONF_DIR settings.json, adding one row, catching only IntegrityError; at v4.9.2-v4.9.4 other errors end base and (guide inference) outer EE lifespans pre-yield (not run).", "components": ["onyx-v45", "onyx-v49", "onyx-v494"], "sources": ["onyx-v45:sc23bf087d9f8", "onyx-v45:sd046764eadb4", "onyx-v49:s85054eff3304", "onyx-v49:sad04863d1458", "onyx-v49:sab9b9d5d76ac", "onyx-v49:sd4abc0fb7b2c", "onyx-v494:sa460f7210ca4", "onyx-v494:sdf15a9263d34", "onyx-v494:s56d064c896d9", "onyx-v494:s55b9746616f4", "onyx-v494:s93073617917f", "onyx-v494:saa2935bc2cc9", "onyx-v494:sa1ae7eae7541", "onyx-v494:s8a5f96230d05", "onyx-v494:s19ba2d899d29", "onyx-v494:s8e0f5703a244", "onyx-v494:s1230ebe5af1d", "onyx-v494:s0bd0b2d82697"], "status": "REASONED"}, "onyx-auth-type-removal-search": {"text": "Documentation reading: notes of the 48 stable Onyx releases v4.5.0 to v5.0.0 in the GitHub release list fetched 2026-10-25 record no removal of AUTH_TYPE or legacy SSO variables. On 2026-11-04 docs llms.txt, sitemap, changelog, release process and migration pages record no full v4.5 removal (the v4.4.0 changelog entry says AUTH_TYPE \"has been removed\", calling it inert). Not found in either scope.", "components": ["onyx", "onyx-v45"], "sources": ["onyx:s340e025f4f48", "onyx:s685daf37d278", "onyx-v45:scfaeea113df6", "onyx:s651883102d82", "onyx:s6681a93c4f99", "onyx:sa7d9d07745ec", "onyx:sa9d706f7a521", "onyx:sd804b75439bd"], "status": "REASONED"}, "onyx-auth-type-ee-main": {"text": "Source reading: backend/ee/onyx/main.py at v4.5.0/v4.9.2 nests base lifespan in its own, passed to base get_application, adds a Google OAuth router only under MULTI_TENANT; no backend/ee file at either has AUTH_TYPE (string search; others unread). Guide inference, v4.9.2-v4.9.4 only (v4.5.0 base factory uncited): EE runs the base startup, narrowing, not settling, runtime selection; nothing run.", "components": ["onyx-v45", "onyx-v49", "onyx-v494"], "sources": ["onyx-v45:s7f4227427c7d", "onyx-v49:sd4abc0fb7b2c", "onyx-v494:s19ba2d899d29", "onyx-v494:s93073617917f", "onyx-v494:s0bd0b2d82697", "onyx-v49:sb57e6f0a50cc"], "status": "REASONED"}, "onyx-base": {"text": "Development merges base plus override; base nginx publishes 80:80 and 3000:80, while web_server has no independent host publication.", "components": ["onyx-compose", "onyx"], "sources": ["onyx-compose:sb82664aa67ff", "onyx-compose:s1b87311a0e14", "onyx:scb3a0b1624de"], "status": "REASONED"}, "onyx-api": {"text": "Development override adds API 8080:8080; remove unless deliberately restricted for access.", "components": ["onyx-compose"], "sources": ["onyx-compose:sb82664aa67ff"], "status": "REASONED"}, "onyx-backing": {"text": "Development adds Postgres 5432, OpenSearch 9200, inference 9000, Redis 6379, MinIO 9004/9005 and interpreter 8000; application auth does not protect these.", "components": ["onyx-compose"], "sources": ["onyx-compose:sb82664aa67ff"], "status": "REASONED"}, "onyx-port-env": {"text": "${VAR:-default} changes or restores host port; an empty variable does not remove publication.", "components": ["onyx-compose"], "sources": ["onyx-compose:sb82664aa67ff", "onyx-compose:s1b87311a0e14"], "status": "REASONED"}, "onyx-secrets": {"text": "Connector credential_json persists in database; use least privilege, protect storage/backups and rotate after exposure.", "components": ["onyx-data"], "sources": ["onyx-data:s991ce173678a"], "status": "REASONED"}, "mfa": {"text": "Native MFA coverage remains unverified; guide relies on identity/fronting MFA and separate machine credentials.", "components": ["mem0", "onyx"], "sources": ["mem0:s37e3935419ed", "onyx:sc7649811ee67"], "status": "REASONED"}, "secrets": {"text": "Runtime provider/signing/service keys stay out of source/images; outbound provider credentials are not inbound authentication.", "components": ["localai", "tei-docs", "mem0", "onyx-data"], "sources": ["localai:sac7ce142c7f6", "tei-docs:s013618141267", "mem0:s37e3935419ed", "onyx-data:s991ce173678a"], "status": "REASONED"}, "verify-placeholder": {"text": "Placeholder scan should print matched tokens with grep_exit=0, clean with 1; read errors are inconclusive. Local guard outcomes and a direct grep match are recorded in the Verify overview; the deployed-state outcomes remain unobserved. The guard and scan runs covered an earlier form whose placeholder refusal exited with status 1 and whose scans exited 0; the current form was not re-run.", "components": ["curl", "grep"], "sources": ["curl:s2b2686afaf41", "grep:sa8d467168870"], "status": "DEMONSTRATED", "evidence": "printed the token and `grep_exit=0` for the exposed fixture and `grep_exit=1` for the fixed fixture", "verify": [1]}, "verify-guards": {"text": "Whole-block marker/count and empty/placeholder guards are prerequisites, not exposure proof; omitted guards and matching inherited positional values remain limits.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED"}, "verify-inventory": {"text": "Compare full IPv4/IPv6 socket, merged model and running Publishers inventory; DNAT may escape ss, exposed-only entries are not publications, and empty/error inventories do not pass.", "components": ["docker"], "sources": ["docker:s351180c6678f", "docker:se716ad33db1f", "docker:s3e97c2c250b6"], "status": "REASONED", "verify": [2]}, "verify-direct": {"text": "Any direct HTTP response or completed TCP connection proves reachability; refused actual backend plus inventory/working ingress supports isolation, timeout or http=000 alone does not.", "components": ["curl", "docker"], "sources": ["curl:s2b2686afaf41", "docker:s351180c6678f"], "status": "REASONED", "verify": [3]}, "verify-backing": {"text": "Probe every actual private application/backing TCP port; connection success is exposure, refusal needs configuration evidence, timeout/local/netcat-option errors are inconclusive.", "components": ["docker"], "sources": ["docker:s351180c6678f", "docker:s3e97c2c250b6"], "status": "REASONED", "verify": [4]}, "verify-ingress": {"text": "Harmless protected request should reject anonymous caller and admit authorized control; inspect redirect destination/body, and test routes outside native auth through ingress too.", "components": ["localai", "tei-docs", "tei", "mem0", "onyx", "curl"], "sources": ["localai:sac7ce142c7f6", "tei-docs:s013618141267", "tei:s2b649738d265", "mem0:s37e3935419ed", "onyx:sc7649811ee67", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [5]}, "verify-tei": {"text": "POST /embed with harmless inputs should reject missing key 401 and return embeddings with valid key; anonymous health/docs/metrics cannot test native auth.", "components": ["tei"], "sources": ["tei:s2b649738d265"], "status": "REASONED", "verify": [5, 6, 7]}, "verify-native": {"text": "From trusted private position vary only empty/wrong/valid credential for same request; first two reject, third returns real output, without publishing a test port.", "components": ["localai", "tei-docs", "mem0", "onyx", "langserve"], "sources": ["localai:sac7ce142c7f6", "tei-docs:s013618141267", "mem0:s37e3935419ed", "onyx:sc7649811ee67", "langserve:sa9fa1381b09a"], "status": "REASONED", "verify": [6]}, "verify-native-limits": {"text": "Intentionally anonymous private SearxNG/LangServe instead use authenticated ingress; exact selected Mem0/Onyx response codes and dashboard policy remain unverified.", "components": ["searx", "langserve", "mem0", "onyx"], "sources": ["searx:s99812c56f9f8", "langserve:sa9fa1381b09a", "mem0:s37e3935419ed", "onyx:sc7649811ee67"], "status": "REASONED", "verify": [6]}, "verify-positive": {"text": "Authorized ingress request must return expected application body, paired with negative checks; 2xx alone, login page, errors or unreachable service cannot establish success.", "components": ["curl", "localai", "tei-docs", "mem0", "onyx"], "sources": ["curl:s2b2686afaf41", "localai:sac7ce142c7f6", "tei-docs:s013618141267", "mem0:s37e3935419ed", "onyx:sc7649811ee67"], "status": "REASONED", "verify": [7]}, "verify-headers": {"text": "Credential headers go on curl stdin under control-character guard; separate ingress/native credentials require both, not argv.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [6, 7]}, "verify-browser": {"text": "An MFA-protected operation must be refused on a password-only attempt and succeed after the second factor; a direct forged identity header must be refused at the network boundary, a valid authenticated session must reach the app through the proxy, and a forged trusted header sent through it must not change the identity in the app log. Neither the MFA nor the header outcomes were demonstrated.", "components": ["mem0", "onyx"], "sources": ["mem0:s37e3935419ed", "onyx:sc7649811ee67"], "status": "REASONED"} } } ```` ====================================================================== ==> realtime-voice-infra.md ====================================================================== # Realtime voice and video infrastructure: LiveKit and coturn LiveKit and coturn are media servers for live calls: LiveKit routes media between participants, and coturn relays media when devices cannot connect directly. This guide helps your AI assistant configure both securely, including strong LiveKit signing secrets, no development mode, TLS for signaling, and authenticated coturn with restricted relay destinations. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/realtime-voice-infra.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against LiveKit v1.13.7, livekit-cli v2.18.2, coturn 4.18.0, coturn DTLS opt-in introduction 4.17.0, curl minimum write-out version 7.75.0, OpenSSL CLI 3.0, and OpenSSL dgst 3.5; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-10-12; documentation checked September 2026. The guide records 50 claims: 1 demonstrated by a check that ran and 49 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. AI voice agents run on self-hosted realtime backends that carry live media, and two of them sit at the network edge: LiveKit, a WebRTC SFU that routes participants' audio, video, and data, and coturn, a TURN/STUN server that relays media when a direct peer path cannot be found. The relay is the sharp edge. A TURN server proxies traffic on a client's behalf, so an unauthenticated or misconfigured one is an *open relay*: an attacker uses it to launder traffic, to reach services on the host's own loopback and private network, and to reflect and amplify UDP toward a victim. LiveKit's edge is different: it will not start without API keys, so the danger is not an anonymous join but a *known or weak* signing secret, which lets anyone mint a token and enter any room to eavesdrop or inject media. LiveKit terminates no TLS on its signaling, so [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) is the reverse-proxy pattern it needs for `wss://`; coturn terminates TLS and DTLS natively from its own certificate. [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) covers generating and holding the signing material. Values below are illustrative; replace them. ## LiveKit LiveKit's HTTP/WebSocket signaling and API listen on `7880`, and ICE media uses a separate TCP mux on `7881`; outside development mode the UDP media sockets are allocated from a range of `50000` to `60000` (development mode instead uses a single UDP mux port, `7882`). With no explicit `bind_addresses`, the signaling and RTC TCP listeners bind the wildcard address, and the UDP media sockets bind each eligible interface address; restricting the signaling bind does not restrict the RTC sockets, which is why a relay or media port can be reachable while signaling is on loopback. The embedded TURN service is off by default and, if enabled, needs its ports set explicitly. Authentication is by API key/secret pairs, and access tokens are HS256 JWTs that carry room grants (`roomJoin`, `room`, `canPublish`, `canSubscribe`, administrative grants such as `roomCreate`) and are signed with the secret. The critical default is that **a normal-mode server refuses to start with no keys configured** (`ValidateKeys()` returns an error), so an exposed instance is not an anonymous free-for-all. The real exposures are the ways a *usable* secret ends up known: - **Development mode** (`--dev`) injects a hard-coded pair, `devkey` / `secret`, when the key map is empty, and (when no bind is supplied) defaults signaling to loopback. The local quickstart selects it deliberately. Never run it on an exposed host: anyone can sign a valid admin token. - **The upstream `config-sample.yaml` ships uncommented `key1: secret1` and `key2: secret2` entries.** These are examples, not generated credentials; remove every one from the effective key set. - **A short secret does not block startup.** Outside development mode a secret under 32 characters only logs an error; it still runs. Treat that log as a finding, and generate a long random secret. Because a signing secret mints any grant, guard it like a root credential, and note that omitted grants are permissive: `canPublish` and `canSubscribe` default to true, so scope tokens deliberately. Generate a real pair and keep it in a key file rather than the main config: ```yaml # livekit.yaml: front this with a proxy for wss:// (LiveKit terminates no TLS on signaling) development: false port: 7880 bind_addresses: ["127.0.0.1"] key_file: /etc/livekit/keys.yaml rtc: tcp_port: 7881 port_range_start: 50000 port_range_end: 60000 ``` ```yaml # /etc/livekit/keys.yaml: generate the pair (e.g. `livekit-server generate-keys`); replace both REPLACE_WITH_GENERATED_API_KEY: "REPLACE_WITH_GENERATED_RANDOM_SECRET" ``` LiveKit refuses a key file that grants any permission to others (it checks the world/other bits), so a world-readable `0644` file stops the server from starting; a group-readable `0640` passes that check, so create the file `0600` owned by the service account to keep the group out as well. Signaling TLS terminates at a reverse proxy or load balancer: the core server calls `Serve`, not `ServeTLS`, and there is no top-level signaling TLS flag, so front it with the [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) pattern and connect over `wss://`. A few surfaces are easy to leave open beside the main port: Prometheus metrics, when enabled, serve unauthenticated unless you set `prometheus.username`/`password`; the debug and profiling routes (`/debug/pprof/`, `/debug/goroutine`, `/debug/rooms`) carry no grant check and, with `debug_handler.port` set, listen on their own port. Another surface easy to leave open beside the main port is the embedded TURN service, which derives its credentials from the same API secret, so a leaked secret also undermines TURN. For static `webhook.urls`, LiveKit sends a webhook JWT in `Authorization`, signed with the configured `webhook.api_key` secret; its `sha256` claim holds the standard-base64 SHA-256 hash of the JSON body. An extra webhook may select a different `SigningKey` and secret. Receivers must verify the JWT signature and compare that claim with a fresh hash of the received body, as protocol `webhook.Receive` does. Unverified until checked in your deployment: whether a Helm chart or package generator creates or overrides keys and binds, the effective bind of a distribution's sample config, and the JWT-library version your build pins. ## coturn coturn listens on `3478` for STUN/TURN over both UDP and TCP, and on `5349` for TLS; **DTLS on `5349` has been opt-in since 4.17.0** and is off unless you set `dtls`. Relay endpoints are allocated on demand from `49152` to `65535` (`min-port`/`max-port`). Three side surfaces are each off until enabled: the telnet CLI (`5766`) and web admin (`8080`) bind loopback when on, but **Prometheus (`9641`) binds a wildcard address when enabled and serves metrics with no authentication**; pin it with `prometheus-address=127.0.0.1` if you use it. The default listener is *not* loopback-only: coturn enumerates addresses on every interface that is up. The default that matters most is authentication. **With the stock example configuration (no users, and both `lt-cred-mech` and `use-auth-secret` commented out), TURN allocation is anonymous**, which is an open relay. That is the out-of-box example, not every install (the upstream Docker image ships `lt-cred-mech` on, and defining static users implies long-term auth), so confirm the effective config rather than assuming either way. Enable one credential mode and a stable realm: ```ini # turnserver.conf: long-term credentials lt-cred-mech realm=turn.example.com user=REPLACE_WITH_USER:REPLACE_WITH_RANDOM_PASSWORD listening-port=3478 tls-listening-port=5349 cert=/etc/coturn/fullchain.pem pkey=/etc/coturn/private-key.pem # Relay policy: a starting denylist, not a complete one. Deny the metadata address and every # private range explicitly; do not rely on coturn's built-in scope checks alone across versions. allow-loopback-peers=false no-multicast-peers denied-peer-ip=169.254.0.0-169.254.255.255 denied-peer-ip=10.0.0.0-10.255.255.255 denied-peer-ip=172.16.0.0-172.31.255.255 denied-peer-ip=192.168.0.0-192.168.255.255 denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff cli=false web-admin=false ``` REASONED: For static users, coturn 4.18.0's example configuration also accepts `user=REPLACE_WITH_USER:0xREPLACE_WITH_HEX_KEY`; `0x` identifies a key instead of a clear password, which the vendor calls "less secure". This keeps the clear password out of `turnserver.conf`. The cited derivation computes unsalted MD5 of `user:realm:password` after `turnadmin`'s input preparation, including `SASLprep`. It follows that the key is bound to the user and realm, and weak passwords remain guessable offline. Protect the key as a credential: the cited static-account loader stores it directly, and the long-term message-integrity routines generate and check integrity using the key. REASONED: In the coturn 4.18.0 files cited below, `turnadmin -k` takes the password through `-p`/`--password` in argv. Tracing `main`, every `adminmain` password-buffer reference, `adminuser` and key derivation shows the argument copied into `pwd`, then `SASLprep` applied to the copy without erasing argv. No other option, config file, database, environment, stdin or prompt supplies that buffer in these files: server config loading is bypassed, database options do not populate it, and `-f` reads a separate AES key. This conclusion is limited to the reviewed files; unprovided build files, helper definitions and linked libraries were not audited. Prompting before expanding a password into `-p` still puts it in argv, where local process inspection or command-line auditing can expose it. No production turnadmin key-generation command is provided here. For static accounts, the following alternative derives the key without sending the password to an external command as an argument. Use a fresh Bash shell with trusted startup files and real builtins; paste the entire block. Substitute the user and the configured realm inside the single quotes. This deliberately accepts only ASCII letters, digits, dot, underscore and hyphen, with 1 to 127 bytes in each field. Choose a strong, independently generated password within that alphabet. Other characters returned by read are refused, not normalized. Enter only the password at the prompt, without embedded newlines or NUL bytes; Bash read is a line input, not a binary input. These bytes are unchanged by coturn 4.18.0's byte-oriented `SASLprep` in `ns_turn_msg.c` lines 1920-1960; that routine is not a general UTF-8 normalization implementation. `mainrelay.c` lines 3105-3129 prepare all three inputs, and `ns_turn_msg.c` lines 346-358 concatenate their bytes as `user:realm:password`, without a newline or the terminating NUL in the digest. The limits here are below turnadmin's input buffers (`ns_turn_msg_defs.h` lines 53-57). MD5 here is coturn's static-key format, not a recommendation to use MD5 for password storage. `SHATYPE_DEFAULT` selects the MD5 branch, even though the enum aliases it to `SHATYPE_SHA1`; the SHA256, SHA384 and SHA512 branches are separate. The default key is 16 bytes, printed as 32 lowercase hex digits with a `0x` prefix (`userdb.c` lines 997-1014). OpenSSL 3.5.5's dgst manual documents `-md5`, `-r` for coreutils output and stdin when no file is supplied. The block checks pipeline success and validates the extracted hex before printing the config entry; a build or policy that refuses MD5 produces no key. It does not change OpenSSL policy to enable MD5. DEMONSTRATED: following block; local Bash 5.3.9 and OpenSSL 3.5.5, with the prompt supplied on stdin, produced `user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee` for coturn 4.18.0's public example in `examples/etc/turnserver.conf` lines 383-390. An instrumented wrapper recorded Bash, wrapper and OpenSSL command lines from procfs: none contained the example password. A synchronized exposed control did contain it. Unsubstituted placeholders, empty or non-ASCII passwords, readonly password/IFS variables and a failing digest command each exited 2 without a key. This is a local derivation and argv observation, not a TURN allocation test or a demonstration of terminal echo suppression. ```bash ( trap - DEBUG RETURN ERR set +x +a +e set -o pipefail shopt -u nocasematch set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_USER' 'REPLACE_WITH_REALM' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not deriving'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'supply exactly user and realm; not deriving'; exit 2; } case "$1:$2" in *REPLACE_WITH_*) echo 'substitute user and realm; not deriving'; exit 2 ;; esac case "$1" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo 'invalid ASCII user; not deriving'; exit 2 ;; esac case "$2" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo 'invalid ASCII realm; not deriving'; exit 2 ;; esac [ "${#1}" -le 127 ] && [ "${#2}" -le 127 ] || { echo 'user or realm too long; not deriving'; exit 2; } { unset -n turn_password && unset -v turn_password; } 2>/dev/null || { echo 'cannot clear password variable; not deriving'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'cannot clear IFS; not deriving'; exit 2; } IFS= read -r -s -p 'TURN password: ' turn_password || { echo 'password read failed; not deriving'; exit 2; } builtin printf '\n' >&2 case "$turn_password" in ""|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) echo 'invalid ASCII password; not deriving'; exit 2 ;; esac [ "${#turn_password}" -le 127 ] || { echo 'password too long; not deriving'; exit 2; } set -- "$1" "$(builtin printf '%s:%s:%s' "$1" "$2" "$turn_password" | openssl dgst -md5 -r && builtin printf '\nOK')" unset -v turn_password case "$2" in *$'\nOK') set -- "$1" "${2%$'\nOK'}" ;; *) echo 'digest failed; no key produced'; exit 2 ;; esac set -- "$1" "${2%% *}" case "$2" in ""|*[!0123456789abcdef]*) echo 'unexpected digest output; no key produced'; exit 2 ;; esac [ "${#2}" -eq 32 ] || { echo 'unexpected digest length; no key produced'; exit 2; } builtin printf 'user=%s:0x%s\n' "$1" "$2" ) ``` The prompt variable is cleared of inherited attributes, never exported and used only by Bash builtins. The builtin `printf` sends the exact bytes on stdin to `openssl dgst -md5 -r`; neither the password nor the key is an external process argument. The block clears inherited traps and disables tracing, allexport and inherited errexit before reading, refuses readonly IFS, and removes the password variable after hashing. These measures close the argv channel only. They do not protect against a compromised shell, same-account or root memory inspection, terminal capture, or tracing that is re-enabled. Never type the password into the pasted block or a command line, where shell history can retain it. Paste it alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. The printed key is itself a credential: protect its output and place the generated entry in the protected coturn configuration under the same realm. The offline-guessing and key-reuse weaknesses described above still apply. For application-generated time-limited credentials, replace the `lt-cred-mech`/`user` lines with `use-auth-secret` and a `static-auth-secret` (plus the same `realm`); keep that shared secret in the server config, never distribute it to clients. A realm is an operational requirement for both modes, not a hard startup gate: omitting it warns rather than refuses, so set it explicitly. Two properties of that config are worth stating plainly. First, **enabling TLS is not the same as requiring it**: coturn autodetects protocols across ports and still accepts plaintext STUN/TURN on `3478` (and even on the TLS-numbered port). Opening `5349` proves TLS is *available*, not that encryption is *mandatory*. To require encrypted client transport, drop the plaintext client listeners with `no-tcp` and `no-udp`, keeping TLS; because that removes the plain-UDP path, enable `dtls` so UDP clients can still connect over DTLS, or accept that clients are TLS-over-TCP only. This affects client transport, not the UDP relay to peers. Second, the relay destination policy does not fully contain a relay by default: - **Loopback** (`127.0.0.0/8`, `::1`) is blocked unless you set `allow-loopback-peers`; leave it off. - **RFC 1918 private ranges are *not* blocked by default and must be denied explicitly**, as in the block above (`10/8`, `172.16/12`, `192.168/16`); without those lines an authenticated user, or on the stock config an anonymous one, relays into your internal network. Stock coturn 4.18.0 *does* reject IPv4 link-local (`169.254.0.0/16`, the `169.254.169.254` metadata address) and IPv6 link-local/ULA through built-in scope checks, but older or modified builds have not, so the explicit link-local and IPv6 denies above are defense in depth rather than the only barrier to the metadata endpoint. - **Multicast** is allowed unless `no-multicast-peers` is set. - `allowed-peer-ip` is an exception list, not a default-deny allowlist: an address it does not name can still be permitted. STUN Binding stays anonymous even when TURN allocation requires credentials, and closing anonymous allocation does not close reflection: an unauthenticated request still draws a `401` challenge response that can itself reflect and amplify toward a spoofed source. `unauthorized-ratelimit` (off by default) bounds that, `secure-stun` requests authenticated Binding, and RFC 5780 (a reflection amplifier) is off by default, so leave it off. The two admin surfaces authenticate separately from TURN users and from each other: the CLI uses `cli-password`, while the web admin uses its own provisioned admin accounts, so setting `cli-password` does not secure the web admin. Disable both (`cli=false`, `web-admin=false`) or keep them on loopback, keep `web-admin-listen-on-workers` off, and do not enable `server-relay`, which drops permission checks on relayed traffic. `--no-cli` is retired in 4.18.0, so use `cli=false`. Unverified until checked in your deployment: the default service state and effective configuration of any specific distro or container image, the build's TLS/feature availability, and real reflection/amplification factors. ## Shared exposures - **An open TURN relay reaches your internal network and amplifies UDP.** An anonymous (or abusive authenticated) allocator originates traffic through the relay to any permitted, reachable peer, which is an SSRF-like path into your private network and the cloud-metadata endpoint, plus a UDP amplification vector. Authentication closes anonymous allocation; explicit `denied-peer-ip` policy and network controls are still needed against authenticated abuse. - **A weak or development LiveKit secret forges room admission.** A known signing secret lets an attacker choose identities and grants, so publishing, subscribing, or administering rooms all become available. Application end-to-end encryption is a separate content boundary, not a substitute for protecting the secret. - **Plaintext signaling or relay.** Public HTTP/WS exposes signaling and bearer tokens in transit. Plaintext TURN does not send the long-term password itself (the protocol proves knowledge of it through a challenge and message integrity), but it exposes usernames and protocol metadata and permits offline guessing against a weak password, so terminate TLS and require it. Media transport encryption is a separate layer again. - **STUN reflection and amplification.** Binding and `401`-challenge responses are unauthenticated regardless of TURN auth, so a spoofed source can reflect UDP toward a victim; rate limiting and disabling response-enlarging features reduce it, but authenticating allocation does not remove it. ## Verify REASONED: following block; listener inventory and interpretation, per the guide's cited LiveKit ports and firewall reference, LiveKit sample config and coturn option reference. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. The sudo stub printed ss -tlnp and ss -aunp; no host sockets were inspected. Those runs covered an earlier form of the block; its current form, which exits 2 when the `command -v ss` check finds no `ss`, 3 when either inventory command fails and otherwise 0, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Per the guide's cited sudo sudoers manual, secure_path entry, where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. A local stand-up opens a listener, which the authoring host forbids without an isolated network namespace; the earlier review reported that it could not create one (`unshare: unshare failed: Operation not permitted`). Exposed signaling/admin/metrics should be reachable where the fixed deployment restricts them; media and allocated relays may remain public. Service outcomes below remain reasoned, not demonstrated: they are derived from the guide's cited vendor sources rather than observed against exposed and fixed services. Local shell, stub and certificate checks do not demonstrate those service outcomes. A redirect, a `404`, a timeout, or a TLS error is inconclusive, never proof of the fixed state; every negative check needs a working positive control. ```bash ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 # the block's status so far: 3 (a command failed) outranks 0; this block classifies no finding, and a missing ss exits 2 above sudo ss -tlnp || { echo "sudo ss -tlnp exit $?: the TCP listener inventory failed; inconclusive"; set -- 3; } sudo ss -aunp || { echo "sudo ss -aunp exit $?: the UDP socket inventory failed; inconclusive"; set -- 3; } # Read the bind address per port, do not just confirm the port. LiveKit signaling 7880 # should be on loopback (behind the proxy); RTC 7881 and coturn 3478/5349 are expected # PUBLIC (a relay and media are useless if unreachable), so their public bind is normal, # not the finding. The findings are: a wildcard-bound 7880, or any exposed CLI 5766, # web-admin 8080, or metrics 9641/6789. coturn 3478 is a persistent listener; the LiveKit # 50000-60000 media range and coturn 49152-65535 relay range appear only during active # calls/allocations. A private bind is not external isolation: confirm reachability from # off-host separately, since NAT or Docker forwarding can publish a loopback-bound port. echo "block exit $1; every listener and socket above is unclassified, so read each bind address against the comments" exit "$1" ) ``` ```bash # LiveKit reachability/auth diagnostic against the direct listener. This ONLY shows the endpoint requires # a token; it does NOT prove the dev/known secret is gone (see the rotation test below). Substitute the # base URL inside the single quotes on the set -- line and paste the whole block so the guard runs. ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_LIVEKIT_BASE_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the LiveKit base URL on the set -- line above; not probing"; exit 2 ;; http://*|https://*) : ;; *) echo "expected an http:// or https:// URL; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1/rtc/validate"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # curl's exit status, then the response and its http= line printf '%s' "$3" set -- "$1" "$2" "${3%$'\n'}" set -- "$1" "$2" "${3##*$'\n'}" # the http= line alone case "$2:$3" in 0:'http=401 '*) echo "block exit 0; the missing-token request was refused with 401, as expected in both key states, and only the rotation test below shows whether the dev or known secret is gone"; exit 0 ;; 0:'http=2'??' '*) echo "FINDING: ${3%% *} without a token, so the endpoint does not require one"; exit 1 ;; 0:*) echo "${3%% *} without a token is neither the expected 401 nor a 2xx finding; inconclusive"; exit 3 ;; *) echo "curl exit $2: the request failed; inconclusive"; exit 3 ;; esac ) ``` REASONED: preceding block; direct token diagnostic (the unauthenticated `/rtc/validate` request), rotation and auxiliary surfaces, per the guide's cited LiveKit tokens and grants, LiveKit v1.13.7 missing-grants HTTP 401 branch, LiveKit sample config and livekit-cli v2.18.2 `lk token create`. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. A local stand-up opens a listener, which the authoring host forbids without an isolated network namespace; the earlier review reported that it could not create one (`unshare: unshare failed: Operation not permitted`). Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. Placeholder, empty, embedded-placeholder, wrong-marker and wrong-count cases exited 2 before any stub call. A substituted URL reached only the curl stub at /rtc/validate; the prompted lk stub printed token-create arguments. Those runs covered earlier forms of the blocks; their current forms are covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of them is recorded here. The current direct-token block exits 0 on the expected 401, 1 on a 2xx and 3 on any other answer or a failed request. The unauthenticated 401 is expected in both key states; the old token must change from accepted to rejected while the new token works. The **decisive** LiveKit check is a key-rotation test that discriminates the exposed state, because an unauthenticated `/rtc/validate` returns `401` whether or not the dev key is still in use. Mint a correctly scoped token signed with the old (`devkey`/`secret`, or a retired real) secret with `livekit-cli` (the guarded block below) or a JWT tool that takes the signing secret from stdin, an owner-only file or its environment, never from its command line, and confirm it is *accepted* on an isolated development fixture. The rotation step is then to confirm the *same token is rejected* against the deployment after its key is rotated, while a token signed with the new key is accepted. An expired or wrong-room token is an invalid rotation test, and the isolated fixture is what keeps the dev secret off the public instance. Separately probe the metrics and debug routes (and any `debug_handler.port`) and the embedded TURN sockets from an untrusted network; a healthy `/` proves none of them protected. REASONED: Allocate and peer-policy discrimination, per the guide's cited coturn option reference, coturn peer-address classification and policy and coturn 4.18.0 `turnutils_uclient` options. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. Placeholder, empty, embedded-placeholder, wrong-marker and wrong-count cases exited 2 before any stub call. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Stub output is not a protocol response; anonymous allocation must change from a relay address to a challenge without allocation, while valid credentials allocate and denied peers return 403 without traffic. For coturn, the discriminator is a TURN **Allocate** exchange, not an HTTP request. Send a well-formed Allocate requesting UDP relay with no credentials: an allocation containing a relayed address means anonymous allocation is open, while an authentication challenge with no allocation means it is closed (the required positive control is a valid-credential allocation, because the initial `401` also occurs during a successful authenticated exchange). Closing anonymous allocation is not the whole fix: authenticated, request `CreatePermission`/`ChannelBind` for an owned RFC 1918 or `169.254.169.254` test peer and confirm a `403` with no traffic reaching it, across the denied ranges and both address families (a plain allocation failure or timeout is not evidence of peer policy). The coturn project ships `turnutils_uclient` for the allocation test; run it against an isolated, plaintext-enabled authentication fixture (the hardened deployment may set `no-udp`, and you must never re-open public plaintext to get a positive control). It needs a peer address via `-e`: point it at an owned permitted peer here, or at a denied internal address to test peer policy (expect refusal). REASONED: credential input and exposure limits, per the guide's cited coturn 4.18.0 `turnutils_uclient` option parser and coturn 4.18.0 `turnutils_uclient` password-buffer trace. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. Placeholder, empty, embedded-placeholder, wrong-marker and wrong-count cases exited 2 before any stub call. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. This run observed argument construction, not the vendor client's internals or operating-system visibility; a throwaway credential bounds the exposure but remains in argv. `turnutils_uclient` takes the TURN password only on its command line, as `-w`, and `-W`, which takes a REST-API shared secret instead, is also an argument. At coturn 4.18.0 the client's option parser copies both from `optarg`, and a trace of the password buffer across the six files its build target lists in `src/apps/uclient/CMakeLists.txt` (`mainuclient.c`, `uclient.c`, `startuclient.c`, `uclient.h`, `startuclient.h` and `session.h`) found no stdin, environment, file, argument-file or prompt input for it. The password buffer, across the whole coturn 4.18.0 source tree, is written only from `-w` and from the password `-W` derives. No other TURN client that takes the password outside argv was evaluated for this guide (backlog row 1.140); prefer one if you have it. The block below prompts for the password, which keeps it out of shell history, the terminal's scrollback and the pasted text, but that is not an argv fix. While the credentialed run lasts, the password is in that process's argv, readable through `ps` and `/proc//cmdline` by every local account, your own and root included, and command-line auditing on the host can record it beyond that; an observer can keep a copy. Bound the exposure instead: - REASONED: throwaway-account lifecycle, per the guide's cited coturn option reference. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. Account creation and removal were not exercised; the expected improvement is limiting the exposed credential's access and invalidating it after the test. Create a throwaway user on the isolated fixture for this test only, with no more access than the test needs, and remove it afterwards. Never use a production credential. - REASONED: shared-secret exclusion, per the guide's cited coturn 4.18.0 `turnutils_uclient` option parser. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The expected improvement is keeping the credential-minting shared secret out of argv; a throwaway password remains visible there. Never use `-W` in this test, whatever secret it would carry: it takes a long-lived TURN REST API shared secret, which never qualifies, and a deployment's `static-auth-secret` would put the secret that mints every credential into argv. - REASONED: bounded client lifetime, per the guide's cited coturn 4.18.0 `turnutils_uclient` options. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The expected improvement is omitting the documented indefinite hang; actual client lifetime was not measured. Do not add `-h`, which makes the client hang on indefinitely after the last sent packet, and keeps the password in its argv for as long as it does. - REASONED: trusted-host prerequisite, per the guide's cited coturn 4.18.0 `turnutils_uclient` option parser. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. No host-user inventory was read; an untrusted local observer may copy argv, while using a trusted host removes that observer assumption without hiding argv. Run the block from a host with no untrusted local users. REASONED: interpreting the allocation positive control, per the guide's cited coturn option reference and coturn 4.18.0 `turnutils_uclient` options. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. Placeholder, empty, embedded-placeholder, wrong-marker and wrong-count cases exited 2 before any stub call. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The stub supplied no TURN responses; an exposed anonymous relay address must become a challenge without allocation, with a credentialed relay address as the positive control. Read the verbose output, not the exit status: the anonymous run must show a `401` challenge and no relayed address, and the credentialed run, the positive control, must show a relayed address. Substitute the test user, the peer and the TURN host inside the single quotes, and paste the whole block. It assumes a clean shell (CONTRIBUTING rule 7). REASONED: following block; anonymous and credentialed `turnutils_uclient -v -e` allocation probes, TURN command construction and peer policy follow Sources entries coturn option reference, coturn peer-address classification and policy, coturn 4.18.0 `turnutils_uclient` options and coturn 4.18.0 `turnutils_uclient` option parser. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. A local stand-up opens a listener, which the authoring host forbids without an isolated network namespace; the earlier review reported that it could not create one (`unshare: unshare failed: Operation not permitted`). Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. Placeholder, empty, embedded-placeholder, wrong-marker and wrong-count cases exited 2 before any stub call. The prompted stub run printed anonymous and credentialed turnutils_uclient arguments, including -w fixture-secret and neither -W nor -h. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The current block refuses a missing turnutils_uclient, exits 3 if the password read fails, refuses an empty password with 2, and otherwise exits 0; the exit status of each turnutils_uclient run is printed but not used, because its verbose output is the result, where the credentialed run must show a relayed address against a permitted peer and the refusal against a denied one. Exposed anonymous allocation should yield a relayed address; fixed anonymous allocation should yield a challenge without allocation, while valid credentials allocate and denied peers fail. Paste the throwaway password alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_TEST_USER' 'REPLACE_WITH_OWNED_PEER_IP' 'REPLACE_WITH_TURN_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "|$1|$2|$3|" in *REPLACE_WITH_*|*'||'*|*'|-'*|*[[:cntrl:]]*) echo "substitute the test user, peer and TURN host on the set -- line above (no empty value, none starting with -); not probing"; exit 2 ;; esac command -v turnutils_uclient > /dev/null || { echo "turnutils_uclient is not on PATH: install coturn's client utilities; not probing"; exit 2; } { unset -n pw && unset -v pw; } 2>/dev/null || { echo 'cannot clear pw in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } echo '--- no credentials: expect a 401 challenge and NO relayed address' turnutils_uclient -v -e "$2" "$3" echo "turnutils_uclient exit $?: not a result; read the verbose output above for a 401 challenge and no relayed address" IFS= read -r -s -p 'Throwaway test password (input hidden; it WILL be in argv while the next command runs): ' pw < /dev/tty || { echo 'password input failed; not probing'; exit 3; } printf '\n' case "$pw" in ""|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty password, placeholder or control character; not probing'; exit 2 ;; esac echo '--- throwaway credentials (the positive control): expect a relayed address' # guard-conventions: allow argv-only TURN password at coturn 4.18.0; prompted throwaway credential on a trusted host under the rule 7 restrictions above turnutils_uclient -v -u "$1" -w "$pw" -e "$2" "$3" echo "turnutils_uclient exit $?: not a result; read the verbose output above for a relayed address against a permitted peer, or the refusal against a denied one" echo "block exit 0; neither run's exit status is classified, so read the verbose output above: the anonymous run must show a 401 challenge and no relayed address, the credentialed run against a permitted peer must show a relayed address, and a run against a denied peer must show the refusal"; exit 0 ) ``` REASONED: following block; LiveKit credential resolution and storage, token minting, secret transport and rotation, per the guide's cited livekit-cli v2.18.2 credential inputs, livekit-cli v2.18.2 saved project credentials, livekit-cli v2.18.2 `lk token create` and LiveKit tokens and grants. No provisioned LiveKit or coturn exposed/fixed fixture is available for this offline review; service outcomes remain unobserved. A local stand-up opens a listener, which the authoring host forbids without an isolated network namespace; the earlier review reported that it could not create one (`unshare: unshare failed: Operation not permitted`). Local bash -n and ShellCheck 0.11.0 both exited 0 without diagnostics. Placeholder, empty, embedded-placeholder, wrong-marker and wrong-count cases exited 2 before any stub call. The prompted stub run printed lk token create --join --room test --identity probe --valid-for 5m without the secret in argv. Those runs covered an earlier form of the block; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The current block refuses a missing lk, exits 3 if the secret read or lk fails, refuses an empty secret with 2, and otherwise exits 0, leaving the printed token for you to use and protect. The same old-key token should change from accepted to rejected after rotation while the new-key token works; those service outcomes were not observed, and stub output establishes neither signature validity nor access. For the LiveKit rotation test, mint each token with `livekit-cli` (`lk`). `lk` takes the API key and secret from `--api-key` and `--api-secret`, which put them in argv, or from `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`, and has no stdin input for them (checked at livekit-cli v2.18.2). A saved project (`lk project add`) is a file input, but it keeps a plaintext copy of the secret in `~/.livekit/cli-config.yaml` until removed, the opposite of what a rotation is for. `lk` uses the environment pair unless `--project` or `--subdomain` is given, ahead of a `livekit.toml` in the working directory and any saved default project, so run the block with neither flag. Prompting should avoid argv secrets for lk, but environment and saved-project exposure remain distinct. The block prompts for the secret, so it stays out of shell history, and hands it and the key to the one `lk` command as a prefix assignment, never exported. That moves the secret out of argv, not out of reach: while `lk` runs, the same account and root can read it from `/proc//environ`. Run the block once with the old key and once with the new. For the public development pair the key is `devkey` and the secret typed at the prompt is `secret`. Each printed token is a bearer credential for its grants until it expires, so keep `--valid-for` short and treat the terminal output as a secret. Keep the old-key token: the same token must be accepted on the isolated development fixture and rejected by the deployment after rotation, and the new-key token must be accepted. Substitute the key inside the single quotes, and paste the whole block. It assumes a clean shell (CONTRIBUTING rule 7). Paste the API secret alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_API_KEY' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not minting"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not minting"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the old or the new API key on the set -- line above; not minting"; exit 2 ;; esac command -v lk > /dev/null || { echo "lk (livekit-cli) is not on PATH; not minting"; exit 2; } { unset -n sec LIVEKIT_API_KEY LIVEKIT_API_SECRET && unset -v sec LIVEKIT_API_KEY LIVEKIT_API_SECRET; } 2>/dev/null || { echo 'cannot clear sec, LIVEKIT_API_KEY or LIVEKIT_API_SECRET in this shell; not minting'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not minting'; exit 2; } IFS= read -r -s -p 'API secret for that key (input hidden): ' sec < /dev/tty || { echo 'secret input failed; not minting'; exit 3; } printf '\n' case "$sec" in ""|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty secret, placeholder or control character; not minting'; exit 2 ;; esac if LIVEKIT_API_KEY="$1" LIVEKIT_API_SECRET="$sec" lk token create --join --room test --identity probe --valid-for 5m; then echo "block exit 0; the token above is not classified: it is a bearer credential, so keep it secret and use it in the rotation test"; exit 0 else echo "lk exit $?: token creation failed; inconclusive"; exit 3 fi ) ``` REASONED: following block; fatal hostname and CA verification with `openssl s_client` and its `-verify_hostname` and `-verify_return_error` options, what a deployment run of that command does not prove, and separate plaintext rejection, per Sources entries OpenSSL 3.0 s_client hostname, CA and fatal verification options and coturn example configuration. No provisioned coturn exposed/fixed fixture is available. A loopback `openssl s_server` fixture or a local coturn stand-up opens a listener, which the authoring host forbids without an isolated network namespace; the earlier review reported that it could not create one (`unshare: unshare failed: Operation not permitted`). No listener or TLS handshake was attempted in this review, and no STUN/TURN exchange was observed. The OpenSSL fence passed `bash -n` and ShellCheck 0.11.0 without diagnostics in an earlier form that named turn.example.com. An OpenSSL 3.5.5 certificate-only fixture passed `openssl verify -verify_hostname turn.example.com` with exit 0 for a trusted matching certificate; checking the same certificate against `wrong.example.com` exited 2 with `hostname mismatch`. An `s_client` stub run of that earlier form exited 0 and recorded `-verify_hostname turn.example.com` and `-verify_return_error`; the stub did not read the CA file or connect to a service. The fence now takes the TURN server's host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains and a leading hyphen or plus sign, refuses a missing openssl and exits 3 when the command fails. Its form before this change, which lowercased the host with `tr` before refusing a reserved name, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes, and no stub run of it is recorded here. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and it refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash and busybox sh. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by openssl-stub runs on 2026-11-02 with no network. In those runs, bash 5.3.9 refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no openssl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached the openssl stub. Under dash and busybox sh, each host name those runs tried that passed the host-syntax check, reserved or not, was refused with 2 and the bash message before any openssl call, and the shipped placeholder was refused with 2 and the host-syntax message. None of the 2026-11-02 runs used zsh, ksh or mksh. A trusted matching certificate should pass and a mismatched certificate should fail fatally. These certificate-only and stub checks do not establish those handshake outcomes. Exposed plaintext acceptance must become rejection on both ports while valid TLS remains usable. A successful deployment run of the following command proves the certificate and handshake only, not TURN authentication, peer policy, or that plaintext is refused; also send plain STUN/TURN to `3478` and to `5349` and confirm it is rejected once `no-tcp`/`no-udp` are set. A local socket binding proves neither public reachability nor TLS. Never add `-k`/`--insecure` to any of these. Verify TLS separately, and require verification to be fatal so a diagnostic tool does not continue past a bad certificate: ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_TURN_HOST' # the TURN server's public host name, the name its certificate covers, with no scheme, user name, port, path, query, fragment, percent sign, leading hyphen, leading plus sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|-*|+*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the TURN server's host name (no scheme, user name, port, path, query, fragment, percent sign, leading hyphen, leading plus sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again command -v openssl > /dev/null || { echo "openssl is not on PATH; not probing"; exit 2; } if openssl s_client -connect "$1:5349" -servername "$1" \ -verify_hostname "$1" -verify_return_error \ -CAfile /etc/ssl/certs/ca-certificates.crt ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - livekit: LiveKit (v1.13.7) - lk: livekit-cli (v2.18.2) - coturn: coturn (4.18.0) - dtls: coturn DTLS opt-in introduction (4.17.0) - livekit-protocol: LiveKit protocol (a4f4b5c0c23f167d0c07de1e1d7652125882e73c) - livekit-docs: LiveKit documentation (unknown) - curl: curl minimum write-out version (7.75.0) - openssl-cli: OpenSSL CLI (3.0) - openssl-dgst: OpenSSL dgst (3.5) Claims (full source mapping in the JSON block below): - livekit-ports (REASONED; livekit, livekit-docs): Signaling/API use 7880, RTC TCP 7881 and normal UDP media 50000-60000; development uses UDP mux 7882. - livekit-bind (REASONED; livekit, livekit-docs): Unset signaling/RTC TCP binds are wildcard; UDP uses eligible interfaces independently of signaling restrictions. - livekit-turn (REASONED; livekit, livekit-docs): Embedded TURN defaults off and needs explicit ports when enabled. - livekit-keys (REASONED; livekit, livekit-docs): Normal mode refuses an empty key map; API secrets sign HS256 room and administrative JWT grants. - livekit-dev (REASONED; livekit): Development mode supplies devkey/secret when keys are empty and defaults signaling to loopback only when no bind is supplied. - livekit-samples (REASONED; livekit): Remove the sample key1/secret1 and key2/secret2 pairs from every effective key set. - livekit-length (REASONED; livekit): Outside development, secrets shorter than 32 characters only log an error; generate long random secrets. - livekit-grants (REASONED; livekit-docs): canPublish and canSubscribe default true; signing secrets mint any grant, so deliberately scope issued tokens. - livekit-keyfile (REASONED; livekit): Use key_file with service-owned mode 0600; world permissions reject startup, but group-readable 0640 passes the server check. - livekit-tls (REASONED; livekit-docs, livekit): Front signaling with TLS/WSS; the guide describes no native signaling TLS and calls Serve. - livekit-metrics (REASONED; livekit): Enabled metrics are unauthenticated unless prometheus.username/password are set. - livekit-debug (REASONED; livekit): Debug/pprof, goroutine and room routes lack grant checks and can use debug_handler.port. - livekit-turn-secret (REASONED; livekit): Embedded TURN derives credentials from the API secret. - livekit-webhook (REASONED; livekit, livekit-protocol): For static webhook.urls, LiveKit signs webhook Authorization JWTs with webhook.api_key's secret; an extra webhook may use a different SigningKey and secret; the sha256 claim holds the base64 SHA-256 body hash; receivers verify both as webhook.Receive does. - coturn-listeners (REASONED; coturn): STUN/TURN use UDP/TCP 3478 and TLS 5349; enabled interfaces are enumerated, not loopback-only. - coturn-dtls (REASONED; dtls): DTLS on 5349 is opt-in since 4.17.0 and requires dtls. - coturn-relays (REASONED; coturn): On-demand relay endpoints use min-port/max-port 49152-65535. - coturn-cli (REASONED; coturn): CLI 5766 defaults off and uses loopback plus cli-password when enabled; use cli=false, since --no-cli retired in 4.18.0. - coturn-admin (REASONED; coturn): Web admin 8080 defaults off/loopback and uses separate admin accounts; keep web-admin-listen-on-workers off. - coturn-metrics (REASONED; coturn): Prometheus 9641 defaults off but binds wildcard without authentication when enabled; set prometheus-address=127.0.0.1. - coturn-anonymous (REASONED; coturn): Stock example with no users and no credential mechanism permits anonymous allocation; static users imply long-term auth. - coturn-longterm (REASONED; coturn): Enable lt-cred-mech, a stable realm and generated user credentials; set certificate/key paths for TLS. - coturn-static-key (REASONED; coturn): Static user=NAME:0xKEY keeps the clear password out of the config. Default derivation is unsalted MD5 of prepared user:realm:password; realm binding and offline guessing follow. Protect the stored key as a credential. - coturn-key-argv (REASONED; coturn): The reviewed 4.18.0 password-buffer trace for turnadmin -k finds only -p/--password argv input, copied and SASLprep-processed without argument erasure. No alternative input appears in these files; unprovided build files, helpers and libraries are outside the review. - coturn-key-derivation (DEMONSTRATED; coturn, openssl-dgst): Local Bash 5.3.9 and OpenSSL 3.5.5 static-key derivation reproduced coturn 4.18.0's public example with the password supplied on stdin; this does not demonstrate TURN allocation or terminal echo suppression. - coturn-secret (REASONED; coturn): For timed application credentials use use-auth-secret/static-auth-secret instead of static user lines; never distribute the shared secret. - coturn-realm (REASONED; coturn): Missing realm warns rather than preventing startup; set it explicitly for either credential mode. - coturn-encryption (REASONED; coturn, dtls): TLS availability does not require encryption: plaintext can use 3478 or 5349. no-tcp/no-udp remove plaintext; enable dtls for encrypted UDP clients. - coturn-loopback (REASONED; coturn): Loopback peers are blocked unless allow-loopback-peers is set; leave it off. - coturn-private (REASONED; coturn): RFC 1918 peers are not denied by default; explicitly deny all private ranges and enforce network controls. - coturn-linklocal (REASONED; coturn): 4.18.0 scope checks reject IPv4 link-local and IPv6 link-local/ULA; explicit denies remain defense in depth across older/modified builds. - coturn-multicast (REASONED; coturn): Multicast is permitted unless no-multicast-peers is set. - coturn-exceptions (REASONED; coturn): allowed-peer-ip adds exceptions rather than establishing a default-deny allowlist. - coturn-binding (REASONED; coturn): STUN Binding stays anonymous with TURN auth; secure-stun requests authenticated Binding. - coturn-reflection (REASONED; coturn): Anonymous 401 challenges still reflect traffic; unauthorized-ratelimit defaults off and bounds it when enabled. - coturn-rfc5780 (REASONED; coturn): RFC 5780 is disabled by default; leave this response-enlarging feature off. - coturn-permissions (REASONED; coturn): Do not enable server-relay, which removes relay permission checks. - turn-plaintext (REASONED; coturn): Plain TURN hides the long-term password itself but exposes usernames/metadata and enables offline guesses against weak passwords. - verify-listeners (REASONED; livekit-docs, livekit, coturn): Inventory TCP/UDP and actual allocations: media/TURN may be public, signaling/admin/metrics must be restricted; separately test off-host reachability. - verify-livekit (REASONED; livekit-docs, curl, livekit): Unauthenticated rtc/validate 401 only tests token requirement, not removal of a known signing key. - verify-rotation (REASONED; livekit-docs, lk): Accept an old-key token on an isolated fixture, reject that same unexpired correctly scoped token after rotation, and accept a new-key token. - verify-extra (REASONED; livekit, livekit-docs): Separately probe metrics, debug routes/port and embedded TURN from untrusted networks; a healthy root path proves no protection. - verify-allocation (REASONED; coturn): Anonymous Allocate must challenge with no relay address; valid throwaway credentials on a private plaintext fixture must allocate. Read output, not exit status. - verify-peers (REASONED; coturn): Authenticated CreatePermission/ChannelBind to owned denied peers must return 403 with no traffic; cover denied ranges and both families. - uclient-argv (REASONED; coturn): turnutils_uclient has only argv password inputs -w/-W at 4.18.0; use a prompted throwaway user, never a production password or -W shared secret. - uclient-lifetime (REASONED; coturn): Run on a trusted host, remove the test user afterwards and omit -h, which keeps the password-bearing process alive indefinitely. - lk-inputs (REASONED; lk): lk reads argv or environment credentials, not stdin; explicit project/subdomain outrank environment, which outranks local/default projects. - lk-storage (REASONED; lk): Saved projects retain plaintext api_secret in ~/.livekit/cli-config.yaml, written 0600. - lk-mint (REASONED; lk): Prompt and prefix environment assignments keep secrets out of argv/history but expose them to same-account/root environment readers; printed short-lived tokens are secrets. - verify-tls (REASONED; coturn, openssl-cli): Fatal hostname/CA verification tests only TURN certificate/handshake; separately test plaintext rejection on 3478 and 5349. ````json { "schema": 1, "checked": "2026-10-12", "documentation_checked": "2026-09", "body_sha256": "947dd0ffdcd7f172c3be537a86676afcf5313ce714ed3fe63097825b1106293c", "components": { "livekit": { "name": "LiveKit", "basis": "v1.13.7", "sources": { "s15a5a2b0b78b": "https://raw.githubusercontent.com/livekit/livekit/v1.13.7/pkg/config/config.go", "s75f8e517e920": "https://raw.githubusercontent.com/livekit/livekit/v1.13.7/cmd/server/main.go", "sc52b46730181": "https://raw.githubusercontent.com/livekit/livekit/v1.13.7/config-sample.yaml", "s022153e2a982": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/server.go#L319", "s81618482f092": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/server.go#L165-L170", "sdbaff31c14ce": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/server.go#L181-L193", "s48a6c1203218": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/server.go#L261-L266", "s646f86dd5bd7": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/turn.go#L315-L325", "sdd5650ac94ff": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/rtcservice.go#L147-L149", "s8bb3ce9cd9a0": "https://github.com/livekit/livekit/blob/v1.13.7/go.mod#L24", "sd7364fdde8bd": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/config/config.go#L93", "se841c3896c09": "https://github.com/livekit/livekit/blob/v1.13.7/pkg/service/wire_gen.go#L224-L233" } }, "lk": { "name": "livekit-cli", "basis": "v2.18.2", "sources": { "sa42f9b5ed5d4": "https://raw.githubusercontent.com/livekit/livekit-cli/v2.18.2/cmd/lk/utils.go", "s6a98a324f01b": "https://raw.githubusercontent.com/livekit/livekit-cli/v2.18.2/cmd/lk/token.go", "sf8b161b60423": "https://raw.githubusercontent.com/livekit/livekit-cli/v2.18.2/pkg/config/config.go" } }, "coturn": { "name": "coturn", "basis": "4.18.0", "sources": { "s58d0de8f7391": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/README.turnadmin", "sa6b17658f8ea": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/src/apps/relay/mainrelay.c", "s1f4d6627e0b7": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/src/apps/relay/userdb.c", "sb4bd08fc99f5": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/src/client/ns_turn_msg.c", "sf189fba0e419": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/src/client/ns_turn_msg_defs.h", "sc4178412962b": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/examples/etc/turnserver.conf", "s5ec265938c96": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/README.turnserver", "s0728ef023209": "https://github.com/coturn/coturn/releases/tag/4.18.0", "s7eb31b944cf1": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/src/server/ns_turn_server.c", "se7856434c3d0": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/src/apps/uclient/mainuclient.c", "s1942d0158590": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/uclient.h#L132", "s0e40b57274d6": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/mainuclient.c#L553-L554", "sdd3c00ef7290": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/uclient.c#L3109", "s414c246eed7f": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/uclient.c#L3132", "s6ba3cc5d04ea": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/mainuclient.c#L381", "sed853e8fb3e3": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/startuclient.c", "se9819d0c6412": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/startuclient.h", "s51a288a4b86e": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/session.h", "s790c96d8eed7": "https://github.com/coturn/coturn/blob/4.18.0/src/apps/uclient/CMakeLists.txt", "s0c57f0580b5c": "https://raw.githubusercontent.com/coturn/coturn/4.18.0/README.turnutils", "s3a76feb6f5e4": "https://github.com/coturn/coturn/blob/4.18.0/docker/coturn/turnserver.conf#L184" } }, "dtls": { "name": "coturn DTLS opt-in introduction", "basis": "4.17.0", "sources": { "s24abe2486071": "https://github.com/coturn/coturn/releases/tag/4.17.0" } }, "livekit-protocol": { "name": "LiveKit protocol", "basis": "a4f4b5c0c23f167d0c07de1e1d7652125882e73c", "sources": { "sd50b1ad9e41a": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/webhook/notifier.go#L29-L101", "sa72bd7b593dd": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/webhook/notifier.go#L129-L153", "sc6a2c5977854": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/webhook/resource_url_notifier.go#L215-L230", "se37eb66231a5": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/webhook/resource_url_notifier.go#L358-L385", "s56e3ca89a351": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/webhook/consts.go#L24-L33", "s7bf1e6a499b7": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/webhook/verifier.go#L31-L66", "s8ecd81813f58": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/auth/verifier.go#L27-L84", "s7fcefd30e452": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/auth/accesstoken.go#L188-L199", "s34f549cb9c10": "https://github.com/livekit/protocol/blob/a4f4b5c0c23f167d0c07de1e1d7652125882e73c/auth/grants.go#L175-L177" } }, "livekit-docs": { "name": "LiveKit documentation", "basis": "unknown", "sources": { "s66f19d919eb5": "https://docs.livekit.io/transport/self-hosting/ports-firewall/", "sb6a31fba4f62": "https://docs.livekit.io/frontends/reference/tokens-grants/", "s38432c6e3577": "https://docs.livekit.io/transport/self-hosting/deployment/" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "openssl-cli": { "name": "OpenSSL CLI", "basis": "3.0", "sources": { "s00aaf106164f": "https://docs.openssl.org/3.0/man1/openssl-s_client/" } }, "openssl-dgst": { "name": "OpenSSL dgst", "basis": "3.5", "sources": { "sd33a4ae3d052": "https://docs.openssl.org/3.5/man1/openssl-dgst/" } } }, "claims": { "livekit-ports": {"text": "Signaling/API use 7880, RTC TCP 7881 and normal UDP media 50000-60000; development uses UDP mux 7882.", "components": ["livekit", "livekit-docs"], "sources": ["livekit:s15a5a2b0b78b", "livekit-docs:s66f19d919eb5"], "status": "REASONED"}, "livekit-bind": {"text": "Unset signaling/RTC TCP binds are wildcard; UDP uses eligible interfaces independently of signaling restrictions.", "components": ["livekit", "livekit-docs"], "sources": ["livekit:s15a5a2b0b78b", "livekit-docs:s66f19d919eb5"], "status": "REASONED"}, "livekit-turn": {"text": "Embedded TURN defaults off and needs explicit ports when enabled.", "components": ["livekit", "livekit-docs"], "sources": ["livekit:sc52b46730181", "livekit-docs:s66f19d919eb5"], "status": "REASONED"}, "livekit-keys": {"text": "Normal mode refuses an empty key map; API secrets sign HS256 room and administrative JWT grants.", "components": ["livekit", "livekit-docs"], "sources": ["livekit:s15a5a2b0b78b", "livekit-docs:sb6a31fba4f62"], "status": "REASONED"}, "livekit-dev": {"text": "Development mode supplies devkey/secret when keys are empty and defaults signaling to loopback only when no bind is supplied.", "components": ["livekit"], "sources": ["livekit:s75f8e517e920"], "status": "REASONED"}, "livekit-samples": {"text": "Remove the sample key1/secret1 and key2/secret2 pairs from every effective key set.", "components": ["livekit"], "sources": ["livekit:sc52b46730181"], "status": "REASONED"}, "livekit-length": {"text": "Outside development, secrets shorter than 32 characters only log an error; generate long random secrets.", "components": ["livekit"], "sources": ["livekit:s15a5a2b0b78b"], "status": "REASONED"}, "livekit-grants": {"text": "canPublish and canSubscribe default true; signing secrets mint any grant, so deliberately scope issued tokens.", "components": ["livekit-docs"], "sources": ["livekit-docs:sb6a31fba4f62"], "status": "REASONED"}, "livekit-keyfile": {"text": "Use key_file with service-owned mode 0600; world permissions reject startup, but group-readable 0640 passes the server check.", "components": ["livekit"], "sources": ["livekit:s15a5a2b0b78b"], "status": "REASONED"}, "livekit-tls": {"text": "Front signaling with TLS/WSS; the guide describes no native signaling TLS and calls Serve.", "components": ["livekit-docs", "livekit"], "sources": ["livekit-docs:s38432c6e3577", "livekit:s022153e2a982"], "status": "REASONED"}, "livekit-metrics": {"text": "Enabled metrics are unauthenticated unless prometheus.username/password are set.", "components": ["livekit"], "sources": ["livekit:s15a5a2b0b78b", "livekit:sc52b46730181", "livekit:s81618482f092"], "status": "REASONED"}, "livekit-debug": {"text": "Debug/pprof, goroutine and room routes lack grant checks and can use debug_handler.port.", "components": ["livekit"], "sources": ["livekit:s15a5a2b0b78b", "livekit:sc52b46730181", "livekit:sdbaff31c14ce", "livekit:s48a6c1203218"], "status": "REASONED"}, "livekit-turn-secret": {"text": "Embedded TURN derives credentials from the API secret.", "components": ["livekit"], "sources": ["livekit:sc52b46730181", "livekit:s646f86dd5bd7"], "status": "REASONED"}, "livekit-webhook": {"text": "For static webhook.urls, LiveKit signs webhook Authorization JWTs with webhook.api_key's secret; an extra webhook may use a different SigningKey and secret; the sha256 claim holds the base64 SHA-256 body hash; receivers verify both as webhook.Receive does.", "components": ["livekit", "livekit-protocol"], "sources": ["livekit:sd7364fdde8bd", "livekit:s8bb3ce9cd9a0", "livekit:se841c3896c09", "livekit-protocol:sd50b1ad9e41a", "livekit-protocol:sa72bd7b593dd", "livekit-protocol:sc6a2c5977854", "livekit-protocol:se37eb66231a5", "livekit-protocol:s56e3ca89a351", "livekit-protocol:s7bf1e6a499b7", "livekit-protocol:s8ecd81813f58", "livekit-protocol:s7fcefd30e452", "livekit-protocol:s34f549cb9c10"], "status": "REASONED"}, "coturn-listeners": {"text": "STUN/TURN use UDP/TCP 3478 and TLS 5349; enabled interfaces are enumerated, not loopback-only.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-dtls": {"text": "DTLS on 5349 is opt-in since 4.17.0 and requires dtls.", "components": ["dtls"], "sources": ["dtls:s24abe2486071"], "status": "REASONED"}, "coturn-relays": {"text": "On-demand relay endpoints use min-port/max-port 49152-65535.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-cli": {"text": "CLI 5766 defaults off and uses loopback plus cli-password when enabled; use cli=false, since --no-cli retired in 4.18.0.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s5ec265938c96", "coturn:s0728ef023209"], "status": "REASONED"}, "coturn-admin": {"text": "Web admin 8080 defaults off/loopback and uses separate admin accounts; keep web-admin-listen-on-workers off.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-metrics": {"text": "Prometheus 9641 defaults off but binds wildcard without authentication when enabled; set prometheus-address=127.0.0.1.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-anonymous": {"text": "Stock example with no users and no credential mechanism permits anonymous allocation; static users imply long-term auth.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s5ec265938c96", "coturn:s3a76feb6f5e4"], "status": "REASONED"}, "coturn-longterm": {"text": "Enable lt-cred-mech, a stable realm and generated user credentials; set certificate/key paths for TLS.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-static-key": {"text": "Static user=NAME:0xKEY keeps the clear password out of the config. Default derivation is unsalted MD5 of prepared user:realm:password; realm binding and offline guessing follow. Protect the stored key as a credential.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:sa6b17658f8ea", "coturn:s1f4d6627e0b7", "coturn:sb4bd08fc99f5", "coturn:sf189fba0e419"], "status": "REASONED"}, "coturn-key-argv": {"text": "The reviewed 4.18.0 password-buffer trace for turnadmin -k finds only -p/--password argv input, copied and SASLprep-processed without argument erasure. No alternative input appears in these files; unprovided build files, helpers and libraries are outside the review.", "components": ["coturn"], "sources": ["coturn:s58d0de8f7391", "coturn:sa6b17658f8ea", "coturn:s1f4d6627e0b7", "coturn:sb4bd08fc99f5"], "status": "REASONED"}, "coturn-key-derivation": {"text": "Local Bash 5.3.9 and OpenSSL 3.5.5 static-key derivation reproduced coturn 4.18.0's public example with the password supplied on stdin; this does not demonstrate TURN allocation or terminal echo suppression.", "components": ["coturn", "openssl-dgst"], "sources": ["coturn:sc4178412962b", "coturn:sb4bd08fc99f5", "openssl-dgst:sd33a4ae3d052"], "status": "DEMONSTRATED", "evidence": "local Bash 5.3.9 and OpenSSL 3.5.5, with the prompt supplied on stdin, produced `user=ninefingers:0xbc807ee29df3c9ffa736523fb2c4e8ee` for coturn 4.18.0's public example in `examples/etc/turnserver.conf` lines 383-390."}, "coturn-secret": {"text": "For timed application credentials use use-auth-secret/static-auth-secret instead of static user lines; never distribute the shared secret.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-realm": {"text": "Missing realm warns rather than preventing startup; set it explicitly for either credential mode.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-encryption": {"text": "TLS availability does not require encryption: plaintext can use 3478 or 5349. no-tcp/no-udp remove plaintext; enable dtls for encrypted UDP clients.", "components": ["coturn", "dtls"], "sources": ["coturn:sc4178412962b", "coturn:s5ec265938c96", "dtls:s24abe2486071"], "status": "REASONED"}, "coturn-loopback": {"text": "Loopback peers are blocked unless allow-loopback-peers is set; leave it off.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s7eb31b944cf1"], "status": "REASONED"}, "coturn-private": {"text": "RFC 1918 peers are not denied by default; explicitly deny all private ranges and enforce network controls.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s7eb31b944cf1"], "status": "REASONED"}, "coturn-linklocal": {"text": "4.18.0 scope checks reject IPv4 link-local and IPv6 link-local/ULA; explicit denies remain defense in depth across older/modified builds.", "components": ["coturn"], "sources": ["coturn:s7eb31b944cf1"], "status": "REASONED"}, "coturn-multicast": {"text": "Multicast is permitted unless no-multicast-peers is set.", "components": ["coturn"], "sources": ["coturn:sc4178412962b", "coturn:s7eb31b944cf1"], "status": "REASONED"}, "coturn-exceptions": {"text": "allowed-peer-ip adds exceptions rather than establishing a default-deny allowlist.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96", "coturn:s7eb31b944cf1"], "status": "REASONED"}, "coturn-binding": {"text": "STUN Binding stays anonymous with TURN auth; secure-stun requests authenticated Binding.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-reflection": {"text": "Anonymous 401 challenges still reflect traffic; unauthorized-ratelimit defaults off and bounds it when enabled.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-rfc5780": {"text": "RFC 5780 is disabled by default; leave this response-enlarging feature off.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "coturn-permissions": {"text": "Do not enable server-relay, which removes relay permission checks.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "turn-plaintext": {"text": "Plain TURN hides the long-term password itself but exposes usernames/metadata and enables offline guesses against weak passwords.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory TCP/UDP and actual allocations: media/TURN may be public, signaling/admin/metrics must be restricted; separately test off-host reachability.", "components": ["livekit-docs", "livekit", "coturn"], "sources": ["livekit-docs:s66f19d919eb5", "livekit:sc52b46730181", "coturn:s5ec265938c96"], "status": "REASONED", "verify": [1]}, "verify-livekit": {"text": "Unauthenticated rtc/validate 401 only tests token requirement, not removal of a known signing key.", "components": ["livekit-docs", "curl", "livekit"], "sources": ["livekit-docs:sb6a31fba4f62", "curl:s2b2686afaf41", "livekit:sdd5650ac94ff"], "status": "REASONED", "verify": [2]}, "verify-rotation": {"text": "Accept an old-key token on an isolated fixture, reject that same unexpired correctly scoped token after rotation, and accept a new-key token.", "components": ["livekit-docs", "lk"], "sources": ["livekit-docs:sb6a31fba4f62", "lk:s6a98a324f01b"], "status": "REASONED", "verify": [4]}, "verify-extra": {"text": "Separately probe metrics, debug routes/port and embedded TURN from untrusted networks; a healthy root path proves no protection.", "components": ["livekit", "livekit-docs"], "sources": ["livekit:sc52b46730181", "livekit-docs:s66f19d919eb5"], "status": "REASONED"}, "verify-allocation": {"text": "Anonymous Allocate must challenge with no relay address; valid throwaway credentials on a private plaintext fixture must allocate. Read output, not exit status.", "components": ["coturn"], "sources": ["coturn:s5ec265938c96", "coturn:s0c57f0580b5c"], "status": "REASONED", "verify": [3]}, "verify-peers": {"text": "Authenticated CreatePermission/ChannelBind to owned denied peers must return 403 with no traffic; cover denied ranges and both families.", "components": ["coturn"], "sources": ["coturn:s7eb31b944cf1", "coturn:s0c57f0580b5c"], "status": "REASONED", "verify": [3]}, "uclient-argv": {"text": "turnutils_uclient has only argv password inputs -w/-W at 4.18.0; use a prompted throwaway user, never a production password or -W shared secret.", "components": ["coturn"], "sources": ["coturn:se7856434c3d0", "coturn:s1942d0158590", "coturn:s0e40b57274d6", "coturn:sdd3c00ef7290", "coturn:s414c246eed7f", "coturn:s6ba3cc5d04ea", "coturn:sed853e8fb3e3", "coturn:se9819d0c6412", "coturn:s51a288a4b86e", "coturn:s790c96d8eed7"], "status": "REASONED", "verify": [3]}, "uclient-lifetime": {"text": "Run on a trusted host, remove the test user afterwards and omit -h, which keeps the password-bearing process alive indefinitely.", "components": ["coturn"], "sources": ["coturn:s0c57f0580b5c"], "status": "REASONED", "verify": [3]}, "lk-inputs": {"text": "lk reads argv or environment credentials, not stdin; explicit project/subdomain outrank environment, which outranks local/default projects.", "components": ["lk"], "sources": ["lk:sa42f9b5ed5d4"], "status": "REASONED"}, "lk-storage": {"text": "Saved projects retain plaintext api_secret in ~/.livekit/cli-config.yaml, written 0600.", "components": ["lk"], "sources": ["lk:sf8b161b60423"], "status": "REASONED"}, "lk-mint": {"text": "Prompt and prefix environment assignments keep secrets out of argv/history but expose them to same-account/root environment readers; printed short-lived tokens are secrets.", "components": ["lk"], "sources": ["lk:sa42f9b5ed5d4", "lk:s6a98a324f01b"], "status": "REASONED", "verify": [4]}, "verify-tls": {"text": "Fatal hostname/CA verification tests only TURN certificate/handshake; separately test plaintext rejection on 3478 and 5349.", "components": ["coturn", "openssl-cli"], "sources": ["coturn:sc4178412962b", "openssl-cli:s00aaf106164f"], "status": "REASONED", "verify": [5]} } } ```` ====================================================================== ==> image-gen-uis.md ====================================================================== # Image-generation UIs: ComfyUI, Stable Diffusion WebUI, InvokeAI, Fooocus ComfyUI, Stable Diffusion WebUI, InvokeAI and Fooocus are self-hosted browser apps for creating images with AI models. This guide helps your AI assistant configure each securely, including ports reachable only on host loopback, a tunnel or authenticated TLS proxy in front, Gradio share links off, and hardened containers where used. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/image-gen-uis.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against ComfyUI v0.37.0, AUTOMATIC1111 v1.10.1, Fooocus v2.5.5, Gradio 3.41.2, Docker Engine minimum loopback boundary 28.0, Docker Compose source v2.32.4, and curl minimum write-out version 7.75.0; 1 further source is pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 28 claims: 2 demonstrated by a check that ran and 26 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. None of these tools ships real authentication by default, and each accepts arbitrary Python through custom nodes or extensions. **Binding one to a public interface is host compromise, not just data exposure.** Keep every instance on loopback and reach it only through a tunnel or an authenticated TLS proxy: [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md), or a reverse proxy per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)/[caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). If you run any of these in a container, do not stop at the network boundary: a compromised custom node or extension can still reach whatever the container can reach, so apply [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md) (a non-root user, a read-only filesystem where the tool allows it, no unnecessary mounts) as a second layer, not a substitute for keeping the port off the network. ## ComfyUI `--listen` with no argument binds to `0.0.0.0,::` (every IPv4 and IPv6 interface); given an address it binds only there. The default with the flag absent is `127.0.0.1`, and the default port is `8188` (both as of v0.37.0). There is no built-in login: the server accepts any workflow from anyone who can reach it. Custom nodes are the bigger risk. They run as plain Python with the same privileges as the server process; ComfyUI's own security update warns that `eval`/`exec` calls in a node are "direct attack vectors" for remote code execution. ComfyUI-Manager (the default node installer) had its own unauthenticated-RCE advisory, CVE-2025-67303 (GHSA-95pq-hr8p-f5g7): an unprotected alternate channel left the manager's data and configuration directories insufficiently protected by ComfyUI's web API access control, letting an attacker upload arbitrary files for full system compromise with no credentials at all. The fix spans both projects and needs both minimums together: ComfyUI v0.3.76 or later (adds the protected-directory API the fix depends on) and ComfyUI-Manager v3.38 or later (contains the fix itself). Keep both at or above those versions, and only install nodes you trust regardless. ```bash python main.py --listen 127.0.0.1 --port 8188 ``` Front it with a TLS proxy that adds login before anything reaches port 8188. If you must run untrusted workflows, `--disable-all-custom-nodes` starts the server with none of them loaded, and `--whitelist-custom-nodes FOLDER...` re-allows specific folders despite that flag; neither is a substitute for keeping the port off the network. ## AUTOMATIC1111 Stable Diffusion WebUI `--listen` launches gradio bound to `0.0.0.0` (default `False`, i.e. loopback unless `GRADIO_SERVER_NAME` is set, below); without `--port`, Gradio 3.41.2 searches for the first free port starting at `GRADIO_SERVER_PORT` if set, otherwise `7860`, trying up to `GRADIO_NUM_PORTS` ports (default `100`). The explicit `--port 7860` below pins the UI to that port; startup fails if it is unavailable. `--gradio-auth-path FILE` (a file of `user:password` entries, comma-delimited within a line or one per line, at v1.10.1) or `--gradio-auth user:pass` (comma-delimited for multiple users) requires a login before the UI loads; `--api-auth user:pass` does the same for the API. Both `user:pass` forms put the password in the launcher's argv, so use the file for the UI login and an authenticating reverse proxy for the API, below. The UI forms split each entry once, at its first `:`, so a UI password may contain `:`; `--api-auth` splits each entry at every `:`, so an API password containing `:` makes startup fail. No form allows a `,` in a password, since it separates entries. `--share` registers a public `*.gradio.live` relay URL, documented as intended for Colab, not a deployment mechanism, and it bypasses your network boundary entirely. `--enable-insecure-extension-access` reopens the extensions tab regardless of other flags and should stay off on anything reachable beyond loopback. At v1.10.1, the shipped WebUI has no stdin, direct environment, file, argument-file or prompt input for the API credential. This is CONTRIBUTING rule 7's third-exception finding, read from the code, not a live demonstration. A whole-tree search for `api_auth`, `api-auth` and `self.credentials`, followed through every assignment and use of the parsed value and credential dictionary, found only the string option in `cmd_args.py`, the `sys.argv` parser in `shared_cmd_options.py`, its alias in `shared.py`, the API credential loader and authentication dependency, and the sysinfo redactor. The generic `get_cmd_flags()` reader returns the namespace; it does not set credentials. The parser has no `fromfile_prefix_chars`, so `@file` cannot supply arguments; `--gradio-auth-path` feeds only the UI login. `config.json` and the settings API populate `shared.opts`, and `ui-config.json` restores component properties, not `cmd_opts.api_auth`. `COMMANDLINE_ARGS` still becomes `sys.argv`, as below. The pinned source trace is in Sources. The API also returns these values: `GET /sdapi/v1/cmd-flags` answers with every command-line option, because its response model is built from the whole argument parser. With `--api-auth`, any account that authenticates to the API reads every configured API user's password there; with `--api` enabled and no `--api-auth`, the API routes need no credentials, so anyone who reaches the API reads a `--gradio-auth` password the same way. `--gradio-auth-path` exposes only the path. Read from the code, not run. An API credential supplied on the command line stays in argv for the process's lifetime, exposed to local argv readers and potentially retained by command-line auditing; the launcher also prints it at startup. Do not enable `--api-auth` on a host where other accounts or processes could read argv. The alternative is an authenticating TLS reverse proxy protecting every `/sdapi/v1/*` route in front of an explicit loopback bind (`--server-name 127.0.0.1`), following [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). The Gradio credential file does not protect the API: without `--api-auth`, the API registers its routes without its authentication dependency, so the proxy must enforce authentication before forwarding them. Do not put a credential on the command line or in `COMMANDLINE_ARGS`. `--gradio-auth user:pass` sits in argv, where `ps` and `/proc//cmdline` show it to other local accounts for the UI's whole lifetime, and quoting does not change that. `COMMANDLINE_ARGS` in the environment is the other way in: at import the WebUI appends its contents to `sys.argv`, and the value stays readable through `/proc//environ` by the same account and by root; `webui.sh` sets it from `webui-user.sh`. At startup, v1.10.1's launcher prints `Launching Web UI with arguments:` followed by `shlex.join(sys.argv[1:])`, and `sys.argv` already includes `COMMANDLINE_ARGS` by then, so every `--gradio-auth` and `--api-auth` password, from the command line or from `COMMANDLINE_ARGS`, is echoed in clear text to stdout, and from there into the terminal, `docker logs`, the journal or any log that captures it. `--gradio-auth-path` puts only the path in that line: the WebUI reads the file itself, and at the pinned tag its only reader is the credential loader, which prints nothing. That echo is the reason to use the file, and the reason never to put a credential in `COMMANDLINE_ARGS`. The pinned sources document no stdin input for the login. Create the file once, as the account that runs the WebUI; the block assumes a clean shell: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -eC umask 077 if [ -e "$HOME/.config/stable-diffusion-webui/gradio-auth" ] || [ -L "$HOME/.config/stable-diffusion-webui/gradio-auth" ]; then echo 'a gradio-auth file already exists in ~/.config/stable-diffusion-webui; nothing written'; exit 2 fi set -- "$(openssl rand -hex 32)" [ "${#1}" -eq 64 ] || { echo 'password generation failed; nothing written'; exit 2; } case "$1" in *[!0123456789abcdef]*) echo 'password generation failed; nothing written'; exit 2 ;; esac mkdir -p -- "$HOME/.config/stable-diffusion-webui" chmod 700 -- "$HOME/.config/stable-diffusion-webui" command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on ~/.config/stable-diffusion-webui cannot be ruled out; install the acl package; nothing written'; exit 2; } case "$(getfacl -d -c -p -- "$HOME/.config/stable-diffusion-webui"; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'the directory ~/.config/stable-diffusion-webui has a default ACL, which a new file inherits in place of umask 077; nothing written'; exit 2 ;; *) echo 'getfacl could not read the ACL of ~/.config/stable-diffusion-webui; nothing written'; exit 2 ;; esac printf 'admin:%s\n' "$1" > "$HOME/.config/stable-diffusion-webui/gradio-auth" case "$(ls -ln -- "$HOME/.config/stable-diffusion-webui/gradio-auth")" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'gradio-auth is not mode 0600, so other accounts may read it; delete ~/.config/stable-diffusion-webui/gradio-auth and do not use the password in it'; exit 2 ;; esac ) ``` The block refuses to run when `~/.config/stable-diffusion-webui/gradio-auth` already exists in any form, a dangling symlink included, before it generates anything. `set -C` adds overwrite protection for an existing regular file only, not for every kind of target, and the existence check runs before the write rather than atomically with it, so both hold only in directories that no other account can write to or replace: `$HOME`, `~/.config` and the owner-only directory the block creates. `umask 077` creates the directory mode `0700` and the file mode `0600`, unless the directory, or the parent it is created in, carries a default ACL: new files inherit that ACL in place of the umask, and `chmod 700` on the directory does not remove it. So once it has created the directory, the block runs `getfacl -d -c -p` on it, which prints the directory's default ACL and nothing else, and writes nothing when that prints anything, when `getfacl` fails, or when `getfacl` is not installed, leaving the directory behind; remove a default ACL you do not need with `setfacl -k ~/.config/stable-diffusion-webui` and run the block again. A missing `getfacl` fails closed because the block then cannot tell, before the write, whether the file would be private; `getfacl` comes from the `acl` package, and where it is not installed the block refuses to run. After the write the block reads the file's permission field from `ls -ln` and fails unless it is exactly `-rw-------` followed by a space or a `.`; any other field fails the check, including the `+`-marked field a named ACL entry produced in the recorded runs. If the check fails, delete the file and do not use the password in it. Without these checks, the block wrote `gradio-auth` mode `0644` when `~/.config` carried a default ACL (run in a scratch home directory with bash 5.3.9, acl 2.3.2 and `openssl` stubbed). The block writes nothing unless the generated password is 64 hex characters, because at v1.10.1 the reader splits each entry once on `:`, so a line of `admin:` would be the user `admin` with an empty password; hex holds no comma or colon for the file format to split on. The password passes only through the subshell's positional parameters and the builtin `printf`, never a command line, and the block clears inherited traps first because a DEBUG, RETURN or ERR trap from your shell could otherwise read it. Read the password back from the file privately when you need it: it is plaintext at rest, readable by that account, by root and by any backup that copies it, so keep it and its backups out of source control ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). A further user is another `user:password` line. Start the WebUI from the file: ```bash ( { unset -n COMMANDLINE_ARGS && unset -v COMMANDLINE_ARGS; } 2>/dev/null || { echo 'cannot clear COMMANDLINE_ARGS in this shell; not starting'; exit 2; } { unset -n GRADIO_SERVER_NAME && unset -v GRADIO_SERVER_NAME; } 2>/dev/null || { echo 'cannot clear GRADIO_SERVER_NAME in this shell; not starting'; exit 2; } f="$HOME/.config/stable-diffusion-webui/gradio-auth" { [ -f "$f" ] && [ ! -L "$f" ]; } || { echo 'need ~/.config/stable-diffusion-webui/gradio-auth to be a regular file (it is missing, a symlink or another kind of file); not starting'; exit 2; } if grep -Eavqx -- '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-]+:[0123456789abcdef]{64}' "$f"; then echo 'a line in ~/.config/stable-diffusion-webui/gradio-auth is not a user name and a 64-hex password; not starting'; exit 2 else rc=$?; [ "$rc" -eq 1 ] || { echo 'could not check ~/.config/stable-diffusion-webui/gradio-auth; not starting'; exit 2; } fi if grep -Eaqx -- '[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-]+:[0123456789abcdef]{64}' "$f"; then :; else rc=$?; [ "$rc" -eq 1 ] || { echo 'could not check ~/.config/stable-diffusion-webui/gradio-auth; not starting'; exit 2; } echo 'no user:password line in ~/.config/stable-diffusion-webui/gradio-auth; not starting'; exit 2 fi d=$(awk -F: 'seen[$1]++ { dup = 1 } END { if (dup) print "duplicate"; else if (NR > 0) print "ok" }' "$f") || d= case "$d" in ok) ;; duplicate) echo 'a user name is on more than one line in ~/.config/stable-diffusion-webui/gradio-auth; not starting'; exit 2 ;; *) echo 'could not check ~/.config/stable-diffusion-webui/gradio-auth; not starting'; exit 2 ;; esac python launch.py --server-name 127.0.0.1 --port 7860 --gradio-auth-path "$HOME/.config/stable-diffusion-webui/gradio-auth" ) ``` Of the login, only the path is on the command line, and only the path appears in the startup line. The block clears `COMMANDLINE_ARGS` first and refuses to start when it cannot (a readonly name in your shell), so an inherited value can add neither `--listen`, `--share` nor a `--gradio-auth` or `--api-auth` password; put any other flag you need on the command line itself. It passes `--server-name 127.0.0.1` explicitly because the loopback default is not the WebUI's own: at v1.10.1, without `--listen` or `--server-name` the WebUI hands Gradio no server name, and Gradio 3.41.2 then binds to `GRADIO_SERVER_NAME` from the environment, falling back to `127.0.0.1` only when it is unset, so an inherited `GRADIO_SERVER_NAME=0.0.0.0` would bind every interface. The block also clears `GRADIO_SERVER_NAME` with the same fail-closed guard. If you start through `webui.sh` instead, keep every credential out of `COMMANDLINE_ARGS` in `webui-user.sh`. The block checks first that the file is a regular file and not a symlink, so a FIFO in its place can neither block the check nor be read by it. It then reads the file as text (`grep -a`: without `-a`, a NUL could make a malformed line look valid to grep). The block refuses a file with no login line, with any line that is not a user name and a 64-hex password, the shape the block above writes, or with the same user name on two lines, because Gradio 3.41.2 keeps the logins in a dictionary keyed by user name, so only the last password given for that user would work. The patterns spell out their ASCII character sets rather than ranges such as `a-f`, whose meaning can depend on the locale, and the duplicate check compares names byte for byte. That check passes only when `awk` exits 0 and prints `ok`, which its program does only after reading at least one line and finding no user name twice, so an exit status alone never counts as a pass. When `grep` or `awk` cannot read the file, the block says that it could not check and refuses, and it gives the same result in a shell that has `set -e` on. If you set a password of your own, relax that pattern, but never let a line with an empty password through. The password stays in the file and in the WebUI's memory, readable by that account and by root, and moving it out of argv does not erase a value already in shell history or a log. Read from the code, not run: at v1.10.1 the WebUI serves `GET /internal/sysinfo` and `GET /internal/sysinfo-download`, which return its system-information report, and that report carries `COMMANDLINE_ARGS` from the environment unredacted, alongside the process's argv. The argv listing hides an element only when it equals the `--gradio-auth` or `--api-auth` value exactly, so neither single-token form `--gradio-auth=user:pass` nor `--api-auth=user:pass` is hidden, and a password in `COMMANDLINE_ARGS` appears verbatim in the environment section. The two routes are registered with no dependency, and at the Gradio release v1.10.1 pins (3.41.2) the login is checked per route rather than by middleware, so by that reading they answer without a login even when the Gradio login is on. Nobody has observed this on a running instance, so it stays REASONED, resting on the v1.10.1 route registrations and sysinfo code and on Gradio 3.41.2's per-route login check. Do three things regardless: use `--gradio-auth-path`, which puts only a path in the report; never put a credential in `COMMANDLINE_ARGS`; and have the reverse proxy refuse every path that begins `/internal/sysinfo`, which covers `/internal/sysinfo-download` too, since the report still carries the rest of the argv and environment. Even with `--gradio-auth-path` set, put TLS in front; the login alone only gates plaintext HTTP. `--server-name` sets the address the WebUI binds to; the block's `--server-name 127.0.0.1` is that bind address, so change it only when you mean to serve another interface. ## InvokeAI InvokeAI's `invokeai.yaml` uses a flat schema (current as of InvokeAI 6.14.1, `schema_version: 4.0.2`): `host` (default `127.0.0.1`) and `port` (default `9090`) are top-level keys, not nested under a `Web Server` or `InvokeAI` section. Setting `host: 0.0.0.0` serves the local network with no login at all in the default single-user mode. `INVOKEAI_HOST` and `INVOKEAI_PORT` override the same settings from the environment. An experimental multi-user mode exists: add `multiuser: true` to `invokeai.yaml` to require per-user login (username and password, stateless JWT sessions), and `strict_password_checking: true` to enforce a minimum password (8+ characters, upper, lower, and a digit) rather than just warning on a weak one. The JWT secret persists in the database. The [administrator guide](https://invoke.ai/features/multi-user-mode/admin-guide/#secret-key-management) instructs administrators to change that secret and then restart to log every user out; it does not establish logout on an ordinary restart. Outside multi-user mode, treat InvokeAI as having no login and keep it on loopback regardless. ```yaml # invokeai.yaml, flat schema (schema_version 4.0.2, InvokeAI 6.14.1 and later) host: 127.0.0.1 port: 9090 multiuser: true strict_password_checking: true ``` ## Fooocus `--listen` with no argument binds to `0.0.0.0` (every IPv4 interface); given an address it binds only there, and with the flag absent Fooocus binds `127.0.0.1` (all as of v2.5.5). `--port` sets the port, which is `7865` by default, unless `GRADIO_SERVER_PORT` is set or 7865 is taken (below). `--share` registers a public `*.gradio.live` endpoint, same relay mechanism and same caution as above. Fooocus's own README states access is unauthenticated by default. Optional basic auth comes from an `auth.json` file with `user`/`pass` entries (no dedicated command-line auth flag exists); use it, but still keep the instance off any public interface. ```json [ {"user": "admin", "pass": "REPLACE_WITH_LONG_RANDOM_VALUE"} ] ``` Fooocus reads `auth.json` by a relative name from the checkout root, the directory `launch.py` changes into, so outside a container put the file next to `launch.py`, owned by the account that runs Fooocus, with mode `0400`. `auth.py` also accepts a `hash` entry in place of `pass`: the lowercase hex SHA-256 of the password. Its `auth_list_to_dict` (`auth.py:8-16`) stores `hash` as given and turns `pass` into that same digest, and its `check_auth` (`auth.py:37-41`) compares the hex SHA-256 of the password typed at login with the stored value, so a `hash` entry keeps the plaintext password out of the file. Check for the login prompt after every start rather than trusting that the file is present. Run on its own with no server started, the v2.5.5 loader (`load_auth_data`) raised `PermissionError` for a file it could not open, because its `open()` sits outside its `try`; for a malformed file it printed `load_auth_data, e: ...` and returned no credentials; for an empty list (`[]`) it also returned none; and for a valid file it returned credentials. From the launch call, which passes a login to gradio only when the loader returned credentials, an unreadable file should stop Fooocus before the UI starts, while a malformed file or an empty list should start the UI with no login. That last step is read from the source, not observed: no Fooocus server was run. Pass the port explicitly as well as the bind. Fooocus hands gradio no port of its own: `launch.py` sets `GRADIO_SERVER_PORT` to `7865` only when that variable is not already set, so a `GRADIO_SERVER_PORT` inherited from your shell, a service unit or a container's environment moves the UI to whatever port it names. With no port passed, gradio 3.41.2 (the version Fooocus v2.5.5 pins) tries that value and then each port above it in turn, up to 100 ports by default, so a 7865 already in use moves the UI to a higher port with no error. `--port 7865` makes gradio try that port alone and fail to start if it is taken, which keeps the UI where your firewall rules and the Verify probe below expect it. `--listen 127.0.0.1` restates the default, so the launch line records the bind. Start Fooocus with `launch.py` rather than the README's `entry_with_update.py`. The updater fetches `origin` and, when the checked-out branch can fast-forward to the remote branch of the same name, moves the branch to that commit and hard-resets the working tree before it imports `launch.py`, so a version you pinned on a branch does not stay pinned. `launch.py` is the same program without that step, and it is what the upstream container image runs. Clone the pinned tag: ```bash git clone --branch v2.5.5 https://github.com/lllyasviel/Fooocus.git ``` After preparing the environment as the README describes, start it from the pinned checkout. The block starts Fooocus only when the checkout's HEAD is exactly the `v2.5.5` tag, and `./Fooocus` keeps a `CDPATH` in your shell from moving it elsewhere. It does not check the working tree, so if the clone printed an error, delete `./Fooocus` and clone again before starting: ```bash ( cd ./Fooocus || { echo 'no ./Fooocus checkout here; not starting'; exit 2; } [ "$(git describe --tags --exact-match 2>/dev/null)" = v2.5.5 ] || { echo 'this is not the pinned v2.5.5 checkout; not starting'; exit 2; } python launch.py --listen 127.0.0.1 --port 7865 ) ``` The upstream container image sets `ENV CMDARGS --listen` in its Dockerfile, and its `docker-compose.yml` and the `docker run` and `podman run` examples in its `docker.md` set `CMDARGS=--listen` again, so inside the container Fooocus binds `0.0.0.0`. Each of them also publishes `7865:7865` with no host address; Docker binds such a publication on every host address, `0.0.0.0` and `[::]` (this guide has not checked Podman's default). On a bridge network the wildcard inside the container is what lets a published port reach the UI, and it also lets any container on the same network reach it, with no login unless a valid `auth.json` is mounted (below). Keep the wildcard inside the container, publish to loopback only, and state the arguments: in the `docker run` and `podman run` examples, replace `-p 7865:7865` with `-p 127.0.0.1:7865:7865` and `-e CMDARGS=--listen` with `-e CMDARGS='--listen 0.0.0.0 --port 7865'`. The `docker-compose.yml` change is to make the `ports` entry `"127.0.0.1:7865:7865"` and the `CMDARGS` entry `CMDARGS=--listen 0.0.0.0 --port 7865`. Under host networking (`--network host`) nothing is published and the container's wildcard is the host's, so set `CMDARGS` to `--listen 127.0.0.1 --port 7865` there instead. A loopback publication is a reliable boundary only on Docker Engine 28.0 and later; see [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md#1-publish-nothing-except-the-tls-proxy). Build the image from the pinned checkout and run that local build, not the GHCR image. The upstream `docker-compose.yml` names both `build: .` and `image: ghcr.io/lllyasviel/fooocus`, so Compose may run the GHCR image it pulls rather than a build of your checkout, and the `docker run` example in the upstream `docker.md` runs `ghcr.io/lllyasviel/fooocus`; this guide has not checked that image's default tag against v2.5.5. With Compose, start it with `docker compose up --build`, or add `pull_policy: build` to the service so that every `docker compose up` builds it; the service keeps `image: ghcr.io/lllyasviel/fooocus`, so Compose tags its local build `ghcr.io/lllyasviel/fooocus`, not `fooocus`. With `docker run`, build it with `docker build . -t fooocus`, as the upstream `docker.md` describes, which tags the build `fooocus`, and run `fooocus` in place of `ghcr.io/lllyasviel/fooocus`. The image build excludes `auth.json`, so mount it read-only at `/content/app/auth.json`: `-v /srv/fooocus/auth.json:/content/app/auth.json:ro` with `docker run`, or `- /srv/fooocus/auth.json:/content/app/auth.json:ro` under the service's `volumes:` in `docker-compose.yml`. Fooocus runs in the image as its unprivileged `user` account, so the host file must be owned by that account's uid and readable by no other account. Read the uid from the image you built: on the `docker build . -t fooocus` path, `docker run --rm --entrypoint id fooocus -u user` prints it; on the Compose path, run `docker compose build` and then `docker run --rm --entrypoint id ghcr.io/lllyasviel/fooocus -u user`, which reads the local build Compose tagged with that name. Then `sudo chown` the file to that uid and `sudo chmod 0400` it. Read from the source and not observed on a running server (above), an unreadable file should stop Fooocus from starting, and a malformed file or an empty list should start it with no login, so check for the login prompt. Under rootless Docker or user-namespace remapping the host-side owner differs; this guide has not checked that case. ## Verify (REASONED: all listener inventories, IPv4/IPv6 external probes, local positive controls, InvokeAI boards authentication, proxy TLS/browser login and Fooocus host/container comparisons follow the cited sources below; no authorized service, isolated listener namespace, container runtime or external observer. Historical Fooocus parsing is not a live-service result. Python 3.14.4 argparse with the printed listen definition returned 127.0.0.1 for [] and 0.0.0.0 for [--listen]. Bash 5.3.9 bash -n accepted the shell fence in an earlier form, before the proxy check took its host on a guarded set -- line. In the extracted port-loop subshell of an earlier form, with curl replaced by printf LOCAL_CURL, 192.168.50.7 and bad value each emitted four markers; empty input, the original placeholder and set -- without arguments emitted only guard diagnostics. All exited 0, so in that form neither exit status nor the guard validated an address or service exposure. The bash -n run recorded here covered the shell fence and the stub runs covered the extracted port-loop subshell, each in an earlier form, whose guard refusals left the subshell with status 0. The checks are now four fences, each ending with its own exit status; the gate suite's bash -n and ShellCheck 0.11.0 passes cover their current form, and so do stub runs, with no network reached, in which each guard refusal exited 2. For the proxy check, those runs covered the form that lowercased the host with tr, and the stub runs of its current form are recorded with that check below) None of this guide's live Verify checks has a recorded demonstration. The missing capability is an isolated host or network namespace in which the services and their proxy may open listeners, as described for Fooocus below. The checks remain REASONED from the cited vendor documentation and pinned sources, as detailed below. The ComfyUI (8188), AUTOMATIC1111 (7860 with the explicit `--port 7860` above) and InvokeAI (9090) entries in the listener inventory and external probe loop are each REASONED, not demonstrated: the authoring host forbids opening a listener until an isolated network namespace exists, and it has none. From the cited ComfyUI v0.37.0 argument parser, AUTOMATIC1111 v1.10.1 `gradio_server_name()` and InvokeAI YAML Config reference, a host process bound to a wildcard address should appear that way in `ss` and answer the external probe with an HTTP code when the network permits it. The expectation drawn from the same cited sources is that a host process bound to `127.0.0.1` as above should appear only on loopback, and that the external probe should report a refusal or timeout reaching your address. Without that explicit AUTOMATIC1111 port, Gradio searches from `GRADIO_SERVER_PORT` (7860 when unset) for up to `GRADIO_NUM_PORTS` ports (default 100); replace 7860 in the inventory and probe loop with the actual listener port. Confirm each service is running and answers on host loopback before interpreting an external refusal as the fixed state. These are expected outcomes from the sources, not observations. The InvokeAI `/api/v1/boards/` check is also REASONED: the authoring host forbids opening a listener until an isolated network namespace exists, and it has none. The cited Multi-User Administrator Guide distinguishes the default single-user mode from `multiuser: true`, which requires a login. The [Multi-User API Guide](https://invoke.ai/features/multi-user-mode/api-guide/) documents the boards endpoint and missing-token `401`. The expected pair is an unauthenticated boards response in single-user mode (the cited guides give no status code for it; record the observed code) and `401` in multi-user mode. REASONED: the proxy check is not demonstrated: the authoring host forbids opening a listener until an isolated network namespace exists, and it has none, so no proxy or backend was run. Following [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), the expected exposed state serves the UI without proxy authentication, and the fixed state requires a login before the UI loads. The header request in the last fence below takes your configured host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains; no proxy has answered it. Stub runs in which each guard refusal exited 2 covered an earlier form of that fence, which lowercased the host with `tr`. Its current form, which matches reserved names in any letter case through bash's `nocasematch` option, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by November 2026 stub runs under bash 5.3.9 with curl stubbed and no network. In those runs eight reserved names in lower, upper and mixed case, bare and as subdomains, each exited 2 with the reserved-name message before any request; two reserved names in upper or mixed case also did with `tr` absent from PATH, and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and a host outside those names reached the header request. Under dash, which lacks bash's `shopt`, stub runs of the current form refused reserved and other host names alike with status 2 and its message that the block needs bash, before any request. The header request checks the TLS connection and authentication response, and a browser check confirms the login gates the UI, so that fence exits 3 when curl fails and otherwise 0, leaving the response and the browser check to you. The listener inventory in the following fence, which refuses with status 2 when `ss` is not installed, and the Fooocus entries below (port 7865 in the `ss` comment and in the probe loop) are REASONED, not demonstrated: the authoring host forbids opening a listener until an isolated network namespace exists, and it has none, so no Fooocus instance was run. From the v2.5.5 sources cited below (the `--listen` default and its bare-flag constant in the argument parser, and the launch call that passes the bind and port to gradio), a Fooocus process started on the host with a bare `--listen` should show a wildcard listener on 7865 in `ss` and answer the probe from another host with an HTTP code. The expectation drawn from the same v2.5.5 sources is that a Fooocus process started on the host as above should show `127.0.0.1:7865` in `ss`, and that the probe should report a refusal or timeout reaching your address. The argument parse alone is demonstrated: `argparse` given that `--listen` definition (`type=str`, `default="127.0.0.1"`, `nargs="?"`, `const="0.0.0.0"`) returns `127.0.0.1` with no flag and `0.0.0.0` for a bare `--listen`. Confirm Fooocus is up and answers on the host itself before reading an external refusal as the fixed state. The `ss` expectations cover host processes only. For a container, the check is the PORTS column of `docker ps` plus the probe from another host, not `ss`, because socket visibility varies by Docker version and config (see [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md#4-verify)). A container published as `7865:7865` should list the publication on every host address in the PORTS column and answer the probe with an HTTP code, and a container published as `127.0.0.1:7865:7865` should list `127.0.0.1` as its only host address, with the probe reporting a refusal or timeout reaching your address. A publication with no host address binds `[::]` as well as `0.0.0.0`, so run the probe block twice, once with your public IPv4 address and once with your public IPv6 address in square brackets, each inside the quotes on the port-loop block's `set --` line. The port-loop block probes every port even when an earlier probe fails, and exits 1 if any port answers with an HTTP code, otherwise 3 if any curl fails other than by failing to connect (exit 7) or timing out (exit 28), and otherwise 0, so read each `port=` line: a refusal or timeout passes only when its `err` text shows that it reached your address. Under rootful Docker's host networking nothing is published and the PORTS column is empty; the listener is in the host's network namespace, so read `ss` for it as for a host process: `127.0.0.1:7865` when fixed, or `0.0.0.0:7865` when `CMDARGS` still binds the wildcard, whether the image's default `--listen` was left in or a bridge-network `--listen 0.0.0.0` was copied over. ```bash # ComfyUI 8188, AUTOMATIC1111 7860, InvokeAI 9090; Fooocus 7865. The authoring host forbids listeners without an isolated network namespace, and has none; expected outcomes and sources above. # Run this block on the host itself. It exits 2 if ss is not installed, 3 if ss fails and otherwise 0, because you judge every listener it prints. ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } if ss -tlnp; then # host processes: read every listener; 8188/7860/9090/7865: each service on 127.0.0.1 only echo "block exit 0; unclassified observation: read every listener above, where 8188, 7860, 9090 and 7865 each pass only on 127.0.0.1" exit 0 else echo "ss -tlnp exit $?: the listener inventory failed; inconclusive; block exit 3" exit 3 fi ) ``` ```bash # InvokeAI authentication follows the cited Multi-User Administrator and API Guides; # the authoring host forbids listeners without an isolated network namespace, and has none. # Run this block on the host itself. With multi it exits 0 for a 401, 1 for a 2xx and 3 for any other # code or a failed request; with single it exits 0 for any answer, whose code you record. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_single_OR_multi' # single for InvokeAI's default single-user mode, or multi if you set multiuser: true: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in single|multi) ;; *) echo "substitute single (InvokeAI's default single-user mode) or multi (multiuser: true) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac # guard-conventions: allow probe of a fixed loopback target; no reader-substituted placeholder in this probe's argv set -- "$1" "$(if curl -q -g -s --noproxy '*' -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/api/v1/boards/; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # the mode, curl's exit status, then the HTTP code printf '%s\n' "$3" # from the host itself, InvokeAI multiuser mode: 401 # without a Bearer token, never the app itself case "$1:$2:$3" in multi:0:401) echo "block exit 0; multiuser mode refused the request without a Bearer token, and this block leaves nothing else to judge"; exit 0 ;; multi:0:2??) echo "FINDING: /api/v1/boards/ answered $3 without a Bearer token in multiuser mode; block exit 1"; exit 1 ;; multi:0:*) echo "HTTP $3 is neither the 401 pass nor a 2xx finding; inconclusive; block exit 3"; exit 3 ;; single:0:*) echo "block exit 0; unclassified observation: single-user mode has no login, so record the code above yourself; loopback binding is the boundary"; exit 0 ;; *) echo "curl exit $2: InvokeAI did not answer on 127.0.0.1:9090, so confirm it is running there; inconclusive; block exit 3"; exit 3 ;; esac ) ``` ```bash # The external probes follow the sources above; the authoring host forbids listeners without an isolated network namespace, and has none. # Each port must be unreachable from another host, so run this block there. Read err, not the number: it must name a refusal or # timeout reaching YOUR address. An HTTP code means the port answered. A resolver failure, a local # socket error, or a timeout that did not come from the remote address is inconclusive. Every port is # probed even after one fails. The block exits 1 if any port answered with an HTTP code, otherwise 3 if # any curl failed other than by failing to connect (exit 7) or timing out (exit 28), and otherwise 0. # Run it once with your public IPv4 address, then again with your public IPv6 address in brackets. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 8188 7860 9090 7865 # your address, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports: ComfyUI, SD WebUI, InvokeAI, Fooocus while [ "$#" -gt 2 ]; do # one pass per port; each pass drops the port it probed set -- "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 \ -w "port=$3 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n" "http://$1:$3/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" printf '%s' "${1%$'\n'*}" # the port= line; the values above now follow curl's output and exit status case "$1" in *" http=000 "*) case "${1##*$'\n'}" in 7|28) set -- "$2" "$3" "${@:5}" ;; # failed to connect or timed out: read err, which must name YOUR address *) echo "curl exit ${1##*$'\n'} with no HTTP answer from port $4; inconclusive" case "$3" in 1) set -- "$2" 1 "${@:5}" ;; *) set -- "$2" 3 "${@:5}" ;; esac ;; esac ;; *" http="*) echo "FINDING: port $4 answered with an HTTP code, so it is reachable from here"; set -- "$2" 1 "${@:5}" ;; *) echo "curl exit ${1##*$'\n'} printed no port= line for port $4; inconclusive" case "$3" in 1) set -- "$2" 1 "${@:5}" ;; *) set -- "$2" 3 "${@:5}" ;; esac ;; esac done echo "block exit $2; a port= line with http=000 and exit 7 or 28 passes only if its err names a refusal or timeout reaching your address, so read each one yourself" exit "$2" ) ``` ```bash # TLS and login follow the fronting-auth.md guidance above; # the authoring host forbids listeners without an isolated network namespace, and has none. # This block exits 3 if curl fails and otherwise 0, because you judge the response and the browser login. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_IMAGEGEN_HOST' # the public host name your proxy serves the UI on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again if curl -q -g -sI "https://$1/"; then # via the proxy: TLS, and a login prompt # or 401 without credentials, before the UI loads echo "block exit 0; unclassified observation: read the status line and headers above, and confirm in a fresh browser session that a login appears before the UI loads" exit 0 else echo "curl exit $?: the request through your proxy failed, so TLS and the login were not checked; inconclusive; block exit 3" exit 3 fi ) ``` ## Common mistakes - Adding `--listen`/`host: 0.0.0.0` "just to test from my phone" and forgetting it is still set a week later. - Treating `--share` (Stable Diffusion WebUI, Fooocus) as a deployment option instead of a short-lived demo link. - Installing a custom node or extension without reading it, on the assumption that "it's just a UI". - Relying on the Gradio login (`--gradio-auth-path`) or Fooocus's `auth.json` alone: single-factor credentials over plain HTTP still leak on the wire without a TLS proxy in front. - Passing `--gradio-auth user:pass` on the command line, where other local accounts can read the password through `ps` for as long as the UI runs and the launcher prints it at startup; or in `COMMANDLINE_ARGS`, where it is held in the environment, readable by the same account and by root, printed at startup the same way, and returned by `/internal/sysinfo` (read from the code, not run). - Assuming InvokeAI's multi-user mode is on by default; the base install has no login at all, so loopback binding still carries the whole burden. ## Sources (checked September 2026) - ComfyUI Startup Flags, `--listen` and `--port` defaults (rolling documentation, checked September 2026): https://docs.comfy.org/development/comfyui-server/startup-flags - ComfyUI Registry custom node security standards, eval/exec prohibited (rolling documentation, checked September 2026): https://docs.comfy.org/registry/standards - ComfyUI 2025 Jan Security Update, custom node code-execution risk: https://blog.comfy.org/p/comfyui-2025-jan-security-update - ComfyUI-Manager security advisory, CVE-2025-67303, GHSA-95pq-hr8p-f5g7 (minimums ComfyUI v0.3.76 and Manager v3.38): https://github.com/Comfy-Org/ComfyUI-Manager/security/advisories/GHSA-95pq-hr8p-f5g7 - AUTOMATIC1111 Command Line Arguments and Settings wiki: https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/Command-Line-Arguments-and-Settings - AUTOMATIC1111 `--gradio-auth-path` ("set gradio authentication file path"), `--gradio-auth`, `--api-auth` and `--server-name` definitions (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L87-L113 - AUTOMATIC1111 `--listen` and `--port` (`default=None`) definitions and the UI launch call passing `server_port=cmd_opts.port` (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L77-L78 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/webui.py#L79-L82 - AUTOMATIC1111 `--share` (a Gradio share link) and `--enable-insecure-extension-access` definitions (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L47 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L51 - AUTOMATIC1111 credential reader: opens `cmd_opts.gradio_auth_path`, splits each line on `,` and each entry once on `:` (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/initialize_util.py#L114-L139 - AUTOMATIC1111 passes the credential list to Gradio as `auth=` (`list(initialize_util.get_gradio_auth_creds()) or None`; pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/webui.py#L70-L90 - AUTOMATIC1111 API credential input trace: plain `ArgumentParser()` without argument-file support, string `--api-auth` defaulting to `None`, parsing of argv and the shared namespace alias (pinned tag v1.10.1). Sources for this item (1 of 2): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L6 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L102. Sources for this item (2 of 2): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/shared_cmd_options.py#L7-L15 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/shared.py#L14-L20 - AUTOMATIC1111 API credential uses: conditional authentication dependency, credential comparison and the namespace-output reader (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L274-L284 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L689-L690 - AUTOMATIC1111 settings inputs are separate from `cmd_opts.api_auth`: initialization and loading of `shared.opts`, settings API writes, and UI configuration restoration (pinned tag v1.10.1). Sources for this item (1 of 2): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/shared_init.py#L17-L24, https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/options.py#L144-L171 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/options.py#L197-L206. Sources for this item (2 of 2): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L678-L687, https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/ui.py#L1110-L1113 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/ui_loadsave.py#L14-L73 - AUTOMATIC1111 `--api-auth` parsing, `auth.split(":")` into a user and a password for each `,`-separated entry (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L199-L205 - AUTOMATIC1111 `/sdapi/v1/cmd-flags` returns every command-line option: the route, its handler, the response model built from the whole parser, and routes that need credentials only with `--api-auth` (pinned tag v1.10.1). Sources for this item (1 of 2): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L222 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L689-L690. Sources for this item (2 of 2): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/models.py#L221-L231 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L274-L277 - AUTOMATIC1111 appends `COMMANDLINE_ARGS` from the environment to `sys.argv` (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/paths_internal.py#L12-L13 - AUTOMATIC1111 launcher prints `shlex.join(sys.argv[1:])` at startup (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/launch_utils.py#L463-L464 - AUTOMATIC1111 `webui.sh` sources `webui-user.sh` (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/webui.sh#L18-L23 - AUTOMATIC1111 system-information report: `COMMANDLINE_ARGS` on the environment whitelist, the unredacted environment dump, and the exact-value argv redaction (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/sysinfo.py#L33 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/sysinfo.py#L130-L148 - AUTOMATIC1111 `/internal/sysinfo` and `/internal/sysinfo-download` routes, registered with no dependency (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/ui.py#L1223-L1232 - AUTOMATIC1111 pins Gradio 3.41.2 and adds only GZip and CORS middleware (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/requirements_versions.txt#L11 and https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/initialize_util.py#L192-L214 - AUTOMATIC1111 `gradio_server_name()`: `--server-name`, else `0.0.0.0` with `--listen`, else `None` (pinned tag v1.10.1): https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/initialize_util.py#L10-L16 - Gradio 3.41.2 default bind, `LOCALHOST_NAME = os.getenv("GRADIO_SERVER_NAME", "127.0.0.1")` and `server_name = server_name or LOCALHOST_NAME` (pinned tag gradio@3.41.2): https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/networking.py#L28 and https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/networking.py#L120 - Gradio 3.41.2 checks the login per route, with `dependencies=[Depends(login_check)]` (pinned tag gradio@3.41.2): https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/routes.py#L193-L305 - Gradio 3.41.2 turns a list `auth` into a dictionary keyed by user name, so a later password for the same user name replaces an earlier one (pinned tag gradio@3.41.2): https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/routes.py#L128-L133 - InvokeAI YAML Config, host/port defaults (rolling documentation, checked September 2026): https://invoke.ai/configuration/invokeai-yaml/ - InvokeAI Multi-User Administrator Guide (rolling documentation, checked September 2026): https://invoke.ai/features/multi-user-mode/admin-guide/ - InvokeAI Multi-User API Guide, boards endpoint and missing-token `401` (rolling documentation, checked September 2026): https://invoke.ai/features/multi-user-mode/api-guide/ - Fooocus README, UI access and authentication (`--listen`, `--port`, `--share`, `auth.json`; pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/readme.md#L296-L301 - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0): https://curl.se/docs/manpage.html - ComfyUI `--listen` default `127.0.0.1`, `0.0.0.0,::` when given without a value, and `--port` default `8188` (pinned tag v0.37.0): https://github.com/Comfy-Org/ComfyUI/blob/v0.37.0/comfy/cli_args.py#L63-L64 - Fooocus `--listen` default `127.0.0.1`, and `0.0.0.0` when given without a value (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/ldm_patched/modules/args_parser.py#L36 - Fooocus sets no port default of its own (`port=None`, pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/args_manager.py#L43-L47 - Fooocus sets `GRADIO_SERVER_PORT` to `7865` only when it is unset (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/launch.py#L13-L14 - Fooocus passes the bind, the port and the `auth.json` login to gradio (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/webui.py#L1120-L1128 - Fooocus pins gradio 3.41.2 (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/requirements_versions.txt#L13 - gradio 3.41.2 reads `GRADIO_SERVER_PORT` (default `7860`) as the start of its port search and `GRADIO_NUM_PORTS` (default `100`) as its length (pinned tag gradio@3.41.2): https://github.com/gradio-app/gradio/blob/gradio%403.41.2/gradio/networking.py#L24-L27 - gradio 3.41.2 tries only the passed port, or searches upward when none is passed, and raises when none is free (pinned tag gradio@3.41.2): https://github.com/gradio-app/gradio/blob/gradio%403.41.2/gradio/networking.py#L133-L171 - Fooocus `entry_with_update.py` fetches `origin` and fast-forwards the checked-out branch before importing `launch.py` (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/entry_with_update.py#L16-L46 - Fooocus container image: `ENV CMDARGS --listen`, and the process runs as the unprivileged `user` account (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/Dockerfile#L3 and https://github.com/lllyasviel/Fooocus/blob/v2.5.5/Dockerfile#L17-L29 - Fooocus container entrypoint runs `launch.py` with `CMDARGS` (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/entrypoint.sh#L33 - Fooocus Compose file names both `build: .` and `image: ghcr.io/lllyasviel/fooocus`, publishes `7865:7865` and sets `CMDARGS=--listen` (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/docker-compose.yml#L6-L11 - Fooocus Docker instructions (`-p 7865:7865`, `-e CMDARGS=--listen`, a `docker run` of `ghcr.io/lllyasviel/fooocus`, building locally; pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/docker.md#L19-L78 - Fooocus image build excludes `auth.json` (pinned tag v2.5.5): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/.dockerignore#L53 - Fooocus reads `auth.json` by a relative name from the checkout root that `launch.py` changes into, and `auth_list_to_dict` stores a `hash` entry as given and turns a `pass` entry into its SHA-256 hex digest (pinned tag v2.5.5). The Fooocus loader opens the file outside its `try` and returns no credentials for a malformed file or an empty list, and `check_auth` compares the SHA-256 hex digest of the password typed at login with the stored value (pinned tag v2.5.5). Sources for this item (1 of 2): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/constants.py#L5, https://github.com/lllyasviel/Fooocus/blob/v2.5.5/launch.py#L7-L9 and https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/auth.py#L8-L16. Sources for this item (2 of 2): https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/auth.py#L19-L34 and https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/auth.py#L37-L41 - Docker port publishing (Engine 28.0 boundary qualification recorded above; with no host address, "the Docker daemon publishes ports to all host addresses (0.0.0.0 and [::])"): https://docs.docker.com/engine/network/port-publishing/ - Compose Specification, `pull_policy` (`build`: "Compose builds the image"; and "The `latest` tag is always pulled even when the `missing` pull policy is used"; pinned commit 914ec15d1fa4): https://github.com/compose-spec/compose-spec/blob/914ec15d1fa498969c0df5c1d672306db3256089/05-services.md#L1816-L1832 - Compose Specification, a service with both `build` and `image` (Compose "follows the rules defined by the `pull_policy` attribute"; if it is missing, "Compose attempts to pull the image first and then builds from source if the image isn't found", and a built image takes the `image` name, as the example shows; pinned commit 914ec15d1fa4). Sources for this item: https://github.com/compose-spec/compose-spec/blob/914ec15d1fa498969c0df5c1d672306db3256089/build.md#L21-L23 and https://github.com/compose-spec/compose-spec/blob/914ec15d1fa498969c0df5c1d672306db3256089/build.md#L50-L62 - `docker compose up --build` ("Build images before starting containers"; pinned tag v2.32.4): https://github.com/docker/compose/blob/v2.32.4/cmd/compose/up.go#L147 - Linux ACLs, acl(5): default ACL inheritance and how a default ACL takes the place of the umask for new files (man-pages; checked against the local acl 2.3.2 manual, October 2026): https://man7.org/linux/man-pages/man5/acl.5.html - getfacl(1): `-d` (default ACL), `-c` (omit the header) and `-p` (keep leading `/`) options (man-pages; checked against the local acl 2.3.2 manual, October 2026): https://man7.org/linux/man-pages/man1/getfacl.1.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - comfy: ComfyUI (v0.37.0) - webui: AUTOMATIC1111 (v1.10.1) - fooocus: Fooocus (v2.5.5) - comfy-rolling: ComfyUI documentation (rolling) (unknown) - comfy-registry-rolling: ComfyUI Registry documentation (rolling) (unknown) - comfy-advisory: ComfyUI January 2025 security advisory (unknown) - comfy-manager-advisory: ComfyUI-Manager security advisory (unknown) - invoke-rolling: InvokeAI documentation (rolling) (unknown) - gradio: Gradio (3.41.2) - engine: Docker Engine minimum loopback boundary (28.0) - compose: Compose Specification (914ec15d1fa4) - compose-cli: Docker Compose source (v2.32.4) - curl: curl minimum write-out version (7.75.0) - linux: Linux permissions and tools (unknown) Claims (full source mapping in the JSON block below): - comfy-bind (REASONED; comfy, comfy-rolling): Default loopback:8188, bare --listen binds IPv4/IPv6 wildcards; explicit bind/port and proxy login. - comfy-nodes (REASONED; comfy-advisory, comfy-registry-rolling, comfy-rolling): ComfyUI has no built-in login; Python custom nodes run with server privileges. Trust nodes and apply container containment. - fooocus-login (REASONED; fooocus): Fooocus is unauthenticated by default and offers optional auth.json login; keep it off public interfaces. - manager (REASONED; comfy-manager-advisory): Manager RCE advisory requires ComfyUI at least v0.3.76 and Manager at least v3.38 together. - node-flags (REASONED; comfy-rolling): --disable-all-custom-nodes and --whitelist-custom-nodes limit loaded nodes, not network exposure. - webui-auth (REASONED; webui): UI auth file/list syntax, API-only auth, colon/comma parsing differences and plaintext HTTP require a TLS proxy. - webui-flags (REASONED; webui, gradio): --listen, --share relay and insecure extension access affect exposure. Without --port, Gradio searches from GRADIO_SERVER_PORT (7860 when unset) for up to GRADIO_NUM_PORTS ports (default 100); explicit --port 7860 pins the UI port. - api-input (REASONED; webui): API credentials have no shipped stdin/file/environment/prompt input; settings and UI config do not populate api_auth. - api-leak (REASONED; webui): cmd-flags reveals all options; API auth users can read API passwords, unauthenticated API can expose UI argv credentials. - argv (REASONED; webui): COMMANDLINE_ARGS becomes argv; launcher prints credentials and local argv/environment readers may read them. - file-create (REASONED; webui, linux): Create-once owner-only credential file uses hex password and clean-shell guards, refuses a default ACL or missing getfacl and checks mode 0600 after the write; directory races, backups and plaintext storage limit protection. Filesystem is unrecorded. - file-launch (REASONED; webui, gradio): Clear inherited launch/bind variables, validate regular non-symlink credential file, reject empty/malformed/duplicate entries and read failures. Shell/grep/awk versions are unrecorded. - webui-bind (REASONED; webui, gradio): Explicit --server-name overrides Gradio environment-based bind; without it GRADIO_SERVER_NAME can replace loopback. - sysinfo (REASONED; webui, gradio): Unredacted environment and incomplete argv redaction in unauthenticated sysinfo routes are source-reasoned; proxy must deny the prefix. - invoke-config (REASONED; invoke-rolling): Flat schema 4.0.2, host 127.0.0.1, port 9090 and INVOKEAI_HOST/PORT overrides; default single-user mode has no login. - invoke-login (REASONED; invoke-rolling): Experimental multiuser login/JWT and strict password checks; changing the stored JWT secret then restarting logs users out. Ordinary restart logout is not established; keep loopback and TLS. - fooocus-flags (REASONED; fooocus): Loopback by default, bare --listen wildcard, --share public relay; auth.json supplies optional login. - fooocus-loader (DEMONSTRATED; fooocus): Isolated loader returns credentials only for valid data; unreadable raises PermissionError, malformed/empty returns none. - fooocus-auth (REASONED; fooocus): Relative auth.json location, pass/hash SHA-256 handling, permissions and expected fail-open for malformed/empty data; no live server test. - fooocus-port (REASONED; fooocus, gradio): Fooocus v2.5.5 pins Gradio 3.41.2; inherited port or occupied 7865 can move the UI; explicit --port makes Gradio try only that port. - fooocus-pin (REASONED; fooocus): entry_with_update updates the checkout; use launch.py from exact v2.5.5, with working-tree caveat. - container (REASONED; fooocus, engine): CMDARGS bare --listen plus wildcard publication exposes image; bridge uses internal wildcard and loopback publish, host networking needs loopback bind. - image-build (REASONED; fooocus, compose, compose-cli): Build pinned source, not unchecked GHCR default; Compose build/image pull policy and --build determine selected/tagged image. - auth-mount (REASONED; fooocus): Image excludes auth.json; mount read-only, use built image user UID and mode 0400; remapped/rootless ownership is unchecked. - argparse (DEMONSTRATED; fooocus): Isolated argparse definition yields loopback with no flag and wildcard for bare --listen; this is not a server demonstration. - verify-network (REASONED; comfy, webui, fooocus, engine, curl, invoke-rolling): Inventory every service and port, confirm loopback service is up, probe actual IPv4/IPv6; container PORTS or host-network ss have different scope. - verify-invoke (REASONED; invoke-rolling): Unauthenticated /api/v1/boards/ should answer in single-user mode and reject with 401 in multi-user mode; exposed status is unrecorded and no listener demonstration was available. - verify-proxy (REASONED; fooocus, comfy-rolling): TLS/header and browser checks should require login before UI; no service/proxy listeners were run. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "77f39e9fc5ca0eaf69041d34459158440c099ae09bc07ffe0fe7fc939a6e8285", "components": { "comfy": { "name": "ComfyUI", "basis": "v0.37.0", "sources": { "sbd178d58a098": "https://github.com/Comfy-Org/ComfyUI/blob/v0.37.0/comfy/cli_args.py#L63-L64" } }, "webui": { "name": "AUTOMATIC1111", "basis": "v1.10.1", "sources": { "s42a42cca605b": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L87-L113", "s0195caed3cb9": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/initialize_util.py#L114-L139", "sd5f3496b66aa": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/webui.py#L70-L90", "s82803810022a": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L6", "s914278bcbf70": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/shared_cmd_options.py#L7-L15", "s66a044b7666c": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L199-L205", "sb59e633133f6": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L274-L284", "s57d98254f09d": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/api.py#L689-L690", "s256946a60534": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/api/models.py#L221-L231", "s5b2e23d1781d": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/paths_internal.py#L12-L13", "sfa27f3a86456": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/launch_utils.py#L463-L464", "sebe977aeb6f3": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/sysinfo.py#L130-L148", "sc9117bccdef5": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/ui.py#L1223-L1232", "s370f032e0497": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/initialize_util.py#L10-L16", "saf69abeba189": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/requirements_versions.txt#L11", "sf3f6d5cafad0": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/shared_init.py#L17-L24", "sbfd3d01539c3": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/ui_loadsave.py#L14-L73", "s7b714092036e": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L77-L78", "s23a49b75b6a1": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/webui.py#L79-L82", "s9a93a7fe8617": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L47", "s72ec61fe38c9": "https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/v1.10.1/modules/cmd_args.py#L51" } }, "fooocus": { "name": "Fooocus", "basis": "v2.5.5", "sources": { "s38232435e83d": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/readme.md#L296-L301", "s63ce6f5a8fa9": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/ldm_patched/modules/args_parser.py#L36", "sca9aed72a579": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/args_manager.py#L43-L47", "s456b2a263c88": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/launch.py#L13-L14", "s2eb7ac87bb1f": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/webui.py#L1120-L1128", "s35ce6e657cb8": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/entry_with_update.py#L16-L46", "s6157fae93872": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/Dockerfile#L17-L29", "s5c792b662186": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/entrypoint.sh#L33", "s79380d927e3b": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/docker-compose.yml#L6-L11", "s431336fa6375": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/docker.md#L19-L78", "scb18d8c421f5": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/.dockerignore#L53", "s170da4e26268": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/auth.py#L8-L16", "s21eb28118a14": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/auth.py#L19-L34", "sfcc14904297e": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/modules/auth.py#L37-L41", "s2ef62a4cd853": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/launch.py#L7-L9", "sc90edaf29ac7": "https://github.com/lllyasviel/Fooocus/blob/v2.5.5/requirements_versions.txt#L13" } }, "comfy-rolling": { "name": "ComfyUI documentation (rolling)", "basis": "unknown", "sources": { "s8b2f213483c1": "https://docs.comfy.org/development/comfyui-server/startup-flags" } }, "comfy-registry-rolling": { "name": "ComfyUI Registry documentation (rolling)", "basis": "unknown", "sources": { "sb5912e72af4b": "https://docs.comfy.org/registry/standards" } }, "comfy-advisory": { "name": "ComfyUI January 2025 security advisory", "basis": "unknown", "sources": { "sfde3f2261e67": "https://blog.comfy.org/p/comfyui-2025-jan-security-update" } }, "comfy-manager-advisory": { "name": "ComfyUI-Manager security advisory", "basis": "unknown", "sources": { "s4ce66189c3c9": "https://github.com/Comfy-Org/ComfyUI-Manager/security/advisories/GHSA-95pq-hr8p-f5g7" } }, "invoke-rolling": { "name": "InvokeAI documentation (rolling)", "basis": "unknown", "sources": { "s23a1a14da479": "https://invoke.ai/features/multi-user-mode/admin-guide/", "s4c6175f3800d": "https://invoke.ai/features/multi-user-mode/api-guide/", "sb0dc1b679ef2": "https://invoke.ai/configuration/invokeai-yaml/" } }, "gradio": { "name": "Gradio", "basis": "3.41.2", "sources": { "s1bd30a313ec9": "https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/networking.py#L28", "sf85e6083e02e": "https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/routes.py#L193-L305", "s2ec1c2a1d665": "https://github.com/gradio-app/gradio/blob/gradio@3.41.2/gradio/routes.py#L128-L133", "s6f6c16695b71": "https://github.com/gradio-app/gradio/blob/gradio%403.41.2/gradio/networking.py#L24-L27", "s67ebd44760a7": "https://github.com/gradio-app/gradio/blob/gradio%403.41.2/gradio/networking.py#L133-L171" } }, "engine": { "name": "Docker Engine minimum loopback boundary", "basis": "28.0", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } }, "compose": { "name": "Compose Specification", "basis": "914ec15d1fa4", "sources": { "sfb1cd5e15347": "https://github.com/compose-spec/compose-spec/blob/914ec15d1fa498969c0df5c1d672306db3256089/05-services.md#L1816-L1832", "s2d25c00e22f6": "https://github.com/compose-spec/compose-spec/blob/914ec15d1fa498969c0df5c1d672306db3256089/build.md#L21-L23", "s16a4e1bb5e9f": "https://github.com/compose-spec/compose-spec/blob/914ec15d1fa498969c0df5c1d672306db3256089/build.md#L50-L62" } }, "compose-cli": { "name": "Docker Compose source", "basis": "v2.32.4", "sources": { "s0e2ca4d9b8c0": "https://github.com/docker/compose/blob/v2.32.4/cmd/compose/up.go#L147" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "linux": { "name": "Linux permissions and tools", "basis": "unknown", "sources": { "s31f41c54750c": "https://man7.org/linux/man-pages/man5/acl.5.html", "s08902025e5f9": "https://man7.org/linux/man-pages/man1/getfacl.1.html" } } }, "claims": { "comfy-bind": {"text": "Default loopback:8188, bare --listen binds IPv4/IPv6 wildcards; explicit bind/port and proxy login.", "components": ["comfy", "comfy-rolling"], "sources": ["comfy:sbd178d58a098", "comfy-rolling:s8b2f213483c1"], "status": "REASONED"}, "comfy-nodes": {"text": "ComfyUI has no built-in login; Python custom nodes run with server privileges. Trust nodes and apply container containment.", "components": ["comfy-advisory", "comfy-registry-rolling", "comfy-rolling"], "sources": ["comfy-advisory:sfde3f2261e67", "comfy-registry-rolling:sb5912e72af4b", "comfy-rolling:s8b2f213483c1"], "status": "REASONED"}, "fooocus-login": {"text": "Fooocus is unauthenticated by default and offers optional auth.json login; keep it off public interfaces.", "components": ["fooocus"], "sources": ["fooocus:s38232435e83d"], "status": "REASONED"}, "manager": {"text": "Manager RCE advisory requires ComfyUI at least v0.3.76 and Manager at least v3.38 together.", "components": ["comfy-manager-advisory"], "sources": ["comfy-manager-advisory:s4ce66189c3c9"], "status": "REASONED"}, "node-flags": {"text": "--disable-all-custom-nodes and --whitelist-custom-nodes limit loaded nodes, not network exposure.", "components": ["comfy-rolling"], "sources": ["comfy-rolling:s8b2f213483c1"], "status": "REASONED"}, "webui-auth": {"text": "UI auth file/list syntax, API-only auth, colon/comma parsing differences and plaintext HTTP require a TLS proxy.", "components": ["webui"], "sources": ["webui:s42a42cca605b", "webui:s0195caed3cb9", "webui:sd5f3496b66aa", "webui:s66a044b7666c"], "status": "REASONED"}, "webui-flags": {"text": "--listen, --share relay and insecure extension access affect exposure. Without --port, Gradio searches from GRADIO_SERVER_PORT (7860 when unset) for up to GRADIO_NUM_PORTS ports (default 100); explicit --port 7860 pins the UI port.", "components": ["webui", "gradio"], "sources": ["webui:s7b714092036e", "webui:s23a49b75b6a1", "webui:saf69abeba189", "gradio:s6f6c16695b71", "gradio:s67ebd44760a7", "webui:s9a93a7fe8617", "webui:s72ec61fe38c9"], "status": "REASONED"}, "api-input": {"text": "API credentials have no shipped stdin/file/environment/prompt input; settings and UI config do not populate api_auth.", "components": ["webui"], "sources": ["webui:s82803810022a", "webui:s914278bcbf70", "webui:sb59e633133f6", "webui:sf3f6d5cafad0", "webui:sbfd3d01539c3"], "status": "REASONED"}, "api-leak": {"text": "cmd-flags reveals all options; API auth users can read API passwords, unauthenticated API can expose UI argv credentials.", "components": ["webui"], "sources": ["webui:sb59e633133f6", "webui:s57d98254f09d", "webui:s256946a60534"], "status": "REASONED"}, "argv": {"text": "COMMANDLINE_ARGS becomes argv; launcher prints credentials and local argv/environment readers may read them.", "components": ["webui"], "sources": ["webui:s914278bcbf70", "webui:s5b2e23d1781d", "webui:sfa27f3a86456"], "status": "REASONED"}, "file-create": {"text": "Create-once owner-only credential file uses hex password and clean-shell guards, refuses a default ACL or missing getfacl and checks mode 0600 after the write; directory races, backups and plaintext storage limit protection. Filesystem is unrecorded.", "components": ["webui", "linux"], "sources": ["webui:s0195caed3cb9", "linux:s31f41c54750c", "linux:s08902025e5f9"], "status": "REASONED"}, "file-launch": {"text": "Clear inherited launch/bind variables, validate regular non-symlink credential file, reject empty/malformed/duplicate entries and read failures. Shell/grep/awk versions are unrecorded.", "components": ["webui", "gradio"], "sources": ["webui:s0195caed3cb9", "gradio:s2ec1c2a1d665"], "status": "REASONED"}, "webui-bind": {"text": "Explicit --server-name overrides Gradio environment-based bind; without it GRADIO_SERVER_NAME can replace loopback.", "components": ["webui", "gradio"], "sources": ["webui:s370f032e0497", "webui:saf69abeba189", "gradio:s1bd30a313ec9"], "status": "REASONED"}, "sysinfo": {"text": "Unredacted environment and incomplete argv redaction in unauthenticated sysinfo routes are source-reasoned; proxy must deny the prefix.", "components": ["webui", "gradio"], "sources": ["webui:sebe977aeb6f3", "webui:sc9117bccdef5", "gradio:sf85e6083e02e"], "status": "REASONED"}, "invoke-config": {"text": "Flat schema 4.0.2, host 127.0.0.1, port 9090 and INVOKEAI_HOST/PORT overrides; default single-user mode has no login.", "components": ["invoke-rolling"], "sources": ["invoke-rolling:sb0dc1b679ef2"], "status": "REASONED"}, "invoke-login": {"text": "Experimental multiuser login/JWT and strict password checks; changing the stored JWT secret then restarting logs users out. Ordinary restart logout is not established; keep loopback and TLS.", "components": ["invoke-rolling"], "sources": ["invoke-rolling:s23a1a14da479"], "status": "REASONED"}, "fooocus-flags": {"text": "Loopback by default, bare --listen wildcard, --share public relay; auth.json supplies optional login.", "components": ["fooocus"], "sources": ["fooocus:s38232435e83d", "fooocus:s63ce6f5a8fa9"], "status": "REASONED"}, "fooocus-loader": {"text": "Isolated loader returns credentials only for valid data; unreadable raises PermissionError, malformed/empty returns none.", "components": ["fooocus"], "sources": ["fooocus:s21eb28118a14"], "status": "DEMONSTRATED", "evidence": "for a malformed file it printed `load_auth_data, e: ...` and returned no credentials; for an empty list (`[]`) it also returned none; and for a valid file it returned credentials."}, "fooocus-auth": {"text": "Relative auth.json location, pass/hash SHA-256 handling, permissions and expected fail-open for malformed/empty data; no live server test.", "components": ["fooocus"], "sources": ["fooocus:s2eb7ac87bb1f", "fooocus:s170da4e26268", "fooocus:s21eb28118a14", "fooocus:sfcc14904297e", "fooocus:s2ef62a4cd853"], "status": "REASONED"}, "fooocus-port": {"text": "Fooocus v2.5.5 pins Gradio 3.41.2; inherited port or occupied 7865 can move the UI; explicit --port makes Gradio try only that port.", "components": ["fooocus", "gradio"], "sources": ["fooocus:sca9aed72a579", "fooocus:s456b2a263c88", "gradio:s6f6c16695b71", "gradio:s67ebd44760a7", "fooocus:sc90edaf29ac7"], "status": "REASONED"}, "fooocus-pin": {"text": "entry_with_update updates the checkout; use launch.py from exact v2.5.5, with working-tree caveat.", "components": ["fooocus"], "sources": ["fooocus:s35ce6e657cb8"], "status": "REASONED"}, "container": {"text": "CMDARGS bare --listen plus wildcard publication exposes image; bridge uses internal wildcard and loopback publish, host networking needs loopback bind.", "components": ["fooocus", "engine"], "sources": ["fooocus:s6157fae93872", "fooocus:s5c792b662186", "fooocus:s79380d927e3b", "fooocus:s431336fa6375", "engine:s1e53417c513d"], "status": "REASONED"}, "image-build": {"text": "Build pinned source, not unchecked GHCR default; Compose build/image pull policy and --build determine selected/tagged image.", "components": ["fooocus", "compose", "compose-cli"], "sources": ["fooocus:s79380d927e3b", "fooocus:s431336fa6375", "compose:sfb1cd5e15347", "compose:s2d25c00e22f6", "compose:s16a4e1bb5e9f", "compose-cli:s0e2ca4d9b8c0"], "status": "REASONED"}, "auth-mount": {"text": "Image excludes auth.json; mount read-only, use built image user UID and mode 0400; remapped/rootless ownership is unchecked.", "components": ["fooocus"], "sources": ["fooocus:s6157fae93872", "fooocus:scb18d8c421f5", "fooocus:s21eb28118a14"], "status": "REASONED"}, "argparse": {"text": "Isolated argparse definition yields loopback with no flag and wildcard for bare --listen; this is not a server demonstration.", "components": ["fooocus"], "sources": ["fooocus:s63ce6f5a8fa9"], "status": "DEMONSTRATED", "evidence": "returns `127.0.0.1` with no flag and `0.0.0.0` for a bare `--listen`."}, "verify-network": {"text": "Inventory every service and port, confirm loopback service is up, probe actual IPv4/IPv6; container PORTS or host-network ss have different scope.", "components": ["comfy", "webui", "fooocus", "engine", "curl", "invoke-rolling"], "sources": ["comfy:sbd178d58a098", "webui:s370f032e0497", "invoke-rolling:sb0dc1b679ef2", "fooocus:s63ce6f5a8fa9", "engine:s1e53417c513d", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1, 3]}, "verify-invoke": {"text": "Unauthenticated /api/v1/boards/ should answer in single-user mode and reject with 401 in multi-user mode; exposed status is unrecorded and no listener demonstration was available.", "components": ["invoke-rolling"], "sources": ["invoke-rolling:s23a1a14da479", "invoke-rolling:s4c6175f3800d"], "status": "REASONED", "verify": [2]}, "verify-proxy": {"text": "TLS/header and browser checks should require login before UI; no service/proxy listeners were run.", "components": ["fooocus", "comfy-rolling"], "sources": ["comfy-rolling:s8b2f213483c1", "fooocus:s38232435e83d"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> chat-uis.md ====================================================================== # Self-hosted chat and agent UIs: AnythingLLM, LobeChat, Chainlit, OpenHands AnythingLLM, LobeChat, Chainlit and OpenHands are self-hosted web apps for AI chat and agents. This guide helps your AI assistant configure them securely, including loopback-only ports, LobeChat's registration allowlist, a Chainlit auth callback, and OpenHands reached only through an authenticated tunnel. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/chat-uis.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against AnythingLLM documentation (no recorded version), LobeHub v2.2.16, Docker CLI v27.5.1, Docker Compose v2.32.4, compose-go v2.4.7, Moby v27.5.1, and curl minimum write-out version 7.75.0; 6 further sources carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 30 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These tools hold provider API keys and full conversation history, and several are wide open the moment they start. Bind every one to loopback, front it with TLS and a login ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)/[nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md)), and turn on the tool's own authentication as a second layer, never as a substitute for the network boundary. See also [open-webui.md](https://github.com/jposluns/secureconfig/blob/main/open-webui.md) for the Open WebUI case, [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) for the provider keys these apps store, and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md)/[mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md) for the identity layer in front. ## AnythingLLM Security features apply to the Docker deployment. Single-user mode offers an optional "Password Protect Instance" toggle: once set, anyone with that one password can use the instance, change any setting, and read every chat, so treat it as a screen door, not a real access-control boundary. Multi-user mode is the documented preferred setup: it adds Admin (full access including logs and analytics), Manager (all workspaces, no LLM/embedder/vector-database settings), and Default (only explicitly assigned workspaces) roles, each requiring its own login. Multi-user mode cannot be reverted to single-user once enabled, so decide before turning it on. ```bash docker run -d -p 127.0.0.1:3001:3001 mintplexlabs/anythingllm ``` Keep it on loopback regardless of which mode you choose, and put the proxy's own TLS and login in front. ## LobeChat `KEY_VAULTS_SECRET` is the key that encrypts stored provider credentials (AES-GCM); generate it with `openssl rand -base64 32`, and once set, never change it, or previously encrypted data becomes unreadable. LobeHub's own basic-variables page describes it loosely as "a password to access the LobeHub service", but its own warning on the same entry says the key is used to encrypt sensitive data: treat it as the encryption key, not the deployment's login gate. Real per-user login comes from LobeChat's Better Auth service: `AUTH_SECRET` (required, generated the same way) signs sessions, and `AUTH_SSO_PROVIDERS` lists enabled SSO providers (for example `google,github,microsoft`) alongside the matching provider credentials each one needs (for example `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET`). LobeChat's Better Auth service also takes `AUTH_DISABLE_EMAIL_PASSWORD=1`, which forces SSO-only login, hiding the password form entirely. `AUTH_ALLOWED_EMAILS` restricts registration to specific addresses or domains, but it defaults to empty, which allows every email through, so set it explicitly rather than relying on federation alone to gate access. Keep the three secrets, `KEY_VAULTS_SECRET`, `AUTH_SECRET` and the Google OAuth client secret, off the `docker run` command line: a value given as `-e NAME=value` is in the docker CLI's argv, where `ps` and `/proc//cmdline` show it to other local accounts while that command runs, and quoting does not change that. At docker/cli v27.5.1, `--env-file` reads environment variables from a file, through a loader that opens the named path and has no `-` stdin form, and `-e NAME` with no value takes the value from the CLI's own environment. Docker Compose's `env_file` reads a file of variables for a service through its own loader in compose-go (v2.4.7, the version Docker Compose v2.32.4 pins). A Compose `secrets` entry puts a value in a file under `/run/secrets/` instead: a file-sourced secret is bind-mounted read-only, and an environment-sourced one is copied into the container, mode `0444` by default, rather than mounted. That only helps if something reads the file into these variables, and LobeHub documents them as environment variables only; a search of its v2.2.16 tree finds no `KEY_VAULTS_SECRET_FILE`, `AUTH_SECRET_FILE` or `AUTH_GOOGLE_SECRET_FILE`. Use the `--env-file` file: `KEY_VAULTS_SECRET` must stay the same for the life of the data it encrypts, so it needs a durable home anyway, and a file keeps the values out of the CLI's own environment too. Create it once, as the account that runs `docker`. The block prompts for the Google client secret without echo, under CONTRIBUTING rule 7's `read` exception, and assumes a clean shell: ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -eC umask 077 if [ -e "$HOME/.config/lobechat/secrets.env" ] || [ -L "$HOME/.config/lobechat/secrets.env" ]; then echo 'a secrets.env already exists in ~/.config/lobechat; nothing written'; exit 2 fi { unset -n gsec && unset -v gsec; } 2>/dev/null || { echo 'cannot clear gsec in this shell; nothing written'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; nothing written'; exit 2; } printf 'Google OAuth client secret (input hidden): ' IFS= read -r -s gsec || { printf '\n'; echo 'no client secret read; nothing written'; exit 2; } printf '\n' case "$gsec" in ""|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty client secret, placeholder or control character; nothing written'; exit 2 ;; esac set -- "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" [ "${#1}" -eq 44 ] || { echo 'key generation failed; nothing written'; exit 2; } [ "${#2}" -eq 44 ] || { echo 'key generation failed; nothing written'; exit 2; } case "$1$2" in *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=]*) echo 'key generation failed; nothing written'; exit 2 ;; esac mkdir -p -- "$HOME/.config/lobechat" chmod 700 -- "$HOME/.config/lobechat" command -v getfacl >/dev/null || { echo 'getfacl not found, so a default ACL on ~/.config/lobechat cannot be ruled out; install the acl package; nothing written'; exit 2; } case "$(getfacl -d -c -p -- "$HOME/.config/lobechat"; echo "getfacl-status=$?")" in getfacl-status=0) ;; *getfacl-status=0) echo 'the directory ~/.config/lobechat has a default ACL, which a new file inherits in place of umask 077; nothing written'; exit 2 ;; *) echo 'getfacl could not read the ACL of ~/.config/lobechat; nothing written'; exit 2 ;; esac printf 'KEY_VAULTS_SECRET=%s\nAUTH_SECRET=%s\nAUTH_GOOGLE_SECRET=%s\n' "$1" "$2" "$gsec" > "$HOME/.config/lobechat/secrets.env" case "$(ls -ln -- "$HOME/.config/lobechat/secrets.env")" in '-rw------- '*|'-rw-------.'*) ;; *) echo 'secrets.env is not mode 0600, so other accounts may read it; delete ~/.config/lobechat/secrets.env, do not use the values in it, and rotate the Google client secret'; exit 2 ;; esac ) ``` The block refuses to run when `~/.config/lobechat/secrets.env` already exists in any form, a dangling symlink included, before it prompts or generates anything, so a rerun cannot replace a `KEY_VAULTS_SECRET` that already encrypts data. `set -C` adds overwrite protection for an existing regular file only, not for every kind of target, and the existence check runs before the write rather than atomically with it, so both hold only in directories that no other account can write to or replace: `$HOME`, `~/.config` and the owner-only directory the block creates. For a deployment that already has these values, put its existing values in such a file instead of generating new ones. `umask 077` creates the directory mode `0700` and the file mode `0600`, unless the directory, or the parent it is created in, carries a default ACL: new files inherit that ACL in place of the umask, and `chmod 700` on the directory does not remove it. So once it has created the directory, which is after the prompt, the block runs `getfacl -d -c -p` on it, which prints the directory's default ACL and nothing else, and writes nothing when that prints anything, when `getfacl` fails, or when `getfacl` is not installed, leaving the directory behind; remove a default ACL you do not need with `setfacl -k ~/.config/lobechat` and run the block again. A missing `getfacl` fails closed because the block then cannot tell, before the write, whether the file would be private; `getfacl` comes from the `acl` package, and where it is not installed the block refuses to run. After the write the block reads the file's permission field from `ls -ln` and fails unless it is exactly `-rw-------` followed by a space or a `.`; any other field fails the check, including the `+`-marked field a named ACL entry produced in the recorded runs. If the check fails, delete the file, do not use the values in it, and rotate the Google client secret. Without these checks, the block wrote `secrets.env` mode `0644` when `~/.config` carried a default ACL (run in a scratch home directory with bash 5.3.9, acl 2.3.2 and `openssl` stubbed). The block writes nothing unless both generated values are 44 base64 characters and the prompted secret is non-empty, is not the placeholder and holds no control character. `read` stops at the first newline, so a newline cannot enter the value; the check refuses a carriage return or any other control character. Paste the secret alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. The generated values pass only through the subshell's positional parameters and the builtin `printf`, and the client secret through one variable local to the subshell, never a command line; the block clears inherited traps first because a DEBUG, RETURN or ERR trap from your shell could otherwise read them. A write failure can leave a partial file. The launch block below refuses the shapes it can detect, a generated value that is not 44 base64 characters, a missing or duplicated line, or any other line, but a client secret cut short still looks like a valid value and cannot be detected, so delete a file left by a failed run and create it again rather than starting on it. The file holds the secrets in plaintext at rest, readable by that account, by root and by any backup that copies it, so keep it and its backups out of source control ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Start LobeChat from the file, with only non-secret values on its command line. Substitute your Google OAuth client ID and the addresses or domains allowed to register inside the single quotes on the `set --` line: ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID' 'admin@example.com,example.com' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not starting'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs exactly 2 values; not starting'; exit 2; } case "$1|$2" in *REPLACE_WITH_*) echo 'substitute the client ID inside the quotes on the set -- line; not starting'; exit 2 ;; esac { [ -n "$1" ] && [ -n "$2" ]; } || { echo 'empty value on the set -- line; not starting'; exit 2; } case "$1$2" in *[[:space:][:cntrl:]]*) echo 'whitespace or a control character on the set -- line; not starting'; exit 2 ;; esac f="$HOME/.config/lobechat/secrets.env" { [ -f "$f" ] && [ ! -L "$f" ]; } || { echo 'need ~/.config/lobechat/secrets.env to be a regular file (it is missing, a symlink or another kind of file); not starting'; exit 2; } if grep -Eavqx -- 'KEY_VAULTS_SECRET=[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/]{43}=|AUTH_SECRET=[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/]{43}=|AUTH_GOOGLE_SECRET=[^[:cntrl:]]+' "$f"; then echo 'a line in ~/.config/lobechat/secrets.env is not one of the three secret lines; not starting'; exit 2 else rc=$?; [ "$rc" -eq 1 ] || { echo 'could not check ~/.config/lobechat/secrets.env; not starting'; exit 2; } fi if grep -aq -- 'REPLACE_WITH_' "$f"; then echo 'a placeholder is still in ~/.config/lobechat/secrets.env; not starting'; exit 2 else rc=$?; [ "$rc" -eq 1 ] || { echo 'could not check ~/.config/lobechat/secrets.env; not starting'; exit 2; } fi c=$(grep -Eacx -- 'KEY_VAULTS_SECRET=[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/]{43}=' "$f") || c= [ "$c" = 1 ] || { echo 'need exactly one generated KEY_VAULTS_SECRET line in ~/.config/lobechat/secrets.env, or could not check it; not starting'; exit 2; } c=$(grep -Eacx -- 'AUTH_SECRET=[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/]{43}=' "$f") || c= [ "$c" = 1 ] || { echo 'need exactly one generated AUTH_SECRET line in ~/.config/lobechat/secrets.env, or could not check it; not starting'; exit 2; } c=$(grep -Eacx -- 'AUTH_GOOGLE_SECRET=[^[:cntrl:]]+' "$f") || c= [ "$c" = 1 ] || { echo 'need exactly one AUTH_GOOGLE_SECRET line in ~/.config/lobechat/secrets.env, or could not check it; not starting'; exit 2; } docker run -d -p 127.0.0.1:3210:3210 \ --env-file "$HOME/.config/lobechat/secrets.env" \ -e AUTH_DISABLE_EMAIL_PASSWORD=1 \ -e AUTH_SSO_PROVIDERS=google \ -e "AUTH_GOOGLE_ID=$1" \ -e "AUTH_ALLOWED_EMAILS=$2" \ lobehub/lobe-chat ) ``` The block refuses the placeholder, an empty value, and whitespace or a control character on the `set --` line. It checks first that `~/.config/lobechat/secrets.env` is a regular file and not a symlink, so a FIFO in its place can neither block the check nor be read by it. It then reads the file as text (`grep -a`: without `-a`, a NUL could make a malformed line look valid to grep) and refuses to start unless the file holds exactly one line for each generated value and exactly one client secret line, no other line and no placeholder. The block makes that check because at v27.5.1 an env-file line holding only a name takes its value from the CLI's own environment, which would bring back the channel the file replaces. It uses a count only from a `grep` that succeeded, says that it could not check when `grep` cannot read the file, and gives the same result in a shell that has `set -e` on. The patterns for the two generated values spell out their ASCII character sets rather than ranges such as `A-Z`, whose meaning can depend on the locale. The client secret pattern, `[^[:cntrl:]]+`, is the exception and depends on the locale: it refuses only control characters, and the locale decides which characters are control characters, as it does for the other `[:cntrl:]` checks, in the block that creates the file and on the `set --` line. A key of another shape from an existing deployment needs the matching pattern relaxed. This moves the secrets out of argv, not out of reach. Docker hands them to the container as environment variables, so anyone who can use the Docker socket can read them back with `docker inspect`, which returns the container's configuration with its `Env` list, for as long as the container exists, running or stopped; and the same user as the container's process, or root, can read them from that process's `/proc//environ` for its whole lifetime. Socket access is root-equivalent already ([container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md)); grant it to nobody you would not trust with these keys. `-e NAME` with no value would keep them out of argv as well, but only under rule 7's guarded one-command prefix assignment, never an `export`, and the values would then sit in the CLI's own environment too. Neither form erases a value already recorded in shell history, tracing or a log. MFA: enforce it at whichever SSO provider you list in `AUTH_SSO_PROVIDERS`; LobeChat's own login has no second factor of its own. ## Chainlit Chainlit applications are public by default: no login, no gate, anyone who reaches the port gets the chat. Set `CHAINLIT_AUTH_SECRET` (generate one with `chainlit create-secret`; changing it logs out every user) and implement at least one auth callback: password authentication, OAuth, or header-based authentication. A callback that returns `None` refuses the login. There is no built-in MFA; put it behind an identity provider that enforces a second factor, or an [Authelia](https://www.authelia.com/)-fronted proxy. ```python import hmac import os from typing import Optional import chainlit as cl # Read the credential from the environment, never from source. Chainlit's own example # compares a literal "admin" against a literal "admin", which is a demonstration rather # than a deployment. These two names are this application's own, not Chainlit's. # Indexing os.environ raises KeyError if either is unset, which stops the process before # the callback is ever registered; the explicit test is for a variable that is SET and # empty, which indexing accepts and which would otherwise be a usable password. EXPECTED_USER = os.environ["CHAINLIT_USER"].encode() EXPECTED_PASSWORD = os.environ["CHAINLIT_PASSWORD"].encode() if not EXPECTED_USER or not EXPECTED_PASSWORD: raise SystemExit("CHAINLIT_USER and CHAINLIT_PASSWORD must both be set and non-empty") @cl.password_auth_callback def auth_callback(username: str, password: str) -> Optional[cl.User]: # compare_digest avoids content-based short-circuiting, so it does not leak how much of # the password was right; Python notes it can still reveal "the types and lengths of a # and b, but not their values". Both comparisons run before the `and` so that a short # circuit there cannot reveal which half failed. The callback as a whole is not constant # time and does not need to be; the point is that the secret is not compared with ==. ok_user = hmac.compare_digest(username.encode(), EXPECTED_USER) ok_password = hmac.compare_digest(password.encode(), EXPECTED_PASSWORD) if ok_user and ok_password: return cl.User(identifier=username) return None ``` That is one shared credential, which is a gate on the deployment rather than user accounts: everyone who gets in is the same `identifier`, and there is nobody to revoke individually. Rotating it is two steps, not one. Changing the environment variable does nothing until the process restarts, because the value above is read once at import. And a new password stops future logins without ending current ones: Chainlit issues a session token signed with `CHAINLIT_AUTH_SECRET`, and that secret, not this password, is what invalidates the sessions already handed out. Change both when you mean to lock everyone out. For real accounts, this callback is one of the options rather than the only one. Chainlit also supports OAuth and header-based authentication, which move the decision to an identity provider and are usually the better answer ([identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md), [oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md)). If you do manage passwords yourself, Chainlit persists user records but provides no password-account management, so the store and the verification are yours: keep a per-user hash from a slow algorithm, Argon2id for anything new, and call it inside this callback in place of the comparison above. The vendor's own advice is the short version of this, "hash password before storing them". A shared service credential still belongs in the environment ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)); a per-user password hash belongs in the account store. ## OpenHands OpenHands is built for a single user on their own workstation: the project's own FAQ states there is no built-in authentication, isolation, or scalability for shared use, and the open-source build authorizes API access with one shared key rather than per-user identity. It also executes agent-generated code against your workspace, with the isolation depending entirely on your deployment choice (a local process backend runs with your user's permissions; container backends can be weakened by broad mounts, privileged mode, or Docker-socket access). Do not expose it to anyone you would not hand a shell to. The project documents a Hardened Docker Installation guide for deployments that must sit on a shared network; multi-tenant use is an enterprise offering, not something the open-source build supports. The documented quickstart itself runs `docker run ... -p 3000:3000 ... openhands/openhands`, which maps every interface, not loopback; change that to `-p 127.0.0.1:3000:3000` before anything else. Reach it only through an authenticated tunnel; there is no login screen to add in front of it, so identity has to come entirely from the proxy or tunnel layer. ## Verify REASONED: the cited UI binding references support this host-side inventory, which is prose rather than part of the block below, because it runs on the UI's host and the block runs from another host. On the UI's host, run `ss -tlnp` and read every listener: 3001/3210/3000, each UI on 127.0.0.1 only. The authoring host lacks an isolated network namespace for authorized live listeners, so no listing is recorded. REASONED: following block; isolation comparison, listener and external-reachability checks, API authentication, proxy TLS/login, LobeChat registration and multi-user isolation follow the cited UI binding/authentication references, LobeChat AUTH_ALLOWED_EMAILS source, AnythingLLM workspace roles and linked pgvector guidance. No live UI/RAG deployment, external test path or paired identities are available, and the authoring host lacks an isolated network namespace for authorized live listeners. The anonymous API probe's flags were run locally with curl 8.18.0 against loopback stub listeners, not a UI, and a closed loopback port: the old `-s` form printed only the response body for a 401 and for a 200, and nothing for the closed port (exit 7), while the `-sS -o /dev/null -w '%{http_code}\n'` form printed `401`, `200`, and `000` with curl's connection error. That `000` is the value those runs observed, not a cited one: the installed curl 8.18.0 manual describes `http_code` only as "The numerical response code that was found in the last retrieved HTTP(S) or FTP(s) transfer." The proxy probe uses `-sSI` rather than `-sI` because the manual says `-s` shows no error messages and `-S` with it shows an error message if curl fails, so a TLS, DNS or connection failure prints curl's error instead of nothing. Run locally with curl 8.18.0 against a closed loopback port, the `-sI` form exited 7 and printed nothing, and the `-sSI` form exited 7 and printed curl's connection error. Those curl 8.18.0 runs covered the earlier form of the two probes, before the probes took the chat host on a guarded `set --` line and gained `-g`; that guarded form, which refuses example.com, example.net, example.org and their subdomains, was not run. The current form leaves the listener inventory to the section's first paragraph and gives the whole block one exit status: 1 if port 3210 answers with an HTTP code, 3 if a request fails or port 3210 gives neither an HTTP code nor a refusal or timeout, 2 if a guard refuses and nothing was found or failed, and otherwise 0. A refusal or timeout on port 3210, an API answer other than 401, the proxy's headers and the LobeChat registration check are left for you to judge, and the block's last line names them. Those 2026-10-31 checks, with bash -n and with curl stubs and no network, covered the earlier form of the block, in which the second part lowercased the host with `tr` before refusing a reserved name; they covered each status, an earlier finding or failure followed by passing later checks, and an inherited `readonly`, `declare -i` or `set -e`. The stub runs covered the refusals of a missing marker, too few or too many values, and an empty or placeholder value in each part, and in the second part of a host name with a scheme, port, trailing dot, `@`, `?`, `%`, `#` or space and of an example.com, example.net or example.org host including `App.Example.COM`; each refused part made no curl call. ShellCheck 0.11.0 passed on that form in the repository's gate suite. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and its second part refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by curl-stub runs on 2026-11-01 with no network. In bash, that part refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probe. Under dash, that part refused every host name with 2 and the bash message before its probe, and the whole block exited 3, because dash reads the first part's `$'\n'` differently and that part reported an inconclusive result. For a multi-user chat or RAG deployment (AnythingLLM workspaces, or a shared assistant over shared documents), verify isolation at the data layer, not only at login. First, as user A, ask a question whose answer lives only in user A's documents or past conversations and confirm A's own document supplies the answer, the positive control that retrieval works at all; then ask the same question as user B and confirm B gets nothing of A's. A retrieval step that searches every user's embeddings without enforcing per-document access hands one user's content into another's session even though each logged in separately; scope retrieval to the requesting user's own documents or workspace, and where the store is pgvector the tenant row-level-security check in [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) is the store-side half. A retrieval filter alone does not isolate conversation history, memory, or a shared cache; check each of those the same way. This isolation check is REASONED, not demonstrated: the authoring environment has no live multi-user RAG deployment. Its expected isolation rests on the cited AnythingLLM workspace-role documentation and the pgvector row-level-security guidance linked above. ```bash # Run this block from another host. Read err, not the number: it must name a refusal or timeout reaching YOUR # address. An HTTP code means the port answered. A resolver failure, a local socket error, or a # timeout that did not come from the remote address is inconclusive, never a pass. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -s -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 20 -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:3210/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # curl's exit status, then its http= exit= err= line printf '%s\n' "$3" case "$2:$3" in 7:*"http=000 "*|28:*"http=000 "*) echo "unclassified: read err above; a refusal or timeout reaching YOUR address is the pass, and any other cause is inconclusive"; exit 0 ;; *"http=000 "*|*:) echo "curl exit $2 with no HTTP code and no refusal or timeout; inconclusive"; exit 3 ;; *"http="*) echo "FINDING: port 3210 answered with an HTTP code, so it is reachable from this host"; exit 1 ;; *) echo "curl exit $2 printed no http= line; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_CHAT_HOST' # the public host name your proxy serves the chat UI on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is this part's status so far: 3 (inconclusive) outranks 0 set -- "$1" "$2" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' "https://$1/api/some-endpoint"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # curl's exit status, then the HTTP code printf '%s\n' "$4" # without a key/token: 401; 000 and curl's error mean no answer, never a pass case "$3:$4" in 0:401) ;; 0:*) echo "unclassified: HTTP $4 is not the 401 expected without a key or token, so read it against an actual protected route of your deployment" ;; *) echo "curl exit $3: the API request got no answer, never a pass; inconclusive"; set -- "$1" 3 ;; esac if curl -q -g -sSI "https://$1/"; then :; else # via the proxy: TLS, login required echo "curl exit $?: the request through the proxy failed; inconclusive"; set -- "$1" 3 fi exit "$2" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # LobeChat SSO: attempt to register/sign in with a Google account that has never registered and is # NOT listed in AUTH_ALLOWED_EMAILS; expect rejection at registration, before any account or session # is created (AUTH_ALLOWED_EMAILS gates new registration; it does not revoke an already-registered # user's existing session) echo "block exit $1; a refusal or timeout on port 3210, an API answer other than 401, the proxy's TLS and login headers and the LobeChat registration check are not classified, so judge them yourself" exit "$1" ) ``` ## Common mistakes - Leaving Chainlit unauthenticated because "it's just for testing"; public by default means public the moment it is reachable. - Treating AnythingLLM's single instance password as equivalent to per-user accounts; it grants full admin to whoever has it. - Running OpenHands with a shared, long-lived API key exposed on the same network as untrusted users. - Rotating LobeChat's `KEY_VAULTS_SECRET` after data has been encrypted with it, which makes that data unreadable. - Passing `KEY_VAULTS_SECRET`, `AUTH_SECRET` or `AUTH_GOOGLE_SECRET` as `-e NAME=value`, which puts the value in the docker CLI's argv for other local accounts to read while the command runs. ## Sources (checked September 2026) - AnythingLLM security and access documentation: https://docs.anythingllm.com/features/security-and-access - LobeHub environment variables (KEY_VAULTS_SECRET; pinned tag v2.2.16): https://github.com/lobehub/lobehub/blob/v2.2.16/docs/self-hosting/environment-variables/basic.mdx#L22-L32 - LobeHub authentication service environment variables (Better Auth; rolling documentation, checked September 2026): https://lobehub.com/only-ai/markdown/docs/en/self-hosting/environment-variables/auth - LobeHub `KEY_VAULTS_SECRET` callout, "This key is used to encrypt sensitive data." (pinned tag v2.2.16): https://github.com/lobehub/lobehub/blob/v2.2.16/docs/self-hosting/environment-variables/basic.mdx#L22-L32 - LobeHub `AUTH_SECRET` generated with `openssl rand -base64 32`, and `AUTH_GOOGLE_SECRET` as the "Client Secret of the Google OAuth application." (pinned tag v2.2.16): https://github.com/lobehub/lobehub/blob/v2.2.16/docs/self-hosting/environment-variables/auth.mdx - Docker `container run` reference, `--env-file` ("Read in a file of environment variables"; pinned tag v27.5.1): https://github.com/docker/cli/blob/v27.5.1/docs/reference/commandline/container_run.md - Docker `container run` reference, `--env VAR` with no value, which "checks the value the variable has in your local environment and passes it to the container" (pinned tag v27.5.1): https://github.com/docker/cli/blob/v27.5.1/docs/reference/commandline/container_run.md#L626-L640 and the `-e` validator's `os.LookupEnv`: https://github.com/docker/cli/blob/v27.5.1/opts/env.go#L18-L31 - Docker CLI env-file loading through `kvfile.Parse`, with `-e` parsed after the file "to allow override" (pinned tag v27.5.1): https://github.com/docker/cli/blob/v27.5.1/opts/parse.go#L19-L39 - Docker CLI key/value file parser, which opens the named path (`os.Open(filename)`) with no `-` stdin form, and looks up a line that holds only a name in the CLI's environment (pinned tag v27.5.1): https://github.com/docker/cli/blob/v27.5.1/pkg/kvfile/kvfile.go#L54-L69 and https://github.com/docker/cli/blob/v27.5.1/pkg/kvfile/kvfile.go#L121-L126 - Docker Compose pins compose-go v2.4.7 (pinned tag v2.32.4): https://github.com/docker/compose/blob/v2.32.4/go.mod#L5-L18 - compose-go service `env_file` resolution ("parses env_files set for services to resolve the actual environment map for services"; pinned tag v2.4.7): https://github.com/compose-spec/compose-go/blob/v2.4.7/types/project.go#L631-L664 - Docker Compose file-sourced secrets bind-mounted read-only under `/run/secrets/`, environment-sourced ones skipped there (pinned tag v2.32.4): https://github.com/docker/compose/blob/v2.32.4/pkg/compose/create.go#L1029-L1084 - Docker Compose environment-sourced secrets copied into the container under `/run/secrets/`, mode `0444` by default (pinned tag v2.32.4): https://github.com/docker/compose/blob/v2.32.4/pkg/compose/secrets.go#L31-L61 and https://github.com/docker/compose/blob/v2.32.4/pkg/compose/secrets.go#L101 - Moby container inspect returns `Config: ctr.Config`, whose `Env []string` holds the environment (pinned tag v27.5.1): https://github.com/moby/moby/blob/v27.5.1/daemon/inspect.go#L107-L110 and https://github.com/moby/moby/blob/v27.5.1/api/types/container/config.go#L55 - Chainlit authentication overview: https://docs.chainlit.io/authentication/overview - Chainlit password authentication (`@cl.password_auth_callback` signature and example): https://docs.chainlit.io/authentication/password - Python `hmac.compare_digest`, for what it does and does not conceal: https://docs.python.org/3/library/hmac.html - Python `os.environ`, for what indexing a missing key does: https://docs.python.org/3/library/os.html - OWASP Password Storage Cheat Sheet, for Argon2id over bcrypt in anything new: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - OpenHands FAQs (single-user design, no built-in auth, sandboxing, hardened deployment): https://docs.openhands.dev/overview/faqs - OpenHands local setup (default docker port mapping): https://docs.openhands.dev/openhands/usage/run-openhands/local-setup - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0; `-s`/`--silent`, which shows no progress meter or error messages; `-S`/`--show-error`, which with `--silent` shows an error message if curl fails; `-o /dev/null` to suppress the response body). The curl manual is also cited for `-I`/`--head`, which fetches the headers only with a HEAD request, and the `http_code` write-out variable, the numerical response code found in the last retrieved HTTP(S) transfer. This Sources item's wording was checked against the installed curl 8.18.0 manual, which does not say what `http_code` prints when no response arrives. The curl manual source: https://curl.se/docs/manpage.html - Linux ACLs, acl(5): default ACL inheritance and how a default ACL takes the place of the umask for new files (man-pages; checked against the local acl 2.3.2 manual, October 2026): https://man7.org/linux/man-pages/man5/acl.5.html - getfacl(1): `-d` (default ACL), `-c` (omit the header) and `-p` (keep leading `/`) options (man-pages; checked against the local acl 2.3.2 manual, October 2026): https://man7.org/linux/man-pages/man1/getfacl.1.html ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - anything: AnythingLLM documentation (unknown) - lobe: LobeHub (v2.2.16) - lobe-docs: LobeHub documentation (rolling) (unknown) - chainlit: Chainlit documentation (unknown) - openhands: OpenHands documentation (unknown) - docker: Docker CLI (v27.5.1) - compose: Docker Compose (v2.32.4) - compose-go: compose-go (v2.4.7) - moby: Moby (v27.5.1) - python: Python documentation (unknown) - owasp: OWASP password guidance (unknown) - curl: curl minimum write-out version (7.75.0) - linux: Linux permissions and tools (unknown) Claims (full source mapping in the JSON block below): - anything-single (REASONED; anything): Docker single-user password protection is optional and grants every holder settings/admin/chat access, not per-user isolation. - anything-roles (REASONED; anything): Multi-user mode is preferred: Admin has full access, Manager all workspaces without provider/store settings, Default only assigned workspaces. - anything-mode (REASONED; anything): Enabling multi-user mode cannot be reverted to single-user; choose deliberately. - anything-bind (REASONED; anything, docker): Publish AnythingLLM only on loopback 3001 and put proxy TLS/login in front. - lobe-vault (REASONED; lobe): KEY_VAULTS_SECRET encrypts stored provider credentials; generate with openssl rand -base64 32 and preserve it for the data lifetime, not as a login password. - lobe-session (REASONED; lobe, lobe-docs): Better Auth requires AUTH_SECRET, generated the same way, to sign sessions. - lobe-sso (REASONED; lobe, lobe-docs): AUTH_SSO_PROVIDERS selects providers and matching credentials; AUTH_DISABLE_EMAIL_PASSWORD=1 forces SSO-only login. - lobe-admission (REASONED; lobe, lobe-docs): AUTH_ALLOWED_EMAILS defaults empty, permitting all emails; set explicit addresses/domains for registration. - lobe-mfa (REASONED; lobe-docs): Enforce MFA at the configured SSO provider; LobeChat login has no second factor of its own. - docker-input (REASONED; docker): Docker --env-file reads a named file, not stdin; -e NAME uses CLI environment, while -e NAME=value exposes argv. - docker-envfile (REASONED; docker): Bare names in env files import CLI environment; validate exactly the intended secret assignments and reject extra/missing/duplicate lines. - compose-envfile (REASONED; compose, compose-go): Compose env_file resolves environment via compose-go v2.4.7, pinned by Compose v2.32.4. - compose-secrets (REASONED; compose): Compose file secrets mount read-only under /run/secrets; environment secrets are copied there, default 0444, and need an application reader. - lobe-file-input (REASONED; lobe): LobeHub documents environment inputs; the recorded v2.2.16 search found no KEY_VAULTS_SECRET_FILE, AUTH_SECRET_FILE or AUTH_GOOGLE_SECRET_FILE. - lobe-file (REASONED; lobe, docker, linux): Create the durable plaintext env file once in protected directories; never replace a live encryption key, refuse a default ACL or missing getfacl, check mode 0600 after the write and discard partial failed writes. - lobe-launch (REASONED; lobe, docker): Use validated env-file with loopback 3210 publication and non-secret SSO/admission settings on argv. - container-secrets (REASONED; moby, docker): Environment values remain readable through Docker inspect for running/stopped containers and process environ during lifetime; env-file avoids CLI argv, not operator access. - chainlit-default (REASONED; chainlit): Chainlit applications are public by default; set CHAINLIT_AUTH_SECRET and at least one password/OAuth/header callback. - chainlit-callback (REASONED; chainlit): Password callback returns a user on success and None on refusal; shared credential creates one identity without individual revocation. - chainlit-environment (REASONED; python, chainlit): Example reads credentials once at import, fails on missing or empty values, and requires restart to adopt replacements. - chainlit-comparison (REASONED; python): Both compare_digest calls run before combining results; content-based short-circuiting is avoided, but types/lengths and whole callback timing are not concealed. - chainlit-rotation (REASONED; chainlit): Password replacement stops new logins, not existing signed sessions; rotate CHAINLIT_AUTH_SECRET too to invalidate them. - chainlit-accounts (REASONED; chainlit, owasp): Chainlit persists users but supplies no password-account management; per-user store/verification are the application's responsibility, with Argon2id for new hashes. - chainlit-mfa (REASONED; chainlit): Chainlit has no built-in MFA; use an enforcing identity provider or authenticating proxy. - openhands-boundary (REASONED; openhands): OpenHands is single-user; shared-key API access is not per-user identity, and execution isolation depends on backend/mount/socket choices. - openhands-bind (REASONED; openhands): Quickstart publishes 3000 on every interface; use 127.0.0.1:3000:3000 and an authenticated tunnel. - verify-listeners (REASONED; docker, openhands, curl): Inventory loopback UI listeners 3001/3210/3000; external refusal must name the real remote path, with DNS/local failures inconclusive. - verify-login (REASONED; anything, chainlit, openhands, lobe-docs, curl): Verify proxy HTTPS and login requirement; generic API refusal is a deployment-specific check requiring an actual protected route. - verify-registration (REASONED; lobe, lobe-docs): A new Google identity absent from AUTH_ALLOWED_EMAILS should fail registration; this does not revoke an existing user's session. - verify-isolation (REASONED; anything): User A's retrieval must succeed before user B is checked for denial; independently test documents, history, memory and caches, not login alone. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "019458c1748a61779dcc36ff48589cb4403b6de793605f895f7662b02588c8e6", "components": { "anything": { "name": "AnythingLLM documentation", "basis": "unknown", "sources": { "s6b5aa8c91ad3": "https://docs.anythingllm.com/features/security-and-access" } }, "lobe": { "name": "LobeHub", "basis": "v2.2.16", "sources": { "s977fd003b436": "https://github.com/lobehub/lobehub/blob/v2.2.16/docs/self-hosting/environment-variables/basic.mdx#L22-L32", "sdb87ee6e14a5": "https://github.com/lobehub/lobehub/blob/v2.2.16/docs/self-hosting/environment-variables/auth.mdx" } }, "lobe-docs": { "name": "LobeHub documentation (rolling)", "basis": "unknown", "sources": { "s84e88654e760": "https://lobehub.com/only-ai/markdown/docs/en/self-hosting/environment-variables/auth" } }, "chainlit": { "name": "Chainlit documentation", "basis": "unknown", "sources": { "sa8ffef5a2afe": "https://docs.chainlit.io/authentication/overview", "sfc1842532120": "https://docs.chainlit.io/authentication/password" } }, "openhands": { "name": "OpenHands documentation", "basis": "unknown", "sources": { "s1756d23e1d0b": "https://docs.openhands.dev/overview/faqs", "s0ceb47353632": "https://docs.openhands.dev/openhands/usage/run-openhands/local-setup" } }, "docker": { "name": "Docker CLI", "basis": "v27.5.1", "sources": { "s52c162a9786f": "https://github.com/docker/cli/blob/v27.5.1/docs/reference/commandline/container_run.md", "s8c2adc7212d7": "https://github.com/docker/cli/blob/v27.5.1/docs/reference/commandline/container_run.md#L626-L640", "s2611a6021e2f": "https://github.com/docker/cli/blob/v27.5.1/opts/env.go#L18-L31", "s9a4c6197f246": "https://github.com/docker/cli/blob/v27.5.1/pkg/kvfile/kvfile.go#L54-L69", "sdc1935b6999f": "https://github.com/docker/cli/blob/v27.5.1/opts/parse.go#L19-L39", "sd1025af9c6ff": "https://github.com/docker/cli/blob/v27.5.1/pkg/kvfile/kvfile.go#L121-L126" } }, "compose": { "name": "Docker Compose", "basis": "v2.32.4", "sources": { "s14c0c0a70b7e": "https://github.com/docker/compose/blob/v2.32.4/go.mod#L5-L18", "se594167796f7": "https://github.com/docker/compose/blob/v2.32.4/pkg/compose/create.go#L1029-L1084", "sf6263e4f21f0": "https://github.com/docker/compose/blob/v2.32.4/pkg/compose/secrets.go#L101", "s0d8af80827c8": "https://github.com/docker/compose/blob/v2.32.4/pkg/compose/secrets.go#L31-L61" } }, "compose-go": { "name": "compose-go", "basis": "v2.4.7", "sources": { "s25bb7bb5626f": "https://github.com/compose-spec/compose-go/blob/v2.4.7/types/project.go#L631-L664" } }, "moby": { "name": "Moby", "basis": "v27.5.1", "sources": { "s638ae9c795a2": "https://github.com/moby/moby/blob/v27.5.1/api/types/container/config.go#L55", "sa3cf5ca8ad4d": "https://github.com/moby/moby/blob/v27.5.1/daemon/inspect.go#L107-L110" } }, "python": { "name": "Python documentation", "basis": "unknown", "sources": { "se86233c0ce69": "https://docs.python.org/3/library/os.html", "s7a54556e5dac": "https://docs.python.org/3/library/hmac.html" } }, "owasp": { "name": "OWASP password guidance", "basis": "unknown", "sources": { "s49e8d76431d9": "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "linux": { "name": "Linux permissions and tools", "basis": "unknown", "sources": { "s31f41c54750c": "https://man7.org/linux/man-pages/man5/acl.5.html", "s08902025e5f9": "https://man7.org/linux/man-pages/man1/getfacl.1.html" } } }, "claims": { "anything-single": {"text": "Docker single-user password protection is optional and grants every holder settings/admin/chat access, not per-user isolation.", "components": ["anything"], "sources": ["anything:s6b5aa8c91ad3"], "status": "REASONED"}, "anything-roles": {"text": "Multi-user mode is preferred: Admin has full access, Manager all workspaces without provider/store settings, Default only assigned workspaces.", "components": ["anything"], "sources": ["anything:s6b5aa8c91ad3"], "status": "REASONED"}, "anything-mode": {"text": "Enabling multi-user mode cannot be reverted to single-user; choose deliberately.", "components": ["anything"], "sources": ["anything:s6b5aa8c91ad3"], "status": "REASONED"}, "anything-bind": {"text": "Publish AnythingLLM only on loopback 3001 and put proxy TLS/login in front.", "components": ["anything", "docker"], "sources": ["anything:s6b5aa8c91ad3", "docker:s52c162a9786f"], "status": "REASONED"}, "lobe-vault": {"text": "KEY_VAULTS_SECRET encrypts stored provider credentials; generate with openssl rand -base64 32 and preserve it for the data lifetime, not as a login password.", "components": ["lobe"], "sources": ["lobe:s977fd003b436"], "status": "REASONED"}, "lobe-session": {"text": "Better Auth requires AUTH_SECRET, generated the same way, to sign sessions.", "components": ["lobe", "lobe-docs"], "sources": ["lobe-docs:s84e88654e760", "lobe:sdb87ee6e14a5"], "status": "REASONED"}, "lobe-sso": {"text": "AUTH_SSO_PROVIDERS selects providers and matching credentials; AUTH_DISABLE_EMAIL_PASSWORD=1 forces SSO-only login.", "components": ["lobe", "lobe-docs"], "sources": ["lobe-docs:s84e88654e760", "lobe:sdb87ee6e14a5"], "status": "REASONED"}, "lobe-admission": {"text": "AUTH_ALLOWED_EMAILS defaults empty, permitting all emails; set explicit addresses/domains for registration.", "components": ["lobe", "lobe-docs"], "sources": ["lobe-docs:s84e88654e760", "lobe:sdb87ee6e14a5"], "status": "REASONED"}, "lobe-mfa": {"text": "Enforce MFA at the configured SSO provider; LobeChat login has no second factor of its own.", "components": ["lobe-docs"], "sources": ["lobe-docs:s84e88654e760"], "status": "REASONED"}, "docker-input": {"text": "Docker --env-file reads a named file, not stdin; -e NAME uses CLI environment, while -e NAME=value exposes argv.", "components": ["docker"], "sources": ["docker:s52c162a9786f", "docker:s8c2adc7212d7", "docker:s2611a6021e2f", "docker:s9a4c6197f246"], "status": "REASONED"}, "docker-envfile": {"text": "Bare names in env files import CLI environment; validate exactly the intended secret assignments and reject extra/missing/duplicate lines.", "components": ["docker"], "sources": ["docker:sdc1935b6999f", "docker:sd1025af9c6ff"], "status": "REASONED"}, "compose-envfile": {"text": "Compose env_file resolves environment via compose-go v2.4.7, pinned by Compose v2.32.4.", "components": ["compose", "compose-go"], "sources": ["compose:s14c0c0a70b7e", "compose-go:s25bb7bb5626f"], "status": "REASONED"}, "compose-secrets": {"text": "Compose file secrets mount read-only under /run/secrets; environment secrets are copied there, default 0444, and need an application reader.", "components": ["compose"], "sources": ["compose:se594167796f7", "compose:sf6263e4f21f0", "compose:s0d8af80827c8"], "status": "REASONED"}, "lobe-file-input": {"text": "LobeHub documents environment inputs; the recorded v2.2.16 search found no KEY_VAULTS_SECRET_FILE, AUTH_SECRET_FILE or AUTH_GOOGLE_SECRET_FILE.", "components": ["lobe"], "sources": ["lobe:s977fd003b436", "lobe:sdb87ee6e14a5"], "status": "REASONED"}, "lobe-file": {"text": "Create the durable plaintext env file once in protected directories; never replace a live encryption key, refuse a default ACL or missing getfacl, check mode 0600 after the write and discard partial failed writes.", "components": ["lobe", "docker", "linux"], "sources": ["lobe:s977fd003b436", "lobe:sdb87ee6e14a5", "docker:s52c162a9786f", "linux:s31f41c54750c", "linux:s08902025e5f9"], "status": "REASONED"}, "lobe-launch": {"text": "Use validated env-file with loopback 3210 publication and non-secret SSO/admission settings on argv.", "components": ["lobe", "docker"], "sources": ["lobe:sdb87ee6e14a5", "docker:s52c162a9786f"], "status": "REASONED"}, "container-secrets": {"text": "Environment values remain readable through Docker inspect for running/stopped containers and process environ during lifetime; env-file avoids CLI argv, not operator access.", "components": ["moby", "docker"], "sources": ["moby:s638ae9c795a2", "moby:sa3cf5ca8ad4d", "docker:s52c162a9786f"], "status": "REASONED"}, "chainlit-default": {"text": "Chainlit applications are public by default; set CHAINLIT_AUTH_SECRET and at least one password/OAuth/header callback.", "components": ["chainlit"], "sources": ["chainlit:sa8ffef5a2afe"], "status": "REASONED"}, "chainlit-callback": {"text": "Password callback returns a user on success and None on refusal; shared credential creates one identity without individual revocation.", "components": ["chainlit"], "sources": ["chainlit:sfc1842532120"], "status": "REASONED"}, "chainlit-environment": {"text": "Example reads credentials once at import, fails on missing or empty values, and requires restart to adopt replacements.", "components": ["python", "chainlit"], "sources": ["python:se86233c0ce69", "chainlit:sfc1842532120"], "status": "REASONED"}, "chainlit-comparison": {"text": "Both compare_digest calls run before combining results; content-based short-circuiting is avoided, but types/lengths and whole callback timing are not concealed.", "components": ["python"], "sources": ["python:s7a54556e5dac"], "status": "REASONED"}, "chainlit-rotation": {"text": "Password replacement stops new logins, not existing signed sessions; rotate CHAINLIT_AUTH_SECRET too to invalidate them.", "components": ["chainlit"], "sources": ["chainlit:sa8ffef5a2afe"], "status": "REASONED"}, "chainlit-accounts": {"text": "Chainlit persists users but supplies no password-account management; per-user store/verification are the application's responsibility, with Argon2id for new hashes.", "components": ["chainlit", "owasp"], "sources": ["chainlit:sfc1842532120", "owasp:s49e8d76431d9"], "status": "REASONED"}, "chainlit-mfa": {"text": "Chainlit has no built-in MFA; use an enforcing identity provider or authenticating proxy.", "components": ["chainlit"], "sources": ["chainlit:sa8ffef5a2afe"], "status": "REASONED"}, "openhands-boundary": {"text": "OpenHands is single-user; shared-key API access is not per-user identity, and execution isolation depends on backend/mount/socket choices.", "components": ["openhands"], "sources": ["openhands:s1756d23e1d0b"], "status": "REASONED"}, "openhands-bind": {"text": "Quickstart publishes 3000 on every interface; use 127.0.0.1:3000:3000 and an authenticated tunnel.", "components": ["openhands"], "sources": ["openhands:s0ceb47353632"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory loopback UI listeners 3001/3210/3000; external refusal must name the real remote path, with DNS/local failures inconclusive.", "components": ["docker", "openhands", "curl"], "sources": ["docker:s52c162a9786f", "openhands:s0ceb47353632", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-login": {"text": "Verify proxy HTTPS and login requirement; generic API refusal is a deployment-specific check requiring an actual protected route.", "components": ["anything", "chainlit", "openhands", "lobe-docs", "curl"], "sources": ["anything:s6b5aa8c91ad3", "lobe-docs:s84e88654e760", "chainlit:sa8ffef5a2afe", "openhands:s1756d23e1d0b", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [1]}, "verify-registration": {"text": "A new Google identity absent from AUTH_ALLOWED_EMAILS should fail registration; this does not revoke an existing user's session.", "components": ["lobe", "lobe-docs"], "sources": ["lobe-docs:s84e88654e760", "lobe:sdb87ee6e14a5"], "status": "REASONED", "verify": [1]}, "verify-isolation": {"text": "User A's retrieval must succeed before user B is checked for denial; independently test documents, history, memory and caches, not login alone.", "components": ["anything"], "sources": ["anything:s6b5aa8c91ad3"], "status": "REASONED"} } } ```` ====================================================================== ==> llm-observability.md ====================================================================== # LLM tracing and observability: Langfuse, Phoenix, Helicone, OpenTelemetry Collector Langfuse, Phoenix and Helicone record an AI app's prompts and responses; the OpenTelemetry Collector relays trace data. This guide helps your AI assistant configure each securely, including open signup off, authentication enabled where it ships disabled, private listeners, and TLS on Collector receivers, plus authentication on those taking off-host data. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/llm-observability.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Langfuse documentation (no recorded version), Phoenix listener source arize-phoenix-v21.0.0 and arize-phoenix-v20.27.0, OpenTelemetry Collector v0.161.0, Collector distributions v0.161.0, and Phoenix TLS source arize-phoenix-v21.0.0, plus 2 more recorded bases; 7 further sources are pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-11-04; documentation checked September 2026. The guide records 48 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These tools store full prompts, completions, and often the provider API keys used to generate them, so an exposed dashboard leaks your most sensitive data at once. Several ship with authentication off or with open signup enabled, so the default install is not safe to expose. ## Langfuse (self-hosted) Email/password authentication is enabled by default: anyone who can reach the URL can register their own account unless you turn signup off. Set `AUTH_DISABLE_SIGNUP=true` to block new registrations, including a user accepting a project invite without an existing account; set `AUTH_DISABLE_USERNAME_PASSWORD=true` to require SSO instead of a password entirely. Create the first account BEFORE you disable signup (or provision it headlessly with `LANGFUSE_INIT_ORG_ID`, `LANGFUSE_INIT_USER_EMAIL`, and `LANGFUSE_INIT_USER_PASSWORD` - a user or project cannot be initialized without also initializing an organization), or you lock yourself out; then confirm a fresh registration is actually refused. SSO runs through Auth.js against Google, GitHub, GitLab, Azure AD/Entra ID, Okta, Auth0, Keycloak, or a custom OIDC provider; `NEXTAUTH_URL` must be set correctly for any method other than email/password. `AUTH_SESSION_MAX_AGE` sets the session lifetime in minutes, and must be an integer greater than five. The current implementation defaults it to 20160 (14 days); the documentation still says 43200 (30 days), so pin your release and check the value you actually get. The ingestion and public API are authenticated separately from the UI session: a project's public key (username) and secret key (password) are sent as HTTP Basic Auth, issued from Project Settings, and unrelated to a user's login credentials. Put MFA at the identity provider per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md); Langfuse's own login has none. Those controls all sit on the sign-in flow, and the self-hosting `docker-compose.yml` sits under them: it ships example secrets marked `# CHANGEME`. The `NEXTAUTH_SECRET` variable protects the session token, which NextAuth encrypts and authenticates, so the shipped `mysecret` lets anyone mint a session for any existing account, administrators included, without the login flow and the SSO or MFA that only guard it. Disabling signup and enforcing SSO do not help, because they gate account creation and sign-in, not a forged cookie. Two related secrets ship the same way: `SALT` (`mysalt`) salts the stored API-key hashes, so a known salt speeds offline cracking of keys lifted from the database; and `ENCRYPTION_KEY`, shipped as 64 hex zeros, encrypts the LLM provider keys and integration credentials Langfuse stores, so anyone holding that key and a copy of the data reads them in clear. Generate a fresh random value for each before the first start (`openssl rand -base64 32` for `NEXTAUTH_SECRET` and `SALT`, `openssl rand -hex 32` for `ENCRYPTION_KEY`) and keep them out of the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)); changing `ENCRYPTION_KEY` later needs a planned re-encryption, and if the shipped key was ever live, treat the stored provider credentials as exposed and rotate them. The same compose bundles a MinIO with the well-known `minio`/`miniosecret` login and publishes its S3 API on host port `9090` on every interface by default, as it does langfuse-web on `3000`. The same compose's bundled Postgres, ClickHouse, and Redis default to loopback; change the MinIO login, update the matching `LANGFUSE_S3_*` access keys Langfuse uses to reach MinIO, and keep every published port off untrusted networks ([minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md), [object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md)). ## Arize Phoenix (self-hosted) At Phoenix 20.27.0, the last 20.x release, authentication is disabled by default, "as you may be just trying Phoenix for the very first time or have Phoenix deployed in a VPC" in the vendor's own words: anyone who reaches the UI has full read and write access with no login at all. Set `PHOENIX_ENABLE_AUTH=True` and `PHOENIX_SECRET` (a long random value used to sign session tokens) to turn it on; at 20.27.0, `phoenix serve` raises an error when auth is enabled without `PHOENIX_SECRET`. At Phoenix 21.0.0, an unset `PHOENIX_ENABLE_AUTH` follows the bind address: authentication is on for any `PHOENIX_HOST` that is not loopback, including `0.0.0.0`, `::` and any hostname other than `localhost`, and off for a loopback address, while an explicit `True` or `False` overrides that. Since 21.0.0's default bind is loopback, this guide reads its defaults as leaving authentication off on a listener only the host itself reaches, though a reverse proxy or tunnel on the same host that forwards to that listener exposes it with authentication still off. `PHOENIX_SECRET` is optional at 21.0.0, but its source says that without it "Saved credentials use a publicly known encryption key and are readable by database readers", so set it there too, and set `PHOENIX_ENABLE_AUTH=True` explicitly rather than relying on the host rule. The served documentation page still says authentication is disabled by default and that both variables are needed, which this guide reads as not reflecting 21.0.0's host rule or its optional `PHOENIX_SECRET`. Turning auth on is not enough. Phoenix creates an administrator account `admin@localhost` with the password `admin` at startup, whether or not auth is on, so the flip alone leaves a reachable instance with a known admin login. `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD` sets that password when the startup creates the account: set it on a brand-new deployment, before the first start. At 20.27.0 that is the only time it takes effect: on any instance that has started before, the variable is inert, and setting it and restarting changes nothing and reports no error. At 21.0.0 a later start also applies a custom value, one other than `admin`, while the local `admin@localhost` account still needs a password reset and still has the password `admin`. That start replaces the password, deletes the account's pending password-reset tokens, ends its sessions and logs "Applied PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD to the default admin account, which still had the default password, and ended its existing sessions". The 21.0.0 `MIGRATION.md` adds that the account's API keys stay valid, and that running replicas can keep accepting cached tokens until they stop, so all replicas must be stopped first for old sessions to end at once. Once the password is no longer `admin`, the variable is inert at 21.0.0 as well. In both releases, never read "I set it and restarted" as proof the password changed: at 20.27.0 the restart changes nothing once the account exists, and at 21.0.0 it changes nothing once the account no longer needs a reset or no longer has `admin`, and replicas left running can keep accepting the old sessions' cached tokens. Unless you are setting it on a brand-new deployment's very first start, treat the account as already existing: log in at the UI as `admin@localhost`, which prompts for a new password, then run the default-credential check in Verify to prove `admin` is dead. At 20.27.0 a login with `admin` opens a session; at 21.0.0 the server answers it with HTTP 403 and a password-reset token, and that token sets the admin password for whoever sent `admin`. In both releases, until that change lands, `admin`/`admin` is a live admin login, so keep the instance off any untrusted network in the window between the flip and the change. Once the password has been changed, the variable only holds an admin password in plaintext for no effect; remove it from your environment files. Phoenix 20.27.0 binds broadly by default: `phoenix serve` gives the HTTP UI/REST/OTLP server on 6006 its `--host` value or `PHOENIX_HOST`, otherwise `HOST = "0.0.0.0"`, and starts a SEPARATE gRPC OTLP server on `[::]:4317` that does not follow the HTTP host setting. Phoenix 21.0.0 binds loopback by default, with `HOST = "127.0.0.1"`, and its `phoenix serve` gives the same `PHOENIX_HOST` value to both servers, so by this guide's reading the two listeners can no longer be bound to different addresses. The repository's 21.0.0 `Dockerfile` sets `ENV PHOENIX_HOST=::`, with the comment "Bind all IPv4 and IPv6 interfaces (dual-stack) for HTTP, gRPC, and Prometheus", so by this guide's reading a container built from it listens on every interface for both protocols with authentication on unless `PHOENIX_ENABLE_AUTH=false`. The 20.27.0 `Dockerfile` sets no `PHOENIX_HOST`, which leaves the `0.0.0.0` default with authentication off. Whether the vendor's published images carry these `Dockerfile` settings is not established here. The vendor Compose file, the same at both releases, builds that `Dockerfile`, publishes `6006:6006` and `4317:4317`, and lists its PostgreSQL port (`- 5432`, with user and password `postgres`) under `ports`, which this guide reads as also publishing the database on a host port the file does not fix. Bind or publish both protocols deliberately and keep the database off host interfaces. Upgrading from 20.x to 21.0.0 changes what an unchanged deployment does, by this guide's reading of the 21.0.0 source and its `MIGRATION.md`. A deployment that set a non-loopback `PHOENIX_HOST` but never `PHOENIX_ENABLE_AUTH`, including a container built from the 21.0.0 `Dockerfile`, comes up with authentication on, so collectors and clients that send no valid credential are refused, and this guide recommends creating API keys for them. By this guide's inference, if such a deployment ran on 20.x with authentication off because `PHOENIX_ENABLE_AUTH` was unset, and its `admin@localhost` account still has `admin` and the password-reset mark 20.27.0 creates it with, then on 21.0.0, with authentication on, whoever first logs in with `admin` gets a password-reset token and can set the admin password with it. Setting `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD` to an unpredictable value before upgrading closes that window, by this guide's inference, because 21.0.0 applies it at startup while that account still needs a reset and still has `admin`; `MIGRATION.md` says to stop all replicas first so old sessions end at once. A deployment outside such a container that relied on the `0.0.0.0` default comes up on `127.0.0.1` only, and `MIGRATION.md` says `phoenix serve --host` is removed in favour of `PHOENIX_HOST`. By this guide's inference about argparse, whose handling of an option a command no longer defines is not in the sources read, a 20.x `phoenix serve --host` command line is no longer accepted at 21.0.0, so move its value to `PHOENIX_HOST` before upgrading. With `PHOENIX_SECRET` unset, 21.0.0 still starts, and whenever authentication is enabled, which `PHOENIX_ENABLE_AUTH` and the host rule decide, not the secret, it signs tokens with a key derived from a deployment seed. Either way, anyone who can read the database, including a Compose PostgreSQL published as above, can read the saved credentials, such as provider API keys. `MIGRATION.md` also says that setting or changing `PHOENIX_SECRET` makes previously saved credentials unreadable, so plan to enter them again when you set it. Enabling auth on a running instance makes Phoenix reject unauthenticated requests, so collectors and SDKs that send no credential stop delivering traces; mint an API key for them immediately after the flip. A system key (admin-created, acts for the whole instance) or a user key authenticates API requests via `PHOENIX_API_KEY` sent as an `Authorization: Bearer` header. Phoenix also accepts the `PHOENIX_ADMIN_SECRET` value, when one is set, and a user's access token as credentials, but this guide recommends API keys for collectors. At 21.0.0 the same rejection follows from the bind rule alone on a non-loopback `PHOENIX_HOST` when `PHOENIX_ENABLE_AUTH` is unset, and `MIGRATION.md` says unauthenticated requests are then rejected with HTTP 401 or gRPC unauthenticated errors. Phoenix's authentication documentation describes no native MFA, and none appears in the 21.0.0 files read for this guide, though that Phoenix has none is not established; it can federate to an OAuth2/OIDC identity provider (its documentation works through Google, AWS Cognito, and Microsoft Entra ID); enforce MFA there, or add an identity-aware proxy in front per [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). Two OAuth caveats: `PHOENIX_OAUTH2__ALLOW_SIGN_UP` defaults to `True`, so any identity the provider will authenticate can provision itself an account unless you set it `False` and restrict provider membership (test an unapproved identity). The second caveat is that local email/password login stays available alongside SSO and bypasses the provider's MFA, so set `PHOENIX_DISABLE_BASIC_AUTH=True` once the IdP is the intended path. Establish and test an approved IdP administrator BEFORE you disable local login or provider signup: disabling basic auth also disables the local admin password, so once `PHOENIX_DISABLE_BASIC_AUTH=True` the "replacement admin password works" control in Verify no longer applies - instead require local password login to FAIL and an approved IdP login with MFA to succeed. ## Helicone (self-hosted) Helicone self-hosts with Better Auth: account signup and organization membership, not a fixed shared login (the `test@helicone.ai` / `password` credential is the manual guide's local trial only). Generate a real `BETTER_AUTH_SECRET` before the first start: the all-in-one image documents the placeholder `change-me-in-production` and the repository Compose file falls back to `your-secret-key`, and a known signing secret weakens every session token it protects, so replace whichever example value your deployment uses. Then restrict who may sign up, and do not expose the dashboard without your own layer in front. Helicone is several services, not one: its Docker Compose publishes the backing stores - PostgreSQL, ClickHouse, MinIO/S3, Redis, and MailHog - on host ports independent of the dashboard login (for example `54388:5432` and `18123:8123`). An identity proxy on the Helicone UI therefore leaves those backing stores wide open; remove or loopback-scope every backing publication and rotate the example storage credentials ([minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md), [object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md)). Ingestion runs through the separate AI Gateway and the provider keys in its environment, not the web session; store those per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). The Jawn service and the S3 store also hold your data, so protect every service, not just the dashboard and gateway: keep them all on a private network or behind an identity-aware fronting layer ([fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)) with MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). ## OpenTelemetry Collector The Collector is the pipe these tools (and others) receive traces through, and it ships with no security applied until you configure it. Bind receivers to a specific interface or loopback (for example `127.0.0.1:4317`). The OTLP receiver's default host became `localhost` in v0.104.0; the `component.UseLocalHostAsDefaultHost` feature gate was stabilized in v0.110.0 and removed in v0.112.0. At v0.161.0, the OTLP factory sets `localhost:4317` (gRPC) and `localhost:4318` (HTTP) directly. **The official `otelcol` and `otelcol-contrib` images and deb/rpm packages ship a configuration that overrides those localhost component defaults.** At v0.161.0, both enable wildcard IPv4 listeners on `0.0.0.0`: OTLP `4317`/`4318`, Jaeger `14250`/`6832`/`6831`/`14268`, Zipkin `9411`, and the diagnostic extensions pprof `1777` and zPages `55679`, with no authentication configured. Their first-line comment tells users to narrow the endpoints, but the shipped values still bind every IPv4 interface. Each Dockerfile copies that config and selects it in the default `CMD`; it declares `EXPOSE 4317 4318 55679`. `EXPOSE` does not publish ports, and `docker run -p` publishes only the mappings you request; the in-container wildcard listeners can still be reachable by container peers. Inventory network paths as well as host publications. For packages, the systemd unit reads `/etc/otelcol/otelcol.conf` or `/etc/otelcol-contrib/otelcol-contrib.conf`; its `OTELCOL_OPTIONS` selects the matching `/etc/otelcol/config.yaml` or `/etc/otelcol-contrib/config.yaml`. The package recipe installs the same shipped config with `config|noreplace` handling; the postinstall scripts manage the service, rather than copying the config. Existing local configuration can therefore differ. Supply your own configuration with explicit endpoints for every enabled listener. Remove pprof and zPages from both `extensions` and `service.extensions` unless needed; if retained, bind them privately and control access separately from receiver authentication. Widen a receiver's bind only where the intended clients, proxy, or mesh need it; require TLS on every receiver and exporter; and attach an authenticator extension to any receiver that accepts data from outside the host. Examples of an authenticator extension are `basicauth` (htpasswd-style credentials, or a static `client_auth` username/password for outgoing calls) and `bearertokenauth` (a static `token` or file `filename`, with `filename` taking precedence). For `bearertokenauth`, each file line with a non-whitespace field supplies its first whitespace-delimited field as a token. The configurable header (default `Authorization`) and scheme (default `Bearer`) apply to outgoing HTTP and gRPC calls: HTTP sends the value as a header, and gRPC sends it as metadata under the lower-case configured header key. On incoming requests, `Authenticate` checks the canonical HTTP header or lower-case gRPC metadata key and compares its first value with each configured scheme-prefixed token and accepts any match; outgoing calls send only the first token. Declaring an authenticator extension is not enough to enforce it: list it under `service.extensions` so it starts, then attach it to EACH receiver protocol with an `auth.authenticator` key naming the extension. Inventory the other listeners too. At v0.161.0, the internal telemetry metrics factory defaults to `localhost:8888`; the two shipped configs explicitly use `127.0.0.1:8888`. Their `0.0.0.0:8888` Prometheus scrape target is an outbound destination, not a listener setting. The enabled health-check extension (`13133`) also needs review independently of receiver authentication. The `otelcol-k8s` Dockerfile at this tag copies no default config and supplies no default config `CMD`; its `EXPOSE` list alone establishes no running listeners. Helm chart and operator configuration are out of scope here. Build or run only the receivers, processors, and exporters you use, since every enabled component is attack surface, and run the process as a non-root user. Langfuse's container deployment does not terminate TLS, so an `https://` URL in front of it is your proxy's, not Langfuse's. Phoenix (8.29 and later) CAN terminate TLS natively for both HTTP and gRPC - set `PHOENIX_TLS_ENABLED=True` (it defaults to `False`) with `PHOENIX_TLS_CERT_FILE` and `PHOENIX_TLS_KEY_FILE` - so its default is TLS off, not TLS unsupported. Later versions add per-protocol overrides (`PHOENIX_TLS_ENABLED_FOR_HTTP` and `PHOENIX_TLS_ENABLED_FOR_GRPC`, each overriding `PHOENIX_TLS_ENABLED` for its protocol), which 20.27.0 and 21.0.0 both have; which release added them is not established, so check your release. Either way terminate TLS deliberately (natively, or at a reverse proxy or tunnel per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)), and make sure any plaintext backend port a proxy forwards to is not separately reachable. For Phoenix with an API key, the proxy or tunnel is itself a key-sending client: it must reach Phoenix over loopback in Phoenix's own network namespace or over TLS with verification on (see Verify). ## Verify REASONED: following block; the listener inventory, with the Langfuse checks and the Collector OTLP/HTTP probe in the two blocks after it, each marked in its own first line, per the guide's cited OpenTelemetry Collector releases v0.161.0 shipped configs, Langfuse public API authentication and OpenTelemetry Collector Contrib basicauthextension entries. No Langfuse, Phoenix, Helicone or Collector deployment and no isolated network namespace were available. During marking review (October 2026) an earlier form, which held all three checks in one block, passed bash -n under bash 5.3.9 and ran with stub curl and ss and no network. With the placeholders it printed the substitute-the-public-key refusal, a tab in the secret key drew the secret-key refusal, dummy keys reached curl only on stdin as an escaped user line, and the OTLP JSON body parsed as an empty resourceSpans list. That review covered the Langfuse checks before they took the Langfuse host on guarded `set --` lines that refuse example.com, example.net, example.org and their subdomains, and the Collector probe while it targeted a fixed illustrative host. The repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block cover the current forms of the three blocks. Stub runs on 2026-10-31 with Bash 5.3.9, stub curl and ss and no network ran the guards and every exit status, on the inventory's current form and on the earlier form of the Langfuse and Collector blocks, which lowercased the host with `tr` before refusing reserved names. Those two blocks now refuse a reserved name under bash's `nocasematch` option instead of through `tr`, and each of their host parts refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. That current form is covered by curl-stub runs on 2026-11-01 with no network. In bash, each host part refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no curl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and a host name outside the reserved names reached its probe. Under dash, stub runs of the current form refused every host name in each host part with 2 and the bash message before its probe, and both blocks exited 2. The inventory's command, run without sudo on the review host as ss -tlnp (iproute2 6.19.0), showed none of ports 3000, 6006, 4317 and 4318 listening, with an empty process column, so it tested no target. Without credentials, expect 401 from Langfuse and 401 or 403 from the Collector, and with them 200 or a 2xx; an anonymous 2xx is the exposed state. The Langfuse and Collector blocks exit 1 for an anonymous 2xx; the Langfuse block exits 3 for any other anonymous answer than 401 or a keyed answer other than a 2xx, and the Collector block exits 0 for 401 or 403 and 3 for any other answer. Each block exits 2 when a guard refuses and 3 when a command it attempts fails, and its last line names what it leaves for you to judge. The listener inventory refuses with status 2 when `ss` is not installed, and an `ss` that runs and fails gives 3. Collector live checks remain **REASONED**: the authoring host forbids opening listeners without an isolated network namespace, and has none. Expected outcomes follow the cited pinned Collector configuration. Inspect the effective config and inventory TCP and UDP sockets in the Collector's network namespace, together with container publications and firewall rules. With the shipped config, expect the nine wildcard endpoints listed above, including unauthenticated pprof and zPages. With your replacement config, expect only the intended private binds and no pprof or zPages listener if removed. Test reachability from an untrusted network and use an authorized client as a positive control; a failed connection alone does not prove authentication. No exposed-versus-fixed Collector run was performed here. ```bash # ss is a listener inventory in THIS namespace - not a firewall, NAT, or authentication check. ( # a subshell, so the exits below end only the subshell set +e # errexit off here, so a failed ss is reported below instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } # a missing ss is a guard refusal; an ss that runs and fails is 3 below ss -tlnp # inventory 3000/6006/4317/4318 and identify each listener's namespace, host publication, and # permitted network paths. A wildcard bind is not itself exposure (Phoenix 20.27.0's gRPC 4317 # binds [::] regardless of PHOENIX_HOST; 21.0.0's binds PHOENIX_HOST) and a port here can still be published by DNAT, so # confirm the actual container publications and firewall, and probe the public IPv4/IPv6 path # from another host - this list alone proves neither exposure nor authentication case "$?" in 0) echo "unclassified observation: the listener table above is not classified, so judge it yourself against the namespace, publications and firewall"; exit 0 ;; *) echo "ss -tlnp exit $?: there is no listener table to judge; inconclusive"; exit 3 ;; esac ) ``` ```bash # REASONED: Langfuse dashboard reachability and public API authentication follow the cited Langfuse authentication and public API documentation; no Langfuse deployment is available. # Langfuse dashboard: a bare GET of / cannot tell a login page from an exposed project view (both are 200 # and the body is discarded), so confirm it in a FRESH browser session - an unauthenticated visit must # land on login, not a project. The curl below is only transport reachability. ( # the whole block in one subshell, so one exit status covers every part of it set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_LANGFUSE_HOST' # the public host name your proxy serves Langfuse on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again curl -q -g -sS -L --proto-redir '=https' --noproxy '*' --connect-timeout 5 --max-time 15 \ -o /dev/null -w 'dashboard final=%{http_code} url=%{url_effective} exit=%{exitcode}\n' "https://$1/" case "$?" in 0) exit 0 ;; *) echo "curl exit $?: the dashboard request failed; inconclusive"; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above # Langfuse public API auth, matched pair: WITHOUT the key expect 401; WITH a valid project key (public:secret) # expect 200 whose JSON body carries the EXPECTED project id - the positive control. A 401/200 pair seen # THROUGH a reverse proxy does not prove Langfuse itself authenticated (a proxy can reject anonymous and # admit authenticated while its backend stays open), so repeat the pair DIRECTLY against the Langfuse # listener from an authorized network position, keeping any proxy credentials constant, and separately # confirm untrusted clients cannot reach that backend. The key reaches curl on stdin via a config file (curl --config -), so it stays out # of argv and /proc//cmdline; still prefer a short-lived project key. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The secret key you substitute on the set -- line enters shell history. # Use a short-lived project key or clear that history line afterward. # Stdin closes the argv channel only: it does not hide the keys from set -x # tracing or your own account's view of the process. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_LANGFUSE_HOST' 'REPLACE_WITH_PUBLIC_KEY' 'REPLACE_WITH_SECRET_KEY' # the same Langfuse host name as the dashboard check above, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then the project's public and secret keys: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the public key on the set -- line above; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the secret key on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "${2//\\/\\\\}" "${3//\\/\\\\}" set -- "$1" "${2//\"/\\\"}" "${3//\"/\\\"}" set -- "$1" "$2" "$3" 0 # the 4th value is this part's status so far, ranked as above set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 -o /dev/null \ -w 'projects no-key=%{http_code} exit=%{exitcode}\n' "https://$1/api/public/projects"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # curl's exit status, then its projects no-key= line printf '%s\n' "$6" case "$5:$6" in "0:projects no-key=401 "*) ;; "0:projects no-key=2"*) echo "FINDING: the public API answered without the key"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "the request without the key got neither 401 nor a 2xx; inconclusive"; set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the request without the key failed; inconclusive"; set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if printf 'user = "%s:%s"\n' "$2" "$3" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 \ -w '\nprojects with-key=%{http_code} exit=%{exitcode}\n' --config - "https://$1/api/public/projects"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then the response body and its projects with-key= line printf '%s' "$6" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'}" # the same, without the final line feed, so the last line is the projects with-key= line case "$5:${6##*$'\n'}" in "0:projects with-key=2"*) ;; 0:*) echo "the request with the key did not get a 2xx, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the request with the key failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac exit "$4" ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "block exit $1; the dashboard's final URL, whether a fresh browser lands on login, and whether the keyed body carries the expected project id are not classified, so judge them yourself" exit "$1" ) ``` ```bash # REASONED: Collector OTLP/HTTP receiver authentication follows the cited Collector security guidance and basicauthextension entries; no Collector deployment is available. # Collector OTLP/HTTP, matched pair: WITHOUT the configured auth header expect 401/403; then repeat the # probe below WITH it, on curl's stdin as described next, and expect a 2xx - the positive # control. Run the pair DIRECTLY against the receiver to establish RECEIVER authentication (a proxy can # answer 401/2xx while the receiver is open), and test the public ingress separately. Keep the JSON content # type (a wrong content type draws a 415 that is not an authentication result). This tests this OTLP/HTTP # receiver only; test each enabled OTLP/gRPC receiver separately, and note the Phoenix check further below # targets a DIFFERENT server, not this Collector. # Never add the credential with -H or -u, which put it in curl's argv, where ps and /proc//cmdline # show it. For bearertokenauth, pipe printf 'Authorization: Bearer %s\n' "$2" into the probe and add # --header @- to its options; for basicauth, pipe printf 'user = "%s"\n' "$2" into it, with "$2" as # user:password, and add --config -. Keep -q as curl's first option in either case. Run that repeat in a # subshell built like the Langfuse block above, in a fresh, clean shell (CONTRIBUTING rule 7): # trap - DEBUG RETURN ERR first, the Collector host first and the credential second on its # set -- line, a refusal of a placeholder, an empty value or a control character, and for basicauth a # backslash and then a double quote escaped in "$2" before the printf. The credential on that set -- line # enters shell history, so clear that line afterward. Stdin closes the argv channel only: it does not # hide the credential from set -x tracing or your own account's view of the process. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_COLLECTOR_HOST' # the host name of the Collector's OTLP/HTTP receiver on port 4318, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute the Collector's host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 -o /dev/null \ -w 'otlp no-auth=%{http_code} exit=%{exitcode}\n' -H 'Content-Type: application/json' -d '{"resourceSpans":[]}' \ "https://$1:4318/v1/traces"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # curl's exit status, then its otlp no-auth= line printf '%s\n' "$3" case "$2:$3" in "0:otlp no-auth=401 "*|"0:otlp no-auth=403 "*) set -- 0 ;; "0:otlp no-auth=2"*) echo "FINDING: the receiver accepted spans with no credential"; set -- 1 ;; 0:*) echo "the anonymous request got neither 401/403 nor a 2xx (a 415 is a content-type answer, not an authentication result); inconclusive"; set -- 3 ;; *) echo "curl exit $2: the request failed, and a failed connection proves nothing about authentication; inconclusive"; set -- 3 ;; esac echo "block exit $1; the authenticated repeat described above and each OTLP/gRPC receiver are not run here, so run and judge them yourself" exit "$1" ) ``` REASONED: the Phoenix default-credential check and the anonymous read-API probe it introduces, per the guide's cited Arize Phoenix authentication entry (the default admin account and /v1/ REST permissions) and Arize Phoenix OAuth2 sign-up and basic-auth controls entry. The 21.0.0 HTTP 403 with a password-reset token and the 20.27.0 session rest on the Phoenix password login entries at arize-phoenix-v21.0.0 and arize-phoenix-v20.27.0, the Phoenix default admin bootstrap entries at both releases and the Phoenix migration notes entry, and the read-API outcomes on the Phoenix /v1 REST authentication and viewer write refusal entry. No Phoenix instance is available in the authoring environment, so the manual login was not attempted; the expected exposed and fixed results are those stated here. For Phoenix specifically, prove the default admin credential is dead and that the read API does not answer an anonymous request. **These checks are REASONED from the cited Phoenix documentation and source, not demonstrated** (no Phoenix instance in the authoring environment). The credential check is manual: at 20.27.0 the vendor documents only the UI login flow, and although the 21.0.0 `MIGRATION.md` documents scripted logins with `admin` receiving HTTP 403 and a reset token, a scripted guess against the wrong endpoint can read a `404` as a rejection. In a fresh browser session with no saved Phoenix cookies, try once to log in as `admin@localhost` with the password `admin`. **Exposed:** it logs in or, at 21.0.0, is answered with a demand to change the password: that release's server answers a login with `admin` with HTTP 403 and a password-reset token that sets the admin password, so `admin` still controls the account. **Fixed:** it is rejected, and your replacement admin password works in a second fresh session (or, if you set `PHOENIX_DISABLE_BASIC_AUTH=True`, local password login is refused entirely and an approved IdP login with MFA succeeds instead). Then, from a network position that legitimately reaches Phoenix (a connection failure proves nothing about authentication), probe the read API anonymously (the probe prints the `exitcode` and `errormsg` write-out variables, which need curl 7.75.0 or newer): ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PHOENIX_HOST' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the host on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 "https://$1/v1/projects" \ -w '\n[unauth-read] http=%{http_code} exit=%{exitcode} err=%{errormsg}\n'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # curl's exit status, then the response and its unauth-read line printf '%s' "$3" set -- "$1" "$2" "${3%$'\n'}" # the same, without the final line feed, so the last line is the unauth-read line case "$2:${3##*$'\n'}" in 0:*" http=2"??" exit="*) echo "FINDING: /v1/projects answered the anonymous request with a 2xx"; set -- 1 ;; 0:*" http=401 exit="*|0:*" http=403 exit="*) set -- 0 ;; 0:*) echo "an answer other than a 2xx, 401 or 403 is inconclusive and does not prove authentication is on"; set -- 3 ;; *) echo "curl exit $2: a transport error is inconclusive and does not prove authentication is on"; set -- 3 ;; esac echo "block exit $1; the response body and the default-credential check above are not classified here, so judge them yourself" exit "$1" ) ``` REASONED: preceding block; the anonymous Phoenix read-API probe and these outcomes, per the guide's cited Arize Phoenix authentication entry (/v1/ REST permissions) and curl manual entry. No Phoenix instance is available. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ran with a stub curl and no network: unchanged, it printed its substitute-the-host refusal and exited 1; without its set -- line, the paste-the-whole-block refusal with exit 1; with phoenix.internal:6006 substituted, the stub received https://phoenix.internal:6006/v1/projects. The bash -n and stub runs recorded here covered an earlier form of this block, whose guard refusals exited with status 1 and whose answer was not classified. The current form is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes over every block and by stub runs on 2026-10-31 with Bash 5.3.9, a stub curl and no network: it exits 2 when a guard refuses, 1 for a 2xx, 0 for 401 or 403 and 3 for anything else, and its last line names what it leaves for you to judge. **Exposed:** `/v1/projects` returns project data with no credential (a `2xx`). **Fixed:** the anonymous request is rejected with `401` or `403`. Anything else (a `404`, a redirect to a login page, a `415`, or a transport error) is inconclusive and does not prove authentication is on; fall back to the credential check above and to the listener inventory. Substitute a bracketed literal for an IPv6 host in the URL, for example `[::1]:6006`. REASONED: the OTLP/HTTP admission and OTLP gRPC Export checks, per the guide's cited Arize Phoenix authentication entry and Arize Phoenix gRPC OTLP Export authorization entry. The HTTP outcomes, including the viewer's HTTP 403, rest on the Phoenix /v1 REST authentication and viewer write refusal entry and the Phoenix OTLP/HTTP traces handler entry. The gRPC outcomes rest on the Phoenix gRPC OTLP Export handler entry, the Phoenix gRPC OTLP server entries at arize-phoenix-v21.0.0 and arize-phoenix-v20.27.0, the Phoenix create_app entries at both releases and the Phoenix authentication settings entry at arize-phoenix-v21.0.0. They need a running Phoenix and, for 4317, an OTLP gRPC client with the protobuf descriptor; the authoring environment has no Phoenix and no protobuf or gRPC Python package, so live HTTP admission, protobuf encoding and gRPC Export were not exercised. During marking review (October 2026), bash ran the HTTP recipe with a stub curl on PATH and no network, anonymously and with a dummy bearer header supplied through stdin using --header @-. In both calls the stub received --data-binary followed by an empty argument, -H followed by Content-Type: application/x-protobuf, and https://phoenix.internal:6006/v1/traces; the anonymous call had empty stdin and no authorization header, and the second received Authorization: Bearer dummy-write-key followed by a newline on stdin. Both stub calls exited 0. This checks local HTTP request construction only, not protobuf serialization, Phoenix admission or gRPC behaviour. The expected exposed and fixed results are those stated here. The OTLP/HTTP ingestion path CAN be probed without hand-encoding a span: an empty `ExportTraceServiceRequest` is a zero-length protobuf body, so send `--data-binary ''` with `Content-Type: application/x-protobuf` to `/v1/traces` on the HTTP port (6006) and read the status. Phoenix's `/v1/traces` handler parses the body as an `ExportTraceServiceRequest`, and at that parse step it answers 422 only when the parse fails and otherwise answers 200 with an empty `ExportTraceServiceResponse` (see Sources); before that step it answers 415 for an unsupported content type or content encoding. By this guide's reading, a zero-length body parses as an empty request. The anonymous request must be rejected (401/403), and the request with a WRITE-authorized `Authorization: Bearer` key (a system key, or a user key allowed to write traces, NOT a viewer key) is admitted (a 2xx). This tests request ADMISSION, not span persistence, and a valid VIEWER-role key is not a positive control here: Phoenix authenticates a viewer but rejects a viewer token for OTLP writes, with HTTP `403` on `/v1/traces` and `PERMISSION_DENIED` on gRPC, so admit only with a system key or a write-authorized user key. The gRPC OTLP receiver on 4317 is a SEPARATE server with its own `ApiKeyInterceptor`, not the HTTP `/v1` router, so a protected HTTP path does not prove the gRPC path is protected. The gRPC path therefore needs a separate test of 4317: invoke `opentelemetry.proto.collector.trace.v1.TraceService/Export` with an empty `ExportTraceServiceRequest`, first with no metadata (expect `UNAUTHENTICATED`) then with `authorization: Bearer ` (expect `OK`). That needs an OTLP gRPC client carrying the protobuf descriptor (and the CA if TLS is on), so it ships REASONED: with authentication disabled the same anonymous empty Export returns `OK` (the exposed outcome), because Phoenix then builds no token store and adds no `ApiKeyInterceptor`, and at 21.0.0 authentication is disabled by default only on a loopback `PHOENIX_HOST`. The authenticated, viewer-rejected, and empty-request behaviours follow, by this guide's reading, Phoenix's gRPC Export handler, which loops over the request's spans and returns an empty `ExportTraceServiceResponse`, so a request with no spans returns at once, and `ApiKeyInterceptor`, which passes an admitted call to that handler (see Sources). At 20.27.0 Phoenix's gRPC listener binds `[::]` regardless of `PHOENIX_HOST`, so you cannot move it to loopback by host setting; at 21.0.0 it binds `PHOENIX_HOST`, the same address as the HTTP server. Either way, isolate it in a private container network, restrict or omit its host publication, and inspect its namespace, publications, and firewall separately from the HTTP port. A wildcard listener inside an isolated namespace is not by itself public exposure. REASONED: transport for the Phoenix API key in the OTLP checks above. The requirement applies the bearer-key rule in [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md), and the Phoenix TLS entries in Sources describe only the server settings named here. No Phoenix instance is available, so no plaintext-versus-TLS run was made and Phoenix transport and authentication were not exercised. During review, only the key-handling pattern at the end of this paragraph was run, in bash under `bash -x` with a stub curl and a dummy key: the subshell traced only its `trap - DEBUG RETURN ERR` and `set +x` lines, none carrying the key. The same review run found that curl's argv held no key and its stdin held the `Authorization: Bearer` line, and that a placeholder, an empty value, a line feed, a carriage return or a tab was refused before any curl call. With `set +x` removed, the trace showed the key on two lines, the `set --` line and the `printf` line, and an interactive run with history enabled kept the key in the history entry. Outside the one plaintext exception below, send a Phoenix API key only over TLS: use an `https://` URL for `/v1/traces` with certificate verification left on, and a TLS channel for the gRPC Export on 4317. The TLS must be terminated by Phoenix itself (`PHOENIX_TLS_ENABLED=True` with `PHOENIX_TLS_CERT_FILE` and `PHOENIX_TLS_KEY_FILE`, or the per-protocol `PHOENIX_TLS_ENABLED_FOR_HTTP` and `PHOENIX_TLS_ENABLED_FOR_GRPC` where your release has them) or by a reverse proxy or tunnel in front of Phoenix. Phoenix defaults to TLS off, so a plain `http://` URL or a plaintext gRPC channel puts the Bearer key on the wire in cleartext, and a private network is not a substitute: anyone who can observe that network reads the key. The plaintext exception, this guide's reasoning rather than machine-auth.md's rule, is a client on the Phoenix host using loopback in Phoenix's own network namespace, for example a direct probe of a plaintext listener behind a TLS-terminating proxy. A TLS-terminating proxy or tunnel that forwards the key to Phoenix is itself a client that sends the key, so it must use that loopback or TLS with verification on. **Exposed:** where any client sends the key other than over that loopback, the `https://` probe of the endpoint those clients use (Phoenix's port when Phoenix terminates TLS, otherwise the proxy or tunnel) fails the TLS handshake against a plaintext listener, or a client sends the key over an `http://` URL or a plaintext gRPC channel other than loopback in Phoenix's own network namespace. **Fixed:** that probe, where it applies, completes with verification on, and every client that sends the key, including each 4317 gRPC exporter, uses TLS with verification on, other than one using loopback in Phoenix's own network namespace (Sources names no gRPC client TLS option, so confirm it in that client's configuration). Sources has no Phoenix reference for an API-key transport requirement or for an OTLP gRPC client's TLS or CA settings, so this guide names no client option for them: for gRPC clients using TLS, trust the CA for the certificate presented by the endpoint your client connects to, whether Phoenix or the TLS-terminating proxy or tunnel. When you script the HTTP probe, keep the key out of argv and tracing. In a fresh, clean shell (CONTRIBUTING rule 7), build a subshell like the Langfuse block above and add `set +x` straight after its `trap - DEBUG RETURN ERR` line, so tracing is off before the key arrives on the `set --` line. That subshell must refuse a placeholder, an empty value or a control character, then pipe `printf 'Authorization: Bearer %s\n' "$1"` into curl with `--header @-`, never `-H` with the key and never the key in the URL. Require exactly one value, omit the Langfuse block's two escaping lines (they apply only to `--config -`), send the empty `application/x-protobuf` POST described above, and delete the history entry for the pasted block. The key on that `set --` line enters shell history, and stdin keeps it out of argv only, not out of your own account's view of the process. REASONED: a summary of the findings the checks above look for, per the guide's cited Langfuse authentication and SSO, Arize Phoenix authentication and OpenTelemetry Collector security best practices entries; it adds no probe of its own, and no deployment was available to show either finding. Exposed, a dashboard or OTLP port answers without a credential; fixed, both require one. A dashboard that renders traces, prompts, or provider keys without a login is a finding; so is an OTLP port that accepts spans with no credential at all. ## Common mistakes - Leaving Phoenix's or Helicone's auth off "because it's just internal" on a host with a public IP. - Enabling `PHOENIX_ENABLE_AUTH` without first provisioning API keys for collectors, which locks out mid-flight every collector that sends no valid credential; at 21.0.0 a non-loopback `PHOENIX_HOST` with `PHOENIX_ENABLE_AUTH` unset does the same. - Leaving Phoenix's default `admin@localhost` / `admin` in place after turning auth on, or trusting `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD` to have changed it on an instance whose admin account already existed, where at 20.27.0 the variable is silently inert and at 21.0.0 it is inert once the account no longer needs a reset or no longer has `admin`. - Running Phoenix 21.0.0 without `PHOENIX_SECRET`, which its source says leaves saved credentials readable by database readers. - Publishing the OTLP gRPC/HTTP ports (4317/4318) to `0.0.0.0` because a docker-compose example did. - Assuming an ingestion key protects the UI, or a UI login protects the ingestion endpoint; they are separate credentials in Langfuse and Phoenix alike. ## Sources (checked September 2026) - Langfuse, authentication and SSO (signup default, `AUTH_DISABLE_SIGNUP`, `AUTH_DISABLE_USERNAME_PASSWORD`, SSO providers, `AUTH_SESSION_MAX_AGE`, `NEXTAUTH_URL`): https://langfuse.com/self-hosting/security/authentication-and-sso - Langfuse, public API authentication (Basic Auth with project public/secret key): https://langfuse.com/docs/api-and-data-platform/features/public-api - Langfuse, session-lifetime implementation (`AUTH_SESSION_MAX_AGE` validated as an integer greater than five, default `14 * 24 * 60` = 20160 minutes, vs the 43200 in the docs): https://github.com/langfuse/langfuse/blob/24c949d8dd5617219a8415f80e4c65ad611ff05c/web/src/env.mjs - Langfuse, self-hosting configuration (`NEXTAUTH_SECRET`, `SALT`, `ENCRYPTION_KEY` and their generation, the bundled MinIO/Postgres/ClickHouse/Redis; checked 2026-09-14): https://langfuse.com/self-hosting/configuration - Langfuse, self-hosting docker-compose.yml (the `# CHANGEME` example secrets `mysecret`/`mysalt`/all-zero `ENCRYPTION_KEY`, MinIO `minio`/`miniosecret` on host `9090`, langfuse-web on `3000`; checked 2026-09-14): https://github.com/langfuse/langfuse/blob/0dd0a7fbe2feb300b8776f02b3684eeee3fbceab/docker-compose.yml - Arize Phoenix, authentication (`PHOENIX_ENABLE_AUTH`, `PHOENIX_SECRET`, system and user API keys, `PHOENIX_API_KEY`, the default `admin@localhost` / `admin` account, `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD`, `/v1/` REST permissions, OAuth2/OIDC identity providers, and no mention of native MFA; read 2026-09-13 and as served on 2026-11-04, checked November 2026). Its admin-password text matches 21.0.0, and its authentication-default text omits 21.0.0's host rule and optional `PHOENIX_SECRET`. Arize Phoenix authentication sources: https://arize.com/docs/phoenix/self-hosting/features/authentication - Arize Phoenix, native HTTP and gRPC TLS introduced in 8.29 (2025-04-28): https://arize.com/docs/phoenix/release-notes/04-2025/04-28-2025-tls-support-for-phoenix-server - Arize Phoenix, TLS configuration implementation at arize-phoenix-v21.0.0 (`PHOENIX_TLS_ENABLED` default `False`, `PHOENIX_TLS_CERT_FILE`/`PHOENIX_TLS_KEY_FILE`, and the per-protocol `PHOENIX_TLS_ENABLED_FOR_HTTP`/`PHOENIX_TLS_ENABLED_FOR_GRPC` overrides, with the same text at arize-phoenix-v20.27.0; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L853-L874 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L1032-L1057 - Helicone, self-hosted deployment (default `test@helicone.ai` / `password` login): https://docs.helicone.ai/getting-started/self-host/manual - Helicone, all-in-one Docker deployment (Better Auth, `BETTER_AUTH_SECRET` documented default `change-me-in-production`): https://docs.helicone.ai/getting-started/self-host/docker - Helicone, repository Compose deployment (`BETTER_AUTH_SECRET` fallback `your-secret-key`; backing stores published on host ports `54388:5432`, `18123:8123`, `19000:9000`, `9000`/`9001`, `6379`, `1025`/`8025`): https://github.com/Helicone/helicone/blob/b12ebaccb824ab9778757ca197ff302d97fda421/docker/docker-compose.yml - Langfuse, headless initialization (`LANGFUSE_INIT_ORG_ID`/`USER_EMAIL`/`USER_PASSWORD`, org-before-user ordering): https://langfuse.com/self-hosting/administration/headless-initialization - Arize Phoenix, OAuth2 sign-up and basic-auth controls (`PHOENIX_OAUTH2__ALLOW_SIGN_UP` default True, `PHOENIX_DISABLE_BASIC_AUTH`): https://arize.com/docs/phoenix/self-hosting/features/authentication - OpenTelemetry Collector v0.161.0, OTLP factory endpoints: https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/receiver/otlpreceiver/factory.go#L41-L67 - OpenTelemetry Collector v0.161.0 changelog, v0.104.0 OTLP localhost transition: https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/CHANGELOG.md#L2058-L2070 - OpenTelemetry Collector v0.161.0 changelog, gate stabilization in v0.110.0 and removal in v0.112.0: https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/CHANGELOG.md#L1793-L1811 and https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/CHANGELOG.md#L1737-L1743 - OpenTelemetry Collector releases v0.161.0, shipped configs (wildcard endpoints, explicit loopback metrics, enabled pipelines and extensions): https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/config.yaml#L1-L81 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/config.yaml#L1-L81 - OpenTelemetry Collector releases v0.161.0, Docker config COPY, CMD and EXPOSE: https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/Dockerfile#L10-L15 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/Dockerfile#L10-L15 - OpenTelemetry Collector releases v0.161.0, deb/rpm config installation and script selection: https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/.goreleaser.yaml#L86-L118 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/.goreleaser.yaml#L94-L126 - OpenTelemetry Collector releases v0.161.0, systemd environment and command: https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/otelcol.service#L5-L7 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/otelcol-contrib.service#L5-L7 - OpenTelemetry Collector releases v0.161.0, package config arguments: https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/otelcol.conf#L1-L5 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/otelcol-contrib.conf#L1-L5 - OpenTelemetry Collector releases v0.161.0, deb postinstall service management: https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/postinstall.sh#L6-L16 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/postinstall.sh#L6-L16 - OpenTelemetry Collector releases v0.161.0, rpm postinstall service management: https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/postinstall-rpm.sh#L6-L9 and https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/postinstall-rpm.sh#L6-L9 - OpenTelemetry Collector releases v0.161.0, otelcol-k8s Dockerfile (no config COPY or CMD): https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-k8s/Dockerfile#L1-L17 - OpenTelemetry, Collector security best practices (bind addresses, TLS, authenticator extensions, minimal components, non-root): https://opentelemetry.io/docs/security/config-best-practices/ - OpenTelemetry Collector Contrib, `basicauthextension` (htpasswd, `client_auth`, `auth.authenticator` wiring): https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/basicauthextension - OpenTelemetry Collector Contrib, `bearertokenauthextension` README (`token`, `filename`, precedence, line parsing, header and scheme defaults, HTTP and gRPC transport; checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/README.md#L21-L39 - OpenTelemetry Collector Contrib, `bearertokenauthextension` gRPC metadata and TLS requirement (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L30-L43 - OpenTelemetry Collector Contrib, `bearertokenauthextension` token resolver handoff and `filename` warning (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L73-L122 - OpenTelemetry Collector Contrib, `bearertokenauthextension` file token parsing and scheme assembly (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L156-L189 - OpenTelemetry Collector Contrib, `bearertokenauthextension` first-token selection for outgoing calls (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L197-L205 - OpenTelemetry Collector Contrib, `bearertokenauthextension` incoming `Authenticate` header and metadata checks (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L234-L255 - OpenTelemetry Collector Contrib, `bearertokenauthextension` HTTP header assignment (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L264-L272 - OpenTelemetry Collector Contrib, `bearertokenauthextension` header and scheme defaults (checked October 2026): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/factory.go#L15-L18 - Arize Phoenix, gRPC OTLP Export authorization at arize-phoenix-v21.0.0 (`ApiKeyInterceptor`: `UNAUTHENTICATED` without a valid credential, `PERMISSION_DENIED` for a viewer token, and the call passed to the Export handler for the admin secret or a valid non-viewer API key or access token; `bearer_auth.py` is the same file at arize-phoenix-v20.27.0; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/bearer_auth.py#L152-L190 - OpenTelemetry Collector v0.161.0, internal telemetry metrics default `localhost:8888`: https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/service/telemetry/otelconftelemetry/factory.go#L49-L64 - OpenTelemetry Collector v0.161.0, zPages default endpoint and HTTP server configuration: https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/extension/zpagesextension/factory.go#L15-L29 - OpenTelemetry Collector v0.161.0, zPages server wiring and optional HTTP authentication: https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/extension/zpagesextension/zpagesextension.go#L87-L105 and https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/config/confighttp/server.go#L352-L364 - OpenTelemetry Collector Contrib, pprof extension (default endpoint `localhost:1777`): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/443567a6a00d7cff8cae1432a6fef655d8698e94/extension/pprofextension/README.md - OpenTelemetry Collector Contrib, health-check extension (default endpoint `localhost:13133`): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/d922ffb299c6b9be026f97dd7d6a5f0f507efdeb/extension/healthcheckextension/README.md - curl manual (the `exitcode` and `errormsg` write-out variables, both added in curl 7.75.0): https://curl.se/docs/manpage.html - Phoenix defaults `HOST = "127.0.0.1"`, `PORT = 6006`, `GRPC_PORT = 4317` at arize-phoenix-v21.0.0, where `PHOENIX_HOST` is the bind address for HTTP, gRPC and Prometheus, `get_env_host()` canonicalizes it, and `is_loopback_host` counts `0.0.0.0`, `::` and hostnames other than `localhost` as not loopback (checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L55-L59, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L3166-L3172 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L3425-L3447 - Phoenix defaults `HOST = "0.0.0.0"`, `PORT = 6006`, `GRPC_PORT = 4317` at arize-phoenix-v20.27.0, and `get_env_host()` returns `PHOENIX_HOST` or that default (checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L3131-L3137 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L3364-L3365 - Phoenix gRPC OTLP server at arize-phoenix-v21.0.0, which binds the host it is given through `_bind_address` and adds `ApiKeyInterceptor` only when it has a token store (checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/grpc_server.py#L55-L82 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/grpc_server.py#L109-L131 - Phoenix gRPC OTLP server at arize-phoenix-v20.27.0, which binds `[::]` regardless of the HTTP host and adds `ApiKeyInterceptor` only when it has a token store (checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/grpc_server.py#L55-L80 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/grpc_server.py#L107-L109 - Phoenix gRPC OTLP Export handler at arize-phoenix-v21.0.0, with the same lines at arize-phoenix-v20.27.0 (`Servicer.Export` decodes and enqueues each span in the request and returns an empty `ExportTraceServiceResponse`; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/grpc_server.py#L41-L52 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/grpc_server.py#L41-L52 - Phoenix `serve` command at arize-phoenix-v21.0.0 (bind host from `get_env_host()`, auth settings from `get_env_auth_settings()`, `grpc_host=host`, and the HTTP server's `host=None if host == "::" else host`; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/cli/commands/serve.py#L238-L246 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/cli/commands/serve.py#L363-L393 - Phoenix `serve` command at arize-phoenix-v20.27.0 (a `--host` option with no default, then `get_env_host()`, `::` passed to the HTTP server as `None`, no gRPC host, and `secret=auth_settings.phoenix_secret`; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/cli/commands/serve.py#L148-L160, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/cli/commands/serve.py#L244-L253 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/cli/commands/serve.py#L383-L417 - Phoenix `create_app` at arize-phoenix-v21.0.0 (the gRPC host defaulting to `get_env_host()`, the admin bootstrap added on every start, the token store only with authentication enabled, and a deployment-seed signing key without `PHOENIX_SECRET`; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/app.py#L1007-L1014, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/app.py#L1060-L1069 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/app.py#L704-L739 - Phoenix `create_app` at arize-phoenix-v20.27.0 (`GrpcServer` built with no host, and the token store only with authentication enabled and a secret; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/app.py#L694-L702 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/app.py#L1018-L1027 - Phoenix `Dockerfile`, `ENV PHOENIX_HOST=::` at arize-phoenix-v21.0.0 and no `PHOENIX_HOST` at arize-phoenix-v20.27.0, each with the `serve` command (checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/Dockerfile#L160-L174 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/Dockerfile#L160-L172 - Phoenix migration notes for v20.x to v21.0.0 at arize-phoenix-v21.0.0 (loopback default bind, authentication following the bind address, Docker authentication by default, `--host` removed, default-admin credentials, optional `PHOENIX_SECRET` and saved credentials; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/MIGRATION.md#L3-L63 - Phoenix vendor Compose file at arize-phoenix-v21.0.0, the same file at arize-phoenix-v20.27.0 (`6006:6006`, `4317:4317`, and PostgreSQL `- 5432` with user and password `postgres`; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/docker-compose.yml#L1-L23 - Phoenix authentication settings at arize-phoenix-v21.0.0 (`PHOENIX_ENABLE_AUTH` following a non-loopback `PHOENIX_HOST` when unset, optional `PHOENIX_SECRET` with saved credentials readable by database readers when unset, and a later start applying a custom `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD` only while the local default admin still needs a reset and has `admin`; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L415-L467, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L1274-L1278, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L1509-L1534 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/db/facilitator.py#L115-L219 - Phoenix authentication settings at arize-phoenix-v20.27.0 (`PHOENIX_ENABLE_AUTH` default `False`, `PHOENIX_SECRET` required when auth is enabled, and `PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD` with no effect once the default admin record exists; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L403-L449, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L1256-L1260 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L1491-L1501 - Phoenix default admin bootstrap at arize-phoenix-v21.0.0 (creates `admin@localhost` with `reset_password=True`, and on a later start, while that account still needs a reset, replaces a still-default password with a custom initial password, deletes its reset tokens and ends its sessions; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/db/facilitator.py#L115-L219 - Phoenix default admin bootstrap at arize-phoenix-v20.27.0 (creates `admin@localhost` only when no admin exists, as a step added to every start; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/db/facilitator.py#L113-L160 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/app.py#L972 - Phoenix password login at arize-phoenix-v21.0.0 (the `/auth` router's `POST /login` and `POST /password-reset` routes; `POST /auth/login` with the default password answers HTTP 403 with a password-reset token, and `POST /auth/password-reset` refuses `admin` as the new password and otherwise sets a new password that passes its format check; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/auth.py#L131-L138, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/auth.py#L147-L198 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/auth.py#L311-L341 - Phoenix password login at arize-phoenix-v20.27.0 (the `/auth` router's `POST /login` and `POST /password-reset` routes; `POST /auth/login` issues tokens for any valid password, with no default-password check; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/api/routers/auth.py#L128-L135 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/api/routers/auth.py#L144-L186 - Phoenix `/v1` REST authentication and viewer write refusal at arize-phoenix-v21.0.0, the same files at arize-phoenix-v20.27.0 (`is_authenticated` on the `/v1` router only with authentication enabled, the bearer backend accepting the admin secret, an API key or an access token, 401 without a valid token, `restrict_access_by_viewers` answering a viewer's non-GET request with 403, and `GET /v1/projects` and `POST /v1/traces` under that router; checked November 2026): , https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/bearer_auth.py#L94-L109, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/bearer_auth.py#L213-L282, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/authorization.py#L73-L84, https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/projects.py#L66-L67 and https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/traces.py#L431-L433 - Phoenix OTLP/HTTP traces handler at arize-phoenix-v21.0.0, the same file at arize-phoenix-v20.27.0 (`post_traces` answers 415 for an unsupported content type or content encoding, then parses the body with `ParseFromString`, answers 422 when that parse fails, and otherwise returns an empty `ExportTraceServiceResponse` with status 200; checked November 2026): https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/traces.py#L468-L500 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - langfuse: Langfuse documentation (unknown) - phoenix-bind: Phoenix listener source (arize-phoenix-v21.0.0) - phoenix-bind-20: Phoenix listener source (arize-phoenix-v20.27.0) - otel: OpenTelemetry Collector (v0.161.0) - otel-dist: Collector distributions (v0.161.0) - lf-session: Langfuse session source (24c949d8dd5617219a8415f80e4c65ad611ff05c) - lf-compose: Langfuse Compose (0dd0a7fbe2feb300b8776f02b3684eeee3fbceab) - phoenix: Phoenix documentation (unknown) - phoenix-tls-min: Phoenix TLS announcement (unknown) - phoenix-tls: Phoenix TLS source (arize-phoenix-v21.0.0) - phoenix-auth: Phoenix auth source (arize-phoenix-v21.0.0) - phoenix-auth-20: Phoenix auth source (arize-phoenix-v20.27.0) - helicone: Helicone documentation (unknown) - helicone-compose: Helicone Compose (b12ebaccb824ab9778757ca197ff302d97fda421) - otel-docs: Collector security guidance (unknown) - basicauth: Collector Basic auth extension (1c897ba9c67afc3c9e218b5cd05a6de49435f4de) - bearertokenauth: Collector bearer token auth extension (1c897ba9c67afc3c9e218b5cd05a6de49435f4de) - pprof: Collector pprof source (443567a6a00d7cff8cae1432a6fef655d8698e94) - health: Collector health source (d922ffb299c6b9be026f97dd7d6a5f0f507efdeb) - curl: curl minimum (7.75.0) Claims (full source mapping in the JSON block below): - lf-signup (REASONED; langfuse): Email/password and public signup default on; AUTH_DISABLE_SIGNUP=true also prevents invite acceptance by new users. - lf-bootstrap (REASONED; langfuse): Create first account before closing signup, or initialize org before headless user/project with LANGFUSE_INIT_*. - lf-sso (REASONED; langfuse): AUTH_DISABLE_USERNAME_PASSWORD=true requires SSO; supported Auth.js providers need correct NEXTAUTH_URL beyond password login. - lf-session (REASONED; lf-session, langfuse): AUTH_SESSION_MAX_AGE must be integer >5 minutes; source default is 20160, while documentation says 43200; check pinned release. - lf-api (REASONED; langfuse): Ingestion/public API use project public-key username and secret-key password via Basic auth, separately from UI credentials. - lf-mfa (REASONED; langfuse): Langfuse login has no native MFA; enforce it at the identity provider. - lf-session-secret (REASONED; langfuse, lf-compose): Replace shipped NEXTAUTH_SECRET=mysecret before first start; known session protection secret permits forged sessions bypassing sign-in controls. - lf-salt (REASONED; langfuse, lf-compose): Replace shipped SALT=mysalt used for API-key hashes with a separate random value. - lf-encryption (REASONED; langfuse, lf-compose): Replace all-zero ENCRYPTION_KEY with 32 random hex bytes; later changes need re-encryption and exposed provider credentials need rotation. - lf-minio (REASONED; lf-compose, langfuse): Rotate bundled minio/miniosecret and matching LANGFUSE_S3_* keys; MinIO S3 host 9090 is wildcard-published. - lf-ports (REASONED; lf-compose): langfuse-web host 3000 is wildcard-published; bundled Postgres, ClickHouse and Redis default loopback; keep all publications private. - lf-tls (REASONED; langfuse): Container deployment needs proxy TLS; an HTTPS public URL does not establish native Langfuse TLS. - phoenix-enable (REASONED; phoenix, phoenix-auth, phoenix-auth-20): At 20.27.0 auth defaults off and needs PHOENIX_ENABLE_AUTH=True with PHOENIX_SECRET; at 21.0.0 an unset PHOENIX_ENABLE_AUTH turns auth on for any non-loopback PHOENIX_HOST, and PHOENIX_SECRET is optional, but without it saved credentials are readable by database readers; set both explicitly. - phoenix-admin (REASONED; phoenix, phoenix-bind, phoenix-auth, phoenix-auth-20): Startup creates admin@localhost/admin unless PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD is set first; later changes are inert at 20.27.0, while 21.0.0 applies a custom value only while the local default admin still needs a reset and has the default password, then revokes its sessions, though running replicas can continue accepting cached tokens until stopped, and its API keys stay valid. - phoenix-password (REASONED; phoenix, phoenix-auth, phoenix-auth-20): Existing admin needs UI password replacement before exposure; admin/admin opens a session at 20.27.0 and gets a password-reset token at 21.0.0; remove the initial-password variable once the password is changed. - phoenix-http (REASONED; phoenix-bind, phoenix-bind-20): HTTP UI/REST/OTLP binds PHOENIX_HOST, or --host at 20.27.0, defaulting to 0.0.0.0:6006 at 20.27.0 and 127.0.0.1:6006 at 21.0.0, whose Dockerfile sets PHOENIX_HOST=::; vendor Compose publishes 6006 and 4317 and lists PostgreSQL ports; restrict bind, publication and database exposure. - phoenix-grpc (REASONED; phoenix-bind, phoenix-bind-20): Separate OTLP gRPC on 4317 binds [::] regardless of PHOENIX_HOST at 20.27.0 but binds PHOENIX_HOST, default 127.0.0.1, at 21.0.0; isolate namespace, publication and firewall separately. - phoenix-keys (REASONED; phoenix, phoenix-bind, phoenix-auth): Enabling auth rejects unauthenticated collection/API requests; system or user API keys sent as PHOENIX_API_KEY Bearer are recommended for collectors, though the admin secret and access tokens also authenticate; at 21.0.0 a non-loopback PHOENIX_HOST with PHOENIX_ENABLE_AUTH unset enables auth on its own. - phoenix-mfa (REASONED; phoenix): Documentation describes no native MFA and none was found in the 21.0.0 files read, though absence is not established; federate OAuth2/OIDC or use identity-aware ingress and enforce MFA there. - phoenix-signup (REASONED; phoenix): PHOENIX_OAUTH2_<IDP>_ALLOW_SIGN_UP defaults True; set False and restrict provider membership. - phoenix-basic (REASONED; phoenix): Local password login remains beside SSO; PHOENIX_DISABLE_BASIC_AUTH=True closes it after an approved IdP administrator is tested. - phoenix-tls (REASONED; phoenix-tls-min, phoenix-tls): Native HTTP/gRPC TLS exists from 8.29, defaults off, uses certificate/key files; per-protocol switches overriding PHOENIX_TLS_ENABLED exist at 20.27.0 and 21.0.0, added in a release not established. - helicone-login (REASONED; helicone): Better Auth uses signup and organizations; test@helicone.ai/password is a local manual trial, not the deployment security model. - helicone-secret (REASONED; helicone, helicone-compose): Replace BETTER_AUTH_SECRET examples change-me-in-production and Compose your-secret-key before first start. - helicone-ports (REASONED; helicone-compose): Backing Postgres/ClickHouse/MinIO/Redis/MailHog publications bypass UI login, including 54388:5432 and 18123:8123; remove or loopback-scope them. - helicone-boundary (REASONED; helicone, helicone-compose): Restrict signup, protect dashboard/Jawn/S3 and gateway separately with private networking or authenticated ingress and MFA; rotate example storage credentials. - helicone-ingest (REASONED; helicone): AI Gateway ingestion and provider environment keys are separate from web sessions; protect those secrets. - otel-default (REASONED; otel, otel-docs): OTLP factory defaults localhost:4317 gRPC and localhost:4318 HTTP at v0.161.0; bind every enabled receiver explicitly. - otel-history (REASONED; otel): OTLP localhost transition was v0.104.0; UseLocalHostAsDefaultHost stabilized v0.110.0 and was removed v0.112.0. - otel-shipped (REASONED; otel-dist): Both shipped configs override component defaults with unauthenticated IPv4 wildcard OTLP 4317/4318, Jaeger 14250/6832/6831/14268 and Zipkin 9411. - otel-diagnostics (REASONED; otel-dist, otel, pprof): Shipped pprof 1777 and zPages 55679 are wildcard and unauthenticated; remove from extensions/service.extensions or restrict separately. - otel-images (REASONED; otel-dist): Dockerfiles COPY/select shipped config; EXPOSE 4317/4318/55679 is not host publication, but container peers may reach wildcard listeners. - otel-packages (REASONED; otel-dist): Systemd reads distribution otelcol.conf; OTELCOL_OPTIONS selects config.yaml; config|noreplace installation can retain local config, and postinstall manages service. - otel-tls (REASONED; otel-docs): Require TLS on receivers/exporters and authentication on every receiver protocol accepting off-host data. - otel-auth (REASONED; otel-docs, basicauth): Declare authenticator, start it in service.extensions, and attach each receiver protocol with auth.authenticator; Basic supports htpasswd/client_auth. - otel-bearer (REASONED; otel-docs, bearertokenauth): bearertokenauth accepts a static token or token file; filename takes precedence over token, and each file line with a non-whitespace field contributes its first whitespace-delimited field. The configurable header (default Authorization) and scheme (default Bearer) apply to HTTP and gRPC outgoing calls; the header becomes the lower-case gRPC metadata key. On incoming requests, Authenticate checks the canonical HTTP header or lower-case gRPC metadata key and compares its first value with each configured scheme-prefixed token and accepts any match; outgoing calls send only the first token. - otel-metrics (REASONED; otel, otel-dist): Internal metrics factory defaults localhost:8888; shipped configs set 127.0.0.1:8888; 0.0.0.0:8888 scrape target is outbound. - otel-health (REASONED; health, otel-dist): Review enabled health-check 13133 independently of receiver authentication. - otel-k8s (REASONED; otel-dist): otelcol-k8s Dockerfile supplies no default config COPY/CMD; EXPOSE alone establishes no listener; Helm/operator are out of scope. - otel-minimal (REASONED; otel-docs): Run only required components and use a non-root process. - verify-inventory (REASONED; otel-dist, otel-docs, phoenix-bind, phoenix-bind-20): Inspect effective config, namespace TCP/UDP, publications and firewall; shipped wildcard listeners should become intended private listeners; outside failures need positive controls. - verify-dashboard (REASONED; langfuse): Langfuse curl is reachability only; fresh unauthenticated browser must show login rather than project data. - verify-lf-api (REASONED; langfuse): Direct /api/public/projects pair should yield 401 without key and 200 with expected project ID; proxy-only results cannot prove native auth. - verify-collector (REASONED; otel-docs, basicauth): Direct OTLP/HTTP JSON /v1/traces should reject anonymous 401/403 and admit configured auth 2xx; wrong content type and failed connections are inconclusive. - verify-phoenix-admin (REASONED; phoenix, phoenix-bind, phoenix-auth, phoenix-auth-20): Default admin/admin must fail, with a 20.27.0 session or a 21.0.0 reset token both exposed, and the replacement password must succeed; with basic auth disabled, require local-login refusal and approved MFA IdP success. - verify-phoenix-read (REASONED; phoenix, phoenix-auth, curl): Anonymous /v1/projects should reject 401/403, versus exposed project data 2xx; 404, redirect, 415 and transport errors are inconclusive. - verify-phoenix-http (REASONED; phoenix, phoenix-auth): Empty protobuf POST /v1/traces on 6006 tests admission, not persistence: anonymous rejects, write-authorized key admits, viewer key gets 403 and is not a positive control. - verify-phoenix-grpc (REASONED; phoenix-auth, phoenix-bind, phoenix-bind-20): Separate empty gRPC TraceService/Export requires UNAUTHENTICATED without key, OK with write key; auth-off anonymous OK and viewer PERMISSION_DENIED remain source-reasoned. ````json { "schema": 1, "checked": "2026-11-04", "documentation_checked": "2026-09", "body_sha256": "fedfe69f91436b52bb1b513ae54fb4099e5dd17eed91691b4f6f6bea16ebe19b", "components": { "langfuse": { "name": "Langfuse documentation", "basis": "unknown", "sources": { "s34b0fb076e86": "https://langfuse.com/self-hosting/security/authentication-and-sso", "sfdfc0ea9fdc1": "https://langfuse.com/docs/api-and-data-platform/features/public-api", "sb92c8a6aaad1": "https://langfuse.com/self-hosting/configuration", "scd1d81693f96": "https://langfuse.com/self-hosting/administration/headless-initialization" } }, "phoenix-bind": { "name": "Phoenix listener source", "basis": "arize-phoenix-v21.0.0", "sources": { "s1e4c3ebd33de": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L55-L59", "se0340e8cbaba": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L3166-L3172", "s4618901856d4": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L3425-L3447", "s74e11942206c": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/grpc_server.py#L55-L82", "s1b9b41c2b9da": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/grpc_server.py#L109-L131", "sf4f82f3650d7": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/grpc_server.py#L41-L52", "s9294363c778c": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/cli/commands/serve.py#L238-L246", "s4746650f1449": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/cli/commands/serve.py#L363-L393", "sfcdf7957ab53": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/app.py#L1007-L1014", "s22756c69d535": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/app.py#L1060-L1069", "s62273aeb92e3": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/app.py#L704-L739", "s93abe2c011ec": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/Dockerfile#L160-L174", "s8402a7dc0c90": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/MIGRATION.md#L3-L63", "s053ea1feac0e": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/docker-compose.yml#L1-L23" } }, "phoenix-bind-20": { "name": "Phoenix listener source", "basis": "arize-phoenix-v20.27.0", "sources": { "scfb88225f2ad": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L3131-L3137", "s68717d339677": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L3364-L3365", "sab369cec4af9": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/grpc_server.py#L55-L80", "s78a38870f8ea": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/grpc_server.py#L107-L109", "s1cb038a6327a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/grpc_server.py#L41-L52", "s27bd68072127": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/cli/commands/serve.py#L148-L160", "s566f5ab791a7": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/cli/commands/serve.py#L244-L253", "s8fb8f6b9d2f3": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/cli/commands/serve.py#L383-L417", "sd9548a2295ae": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/app.py#L694-L702", "s5f40ca1e1785": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/app.py#L1018-L1027", "s6c614a43f382": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/Dockerfile#L160-L172" } }, "otel": { "name": "OpenTelemetry Collector", "basis": "v0.161.0", "sources": { "s6eb17ad3eb47": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/receiver/otlpreceiver/factory.go#L41-L67", "s3252b8ba9775": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/CHANGELOG.md#L2058-L2070", "s5a13f52d7e6d": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/CHANGELOG.md#L1793-L1811", "sb419d6444c19": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/CHANGELOG.md#L1737-L1743", "s9780bf233e75": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/service/telemetry/otelconftelemetry/factory.go#L49-L64", "se93854d097ec": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/extension/zpagesextension/factory.go#L15-L29", "s6957342b0bf4": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/extension/zpagesextension/zpagesextension.go#L87-L105", "sdac173d533e6": "https://github.com/open-telemetry/opentelemetry-collector/blob/v0.161.0/config/confighttp/server.go#L352-L364" } }, "otel-dist": { "name": "Collector distributions", "basis": "v0.161.0", "sources": { "s08ca284a6d6f": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/config.yaml#L1-L81", "s9d2ef3c6b75d": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/config.yaml#L1-L81", "s7a517873f7be": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/Dockerfile#L10-L15", "sc806e2cc23e6": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/Dockerfile#L10-L15", "s96832924ecbd": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/.goreleaser.yaml#L86-L118", "s3cf497a4401c": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/.goreleaser.yaml#L94-L126", "s532d68e28e68": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/otelcol.service#L5-L7", "saa59feb48156": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/otelcol-contrib.service#L5-L7", "s73f6337cd50e": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/otelcol.conf#L1-L5", "sff25af5f7a79": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/otelcol-contrib.conf#L1-L5", "sd829f5067d73": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/postinstall.sh#L6-L16", "secd03f9c13d6": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/postinstall.sh#L6-L16", "s353fc01c0b2c": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol/postinstall-rpm.sh#L6-L9", "s72fc7a0602d1": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-contrib/postinstall-rpm.sh#L6-L9", "sb93573df35e2": "https://github.com/open-telemetry/opentelemetry-collector-releases/blob/v0.161.0/distributions/otelcol-k8s/Dockerfile#L1-L17" } }, "lf-session": { "name": "Langfuse session source", "basis": "24c949d8dd5617219a8415f80e4c65ad611ff05c", "sources": { "s01522120437c": "https://github.com/langfuse/langfuse/blob/24c949d8dd5617219a8415f80e4c65ad611ff05c/web/src/env.mjs" } }, "lf-compose": { "name": "Langfuse Compose", "basis": "0dd0a7fbe2feb300b8776f02b3684eeee3fbceab", "sources": { "s4d19a7b83526": "https://github.com/langfuse/langfuse/blob/0dd0a7fbe2feb300b8776f02b3684eeee3fbceab/docker-compose.yml" } }, "phoenix": { "name": "Phoenix documentation", "basis": "unknown", "sources": { "s5a90699b7950": "https://arize.com/docs/phoenix/self-hosting/features/authentication" } }, "phoenix-tls-min": { "name": "Phoenix TLS announcement", "basis": "unknown", "sources": { "s8af9d57a7636": "https://arize.com/docs/phoenix/release-notes/04-2025/04-28-2025-tls-support-for-phoenix-server" } }, "phoenix-tls": { "name": "Phoenix TLS source", "basis": "arize-phoenix-v21.0.0", "sources": { "s8c5c46660f2a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L853-L874", "s3ed506b65674": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L1032-L1057" } }, "phoenix-auth": { "name": "Phoenix auth source", "basis": "arize-phoenix-v21.0.0", "sources": { "s253973703f36": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/bearer_auth.py#L152-L190", "s036fb8ea6f51": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L415-L467", "sdbaa6371c568": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L1274-L1278", "s438ca2a1d10a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/config.py#L1509-L1534", "s921dc8bddc0a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/db/facilitator.py#L115-L219", "s12d17ea860a0": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/auth.py#L131-L138", "s9b5694263627": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/auth.py#L147-L198", "sb292bb2171aa": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/auth.py#L311-L341", "se91e507879d3": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/__init__.py#L35-L83", "sd4999c5d2719": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/bearer_auth.py#L94-L109", "sc7c47b61ba20": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/bearer_auth.py#L213-L282", "sb802793835cd": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/authorization.py#L73-L84", "s00e9a72f392a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/projects.py#L66-L67", "se7438e02fc6a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/traces.py#L431-L433", "saff9365321eb": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v21.0.0/src/phoenix/server/api/routers/v1/traces.py#L468-L500" } }, "phoenix-auth-20": { "name": "Phoenix auth source", "basis": "arize-phoenix-v20.27.0", "sources": { "s710acd292052": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L403-L449", "sbb3f3e1ef927": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L1256-L1260", "s530d72b9665c": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/config.py#L1491-L1501", "s048f854055c7": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/db/facilitator.py#L113-L160", "sabe5c0470bf0": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/app.py#L972", "s341552cade7a": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/api/routers/auth.py#L128-L135", "s8a3e177589da": "https://github.com/Arize-ai/phoenix/blob/arize-phoenix-v20.27.0/src/phoenix/server/api/routers/auth.py#L144-L186" } }, "helicone": { "name": "Helicone documentation", "basis": "unknown", "sources": { "s48d6e38bf1fa": "https://docs.helicone.ai/getting-started/self-host/manual", "sac4298a08e00": "https://docs.helicone.ai/getting-started/self-host/docker" } }, "helicone-compose": { "name": "Helicone Compose", "basis": "b12ebaccb824ab9778757ca197ff302d97fda421", "sources": { "s05ca7cfb09fe": "https://github.com/Helicone/helicone/blob/b12ebaccb824ab9778757ca197ff302d97fda421/docker/docker-compose.yml" } }, "otel-docs": { "name": "Collector security guidance", "basis": "unknown", "sources": { "s5fc11440844b": "https://opentelemetry.io/docs/security/config-best-practices/" } }, "basicauth": { "name": "Collector Basic auth extension", "basis": "1c897ba9c67afc3c9e218b5cd05a6de49435f4de", "sources": { "s473397b3887e": "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/basicauthextension" } }, "bearertokenauth": { "name": "Collector bearer token auth extension", "basis": "1c897ba9c67afc3c9e218b5cd05a6de49435f4de", "sources": { "sc102b008afb0": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/README.md#L21-L39", "s664ba81d1aa6": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L30-L43", "saa550790364b": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L73-L122", "s93d4733c051d": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L156-L189", "s828ec6427328": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L197-L205", "s48a3bfa9c99e": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L234-L255", "s4326cb59f96e": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/bearertokenauth.go#L264-L272", "sa8d47408d195": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/1c897ba9c67afc3c9e218b5cd05a6de49435f4de/extension/bearertokenauthextension/factory.go#L15-L18" } }, "pprof": { "name": "Collector pprof source", "basis": "443567a6a00d7cff8cae1432a6fef655d8698e94", "sources": { "s3f2efc571022": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/443567a6a00d7cff8cae1432a6fef655d8698e94/extension/pprofextension/README.md" } }, "health": { "name": "Collector health source", "basis": "d922ffb299c6b9be026f97dd7d6a5f0f507efdeb", "sources": { "s6ac60517accc": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/d922ffb299c6b9be026f97dd7d6a5f0f507efdeb/extension/healthcheckextension/README.md" } }, "curl": { "name": "curl minimum", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } } }, "claims": { "lf-signup": {"text": "Email/password and public signup default on; AUTH_DISABLE_SIGNUP=true also prevents invite acceptance by new users.", "components": ["langfuse"], "sources": ["langfuse:s34b0fb076e86"], "status": "REASONED"}, "lf-bootstrap": {"text": "Create first account before closing signup, or initialize org before headless user/project with LANGFUSE_INIT_*.", "components": ["langfuse"], "sources": ["langfuse:scd1d81693f96", "langfuse:s34b0fb076e86"], "status": "REASONED"}, "lf-sso": {"text": "AUTH_DISABLE_USERNAME_PASSWORD=true requires SSO; supported Auth.js providers need correct NEXTAUTH_URL beyond password login.", "components": ["langfuse"], "sources": ["langfuse:s34b0fb076e86"], "status": "REASONED"}, "lf-session": {"text": "AUTH_SESSION_MAX_AGE must be integer >5 minutes; source default is 20160, while documentation says 43200; check pinned release.", "components": ["lf-session", "langfuse"], "sources": ["lf-session:s01522120437c", "langfuse:s34b0fb076e86"], "status": "REASONED"}, "lf-api": {"text": "Ingestion/public API use project public-key username and secret-key password via Basic auth, separately from UI credentials.", "components": ["langfuse"], "sources": ["langfuse:sfdfc0ea9fdc1"], "status": "REASONED"}, "lf-mfa": {"text": "Langfuse login has no native MFA; enforce it at the identity provider.", "components": ["langfuse"], "sources": ["langfuse:s34b0fb076e86"], "status": "REASONED"}, "lf-session-secret": {"text": "Replace shipped NEXTAUTH_SECRET=mysecret before first start; known session protection secret permits forged sessions bypassing sign-in controls.", "components": ["langfuse", "lf-compose"], "sources": ["langfuse:sb92c8a6aaad1", "lf-compose:s4d19a7b83526"], "status": "REASONED"}, "lf-salt": {"text": "Replace shipped SALT=mysalt used for API-key hashes with a separate random value.", "components": ["langfuse", "lf-compose"], "sources": ["langfuse:sb92c8a6aaad1", "lf-compose:s4d19a7b83526"], "status": "REASONED"}, "lf-encryption": {"text": "Replace all-zero ENCRYPTION_KEY with 32 random hex bytes; later changes need re-encryption and exposed provider credentials need rotation.", "components": ["langfuse", "lf-compose"], "sources": ["langfuse:sb92c8a6aaad1", "lf-compose:s4d19a7b83526"], "status": "REASONED"}, "lf-minio": {"text": "Rotate bundled minio/miniosecret and matching LANGFUSE_S3_* keys; MinIO S3 host 9090 is wildcard-published.", "components": ["lf-compose", "langfuse"], "sources": ["lf-compose:s4d19a7b83526", "langfuse:sb92c8a6aaad1"], "status": "REASONED"}, "lf-ports": {"text": "langfuse-web host 3000 is wildcard-published; bundled Postgres, ClickHouse and Redis default loopback; keep all publications private.", "components": ["lf-compose"], "sources": ["lf-compose:s4d19a7b83526"], "status": "REASONED"}, "lf-tls": {"text": "Container deployment needs proxy TLS; an HTTPS public URL does not establish native Langfuse TLS.", "components": ["langfuse"], "sources": ["langfuse:sb92c8a6aaad1"], "status": "REASONED"}, "phoenix-enable": {"text": "At 20.27.0 auth defaults off and needs PHOENIX_ENABLE_AUTH=True with PHOENIX_SECRET; at 21.0.0 an unset PHOENIX_ENABLE_AUTH turns auth on for any non-loopback PHOENIX_HOST, and PHOENIX_SECRET is optional, but without it saved credentials are readable by database readers; set both explicitly.", "components": ["phoenix", "phoenix-auth", "phoenix-auth-20"], "sources": ["phoenix:s5a90699b7950", "phoenix-auth:s036fb8ea6f51", "phoenix-auth:sdbaa6371c568", "phoenix-auth:s438ca2a1d10a", "phoenix-auth-20:sbb3f3e1ef927", "phoenix-auth-20:s530d72b9665c"], "status": "REASONED"}, "phoenix-admin": {"text": "Startup creates admin@localhost/admin unless PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD is set first; later changes are inert at 20.27.0, while 21.0.0 applies a custom value only while the local default admin still needs a reset and has the default password, then revokes its sessions, though running replicas can continue accepting cached tokens until stopped, and its API keys stay valid.", "components": ["phoenix", "phoenix-bind", "phoenix-auth", "phoenix-auth-20"], "sources": ["phoenix:s5a90699b7950", "phoenix-bind:s8402a7dc0c90", "phoenix-auth:s036fb8ea6f51", "phoenix-auth:s921dc8bddc0a", "phoenix-auth-20:s710acd292052", "phoenix-auth-20:s048f854055c7", "phoenix-auth-20:sabe5c0470bf0"], "status": "REASONED"}, "phoenix-password": {"text": "Existing admin needs UI password replacement before exposure; admin/admin opens a session at 20.27.0 and gets a password-reset token at 21.0.0; remove the initial-password variable once the password is changed.", "components": ["phoenix", "phoenix-auth", "phoenix-auth-20"], "sources": ["phoenix:s5a90699b7950", "phoenix-auth:s12d17ea860a0", "phoenix-auth:s9b5694263627", "phoenix-auth:sb292bb2171aa", "phoenix-auth-20:s341552cade7a", "phoenix-auth-20:s8a3e177589da"], "status": "REASONED"}, "phoenix-http": {"text": "HTTP UI/REST/OTLP binds PHOENIX_HOST, or --host at 20.27.0, defaulting to 0.0.0.0:6006 at 20.27.0 and 127.0.0.1:6006 at 21.0.0, whose Dockerfile sets PHOENIX_HOST=::; vendor Compose publishes 6006 and 4317 and lists PostgreSQL ports; restrict bind, publication and database exposure.", "components": ["phoenix-bind", "phoenix-bind-20"], "sources": ["phoenix-bind:s1e4c3ebd33de", "phoenix-bind:se0340e8cbaba", "phoenix-bind:s4618901856d4", "phoenix-bind:s9294363c778c", "phoenix-bind:s4746650f1449", "phoenix-bind:s93abe2c011ec", "phoenix-bind:s8402a7dc0c90", "phoenix-bind:s053ea1feac0e", "phoenix-bind-20:scfb88225f2ad", "phoenix-bind-20:s68717d339677", "phoenix-bind-20:s27bd68072127", "phoenix-bind-20:s566f5ab791a7", "phoenix-bind-20:s8fb8f6b9d2f3", "phoenix-bind-20:s6c614a43f382"], "status": "REASONED"}, "phoenix-grpc": {"text": "Separate OTLP gRPC on 4317 binds [::] regardless of PHOENIX_HOST at 20.27.0 but binds PHOENIX_HOST, default 127.0.0.1, at 21.0.0; isolate namespace, publication and firewall separately.", "components": ["phoenix-bind", "phoenix-bind-20"], "sources": ["phoenix-bind:se0340e8cbaba", "phoenix-bind:s74e11942206c", "phoenix-bind:s1b9b41c2b9da", "phoenix-bind:sfcdf7957ab53", "phoenix-bind:s62273aeb92e3", "phoenix-bind:s4746650f1449", "phoenix-bind-20:s78a38870f8ea", "phoenix-bind-20:sd9548a2295ae"], "status": "REASONED"}, "phoenix-keys": {"text": "Enabling auth rejects unauthenticated collection/API requests; system or user API keys sent as PHOENIX_API_KEY Bearer are recommended for collectors, though the admin secret and access tokens also authenticate; at 21.0.0 a non-loopback PHOENIX_HOST with PHOENIX_ENABLE_AUTH unset enables auth on its own.", "components": ["phoenix", "phoenix-bind", "phoenix-auth"], "sources": ["phoenix:s5a90699b7950", "phoenix-bind:s8402a7dc0c90", "phoenix-auth:sdbaa6371c568", "phoenix-auth:sd4999c5d2719", "phoenix-auth:s253973703f36"], "status": "REASONED"}, "phoenix-mfa": {"text": "Documentation describes no native MFA and none was found in the 21.0.0 files read, though absence is not established; federate OAuth2/OIDC or use identity-aware ingress and enforce MFA there.", "components": ["phoenix"], "sources": ["phoenix:s5a90699b7950"], "status": "REASONED"}, "phoenix-signup": {"text": "PHOENIX_OAUTH2__ALLOW_SIGN_UP defaults True; set False and restrict provider membership.", "components": ["phoenix"], "sources": ["phoenix:s5a90699b7950"], "status": "REASONED"}, "phoenix-basic": {"text": "Local password login remains beside SSO; PHOENIX_DISABLE_BASIC_AUTH=True closes it after an approved IdP administrator is tested.", "components": ["phoenix"], "sources": ["phoenix:s5a90699b7950"], "status": "REASONED"}, "phoenix-tls": {"text": "Native HTTP/gRPC TLS exists from 8.29, defaults off, uses certificate/key files; per-protocol switches overriding PHOENIX_TLS_ENABLED exist at 20.27.0 and 21.0.0, added in a release not established.", "components": ["phoenix-tls-min", "phoenix-tls"], "sources": ["phoenix-tls-min:s8af9d57a7636", "phoenix-tls:s8c5c46660f2a", "phoenix-tls:s3ed506b65674"], "status": "REASONED"}, "helicone-login": {"text": "Better Auth uses signup and organizations; test@helicone.ai/password is a local manual trial, not the deployment security model.", "components": ["helicone"], "sources": ["helicone:s48d6e38bf1fa", "helicone:sac4298a08e00"], "status": "REASONED"}, "helicone-secret": {"text": "Replace BETTER_AUTH_SECRET examples change-me-in-production and Compose your-secret-key before first start.", "components": ["helicone", "helicone-compose"], "sources": ["helicone:sac4298a08e00", "helicone-compose:s05ca7cfb09fe"], "status": "REASONED"}, "helicone-ports": {"text": "Backing Postgres/ClickHouse/MinIO/Redis/MailHog publications bypass UI login, including 54388:5432 and 18123:8123; remove or loopback-scope them.", "components": ["helicone-compose"], "sources": ["helicone-compose:s05ca7cfb09fe"], "status": "REASONED"}, "helicone-boundary": {"text": "Restrict signup, protect dashboard/Jawn/S3 and gateway separately with private networking or authenticated ingress and MFA; rotate example storage credentials.", "components": ["helicone", "helicone-compose"], "sources": ["helicone:sac4298a08e00", "helicone-compose:s05ca7cfb09fe"], "status": "REASONED"}, "helicone-ingest": {"text": "AI Gateway ingestion and provider environment keys are separate from web sessions; protect those secrets.", "components": ["helicone"], "sources": ["helicone:sac4298a08e00"], "status": "REASONED"}, "otel-default": {"text": "OTLP factory defaults localhost:4317 gRPC and localhost:4318 HTTP at v0.161.0; bind every enabled receiver explicitly.", "components": ["otel", "otel-docs"], "sources": ["otel:s6eb17ad3eb47", "otel-docs:s5fc11440844b"], "status": "REASONED"}, "otel-history": {"text": "OTLP localhost transition was v0.104.0; UseLocalHostAsDefaultHost stabilized v0.110.0 and was removed v0.112.0.", "components": ["otel"], "sources": ["otel:s3252b8ba9775", "otel:s5a13f52d7e6d", "otel:sb419d6444c19"], "status": "REASONED"}, "otel-shipped": {"text": "Both shipped configs override component defaults with unauthenticated IPv4 wildcard OTLP 4317/4318, Jaeger 14250/6832/6831/14268 and Zipkin 9411.", "components": ["otel-dist"], "sources": ["otel-dist:s08ca284a6d6f", "otel-dist:s9d2ef3c6b75d"], "status": "REASONED"}, "otel-diagnostics": {"text": "Shipped pprof 1777 and zPages 55679 are wildcard and unauthenticated; remove from extensions/service.extensions or restrict separately.", "components": ["otel-dist", "otel", "pprof"], "sources": ["otel-dist:s08ca284a6d6f", "otel-dist:s9d2ef3c6b75d", "otel:se93854d097ec", "otel:s6957342b0bf4", "otel:sdac173d533e6", "pprof:s3f2efc571022"], "status": "REASONED"}, "otel-images": {"text": "Dockerfiles COPY/select shipped config; EXPOSE 4317/4318/55679 is not host publication, but container peers may reach wildcard listeners.", "components": ["otel-dist"], "sources": ["otel-dist:s7a517873f7be", "otel-dist:sc806e2cc23e6"], "status": "REASONED"}, "otel-packages": {"text": "Systemd reads distribution otelcol.conf; OTELCOL_OPTIONS selects config.yaml; config|noreplace installation can retain local config, and postinstall manages service.", "components": ["otel-dist"], "sources": ["otel-dist:s96832924ecbd", "otel-dist:s3cf497a4401c", "otel-dist:s532d68e28e68", "otel-dist:saa59feb48156", "otel-dist:s73f6337cd50e", "otel-dist:sff25af5f7a79", "otel-dist:sd829f5067d73", "otel-dist:secd03f9c13d6", "otel-dist:s353fc01c0b2c", "otel-dist:s72fc7a0602d1"], "status": "REASONED"}, "otel-tls": {"text": "Require TLS on receivers/exporters and authentication on every receiver protocol accepting off-host data.", "components": ["otel-docs"], "sources": ["otel-docs:s5fc11440844b"], "status": "REASONED"}, "otel-auth": {"text": "Declare authenticator, start it in service.extensions, and attach each receiver protocol with auth.authenticator; Basic supports htpasswd/client_auth.", "components": ["otel-docs", "basicauth"], "sources": ["otel-docs:s5fc11440844b", "basicauth:s473397b3887e"], "status": "REASONED"}, "otel-bearer": {"text": "bearertokenauth accepts a static token or token file; filename takes precedence over token, and each file line with a non-whitespace field contributes its first whitespace-delimited field. The configurable header (default Authorization) and scheme (default Bearer) apply to HTTP and gRPC outgoing calls; the header becomes the lower-case gRPC metadata key. On incoming requests, Authenticate checks the canonical HTTP header or lower-case gRPC metadata key and compares its first value with each configured scheme-prefixed token and accepts any match; outgoing calls send only the first token.", "components": ["otel-docs", "bearertokenauth"], "sources": ["otel-docs:s5fc11440844b", "bearertokenauth:sc102b008afb0", "bearertokenauth:s664ba81d1aa6", "bearertokenauth:saa550790364b", "bearertokenauth:s93d4733c051d", "bearertokenauth:s828ec6427328", "bearertokenauth:s48a3bfa9c99e", "bearertokenauth:s4326cb59f96e", "bearertokenauth:sa8d47408d195"], "status": "REASONED"}, "otel-metrics": {"text": "Internal metrics factory defaults localhost:8888; shipped configs set 127.0.0.1:8888; 0.0.0.0:8888 scrape target is outbound.", "components": ["otel", "otel-dist"], "sources": ["otel:s9780bf233e75", "otel-dist:s08ca284a6d6f", "otel-dist:s9d2ef3c6b75d"], "status": "REASONED"}, "otel-health": {"text": "Review enabled health-check 13133 independently of receiver authentication.", "components": ["health", "otel-dist"], "sources": ["health:s6ac60517accc", "otel-dist:s08ca284a6d6f", "otel-dist:s9d2ef3c6b75d"], "status": "REASONED"}, "otel-k8s": {"text": "otelcol-k8s Dockerfile supplies no default config COPY/CMD; EXPOSE alone establishes no listener; Helm/operator are out of scope.", "components": ["otel-dist"], "sources": ["otel-dist:sb93573df35e2"], "status": "REASONED"}, "otel-minimal": {"text": "Run only required components and use a non-root process.", "components": ["otel-docs"], "sources": ["otel-docs:s5fc11440844b"], "status": "REASONED"}, "verify-inventory": {"text": "Inspect effective config, namespace TCP/UDP, publications and firewall; shipped wildcard listeners should become intended private listeners; outside failures need positive controls.", "components": ["otel-dist", "otel-docs", "phoenix-bind", "phoenix-bind-20"], "sources": ["otel-dist:s08ca284a6d6f", "otel-dist:s9d2ef3c6b75d", "otel-docs:s5fc11440844b", "phoenix-bind:se0340e8cbaba", "phoenix-bind:s1b9b41c2b9da", "phoenix-bind-20:scfb88225f2ad", "phoenix-bind-20:s78a38870f8ea"], "status": "REASONED", "verify": [1]}, "verify-dashboard": {"text": "Langfuse curl is reachability only; fresh unauthenticated browser must show login rather than project data.", "components": ["langfuse"], "sources": ["langfuse:s34b0fb076e86"], "status": "REASONED", "verify": [2]}, "verify-lf-api": {"text": "Direct /api/public/projects pair should yield 401 without key and 200 with expected project ID; proxy-only results cannot prove native auth.", "components": ["langfuse"], "sources": ["langfuse:sfdfc0ea9fdc1"], "status": "REASONED", "verify": [2]}, "verify-collector": {"text": "Direct OTLP/HTTP JSON /v1/traces should reject anonymous 401/403 and admit configured auth 2xx; wrong content type and failed connections are inconclusive.", "components": ["otel-docs", "basicauth"], "sources": ["otel-docs:s5fc11440844b", "basicauth:s473397b3887e"], "status": "REASONED", "verify": [3]}, "verify-phoenix-admin": {"text": "Default admin/admin must fail, with a 20.27.0 session or a 21.0.0 reset token both exposed, and the replacement password must succeed; with basic auth disabled, require local-login refusal and approved MFA IdP success.", "components": ["phoenix", "phoenix-bind", "phoenix-auth", "phoenix-auth-20"], "sources": ["phoenix:s5a90699b7950", "phoenix-bind:s8402a7dc0c90", "phoenix-auth:s921dc8bddc0a", "phoenix-auth:s12d17ea860a0", "phoenix-auth:s9b5694263627", "phoenix-auth:sb292bb2171aa", "phoenix-auth-20:s048f854055c7", "phoenix-auth-20:s341552cade7a", "phoenix-auth-20:s8a3e177589da"], "status": "REASONED"}, "verify-phoenix-read": {"text": "Anonymous /v1/projects should reject 401/403, versus exposed project data 2xx; 404, redirect, 415 and transport errors are inconclusive.", "components": ["phoenix", "phoenix-auth", "curl"], "sources": ["phoenix:s5a90699b7950", "phoenix-auth:se91e507879d3", "phoenix-auth:sc7c47b61ba20", "phoenix-auth:s00e9a72f392a", "curl:s2b2686afaf41"], "status": "REASONED", "verify": [4]}, "verify-phoenix-http": {"text": "Empty protobuf POST /v1/traces on 6006 tests admission, not persistence: anonymous rejects, write-authorized key admits, viewer key gets 403 and is not a positive control.", "components": ["phoenix", "phoenix-auth"], "sources": ["phoenix:s5a90699b7950", "phoenix-auth:se91e507879d3", "phoenix-auth:sd4999c5d2719", "phoenix-auth:sc7c47b61ba20", "phoenix-auth:sb802793835cd", "phoenix-auth:se7438e02fc6a", "phoenix-auth:saff9365321eb"], "status": "REASONED"}, "verify-phoenix-grpc": {"text": "Separate empty gRPC TraceService/Export requires UNAUTHENTICATED without key, OK with write key; auth-off anonymous OK and viewer PERMISSION_DENIED remain source-reasoned.", "components": ["phoenix-auth", "phoenix-bind", "phoenix-bind-20"], "sources": ["phoenix-auth:s253973703f36", "phoenix-auth:sdbaa6371c568", "phoenix-bind:s74e11942206c", "phoenix-bind:s1b9b41c2b9da", "phoenix-bind:sf4f82f3650d7", "phoenix-bind:s22756c69d535", "phoenix-bind-20:sab369cec4af9", "phoenix-bind-20:s78a38870f8ea", "phoenix-bind-20:s1cb038a6327a", "phoenix-bind-20:s5f40ca1e1785"], "status": "REASONED"} } } ```` ====================================================================== ==> workflow-orchestrators.md ====================================================================== # Workflow and agent orchestrators: Prefect, Dagster, Airflow, Temporal, Flower, Argo Prefect, Dagster, Airflow, Temporal, Flower and Argo Workflows schedule, run or monitor jobs and pipelines, and their consoles and APIs can start code on workers. This guide helps your AI assistant configure each securely, including no public UI or API, MFA for people, separate machine-client credentials, and limited worker egress. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/workflow-orchestrators.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Prefect Basic Auth minimum 3.1.8, Dagster OSS 1.13.24, Apache Airflow 3.3.2, Temporal Go SDK codec source v1.28.0, Flower v2.2.0, Argo Workflows v3.7.18 and v4.1.4, Prefect Blocks source 3.1.8, and Apache Airflow historical configuration 2.11.0, plus 10 more recorded bases; 1 further source is pinned to a commit and 7 carry no recorded version. Metadata reviewed 2026-10-05; documentation checked October 2026. The guide records 62 claims: 3 demonstrated by a check that ran and 59 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These webservers and UIs schedule and trigger arbitrary code execution across your infrastructure, and most ship with no authentication at all. Keep every one of them off the public internet and add auth before anyone but you can reach the port. The same baseline applies across all of them: keep each UI and API origin on loopback or a restricted private network, require HTTPS and an authenticating reverse proxy with MFA at the identity provider for browser access, and protect the API routes as well as the HTML ones, configuring machine-client authentication separately from interactive MFA ([fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). Because each of these executes arbitrary tasks, also restrict every worker's and task's egress to the destinations it needs and deny cloud metadata and unrelated internal services; inbound authentication does not constrain what an executing workload reaches outward ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). ## Prefect (self-hosted server) There is no default authentication; `prefect server start` accepts unauthenticated API calls until you set one up. Built-in Basic Auth requires Prefect 3.1.8 or newer. It uses a single administrator/password string, set on the server with `PREFECT_SERVER_API_AUTH_STRING` (or the `server.api.auth_string` setting) and the identical value on every client with `PREFECT_API_AUTH_STRING` (`api.auth_string`); the UI prompts for the string on first load. This is unrelated to Prefect Cloud: `PREFECT_API_KEY` authenticates only to Prefect Cloud, and if it happens to be set alongside `PREFECT_API_AUTH_STRING` on a client talking to a self-hosted server, the key takes precedence and the request fails with 401. Store the auth string in a secret manager or a private `.env` file, never in the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Treat access to the server API as access to credential-bearing Blocks and workflow controls: Prefect 3.1.8 block-document read routes accept `include_secrets`, and `Secret.get()` returns the underlying value. These source files do not establish per-caller authorization for `include_secrets`; restrict API and worker access, and keep credentials out of flow parameters and logs. ## Dagster (OSS) The open-source `dagster-webserver` (as of 1.13.24, run directly or by `dagster dev`) binds `127.0.0.1` on port 3000 unless a flag or an environment variable sets the host or a nonzero port, and with no port set either way, or a port of 0, it moves to a free port when 3000 is taken. The official Helm chart (as of 1.13.24) always passes `-h 0.0.0.0`, on the Service port, 80 by default, behind a Service of type `ClusterIP` by default. In the open-source `dagster-webserver` (as of 1.13.24), `build_middleware()` adds only a call-counting middleware and `build_graphql_middleware()` adds none, so neither adds authentication; this guide therefore treats the webserver as having no built-in login or access control. Put it entirely behind an identity-aware fronting layer ([fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md)) or your own reverse proxy with its own authentication ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)) plus MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)); for a proxy on the same host, bind the webserver explicitly with `dagster-webserver --host 127.0.0.1 --port 3000`, and never publish the port directly. ## Apache Airflow Airflow's own security model states it plainly: "Airflow doesn't support unauthenticated users by default" and "Airflow is not designed to be exposed to untrusted users on the public internet"; every user of the UI and API is assumed to be authenticated and known, and keeping it off the public internet is the deployment manager's responsibility, not something the software enforces for you. The guidance below targets Airflow 3 (checked against 3.3.2 with FAB provider 3.9.0); earlier releases differ, so confirm your version. Airflow 2.11.0 governs API authentication through `[api] auth_backends`, defaulting to `airflow.api.auth.backend.session`. Before 2.3 the setting was singular `auth_backend`; in 2.2.5 its default was `airflow.api.auth.backend.deny_all`. Airflow 3's public API uses JWT independently of FAB's `[fab] auth_backends`. Access is governed by a pluggable "auth manager". The default is the Simple Auth Manager, which the documentation marks for development and testing only: it prints a warning banner on login, and its users and roles (`viewer`, `user`, `op`, `admin`) come from `simple_auth_manager_users` in `[core]` (for example `bob:admin,peter:viewer`), with a password auto-generated per user and printed to the webserver logs unless you set your own. For production, configure the FAB auth manager instead: install the FAB provider, then set `[core] auth_manager` to `airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager` and confirm the effective manager with `airflow config get-value core auth_manager`. `[fab] auth_backends` is a different setting that selects the authentication backends for the FAB API, not the auth manager. Point FAB at LDAP, OAuth, or another real identity backend, and put MFA at that identity provider ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). Keep `[core] simple_auth_manager_all_admins` unset or `False`, since setting it disables login and treats every visitor as an admin, and with FAB leave `[fab] auth_role_public` and legacy `AUTH_ROLE_PUBLIC` in `webserver_config.py` unset. A configured public role can make the FAB logged-in check pass for an anonymous visitor and can supply that role's permissions; check the effective configuration and anonymous API behavior after deployment. The official `docker-compose.yaml` takes the right first step and the wrong second one: it selects the FAB auth manager, then seeds it with a known admin, `_AIRFLOW_WWW_USER_USERNAME` and `_AIRFLOW_WWW_USER_PASSWORD` both defaulting to `airflow`, and gives the API a fallback signing secret, `AIRFLOW__API_AUTH__JWT_SECRET` defaulting to `airflow_jwt_secret`, used whenever that variable is unset. Its header marks it local-development only, but it is the quickstart the docs give for a local run, so a reader who brings it up unchanged gets an `airflow`/`airflow` admin login exposed wherever port 8080 is reachable, and a token-signing secret published in the vendor's file, which lets anyone forge an API token and impersonate an existing user. Set the admin username and password before the first start creates the account (changing them afterward does not reset the account already created, so update or delete that account instead), set your own `AIRFLOW__API_AUTH__JWT_SECRET` (`openssl rand -hex 32`) and give it to every component that signs or validates tokens. That is separate from the web and API signing secret `secret_key` (`[webserver] secret_key` on 2.11.0, `[api] secret_key` on 3.3.2): provision a strong value for it too, because securing one does not secure the other. Keep configuration exposure off as well (`AIRFLOW__WEBSERVER__EXPOSE_CONFIG=False` on 2.11.0, `AIRFLOW__API__EXPOSE_CONFIG=False` on 3.3.2). An exposed Airflow is also a credential store: its Connections and Variables hold credentials for the systems your DAGs reach, protected at rest by the `[core] fernet_key`, so restrict permissions on Connections and Variables and protect the metadata database, the Fernet key, and any secrets backend, since encryption at rest and UI masking do not stop an authorized workload from using them. An empty `[core] fernet_key` selects `_NullFernet`, which does not encrypt newly stored values; configured comma-separated keys use `MultiFernet`; preserve keys needed to decrypt existing ciphertext. Keep every secret out of the repository ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)), and publish 8080 to loopback for the proxy (`127.0.0.1:8080:8080`) rather than to every interface, behind your fronting layer. ## Temporal (self-hosted) With no authorizer configured, the server runs the default `noopAuthorizer`, which the documentation says "allows every API request, with no authentication or access control" at all, including administrative operations. Configure both an `Authorizer` and a `ClaimMapper`: leaving either selector empty selects a no-op, and the no-op claim mapper grants system-admin claims, so both matter. For the built-in JWT implementation set `global.authorization.authorizer: default`, `global.authorization.claimMapper: default`, and a trusted `global.authorization.jwtKeyProvider.keySourceURIs`, with the required audience and frontend TLS configured, so protected workflow API calls require sufficient mapped claims. The programmatic equivalents for the built-in JWT implementation are `temporal.WithAuthorizer()` and `temporal.WithClaimMapper()`. The built-in default authorizer permits health-check APIs without claims. This is entirely separate from Web UI login: the UI's own config reference documents an `auth` block whose `enabled` flag turns UI login on and whose `providers` list carries each OIDC entry (`type: oidc`, `providerUrl`, `issuerUrl`, `clientId`, `clientSecret`, `callbackUrl`, `scopes`) for SSO into the dashboard. `enabled` is a sibling of `providers`, not a field inside a provider entry, and with environment configuration it is `TEMPORAL_AUTH_ENABLED=true` alongside the `TEMPORAL_AUTH_*` provider settings. That only gates the UI, not the server API a worker or CLI talks to directly. mTLS secures internode and frontend traffic separately again. Set up both layers; MFA comes from whichever identity provider the UI's OIDC settings point at. Temporal also persists workflow inputs, results, and history, so keep plaintext credentials out of them and fetch operational secrets inside authorized workers; if you encrypt payloads with a Payload Codec, authenticate and restrict any Codec Server independently of the UI and the frontend. The Temporal Go SDK v1.28.0 codec HTTP handler accepts encode/decode POSTs without an authentication check; a deployed server needs its own access control. ## Flower (for Celery) Flower binds every interface by default (as of v2.2.0, `--address` is empty, meaning all interfaces, and `--port` defaults to 5555, unless `--unix_socket` names a socket path, `FLOWER_`-prefixed environment variables set them, or a `flowerconfig.py` in the working directory, which it loads without being asked, does) with authentication disabled unless you configure it. When no authentication is configured its HTTP API is disabled unless you set `FLOWER_UNAUTHENTICATED_API=true`, so keep that unset and never read an API rejection as proof the dashboard itself is protected. `--basic-auth="user1:password1,user2:password2"` turns on HTTP Basic Auth with a comma-separated credential list; OAuth 2.0 login against Google, GitHub, GitLab, or Okta is enabled by setting `--auth_provider` to the provider's handler class plus `--oauth2_key`, `--oauth2_secret`, `--oauth2_redirect_uri`, and an `--auth` regular expression of the email addresses allowed to sign in. Supply the Basic Auth credential list through `FLOWER_BASIC_AUTH` and the OAuth client secret through `FLOWER_OAUTH2_SECRET`, or set `basic_auth` and `oauth2_secret` in a protected configuration file, rather than on the command line where they are readable in the process list ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Prefer OAuth against a provider that enforces MFA over Basic Auth alone ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)), and bind `--address=127.0.0.1` behind a proxy rather than relying on Basic Auth as the only control. With `--unix_socket` set to a filesystem path, Flower v2.2.0 passes Tornado a fixed mode, `0o777`, for the socket file: each start deletes any socket left at that path, creates a new one and applies that mode, so a `chmod` of the socket is undone at the next start. On Linux, connecting needs write permission on the socket and search permission on every directory in its path, so the socket's mode lets any unprivileged local account that can search the directory connect. A Linux process whose effective `CAP_DAC_OVERRIDE` or `CAP_DAC_READ_SEARCH` applies to the directories bypasses the search check: root on the host does, and so does root in a container that shares the host's user namespace, since Docker's default capabilities include `CAP_DAC_OVERRIDE`. A capability inside a separate Linux user namespace applies only to files whose owner and group are mapped there. Put the socket in a directory of its own, owned by Flower's user, with a group that only the proxy uses and mode `0750`, and with no ACL entry that grants anyone else search. Set that owner, group and mode in whatever creates the directory, because files under `/run` are cleared at boot and systemd's `RuntimeDirectory=` creates its directory with mode `0755` unless `RuntimeDirectoryMode=` sets another. When the proxy runs in another container and shares the directory through a volume, permissions are checked against numeric IDs, not names, after any user-namespace or idmapped-mount mapping, so the proxy's process must hold the directory's group, as the kernel sees it, as its primary or a supplementary group. Use a filesystem path, never an abstract socket name (one beginning with a NUL byte): Tornado applies no mode to such a name, and on Linux file permissions have no meaning for abstract sockets. ## Argo Workflows At Argo Workflows v3.7.18 and v4.1.4, Argo Server binds `:` (`:2746` by default) using Go's `tcp` listener: a wildcard bind, with IPv4/IPv6 availability determined by the host. There is no bind-address flag. The CLI defaults are `--secure=true`, `--auth-mode=client` and `--hsts=true`; flags or their `ARGO_*` environment equivalents can override them. Client mode uses the caller's Kubernetes credentials and permissions. At v4.1.4 it also validates the token with a `SelfSubjectReview`, requiring Kubernetes 1.28 or later. **The shipped quick-start manifests override the authentication default.** At both tags, `manifests/quick-start-minimal.yaml`, `manifests/quick-start-mysql.yaml` and `manifests/quick-start-postgres.yaml` pass `--auth-mode server --auth-mode client`; v4.1.4 also ships `manifests/quick-start-telemetry.yaml` with those arguments. They leave TLS enabled. Following the quick-start therefore leaves 2746 open to anonymous callers who can reach it, acting with the `argo-server` ServiceAccount's permissions. Its `argo-server-cluster-role` is bound cluster-wide and permits creating, listing, updating and deleting workflows and templates, as well as getting and creating Secrets. The quick-start also binds `argo-server-clusterworkflowtemplate-role`. These are demo installations: the quick-start docs explicitly say they are unsuitable for production. The sources used to build `manifests/install.yaml` set only `args: [server]`, retaining client auth and TLS, and bind the same `argo-server-cluster-role` to the server account. The sources for `manifests/namespace-install.yaml` add `--namespaced` and instead bind the namespace-scoped `argo-server-role`. Neither enables server auth or disables TLS. In all these manifests the `argo-server` Service omits `type` (Kubernetes defaults it to `ClusterIP`) and maps port 2746 to 2746. That does not publish it on the internet, but does not authenticate reachable callers either. Remove `--auth-mode=server` from the effective configuration. Adding client or SSO mode does not remove anonymous access while server mode remains: a request without credentials falls back to the server's own Kubernetes clients. In local mode those use the server's kubeconfig credentials instead. `--auth-mode=sso` provides OIDC login; enable SSO RBAC (`sso.rbac.enabled: true`) and map groups to narrowly scoped ServiceAccounts with `workflows.argoproj.io/rbac-rule` and `workflows.argoproj.io/rbac-rule-precedence`. Without SSO RBAC, authenticated users share the server's permissions. Store OAuth client credentials in Kubernetes Secrets referenced by the SSO configuration, and feed API tokens to clients through protected input rather than command-line arguments ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Keep 2746 private behind an authenticating HTTPS proxy, with MFA enforced at the identity provider ([fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)); restrict direct Pod and Service access with NetworkPolicy and do not publish the port publicly. Enforce private exposure at the network and Service layers. Keep TLS enabled and provision a certificate trusted by clients using `--tls-certificate-secret-name`. With secure mode enabled and no certificate Secret name supplied, the server generates a self-signed certificate; failure to load a named Secret fails startup. `--secure=false` switches the same listener to plaintext, and HSTS has no effect without TLS. The server's `/metrics` endpoint shares this listener and uses the auth gate unless `ARGO_SERVER_METRICS_AUTH=false`; server mode also admits anonymous metrics requests. Limit the namespaces and permissions available to the server, SSO accounts, and workflow execution accounts. Permission to submit workflows permits arbitrary containers by default (workflow restrictions and admission policies can constrain the permitted specs), and powerful execution accounts or privileged Pods can turn an exposed API into cluster compromise. ## Verify REASONED: the nine blocks below each carry their own declaration, in the paragraph before the block or, for the last block, the paragraph after it. Together they hold the ss inventory, the Prefect, Dagster, Airflow, Temporal, Flower and Argo anonymous and authorized calls, the Flower socket-directory stat and getfacl lines, and the guarded direct-origin probe. Those declarations rest on the guide's cited entries Prefect, security settings; Dagster webserver and UI; Apache Airflow FAB provider, API authentication; Apache Airflow running in Docker; Temporal, self-hosted security; Flower configuration; and Argo anonymous fallback when server mode is enabled. The checks are separate blocks because each tests a different product or vantage, so one product's result can never hide another's in a shared exit status. The authoring environment has no container runtime, Kubernetes cluster or orchestrator install, and no network access to obtain one. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ShellCheck 0.11.0 and ran with stub ss, curl and temporal and no network: each illustrative probe reached only the stubs. The origin guard in that stub run (marking review, October 2026) printed its substitute-your-origin-URL refusal unchanged, the paste-the-whole-block refusal without its set -- line, the exactly-1-value refusal when shortened and the http-or-https refusal for an ftp:// URL, and passed a substituted https:// URL to the stub. The October 2026 marking review reported piped-input helper checks, including header encoding, JSON escaping and invalid Argo tokens; these are historical observations, not new runs. In the first TODO 50 review, the unchanged and changed blocks ran from files with hidden prompts driven through a pseudo-terminal, dummy credentials, and stubs for every external command (the Python stub delegated only the known helpers to the isolated interpreter). No probe reached a service. Before the change, all three authenticated labels still ran when their helper exited 73, including after partial output. An empty terminal response produced a Basic header with an empty value for Prefect and JSON containing an empty username or password for Airflow; those helpers succeeded. Argo rejected an empty response, but its curl still ran with empty stdin. After the change, valid dummy inputs reached all three authenticated curl stubs with the expected header or JSON. Each empty credential, each helper exit 73 and each partial-output failure printed the matching credential-helper refusal and skipped only that authenticated probe; the other labelled calls were unchanged. These local runs test shell control flow, not service authentication. During marking review (October 2026), the second TODO 50 review ran the unchanged and revised blocks from files under bash 5.3.9(1)-release and Python 3.14.4 with dummy credentials on a pseudo-terminal, stubs for every external command and only the known credential helpers delegated to the scratch interpreter. Under bash -e before that revision, each helper exit 73, partial-output failure or empty credential stopped the calling shell at that authenticated probe, so later probes did not run. In those second-review fixtures, where every other external-command stub succeeded, each refusal skipped only its authenticated request and all later probes ran. Valid credentials reached all three authenticated curl stubs with the expected header or JSON before and after, with and without bash -e. All four empty credential cases and each helper failure were also checked without errexit before and after. With PYTHONOPTIMIZE=1 and an empty Argo token, the unchanged helper sent an empty bearer header; the revised helper refused it and skipped the Argo authenticated request, with and without bash -e. In that second review, every completed sequence reached the unchanged direct-origin placeholder refusal; all files exited 2, either at the first unhandled refusal before that revision under bash -e or at the final origin guard. These runs checked shell flow and credential encoding only, not service authentication. During marking review (October 2026), the third TODO 50 review ran the unchanged and revised blocks from files under bash 5.3.9(1)-release and Python 3.14.4 through a pseudo-terminal with dummy credentials, stubs for every external command and only the three exact credential helpers delegated to the isolated scratch interpreter. No probe reached a service or read the host socket paths. Before this revision, under bash -e, exit 1 from ss, Temporal, stat or getfacl, and curl exits 6, 7 or 28 from each anonymous or default-account probe, stopped the file at that label and skipped the remaining probes. After this revision, under bash -e, those same cases reached every later label and the final origin-placeholder refusal. Each authenticated curl exit 6, 7 or 28, helper exit 73, partial-output failure and empty credential case reached all later probes before and after; helper refusals sent no authenticated request. Valid dummy credentials produced the expected Basic header, JSON and bearer header before and after. An empty Argo token was also refused with PYTHONOPTIMIZE=1 before and after. With a substituted origin URL, origin curl exits 6, 7 and 28 were reported before and after. The missing-set-line, short-set-line and ftp:// origin cases refused without an origin request before and after. The revised files exited 0 after their labelled continuation messages, including the final origin refusal; this status means the sequence continued, not that any service was secure. These stub runs establish shell flow and encoding only, not service authentication. All of the local runs above covered these checks in earlier forms, when they were one block, and before its three Airflow requests took the Airflow host on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains. In the latest form those runs covered, the prefect-auth, airflow-valid-token, argo-auth and origin subshells ended with an echo of `skipped or failed; continuing` that printed no status; a later form ended each guarded subshell with an echo of the form `) || echo "origin: block exit $?; continuing"`, and no recorded run covered that form. Each of the nine blocks now records every classified result and every failed command in it and exits once: 0 for a pass or for an observation it says it cannot classify, 1 for a finding, 2 for a guard refusal and 3 for a failed or inconclusive check, a finding outranking an inconclusive result and both outranking a refusal. The current form of every block is covered by the bash -n and ShellCheck 0.11.0 passes that the repository's gate suite runs over every block. During drafting (October 2026) each block also ran whole under bash 5.3.9 with stub curl, ss, temporal, stat, getfacl, tr and credential prompts and no network, including the Airflow and origin guards and runs that inherited a readonly variable, a declare -i variable and set -e. Those runs also covered a missing ss or python3 and malformed and validly spaced JSON answers; they test shell flow and status only, not service authentication. The two Airflow token print lines, which show only curl's write-out line after curl exits 0 and otherwise a fixed message naming curl's exit status or the missing line, and the Airflow block's opening trap reset and `set +x +a +e` were changed after those runs. Those changes are covered by the gate passes and by later stub runs of the Airflow block under bash 5.3.9 with no network (October 2026). Those runs inherited a readonly variable, a declare -i variable, set -e, set -x and a DEBUG trap under set -T, and included a curl stopped before its write-out line, also after a response holding a line shaped like that write-out line, and a transfer error after a full write-out line. The drafting and later stub runs of the Airflow block covered its earlier form, which lowercased the Airflow host with `tr`, checked `tr`'s exit status and exited 3 when it failed. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and it refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. In curl-stub runs of the current form on 2026-11-01 with no network, bash refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case with 2 and no request to that host, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. In those runs a host name outside the reserved names reached the default-account and anonymous requests. Under dash, stub runs of the current form refused every host name with 2 and the bash message before any request, except that under an inherited `set -e` the block ended with status 1 at its `trap` line, which dash rejects, as it did before this change. The stat and getfacl observations are recorded after the last block. These probes are reasoned, not demonstrated, because the authoring environment has no container runtime, so no live orchestrator was stood up in its exposed or fixed state. For Argo, the authoring host forbids opening listeners without an isolated network namespace, and has none; no Kubernetes cluster is available. Its listener, TLS and authentication checks remain REASONED. The exception is the Flower unix-socket stat mode and named-ACL checks, demonstrated without a container and recorded after the last block. Each names its expected exposed and fixed result so it discriminates against a live instance, based on the cited vendor documentation and pinned sources across all six tools. The `*.internal` hostnames are examples for a reachable vantage (you have not isolated the port yet, or you are on an allowed network); substitute your own. For every negative check run the matched authorized call against the SAME origin, and separately probe the origin directly from an untrusted vantage with the guarded block at the end, because a rejection seen only through a proxy does not prove the origin enforces anything. The write-out fields need curl 7.75.0 or newer; never use `-k`. REASONED: following block; the ss inventory in the following block rests on the guide's cited `ss` manual and network-namespaces entries and on the default ports in its Prefect, Dagster, Airflow, Temporal, Flower and Argo entries. It classifies no listener, so it exits 0 after printing the table, or 3 if ss fails, and its last line leaves the table for you to read. If ss is not installed it lists nothing and refuses with status 2. ```bash ( # a subshell, so the exit below ends only the subshell set +e # errexit off here, so a failing ss still reaches the status line below set -- 0 # the block's status: 2 if ss is missing, 3 if ss fails, otherwise 0, because no listener is classified command -v ss > /dev/null || { echo 'ss is not installed here; not listing sockets'; exit 2; } # a missing required tool is a refusal ss -tlnp || { echo 'ss: exited nonzero; continuing'; set -- 3; } # TCP listeners in THIS network namespace only (4200/3000/8080/7233/8233/5555/2746): not a firewall, # publication, routing, or auth check. Inspect container publications and probe external # reachability separately; a wildcard bind is a prompt to investigate, not proof of exposure echo "inventory: block exit $1; no listener is classified, so read every listener above yourself" exit "$1" ) ``` REASONED: following block; the Prefect anonymous and authorized calls in the following block follow the guide's cited entries Prefect, security settings and Prefect server source, and no Prefect server was available in the authoring environment to show either state. ```bash # Prefect: an empty JSON filter. Once the auth string is set the anonymous call is 401 and the authorized call # returns a JSON flow list; an anonymous list, even an empty one, is the finding. Feed the auth string on stdin. # getpass prompts without echo on a terminal, so the auth string enters neither shell history nor set -x # tracing; stdin closes the argv channel only, and the account owner and root can still read it in # python3, the block's subshell and curl. # Do NOT use /api/health or /api/ready as the authentication discriminator. Current server source exempts # those GET paths; this behavior is version-dependent and was not present in 3.1.8. # Credential guards assume a clean shell with trusted startup files and real builtins. # Each capture ends with the helper status; strip it only after success, then feed curl on stdin. ( # the whole block in one subshell, so one exit status covers both calls set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nprefect-anon http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ -H 'Content-Type: application/json' -X POST --data-binary '{}' http://prefect.internal:4200/api/flows/filter; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, curl's output, then curl's exit status printf '%s' "$2" case "$3:${2##*$'\nprefect-anon http='}" in 0:401' '*) ;; # the anonymous call is refused 0:2??' '*) # parse the body, so a list counts whatever JSON whitespace ends it and a malformed body does not if ! command -v python3 > /dev/null; then echo 'prefect-anon: python3 not found, so the 2xx above is not classified'; set -- 2 elif printf '%s' "${2%$'\nprefect-anon http='*}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); sys.exit(not isinstance(d, list))'; then echo 'FINDING: prefect-anon returned a JSON list with no credential'; set -- 1 else echo 'prefect-anon: a 2xx without a JSON list is neither the 401 pass nor the finding; inconclusive'; set -- 3 fi ;; 0:*) echo 'prefect-anon: an HTTP code other than 401 or a 2xx; inconclusive'; set -- 3 ;; *) echo 'prefect-anon: exited nonzero; continuing'; set -- 3 ;; esac ( trap - DEBUG RETURN ERR set +x +a +e command -v python3 > /dev/null || { echo 'prefect-auth: python3 not found; not probing'; exit 2; } set -- "$(if python3 -I -c 'import base64,getpass,sys; s=getpass.getpass("Prefect username:password: "); s or sys.exit(print("empty credential", file=sys.stderr) or 64); print("header = \"Authorization: Basic " + base64.b64encode(s.encode()).decode() + "\"")'; then printf '\n0'; else printf '\n%s' "$?"; fi)" case "$1" in *$'\n0') set -- "${1%$'\n'*}" ;; *$'\n64') echo "prefect-auth: credential input refused; not probing"; exit 2 ;; *) echo "prefect-auth: credential helper failed; not probing"; exit 3 ;; esac # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$(if printf '%s' "$1" \ | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --config - \ -w '\nprefect-auth http=%{http_code} exit=%{exitcode}\n' \ -H 'Content-Type: application/json' -X POST --data-binary '{}' http://prefect.internal:4200/api/flows/filter; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" # curl's output, then curl's exit status printf '%s' "$1" case "$2:${1##*$'\nprefect-auth http='}" in 0:2??' '*) # parse the body: a JSON flow list is a JSON list of objects if printf '%s' "${1%$'\nprefect-auth http='*}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); sys.exit(not (isinstance(d, list) and all(isinstance(f, dict) for f in d)))'; then exit 0; fi echo 'prefect-auth: a 2xx without a JSON flow list; inconclusive'; exit 3 ;; 0:*) echo 'prefect-auth: no JSON flow list with the auth string, so the matched authorized call failed; inconclusive'; exit 3 ;; *) echo 'prefect-auth: exited nonzero; inconclusive'; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "prefect: block exit $1; both calls are classified, so only the cause an inconclusive line names above is left for you to judge" exit "$1" ) ``` REASONED: following block; the Dagster anonymous query in the following block follows the guide's cited entry Dagster webserver and UI and its pinned Dagster 1.13.24 webserver sources, and no Dagster webserver was available in the authoring environment to show either state. Against the origin it reports 0 only for an answer it cannot classify: an isolated origin exits 3 and a reachable Dagster answers the query, since it has no login; sent to the proxy URL, a 401 or login redirect exits 0 and is left for you to judge. ```bash # dagster-webserver 1.13.24's build_middleware() and build_graphql_middleware() add no authentication, # so test whether /graphql at the origin answers an anonymous query. # A RepositoryConnection containing nodes (including an empty nodes list) is a successful read. # PythonError, top-level GraphQL errors, and transport failures are inconclusive. # Test the proxy separately: send this identical query anonymously and authenticated to the SAME proxy URL. # Test origin reachability independently; proxy authentication does not authenticate the OSS origin. # Against the origin this block reports 0 only for an answer it cannot classify: an isolated origin exits 3 and a # reachable Dagster answers the query, since it has no login; sent to the proxy URL, a 401 or login redirect exits 0 and is left for you to judge. ( # a subshell, so the exit below ends only the subshell set +e # errexit off here, so a failed query still reaches the status line below # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\ndagster-anon http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ -H 'Content-Type: application/json' -X POST \ --data-binary '{"query":"{ repositoriesOrError { __typename ... on RepositoryConnection { nodes { name location { name } } } } }"}' http://dagster.internal:3000/graphql; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" # curl's output, then curl's exit status printf '%s' "$1" case "$2:$1" in 0:*'"RepositoryConnection"'*'"nodes"'*) echo 'FINDING: dagster-anon read a RepositoryConnection with no credential'; set -- 1 ;; 0:*'"PythonError"'*|0:*'"errors"'*) echo 'dagster-anon: a PythonError or GraphQL error, not a read; inconclusive'; set -- 3 ;; 0:*) echo 'dagster-anon: unclassified observation: neither a RepositoryConnection nor a GraphQL error; read the answer above'; set -- 0 ;; *) echo 'dagster-anon: exited nonzero; continuing'; set -- 3 ;; esac echo "dagster: block exit $1; an answer that is neither a RepositoryConnection nor a GraphQL error, such as a proxy's 401, is not classified, so judge it yourself" exit "$1" ) ``` REASONED: following block; the Airflow default-account, valid-account and anonymous API calls in the following block follow the guide's cited entries Apache Airflow FAB provider, API authentication and Apache Airflow running in Docker, and no Airflow deployment was available in the authoring environment to show either state. ```bash # Airflow: obtaining a token from the documented Compose account is the finding. Send the credentials as JSON # on stdin; FAB 3.9.0 returns 201 Created with a nonempty access_token when airflow/airflow works. Compare with a valid account on # the same route (the second call below), and test an API read with no token then a token at the proxy and the # origin. On Airflow 2 use its /api/v1/ endpoint and its own auth mechanism instead. The first call's body is # fixed; never type a real password into it. # Credential guards assume a clean shell with trusted startup files and real builtins. # Each capture ends with the helper status; strip it only after success, then feed curl on stdin. ( # a subshell, so your own script arguments are untouched trap - DEBUG RETURN ERR # inherited traps cleared, so no trap reads an issued token (rule 7) set +x +a +e # tracing and allexport off, so no issued token is traced; errexit off, so a part that ends nonzero is folded into the status below set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_AIRFLOW_HOST' # the host name the Airflow API server is served on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; *) shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 set -- "$1" "$2" "$(if printf '%s' '{"username":"airflow","password":"airflow"}' \ | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nairflow-token http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ -H 'Content-Type: application/json' --data-binary @- "https://$1/auth/token"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the host, the status, curl's output, then curl's exit status # the body carries any access_token issued to the default account, so print only curl's write-out line, and only after curl exits 0 case "$4:$3" in # curl's exit status first: a body can hold a line shaped like the write-out line, so after a nonzero exit none of the capture is shown 0:*$'\nairflow-token http='*) printf 'airflow-token http=%s' "${3##*$'\nairflow-token http='}" ;; 0:*) echo 'airflow-token: no write-out line, so the response is not shown; inconclusive'; set -- "$1" "$2" '' skip ;; # the response is dropped unread *) printf 'airflow-token: curl exit %s, so the response is not shown; inconclusive\n' "$4"; set -- "$1" "$2" '' "$4" ;; # the response is dropped unread, and the exited-nonzero arm below records 3 esac case "$4:${3##*$'\nairflow-token http='}" in skip:*) [ "$2" = 1 ] || set -- "$1" 3 ;; # no write-out line, so this request is not classified 0:2??' '*) # parse the body: the finding is a nonempty access_token string, however the JSON is spaced if ! command -v python3 > /dev/null; then echo 'airflow-token: python3 not found, so the 2xx above is not classified'; set -- "$1" 2 elif printf '%s' "${3%$'\nairflow-token http='*}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); t = d.get("access_token") if isinstance(d, dict) else None; sys.exit(0 if isinstance(t, str) and t else 5 if t == "" else 1)'; then echo 'FINDING: airflow-token obtained an access_token for the documented airflow/airflow account'; set -- "$1" 1 else case "$?" in # the parser's status: 5 for an empty access_token, otherwise no access_token or no JSON object 5) echo 'airflow-token: an empty access_token; inconclusive' ;; *) echo 'airflow-token: a 2xx without an access_token; inconclusive' ;; esac set -- "$1" 3 fi ;; 0:*) ;; # no token for the documented account *) echo 'airflow-token: exited nonzero; continuing'; set -- "$1" 3 ;; esac # The valid-account comparison. getpass prompts without echo on a terminal, so the username and password enter # neither shell history nor set -x tracing, and json.dumps escapes any double quote, backslash or control # character in them, so the body stays one JSON object; stdin closes the argv channel only, and the account # owner and root can still read the password in python3, the block's subshell and curl. ( trap - DEBUG RETURN ERR set +x +a +e command -v python3 > /dev/null || { echo 'airflow-valid-token: python3 not found; not probing'; exit 2; } set -- "$1" "$(if python3 -I -c 'import getpass,json,sys; u=getpass.getpass("Airflow username: "); p=getpass.getpass("Airflow password: "); (u and p) or sys.exit(print("empty credential", file=sys.stderr) or 64); print(json.dumps({"username": u, "password": p}))'; then printf '\n0'; else printf '\n%s' "$?"; fi)" case "$2" in *$'\n0') set -- "$1" "${2%$'\n'*}" ;; *$'\n64') echo "airflow-valid-token: credential input refused; not probing"; exit 2 ;; *) echo "airflow-valid-token: credential helper failed; not probing"; exit 3 ;; esac set -- "$1" "$(if printf '%s' "$2" \ | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nairflow-valid-token http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ -H 'Content-Type: application/json' --data-binary @- "https://$1/auth/token"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the host, curl's output, then curl's exit status # the body carries the access_token issued to the valid account, so print only curl's write-out line, and only after curl exits 0 case "$3:$2" in # curl's exit status first: a body can hold a line shaped like the write-out line, so after a nonzero exit none of the capture is shown 0:*$'\nairflow-valid-token http='*) printf 'airflow-valid-token http=%s' "${2##*$'\nairflow-valid-token http='}" ;; 0:*) echo 'airflow-valid-token: no write-out line, so the response is not shown; inconclusive'; exit 3 ;; *) printf 'airflow-valid-token: curl exit %s, so the response is not shown; inconclusive\n' "$3"; set -- "$1" '' "$3" ;; # the response is dropped unread, and the exited-nonzero arm below exits 3 esac case "$3:${2##*$'\nairflow-valid-token http='}" in 0:2??' '*) # parse the body: the pass is a nonempty access_token string, however the JSON is spaced if printf '%s' "${2%$'\nairflow-valid-token http='*}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); t = d.get("access_token") if isinstance(d, dict) else None; sys.exit(not (isinstance(t, str) and t))'; then exit 0; fi echo 'airflow-valid-token: no nonempty access_token for the valid account; inconclusive'; exit 3 ;; 0:*) echo 'airflow-valid-token: no token for the valid account, so the comparison failed; inconclusive'; exit 3 ;; *) echo 'airflow-valid-token: exited nonzero; inconclusive'; exit 3 ;; esac ) case "$?:$2" in 0:*) ;; 1:*) set -- "$1" 1 ;; 2:0) set -- "$1" 2 ;; 2:*|*:1) ;; *) set -- "$1" 3 ;; esac # fold that part's status into the block's, ranked as above curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nairflow-pools-anon http=%{http_code} exit=%{exitcode}\n' "https://$1/api/v2/pools" \ || { echo 'airflow-pools-anon: exited nonzero; continuing'; [ "$2" = 1 ] || set -- "$1" 3; } echo "airflow: block exit $2; the anonymous pools answer is not classified, so judge it yourself" exit "$2" ;; esac ) ``` REASONED: following block; the Temporal workflow listing in the following block follows the guide's cited entry Temporal, self-hosted security, and no Temporal Service was available in the authoring environment to show either state. As its comments say, it cannot report 0: a rejection and a TLS, namespace or connection error all make temporal exit nonzero, so it exits 3 and leaves that error for you to read. ```bash # Temporal: a protected read against the frontend gRPC port. With an Authorizer and ClaimMapper configured this # is rejected without credentials (an mTLS deployment refuses the connection outright); a listing, even an empty # one, is the finding. Health checks, TLS errors, missing namespaces, and unavailable services do not prove # authorization, so use a real workflow list, not a health call, and run it with no ambient credentials set. # This block cannot report 0: a rejection and a TLS, namespace or connection error all make temporal exit # nonzero, so a nonzero exit records 3 and you read the error it printed. ( # a subshell, so the exit below ends only the subshell set +e # errexit off here, so a failing temporal still reaches the status line below if temporal workflow list --address temporal.internal:7233 --namespace default --limit 1; then echo 'FINDING: temporal workflow list answered with no credential'; set -- 1 else echo 'temporal: exited nonzero; continuing'; set -- 3 fi echo "temporal: block exit $1; any error above is not classified, so judge it yourself: a rejection without credentials, or a refused mTLS connection, is the pass, and a TLS, namespace or availability error is inconclusive" exit "$1" ) ``` REASONED: following block; the Flower dashboard and API calls in the following block follow the guide's cited entry Flower configuration, and no Flower install was available in the authoring environment to show either state. ```bash # Flower: the dashboard and the API. With Basic Auth an anonymous request is 401; with OAuth it is a 302 redirect into # the login flow, not 401, so inspect the redirect without following it and confirm access with a # valid session. An anonymous worker list from /api/workers is the finding; an API-disabled response does not # prove the dashboard is protected. ( # the whole block in one subshell, so one exit status covers both calls set +e # errexit off here, so a failed call still reaches the status line below set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (no python3 to classify the API answer), which outranks 0 # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --dump-header - \ -w '\nflower-ui http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' http://flower.internal:5555/; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, curl's output, then curl's exit status printf '%s' "$2" case "$3:${2##*$'\nflower-ui http='}" in 0:401' '*) ;; # Basic Auth refused the anonymous request 0:2??' '*) echo 'FINDING: flower-ui rendered the dashboard with no credential'; set -- 1 ;; 0:302' '*) echo 'flower-ui: unclassified observation: a 302; read its Location above, which must lead into your login flow' ;; 0:*) echo 'flower-ui: an HTTP code other than 401, 302 or a 2xx; inconclusive'; set -- 3 ;; *) echo 'flower-ui: exited nonzero; continuing'; set -- 3 ;; esac # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --dump-header - \ -w '\nflower-api http=%{http_code} exit=%{exitcode}\n' http://flower.internal:5555/api/workers; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, curl's output, then curl's exit status printf '%s' "$2" case "$3:${2##*$'\nflower-api http='}" in 0:2??' '*) # parse the body: a worker list is a JSON object whose values are all objects, one per worker, even when empty set -- "$1" "${2%$'\nflower-api http='*}" # the status, then the headers and body without the write-out line if ! command -v python3 > /dev/null; then echo 'flower-api: python3 not found, so the 2xx above is not classified'; [ "$1" != 0 ] || set -- 2 elif printf '%s' "${2#*$'\r\n\r\n'}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); sys.exit(not (isinstance(d, dict) and all(isinstance(w, dict) for w in d.values())))'; then echo 'FINDING: flower-api returned a worker list with no credential'; set -- 1 else echo 'flower-api: a 2xx without a JSON worker list; inconclusive'; [ "$1" = 1 ] || set -- 3 fi ;; 0:*) ;; # no anonymous worker list; an API-disabled answer says nothing about the dashboard, which the call above checks *) echo 'flower-api: exited nonzero; continuing'; [ "$1" = 1 ] || set -- 3 ;; esac echo "flower: block exit $1; a 302's Location and any API answer other than a 2xx are not classified, so judge them yourself" exit "$1" ) ``` REASONED: following block; the Flower socket-directory checks in the following block follow the guide's cited Flower `--unix_socket` entry and the Linux permission and ACL manual pages it lists. The stat and getfacl values recorded after the last block were demonstrated on directories; the mode and named-entry classification this block adds ran only against stub output modelled on those values. ```bash # Flower on a unix socket. The stat mode and named-ACL checks were demonstrated (recorded after the last block); the # socket line is reasoned, not demonstrated: this authoring environment has no Flower install, and the authoring # host forbids opening listeners, unix sockets included (the expectation follows the cited Flower source). Expect the directory # to grant nothing to "other" (750, or 2750 with setgid), owned by Flower's user and the proxy-only group, # compared by number (%u:%g) from inside each container that uses it; the socket shows 777 by design. The mode # bits do not show named ACL entries, so getfacl (from the acl package) should list only the user::, group:: and # other:: access entries. Any named user: or group: entry whose own permissions include x is a finding, even where # getfacl prints #effective: without x, because a later chmod of the directory resets mask:: to its group bits. # default: entries set the ACL of files created inside, not access to the directory. Recheck after each reboot and # deployment change. Substitute your socket's directory and path. ( # the whole block in one subshell, so one exit status covers both checks set +e # errexit off here, so a failed check still reaches the status line below set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$(if stat -c '%a %U:%G %u:%g %n' /run/flower; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the directory alone, so a missing socket cannot skip its mode check set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, stat's output, then stat's exit status printf '%s' "$2" if [ "$3" != 0 ]; then echo 'flower-stat: exited nonzero; continuing'; set -- 3 else case "${2%%' '*}" in # the directory's mode, the first field of its line *0) ;; *) echo 'FINDING: the socket directory grants permissions to other'; set -- 1 ;; esac fi set -- "$1" "$(if stat -c '%a %U:%G %u:%g %n' /run/flower/flower.sock; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the socket, whose mode is left for you to read set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, stat's output, then stat's exit status printf '%s' "$2" if [ "$3" != 0 ]; then echo 'flower-stat: exited nonzero; continuing'; [ "$1" = 1 ] || set -- 3 fi set -- "$1" "$(if getfacl -p /run/flower; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, getfacl's output, then getfacl's exit status printf '%s' "$2" if [ "$3" != 0 ]; then echo 'flower-getfacl: exited nonzero; continuing'; [ "$1" = 1 ] || set -- 3 elif printf '%s' "$2" | awk -F: '($1 == "user" || $1 == "group") && $2 != "" && substr($3, 1, 3) ~ /x/ { found = 1 } END { exit !found }'; then echo 'FINDING: a named user: or group: access entry on the socket directory includes x'; set -- 1 else case "$?" in 1) ;; *) echo 'awk could not read the getfacl output; inconclusive'; [ "$1" = 1 ] || set -- 3 ;; esac fi echo "flower-socket: block exit $1; the owners, the numeric IDs and the socket's own mode are not classified, so judge them yourself" exit "$1" ) ``` REASONED: following block; the Argo anonymous and authorized calls in the following block follow the guide's cited entries Argo anonymous fallback when server mode is enabled and the Argo Workflows workflow-list API, and no Kubernetes cluster was available in the authoring environment to show either state. ```bash # Argo: REASONED, not demonstrated; the authoring host forbids opening listeners without an isolated # network namespace, and has none; no Kubernetes cluster is available. # Server mode: an anonymous HTTP 200 containing a workflow list, even empty, is the finding, # provided the server identity can list workflows in this namespace. Without server mode (client/sso only): # a missing token is 401. A 403 means only that this operation was denied to the identity used; it does NOT # prove server mode is off, since server mode can be enabled yet lack list RBAC in this namespace and also # return 403, so confirm the configured --auth-mode separately. The matched authorized call must return # HTTP 200 with a workflow list. A health endpoint is NOT the discriminator. Replace the hostname and # namespace in BOTH calls; use a reachable allowed vantage and trusted TLS, configure the private CA if # necessary, never -k. TLS/DNS errors, redirects, 404s, and 5xx are inconclusive. # Credential guards assume a clean shell with trusted startup files and real builtins. # Each capture ends with the helper status; strip it only after success, then feed curl on stdin. ( # the whole block in one subshell, so one exit status covers both calls set +e # errexit off here, so a part that ends nonzero is folded into the status below instead of ending the block set -- 0 # the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (a part refused its input), which outranks 0 # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$1" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --dump-header - \ -w '\nargo-anon http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ https://argo.internal:2746/api/v1/workflows/argo; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status, curl's output, then curl's exit status printf '%s' "$2" case "$3:${2##*$'\nargo-anon http='}" in 0:401' '*) ;; # a missing token is refused 0:200' '*) # parse the body: a workflow list is a JSON object with a metadata object and an items list, null or absent set -- "$1" "${2%$'\nargo-anon http='*}" # the status, then the headers and body without the write-out line if ! command -v python3 > /dev/null; then echo 'argo-anon: python3 not found, so the 200 above is not classified'; set -- 2 elif printf '%s' "${2#*$'\r\n\r\n'}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); sys.exit(not (isinstance(d, dict) and isinstance(d.get("metadata"), dict) and isinstance(d.get("items"), (list, type(None)))))'; then echo 'FINDING: argo-anon returned a workflow list with no credential'; set -- 1 else echo 'argo-anon: a 200 without a workflow list; inconclusive'; set -- 3 fi ;; 0:403' '*) echo 'argo-anon: a 403 does not prove server mode is off, so confirm the configured --auth-mode; inconclusive'; set -- 3 ;; 0:*) echo 'argo-anon: a redirect, 404, 5xx or other code that is neither the 401 pass nor the finding; inconclusive'; set -- 3 ;; *) echo 'argo-anon: exited nonzero; continuing'; set -- 3 ;; esac # Enter a Kubernetes bearer token (client mode) or an Argo session token (SSO mode) with list permission in # this namespace. The token goes to curl on stdin, never argv. # getpass prompts without echo on a terminal, so the token enters neither shell history nor set -x # tracing; stdin closes the argv channel only, and the account owner and root can still read it in # python3, the block's subshell and curl. ( trap - DEBUG RETURN ERR set +x +a +e command -v python3 > /dev/null || { echo 'argo-auth: python3 not found; not probing'; exit 2; } set -- "$(if python3 -I -c 'import getpass,sys; t=getpass.getpass("Argo bearer token (without Bearer prefix): "); (t and all(33 <= ord(c) <= 126 for c in t)) or sys.exit(print("invalid token", file=sys.stderr) or 64); print("Authorization: Bearer " + t)'; then printf '\n0'; else printf '\n%s' "$?"; fi)" case "$1" in *$'\n0') set -- "${1%$'\n'*}" ;; *$'\n64') echo "argo-auth: credential input refused; not probing"; exit 2 ;; *) echo "argo-auth: credential helper failed; not probing"; exit 3 ;; esac # guard-conventions: allow probe of an illustrative internal host; no reader-substituted placeholder in this probe's argv set -- "$(if printf '%s' "$1" \ | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 --dump-header - \ --header @- -w '\nargo-auth http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ https://argo.internal:2746/api/v1/workflows/argo; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%$'\n'*}" "${1##*$'\n'}" # curl's output, then curl's exit status printf '%s' "$1" case "$2:${1##*$'\nargo-auth http='}" in 0:200' '*) # parse the body as the anonymous call does set -- "${1%$'\nargo-auth http='*}" # the headers and body without the write-out line if printf '%s' "${1#*$'\r\n\r\n'}" | python3 -I -c 'import json, sys; sys.excepthook = lambda k, e, b: print("python3:", k.__name__, e, file=sys.stderr); d = json.load(sys.stdin.buffer); sys.exit(not (isinstance(d, dict) and isinstance(d.get("metadata"), dict) and isinstance(d.get("items"), (list, type(None)))))'; then exit 0; fi echo 'argo-auth: a 200 without a workflow list; inconclusive'; exit 3 ;; 0:*) echo 'argo-auth: the matched authorized call did not return HTTP 200 with a workflow list; inconclusive'; exit 3 ;; *) echo 'argo-auth: exited nonzero; inconclusive'; exit 3 ;; esac ) case "$?:$1" in 0:*) ;; 1:*) set -- 1 ;; 2:0) set -- 2 ;; 2:*|*:1) ;; *) set -- 3 ;; esac # fold that part's status into the block's, ranked as above echo "argo: block exit $1; the response headers are not classified, and a 403 needs the configured --auth-mode, so judge those yourself" exit "$1" ) ``` ```bash # Direct-origin reachability from an UNTRUSTED vantage (run from OUTSIDE your network). Paste the whole block and # substitute your origin URL inside the quotes; any HTTP response means the origin is published and is itself the # finding, whatever a fronting proxy does. First confirm this SAME origin URL responds from an allowed # vantage, then probe it from the untrusted vantage. DNS, TLS, connection errors, and timeouts remain # inconclusive. A responding public proxy is only a separate connectivity check. # This block cannot report 0: any HTTP response records 1, read from the http= field even when a later transfer # error such as curl exit 18 follows it, and with no HTTP response it exits 3, because the fixed state and a local # DNS, TLS or connection failure look the same from here, so read curl's error against the allowed-vantage confirmation. ( set +e # errexit off here, so a failed probe still reaches the status line below set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORIGIN_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute your origin URL on the set -- line above; not probing"; exit 2 ;; http://*|https://*) set -- "$(curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -o /dev/null \ -w 'origin http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1")" # curl's write-out line; its http= field, not curl's exit status, says whether a response arrived printf '%s\n' "$1" case "$1" in 'origin http=000 '*) set -- 3 ;; # no HTTP response 'origin http='???' '*) echo 'FINDING: the origin answered over HTTP from this vantage'; set -- 1 ;; *) set -- 3 ;; # no write-out line, so whether a response arrived is unknown esac echo "origin: block exit $1; whether this vantage is untrusted, the allowed-vantage confirmation and any curl error above are not classified, so judge them yourself" exit "$1" ;; *) echo "give an http:// or https:// URL; not probing"; exit 2 ;; esac ) ``` REASONED: preceding block; the direct-origin probe in the block above follows the guide's cited entries Apache Airflow security model and the Argo Workflows security model, and as its comments say, it cannot report 0, because from the untrusted vantage the fixed state and a local DNS, TLS or connection failure look the same. This paragraph also summarizes the blocks above as findings, per the guide's cited entries Prefect, security settings; Dagster webserver and UI; Apache Airflow running in Docker; Temporal, self-hosted security; Temporal, Web UI configuration reference; Flower configuration; and Argo anonymous fallback when server mode is enabled. It adds no probe of its own; the local runs of these blocks are recorded in the first paragraph of this section, and no orchestrator was available in the authoring environment to show either state. Exposed, a listing answers without a credential; fixed, the anonymous call is refused while the matched authorized call succeeds. A DAG list, flow run, task graph, repository, or worker pool that renders without a credential is a finding; so is a Temporal Service that answers `temporal workflow list` with no claim behind it, since the UI login does not cover the frontend gRPC path. DEMONSTRATED: the Flower socket-directory stat and getfacl checks in the items below, on directories only, exposed (mode 0755, or a named entry granting x) against fixed (mode 0750 with only the base entries). During marking review (October 2026) they were run again in a btrfs scratch directory with uutils stat 0.8.0 and getfacl 2.3.2 and printed the values recorded below; again no socket was created and no Flower process ran. The Flower unix-socket directory checks were demonstrated on the authoring host, on tmpfs, with directories only: no socket was created and no Flower process ran. - DEMONSTRATED: the mode check, exposed 0755 against fixed 0750; the marking-review run (October 2026, btrfs, uutils stat 0.8.0, getfacl 2.3.2) printed the same values. `stat -c '%a %U:%G %u:%g %n'` printed `755` for a directory created with mode `0755` and `750` for one created with `0750`; `getfacl -p` on the `0750` directory listed only `user::rwx`, `group::r-x` and `other::---`. - DEMONSTRATED: a named entry on the fixed 0750 directory, before and after; the marking-review run (October 2026, btrfs, uutils stat 0.8.0, getfacl 2.3.2) printed the same values. After `setfacl -m u:nobody:--x` on the `0750` directory, `stat` still printed `750`. Only the trailing `+` in `ls -ld` and getfacl's `user:nobody:--x` line showed the named entry. - DEMONSTRATED: the mask and chmod interaction, compared with the fixed 0750 directory in the first item, where `getfacl -p` listed only `user::rwx`, `group::r-x` and `other::---`; the marking-review run (October 2026, btrfs, uutils stat 0.8.0, getfacl 2.3.2) printed the same values, and access as nobody was again not tested. After `setfacl -m m::r--`, getfacl printed `user:nobody:--x` with `#effective:---`. While masked, `stat` printed `740`. A later `chmod 0750` reset `mask::` to `r-x`, and getfacl printed `user:nobody:--x` with no `#effective:` mark, so under acl(5) the entry's `x` is in effect again (access as `nobody` was not tested). REASONED: the Flower service, cross-account and container checks remain unobserved, per the guide's cited Flower `--unix_socket` passes the fixed mode `0o777` entry (pinned v2.2.0 and v6.5.10, with unix(7), path_resolution(7), user_namespaces(7) and acl(5)). The authoring environment has no Flower install, no container runtime and no privilege to act as a second account, so neither an outside account's refusal at a 0750 directory (fixed) nor its traversal through a named x entry (exposed) was observed. During marking review (October 2026) the 2750 form and a default: entry were run in a btrfs scratch directory: after chmod 2750, uutils stat 0.8.0 printed 2750 (uutils mkdir 0.8.0 given -m 2750 had left 750); getfacl 2.3.2 listed default:user:nobody:--x beside an access ACL of only user::, group:: and other::, and a directory created inside inherited user:nobody:--x as an access entry. Not run: whether an account outside the proxy's group is refused at the directory, the socket's `777` after each Flower start, and the `%u:%g` comparison from inside a container that shares the directory. ## Common mistakes - Assuming Airflow's Simple Auth Manager, meant for development, is acceptable in production because it technically requires a password. - Configuring Temporal's UI SSO and believing the server API is now protected too; they are independent. - Setting `PREFECT_API_KEY` on a client that should be using `PREFECT_API_AUTH_STRING` against a self-hosted server, then debugging the resulting 401 as a server problem. - Publishing Flower's `5555` or Dagster's `3000` straight to the internet "for the team" with no proxy. - Storing credentials in Airflow Connections and Variables, Prefect Blocks, or Temporal payloads without restricting who can retrieve or use them. UI masking and database encryption do not enforce caller authorization. Temporal client-side payload encryption adds a separate boundary: decoding requires the key or access to a Codec Server, so authenticate and restrict any Codec Server separately. ## Sources (checked October 2026) These defaults are checked against Prefect 3.1.8+ for Basic Auth, Dagster 1.13.x, Apache Airflow 3.3.2 with FAB provider 3.9.0 (Airflow 2.11.0 noted where the configuration paths differ), Temporal Server 1.28.x and UI Server 2.34.x, and Flower 2.2.0; confirm your own versions, since several of these settings moved between releases. Argo Workflows listener, authentication paths, TLS and manifest settings were checked against tags v3.7.18 (66e32e5cc367f223e2ecf4fbe852b95eaed83034) and v4.1.4 (b5b4d665e9be9b87c115f943584c3e0ae96fe073). The install manifests were traced through their build recipes and Kustomize inputs; their generated YAML files were not present in the supplied trees. SSO setup, API examples and security guidance retain the release-3.7 documentation links. - Prefect, security settings (`PREFECT_SERVER_API_AUTH_STRING`, `PREFECT_API_AUTH_STRING`, Cloud API keys taking precedence and causing 401) (rolling documentation, checked September 2026): https://docs.prefect.io/v3/advanced/security-settings - Prefect 3.1.8 release announcement, Basic Auth support added to the API and UI: https://github.com/PrefectHQ/prefect/releases/tag/3.1.8 - Prefect 3.1.8 block-document filter and ID read routes accept `include_secrets`, and `Secret.get()` returns the underlying value. Prefect 3.1.8 sources: https://github.com/PrefectHQ/prefect/blob/3.1.8/src/prefect/server/api/block_documents.py#L47-L70, https://github.com/PrefectHQ/prefect/blob/3.1.8/src/prefect/server/api/block_documents.py#L101-L119 and https://github.com/PrefectHQ/prefect/blob/3.1.8/src/prefect/blocks/system.py#L154-L159 - Apache Airflow FAB provider 3.9.0 reads `[fab] auth_role_public` with legacy `AUTH_ROLE_PUBLIC` fallback, attaches the public role's permissions to an anonymous user when that role exists, and pins Flask-AppBuilder 5.2.2. Apache Airflow FAB provider 3.9.0 sources: https://github.com/apache/airflow/blob/providers-fab/3.9.0/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py#L327-L381 and https://github.com/apache/airflow/blob/providers-fab/3.9.0/providers/fab/pyproject.toml#L75-L81 - Apache Airflow 3.3.2 `_NullFernet` and `get_fernet()` key selection: https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/crypto.py#L47-L68 and https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/crypto.py#L154-L173 - Temporal Go SDK v1.28.0 codec HTTP handler POST encode/decode paths and construction: https://github.com/temporalio/sdk-go/blob/v1.28.0/converter/codec.go#L284-L350 - Prefect self-hosted server, default port 4200 (rolling documentation, checked September 2026): https://docs.prefect.io/v3/how-to-guides/self-hosted/server-cli - Dagster webserver and UI, default local port and no documented built-in auth (rolling documentation, checked September 2026): https://dagster.io/docs/guides/operate/webserver - Dagster webserver `DEFAULT_WEBSERVER_HOST` "127.0.0.1" and port 3000 with the free-port fallback, and `dagster dev` forwarding `--host` only when one is given (pinned tag 1.13.24). The Dagster environment-variable routes (`DAGSTER_WEBSERVER_*` through `auto_envvar_prefix`, legacy `DAGIT_*` copied onto them, and the `dagster` CLI's `DAGSTER_CLI` prefix, which Click extends per subcommand, so `dagster dev` reads `DAGSTER_CLI_DEV_*`) (pinned tag 1.13.24). The Helm chart's webserver command, which hardcodes `-h 0.0.0.0` and takes the port from `dagsterWebserver.service.port` (80 by default, Service type `ClusterIP` by default) (pinned tag 1.13.24). Dagster 1.13.24 sources (1 of 3): https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L41-L42, https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L81-L96 and https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L306-L311. Dagster 1.13.24 sources (2 of 3): https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster/dagster/_cli/dev.py#L251-L252, https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L339-L351 and . Click's subcommand prefix rule (pinned tag 8.5.0): https://github.com/pallets/click/blob/8.5.0/src/click/core.py#L476-L484. Dagster 1.13.24 sources (3 of 3): https://github.com/dagster-io/dagster/blob/1.13.24/helm/dagster/templates/helpers/_deployment-webserver.tpl#L86-L90, https://github.com/dagster-io/dagster/blob/1.13.24/helm/dagster/templates/helpers/_helpers.tpl#L55 and https://github.com/dagster-io/dagster/blob/1.13.24/helm/dagster/values.yaml#L54-L58 - Dagster webserver `build_graphql_middleware()` returning an empty list, `build_middleware()` returning only `DagsterTracedCounterMiddleware`, that middleware counting traced calls and adding an `x-dagster-call-counts` response header, and `/graphql` routed for HTTP GET and POST and for WebSocket (pinned tag 1.13.24). Dagster 1.13.24 `webserver.py` sources (1 of 2): https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L74-L75 and https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L83-L84. Dagster 1.13.24 `webserver.py` sources (2 of 2): https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L299-L315 and https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L368-L390 - Apache Airflow security overview (rolling documentation, checked September 2026): https://airflow.apache.org/docs/apache-airflow/stable/security/ - Apache Airflow, auth manager selection (`[core] auth_manager`, `airflow config get-value core auth_manager`) (Airflow 3.3.2 documentation): https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/auth-manager/index.html - Apache Airflow FAB provider, API authentication (`[fab] auth_backends`, independent of the auth manager) (rolling documentation, checked September 2026): https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/api-authentication.html - Prefect server source (health and ready paths exempted from the auth string on GET): https://github.com/PrefectHQ/prefect/blob/9e560c9b6df4e19a5109a66e66d461f9facb538d/src/prefect/server/api/server.py - Apache Airflow, quickstart (default port 8080): https://airflow.apache.org/docs/apache-airflow/stable/start.html - Apache Airflow running in Docker, development Compose defaults for credentials, JWT signing and port publication (rolling documentation, checked September 2026): https://airflow.apache.org/docs/apache-airflow/stable/howto/docker-compose/index.html - Apache Airflow security model, authenticated users and private deployment (Airflow 3.3.2 documentation): https://airflow.apache.org/docs/apache-airflow/3.3.2/security/security_model.html - Apache Airflow Simple auth manager, default for development/testing, configured users and generated passwords (Airflow 3.3.2 documentation): https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/auth-manager/simple/index.html - Temporal, self-hosted security (`noopAuthorizer` default, `Authorizer`, `ClaimMapper`) (rolling documentation, checked September 2026): https://docs.temporal.io/self-hosted-guide/security - Temporal, Web UI configuration reference (`auth.providers`, `enabled`, `type: oidc`, `providerUrl`, `clientId`, `clientSecret`, `callbackUrl`, `scopes`) (rolling documentation, checked September 2026): https://docs.temporal.io/references/web-ui-configuration - Temporal CLI server reference, default frontend gRPC port 7233 and Web UI port 8233 (rolling documentation, checked September 2026): https://docs.temporal.io/cli/command-reference/server - Flower configuration, `--address`, `--port` 5555 default, `--basic-auth`, `--auth_provider`, `--oauth2_key`, `--oauth2_secret`, `--oauth2_redirect_uri` and `--auth` (rolling documentation, checked September 2026): https://flower.readthedocs.io/en/latest/config.html - Flower `port` default 5555, `address` default `''` and the `unix_socket` branch, the `FLOWER_` environment variables, and the implicit `flowerconfig.py` load from the working directory (pinned tag v2.2.0), with Tornado's `bind_sockets` treating an empty address as all interfaces (pinned tag v6.5.10). Flower v2.2.0 and Tornado v6.5.10 sources: https://github.com/mher/flower/blob/v2.2.0/flower/options.py#L7-L15, https://github.com/mher/flower/blob/v2.2.0/flower/options.py#L56-L57, https://github.com/mher/flower/blob/v2.2.0/flower/command.py#L39-L91, https://github.com/mher/flower/blob/v2.2.0/flower/app.py#L71-L76 and https://github.com/tornadoweb/tornado/blob/v6.5.10/tornado/netutil.py#L72-L73 - Flower `--unix_socket` passes the fixed mode `0o777` (pinned tag v2.2.0); Tornado's `bind_unix_socket` removes an existing socket at a filesystem path, binds, then applies that mode, and binds a name with a leading NUL with no mode (pinned tag v6.5.10). A connection to a stream socket on Linux needs write permission on that socket, and file permissions on Linux have no meaning for abstract sockets. Path lookup needs search permission on each directory, `CAP_DAC_OVERRIDE` overrides permission checks and `CAP_DAC_READ_SEARCH` grants search on directories. File permission checks in a user namespace compare IDs mapped back to the initial namespace, and a capability in a user namespace applies only to a file whose owner and group are mapped there. Docker's default capability set includes `CAP_DAC_OVERRIDE` (moby docker-v29.8.1); an idmapped mount changes ownership for that mount only; `RuntimeDirectoryMode=` defaults to `0755` (systemd v257); files under `/run` must be cleared at the beginning of the boot process (FHS 3.0). Named ACL entries grant access only up to the mask, changing a file's group permission bits sets its `ACL_MASK` entry, a directory's default ACL governs the initial ACL of objects created within it, and `getfacl` marks a limited entry `#effective:`; `stat -c` format sequences. Sources for this item (1 of 3): https://github.com/mher/flower/blob/v2.2.0/flower/app.py#L85-L89, https://github.com/tornadoweb/tornado/blob/v6.5.10/tornado/netutil.py#L215-L228, https://man7.org/linux/man-pages/man7/unix.7.html and https://man7.org/linux/man-pages/man7/path_resolution.7.html. Sources for this item (2 of 3): https://man7.org/linux/man-pages/man7/user_namespaces.7.html, https://www.kernel.org/doc/html/latest/filesystems/idmappings.html and https://github.com/moby/moby/blob/docker-v29.8.1/daemon/pkg/oci/caps/defaults.go#L4-L7. Sources for this item (3 of 3): https://github.com/systemd/systemd/blob/v257/man/systemd.exec.xml#L1630-L1640, https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s15.html, https://man7.org/linux/man-pages/man5/acl.5.html, https://man7.org/linux/man-pages/man1/getfacl.1.html and https://man7.org/linux/man-pages/man1/stat.1.html - Linux `ss` socket statistics (`iproute2` v6.15.0), with `-N`/`--net` to switch network namespace. The `iproute2` v6.15.0 `ss` manual sources: https://github.com/iproute2/iproute2/blob/v6.15.0/man/man8/ss.8#L7-L12 and https://github.com/iproute2/iproute2/blob/v6.15.0/man/man8/ss.8#L358-L359. Network namespaces isolating network devices, protocol stacks and port numbers (Linux man-pages-5.13). The Linux man-pages-5.13 source: https://github.com/mkerrisk/man-pages/blob/man-pages-5.13/man7/network_namespaces.7#L30-L40 - Argo CLI defaults and environment overrides: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/cmd/argo/commands/server.go#L191-L229), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/cmd/argo/commands/server.go#L195-L239). - Argo TLS certificate selection and generation: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/cmd/argo/commands/server.go#L113-L137), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/cmd/argo/commands/server.go#L119-L143). - Argo wildcard TCP listener and TLS wrapper: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/apiserver/argoserver.go#L263-L282), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/apiserver/argoserver.go#L299-L320). - Argo anonymous fallback when server mode is enabled: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/auth/mode.go#L33-L44), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/auth/mode.go#L33-L44). - Argo client credentials, server credentials and SSO RBAC selection: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/auth/gatekeeper.go#L166-L218), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/auth/gatekeeper.go#L189-L244). - Argo quick-start auth override: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start/base/overlays/argo-server-deployment.yaml#L1-L15), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start/base/overlays/argo-server-deployment.yaml#L1-L15). - Argo quick-start-minimal server deployment: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start-minimal.yaml#L3613-L3664), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-minimal.yaml#L192772-L192823). - Argo quick-start-mysql server deployment: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start-mysql.yaml#L3678-L3729), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-mysql.yaml#L192838-L192889). - Argo quick-start-postgres server deployment: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start-postgres.yaml#L3677-L3728), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-postgres.yaml#L192837-L192888). - Argo additional quick-start-telemetry server deployment: [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-telemetry.yaml#L192919-L192970). - Argo install manifest build recipes: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/Makefile#L493-L515), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/Makefile#L608-L634). - Argo base server account, args and HTTPS readiness probe: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/base/argo-server/argo-server-deployment.yaml#L14-L34), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/base/argo-server/argo-server-deployment.yaml#L14-L34). - Argo base Service ports (type omitted): [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/base/argo-server/argo-server-service.yaml#L1-L11), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/base/argo-server/argo-server-service.yaml#L1-L11). - Argo namespace install server args overlay: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/namespace-install/overlays/argo-server-deployment.yaml#L1-L4), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/namespace-install/overlays/argo-server-deployment.yaml#L1-L4). - Argo server ClusterRole permissions: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterole.yaml#L1-L65), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterole.yaml#L1-L67). - Argo server ClusterRole binding: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterolebinding.yaml#L1-L11), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterolebinding.yaml#L1-L11). - Argo namespace server Role permissions: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml#L1-L65), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml#L1-L67). - Argo namespace server Role binding: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/namespace-install/argo-server-rbac/argo-server-rolebinding.yaml#L1-L11), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/namespace-install/argo-server-rbac/argo-server-rolebinding.yaml#L1-L11). - Argo quick-start base resources, which include `cluster-install-no-crds`: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start/base/kustomization.yaml#L4-L5), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start/base/kustomization.yaml#L4-L5). - Argo additional quick-start cluster-template permissions and bindings: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start/base/cluster-workflow-template-rbac.yaml#L1-L58), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start/base/cluster-workflow-template-rbac.yaml#L1-L58). - Argo gatekeeper `Context` path to the same client selection: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/auth/gatekeeper.go#L106-L121), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/auth/gatekeeper.go#L133-L148). - Argo metrics auth and conditional HSTS: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/apiserver/argoserver.go#L430-L449), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/apiserver/argoserver.go#L476-L494). - Argo quick-start purpose and production warning: [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/docs/quick-start.md#L3-L34), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/docs/quick-start.md#L3-L34). - Argo auth-mode history and hosted/local identities (v4.1.4 also documents the Kubernetes 1.28 requirement): [v3.7.18](https://github.com/argoproj/argo-workflows/blob/v3.7.18/docs/argo-server-auth-mode.md#L3-L9), [v4.1.4](https://github.com/argoproj/argo-workflows/blob/v4.1.4/docs/argo-server-auth-mode.md#L3-L9). - Argo Workflows, [SSO and RBAC](https://argo-workflows.readthedocs.io/en/release-3.7/argo-server-sso/), [workflow-list API](https://argo-workflows.readthedocs.io/en/release-3.7/rest-examples/), and [security model](https://argo-workflows.readthedocs.io/en/release-3.7/security/). - Apache Airflow API authentication setting and default (2.11.0 documentation): https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#auth-backends - Apache Airflow singular API authentication setting and deny-all default (2.2.5 documentation): https://airflow.apache.org/docs/apache-airflow/2.2.5/configurations-ref.html#auth-backend - Apache Airflow Simple Auth Manager all-admin authentication bypass (3.3.2 documentation): https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#simple-auth-manager-all-admins - Apache Airflow public API JWT authentication (3.3.2 documentation): https://airflow.apache.org/docs/apache-airflow/3.3.2/security/api.html - Apache Airflow development Compose FAB selection, administrator credentials and JWT fallback secret (pinned tag 3.3.2): https://raw.githubusercontent.com/apache/airflow/3.3.2/airflow-core/docs/howto/docker-compose/docker-compose.yaml - Apache Airflow webserver secret_key and EXPOSE_CONFIG settings (2.11.0 documentation): https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#secret-key, https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#expose-config - Apache Airflow API secret_key and EXPOSE_CONFIG settings (3.3.2 documentation): https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#secret-key, https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#expose-config ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - prefect: Prefect Basic Auth minimum (3.1.8) - dagster: Dagster OSS (1.13.24) - airflow: Apache Airflow (3.3.2) - temporal-codec: Temporal Go SDK codec source (v1.28.0) - flower: Flower (v2.2.0) - argo-three: Argo Workflows (v3.7.18) - argo-four: Argo Workflows (v4.1.4) - prefect-blocks: Prefect Blocks source (3.1.8) - airflow-two: Apache Airflow historical configuration (2.11.0) - airflow-old: Apache Airflow historical default (2.2.5) - fab-source: Apache Airflow FAB provider source (providers-fab/3.9.0) - airflow-crypto: Apache Airflow crypto source (3.3.2) - argo-docs: Argo Workflows documentation (release-3.7) - prefect-source: Prefect server source (9e560c9b6df4e19a5109a66e66d461f9facb538d) - prefect-rolling: Prefect documentation (rolling) (unknown) - dagster-rolling: Dagster documentation (rolling) (unknown) - airflow-rolling: Apache Airflow documentation (rolling) (unknown) - fab-rolling: Apache Airflow FAB provider documentation (rolling) (unknown) - temporal-rolling: Temporal documentation (rolling) (unknown) - flower-rolling: Flower documentation (rolling) (unknown) - tornado: Tornado (v6.5.10) - docker: Moby Docker (docker-v29.8.1) - linux: Linux permissions and tools (unknown) - systemd: systemd (v257) - fhs: Filesystem Hierarchy Standard (3.0) - iproute2: iproute2 ss manual (v6.15.0) - network-namespaces: Linux network namespaces manual (man-pages-5.13) Claims (full source mapping in the JSON block below): - fronting (REASONED; airflow, argo-docs, temporal-rolling): Keep UI/API origins private behind HTTPS and authentication; use IdP MFA for browsers and separate machine authentication, and constrain worker egress. - prefect-auth (REASONED; prefect, prefect-rolling): Self-hosted Prefect defaults unauthenticated; Basic Auth from 3.1.8 uses matching server/client auth strings and prompts the UI. - prefect-key (REASONED; prefect, prefect-rolling): Cloud-only PREFECT_API_KEY takes precedence over PREFECT_API_AUTH_STRING and produces 401 against a self-hosted server. - prefect-secrets (REASONED; prefect-blocks, prefect-rolling): Protect the auth string and credential-bearing Blocks; Prefect 3.1.8 block-document read routes accept include_secrets, and Secret.get() returns the underlying value. Restrict API and worker access; UI masking does not enforce authorization. - prefect-port (REASONED; prefect-rolling): The self-hosted Prefect server uses port 4200. - dagster-bind (REASONED; dagster): Direct dagster-webserver/dev defaults to 127.0.0.1:3000, with flag/environment overrides and free-port fallback when the port is unset or 0; explicitly bind the private origin. - dagster-helm (REASONED; dagster): The chart passes -h 0.0.0.0 on the Service port, default 80, with ClusterIP Service by default. - dagster-auth (REASONED; dagster): In dagster-webserver 1.13.24, build_middleware() returns only DagsterTracedCounterMiddleware, which counts traced calls and adds a response header, and build_graphql_middleware() returns an empty list, so neither adds authentication; this guide therefore treats the OSS webserver as having no built-in login. Protect all routes with an authenticating proxy. - airflow-boundary (REASONED; airflow): Airflow assumes authenticated known users and is not designed for untrusted public exposure; deployment managers must keep it private. - airflow-two-api (REASONED; airflow-two, airflow-old): Airflow 2.11.0 uses [api] auth_backends with session default; before 2.3 the name was auth_backend and 2.2.5 defaulted to deny_all. - airflow-simple (REASONED; airflow): Airflow 3 defaults to development-only Simple Auth Manager, with configured users/roles and generated passwords printed to logs unless supplied. - airflow-fab (REASONED; airflow, fab-rolling): For production install FAB and select FabAuthManager through [core] auth_manager; verify the effective manager and use an identity backend with MFA. - airflow-api (REASONED; airflow, fab-rolling): Airflow 3 public API uses JWT independently of [fab] auth_backends, which selects FAB API backends rather than the auth manager. - airflow-all-admins (REASONED; airflow): Keep simple_auth_manager_all_admins unset or False; enabling it disables login and grants every visitor admin. - fab-public-role (REASONED; fab-source): Leave [fab] auth_role_public and legacy AUTH_ROLE_PUBLIC unset; a configured public role can make FAB treat an anonymous visitor as logged in and supply that role's permissions. - airflow-compose-account (REASONED; airflow-rolling, fab-rolling, airflow): Development Compose selects FAB but defaults to airflow/airflow; set credentials before account creation and update/delete an existing account separately. - airflow-jwt (REASONED; airflow-rolling, airflow): Replace Compose fallback AIRFLOW__API_AUTH__JWT_SECRET=airflow_jwt_secret and share the strong value with all signers/validators; the public fallback permits token forgery. - airflow-secret-key (REASONED; airflow-two, airflow-rolling, airflow): Provision the independent secret_key too: [webserver] on 2.11.0 and [api] on 3.3.2. - airflow-config (REASONED; airflow-two, airflow-rolling, airflow): Keep configuration exposure off with WEBSERVER__EXPOSE_CONFIG on 2.11.0 or API__EXPOSE_CONFIG on 3.3.2. - airflow-fernet (REASONED; airflow-crypto, airflow-rolling): Protect Connections, Variables, database and Fernet keys; an empty [core] FERNET_KEY selects a no-encryption implementation for new values, while configured comma-separated keys use MultiFernet. Preserve keys needed to decrypt existing values; authorized workloads still use secrets. - airflow-port (REASONED; airflow-rolling): Compose publishes 8080 on all interfaces; use 127.0.0.1:8080:8080 behind the fronting layer. - temporal-authorizer (REASONED; temporal-rolling): Empty authorizer selects noopAuthorizer allowing every API request, including administration; configure default authorization with trusted JWT keys and audience. - temporal-claims (REASONED; temporal-rolling): Empty claimMapper selects a no-op granting system-admin claims; configure both Authorizer and ClaimMapper, with frontend TLS. - temporal-health (REASONED; temporal-rolling): The built-in default authorizer permits health checks without claims; health is not an authorization discriminator. - temporal-ui (REASONED; temporal-rolling): UI OIDC auth.enabled is a sibling of providers; TEMPORAL_AUTH_ENABLED plus provider settings gates the UI only, with MFA at the IdP. - temporal-tls (REASONED; temporal-rolling): Internode and frontend mTLS are separate from UI login and API authorization. - temporal-payloads (REASONED; temporal-rolling, temporal-codec): Keep plaintext credentials out of persisted inputs/results/history; the Temporal Go SDK v1.28.0 codec HTTP handler accepts encode/decode POSTs without an authentication check, so authenticate and restrict a deployed Codec Server separately. - temporal-ports (REASONED; temporal-rolling): Frontend gRPC uses 7233 and the Web UI 8233 in the cited CLI server reference. - flower-bind (REASONED; flower, tornado): Flower defaults to wildcard address and 5555 unless Unix socket, FLOWER_ variables or implicit working-directory flowerconfig.py override; bind loopback explicitly. - flower-api (REASONED; flower-rolling): Authentication defaults off; unauthenticated HTTP API is disabled unless FLOWER_UNAUTHENTICATED_API=true, which must not be mistaken for dashboard protection. - flower-basic (REASONED; flower-rolling): Basic auth uses a comma-separated credential list; supply it through FLOWER_BASIC_AUTH or protected config rather than argv and retain private TLS fronting. - flower-oauth (REASONED; flower-rolling): OAuth needs handler, client key/secret, redirect and allowed-email regex; protect the secret outside argv and prefer an MFA-enforcing provider. - flower-socket (REASONED; flower, tornado, linux): Flower passes fixed mode 0777; Tornado removes/recreates filesystem sockets on startup, so chmod does not persist. Abstract sockets have no filesystem permission boundary. - flower-directory (REASONED; linux): Restrict socket directory search with Flower ownership, proxy-only group, mode 0750 and no outsider ACL; directory access is essential because socket mode permits writes. - flower-capabilities (REASONED; linux, docker): Applicable DAC capabilities bypass directory search checks; Docker defaults include CAP_DAC_OVERRIDE, while user namespaces and idmapped mounts change identity interpretation. - flower-runtime-dir (REASONED; systemd, fhs): Reapply directory owner/group/mode on creation: /run is cleared at boot and RuntimeDirectory defaults 0755 unless RuntimeDirectoryMode overrides it. - flower-numeric-ids (REASONED; linux): A proxy sharing the socket directory must hold the required numeric group after namespace/mount mappings; names alone do not establish access. - argo-bind (REASONED; argo-three, argo-four): Argo Server binds wildcard :2746 with host-dependent IPv4/IPv6 support and no bind-address flag. - argo-defaults (REASONED; argo-three, argo-four): Both tags default secure=true, auth-mode=client and hsts=true; flags and ARGO_ environment equivalents can override. - argo-client (REASONED; argo-three, argo-four): Client mode uses caller Kubernetes credentials/RBAC; v4.1.4 validates via SelfSubjectReview and requires Kubernetes 1.28+. - argo-quickstart (REASONED; argo-three, argo-four): Both tags' minimal/mysql/postgres quick-starts enable server plus client mode while retaining TLS; v4.1.4 telemetry does too. Anonymous callers inherit server permissions. - argo-quickstart-rbac (REASONED; argo-three, argo-four): Quick-start binds cluster-wide workflow/template and Secret get/create permissions, plus cluster-workflow-template rights; it is demo-only. - argo-installs (REASONED; argo-three, argo-four): Traced install inputs keep client auth/TLS with cluster-wide server RBAC; namespace-install adds --namespaced and namespace RBAC. Generated install YAML was absent. - argo-service (REASONED; argo-three, argo-four): Base Service omits type, defaulting to ClusterIP, and maps 2746 to 2746; this does not authenticate reachable callers. - argo-fallback (REASONED; argo-three, argo-four): Remove server auth mode: adding client/SSO does not prevent anonymous fallback to server credentials; local mode uses the server kubeconfig. - argo-sso (REASONED; argo-docs, argo-three, argo-four): Enable sso.rbac.enabled and map groups to narrow ServiceAccounts through rbac-rule/precedence; without SSO RBAC, authenticated users share server permissions. - argo-secrets (REASONED; argo-docs): Store SSO OAuth credentials in referenced Kubernetes Secrets and supply API tokens through protected input, not argv. - argo-tls (REASONED; argo-three, argo-four): Keep TLS enabled with a trusted certificate Secret; no name generates a self-signed certificate, failed named-Secret loading fails startup, secure=false is plaintext and defeats HSTS. - argo-metrics (REASONED; argo-three, argo-four): /metrics shares the server listener and auth gate unless ARGO_SERVER_METRICS_AUTH=false; server mode also admits anonymous metrics. - argo-network (REASONED; argo-docs): Keep 2746 private behind authenticated HTTPS; restrict direct Pod/Service access and scope server, SSO and execution-account permissions. Workflow submission permits arbitrary containers unless constrained. - verify-inventory (REASONED; flower, argo-three, iproute2, network-namespaces, prefect-rolling): ss inventories only the current namespace, not publication, routing or authentication; inspect publications and external reachability separately. - verify-prefect (REASONED; prefect, prefect-source, prefect-rolling): Anonymous POST /api/flows/filter returning a JSON list is exposed; fixed is 401 with an authorized list on the same origin. Health/ready GET exemptions are version-dependent. - verify-dagster (REASONED; dagster, dagster-rolling): Dagster 1.13.24 routes /graphql over HTTP GET and POST and over WebSocket, and its build_middleware() adds no authentication. This guide reads a RepositoryConnection from /graphql as a read, including empty nodes; GraphQL/transport errors are inconclusive. Test proxy authentication and origin isolation separately. - verify-airflow (REASONED; airflow-two, airflow-rolling, fab-rolling): FAB 3.9.0 returning 201 with access_token for airflow/airflow is exposed; pair a valid account and anonymous/authenticated API reads at proxy and origin. Airflow 2 uses its own API/auth. - verify-temporal (REASONED; temporal-rolling): Credential-free workflow listing on 7233 is exposed; require rejection and a matched authorized call, not health, TLS errors or missing namespaces as proof of auth. - verify-flower (REASONED; flower-rolling): Anonymous UI/API should yield Basic 401 or OAuth login redirect, with a valid-session control; API-disabled is not proof the dashboard is protected. - verify-socket (REASONED; flower, linux): Directory/socket permissions, outsider denial, reboot persistence and container numeric IDs remain reasoned; no socket or Flower process ran. - flower-stat-run (DEMONSTRATED; linux): Tmpfs directory-only stat distinguished 0755 and 0750; the 0750 directory initially had only base ACL entries. - flower-acl-run (DEMONSTRATED; linux): A named nobody search ACL remained invisible in stat mode 750; ls and getfacl exposed the entry. - flower-mask-run (DEMONSTRATED; linux): Changing the ACL mask hid named search permission; chmod 0750 restored its reported effectiveness. Access as nobody was not tested. - verify-argo (REASONED; argo-three, argo-four, argo-docs): Anonymous workflow JSON is exposed when server RBAC permits; missing tokens should yield 401 without server mode. A 403 alone is inconclusive; pair authorized 200 and inspect auth modes. - verify-origin (REASONED; airflow, argo-docs): Any HTTP response from the origin at an untrusted vantage proves publication; use the same responding allowed origin as control and treat connection errors/timeouts as inconclusive. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-10", "body_sha256": "3a0875a2a4d93fab899c35a08c00c67cda26f1cf94c47b48d7d6665196da2a14", "components": { "prefect": { "name": "Prefect Basic Auth minimum", "basis": "3.1.8", "sources": { "s12416d1a0d69": "https://github.com/PrefectHQ/prefect/releases/tag/3.1.8" } }, "dagster": { "name": "Dagster OSS", "basis": "1.13.24", "sources": { "sab1b42d92f27": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L41-L42", "s6bc57f86dfe1": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L81-L96", "s2455602a504b": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L306-L311", "s3c7d8f17f245": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster/dagster/_cli/dev.py#L251-L252", "s469c458c4a71": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/cli.py#L339-L351", "s695dfd19dcca": "https://github.com/dagster-io/dagster/blob/1.13.24/helm/dagster/templates/helpers/_deployment-webserver.tpl#L86-L90", "sf6f027e96a0d": "https://github.com/dagster-io/dagster/blob/1.13.24/helm/dagster/templates/helpers/_helpers.tpl#L55", "seda3a1b24425": "https://github.com/dagster-io/dagster/blob/1.13.24/helm/dagster/values.yaml#L54-L58", "sabd01546900d": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L74-L75", "s1fb740d34a69": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L83-L84", "sf751886ecbdd": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L299-L315", "s9e0d6cad1a6f": "https://github.com/dagster-io/dagster/blob/1.13.24/python_modules/dagster-webserver/dagster_webserver/webserver.py#L368-L390" } }, "airflow": { "name": "Apache Airflow", "basis": "3.3.2", "sources": { "s1d0b86fe4334": "https://airflow.apache.org/docs/apache-airflow/3.3.2/security/security_model.html", "sad02a49561a0": "https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/auth-manager/simple/index.html", "s3be3e4616150": "https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/auth-manager/index.html", "s78c661193a1a": "https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#simple-auth-manager-all-admins", "s963b49f58a13": "https://airflow.apache.org/docs/apache-airflow/3.3.2/security/api.html", "s705f1eb5de00": "https://raw.githubusercontent.com/apache/airflow/3.3.2/airflow-core/docs/howto/docker-compose/docker-compose.yaml", "sdfcfd543928c": "https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#secret-key", "s4ed64009ed8f": "https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#expose-config" } }, "temporal-codec": { "name": "Temporal Go SDK codec source", "basis": "v1.28.0", "sources": { "s24435f531da0": "https://github.com/temporalio/sdk-go/blob/v1.28.0/converter/codec.go#L284-L350" } }, "flower": { "name": "Flower", "basis": "v2.2.0", "sources": { "s25760fae7152": "https://github.com/mher/flower/blob/v2.2.0/flower/options.py#L7-L15", "s1f72a8415337": "https://github.com/mher/flower/blob/v2.2.0/flower/options.py#L56-L57", "s4dd518442b7f": "https://github.com/mher/flower/blob/v2.2.0/flower/command.py#L39-L91", "s4e49ebc71dc1": "https://github.com/mher/flower/blob/v2.2.0/flower/app.py#L71-L76", "s0fe6c77e9c9d": "https://github.com/mher/flower/blob/v2.2.0/flower/app.py#L85-L89" } }, "argo-three": { "name": "Argo Workflows", "basis": "v3.7.18", "sources": { "s262098553d2f": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/cmd/argo/commands/server.go#L191-L229", "sbddb9f07a301": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/apiserver/argoserver.go#L263-L282", "s2ca4f14c3164": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/auth/gatekeeper.go#L166-L218", "sfbb904619bfa": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start/base/overlays/argo-server-deployment.yaml#L1-L15", "s0ac72aa1a86c": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterole.yaml#L1-L65", "s1689f25010be": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start/base/cluster-workflow-template-rbac.yaml#L1-L58", "sf0c3a52d3302": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/Makefile#L493-L515", "sfdcdf614ee37": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/base/argo-server/argo-server-deployment.yaml#L14-L34", "sc7ca3a37b16c": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/namespace-install/overlays/argo-server-deployment.yaml#L1-L4", "s01f102470d97": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/base/argo-server/argo-server-service.yaml#L1-L11", "s49cdaf0691c0": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/auth/mode.go#L33-L44", "sdb4a0c18b7c6": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/cmd/argo/commands/server.go#L113-L137", "s36759aec55fa": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/apiserver/argoserver.go#L430-L449", "s862033e52c9b": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start-minimal.yaml#L3613-L3664", "s73e9beeef775": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start-mysql.yaml#L3678-L3729", "s476ff15db454": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start-postgres.yaml#L3677-L3728", "sa4b2fa875915": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterolebinding.yaml#L1-L11", "s48db5ba07d23": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/quick-start/base/kustomization.yaml#L4-L5", "se3ffdf2973a6": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml#L1-L65", "sb901e7f87b54": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/manifests/namespace-install/argo-server-rbac/argo-server-rolebinding.yaml#L1-L11", "s49a8855b6da2": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/docs/argo-server-auth-mode.md#L3-L9", "s64e74b0dbe95": "https://github.com/argoproj/argo-workflows/blob/v3.7.18/server/auth/gatekeeper.go#L106-L121" } }, "argo-four": { "name": "Argo Workflows", "basis": "v4.1.4", "sources": { "s774215022573": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/cmd/argo/commands/server.go#L195-L239", "s0f074a9ac21e": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/apiserver/argoserver.go#L299-L320", "sb284f93cde9c": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/auth/gatekeeper.go#L189-L244", "s0871a1dd9b13": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/docs/argo-server-auth-mode.md#L3-L9", "sa7b9db8384be": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start/base/overlays/argo-server-deployment.yaml#L1-L15", "s79648b46d3f5": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-telemetry.yaml#L192919-L192970", "sd2706d04971d": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterole.yaml#L1-L67", "sd920da4ad4b7": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/docs/quick-start.md#L3-L34", "s144b824252c1": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/Makefile#L608-L634", "s51645bfbc47c": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/base/argo-server/argo-server-deployment.yaml#L14-L34", "s5188f6f2efbb": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/namespace-install/overlays/argo-server-deployment.yaml#L1-L4", "s639e9fe0024e": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/base/argo-server/argo-server-service.yaml#L1-L11", "s4613c15c72af": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/auth/mode.go#L33-L44", "s0b16f1ac50df": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/cmd/argo/commands/server.go#L119-L143", "sa120f74372eb": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/apiserver/argoserver.go#L476-L494", "s6c08a5f34f2c": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-minimal.yaml#L192772-L192823", "sa0bf6187d1db": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-mysql.yaml#L192838-L192889", "s31f4a149576b": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start-postgres.yaml#L192837-L192888", "s3be9b03e29be": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/cluster-install-no-crds/argo-server-rbac/argo-server-clusterolebinding.yaml#L1-L11", "sc263bd937cf0": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/quick-start/base/kustomization.yaml#L4-L5", "s1b134f96428f": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml#L1-L67", "s2418ee1aa49d": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/manifests/namespace-install/argo-server-rbac/argo-server-rolebinding.yaml#L1-L11", "se758d83aaf1f": "https://github.com/argoproj/argo-workflows/blob/v4.1.4/server/auth/gatekeeper.go#L133-L148" } }, "prefect-blocks": { "name": "Prefect Blocks source", "basis": "3.1.8", "sources": { "s56081101d16a": "https://github.com/PrefectHQ/prefect/blob/3.1.8/src/prefect/server/api/block_documents.py#L47-L70", "s1eae2c5f9f5e": "https://github.com/PrefectHQ/prefect/blob/3.1.8/src/prefect/server/api/block_documents.py#L101-L119", "s46e69a2090c4": "https://github.com/PrefectHQ/prefect/blob/3.1.8/src/prefect/blocks/system.py#L154-L159" } }, "airflow-two": { "name": "Apache Airflow historical configuration", "basis": "2.11.0", "sources": { "sb97f3044e215": "https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#auth-backends", "sabb71378ad74": "https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#secret-key", "s6813801b7880": "https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#expose-config" } }, "airflow-old": { "name": "Apache Airflow historical default", "basis": "2.2.5", "sources": { "s4e72b1f51038": "https://airflow.apache.org/docs/apache-airflow/2.2.5/configurations-ref.html#auth-backend" } }, "fab-source": { "name": "Apache Airflow FAB provider source", "basis": "providers-fab/3.9.0", "sources": { "s3c94f0f7176a": "https://github.com/apache/airflow/blob/providers-fab/3.9.0/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py#L327-L381", "s5552a28ab9d8": "https://github.com/apache/airflow/blob/providers-fab/3.9.0/providers/fab/pyproject.toml#L75-L81" } }, "airflow-crypto": { "name": "Apache Airflow crypto source", "basis": "3.3.2", "sources": { "sdeaf2f54c054": "https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/crypto.py#L47-L68", "s70ea7631e820": "https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/crypto.py#L154-L173" } }, "argo-docs": { "name": "Argo Workflows documentation", "basis": "release-3.7", "sources": { "s6c8e78288632": "https://argo-workflows.readthedocs.io/en/release-3.7/security/", "s9898d4653a47": "https://argo-workflows.readthedocs.io/en/release-3.7/argo-server-sso/", "s96145eacf345": "https://argo-workflows.readthedocs.io/en/release-3.7/rest-examples/" } }, "prefect-source": { "name": "Prefect server source", "basis": "9e560c9b6df4e19a5109a66e66d461f9facb538d", "sources": { "sa4574c7ed80f": "https://github.com/PrefectHQ/prefect/blob/9e560c9b6df4e19a5109a66e66d461f9facb538d/src/prefect/server/api/server.py" } }, "prefect-rolling": { "name": "Prefect documentation (rolling)", "basis": "unknown", "sources": { "sbe64d51110a2": "https://docs.prefect.io/v3/how-to-guides/self-hosted/server-cli", "s0ef73a227667": "https://docs.prefect.io/v3/advanced/security-settings" } }, "dagster-rolling": { "name": "Dagster documentation (rolling)", "basis": "unknown", "sources": { "sb623df76a5b1": "https://dagster.io/docs/guides/operate/webserver" } }, "airflow-rolling": { "name": "Apache Airflow documentation (rolling)", "basis": "unknown", "sources": { "s2d1891641c91": "https://airflow.apache.org/docs/apache-airflow/stable/security/", "s39bfd91c507b": "https://airflow.apache.org/docs/apache-airflow/stable/howto/docker-compose/index.html" } }, "fab-rolling": { "name": "Apache Airflow FAB provider documentation (rolling)", "basis": "unknown", "sources": { "s989f09d05161": "https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/api-authentication.html" } }, "temporal-rolling": { "name": "Temporal documentation (rolling)", "basis": "unknown", "sources": { "s12f2c64ad396": "https://docs.temporal.io/cli/command-reference/server", "s06d4f0dbd0c8": "https://docs.temporal.io/self-hosted-guide/security", "sa9d5614385ca": "https://docs.temporal.io/references/web-ui-configuration" } }, "flower-rolling": { "name": "Flower documentation (rolling)", "basis": "unknown", "sources": { "sb1ba69f79c9b": "https://flower.readthedocs.io/en/latest/config.html" } }, "tornado": { "name": "Tornado", "basis": "v6.5.10", "sources": { "sc503b4425831": "https://github.com/tornadoweb/tornado/blob/v6.5.10/tornado/netutil.py#L72-L73", "s737c51cccd00": "https://github.com/tornadoweb/tornado/blob/v6.5.10/tornado/netutil.py#L215-L228" } }, "docker": { "name": "Moby Docker", "basis": "docker-v29.8.1", "sources": { "s1f268e247a1f": "https://github.com/moby/moby/blob/docker-v29.8.1/daemon/pkg/oci/caps/defaults.go#L4-L7" } }, "linux": { "name": "Linux permissions and tools", "basis": "unknown", "sources": { "s8ffd93b0efa4": "https://man7.org/linux/man-pages/man7/unix.7.html", "sfb53d2819cd0": "https://man7.org/linux/man-pages/man7/path_resolution.7.html", "s31f41c54750c": "https://man7.org/linux/man-pages/man5/acl.5.html", "s3cf3d5be0f64": "https://man7.org/linux/man-pages/man7/user_namespaces.7.html", "sa5ccbfea6e16": "https://www.kernel.org/doc/html/latest/filesystems/idmappings.html", "s08902025e5f9": "https://man7.org/linux/man-pages/man1/getfacl.1.html", "sa0d3793fabb7": "https://man7.org/linux/man-pages/man1/stat.1.html" } }, "systemd": { "name": "systemd", "basis": "v257", "sources": { "s48ece26c16d3": "https://github.com/systemd/systemd/blob/v257/man/systemd.exec.xml#L1630-L1640" } }, "fhs": { "name": "Filesystem Hierarchy Standard", "basis": "3.0", "sources": { "seafd8219f367": "https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s15.html" } }, "iproute2": { "name": "iproute2 ss manual", "basis": "v6.15.0", "sources": { "s6cc7f285a0c7": "https://github.com/iproute2/iproute2/blob/v6.15.0/man/man8/ss.8#L358-L359", "s9fa9a260f1de": "https://github.com/iproute2/iproute2/blob/v6.15.0/man/man8/ss.8#L7-L12" } }, "network-namespaces": { "name": "Linux network namespaces manual", "basis": "man-pages-5.13", "sources": { "s427c923e346f": "https://github.com/mkerrisk/man-pages/blob/man-pages-5.13/man7/network_namespaces.7#L30-L40" } } }, "claims": { "fronting": {"text": "Keep UI/API origins private behind HTTPS and authentication; use IdP MFA for browsers and separate machine authentication, and constrain worker egress.", "components": ["airflow", "argo-docs", "temporal-rolling"], "sources": ["airflow:s1d0b86fe4334", "temporal-rolling:s06d4f0dbd0c8", "argo-docs:s6c8e78288632"], "status": "REASONED"}, "prefect-auth": {"text": "Self-hosted Prefect defaults unauthenticated; Basic Auth from 3.1.8 uses matching server/client auth strings and prompts the UI.", "components": ["prefect", "prefect-rolling"], "sources": ["prefect:s12416d1a0d69", "prefect-rolling:s0ef73a227667"], "status": "REASONED"}, "prefect-key": {"text": "Cloud-only PREFECT_API_KEY takes precedence over PREFECT_API_AUTH_STRING and produces 401 against a self-hosted server.", "components": ["prefect", "prefect-rolling"], "sources": ["prefect:s12416d1a0d69", "prefect-rolling:s0ef73a227667"], "status": "REASONED"}, "prefect-secrets": {"text": "Protect the auth string and credential-bearing Blocks; Prefect 3.1.8 block-document read routes accept include_secrets, and Secret.get() returns the underlying value. Restrict API and worker access; UI masking does not enforce authorization.", "components": ["prefect-blocks", "prefect-rolling"], "sources": ["prefect-blocks:s56081101d16a", "prefect-blocks:s1eae2c5f9f5e", "prefect-blocks:s46e69a2090c4", "prefect-rolling:s0ef73a227667"], "status": "REASONED"}, "prefect-port": {"text": "The self-hosted Prefect server uses port 4200.", "components": ["prefect-rolling"], "sources": ["prefect-rolling:sbe64d51110a2"], "status": "REASONED"}, "dagster-bind": {"text": "Direct dagster-webserver/dev defaults to 127.0.0.1:3000, with flag/environment overrides and free-port fallback when the port is unset or 0; explicitly bind the private origin.", "components": ["dagster"], "sources": ["dagster:sab1b42d92f27", "dagster:s6bc57f86dfe1", "dagster:s2455602a504b", "dagster:s3c7d8f17f245", "dagster:s469c458c4a71"], "status": "REASONED"}, "dagster-helm": {"text": "The chart passes -h 0.0.0.0 on the Service port, default 80, with ClusterIP Service by default.", "components": ["dagster"], "sources": ["dagster:s695dfd19dcca", "dagster:sf6f027e96a0d", "dagster:seda3a1b24425"], "status": "REASONED"}, "dagster-auth": {"text": "In dagster-webserver 1.13.24, build_middleware() returns only DagsterTracedCounterMiddleware, which counts traced calls and adds a response header, and build_graphql_middleware() returns an empty list, so neither adds authentication; this guide therefore treats the OSS webserver as having no built-in login. Protect all routes with an authenticating proxy.", "components": ["dagster"], "sources": ["dagster:sabd01546900d", "dagster:s1fb740d34a69", "dagster:s9e0d6cad1a6f"], "status": "REASONED"}, "airflow-boundary": {"text": "Airflow assumes authenticated known users and is not designed for untrusted public exposure; deployment managers must keep it private.", "components": ["airflow"], "sources": ["airflow:s1d0b86fe4334"], "status": "REASONED"}, "airflow-two-api": {"text": "Airflow 2.11.0 uses [api] auth_backends with session default; before 2.3 the name was auth_backend and 2.2.5 defaulted to deny_all.", "components": ["airflow-two", "airflow-old"], "sources": ["airflow-two:sb97f3044e215", "airflow-old:s4e72b1f51038"], "status": "REASONED"}, "airflow-simple": {"text": "Airflow 3 defaults to development-only Simple Auth Manager, with configured users/roles and generated passwords printed to logs unless supplied.", "components": ["airflow"], "sources": ["airflow:sad02a49561a0", "airflow:s3be3e4616150"], "status": "REASONED"}, "airflow-fab": {"text": "For production install FAB and select FabAuthManager through [core] auth_manager; verify the effective manager and use an identity backend with MFA.", "components": ["airflow", "fab-rolling"], "sources": ["airflow:s3be3e4616150", "fab-rolling:s989f09d05161"], "status": "REASONED"}, "airflow-api": {"text": "Airflow 3 public API uses JWT independently of [fab] auth_backends, which selects FAB API backends rather than the auth manager.", "components": ["airflow", "fab-rolling"], "sources": ["airflow:s3be3e4616150", "fab-rolling:s989f09d05161", "airflow:s963b49f58a13"], "status": "REASONED"}, "airflow-all-admins": {"text": "Keep simple_auth_manager_all_admins unset or False; enabling it disables login and grants every visitor admin.", "components": ["airflow"], "sources": ["airflow:sad02a49561a0", "airflow:s78c661193a1a"], "status": "REASONED"}, "fab-public-role": {"text": "Leave [fab] auth_role_public and legacy AUTH_ROLE_PUBLIC unset; a configured public role can make FAB treat an anonymous visitor as logged in and supply that role's permissions.", "components": ["fab-source"], "sources": ["fab-source:s3c94f0f7176a", "fab-source:s5552a28ab9d8"], "status": "REASONED"}, "airflow-compose-account": {"text": "Development Compose selects FAB but defaults to airflow/airflow; set credentials before account creation and update/delete an existing account separately.", "components": ["airflow-rolling", "fab-rolling", "airflow"], "sources": ["airflow-rolling:s39bfd91c507b", "fab-rolling:s989f09d05161", "airflow:s705f1eb5de00"], "status": "REASONED"}, "airflow-jwt": {"text": "Replace Compose fallback AIRFLOW__API_AUTH__JWT_SECRET=airflow_jwt_secret and share the strong value with all signers/validators; the public fallback permits token forgery.", "components": ["airflow-rolling", "airflow"], "sources": ["airflow-rolling:s39bfd91c507b", "airflow:s705f1eb5de00"], "status": "REASONED"}, "airflow-secret-key": {"text": "Provision the independent secret_key too: [webserver] on 2.11.0 and [api] on 3.3.2.", "components": ["airflow-two", "airflow-rolling", "airflow"], "sources": ["airflow-rolling:s2d1891641c91", "airflow-two:sabb71378ad74", "airflow:sdfcfd543928c"], "status": "REASONED"}, "airflow-config": {"text": "Keep configuration exposure off with WEBSERVER__EXPOSE_CONFIG on 2.11.0 or API__EXPOSE_CONFIG on 3.3.2.", "components": ["airflow-two", "airflow-rolling", "airflow"], "sources": ["airflow-rolling:s2d1891641c91", "airflow-two:s6813801b7880", "airflow:s4ed64009ed8f"], "status": "REASONED"}, "airflow-fernet": {"text": "Protect Connections, Variables, database and Fernet keys; an empty [core] FERNET_KEY selects a no-encryption implementation for new values, while configured comma-separated keys use MultiFernet. Preserve keys needed to decrypt existing values; authorized workloads still use secrets.", "components": ["airflow-crypto", "airflow-rolling"], "sources": ["airflow-crypto:sdeaf2f54c054", "airflow-crypto:s70ea7631e820", "airflow-rolling:s2d1891641c91"], "status": "REASONED"}, "airflow-port": {"text": "Compose publishes 8080 on all interfaces; use 127.0.0.1:8080:8080 behind the fronting layer.", "components": ["airflow-rolling"], "sources": ["airflow-rolling:s39bfd91c507b"], "status": "REASONED"}, "temporal-authorizer": {"text": "Empty authorizer selects noopAuthorizer allowing every API request, including administration; configure default authorization with trusted JWT keys and audience.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:s06d4f0dbd0c8"], "status": "REASONED"}, "temporal-claims": {"text": "Empty claimMapper selects a no-op granting system-admin claims; configure both Authorizer and ClaimMapper, with frontend TLS.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:s06d4f0dbd0c8"], "status": "REASONED"}, "temporal-health": {"text": "The built-in default authorizer permits health checks without claims; health is not an authorization discriminator.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:s06d4f0dbd0c8"], "status": "REASONED"}, "temporal-ui": {"text": "UI OIDC auth.enabled is a sibling of providers; TEMPORAL_AUTH_ENABLED plus provider settings gates the UI only, with MFA at the IdP.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:sa9d5614385ca"], "status": "REASONED"}, "temporal-tls": {"text": "Internode and frontend mTLS are separate from UI login and API authorization.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:s06d4f0dbd0c8"], "status": "REASONED"}, "temporal-payloads": {"text": "Keep plaintext credentials out of persisted inputs/results/history; the Temporal Go SDK v1.28.0 codec HTTP handler accepts encode/decode POSTs without an authentication check, so authenticate and restrict a deployed Codec Server separately.", "components": ["temporal-rolling", "temporal-codec"], "sources": ["temporal-rolling:s06d4f0dbd0c8", "temporal-codec:s24435f531da0"], "status": "REASONED"}, "temporal-ports": {"text": "Frontend gRPC uses 7233 and the Web UI 8233 in the cited CLI server reference.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:s12f2c64ad396"], "status": "REASONED"}, "flower-bind": {"text": "Flower defaults to wildcard address and 5555 unless Unix socket, FLOWER_ variables or implicit working-directory flowerconfig.py override; bind loopback explicitly.", "components": ["flower", "tornado"], "sources": ["flower:s25760fae7152", "flower:s1f72a8415337", "flower:s4dd518442b7f", "flower:s4e49ebc71dc1", "tornado:sc503b4425831"], "status": "REASONED"}, "flower-api": {"text": "Authentication defaults off; unauthenticated HTTP API is disabled unless FLOWER_UNAUTHENTICATED_API=true, which must not be mistaken for dashboard protection.", "components": ["flower-rolling"], "sources": ["flower-rolling:sb1ba69f79c9b"], "status": "REASONED"}, "flower-basic": {"text": "Basic auth uses a comma-separated credential list; supply it through FLOWER_BASIC_AUTH or protected config rather than argv and retain private TLS fronting.", "components": ["flower-rolling"], "sources": ["flower-rolling:sb1ba69f79c9b"], "status": "REASONED"}, "flower-oauth": {"text": "OAuth needs handler, client key/secret, redirect and allowed-email regex; protect the secret outside argv and prefer an MFA-enforcing provider.", "components": ["flower-rolling"], "sources": ["flower-rolling:sb1ba69f79c9b"], "status": "REASONED"}, "flower-socket": {"text": "Flower passes fixed mode 0777; Tornado removes/recreates filesystem sockets on startup, so chmod does not persist. Abstract sockets have no filesystem permission boundary.", "components": ["flower", "tornado", "linux"], "sources": ["flower:s0fe6c77e9c9d", "tornado:s737c51cccd00", "linux:s8ffd93b0efa4"], "status": "REASONED"}, "flower-directory": {"text": "Restrict socket directory search with Flower ownership, proxy-only group, mode 0750 and no outsider ACL; directory access is essential because socket mode permits writes.", "components": ["linux"], "sources": ["linux:sfb53d2819cd0", "linux:s8ffd93b0efa4", "linux:s31f41c54750c"], "status": "REASONED"}, "flower-capabilities": {"text": "Applicable DAC capabilities bypass directory search checks; Docker defaults include CAP_DAC_OVERRIDE, while user namespaces and idmapped mounts change identity interpretation.", "components": ["linux", "docker"], "sources": ["linux:s3cf3d5be0f64", "linux:sa5ccbfea6e16", "docker:s1f268e247a1f"], "status": "REASONED"}, "flower-runtime-dir": {"text": "Reapply directory owner/group/mode on creation: /run is cleared at boot and RuntimeDirectory defaults 0755 unless RuntimeDirectoryMode overrides it.", "components": ["systemd", "fhs"], "sources": ["systemd:s48ece26c16d3", "fhs:seafd8219f367"], "status": "REASONED"}, "flower-numeric-ids": {"text": "A proxy sharing the socket directory must hold the required numeric group after namespace/mount mappings; names alone do not establish access.", "components": ["linux"], "sources": ["linux:s3cf3d5be0f64", "linux:sa5ccbfea6e16"], "status": "REASONED"}, "argo-bind": {"text": "Argo Server binds wildcard :2746 with host-dependent IPv4/IPv6 support and no bind-address flag.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s262098553d2f", "argo-four:s774215022573", "argo-three:sbddb9f07a301", "argo-four:s0f074a9ac21e"], "status": "REASONED"}, "argo-defaults": {"text": "Both tags default secure=true, auth-mode=client and hsts=true; flags and ARGO_ environment equivalents can override.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s262098553d2f", "argo-four:s774215022573"], "status": "REASONED"}, "argo-client": {"text": "Client mode uses caller Kubernetes credentials/RBAC; v4.1.4 validates via SelfSubjectReview and requires Kubernetes 1.28+.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s2ca4f14c3164", "argo-four:sb284f93cde9c", "argo-four:s0871a1dd9b13"], "status": "REASONED"}, "argo-quickstart": {"text": "Both tags' minimal/mysql/postgres quick-starts enable server plus client mode while retaining TLS; v4.1.4 telemetry does too. Anonymous callers inherit server permissions.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:sfbb904619bfa", "argo-four:sa7b9db8384be", "argo-four:s79648b46d3f5", "argo-three:s862033e52c9b", "argo-three:s73e9beeef775", "argo-three:s476ff15db454", "argo-three:s262098553d2f", "argo-three:s49cdaf0691c0", "argo-three:s2ca4f14c3164", "argo-four:s6c08a5f34f2c", "argo-four:sa0bf6187d1db", "argo-four:s31f4a149576b", "argo-four:s774215022573", "argo-four:s4613c15c72af", "argo-four:sb284f93cde9c"], "status": "REASONED"}, "argo-quickstart-rbac": {"text": "Quick-start binds cluster-wide workflow/template and Secret get/create permissions, plus cluster-workflow-template rights; it is demo-only.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s0ac72aa1a86c", "argo-four:sd2706d04971d", "argo-three:s1689f25010be", "argo-four:sd920da4ad4b7", "argo-three:sa4b2fa875915", "argo-three:s48db5ba07d23", "argo-four:s3be9b03e29be", "argo-four:sc263bd937cf0"], "status": "REASONED"}, "argo-installs": {"text": "Traced install inputs keep client auth/TLS with cluster-wide server RBAC; namespace-install adds --namespaced and namespace RBAC. Generated install YAML was absent.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:sf0c3a52d3302", "argo-four:s144b824252c1", "argo-three:sfdcdf614ee37", "argo-four:s51645bfbc47c", "argo-three:sc7ca3a37b16c", "argo-four:s5188f6f2efbb", "argo-three:s262098553d2f", "argo-three:s0ac72aa1a86c", "argo-three:sa4b2fa875915", "argo-three:se3ffdf2973a6", "argo-three:sb901e7f87b54", "argo-four:s774215022573", "argo-four:sd2706d04971d", "argo-four:s3be9b03e29be", "argo-four:s1b134f96428f", "argo-four:s2418ee1aa49d"], "status": "REASONED"}, "argo-service": {"text": "Base Service omits type, defaulting to ClusterIP, and maps 2746 to 2746; this does not authenticate reachable callers.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s01f102470d97", "argo-four:s639e9fe0024e"], "status": "REASONED"}, "argo-fallback": {"text": "Remove server auth mode: adding client/SSO does not prevent anonymous fallback to server credentials; local mode uses the server kubeconfig.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s49cdaf0691c0", "argo-four:s4613c15c72af", "argo-three:s2ca4f14c3164", "argo-four:sb284f93cde9c", "argo-four:s0871a1dd9b13", "argo-three:s49a8855b6da2"], "status": "REASONED"}, "argo-sso": {"text": "Enable sso.rbac.enabled and map groups to narrow ServiceAccounts through rbac-rule/precedence; without SSO RBAC, authenticated users share server permissions.", "components": ["argo-docs", "argo-three", "argo-four"], "sources": ["argo-docs:s9898d4653a47", "argo-three:s2ca4f14c3164", "argo-four:sb284f93cde9c"], "status": "REASONED"}, "argo-secrets": {"text": "Store SSO OAuth credentials in referenced Kubernetes Secrets and supply API tokens through protected input, not argv.", "components": ["argo-docs"], "sources": ["argo-docs:s9898d4653a47", "argo-docs:s96145eacf345"], "status": "REASONED"}, "argo-tls": {"text": "Keep TLS enabled with a trusted certificate Secret; no name generates a self-signed certificate, failed named-Secret loading fails startup, secure=false is plaintext and defeats HSTS.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:sdb4a0c18b7c6", "argo-four:s0b16f1ac50df", "argo-three:s36759aec55fa", "argo-four:sa120f74372eb"], "status": "REASONED"}, "argo-metrics": {"text": "/metrics shares the server listener and auth gate unless ARGO_SERVER_METRICS_AUTH=false; server mode also admits anonymous metrics.", "components": ["argo-three", "argo-four"], "sources": ["argo-three:s36759aec55fa", "argo-four:sa120f74372eb", "argo-three:s49cdaf0691c0", "argo-three:s2ca4f14c3164", "argo-four:s4613c15c72af", "argo-four:sb284f93cde9c", "argo-three:s64e74b0dbe95", "argo-four:se758d83aaf1f"], "status": "REASONED"}, "argo-network": {"text": "Keep 2746 private behind authenticated HTTPS; restrict direct Pod/Service access and scope server, SSO and execution-account permissions. Workflow submission permits arbitrary containers unless constrained.", "components": ["argo-docs"], "sources": ["argo-docs:s6c8e78288632"], "status": "REASONED"}, "verify-inventory": {"text": "ss inventories only the current namespace, not publication, routing or authentication; inspect publications and external reachability separately.", "components": ["flower", "argo-three", "iproute2", "network-namespaces", "prefect-rolling"], "sources": ["prefect-rolling:sbe64d51110a2", "flower:s25760fae7152", "argo-three:sbddb9f07a301", "iproute2:s6cc7f285a0c7", "network-namespaces:s427c923e346f", "iproute2:s9fa9a260f1de"], "status": "REASONED", "verify": [1]}, "verify-prefect": {"text": "Anonymous POST /api/flows/filter returning a JSON list is exposed; fixed is 401 with an authorized list on the same origin. Health/ready GET exemptions are version-dependent.", "components": ["prefect", "prefect-source", "prefect-rolling"], "sources": ["prefect:s12416d1a0d69", "prefect-source:sa4574c7ed80f", "prefect-rolling:s0ef73a227667"], "status": "REASONED", "verify": [2]}, "verify-dagster": {"text": "Dagster 1.13.24 routes /graphql over HTTP GET and POST and over WebSocket, and its build_middleware() adds no authentication. This guide reads a RepositoryConnection from /graphql as a read, including empty nodes; GraphQL/transport errors are inconclusive. Test proxy authentication and origin isolation separately.", "components": ["dagster", "dagster-rolling"], "sources": ["dagster:sf751886ecbdd", "dagster:s1fb740d34a69", "dagster:s9e0d6cad1a6f", "dagster-rolling:sb623df76a5b1"], "status": "REASONED", "verify": [3]}, "verify-airflow": {"text": "FAB 3.9.0 returning 201 with access_token for airflow/airflow is exposed; pair a valid account and anonymous/authenticated API reads at proxy and origin. Airflow 2 uses its own API/auth.", "components": ["airflow-two", "airflow-rolling", "fab-rolling"], "sources": ["fab-rolling:s989f09d05161", "airflow-rolling:s39bfd91c507b", "airflow-two:sb97f3044e215"], "status": "REASONED", "verify": [4]}, "verify-temporal": {"text": "Credential-free workflow listing on 7233 is exposed; require rejection and a matched authorized call, not health, TLS errors or missing namespaces as proof of auth.", "components": ["temporal-rolling"], "sources": ["temporal-rolling:s06d4f0dbd0c8", "temporal-rolling:s12f2c64ad396"], "status": "REASONED", "verify": [5]}, "verify-flower": {"text": "Anonymous UI/API should yield Basic 401 or OAuth login redirect, with a valid-session control; API-disabled is not proof the dashboard is protected.", "components": ["flower-rolling"], "sources": ["flower-rolling:sb1ba69f79c9b"], "status": "REASONED", "verify": [6]}, "verify-socket": {"text": "Directory/socket permissions, outsider denial, reboot persistence and container numeric IDs remain reasoned; no socket or Flower process ran.", "components": ["flower", "linux"], "sources": ["flower:s0fe6c77e9c9d", "linux:s31f41c54750c", "linux:s08902025e5f9", "linux:sa0d3793fabb7"], "status": "REASONED", "verify": [7]}, "flower-stat-run": {"text": "Tmpfs directory-only stat distinguished 0755 and 0750; the 0750 directory initially had only base ACL entries.", "components": ["linux"], "sources": ["linux:sa0d3793fabb7", "linux:s08902025e5f9"], "status": "DEMONSTRATED", "evidence": "`stat -c '%a %U:%G %u:%g %n'` printed `755` for a directory created with mode `0755` and `750` for one created with `0750`; `getfacl -p` on the `0750` directory listed only `user::rwx`, `group::r-x` and `other::---`."}, "flower-acl-run": {"text": "A named nobody search ACL remained invisible in stat mode 750; ls and getfacl exposed the entry.", "components": ["linux"], "sources": ["linux:s31f41c54750c", "linux:s08902025e5f9"], "status": "DEMONSTRATED", "evidence": "After `setfacl -m u:nobody:--x` on the `0750` directory, `stat` still printed `750`. Only the trailing `+` in `ls -ld` and getfacl's `user:nobody:--x` line showed the named entry."}, "flower-mask-run": {"text": "Changing the ACL mask hid named search permission; chmod 0750 restored its reported effectiveness. Access as nobody was not tested.", "components": ["linux"], "sources": ["linux:s31f41c54750c", "linux:s08902025e5f9"], "status": "DEMONSTRATED", "evidence": "After `setfacl -m m::r--`, getfacl printed `user:nobody:--x` with `#effective:---`. While masked, `stat` printed `740`."}, "verify-argo": {"text": "Anonymous workflow JSON is exposed when server RBAC permits; missing tokens should yield 401 without server mode. A 403 alone is inconclusive; pair authorized 200 and inspect auth modes.", "components": ["argo-three", "argo-four", "argo-docs"], "sources": ["argo-three:s49cdaf0691c0", "argo-four:s4613c15c72af", "argo-docs:s96145eacf345", "argo-three:s2ca4f14c3164", "argo-four:sb284f93cde9c"], "status": "REASONED", "verify": [8]}, "verify-origin": {"text": "Any HTTP response from the origin at an untrusted vantage proves publication; use the same responding allowed origin as control and treat connection errors/timeouts as inconclusive.", "components": ["airflow", "argo-docs"], "sources": ["airflow:s1d0b86fe4334", "argo-docs:s6c8e78288632"], "status": "REASONED", "verify": [9]} } } ```` ====================================================================== ==> admin-uis.md ====================================================================== # Admin panels: phpMyAdmin, pgAdmin, mongo-express, Grafana, Prometheus phpMyAdmin, pgAdmin, mongo-express, Adminer, Grafana, Prometheus and RedisInsight are browser tools for managing databases or watching system metrics. This guide helps your AI assistant keep them secure, including no public exposure, access only through private paths or a login proxy with TLS and MFA, and default credentials replaced. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/admin-uis.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against phpMyAdmin (no recorded version), Grafana v13.2.2, Prometheus v3.14.0, RedisInsight 3.8.0, Apache htpasswd 2.4, Apache httpd htpasswd source 2.4.69, and Moby port mapping source v28.3.3; 2 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 20 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Database and monitoring panels are the most-scanned targets on the internet, and several ship with known default credentials. One rule dominates everything tool-specific below: **an admin panel is never reachable from the public internet.** Bind it to loopback and reach it through SSH port forwarding, a VPN or tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)), or Cloudflare Access ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)); anything public sits behind a TLS proxy with its own authentication ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)) plus MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). ## mongo-express The web login is off by default on recent releases, and the enabling variable has changed across mongo-express versions: current releases read `ME_CONFIG_BASICAUTH_ENABLED` (the README marks the older `ME_CONFIG_BASICAUTH` deprecated but still honors it as a fallback), while some 1.0.x builds instead key the login off a non-empty `ME_CONFIG_BASICAUTH_USERNAME`. Rather than track the exact version boundary, set both flags, set your own non-empty credentials, and VERIFY that an unauthenticated request is rejected, because mongo-express can otherwise fall back to the widely-scanned `admin`:`pass`. Keep it private: ``` ME_CONFIG_BASICAUTH=true # older, now-deprecated enable flag, still honored as a fallback ME_CONFIG_BASICAUTH_ENABLED=true # the current enable flag; set both, then verify the login actually appears ME_CONFIG_BASICAUTH_USERNAME= # otherwise defaults to admin ME_CONFIG_BASICAUTH_PASSWORD= # otherwise defaults to pass ``` These control only the web login; MongoDB credentials go in `ME_CONFIG_MONGODB_URL` ([mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md) hardens the database itself). ## Grafana - First sign-in uses `admin`/`admin` and prompts for a new password; set a strong one immediately and create individual accounts for everyone else. - Disable anonymous access if it was enabled, and prefer SSO with MFA enforced at the identity provider. - Native HTTPS in `grafana.ini`: ```ini [server] protocol = https # bind loopback (Grafana's default http_addr is empty as of v13.2.2, which means all interfaces); reach it via a tunnel or proxy http_addr = 127.0.0.1 cert_file = /etc/grafana/grafana.crt cert_key = /etc/grafana/grafana.key ``` ## Prometheus No authentication at all by default, and it listens on `0.0.0.0:9090` by default (as of v3.14.0). Give it a web configuration file and bind it to loopback, starting with `--web.listen-address=127.0.0.1:9090 --web.config.file=web.yml`: ```yaml basic_auth_users: admin: $2b$12$REPLACE_WITH_BCRYPT_HASH # htpasswd -nB -C 12 admin, hash part (Apache httpd 2.4.69 bare -B defaults to bcrypt cost 5; by this guide's comparison, below OWASP's minimum 10 for legacy bcrypt) ``` The same file carries TLS (`tls_server_config` with `cert_file` and `key_file`; see the Prometheus TLS guide below). Validate with `promtool check web-config web.yml`. Exporters and Alertmanager need the same treatment; see [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md). ## phpMyAdmin and pgAdmin Neither belongs on a public vhost. Serve them only behind the proxy-level TLS and authentication of your web server guide, restrict by source IP where the proxy supports it, and keep them updated; both are perennial exploit targets. pgAdmin in server mode has its own login; treat its accounts per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). ## Adminer Adminer is database management in a single PHP file, and a filename like `adminer.php` or `adminer-.php` is not an access control; renaming it or hiding the directory does not satisfy the never-public rule, since scanners enumerate those paths. It has no built-in account store of its own: by default the login form takes the database server's own address, username, and password, so an internet-reachable Adminer is a public interface for logging in to your database from the Adminer host. Because Adminer connects to the database server-side, that exposes the database to internet login attempts even when the database itself is not reachable from the internet, and older versions carry their own exploit history. The rule at the top of this guide applies without exception: keep it off a public vhost, reach it privately or behind the proxy's TLS, authentication, and MFA, restrict by source IP where you can, keep it updated, and delete the file (and any other copy) from the docroot when you are not using it. ## RedisInsight and similar tools As this guide's advice, keep them on loopback or a private network and reach them through the tunnels above. When in doubt, apply the generic pattern: loopback bind, TLS proxy, proxy or SSO authentication, MFA. RedisInsight 3.8.0's default server configuration uses `RI_APP_HOST` with a fallback of `0.0.0.0` and `RI_APP_PORT` with a fallback of `5540`; its default `excludeAuthRoutes` is an empty list. The fetched `SingleUserAuthMiddleware.use` creates a default session if absent, assigns default session metadata and calls `next()`; `WindowAuthMiddleware.use` checks a window ID through `WindowAuthService` and throws on rejection. These fetched files do not establish the complete inbound authentication policy or which routes use these middleware; they do not establish either the absence of authentication or login and dashboard pages both returning 200. RedisInsight is therefore omitted from the Verify list asserting those two 200 responses. ## Verify REASONED: the panel sources below support the host-side listener inventory in this paragraph, and Docker's pinned documentation supports explicit publish addresses and Linux firewall rules for publication. Docker documents IPv6-to-IPv4 mapping when no host IP is given, the bridge is IPv4-only and `--userland-proxy=true` (the default). The fetched Moby port-mapping source starts the userland proxy conditionally and listens on bound TCP sockets even without a proxy; these fetched sources do not establish NAT publication without a host listener. As this guide's assessment, a socket listing alone does not prove reachability. This host-side inventory is prose rather than part of the block below, because it runs on the panel's host and the block runs from outside your network. On the panel's host, run `ss -tlnp` and read every panel listener: each should be loopback (127.0.0.1) or a deliberately chosen private address. Also inspect the actual published addresses and ports (`docker ps` and the port mappings), and from outside your network probe any public backend mapping too: a protected frontend does not prove a separately published backend port is closed. No panel deployment or external test network is available here, so no listing is recorded. REASONED: following block; panel sources below support the anonymous front-page/data-route checks and the authenticated data-route positive control, with the listener inventory and published-backend inspection in the section's first paragraph; that inspection follows the pinned Docker sources and this guide's assessment that a protected frontend or absent socket does not prove backend isolation. No panel deployment or external test network is available here; no live run is recorded. The comments give expected denial, exposure and inconclusive outcomes. The two `curl` checks take one panel's host name and protected path on a guarded `set --` line and refuse example.com, example.net, example.org and their subdomains. The block records each outcome and exits once: 2 if its guard refuses, 3 if a request fails or the protected route answers 404 or a server error, and otherwise 0. A 401 or 403 is the pass; any other answer is printed for you to judge, because only its body or Location shows a dashboard or a login, so the block reports no finding itself and its last line says so. Run the `ss` listener inventory and the published address and port inspection (`docker ps` and the port mappings) on the panel's host. Run the two `curl` checks, their authenticated positive control and any probe of a published backend port from a second network against each panel's real hostname; a 200 that renders a dashboard, or any content past the login, without credentials is a finding, while a proxy, DNS, or TLS error is inconclusive, not a pass. The block's earlier form, which lowercased the host with `tr` before refusing reserved names, was checked on 2026-10-31 with bash -n and with curl stubs and no network, covering each status, a failed or inconclusive first request followed by a passing second one, and an inherited `readonly`, `declare -i` or `set -e`. Those stub runs covered the refusals of a missing marker, too few or too many values, a host name that is empty or a placeholder or has a scheme, port, trailing dot, `@`, `?`, `%`, `#` or space, an example.com, example.net or example.org host including `App.Example.COM`, and a path that is empty or a placeholder or has a leading slash or space, each with no curl call. ShellCheck 0.11.0 passed on that earlier form in the repository's gate suite, and no run of a form before it is recorded. The current form, which matches reserved names in any letter case through bash's `nocasematch` option instead of `tr`, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by stub runs on 2026-10-31 with curl stubs and no network. Those runs refused example.com, example.net, example.org and their subdomains in lower, upper and mixed case, including `App.Example.COM`, each with no curl call, also with `tr` absent from PATH and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`, and did not refuse a host name outside those domains. Under dash, which lacks bash's `shopt`, stub runs of the current form refused reserved and other host names alike with status 2 and its message that the block needs bash, with no curl call. ```bash # From OUTSIDE your network, with client proxies disabled so a proxy cannot answer for the panel. Read the # BODY, not just the status: a login page and a dashboard can both be 200. Run the block once per panel. ( # a subshell, so your own script arguments are untouched and one exit status covers the whole block set +e # errexit off, so a request that fails is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PANEL_HOST' 'REPLACE_WITH_PROTECTED_PATH' # the panel's public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot), then a route you know returns real data when authorized, without its leading slash: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|/*|*[[:space:][:cntrl:]]*) echo "substitute a protected path, without its leading slash, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 3 (inconclusive) outranks 0; the block classifies no finding (see its last line) set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -i -w '\n%{http_code}' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then the response with the HTTP code on its last line set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$5" # PASS: a denial from whatever protects this panel - the panel's own 401/403, the reverse proxy's, or a # Cloudflare Access challenge - or a redirect into that layer's login (which may be an external identity # provider). FINDING: a 200 that renders the dashboard or app content. An arbitrary redirect is inconclusive. case "$4:$6" in 0:401|0:403) ;; 0:*) echo "unclassified: HTTP $6 from the front page is not a 401 or 403 denial, so read it against the comment above" ;; *) echo "curl exit $4: the front-page request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac # SPA panels (Grafana, a phpMyAdmin/Adminer login shell) return 200 for BOTH the login page and # the dashboard, so also request a route you know returns real data when authorized, and require it be denied # without credentials: set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -i -w '\n%{http_code}' "https://$1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then the response with the HTTP code on its last line set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$5" # Confirm that route returns data WITH valid auth first (a positive control); a 404 or a server error is # inconclusive, not a pass, and the guard above refuses an unsubstituted placeholder. case "$4:$6" in 0:401|0:403) ;; 0:404|0:5??) echo "HTTP $6 from the protected route is a 404 or a server error: inconclusive, not a pass"; set -- "$1" "$2" 3 ;; 0:*) echo "unclassified: HTTP $6 from the protected route is not a 401 or 403 denial, so read it against the comments above" ;; *) echo "curl exit $4: the protected-route request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac echo "block exit $3; a front-page answer other than 401 or 403, and a protected-route answer other than 401, 403, 404 or a server error, are not classified, so judge them yourself: a 200 that renders the dashboard or app content is a finding, and a redirect passes only into your login layer" exit "$3" ) ``` ## Sources (checked September 2026) - mongo-express README (defaults and variables): https://github.com/mongo-express/mongo-express - Grafana configuration and HTTPS: https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/ and https://grafana.com/docs/grafana/latest/setup-grafana/set-up-https/ - Prometheus basic auth and TLS guides: https://prometheus.io/docs/guides/basic-auth/ and https://prometheus.io/docs/guides/tls-encryption/ - phpMyAdmin documentation: https://www.phpmyadmin.net/docs/ and pgAdmin documentation: https://www.pgadmin.org/docs/ - Adminer, database management in a single PHP file (login uses the database server's own credentials): https://www.adminer.org/ - Prometheus `--web.listen-address` default `0.0.0.0:9090` (pinned tag v3.14.0): https://github.com/prometheus/prometheus/blob/v3.14.0/cmd/prometheus/main.go#L424-L425 - Grafana default `http_addr` is empty (pinned tag v13.2.2): https://github.com/grafana/grafana/blob/v13.2.2/conf/defaults.ini#L49-L50 - Grafana joins `http_addr` with the port into the server address (pinned tag v13.2.2): https://github.com/grafana/grafana/blob/v13.2.2/pkg/api/http_server.go#L467-L469 - Grafana listens on that address with `net.Listen("tcp", ...)` (pinned tag v13.2.2): https://github.com/grafana/grafana/blob/v13.2.2/pkg/api/http_server.go#L568 - Go `net.Listen` with an empty host listens on all available unicast and anycast addresses of the local system: https://pkg.go.dev/net#Listen - Apache 2.4 `htpasswd` bcrypt default cost and `-C` override (checked October 2026): https://httpd.apache.org/docs/2.4/programs/htpasswd.html - Apache httpd 2.4.69 htpasswd bcrypt default cost (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45 - Apache httpd 2.4.69 htpasswd default cost applied (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L292-L295 - Apache httpd 2.4.69 htpasswd `-C` cost option (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L383 - Apache httpd 2.4.69 htpasswd usage (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L129 - OWASP Password Storage Cheat Sheet at 72865b1e3b5754246a101778dc00b37f9c233373, legacy bcrypt work factor (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13 - Apache httpd 2.4.69 htpasswd zero-initialized cost (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L297 - RedisInsight 3.8.0 default listen host and port (checked October 2026): https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/config/default.ts#L99-L100 - RedisInsight 3.8.0 default authentication-route exclusions (checked October 2026): https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/config/default.ts#L133 - RedisInsight 3.8.0 default-session middleware (checked October 2026): https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/src/common/middlewares/single-user-auth.middleware.ts#L20-L49 - RedisInsight 3.8.0 window-authentication middleware (checked October 2026): https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/src/modules/auth/window-auth/middleware/window.auth.middleware.ts#L14-L35 - Docker explicit publish addresses, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L33-L38 - Docker conditional IPv6-to-IPv4 userland-proxy mapping, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L60-L62 - Docker Linux firewall rules for publication, at 4e9a5751518ed8223a8dcde53693badddd72604f (checked October 2026): https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L11-L15 - Moby port mapping at v28.3.3, conditional proxy startup and TCP listening without a proxy (checked October 2026): https://raw.githubusercontent.com/moby/moby/v28.3.3/libnetwork/drivers/bridge/port_mapping_linux.go ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - phpmyadmin: phpMyAdmin (unknown) - grafana-pin: Grafana (v13.2.2) - prometheus-pin: Prometheus (v3.14.0) - pgadmin: pgAdmin (unknown) - mongo: mongo-express (unknown) - grafana: Grafana docs (unknown) - prometheus: Prometheus docs (unknown) - adminer: Adminer (unknown) - redisinsight: RedisInsight (3.8.0) - go: Go net (unknown) - htpasswd: Apache htpasswd (2.4) - httpd-source: Apache httpd htpasswd source (2.4.69) - engine-pinned: Docker Engine networking documentation (4e9a5751518ed8223a8dcde53693badddd72604f) - moby-pinned: Moby port mapping source (v28.3.3) - owasp-source: OWASP Password Storage Cheat Sheet (72865b1e3b5754246a101778dc00b37f9c233373) Claims (full source mapping in the JSON block below): - private (REASONED; grafana, prometheus, phpmyadmin, pgadmin, adminer, redisinsight): Keep panels private; public front doors need proxy TLS, authentication and MFA. RedisInsight 3.8.0 config defaults to 0.0.0.0:5540 via RI_APP_HOST/RI_APP_PORT, with empty excludeAuthRoutes; fetched session/window middleware does not establish complete inbound authentication or login/dashboard 200 responses. Keeping RedisInsight and similar tools on loopback or a private network is this guide's advice. - mongo-enable (REASONED; mongo): Recent login defaults off; current ENABLED and deprecated BASICAUTH fallback differ from some 1.0.x username-triggered builds. Set both flags and verify. - mongo-credentials (REASONED; mongo): Set nonempty BASICAUTH_USERNAME/PASSWORD instead of admin/pass defaults. - mongo-database (REASONED; mongo): Web Basic auth is separate from MongoDB credentials in ME_CONFIG_MONGODB_URL. - grafana-admin (REASONED; grafana): First login uses admin/admin and prompts for replacement; use strong credentials and individual accounts. - grafana-anonymous (REASONED; grafana): Disable Grafana anonymous access if enabled. - grafana-mfa (REASONED; grafana): Prefer SSO with MFA at the identity provider. - grafana-bind (REASONED; grafana-pin, go): Empty http_addr joins the port and binds all interfaces; set 127.0.0.1. - grafana-tls (REASONED; grafana): Native HTTPS uses server protocol=https, cert_file and cert_key. - prometheus-auth (REASONED; prometheus): Authentication defaults off; web.config.file supplies basic_auth_users with bcrypt hashes. - prometheus-bcrypt (REASONED; prometheus, htpasswd, httpd-source, owasp-source): Use htpasswd -nB -C 12; Apache httpd 2.4.69 bare -B defaults to bcrypt cost 5, by this guide's comparison below OWASP's minimum work factor of 10 for legacy bcrypt. - prometheus-bind (REASONED; prometheus-pin): Default 0.0.0.0:9090; set --web.listen-address=127.0.0.1:9090. - prometheus-tls (REASONED; prometheus): web.yml tls_server_config cert_file/key_file enable TLS; validate with promtool check web-config. - database-panels (REASONED; phpmyadmin, pgadmin): Keep phpMyAdmin/pgAdmin behind proxy TLS/auth, restrict source IPs where supported and keep updated. - pgadmin-login (REASONED; pgadmin): pgAdmin server mode has its own accounts/login. - adminer-login (REASONED; adminer): Adminer uses database credentials, not a separate account store; server-side connections expose even private databases to login attempts. - adminer-file (REASONED; adminer): Renaming Adminer is no access control; require private access or proxy TLS/auth/MFA, updates and removal of unused copies. - verify-listeners (REASONED; grafana-pin, prometheus-pin, engine-pinned, moby-pinned): Inventory sockets and Docker publications; as this guide's assessment, a protected frontend or absent socket does not prove backend isolation or reachability. Docker documents explicit publish addresses and IPv6-to-IPv4 proxying when no host IP is given, the bridge is IPv4-only and --userland-proxy=true (default). Fetched sources do not establish NAT publication without a host listener; the fetched Moby TCP path listens even without a proxy. - verify-login (REASONED; mongo, grafana, prometheus, phpmyadmin, pgadmin, adminer): Anonymous dashboard data is a finding; require denial or verified login challenge. Arbitrary redirects and DNS/TLS/proxy errors are inconclusive. - verify-data (REASONED; mongo, grafana, prometheus, phpmyadmin, pgadmin, adminer): A 200 login shell proves nothing; require a known data route to succeed authorized and deny anonymous access; 404/server errors are inconclusive. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "4120beeb633c0d6f7be2a9c086a2d20658467493411902d46953895ccd1576af", "components": { "phpmyadmin": { "name": "phpMyAdmin", "basis": "unknown", "sources": { "s5f6c8965c539": "https://www.phpmyadmin.net/docs/" } }, "grafana-pin": { "name": "Grafana", "basis": "v13.2.2", "sources": { "s4c6ff3174e7e": "https://github.com/grafana/grafana/blob/v13.2.2/conf/defaults.ini#L49-L50", "sb00e234d0ddd": "https://github.com/grafana/grafana/blob/v13.2.2/pkg/api/http_server.go#L467-L469", "sf14b29976adb": "https://github.com/grafana/grafana/blob/v13.2.2/pkg/api/http_server.go#L568" } }, "prometheus-pin": { "name": "Prometheus", "basis": "v3.14.0", "sources": { "s6d5f308b02a2": "https://github.com/prometheus/prometheus/blob/v3.14.0/cmd/prometheus/main.go#L424-L425" } }, "pgadmin": { "name": "pgAdmin", "basis": "unknown", "sources": { "s2f7e33714435": "https://www.pgadmin.org/docs/" } }, "mongo": { "name": "mongo-express", "basis": "unknown", "sources": { "s69ec4eee2c85": "https://github.com/mongo-express/mongo-express" } }, "grafana": { "name": "Grafana docs", "basis": "unknown", "sources": { "s7aa447405cc5": "https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/", "sc241e0772095": "https://grafana.com/docs/grafana/latest/setup-grafana/set-up-https/" } }, "prometheus": { "name": "Prometheus docs", "basis": "unknown", "sources": { "scce074c47e9c": "https://prometheus.io/docs/guides/basic-auth/", "sc48b506cc29a": "https://prometheus.io/docs/guides/tls-encryption/" } }, "adminer": { "name": "Adminer", "basis": "unknown", "sources": { "sb5eda9397b18": "https://www.adminer.org/" } }, "redisinsight": { "name": "RedisInsight", "basis": "3.8.0", "sources": { "s6b8fb1c9bbbc": "https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/config/default.ts#L99-L100", "s7ba57be16bb3": "https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/config/default.ts#L133", "sddf9b5ee80b9": "https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/src/common/middlewares/single-user-auth.middleware.ts#L20-L49", "s5e6e219b4b65": "https://github.com/redis/RedisInsight/blob/3.8.0/redisinsight/api/src/modules/auth/window-auth/middleware/window.auth.middleware.ts#L14-L35" } }, "go": { "name": "Go net", "basis": "unknown", "sources": { "s37204ff1b27c": "https://pkg.go.dev/net#Listen" } }, "htpasswd": { "name": "Apache htpasswd", "basis": "2.4", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "httpd-source": { "name": "Apache httpd htpasswd source", "basis": "2.4.69", "sources": { "sfed14ba2991a": "https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L297", "sf711558bdf3c": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.h#L45", "s4aa0f7f341fb": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L292-L295", "s4ec6b9b216dc": "https://github.com/apache/httpd/blob/2.4.69/support/passwd_common.c#L373-L383", "s6952d96564d9": "https://github.com/apache/httpd/blob/2.4.69/support/htpasswd.c#L100-L129" } }, "engine-pinned": { "name": "Docker Engine networking documentation", "basis": "4e9a5751518ed8223a8dcde53693badddd72604f", "sources": { "sb3dacfbaf7e1": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L33-L38", "s1c82ff316a7e": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/port-publishing.md#L60-L62", "sc244607b20e4": "https://github.com/docker/docs/blob/4e9a5751518ed8223a8dcde53693badddd72604f/content/manuals/engine/network/packet-filtering-firewalls.md#L11-L15" } }, "moby-pinned": { "name": "Moby port mapping source", "basis": "v28.3.3", "sources": { "seaf4197aafaa": "https://raw.githubusercontent.com/moby/moby/v28.3.3/libnetwork/drivers/bridge/port_mapping_linux.go" } }, "owasp-source": { "name": "OWASP Password Storage Cheat Sheet", "basis": "72865b1e3b5754246a101778dc00b37f9c233373", "sources": { "s4531e65b6502": "https://github.com/OWASP/CheatSheetSeries/blob/72865b1e3b5754246a101778dc00b37f9c233373/cheatsheets/Password_Storage_Cheat_Sheet.md#L13" } } }, "claims": { "private": {"text": "Keep panels private; public front doors need proxy TLS, authentication and MFA. RedisInsight 3.8.0 config defaults to 0.0.0.0:5540 via RI_APP_HOST/RI_APP_PORT, with empty excludeAuthRoutes; fetched session/window middleware does not establish complete inbound authentication or login/dashboard 200 responses. Keeping RedisInsight and similar tools on loopback or a private network is this guide's advice.", "components": ["grafana", "prometheus", "phpmyadmin", "pgadmin", "adminer", "redisinsight"], "sources": ["grafana:s7aa447405cc5", "prometheus:scce074c47e9c", "phpmyadmin:s5f6c8965c539", "pgadmin:s2f7e33714435", "adminer:sb5eda9397b18", "redisinsight:s6b8fb1c9bbbc", "redisinsight:s7ba57be16bb3", "redisinsight:sddf9b5ee80b9", "redisinsight:s5e6e219b4b65"], "status": "REASONED"}, "mongo-enable": {"text": "Recent login defaults off; current ENABLED and deprecated BASICAUTH fallback differ from some 1.0.x username-triggered builds. Set both flags and verify.", "components": ["mongo"], "sources": ["mongo:s69ec4eee2c85"], "status": "REASONED"}, "mongo-credentials": {"text": "Set nonempty BASICAUTH_USERNAME/PASSWORD instead of admin/pass defaults.", "components": ["mongo"], "sources": ["mongo:s69ec4eee2c85"], "status": "REASONED"}, "mongo-database": {"text": "Web Basic auth is separate from MongoDB credentials in ME_CONFIG_MONGODB_URL.", "components": ["mongo"], "sources": ["mongo:s69ec4eee2c85"], "status": "REASONED"}, "grafana-admin": {"text": "First login uses admin/admin and prompts for replacement; use strong credentials and individual accounts.", "components": ["grafana"], "sources": ["grafana:s7aa447405cc5"], "status": "REASONED"}, "grafana-anonymous": {"text": "Disable Grafana anonymous access if enabled.", "components": ["grafana"], "sources": ["grafana:s7aa447405cc5"], "status": "REASONED"}, "grafana-mfa": {"text": "Prefer SSO with MFA at the identity provider.", "components": ["grafana"], "sources": ["grafana:s7aa447405cc5"], "status": "REASONED"}, "grafana-bind": {"text": "Empty http_addr joins the port and binds all interfaces; set 127.0.0.1.", "components": ["grafana-pin", "go"], "sources": ["grafana-pin:s4c6ff3174e7e", "grafana-pin:sb00e234d0ddd", "grafana-pin:sf14b29976adb", "go:s37204ff1b27c"], "status": "REASONED"}, "grafana-tls": {"text": "Native HTTPS uses server protocol=https, cert_file and cert_key.", "components": ["grafana"], "sources": ["grafana:s7aa447405cc5", "grafana:sc241e0772095"], "status": "REASONED"}, "prometheus-auth": {"text": "Authentication defaults off; web.config.file supplies basic_auth_users with bcrypt hashes.", "components": ["prometheus"], "sources": ["prometheus:scce074c47e9c"], "status": "REASONED"}, "prometheus-bcrypt": {"text": "Use htpasswd -nB -C 12; Apache httpd 2.4.69 bare -B defaults to bcrypt cost 5, by this guide's comparison below OWASP's minimum work factor of 10 for legacy bcrypt.", "components": ["prometheus", "htpasswd", "httpd-source", "owasp-source"], "sources": ["prometheus:scce074c47e9c", "htpasswd:scde9004bb967", "httpd-source:sfed14ba2991a", "httpd-source:sf711558bdf3c", "httpd-source:s4aa0f7f341fb", "httpd-source:s4ec6b9b216dc", "httpd-source:s6952d96564d9", "owasp-source:s4531e65b6502"], "status": "REASONED"}, "prometheus-bind": {"text": "Default 0.0.0.0:9090; set --web.listen-address=127.0.0.1:9090.", "components": ["prometheus-pin"], "sources": ["prometheus-pin:s6d5f308b02a2"], "status": "REASONED"}, "prometheus-tls": {"text": "web.yml tls_server_config cert_file/key_file enable TLS; validate with promtool check web-config.", "components": ["prometheus"], "sources": ["prometheus:sc48b506cc29a"], "status": "REASONED"}, "database-panels": {"text": "Keep phpMyAdmin/pgAdmin behind proxy TLS/auth, restrict source IPs where supported and keep updated.", "components": ["phpmyadmin", "pgadmin"], "sources": ["phpmyadmin:s5f6c8965c539", "pgadmin:s2f7e33714435"], "status": "REASONED"}, "pgadmin-login": {"text": "pgAdmin server mode has its own accounts/login.", "components": ["pgadmin"], "sources": ["pgadmin:s2f7e33714435"], "status": "REASONED"}, "adminer-login": {"text": "Adminer uses database credentials, not a separate account store; server-side connections expose even private databases to login attempts.", "components": ["adminer"], "sources": ["adminer:sb5eda9397b18"], "status": "REASONED"}, "adminer-file": {"text": "Renaming Adminer is no access control; require private access or proxy TLS/auth/MFA, updates and removal of unused copies.", "components": ["adminer"], "sources": ["adminer:sb5eda9397b18"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory sockets and Docker publications; as this guide's assessment, a protected frontend or absent socket does not prove backend isolation or reachability. Docker documents explicit publish addresses and IPv6-to-IPv4 proxying when no host IP is given, the bridge is IPv4-only and --userland-proxy=true (default). Fetched sources do not establish NAT publication without a host listener; the fetched Moby TCP path listens even without a proxy.", "components": ["grafana-pin", "prometheus-pin", "engine-pinned", "moby-pinned"], "sources": ["grafana-pin:s4c6ff3174e7e", "prometheus-pin:s6d5f308b02a2", "engine-pinned:sb3dacfbaf7e1", "engine-pinned:s1c82ff316a7e", "engine-pinned:sc244607b20e4", "moby-pinned:seaf4197aafaa"], "status": "REASONED"}, "verify-login": {"text": "Anonymous dashboard data is a finding; require denial or verified login challenge. Arbitrary redirects and DNS/TLS/proxy errors are inconclusive.", "components": ["mongo", "grafana", "prometheus", "phpmyadmin", "pgadmin", "adminer"], "sources": ["mongo:s69ec4eee2c85", "grafana:s7aa447405cc5", "prometheus:scce074c47e9c", "phpmyadmin:s5f6c8965c539", "pgadmin:s2f7e33714435", "adminer:sb5eda9397b18"], "status": "REASONED", "verify": [1]}, "verify-data": {"text": "A 200 login shell proves nothing; require a known data route to succeed authorized and deny anonymous access; 404/server errors are inconclusive.", "components": ["mongo", "grafana", "prometheus", "phpmyadmin", "pgadmin", "adminer"], "sources": ["mongo:s69ec4eee2c85", "grafana:s7aa447405cc5", "prometheus:scce074c47e9c", "phpmyadmin:s5f6c8965c539", "pgadmin:s2f7e33714435", "adminer:sb5eda9397b18"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> observability-components.md ====================================================================== # Self-hosted observability components: node_exporter, Alertmanager, Pushgateway, Jaeger, and Loki node_exporter, Alertmanager, Pushgateway, Jaeger and Loki are monitoring services that usually run beside Prometheus, handling host metrics, alerts, pushed metrics, traces and logs. This guide helps your AI assistant configure each securely, including private listeners, each component's TLS and authentication options switched on, and a login proxy for browsers. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/observability-components.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against node_exporter v1.12.1, Alertmanager v0.34.1, Pushgateway v1.11.3, Jaeger v2.21.0, Loki v3.7.8, Exporter toolkit v0.17.1, OpenTelemetry configtls v1.66.0, and OpenTelemetry basicauth extension v0.160.0, plus 1 more recorded basis; 3 further sources are pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-10-20; documentation checked September 2026. The guide records 102 claims: 14 demonstrated by a check that ran and 88 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. The Prometheus server has its own section in [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md). This guide covers the pieces that usually run next to it, and each of them is an unauthenticated HTTP service by default. node_exporter hands out a map of the host and a Go profiler. Alertmanager lets anyone create silences, so a real incident stays quiet. Pushgateway lets anyone write or delete series that Prometheus then trusts. Jaeger's query port serves every trace, and in v2 an MCP server on the same port. Loki answers any client that names a tenant. None of them has a login screen or MFA. The three Prometheus-family tools can require TLS client certificates or bcrypt basic auth natively, Loki can require client certificates but has no login of its own, and Jaeger can take an OpenTelemetry authenticator, but all of these controls are off until you configure them. Keep every listener private ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md), [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md)), and front anything a person reaches with a browser per [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). Versions checked: node_exporter v1.12.1, Alertmanager v0.34.1, Pushgateway v1.11.3, Jaeger v2.21.0, and Loki v3.7.8. ## Prometheus exporters and the shared web configuration node_exporter listens on port 9100, and its `--web.listen-address` default is `:9100`. An address with no host part binds every interface. The exporter serves `/metrics` without authentication. The v1.12.1 binary imports Go's `net/http/pprof` and serves the default mux, so `/debug/pprof/` also answers. On a loopback run, both paths returned `200` with no credentials. The metrics show mounts, interfaces, kernel and hardware, and the profiler exposes the command line and heap data. The Prometheus security model says exporters "generally only talk to one configured instance with a preset set of commands/requests", so the concern is disclosure rather than control. It is still a free survey of the host for anyone who reaches the port. node_exporter, Alertmanager and Pushgateway share one fix. The exporter-toolkit web configuration file, passed with `--web.config.file`, turns on TLS and bcrypt basic auth for every HTTP path the process serves. The toolkit calls the format experimental. Keep the listener private as well, even with the file in place. ```yaml # web.yml, passed as --web.config.file=/etc/prometheus/web.yml tls_server_config: cert_file: /etc/prometheus/tls/server.crt key_file: /etc/prometheus/tls/server.key # For client certificates instead of (or as well as) passwords: # client_auth_type: RequireAndVerifyClientCert # client_ca_file: /etc/prometheus/tls/clients-ca.crt basic_auth_users: # bcrypt hash; the toolkit docs suggest: htpasswd -nBC 10 "" | tr -d ':\n' scrape: REPLACE_WITH_A_BCRYPT_HASH ``` With this file on the v1.12.1 loopback run, plain HTTP to the port got `400`. HTTPS without credentials got `401` on both `/metrics` and `/debug/pprof/`, and HTTPS with the right credentials got `200`. The toolkit says that `client_auth_type` values other than `RequireAndVerifyClientCert` are insecure. It reads the file on every request, so a changed password or certificate applies without a restart. It also advises that basic auth is "meant for simple use cases, with a few users", and points to client certificates or a reverse proxy beyond that. Give Prometheus's scrape job the matching credentials and CA certificate. Other exporters built on the exporter toolkit take the same `--web.config.file` flag, but check each exporter's own `--help`, because not every exporter uses the toolkit. ## Alertmanager Alertmanager serves its UI and API on `:9093`, all interfaces. Its HA gossip listener is on by default, at `--cluster.listen-address` default `0.0.0.0:9094`, and the README says gossip needs both TCP and UDP. The security model is direct: "Any user with access to the Alertmanager HTTP endpoint has access to its data. They can create and resolve alerts. They can create, modify and delete silences." It also warns that Alertmanager serves `/api/v2` with `Access-Control-Allow-Origin: *`, which "grants API access to any website visited by a browser capable of reaching the service". On the v0.34.1 loopback run with no web configuration, each of these succeeded without credentials: `GET /api/v2/status` returned `200`, `POST /api/v2/silences` created a silence and returned `200`, and `POST /-/reload` returned `200`. `GET /debug/pprof/` also returned `200`. The status response carried `Access-Control-Allow-Origin: *`. The management API documents `/-/reload` with no gating flag. - **HTTP:** use the same `--web.config.file` as above. With it, the loopback runs returned `401` to an unauthenticated silence creation and to an unauthenticated or wrong-password status read, and `200` to an authenticated status read. Give Prometheus's Alertmanager client the matching credentials and CA, or alerts stop arriving. Alertmanager's own docs call its TLS and basic auth experimental. - **Gossip:** a single instance does not need it, so set `--cluster.listen-address=` (empty) to stop Alertmanager listening for peers. On a loopback run with it empty, Alertmanager held no TCP or UDP socket on 9094. An HA pair needs the port reachable by its peers only. Its gossip is plaintext unless you pass `--cluster.tls-config` (experimental), a file with `tls_server_config` and `tls_client_config` sections, described in Alertmanager's HTTPS documentation. - **Behind a proxy:** the security model's API Security section, which covers every Prometheus component, suggests blocking the mutating paths at a reverse proxy to prevent CSRF, and setting CORS headers there for the read-only ones. The README warns not to load-balance Prometheus-to-Alertmanager traffic. Point Prometheus at every Alertmanager instead. ## Pushgateway Pushgateway listens on `:9091`. The security model states the exposure: "Any user with access to the Pushgateway HTTP endpoint can create, modify and delete the metrics contained within. As the Pushgateway is usually scraped with `honor_labels` enabled, this means anyone with access to the Pushgateway can create any time series in Prometheus." That reaches your alerting and dashboards. A forged series can hide an outage or trigger a false page. On the v1.11.3 loopback run, an unauthenticated `POST /metrics/job/demo` returned `200` and the series appeared on `/metrics`. An unauthenticated `DELETE` of the group returned `202`. `/debug/pprof/` is registered outside every flag check in the source, and it answered `200`. The admin API (which includes wipe) is off unless you pass `--web.enable-admin-api`, and `PUT /api/v1/admin/wipe` returned `404` by default. Lifecycle shutdown is off unless you pass `--web.enable-lifecycle`. Leave both flags off. The same `--web.config.file` protects Pushgateway. The README says the settings "affect all HTTP endpoints", including `/metrics`, the push API, the admin API and the web UI. With the file in place, an unauthenticated push returned `401` and an authenticated one returned `200`. Give pushing jobs their own credentials, and scrape with credentials too. ## Jaeger (v2) Jaeger v2 run with no `--config` uses its built-in all-in-one configuration, with in-memory storage. In that configuration, the collector receivers take their host from `${env:JAEGER_LISTEN_HOST:-localhost}`. That covers OTLP on 4317 and 4318, Jaeger gRPC and Thrift HTTP on 14250 and 14268, Jaeger Thrift over UDP on 6831 and 6832, Zipkin on 9411, remote sampling on 5778 and 5779, health on 13133, expvar on 27777, zpages on 27778, and self-metrics on 8888 (the loopback run showed each of them, UDP included, on 127.0.0.1). The docs explain that `JAEGER_LISTEN_HOST` is "useful when running Jaeger in a container and it needs to be `0.0.0.0`", and the project's v2.21.0 Dockerfile sets `ENV JAEGER_LISTEN_HOST=0.0.0.0`, so in the official image every one of these listens on every interface and a published port reaches it. The query service is the exception. The all-in-one file does not set `jaeger_query` endpoints, and the code default is `":" + port`. That makes the UI and query API `:16686` and query gRPC `:16685`, both on every interface, whether or not `JAEGER_LISTEN_HOST` is set. The documentation's own example sets them to `0.0.0.0:16686` and `0.0.0.0:16685`. The v2.21.0 all-in-one also enables MCP (`ai: mcp: {}`), which the source says serves "Jaeger telemetry MCP server at /api/ai/mcp/ on the query port". An exposed query port therefore gives an AI client the same unauthenticated trace access. On a v2.21.0 loopback run, every query surface answered without credentials: `GET /api/v3/services` returned `200`, the UI at `/` returned `200`, and an MCP `initialize` POST to `/api/ai/mcp/` returned `200`. The v1 `/api/services` returned `404`; the v2.21.0 release notes list "remove v1 http endpoints the ui no longer calls". Probe the v3 path, or a 404 will read as a pass. Traces carry URLs, SQL, headers and whatever else the instrumentation recorded. Bind the query endpoints explicitly and add an authenticator. Jaeger's security page lists TLS and mTLS for query clients and points to proxies for user login. The v2.21.0 build includes the OpenTelemetry `basicauth` extension, and the query `http` block is an OpenTelemetry HTTP server configuration, so it takes an `auth.authenticator`: ```yaml extensions: basicauth/server: htpasswd: file: /etc/jaeger/htpasswd # htpasswd-format entries; bcrypt ones worked on the loopback run jaeger_query: http: endpoint: 10.0.0.5:16686 # a private address, not the ":16686" default tls: cert_file: /etc/jaeger/tls/server.crt key_file: /etc/jaeger/tls/server.key auth: authenticator: basicauth/server grpc: endpoint: 127.0.0.1:16685 storage: traces: some_storage service: extensions: [basicauth/server, jaeger_storage, jaeger_query] # keep your other extensions ``` On the loopback run with `basicauth/server` on the query `http` server, `/api/v3/services`, the UI and the MCP endpoint each returned `401` without credentials and `401` with a wrong password. Each returned `200` with the right credentials. With the `tls` block added (the OpenTelemetry `cert_file` and `key_file` settings), HTTPS without credentials got `401`, HTTPS with them `200`, and plain HTTP `400`; leave `tls` out and the password crosses the network in cleartext. That wiring is inferred from the OpenTelemetry types. Jaeger's documentation does not describe it, so re-test it after an upgrade. Keep the gRPC query port on loopback unless a client needs it: its server configuration also has an `auth` setting, which was not demonstrated here. Remove `mcp` from `ai` if nothing uses it. For per-user login, front the UI with an authenticating proxy per [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). Collector receivers that must accept spans from other hosts need their own authenticator, as described for the OpenTelemetry Collector in [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md). ## Grafana Loki Loki's documentation leaves no doubt: "Grafana Loki does not come with any included authentication layer. You must run an authenticating reverse proxy in front of your services." At Loki v3.7.8 with dskit `8d1c6d34bb5a42b04caa982d68403c5a643bb742`, `server.http-listen-address` and `server.grpc-listen-address` default to empty. `server.http-listen-port` defaults to 3100 in Loki, overriding dskit's 80; `server.grpc-listen-port` keeps dskit's 9095. Both listen networks default to `tcp`. dskit passes the address and port through `net.JoinHostPort` to `net.Listen`: an empty address becomes `:3100` or `:9095`, a wildcard listener. IPv4 and IPv6 coverage depends on the host's Go/OS networking support; do not assume IPv6 is excluded. These are source-derived defaults, not observed sockets. The shipped `cmd/loki/loki-local-config.yaml` instead sets gRPC 9096 and `auth_enabled: false`; `production/docker/config/loki.yaml` explicitly sets both addresses to `0.0.0.0`, ports 3100/9095 and `auth_enabled: true`. Read your effective configuration. With memberlist as the ring store, Loki v3.7.8 uses dskit `8d1c6d34bb5a42b04caa982d68403c5a643bb742`: its TCP-only gossip transport defaults to `memberlist.bind-port` 7946 and `memberlist.bind-addr` 0.0.0.0 (a wildcard bind). An omitted or empty bind-address list selects 0.0.0.0; an empty string inside the list is invalid. TLS defaults to off, and gossip has no peer authentication without it. `memberlist.cluster-label` adds a label to outgoing messages and rejects mismatched incoming labels unless `memberlist.cluster-label-verification-disabled` is true. Treat that label as protection against accidental cluster mixing, not as a credential: a peer can send the same label. This dskit wiring exposes no HashiCorp memberlist `SecretKey` or keyring setting. Without TLS, a reachable peer speaking the protocol and matching any configured label can join the gossip cluster and submit KV updates, including ring tokens and instance states, subject to the codec and merge rules. Disrupted ingestion or queries are a consequence inferred from that state-sharing path, not demonstrated here. `memberlist.tls-enabled` enables TLS, but at this pin it does **not** require or verify incoming client certificates. The transport reuses dskit's `ClientConfig`: `memberlist.tls-cert-path` and `memberlist.tls-key-path` supply its certificate and key, while `memberlist.tls-ca-path` populates `RootCAs` for outgoing server verification, not `ClientCAs`. `ClientAuth` stays at Go's default `NoClientCert`; no memberlist setting here selects `RequireAndVerifyClientCert`. Keep `memberlist.tls-insecure-skip-verify` false (the default); `memberlist.tls-server-name` can set the expected server certificate name. TLS with these settings is not mutual peer authentication. Bind `memberlist.bind-addr` (YAML `memberlist.bind_addr`, a list) to the private cluster network and restrict access to trusted cluster peers with network controls, even with TLS enabled. If incoming client certificate verification is required, enforce it through a separate authenticated transport boundary. `memberlist.advertise-addr` and `memberlist.advertise-port` select the IP and port announced to peers, for example through NAT; they do not restrict the listener. Without an explicit advertise address, the transport derives an address from the first bind (a private IP for 0.0.0.0) and advertises its actual bound port. Set an explicit reachable cluster IP when that automatic choice is unsuitable. `auth_enabled: true`, the default, selects multi-tenancy, not authentication. Tenant-scoped HTTP handlers require `X-Scope-OrgID`, but the header does not prove the caller owns that tenant. dskit's tenant validation checks syntax and length, not identity or permission. With `auth_enabled: false`, Loki uses tenant `fake`. A missing-header rejection is not a login gate. dskit's default gRPC interceptor chain has no credential authentication; Loki adds its auth setup, with explicit tenant-check exemptions for health, frontend worker, scheduler and other internal RPCs. The complete interceptor behaviour has not been re-traced here. Do not expose gRPC on the strength of `auth_enabled`. A reachable client without credentials can submit logs through `POST /loki/api/v1/push` and query through `/loki/api/v1/query` or `/loki/api/v1/query_range`, subject to tenant headers and ingestion or query policies. `GET /config` exposes configuration; `/metrics` and `/debug/pprof/` are registered by default. These routes have no tenant-auth wrapper. When the ingester runs, `GET` or `POST /flush` and `GET` or `POST /ingester/shutdown` call maintenance handlers without that wrapper; `/ingester/prepare_shutdown` also accepts `POST`, `GET` and `DELETE`. The compactor's worker mode returns before the deletion routes are registered. The compactor outside its worker mode, with no TSDB or boltdb-shipper index in the schema, logs that it is not starting and registers nothing, with no error. The `retention_enabled: true` setting also requires `compactor.delete-request-store`, or startup fails. When the compactor runs outside its worker mode, with a TSDB or boltdb-shipper index in the schema and `retention_enabled: true`, `/loki/api/v1/delete` accepts `PUT`/`POST` to request deletion, `GET` to list requests and `DELETE` to cancel one. Those routes wrap HTTP tenant middleware and deletion `TenantMiddleware`. The latter rejects a missing/invalid tenant or policy error with HTTP 400, and a tenant for which `validDeletionLimit` returns false with HTTP 403. A caller reaching the route with a tenant context that passes this check may use the handlers; tenant headers alone do not authenticate the caller. Limits name `disabled`, `filter-only` and `filter-and-delete`, default to `filter-and-delete`, and expose per-tenant overrides. The flag help says the API is available for `filter-only` and `filter-and-delete` when retention is on. In v3.7.8, `validDeletionLimit` reads the tenant's `DeletionMode`, passes it to `deletionmode.ParseMode`, and returns `DeleteEnabled()`: true for `filter-only` and `filter-and-delete`, false for `disabled`. Parsing accepts only those exact strings; any other value returns an error. Thus, after tenant validation, the same middleware allows create, list and cancel for either enabled mode, returns HTTP 403 for `disabled`, and HTTP 400 for an unrecognized mode. This is source reasoning about admission to the handlers, not observed responses or proof of eventual deletion. A permitted `PUT`/`POST` validates the query and time range, returns HTTP 400 for bad inputs or 500 for store failure, and returns HTTP 204 with `X-Delete-Request-ID` after the request is stored. `GET` lists that tenant's requests as JSON, with 500 on store failure. When both `start` and `end` are supplied, an invalid value writes HTTP 400 without stopping the handler. `DELETE` looks up a request under the tenant ID: an ID the store does not find returns 404; processed requests return 400; partially completed or overdue requests return 400 unless `force=true`; store failure returns 500; removal returns 204. A stored request does not establish eventual deletion. From source reading of the compactor at v3.7.8, without a Loki run (REASONED), its delete-requests manager selects the requests its store returns as unprocessed only after `delete_request_cancel_period` plus one minute has passed since each was created, and only for tenants whose mode is then `filter-and-delete`, so it never selects a `filter-only` tenant's request for removal. The manager tries to mark a request whose whole interval is older than the tenant's longest retention period as processed without filtering, and loads at most the configured delete batch size at a time. Each attempt to mark a request processed calls the delete-request store's `MarkShardAsProcessed`; on a store error, the manager logs the error and returns nothing to the caller, which carries on. The compactor passes the manager, behind its retention expiration checker, to the retention marker. When a mark phase starts, the manager loads that batch (in the main horizontal-scaling mode, only requests without a line filter), and when the phase finishes it tries to mark each loaded request processed, which it does not do when the phase fails or times out; the code that runs those phases and the marker's code were not read. In the main horizontal-scaling mode, requests with a line filter instead go into a deletion manifest that the manager builds every five minutes. Given a job built from that manifest, the deletion job runner examines each chunk the job lists. The runner skips a listed chunk that storage reports as not found, leaves a found chunk unchanged when no request's filter matches any of its lines, and otherwise rewrites the found chunk without the matched lines; the job queue's dispatch of those jobs and the worker's registration of that runner were not read. After passing the collected storage updates to the tables manager, whose code was not read, the job builder tries to mark the manifest's requests processed through a callback that returns nothing, then goes on to delete the manifest's files whatever the marks' outcome, and a failed job cancels the manifest's processing. Marking a request processed does not establish that its chunks are gone: the retention sweeper that deletes marked chunks was not read. The gRPC listener registers ingester push/query services in single-binary mode and an HTTP-over-gRPC bridge to the HTTP router. HTTP-only TLS or a proxy in front of 3100 therefore does not protect all paths to these services. These exposure consequences are REASONED, not live attack results. There are two fixes, and you can use both: - **mTLS on the servers.** dskit's `server.http_tls_config` and `server.grpc_tls_config` each accept `cert_file`, `key_file`, `client_ca_file` and `client_auth_type`; YAML also accepts inline `cert`, `key` and `client_ca`. All default to empty, so both listeners serve plaintext by default. TLS is constructed only when both a certificate and a key are supplied. A CA file alone does not enable TLS or require a client certificate. Unlike memberlist's transport above, both server configs pass client-auth policy and client CAs to the TLS builder. Set `client_auth_type: RequireAndVerifyClientCert` and the trusted client CA to require verified client certificates. The example enables HTTP mTLS and recommends loopback gRPC for one process with no remote gRPC clients; check ingestion and queries with your effective configuration: ```yaml # Overlay on a working single-binary config (keep your storage and schema_config). common: instance_addr: 127.0.0.1 # advertised address must be reachable on the gRPC bind replication_factor: 1 # one instance; the default is 3 ring: kvstore: store: inmemory # single binary: no memberlist gossip listener server: http_listen_address: 10.0.0.6 grpc_listen_address: 127.0.0.1 # single-process recommendation; verify internal clients http_tls_config: cert_file: /etc/loki/tls/server.crt key_file: /etc/loki/tls/server.key client_auth_type: RequireAndVerifyClientCert client_ca_file: /etc/loki/tls/clients-ca.crt ``` `common.instance_addr` supplies common ring and frontend addresses; explicit component settings can override it. `modules.go` sets the ingester lifecycler's listen port to the server gRPC listen port, which Loki replaces with the bound port when it is configured as 0 (the ingester module depends on `Server`, and dskit's module manager initializes a module's dependencies before the module itself). The lifecycler takes its advertised port from `GetInstancePort(cfg.Port, cfg.ListenPort)`, which returns a positive configured `Port` (default 0; a configured common ring copies its `instance_port` into it) and otherwise that listen port. The advertised address is the configured `Addr`, or else one that `netutil.GetFirstAddressOf` selects from the configured interfaces, joined with that port. The lifecycler registers this address in the ring, and distributor and querier construction receive the ring and ingester client settings. Distributors select ingester ring entries for writes; queriers select ring entries for reads. Both obtain clients by each entry's advertised address, and the ingester client calls `grpc.Dial` with dskit gRPC client options. `modules.go` builds the server gRPC listen address and port, target flags, scheduler ring and worker configuration into the worker service configuration and passes it to `querier.InitWorkerService`. In v3.7.8 `worker_service.go`, standalone means the frontend, scheduler, read and all target flags are all false. In that case, a nil scheduler ring and empty frontend and scheduler addresses return no worker service; otherwise the worker configuration and ring are passed to `NewQuerierWorker`. Outside that standalone case, if the ring is nil and both addresses are empty, the initializer sets `FrontendAddress` to the server gRPC port with the nonempty gRPC listen address, or `127.0.0.1` when that listen address is empty, then passes the configuration and ring to `NewQuerierWorker`. This automatic branch warns that unresponsive queries may need explicit worker configuration. The querier worker selects the scheduler ring first, then an explicit scheduler address, then a frontend address, which may be explicit or automatically set above. It constructs ring/DNS watchers. Each DNS lookup period, the ring watcher requests the scheduler ring's `Write` replication set for key 0 and, only when that set is non-empty, reports addresses added to or removed from its previous list. The DNS watcher hands the lookup period to dskit's resolver as its polling frequency and resolves the address with no SRV service name: an IP address is reported once, and a host name is re-resolved by host lookup each period, with dskit's default port 443 when the address names no port. `AddressAdded` passes a target to `connect`, whose `grpc.DialContext` uses `QuerySchedulerGRPCClientConfig` for scheduler connections or `NewQueryFrontendGRPCClientConfig` for frontend connections. `AddressAdded` then passes that connection to a processor manager, which runs processor goroutines on it (`processQueriesOnSingleStream`); the worker divides its maximum concurrency among managers, at least one each. `AddressRemoved` stops a manager, which notifies the remote end of shutdown and closes the connection. A direct frontend processor opens `Process` on the supplied connection; a scheduler processor opens `QuerierLoop` on it. Scheduler requests carry `FrontendAddress` into the result path, which asks the frontend pool for that address and calls `QueryResult`; the pool's client factory calls `grpc.Dial` with `NewQueryFrontendGRPCClientConfig`. These are source call sites; this guide does not infer successful connections from them. The scheduler processor's frontend pool has no service discovery and health-checks its clients every 5 seconds with a 1-second timeout. dskit client pools cache one client per address and create it through the pool factory on first use. A pool with health checks enabled removes a client whose health check keeps failing past the grace period. Health checks are always enabled for the frontend pool and, through `health_check_ingesters` (default true), for the ingester pools; Loki sets no grace period in these pools, so the first failed check removes the client. The distributor and querier ingester pools also remove clients whose addresses are no longer among the healthy ring entries. Lifecyclers write their instance entry, including the advertised address, into the ring key by KV compare-and-swap at ring initialization and on each heartbeat. The KV backend is `consul`, `etcd`, a process-wide `inmemory` store, `memberlist` or `multi` (plus a test-only `mock`). Ring clients read and then watch the same key and build replication sets from its entries: distributors take a `WriteNoExtend` set per stream, and queriers, outside partition-ingester queries, take the healthy `Read` set and call `ring.DoUntilQuorum`. The `ring.DoUntilQuorum` function runs the per-ingester call in parallel until its quorum condition is met or too many calls fail; each call takes a pooled client by the entry's address. Push HTTP handlers call `PushWithResolver`; outside the `all`, `write` and `ingester` targets the distributor also registers the gRPC `Pusher` service. Kafka writes are off by default and ingester writes are on. With Kafka writes on, the distributor requires a partition ring, creates a Kafka writer client and produces each stream's records to an active partition. Queriers send the ingester interval of a log selection to the ingester querier when `query_store_only` is unset and the selection ends within the ingester lookback, which is `query_ingesters_within` (0 sends every selection) unless the ingester's `query_store_max_look_back_period` is nonzero when the querier module initializes and replaces it. Without the `all` target, a configured 0 stays 0 and `query_ingesters_within` applies. When the target list includes `all` but none of `querier`, `ruler`, `read`, `index-gateway` or `backend`, and the active or next schema period uses a boltdb-shipper or TSDB index, the store module, which the querier module depends on and the module manager initializes first, turns a configured 0 into a computed minimum that then replaces `query_ingesters_within`. That minimum is the ingester's maximum chunk age plus 21 minutes, the index cache validity and three resync intervals of that index's shipper. The setting's flag help limits it to boltdb-shipper or TSDB indexes with the `filesystem` object store, and the same `all`-target store setup rejects a nonzero value with any other object store. With the `filesystem` object store, that setup also rejects a positive value below the computed minimum and keeps `-1` and any other negative value unchanged. The flag help documents `-1` as infinite, which sends every selection to ingesters and none directly to the store; the querier treats only exactly `-1` that way, querying ingesters for the whole selection and skipping its store query. A positive value, once the querier module copies it, limits the querier's own store query to the part of a selection before now minus that value, and a selection starting after that point goes to ingesters only. For any other negative value, now minus that value lies in the future, and the querier sets the end of any store request it makes to that point without clamping it to the selection's end, whereas the ingester leaves the start of its own store request unchanged for `-1` and every other negative value. From this source reading, without a Loki run, when that future point is after the selection's end, the querier's interval code also starts the ingester interval there and ends it at the selection's end without checking their order. The same source reading, without a Loki run, shows that in that case, unless `query_store_only` is set, the querier sends that inverted interval to the ingester querier for log and sample selections that pass the querier's request validation and reach interval construction. Under those `all`-target conditions, the ingester itself also queries the store for the log and sample selections it serves: the ingester module depends on the store module, so the module manager initializes the store first and the ingester receives the adjusted configuration. The ingester moves the start of each store request up to now minus a positive lookback, leaves it unchanged for `-1` and other negative values, and skips the store query when that adjusted start is after the selection's end. From the same source reading, the ingester querier passes the inverted interval to the gRPC `Query` or `QuerySample` call of each ingester it queries, which the ingester module registers the ingester, or a wrapper that Go code embedding Loki may set (the field has no YAML key), to serve. Without a configured wrapper, the ingester itself serves those calls: since it never moves a store request's start earlier, it makes no store query for that interval whatever its own lookback, and its in-memory query looks up streams in its index at the interval's start and skips each chunk unless the chunk's bounds start no later than the selection's end and end no earlier than that future point. From the same source reading, in that case the ingester adds every stream to each index it builds (a bit-prefix index for TSDB schema periods, an inverted index for others), and the lookup uses the interval's start only to pick the index of the schema period containing it, for a future start the latest period starting at or before it. The same source reading shows that, in that case, the lookup matches label matchers and shard without reading time, so the inverted interval causes no lookup error. In the same reading, no stream, chunk or head-block iterator swaps or clamps the interval: a chunk, block or head block is read only if it starts no later than the selection's end and ends no earlier than that future point, and the ordered head block's log iterator drops every entry before the start or at or after the end. By that reading, the chunk's time-range filters, used for block entries in both directions, the head block in forward log queries and all sample queries, skip entries before the start but accept an entry timestamped exactly at the start before testing the end, so, in this guide's assessment, they pass only entries dated exactly at that future point. From the same source reading, the distributor validates each pushed entry against a context built from `time.Now()`: a timestamp strictly newer than now plus the tenant's `creation_grace_period` (default 10m) is rejected as too far in the future, so one exactly at that bound passes. The `reject_old_samples` setting (default true), in the same validation and source reading, rejects entries older than now minus `reject_old_samples_max_age` (default 7d). The distributor, from the same source reading, after validation and with `increment_duplicate_timestamp` on (default false), sets each different-line duplicate after the first to the previous stored entry's timestamp plus one nanosecond, so a push of n such entries at the bound can store the last one n-1 nanoseconds past it. The ingester's own push chain, from its gRPC push through stream validation and append, compares entry timestamps with stream state and the schema configuration that stream creation reads, never the clock. That push chain skips duplicates of the stream's last line, applies rate limits, drops entries below the stream's highest timestamp minus half `max_chunk_age` (default 2h) when unordered writes (default true) are on, and in the ordered path rejects an entry older than the head block's newest or, when the head block is empty with earlier cut blocks, than the previous block's maximum. This push chain, in source, therefore imposes no clock-relative upper bound on a stored entry's timestamp. In this guide's assessment, when the lookback's magnitude is at most the tenant's creation grace period, the inverted interval's start lies at or before the latest timestamp the distributor accepts at the query's moment, so an ingester can hold an entry dated exactly at that start. The comparison assumes the querier and distributor read one reference clock and the tenant's grace period is unchanged, since an entry keeping its submitted timestamp must pass validation while now plus that grace period still reaches the start. For a larger magnitude, a direct ingester gRPC push is one route this reading establishes. The Kafka partition-ingester consumer decodes each record of its partition and calls the ingester's own `Push` directly, not through gRPC, with the record key as tenant. The distributor writes its Kafka records only after the same validation and duplicate-timestamp handling as its ingester writes, so in this guide's assessment the distributor's records add no route of their own, and the distributor's duplicate-timestamp shifts are not ruled out. Ingesters read the topic that `kafka_config.topic` names (flag `kafka.topic`, empty by default and required when the ingester's Kafka ingestion is enabled). In this guide's assessment from source reading, the repository's other Go Kafka producers add no route either. The data-object tee, built only with Kafka writes on, encodes the same validated streams with its own topic, set by flag `distributor.dataobj-tee.topic` (empty by default and required when the tee is enabled), and produces them through the distributor's Kafka client. No validation found in this reading compares the tee's topic with the ingesters' topic, so with the two set equal an ingester's partition reader can receive the tee's records, as copies of streams the distributor has already validated. The data-object consumer's metastore producer writes to the fixed topic `loki.metastore-events`, the ingest-limits producer to the topic its flag `ingest-limits.topic` names and the stream generator tool to the topic its own `kafka.topic` flag names, and the ingesters' topic can be set to any of them. Those three producers write metastore object-written events, stream-metadata records and stream metadata. In this guide's assessment, decoding any of those records as a log stream fails, because each record carries a field whose wire type the stream decoder rejects or, for stream metadata whose hash and size are both zero, is empty, which the decoder also rejects; the Kafka consumer logs the failure and pushes nothing for that record. For metastore and ingest-limits records, that outcome assumes that the timestamp and label decoders that `Entry.Unmarshal` calls return an error rather than panic; those decoders were not read. In this guide's assessment, a panic there would push nothing either, so metastore and ingest-limits records add no route either way. Programs that import Loki are not traced. No Loki run has observed an ingester holding or returning such an entry. By that reading too, backward log queries do not time-filter the head block, and the unordered head block queries its vendored go-datastructures v1.1.7 range tree from the interval's start to one nanosecond before its end, both inclusive; for the inverted interval that query returns no entry, so the head block yields nothing. From the same source reading, when the ingester's `Query` or `QuerySample` code gets an iterator that yields nothing without an error and the request context is not yet done, it sends one batch with no streams or series, carrying its query statistics and warnings, and returns no error unless that send returns an error other than the exact value `context.Canceled`. If the context is already done before the send, that code skips it with no error; it never aborts a send in progress. In Loki's vendored grpc-go `stream.go`, the server stream's `SendMsg` makes no context check of its own: it returns an error from `prepareMsg`'s encoding or compression as is, a `ResourceExhausted` status for a message over the send limit and any error from the transport stream's `Write` passed through `toRPCErr`, and writes a status for any error other than `io.EOF`. From source reading of the generated code, the `Send` methods of the `Query` and `QuerySample` server streams return `ServerStream.SendMsg`'s result unchanged, and grpc-go builds the server stream and passes it to the handler through the configured stream interceptors. In dskit, the logging and instrumentation stream interceptors pass that stream and the handler's error through unchanged, and the user-header interceptor's wrapper replaces only the stream's `Context`. The in-flight limiter stream interceptor that dskit adds only when code sets `GrpcMethodLimiter`, a field with no YAML key, also passes that stream and the handler's error through unchanged, after a limiter check that can end the call before the handler runs. Loki also installs a recovery stream interceptor on every target, query-tags, HTTP-header and ingestion-policy ones on the `ingester`, `write` and `all` targets and a query-limits one when per-request limits are enabled, and it passes the server configuration to `fakeauth.SetupAuthMiddleware`. That function appends one stream interceptor: a fake-tenant one with authentication off, or, with authentication on, one that calls dskit's user-header interceptor except for listed methods. From source reading, the query-tags, HTTP-header, ingestion-policy, query-limits and fake-tenant interceptors, and the OpenTracing interceptor that dskit adds when a global tracer is registered, each pass the handler either the stream itself or a wrapper that replaces only its `Context`, and return the handler's error unchanged. In the vendored opentracing-contrib go-grpc package, `OpenTracingStreamServerInterceptor` passes the original stream when an `IncludingSpans` function rejects the call and otherwise a wrapper that embeds the stream and overrides only `Context`, so under none of its options does it wrap `SendMsg`; dskit passes it no option. The query-limits interceptor can also end the call before the handler runs when its metadata does not parse. The recovery interceptor is built by the vendored go-grpc-middleware recovery package. From source reading at v3.7.8, that package's `StreamServerInterceptor` passes the handler the original stream and returns the handler's error, replacing it only after a panic, with the result of Loki's `onPanic`; it wraps neither the stream nor `SendMsg`. A search of every Go file in the v3.7.8 tree, `vendor/` included, finds no appender to dskit's `GRPCStreamMiddleware` beyond `loki.go`, `modules.go` and `fakeauth.SetupAuthMiddleware`, so the stream interceptors named here are all that Loki code adds. The same search finds nothing that sets dskit's `GRPCOptions`, a field with no YAML key whose server options dskit appends to its own, and no call of grpc-go's `AddGlobalServerOptions` hook, whose options `NewServer` applies first, so neither adds a stream interceptor or stats handler in the v3.7.8 repository; a program that imports Loki is not traced. The transport stream's `Write`, which `SendMsg` calls, only returns the result of its transport's `write`, and grpc-go's HTTP/2 server transport sets itself as that transport for each stream it creates. In that transport, `write`, and the header write it makes first, check the stream's state, not its context: for a stream already marked done, they return `ErrConnClosing` if the transport's `done` channel is closed and otherwise `ContextErr` of the context's error, and `write` returns the same after a failed wait for write quota. `Close` marks the transport closing before it closes that channel, so such a write in between takes the `ContextErr` path. `ContextErr` gives a `Canceled` or `DeadlineExceeded` status for those two context errors and an `Internal` status for any other value, including none. A client reset or the server's deadline timer cancels the stream's context and then marks the stream done, while a transport close cancels it without marking it done; `toRPCErr` turns `ErrConnClosing`, a connection error, into an `Unavailable` status and returns status errors unchanged. In this guide's reading of `http2_server.go`, every path that marks a stream done cancels its context first, since only `finishStream` and `closeStream` mark it and each cancels the context before doing so. Those paths cancel the context the transport creates for the stream, but `streamContextErr` reads the stream's current context, which grpc-go's `handleStream` replaces through `SetContext` with a context built from the original and passed through the configured stats handlers' `TagRPC`. The dskit server always configures otelgrpc's server handler, and adds its in-flight limiter when `GrpcMethodLimiter` is set and its own stats handler when stats tracking is enabled. From source reading, the limiter's `TagRPC` returns its input, dskit's stats handler's returns a `context.WithValue` child of its input, and otelgrpc's returns a `context.WithValue` child of the context that its propagator extraction and tracer start return. From source reading at v3.7.8, grpc-go's `NewCombinedHandler` returns nil for no handler, a lone handler as is and otherwise one that chains each handler's `TagRPC` in order, each taking the previous one's result; dskit always passes otelgrpc's handler, so Loki's servers have at least one, and the search above finds no handler added through `GRPCOptions` or global server options. Before `TagRPC`, the transport wraps the context it creates for the stream in a `context.WithValue` child through `metadata.NewIncomingContext` when the request carries metadata, then replaces it with what any tap handle returns, and `handleStream` adds a `context.WithValue` child through `contextWithServer`. The dskit server builds that tap handle with `grpcutil.ComposeTapHandles` from its `GRPCTapHandles` field, which has no YAML key, and, when `GrpcMethodLimiter` is set, the in-flight limiter's tap handle. `ComposeTapHandles` returns a lone handle as is and otherwise calls each in order on the previous one's context. A search of every file in the v3.7.8 tag archive finds `GRPCTapHandles` only in that field's declaration and dskit's append, so nothing in the repository sets it; a program that imports Loki is not traced. The otelgrpc server handler, which dskit builds with no option, takes OpenTelemetry's global propagator and tracer provider by default. From source reading of `cmd/loki/main.go`, Loki's `main` calls dskit's `NewOTelOrJaegerFromEnv`, with no option, when `tracing.enabled`, true by default, is set. That function takes its Jaeger path when `JAEGER_AGENT_HOST`, `JAEGER_ENDPOINT` or `JAEGER_SAMPLER_MANAGER_HOST_PORT` is non-empty, else its OpenTelemetry path when `OTEL_TRACES_EXPORTER`, `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is non-empty, and otherwise installs no tracer provider or propagator. Unless its setup fails first, each path sets the global tracer provider to `otelpyroscope`'s provider wrapping an SDK provider, since `main` does not disable pyroscope. It sets the global propagator to a composite of TraceContext, Baggage, contrib Jaeger and dskit Jaeger debugging when `OTEL_PROPAGATORS` is empty, otherwise of those its `tracecontext`, `baggage` and `jaeger` entries name, or of none if it lists `none`. By the same source reading, the `Extract` methods of OpenTelemetry's global, composite, TraceContext and Baggage propagators, the contrib Jaeger propagator and dskit's Jaeger debugging propagator return their input or a context built from it only by `context.WithValue`. So do the `Start` methods of the SDK, global and noop tracers, except where the global tracer returns its delegate's result, where the SDK tracer returns the context of `runtime/trace.NewTask`, which it calls when `runtime/trace.IsEnabled` reports true, and in `otelpyroscope`'s `Start`, which otelgrpc's tracer start reaches once `main` installs a provider; `NewTask` and that `Start` were not read. That reading also sets aside the global tracer's auto-instrumentation path, which its code comment says is off by default, and a baggage set hook, for which a search of every file in the v3.7.8 tag archive finds no installer. It also does not cover the Go standard library's `context.WithValue`, the `pprof.WithLabels` and `x/net/trace` contexts that `handleStream` can add before `TagRPC`, or the in-flight limiter's `RPCCallStarting`, whose returned context the limiter's tap handle wraps in a `context.WithValue` child that the transport makes the stream's context. From source reading, `handleStream` adds the `x/net/trace` context only when grpc-go's `EnableTracing` package variable is true, and a search of every Go file in the v3.7.8 tag archive finds that variable read in `NewServer`, `handleStream` and the client's `newAttemptLocked` but never assigned, so nothing in the repository sets it; a program that imports Loki is not traced. `handleStream` adds the `pprof.WithLabels` context only when the setting grpc-go reads from the `GRPC_GO_SERVER_GOROUTINE_LABELS` environment variable has its server-method bit set, so that depends on the deployment's environment. Which status code the send then fails with is therefore not established, though the failure is a gRPC status error either way. In this guide's assessment, which this reading does not establish, that status would be `Canceled` or `DeadlineExceeded`, not `Internal`, provided a `context.WithValue` child reports its parent's `Done` and `Err` and the code not covered adds no cancellable context. So, in this guide's reading, an empty-batch send that passes `SendMsg`'s message preparation and reaches `Write` on a stream already marked done fails with a gRPC status error, never the exact value `context.Canceled`, and the ingester code returns that error. That reading does not cover an error from `prepareMsg`, which `SendMsg` returns as is before it calls `Write`. That reading also assumes grpc-go's HTTP/2 server transport. In grpc-go, `ServeHTTP` builds a different transport on Go's own HTTP/2 server, whose code was not read. A search of the v3.7.8 tree's non-test Go files finds no call of a gRPC server's `ServeHTTP`, and outside the vendored Bigtable emulator and grpc-go's xDS wrapper the only non-test `grpc.NewServer` call is dskit's, which builds Loki's main and internal gRPC servers. In dskit, the HTTP server's handler wraps only the HTTP router, and `Run` serves the gRPC server with `Serve` on its gRPC listener, so the empty-batch reading's transport assumption holds for Loki's servers in the v3.7.8 repository; a program that imports Loki is not traced. From source reading of grpc-go's control buffer and write quota, enqueuing a frame fails, with `ErrConnClosing`, only once the control buffer is finished, which the transport's `Close` does and the transport's loopy writer also does when it exits, and a write-quota wait ends only when quota returns or the context the transport created is done. So if the context ends after those checks, a send whose write-quota wait succeeds then puts its data frame on the control buffer. If that enqueue succeeds, the send returns no error and the ingester code then returns none; once the control buffer is finished, the enqueue fails with `ErrConnClosing`, as in the transport-close case below. After a failed wait, `write` returns `ErrConnClosing` if the transport's `done` channel is closed and otherwise `ContextErr` of the stream's current context, whose state is not established beyond the source reading above. If the transport closes before the stream is marked done, a send that enqueues its frames before the control buffer is finished returns no error. A later enqueue fails with `ErrConnClosing`, and so does `write` after a write-quota wait that `Close` ends by cancelling the stream, since `Close` first closes the transport's `done` channel; the header write and `toRPCErr` turn that error into an `Unavailable` status, which the ingester code returns. The ingester querier's per-ingester iterator records those statistics and warnings, yields nothing and ends without an error at the end of the stream, while any other receive error propagates, and the querier merges the per-ingester iterators with any store query it makes for the selection. A source-reading word search for `Wrapper` in Loki's `pkg`, `cmd`, `integration`, `tools` and `clients` trees at v3.7.8 finds only that field's declaration, its one-method `Wrap` interface and the ingester module's nil check and `Wrap` call, besides a code comment and two Bootstrap stylesheet source maps; nothing in those trees sets it. The same search over the rest of the v3.7.8 repository outside `vendor/` adds only a comment in a documentation script, and no vendored package imports Loki's `ingester` or `loki` package; nothing in the repository sets it. So what the ingester querier and ingesters return for the interval is still not established and has not been observed, and what a wrapper set by a program that imports Loki does is not traced. `Run` initializes the target modules through the module manager and starts the resulting services together. The internal server module exists only when `internal-server.enable` is true (default false); it is then inserted before `Server` in each module dependency list that names `Server`, and its HTTP listener defaults to `localhost:3101`. It serves routes including `/ready` and the buildinfo route and, when their modules run, `/ring`, `/memberlist` and the compactor's handler prefix. Its flags register no gRPC listen network, address or port, and `initInternalServer` hands its configuration to dskit's `server.New` after replacing only its signal handler. From source reading, without a Loki run, unless YAML sets those fields, dskit therefore listens on `tcp`, its default for an empty network, at an empty address and port 0, an any-address listener on an OS-chosen port, and dskit's `Server.Run` registers the HTTP-over-gRPC bridge to the internal router on that listener's gRPC server. `pkg/loki/config_wrapper.go`, read whole at v3.7.8, does not set these fields either: apart from passing flag registration to `Config.RegisterFlags`, it changes configuration only in `ApplyDynamicConfig`, which sets `QueryScheduler.UseSchedulerRing` itself and makes its other changes in the helpers it calls, and the file contains no `Server` identifier. No such listener has been observed. `common.instance_addr` also sets `MemberlistKV.AdvertiseAddr` when set; memberlist gossip is separate from these gRPC calls. A single-binary compactor client uses the server gRPC listen address and port when deletion filtering is enabled. Loopback remains this guide's recommendation for this configuration, not a guarantee for every single-binary deployment: verify effective advertised and dial addresses, worker/frontend/scheduler settings, successful pushes and queries, and any compactor calls. From source reading of `setupModuleManager`, without a Loki run, `write` depends on the ingester, distributor and pattern ingester modules, `read` on the query frontend and querier, `backend` on the query scheduler, ruler, compactor, index gateway and bloom planner, builder and gateway, and `all` on the same modules as `read` and `write` plus the query scheduler, ruler, compactor and UI. The four targets have no init function of their own; configuration adds ingest limits to `all` when they are enabled, the backend modules to `read` in the deprecated legacy read mode, and other dependencies by configuration and target. The dskit module manager runs each module's init function once, after those of its dependencies, and returns the first init error with the module's name, which Loki's `Run` returns before it creates the service manager. By the same reading, init functions register gRPC services only on the main server: the distributor's `Pusher` outside the `all`, `write` and `ingester` targets, the ingester's `Pusher`, `Querier` and `StreamData`, the query scheduler's two services, the index gateway's, and that of whichever query frontend version is built. Others depend on settings: the pattern ingester, ingest limits, ingest-limits frontend, bloom gateway and bloom planner services when enabled, the ruler's with its API enabled, and the compactor's delete-requests service with the deletion routes above and its job queue in the main horizontal-scaling mode; `Run` adds the gRPC health service. From a search of the v3.7.8 tree's non-test Go files for the generated client constructors of the services that `modules.go` registers, without a Loki run, every in-tree caller of a service that `modules.go` registers uses the clients below, and no other non-test code names a service method directly. The ingester's `Pusher`, `Querier` and `StreamData` clients are built only for the distributor's and the ingester querier's client pools, and besides the `Push` and `Querier` calls above, the distributor's rate store calls `GetStreamRates` on ingesters. The pattern ring client carries the pattern tee's `Push` and the pattern querier's `Query` to the pattern ingesters in its ring. The ingest-limits frontend calls `ExceedsLimits`, `UpdateRates` and `GetAssignedPartitions` on the ingest-limits instances in their ring, and the distributor calls the frontend's `ExceedsLimits` and `UpdateRates` on healthy instances of the frontend's ring. A query frontend opens `FrontendLoop` to each scheduler that the scheduler ring or DNS reports, and, besides the querier worker's calls above, a scheduler reports a failed request's error by `QueryResult` to the frontend address that the request carries. A ruler calls `Rules` on each ruler in its ring's replication set. The compactor's gRPC client, dialled at the gRPC address that `compactorAddress` returns or, in the compactor's worker mode, at `compactor_grpc_address`, calls `GetDeleteRequests` and `GetCacheGenNumbers` and, in worker mode, opens the job queue's `Loop`. The index gateway client calls the index gateway on ring or DNS-resolved addresses, the bloom gateway client calls `FilterChunkRefs` and `PrefetchBloomBlocks` on DNS-resolved addresses, and the bloom builder opens `BuilderLoop` and sends `NotifyBuilderShutdown` to the planner's ring-leader or configured address. Outside `pkg`, only the stream generator tool in `tools` builds such clients: a `Pusher` client for its configured distributor address and an ingest-limits-frontend client pool on that frontend's ring. These call sites do not show that any call succeeds. From the same source reading, with Kafka writes on and ingester writes off, the distributor's own Kafka write sends each validated stream to an active partition of the tenant's partition-ring shuffle shard, and the distributor skips its ingester write; an enabled pattern tee still pushes the streams to pattern ingesters, and an enabled data-object tee writes them to that tee's configured topic. The distributor module fails to initialize unless the ingester's Kafka ingestion is also enabled in the same configuration. An ingester with Kafka ingestion enabled takes its partition ID from its instance ID, builds a partition-ring lifecycler and, before its ingester ring lifecycler starts, starts a partition reader for that partition of the configured topic, from the offset after the last committed one or, when none is committed, from the reader's start offset. The reader hands each polled batch to the Kafka consumer, which pushes each record as above, retries a push only while it fails with the ingester's read-only error, and queues for commit the highest offset before the first record it did not push. With `query_partition_ingesters` set, the ingester querier shuffle-shards the partition ring for the tenant over the ingester lookback and queries each partition's replication set until its quorum is met, with minimized requests. This guide's assessment is that these cited paths do not establish whether each module starts successfully or live reachability. Remote components need a private address reachable from their hosts. Keep gRPC restricted to trusted callers: plaintext loopback is still reachable by local users. Enabling `grpc_tls_config` also requires compatible TLS settings on every internal gRPC client, including client certificates when required; changing the server alone can break internal RPCs. This HTTP mTLS overlay does not configure those clients. mTLS does not set `X-Scope-OrgID`; your agent or proxy must still supply the tenant header when multi-tenancy is enabled. With the `inmemory` ring, check that no gossip listener is present. With memberlist, apply the separate private-cluster and trusted-peer restrictions above. **REASONED:** all Loki socket, push/query, administrative-route and TLS/client-certificate checks here follow the cited documentation and pinned sources: the authoring host forbids opening listeners without an isolated network namespace, and has none. Expected HTTP mTLS outcomes are a successful authorized request, handshake rejection without a trusted client certificate, and refusal of plaintext application requests. Check both IPv4 and IPv6 exposure, gRPC reachability and internal RPC success. No Loki listener or TLS test was run for this audit. - **An authenticating proxy that owns the tenant header.** The vendor's nginx example sets `proxy_set_header X-Scope-OrgID $remote_user;` so that "nginx overwrites any tenant header sent by the client". Keep Loki itself reachable only from that proxy. See [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). ## Verify REASONED: following block; this introduction and the TCP and UDP listener inventory below for every port it names, per the guide's cited node_exporter v1.12.1 source, Alertmanager v0.34.1 README, Pushgateway v1.11.3 README, Jaeger v2.21.0 ports, dskit pinned wildcard listener construction and sudo sudoers manual, secure_path entries. No second host and no isolated network namespace were available, so no wildcard bind was observed. During marking review (October 2026) the block passed bash -n under bash 5.3.9, and its two commands, run without sudo on the review host as ss -tlnp and ss -ulnp (iproute2 6.19.0), accepted their flags and showed none of the named TCP or UDP ports listening, with an empty process column, so they tested no target; the sudo form was not run. That review covered an earlier form of the block, two top-level commands with no exit status of their own; the current form, which runs both in a subshell that exits 2 before either inventory when ss is not installed, 1 for a named port on a wildcard address and 3 when either inventory fails, is covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes. Stub runs of the current form (October 2026, bash 5.3.9, with sudo and ss replaced by stubs, under inherited set -e, a readonly variable and a declare -i variable) exited 0 with a passing ss that listed only loopback binds, 3 with a failing ss, and 2 with no ss on PATH, printing "ss is not installed here; not listing sockets" once. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. Exposed, a listed port shows a wildcard bind; fixed, loopback or a private address. The non-Loki loopback HTTP runs on 127.0.0.1 against the versions named at the top are declared DEMONSTRATED at the credential block below and were not repeated in this marking review. Those loopback runs used an earlier form of the credential block, whose guard refusals ended with status 0 and whose results set no exit status; the current form, whose guard refusals exit with status 2 and which exits 1, 3 or 0 on its two results, has not been run against a service, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. All Loki checks are REASONED: the authoring host forbids opening listeners without an isolated network namespace, and has none. The default all-interfaces binds were not observed, so they are reasoned: the authoring host forbids binding every interface, so each run overrode the address to 127.0.0.1. The binds follow from each binary's `--help` default (`:9100`, `:9093`, `0.0.0.0:9094`, `:9091`) and from source (Loki's empty listen addresses; Jaeger's `":" + port` query default). Go listens on every interface for an address with no host. The probes from a second host are reasoned too: the authoring environment had no second host, so the refusal or timeout expected there was not observed. On the host: ```bash # Listener inventory expectations follow the cited sources and recorded loopback runs; wildcard binds and Loki listeners were not observed. ( # a subshell, so the exit below ends only the subshell set +e # errexit off here, so each result below is recorded instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 "$(if sudo ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0; a missing ss exited 2 above, before either inventory), the TCP table, then its exit status printf '%s' "$2" # loopback or a private address only: 9100 9093 9094 9091 3100 9095 7946, and # Jaeger's 16686 16685 4317 4318 14250 14268 9411 5778 5779 13133 27777 27778 8888 [ "$3" = 0 ] || { echo "sudo ss -tlnp exit $3: the TCP inventory failed; inconclusive"; set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk -v ports=' 9100 9093 9094 9091 3100 9095 7946 16686 16685 4317 4318 14250 14268 9411 5778 5779 13133 27777 27778 8888 ' 'NR > 1 { p = $4; sub(/.*:/, "", p); h = substr($4, 1, length($4) - length(p) - 1); if ((h == "*" || h == "0.0.0.0" || h == "[::]") && index(ports, " " p " ")) found = 1 } END { exit !found }'; then echo "FINDING: a TCP port named above is bound to a wildcard address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the TCP table; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi set -- "$1" "$(if sudo ss -ulnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # the status so far, the UDP table, then its exit status printf '%s' "$2" # Alertmanager gossip also uses UDP 9094; Jaeger's Thrift receivers UDP 6831 and 6832 [ "$3" = 0 ] || { echo "sudo ss -ulnp exit $3: the UDP inventory failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk -v ports=' 9094 6831 6832 ' 'NR > 1 { p = $4; sub(/.*:/, "", p); h = substr($4, 1, length($4) - length(p) - 1); if ((h == "*" || h == "0.0.0.0" || h == "[::]") && index(ports, " " p " ")) found = 1 } END { exit !found }'; then echo "FINDING: a UDP port named above is bound to a wildcard address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the UDP table; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi echo "block exit $1; whether a specific address is private, and every port not named above, are not classified, so judge them yourself" exit "$1" ) ``` REASONED: the recorded stub run of the following block's guard and the off-host probe this paragraph introduces, per the guide's cited listener and authentication sources; no wildcard bind was observed because no isolated network namespace was available. The following block's guard was run with a stub curl: unchanged, it printed its substitute-the-service-address refusal. That stub run used an earlier form of the following block, whose guard refusals ended with status 0, and was not repeated on the current form, whose guard refusals exit with status 2 and which exits 1 for any 200 and 3 for a request that fails other than by a refused or timed-out connection; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Exposed, the reasoned expectation is `*:9100` (or `0.0.0.0:`/`[::]:`) for an unconfigured node_exporter, and likewise for the others. Fixed means a loopback or private address. Then probe from a host that should not have access. The block refuses to run until you substitute the address. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_THE_SERVICE_ADDRESS' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the service address on the set -- line above; not probing"; exit 2 ;; *) set +e # errexit off here, so each result below is recorded instead of ending the block set -- 0 "http://$1:9100/metrics" "http://$1:9093/api/v2/status" "http://$1:9091/metrics" \ "http://$1:16686/api/v3/services" "http://$1:3100/config" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the URLs still to probe while [ "$#" -gt 1 ]; do set -- "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 15 \ -w "$2 http=%{http_code} exit=%{exitcode} err=%{errormsg}\n" "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" printf '%s' "${1%$'\n'*}" # this URL's line; the capture is followed by the status so far and the URLs case "${1##*$'\n'}:${1%$'\n'*}" in *' http=200 '*) echo "FINDING: $3 answered an unauthenticated request with 200"; set -- "$1" 1 "${@:3}" ;; 0:*|7:*|28:*) ;; # another status, or a refused or timed-out connection: judged below *) echo "curl exit ${1##*$'\n'}: the request to $3 failed in some other way; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 "${@:3}" ;; esac set -- "$2" "${@:4}" done echo "block exit $1; every status other than 200, including 401 and a Loki 401 without a tenant header, and each refused or timed-out connection, whose err text must show it failed while connecting, are not classified, so judge them yourself" exit "$1" ;; esac ) ``` REASONED: preceding block; the second-host probes of node_exporter, Alertmanager, Pushgateway, Jaeger and Loki and the Loki tenant-header repeat, per the guide's cited Prometheus security model, Exporter toolkit v0.17.1 web configuration, Jaeger 2.21 security and Loki v3.7.8 authentication entries. External probes follow the cited listener and authentication sources. The authoring environment had no second host and no Loki listener. During marking review (October 2026) the block passed bash -n under bash 5.3.9 and ran with a stub curl and no network: without its set -- line it printed the paste-the-whole-block refusal, and with 203.0.113.10 substituted the stub received the five URLs in order. The bash -n and stub runs recorded here covered the probe block in an earlier form, whose guard refusals ended with status 0, and the stub runs were not repeated on the current form. The current form's guard refusals exit with status 2, it exits 1 for any 200 and 3 for a request that fails other than by a refused or timed-out connection, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. The expected exposed and fixed results are those stated here. Any `200` is the finding: that service answers the unauthenticated request. For a service that uses TLS, switch that URL to `https`. A `401` then means its authentication is on. From a host that should be blocked, a connection refusal or a connect timeout is consistent with isolation. Read the `err` text to confirm it happened while connecting, not in a local proxy or policy. The write-out fields need curl 7.75.0 or newer. For Loki, a `401` without a tenant header proves nothing. Repeat the Loki probe with `-H 'X-Scope-OrgID: anyone'` added, and treat a `200` as exposure. REASONED: the positive-control and stdin-password instructions for the following credential pair, per the guide's cited Exporter toolkit v0.17.1 web configuration entry. The pair itself is declared below from the guide's recorded loopback runs; this marking review did not repeat them. During marking review (October 2026) that block passed bash -n under bash 5.3.9 and ran with a stub curl and no network: unchanged, it printed its substitute-the-URL refusal; an http:// URL drew the use-an-https-URL refusal; a user containing a colon was refused; and with dummy values the password reached curl only on stdin as an escaped user line, never in argv. The bash -n and stub runs recorded here covered the credential block in an earlier form, whose guard refusals ended with status 0, and the stub runs were not repeated on the current form, whose guard refusals exit with status 2 and which exits 1, 3 or 0 on its two results; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Exposed, the request without credentials returns 200; fixed, 401 without and 200 with credentials. Run the positive control from a host that should have access, so a dead service is not read as fixed. The password reaches curl on stdin, not argv. Substitute inside the quotes, and add `--cacert /path/to/ca.crt` if the certificate is private. DEMONSTRATED: following block; the recorded loopback HTTPS GETs for node_exporter, Pushgateway, Alertmanager and Jaeger returned 401 without credentials and 200 with them. The loopback runs used an earlier form of the block, whose guard refusals ended with status 0 and whose two results set no exit status; the current form, whose guard refusals exit with status 2 and which exits 1, 3 or 0 on those two results, has not been run against a service, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. The Loki mTLS sentence below is a reasoned equivalent that did not run. Fixed, the following block's `no credentials` result is `401` and its `with credentials` result is `200`. The loopback runs gave exactly that pair over HTTPS for GETs of node_exporter `/metrics`, Pushgateway `/metrics`, Alertmanager `/api/v2/status` and Jaeger `/api/v3/services`. For Loki under mTLS, the REASONED equivalent is `--cert` and `--key` on an authorized client (`200`) against the same request without them, which fails in the handshake. This closes the argv channel only. The password can still reach shell history or `set -x` output, and stdin does not hide it from your own account's view of the process. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The password you substitute on the set -- line enters shell history. # Clear that history line afterward. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_URL' 'REPLACE_WITH_USER' 'REPLACE_WITH_PASSWORD' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the URL (https://...) on the set -- line above; not probing"; exit 2 ;; esac case "$1" in https://*) ;; *) echo "use an https:// URL: basic auth over plain HTTP sends the password in cleartext; not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|""|*[[:cntrl:]]*|*:*) echo "substitute the user (no colon) on the set -- line above; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the password on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "${2//\\/\\\\}" "${3//\\/\\\\}" set -- "$1" "${2//\"/\\\"}" "${3//\"/\\\"}" 0 # the 4th value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set +e # errexit off here, so each result below is recorded instead of ending the block set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 15 \ -w 'no credentials: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, then its output printf '%s' "$6" case "$5:$6" in 0:*'http=401 '*) ;; 0:*'http=200 '*) echo "FINDING: the request without credentials returned 200"; set -- "$1" "$2" "$3" 1 ;; 0:*) echo "the request without credentials returned neither 401 nor 200; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the request without credentials failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac set -- "$1" "$2" "$3" "$4" "$(if printf 'user = "%s:%s"\n' "$2" "$3" | curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 15 \ -w 'with credentials: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' --config - "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" printf '%s' "$6" case "$5:$6" in 0:*'http=200 '*) ;; 0:*) echo "the request with credentials did not return 200, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; *) echo "curl exit $5: the request with credentials failed, so the positive control failed; inconclusive"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 ;; esac echo "block exit $4; whether this host should have access is not checked" exit "$4" ) ``` ## Sources (checked September 2026) - node_exporter v1.12.1 README (port 9100, `--web.config.file`): https://github.com/prometheus/node_exporter/blob/v1.12.1/README.md - node_exporter v1.12.1 source (`:9100` default, `net/http/pprof` import): https://github.com/prometheus/node_exporter/blob/v1.12.1/node_exporter.go - Exporter toolkit v0.17.1 web configuration (`tls_server_config`, `client_auth_type`, `basic_auth_users`, experimental status): https://github.com/prometheus/exporter-toolkit/blob/v0.17.1/docs/web-configuration.md - Prometheus security model (exporters, Alertmanager, Pushgateway, CORS, API Security): https://github.com/prometheus/docs/blob/a0d29881382ad1ea20597d34fc4229984b326576/docs/operating/security.md - Alertmanager v0.34.1 README (cluster listen address, TCP and UDP, load-balancing warning): https://github.com/prometheus/alertmanager/blob/v0.34.1/README.md - Alertmanager v0.34.1 HTTPS and gossip TLS (`--web.config.file`, `--cluster.tls-config`): https://github.com/prometheus/alertmanager/blob/v0.34.1/docs/https.md - Alertmanager v0.34.1 management API (`/-/reload`): https://github.com/prometheus/alertmanager/blob/v0.34.1/docs/management_api.md - Pushgateway v1.11.3 README (port 9091, TLS and basic auth scope, DELETE, admin API): https://github.com/prometheus/pushgateway/blob/v1.11.3/README.md - Pushgateway v1.11.3 source (admin and lifecycle flags, pprof route): https://github.com/prometheus/pushgateway/blob/v1.11.3/main.go - Jaeger 2.21 configuration (`JAEGER_LISTEN_HOST`, `jaeger_query` endpoints): https://github.com/jaegertracing/documentation/blob/4d150659ee4ed3ccc69253ec77c368392f59e625/content/docs/v2/2.21/deployment/configuration.md - Jaeger 2.21 security (TLS and mTLS for query clients, proxy guidance): https://github.com/jaegertracing/documentation/blob/4d150659ee4ed3ccc69253ec77c368392f59e625/content/docs/v2/2.21/deployment/security.md - Jaeger v2.21.0 all-in-one configuration: https://github.com/jaegertracing/jaeger/blob/v2.21.0/cmd/jaeger/internal/all-in-one.yaml - Jaeger v2.21.0 query defaults and MCP (`PortToHostPort`, `ai.mcp`): https://github.com/jaegertracing/jaeger/blob/v2.21.0/cmd/jaeger/internal/extension/jaegerquery/internal/flags.go - Jaeger v2.21.0 ports: https://github.com/jaegertracing/jaeger/blob/v2.21.0/ports/ports.go - Jaeger v2.21.0 Dockerfile (`ENV JAEGER_LISTEN_HOST=0.0.0.0`): https://github.com/jaegertracing/jaeger/blob/v2.21.0/cmd/jaeger/Dockerfile - Jaeger v2.21.0 release notes (v1 HTTP endpoints removed): https://github.com/jaegertracing/jaeger/releases/tag/v2.21.0 - OpenTelemetry Collector configtls v1.66.0, as pinned by Jaeger v2.21.0 (`cert_file`, `key_file`): https://github.com/open-telemetry/opentelemetry-collector/blob/cd3455cf3a7f672208140b1ebb1581c542b2b0ed/config/configtls/README.md - OpenTelemetry Collector contrib v0.160.0 basicauth extension (`htpasswd`, `authenticator`): https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.160.0/extension/basicauthextension/README.md - Loki v3.7.8 authentication (no built-in auth, tenancy header, mTLS, nginx tenant header): https://github.com/grafana/loki/blob/v3.7.8/docs/sources/operations/authentication.md - Loki v3.7.8 HTTP API (`/config`, `/flush`, `/ingester/shutdown`, deletion): https://github.com/grafana/loki/blob/v3.7.8/docs/sources/reference/loki-http-api.md - Loki v3.7.8 `auth.enabled` default and fake tenant: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L150-L162 - Loki v3.7.8 common config (`common.replication-factor` default 3): https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/common/common.go - Loki v3.7.8 config wrapper (`common.instance_addr` copied into component addresses): https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/config_wrapper.go#L162-L174 - Loki v3.7.8 config wrapper, read whole (no `Server` field; configuration changes only in `ApplyDynamicConfig` and its helpers): https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/config_wrapper.go - Loki v3.7.8 sample local configuration: https://github.com/grafana/loki/blob/v3.7.8/cmd/loki/loki-local-config.yaml#L1-L16 - The dskit pinned listener and TLS flag defaults, and the `server.register-instrumentation` default (true) are the subject of this entry. The `server.register-instrumentation` default was read in the module tree extracted from the downloaded dskit v0.0.0-20260209132809-8d1c6d34bb5a zip, whose proxy-reported origin is the commit the URL below pins, and the zip was not compared with that commit's git tree. Pinned dskit sources: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L189-L212 - Loki v3.7.8 HTTP port override: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L241-L269 - Loki v3.7.8 production Docker listener overrides: https://github.com/grafana/loki/blob/v3.7.8/production/docker/config/loki.yaml#L1-L8 - Loki v3.7.8 auth setup and gRPC exemptions: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L495-L509 - Loki v3.7.8 fake HTTP tenant propagation: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L208-L222 - Loki v3.7.8 push routes and middleware: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L399-L422 - Loki v3.7.8 query frontend middleware and routes: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1307-L1378 - Loki v3.7.8 config route: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L533-L540 - Loki v3.7.8 ingester gRPC services and maintenance routes: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L751-L790 - Loki v3.7.8 conditional deletion routes and middleware (compactor worker mode returns first) are the subject of this entry. Loki v3.7.8 sources: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1866-L1869 ; https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1880-L1883 ; https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1899-L1908 ; https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1935-L1952 - Loki v3.7.8 deletion tenant-policy middleware: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/tenant_request_handler.go#L10-L34 - Loki v3.7.8 deletion create handler: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/request_handler.go#L49-L110 - Loki v3.7.8 deletion list handler: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/request_handler.go#L134-L190 - Loki v3.7.8 deletion cancel handler: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/request_handler.go#L259-L303 - Loki v3.7.8 log entry deletion documentation (cancellation period, `deletion_mode` values): https://github.com/grafana/loki/blob/v3.7.8/docs/sources/operations/storage/logs-deletion.md - Loki v3.7.8 compactor retention marker, tables manager, delete-requests manager initialization and deletion job builder registration: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/compactor.go#L309-L347 - Loki v3.7.8 compactor delete-request store, handlers and delete-requests manager set-up, behind the retention expiration checker: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/compactor.go#L352-L393 - Loki v3.7.8 compactor expiration checker (retention first, then the delete-requests manager): https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/compactor.go#L552-L607 - Loki v3.7.8 delete-requests manager initialization (job builder marking requests processed in the main horizontal-scaling mode): https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/delete_requests_manager.go#L121-L157 - Loki v3.7.8 delete-requests manager deletion manifest loop: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/delete_requests_manager.go#L201-L288 - Loki v3.7.8 delete-requests manager request selection (cancellation period plus one minute, `filter-and-delete` only, requests beyond retention, batch size): https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/delete_requests_manager.go#L332-L457 ; https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/delete_requests_manager.go#L667-L683 - Loki v3.7.8 delete-requests manager expiration check, mark phases and marking requests processed: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/delete_requests_manager.go#L478-L584 - Loki v3.7.8 deletion job builder manifest and segment processing: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/job_builder.go#L116-L239 - Loki v3.7.8 deletion job builder jobs split at `maxChunksPerJob` chunks: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/job_builder.go#L323-L370 - Loki v3.7.8 deletion job builder job results, storage updates and manifest cleanup marking requests processed: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/job_builder.go#L372-L437 - Loki v3.7.8 deletion job runner chunk rewrite: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/job_runner.go#L54-L203 - Loki v3.7.8 deletion mode defaults: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L450-L453 - Loki v3.7.8 tenant deletion mode override: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L1128-L1132 - Loki v3.7.8 worker address selection and discovery: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L117-L182 - Loki v3.7.8 worker gRPC dial: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L222-L245 - Loki v3.7.8 worker gRPC dial options: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L297-L310 - Loki v3.7.8 frontend stream: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/frontend_processor.go#L49-L80 - Loki v3.7.8 scheduler stream: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L87-L164 - Loki v3.7.8 scheduler request handling and reply to the frontend address: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L183-L244 - Loki v3.7.8 scheduler response gRPC dial: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L246-L335 - Loki v3.7.8 frontend client pool construction: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L57 - Loki v3.7.8 ingester client dial: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/client/client.go#L65-L91 - Loki v3.7.8 ingester lifecycler construction: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L358-L365 - Loki v3.7.8 querier ingester client factory: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L58-L74 - Loki v3.7.8 querier ingester call: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L187-L220 - Loki v3.7.8 distributor ingester client factory: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L249-L253 - Loki v3.7.8 distributor ingester ring assignment: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L344 - Loki v3.7.8 distributor client and ring parameters: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L231-L236 - Loki v3.7.8 distributor push dispatch: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L900-L921 - Loki v3.7.8 distributor per-ingester push task: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L1216-L1231 - Loki v3.7.8 distributor ring selection: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L879-L893 - Loki v3.7.8 distributor ingester push: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L1258-L1271 - Loki v3.7.8 distributor ingester client pool: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L346 - Loki v3.7.8 worker connection inputs: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L571-L580 - Loki v3.7.8 worker service initializer call: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L734-L740 - Loki v3.7.8 common ring propagation to ingester: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/config_wrapper.go#L193-L248 - Loki v3.7.8 ingester clients receive ring configuration: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L373-L390 - Loki v3.7.8 ingester ring built from the lifecycler ring configuration and ring key: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L271-L272 - Loki v3.7.8 querier ingester-client construction: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1130-L1138 - Loki v3.7.8 single-binary compactor address: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1241-L1262 - Loki v3.7.8 conditional compactor gRPC client: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2532-L2561 - dskit pinned TLS YAML fields: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L69-L78 - dskit pinned wildcard listener construction: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L309-L332 - dskit pinned server TLS and client-auth construction: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L357-L395 - dskit pinned instrumentation registration gate (`if cfg.RegisterInstrumentation`), read in the module tree extracted from the downloaded dskit v0.0.0-20260209132809-8d1c6d34bb5a zip, whose proxy-reported origin is the commit this link pins; the zip was not compared with that commit's git tree: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L411-L412 - dskit pinned default gRPC interceptor chain: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L433-L468 - dskit pinned metrics and pprof routes: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L577-L583 - dskit pinned HTTP-over-gRPC bridge: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L673-L681 - dskit pinned HTTP tenant propagation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/middleware/http_auth.go#L13-L23 - dskit pinned gRPC tenant propagation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/middleware/grpc_auth.go#L36-L57 - dskit pinned tenant syntax validation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/tenant/tenant.go#L85-L99 - Loki v3.7.8 dskit dependency pin: https://github.com/grafana/loki/blob/v3.7.8/go.mod#L55 - dskit pinned transport flags (bind list, port 7946, TLS off): https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/tcp_transport.go#L41-L86 - dskit pinned listener construction (empty list becomes 0.0.0.0, invalid address rejection, shared TLS config): https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/tcp_transport.go#L133-L197 - dskit pinned advertise address and port selection: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/tcp_transport.go#L394-L451 - dskit pinned cluster-label and advertise flags: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L229-L232 - dskit pinned memberlist wiring (TCP transport, delegate and labels; no SecretKey or keyring configuration): https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L457-L520 - dskit pinned TLS fields and defaults: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/crypto/tls/tls.go#L27-L64 - dskit pinned TLS construction (RootCAs and certificates; ClientAuth and ClientCAs unset): https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/crypto/tls/tls.go#L86-L175 - Go TLS ClientAuthType (NoClientCert default and RequireAndVerifyClientCert semantics): https://pkg.go.dev/crypto/tls#ClientAuthType - dskit pinned incoming KV updates and propagation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L1383-L1470 - dskit pinned gRPC dial options: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcclient/grpcclient.go#L125-L134 - dskit pinned ring lifecycler advertised address: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L189-L229 - dskit pinned ring lifecycler registration: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L709-L723 - dskit pinned lifecycler ring entry update: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L778-L781 - dskit pinned lifecycler loop calling ring initialization: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L541-L544 - dskit pinned lifecycler ring initialization entry: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L681 - dskit pinned remote-state merging and codec validation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L1577-L1672 - Go `net.Listen` (an empty host listens on all addresses): https://pkg.go.dev/net#Listen - [Loki v3.7.8 tenant middleware, fake tenant, and gRPC exemptions (checked October 2026)](https://raw.githubusercontent.com/grafana/loki/v3.7.8/pkg/util/fakeauth/fake_auth.go). - [curl curl-8_14_1 write-out fields and version requirements (checked October 2026)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79). - Loki v3.7.8 deletion limit-to-mode call chain: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/util.go#L24-L36 - Loki v3.7.8 deletion mode names: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletionmode/mode.go#L15-L24 - Loki v3.7.8 deletion mode parsing and DeleteEnabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletionmode/mode.go#L38-L56 - Loki v3.7.8 worker service configuration and standalone predicate: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker_service.go#L18-L42 - Loki v3.7.8 worker service address fallback and constructor calls: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker_service.go#L56-L112 - Loki v3.7.8 scheduler processor frontend client configuration and factory: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L37-L59 - Loki v3.7.8 bound gRPC port replacing a configured 0: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L224-L230 - dskit pinned instance address and GetInstancePort precedence: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/util.go#L16-L39 - dskit pinned lifecycler address and port flags: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L97-L100 - dskit pinned lifecycler heartbeat: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L602-L606 - dskit pinned lifecycler ring initialization write: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L697-L725 - dskit pinned lifecycler heartbeat write: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L950-L973 - dskit pinned KV backend selection: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/client.go#L137-L176 - dskit pinned ring KV client construction: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L319-L334 - dskit pinned ring initial read and watch: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L379-L411 - dskit pinned ring replication set for a key: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L497-L544 - dskit pinned healthy replication set for an operation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L711-L792 - dskit pinned DoUntilQuorum: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/replication_set.go#L157-L316 - dskit pinned pool address factory: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/pool.go#L43-L49 - dskit pinned client pool construction and client creation: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/pool.go#L95-L165 - dskit pinned stale and unhealthy client removal: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/pool.go#L238-L318 - dskit pinned ring service discovery for client pools: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/ring_service_discovery.go#L9-L25 - dskit pinned DNS target parsing and resolution: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcutil/dns_resolver.go#L76-L171 - dskit pinned DNS SRV skip, host lookup and polling: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcutil/dns_resolver.go#L191-L268 - Loki v3.7.8 module start-up in Run: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L562-L664 - Loki v3.7.8 server and conditional internal-server module registration: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L750-L761 - Loki v3.7.8 module dependencies: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L816-L867 - Loki v3.7.8 internal-server dependency insertion: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L923-L941 - Loki v3.7.8 internal-server flags: https://github.com/grafana/loki/blob/v3.7.8/pkg/server/internal_server.go#L10-L33 - Loki v3.7.8 internal-server construction: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L245-L268 - Loki v3.7.8 server service running the server: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2646-L2664 - Loki v3.7.8 ring routes on both servers: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L276-L279 - Loki v3.7.8 internal-server signal-handler replacement: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2684-L2687 - Loki v3.7.8 module init functions, with none for the `all`, `read`, `write` and `backend` targets: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L753-L813 - Loki v3.7.8 configuration- and target-dependent module dependencies: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L869-L921 - Loki v3.7.8 analytics dependency on the ring when the ingester is active: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L952-L956 - Loki v3.7.8 ingest-limits gRPC service when enabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L452-L477 - Loki v3.7.8 ingest-limits frontend gRPC service when enabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L508-L528 - Loki v3.7.8 pattern ingester gRPC service when enabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L794-L810 - Loki v3.7.8 query frontend v1 or v2 gRPC service: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1288-L1296 - Loki v3.7.8 ruler gRPC service with the ruler API enabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1670-L1677 - Loki v3.7.8 bloom gateway gRPC service when enabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1954-L1966 - Loki v3.7.8 index gateway gRPC service: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2008-L2015 - Loki v3.7.8 bloom planner gRPC service when bloom building is enabled: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2064-L2096 - Loki v3.7.8 query scheduler gRPC services: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2144-L2151 - Loki v3.7.8 ingester querier's other `Querier` calls: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L266-L492 - Loki v3.7.8 ingester client construction (`Pusher`, `Querier` and `StreamData` clients): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ingester/client/client.go#L66-L92 - Loki v3.7.8 distributor rate store `GetStreamRates` calls to ingesters: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/ratestore.go#L288-L309 - Loki v3.7.8 pattern ring client pool: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/ring_client.go#L45-L55 - Loki v3.7.8 pattern tee `Push` calls to pattern ingesters: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/tee_service.go#L306-L317 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/tee_service.go#L399 - Loki v3.7.8 pattern querier `Query` calls: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/ingester_querier.go#L52-L59 - Loki v3.7.8 ingest-limits frontend client pool on the ingest-limits ring: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/frontend/frontend.go#L68-L85 - Loki v3.7.8 ingest-limits frontend `ExceedsLimits` and `UpdateRates` calls: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/frontend/ring.go#L144 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/frontend/ring.go#L194 - Loki v3.7.8 ingest-limits frontend `GetAssignedPartitions` call: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/frontend/ring.go#L388 - Loki v3.7.8 distributor ingest-limits-frontend client pool and ring client: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/distributor.go#L267-L278 - Loki v3.7.8 distributor `ExceedsLimits` and `UpdateRates` calls on healthy frontends: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/ingest_limits.go#L39-L64 - Loki v3.7.8 query frontend scheduler watchers (scheduler ring or DNS): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/lokifrontend/frontend/v2/frontend_scheduler_worker.go#L49-L64 - Loki v3.7.8 query frontend scheduler worker `FrontendLoop`: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/lokifrontend/frontend/v2/frontend_scheduler_worker.go#L208-L235 - Loki v3.7.8 scheduler error forwarded by `QueryResult` to the request's frontend address: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/scheduler/scheduler.go#L553-L589 - Loki v3.7.8 ruler `Rules` calls to the rulers in its ring's replication set: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ruler/base/ruler.go#L968-L1001 - Loki v3.7.8 ruler client construction: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ruler/base/client_pool.go#L82-L100 - Loki v3.7.8 compactor gRPC client (`GetDeleteRequests`, `GetCacheGenNumbers` and the job queue client): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/compactor/client/grpc.go#L42-L146 - Loki v3.7.8 cache-generation loader compactor client: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L1212-L1231 - Loki v3.7.8 compactor worker mode client at `compactor_grpc_address`: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L1821-L1864 - Loki v3.7.8 compactor job queue worker `Loop`: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/compactor/jobqueue/worker.go#L143-L148 - Loki v3.7.8 index gateway client pool (ring mode, or DNS of the configured address) and its client construction: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/indexgateway/client.go#L166-L223 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/indexgateway/client_pool.go#L25-L36 - Loki v3.7.8 bloom gateway client (DNS-discovered addresses; `PrefetchBloomBlocks` and `FilterChunkRefs`): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/bloomgateway/client.go#L124-L260 - Loki v3.7.8 bloom builder planner address, client and `BuilderLoop`: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/bloombuild/builder/builder.go#L211-L244 - Loki v3.7.8 bloom builder `NotifyBuilderShutdown` call: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/bloombuild/builder/builder.go#L132 - Loki v3.7.8 stream generator distributor `Pusher` client: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/distributor/client/client.go#L63-L98 - Loki v3.7.8 stream generator ingest-limits-frontend client pool: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/service.go#L104-L114 - Loki v3.7.8 push HTTP handlers: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/http.go#L28-L38 - Loki v3.7.8 push handler dispatch: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/http.go#L151 - Loki v3.7.8 distributor gRPC Push: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L552-L558 - Loki v3.7.8 distributor Kafka and ingester write flags: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L127-L136 - Loki v3.7.8 distributor Kafka writer construction: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L293-L308 - Loki v3.7.8 distributor Kafka dispatch: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L860-L867 - Loki v3.7.8 distributor Kafka record production: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L1296-L1326 - Loki v3.7.8 distributor push validation context time: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L589-L592 - Loki v3.7.8 distributor per-entry validation call: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L690-L695 - Loki v3.7.8 distributor duplicate-timestamp reference initialized to the first entry's timestamp: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L686 - Loki v3.7.8 distributor duplicate-timestamp increment after validation: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L749-L758 - Loki v3.7.8 validation context timestamp bounds: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/validator.go#L66-L71 - Loki v3.7.8 entry timestamp validation (old and future checks): https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/validator.go#L94-L113 - Loki v3.7.8 `reject_old_samples` default: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L327 - Loki v3.7.8 `increment_duplicate_timestamp` default: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L328 - Loki v3.7.8 `reject_old_samples_max_age` and `creation_grace_period` defaults: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L351-L354 - Loki v3.7.8 `creation_grace_period` override getter (future meaning): https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L751-L754 - Loki v3.7.8 `unordered_writes` default: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L362 - Loki v3.7.8 ingester `max_chunk_age` default: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L162 - Loki v3.7.8 ingester gRPC push to its tenant instance: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1001-L1024 - Loki v3.7.8 ingester Kafka consumer factory (the ingester as pusher): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L395 - Loki v3.7.8 Kafka consumer storing the pusher it pushes into: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/kafka_consumer.go#L53-L71 - Loki v3.7.8 Kafka consumer record push into the ingester: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/kafka_consumer.go#L150-L159 - Loki v3.7.8 distributor tee call, write count and ingester write skipped with ingester writes off: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/distributor.go#L839-L869 - Loki v3.7.8 pattern tee added to the distributor's tee when the pattern ingester is enabled: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L832-L854 - Loki v3.7.8 distributor data-object tee, when enabled, producing streams to that tee's configured topic: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/distributor.go#L310-L329 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/dataobj_tee.go#L206-L214 - Loki v3.7.8 distributor module requiring the ingester's Kafka ingestion when Kafka writes are on: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L354-L359 - Loki v3.7.8 ingester partition ID, partition-ring lifecycler and partition reader: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ingester/ingester.go#L371-L405 - Loki v3.7.8 ingester partition reader started before the ingester ring lifecycler: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ingester/ingester.go#L633-L647 - Loki v3.7.8 partition reader start offset, running phase and fetch loop: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/partition/reader_service.go#L150-L209 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/partition/reader_service.go#L349-L371 - Loki v3.7.8 Kafka reader for its partition of the configured topic: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/partition/reader.go#L122-L151 - Loki v3.7.8 Kafka reader records (tenant from the record key): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/partition/reader.go#L216-L251 - Loki v3.7.8 Kafka consumer record decode, push retries on `ErrReadOnly` and offset commit: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ingester/kafka_consumer.go#L139-L232 - Loki v3.7.8 Kafka `topic` setting (flag `kafka.topic`, empty by default) and its required check: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/config.go#L83-L87 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/config.go#L118-L124 - Loki v3.7.8 top-level `kafka_config`, validated when the ingester's Kafka ingestion is enabled: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/loki.go#L117 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/loki.go#L358-L361 - Loki v3.7.8 top-level `kafka_config` passed to the ingester: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L754 - Loki v3.7.8 Kafka writer client's default produce topic from its configuration: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/client/writer_client.go#L40-L49 - Loki v3.7.8 vendored franz-go record topic, replaced by the client default when empty or when the client sets `DefaultProduceTopicAlways`, an option nothing outside `vendor/` uses: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/vendor/github.com/twmb/franz-go/pkg/kgo/producer.go#L536-L551 - Loki v3.7.8 data-object tee topic setting, empty by default and required when the tee is enabled: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/dataobj_tee.go#L39-L54 - Loki v3.7.8 Kafka record encoding with the topic, the tenant as key and a protobuf log stream as value: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/encoding.go#L45-L58 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/encoding.go#L118-L131 - Loki v3.7.8 data-object consumer's metastore events client on the fixed topic `loki.metastore-events`: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/dataobj/consumer/service.go#L63-L74 - Loki v3.7.8 metastore event record and its field tags: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/dataobj/consumer/metastore.go#L35-L54 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/dataobj/metastore/metastore.pb.go#L180-L207 - Loki v3.7.8 ingest-limits topic setting, empty by default and required when ingest limits are enabled: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/config.go#L101-L106 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/config.go#L109-L137 - Loki v3.7.8 ingest-limits writer client and producer on that topic: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/service.go#L97-L101 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/service.go#L139-L159 - Loki v3.7.8 ingest-limits stream-metadata record production: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/limit.go#L58-L85 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/producer.go#L62-L85 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/store.go#L211-L269 - Loki v3.7.8 ingest-limits stream-metadata record field tags: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/proto/limits.pb.go#L1489-L1505 - Loki v3.7.8 stream generator Kafka settings registered under the `kafka` prefix: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/config.go#L60-L61 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/config.go#L106 - Loki v3.7.8 stream generator Kafka writer, built only in the `stream-metadata` push mode: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/service.go#L96-L102 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/kafka.go#L163-L176 - Loki v3.7.8 stream generator stream-metadata record production: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/kafka.go#L97-L114 - Loki v3.7.8 stream metadata field tags: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/proto/limits.pb.go#L1449-L1472 - Loki v3.7.8 Kafka record decoding without labels, rejecting empty data: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/encoding.go#L178-L190 - Loki v3.7.8 log stream type alias and its decoder's field wire-type checks: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/logproto/alias.go#L12 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/push/types.go#L246-L359 - Loki v3.7.8 ingester querier partition replication sets queried with minimized requests: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/querier/ingester_querier.go#L195-L205 - Loki v3.7.8 ingester instance push to streams: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L231-L261 - Loki v3.7.8 stream creation chunk format from the entries' minimum timestamp: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L316 - Loki v3.7.8 chunk format selection from the schema configuration: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L423-L440 - Loki v3.7.8 ingester stream push (validate, then store): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L186-L247 - Loki v3.7.8 ingester stream entry validation (duplicate, rate limit, too far behind): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L401-L461 - Loki v3.7.8 ingester stream store and ordered append: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L339-L384 - Loki v3.7.8 ordered head block append: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L186-L200 - Loki v3.7.8 ordered chunk append previous-block check: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L872-L874 - Loki v3.7.8 querier store and ingester flags: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L77-L81 - Loki v3.7.8 querier ingester interval: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L176-L198 - Loki v3.7.8 querier sample ingester interval: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L242-L261 - Loki v3.7.8 querier intervals built from its configuration: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L302-L304 - Loki v3.7.8 ingester querier ring and partition sets: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L168-L186 - Loki v3.7.8 ingester client pool with ring discovery: https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/clientpool/ingester_client_pool.go#L30-L46 - Loki v3.7.8 processor manager: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/processor_manager.go#L35-L89 - Loki v3.7.8 worker watchers: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L157-L196 - Loki v3.7.8 worker address removal and concurrency: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L247-L295 - Loki v3.7.8 ring watcher: https://github.com/grafana/loki/blob/v3.7.8/pkg/util/ring_watcher.go#L14-L117 - Loki v3.7.8 DNS watcher: https://github.com/grafana/loki/blob/v3.7.8/pkg/util/dns_watcher.go#L30-L83 - dskit pinned DNS default port and resolver polling frequency: https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcutil/dns_resolver.go#L18-L36 - Loki v3.7.8 ingester pool health-check flag (`health_check_ingesters` default true): https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/clientpool/ingester_client_pool.go#L26 - dskit pinned module initialization (dependencies before the module): https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/modules/modules.go#L116-L156 - Loki v3.7.8 querier ingester and store intervals: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/intervals.go#L9-L104 - Loki v3.7.8 ingester store lookback copied to the querier: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L540-L542 - Loki v3.7.8 ingester store lookback flag (`-1` for infinite): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L163 - Loki v3.7.8 querier store query skipped without a store interval: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L200-L212 - Loki v3.7.8 querier sample store query skipped without a store interval: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L263-L272 - Loki v3.7.8 `-1` store lookback returning no store interval: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/intervals.go#L23-L25 - Loki v3.7.8 store module shipper setup for an object-storage index: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L910-L918 - Loki v3.7.8 all-target shipper store setup applying the store lookback: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1060-L1112 - Loki v3.7.8 store lookback check (0 becomes the minimum; nonzero rejected without the `filesystem` object store): https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2573-L2588 - Loki v3.7.8 shipper delays, minimum store lookback and resync interval: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2607-L2639 - Loki v3.7.8 boltdb shard period (15 minutes): https://github.com/grafana/loki/blob/v3.7.8/pkg/storage/stores/shipper/indexshipper/boltdb/table.go#L27 - Loki v3.7.8 index upload interval (1 minute): https://github.com/grafana/loki/blob/v3.7.8/pkg/storage/stores/shipper/indexshipper/shipper.go#L45 - Loki v3.7.8 active schema period and object-storage index check: https://github.com/grafana/loki/blob/v3.7.8/pkg/storage/config/schema_config.go#L345-L378 - Loki v3.7.8 store and querier module registration: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L770-L771 - Loki v3.7.8 module dependency registration: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L943-L947 - Loki v3.7.8 target-list membership check (`isTarget`): https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L387-L389 - Loki v3.7.8 ingester log query merging a store query: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1102-L1119 - Loki v3.7.8 ingester sample query merging a store query: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1175-L1191 - Loki v3.7.8 ingester store query range (`QueryStore` gate; `-1` leaves the start unchanged): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1640-L1660 - Loki v3.7.8 querier log request validation: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L158-L169 - Loki v3.7.8 querier sample request validation: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L224-L235 - Loki v3.7.8 ingester querier log and sample calls: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L234-L264 - Loki v3.7.8 ingester registered as the gRPC Querier service: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L768-L773 - Loki v3.7.8 generated Querier `Query` and `QuerySample` handlers: https://github.com/grafana/loki/blob/v3.7.8/pkg/logproto/logproto.pb.go#L6166-L6193 - Loki v3.7.8 ingester log query of its tenant instance: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1093-L1100 - Loki v3.7.8 ingester sample query of its tenant instance: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1160-L1168 - Loki v3.7.8 ingester instance log query over matching streams: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L519-L532 - Loki v3.7.8 ingester instance sample query over matching streams: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L587-L610 - Loki v3.7.8 ingester index lookup at the request start: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L962-L975 - Loki v3.7.8 ingester stream chunk skip for log queries: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L588-L594 - Loki v3.7.8 ingester stream chunk skip for sample queries: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L635-L641 - Loki v3.7.8 ingester instance index construction (`NewMultiInvertedIndex`): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L152-L163 - Loki v3.7.8 ingester stream creation adding the stream to the index: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L312-L314 - Loki v3.7.8 ingester stream creation by fingerprint adding the stream to the index: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L404-L405 - Loki v3.7.8 ingester per-period index construction and add to every built index: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/multi.go#L26-L73 - Loki v3.7.8 ingester per-period index lookup and period selection: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/multi.go#L84-L117 - Loki v3.7.8 inverted index lookup by matchers and shard: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/index.go#L159-L183 - Loki v3.7.8 bit-prefix index lookup by matchers and shard: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/bitprefix.go#L131-L169 - Loki v3.7.8 ingester stream log chunk iterator call: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L601-L607 - Loki v3.7.8 ingester stream sample chunk iterator call: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L648-L650 - Loki v3.7.8 chunk log iterator (block skip, head block, time-range filters): https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1005-L1087 - Loki v3.7.8 chunk sample iterator (block skip, head block, time-range filter): https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1090-L1147 - Loki v3.7.8 ordered head block log iterator bounds and entry filter: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1310-L1328 - Loki v3.7.8 ordered head block sample iterator bounds: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1380-L1397 - Loki v3.7.8 unordered head block range-tree interval and query: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/unordered.go#L185-L210 - Loki v3.7.8 unordered head block construction with a one-dimension range tree: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/unordered.go#L69-L75 - Loki v3.7.8 unordered head block log iterator over the range-tree query: https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/unordered.go#L260-L310 - Loki v3.7.8 go-datastructures v1.1.7 requirement: https://github.com/grafana/loki/blob/v3.7.8/go.mod#L19 - Loki v3.7.8 vendored go-datastructures range-tree inclusive-interval contract: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/interface.go#L38-L47 - Loki v3.7.8 vendored go-datastructures range-tree constructor: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/orderedtree.go#L259-L263 - Loki v3.7.8 vendored go-datastructures range-tree apply and Query: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/orderedtree.go#L180-L225 - Loki v3.7.8 vendored go-datastructures range-tree node search: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/ordered.go#L25-L30 - Loki v3.7.8 vendored go-datastructures range-tree bounded scan: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/ordered.go#L81-L98 - Loki v3.7.8 vendored go-datastructures range-tree entries pool and `NewEntries`: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/entries.go#L21-L45 - Loki v3.7.8 entry time-range filter: https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/entry_iterator.go#L478-L509 - Loki v3.7.8 sample time-range filter: https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/sample_iterator.go#L681-L716 - Loki v3.7.8 ingester log batch send: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L1104-L1143 - Loki v3.7.8 ingester sample batch send: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L1145-L1179 - Loki v3.7.8 vendored grpc-go server stream `SendMsg` (no context check; `toRPCErr` of a transport `Write` error): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/stream.go#L1726-L1807 - Loki v3.7.8 vendored grpc-go `prepareMsg` (encoding and compression errors returned as is): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/stream.go#L1902-L1919 - Loki v3.7.8 generated Querier `Query` and `QuerySample` server-stream `Send` methods (return `SendMsg`'s result): https://github.com/grafana/loki/blob/v3.7.8/pkg/logproto/logproto.pb.go#L6179-L6206 - Loki v3.7.8 vendored grpc-go stream interceptor chaining: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1575-L1588 - Loki v3.7.8 vendored grpc-go `processStreamingRPC` (server stream passed through the interceptor chain): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1590-L1779 - Loki v3.7.8 vendored grpc-go `AddGlobalServerOptions` hook (appends global server options): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L80-L82 - Loki v3.7.8 vendored grpc-go `NewServer` (global server options applied before the caller's): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L704-L711 - Loki v3.7.8 vendored grpc-go `StreamInterceptor` and `ChainStreamInterceptor` server options: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L504-L523 - Loki v3.7.8 vendored dskit server stream interceptors (logging, OpenTracing with a global tracer, instrumentation, configured ones, then the in-flight limiter when `GrpcMethodLimiter` is set, chained with `grpc.ChainStreamInterceptor`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L455-L493 - Loki v3.7.8 vendored dskit `GrpcMethodLimiter` field (`yaml:"-"`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L170 - Loki v3.7.8 vendored dskit `GRPCOptions` field (`yaml:"-"`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L124 - Loki v3.7.8 vendored dskit server options (`GRPCOptions` appended, then any TLS credentials, before `grpc.NewServer`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L529-L534 - Loki v3.7.8 vendored dskit logging stream interceptor (stream and error passed through): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_logging.go#L84-L103 - Loki v3.7.8 vendored dskit instrumentation stream interceptor (stream and error passed through): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_instrumentation.go#L57-L66 - Loki v3.7.8 vendored dskit user-header stream wrapper (replaces only `Context`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_auth.go#L46-L66 - Loki v3.7.8 vendored dskit in-flight limiter stream interceptor (stream and error passed through): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/limits.go#L140-L150 - Loki v3.7.8 auth and gRPC recovery middleware setup: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L486-L514 - Loki v3.7.8 query-limits and ingester stream interceptors: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2192-L2217 - Loki v3.7.8 vendored grpc-go server stream deadline timer: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L601-L616 - Loki v3.7.8 vendored grpc-go server stream reset handling: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L837-L842 - Loki v3.7.8 vendored grpc-go `streamContextErr` and server header write: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1010-L1047 - Loki v3.7.8 vendored grpc-go server transport `write` (state checks, no context check): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1157-L1188 - Loki v3.7.8 vendored grpc-go `ServerStream.Write` (returns its transport's `write`): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/server_stream.go#L69-L72 - Loki v3.7.8 vendored grpc-go server stream construction (the HTTP/2 server transport set as its transport): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L402-L407 - Loki v3.7.8 vendored grpc-go server stream context and cancel function, created with the stream: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L535-L539 - Loki v3.7.8 vendored grpc-go server transport `Close` (streams cancelled, not marked done): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1290-L1313 - Loki v3.7.8 vendored grpc-go `finishStream` and `closeStream` (context cancelled, then stream marked done): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1337-L1379 - Loki v3.7.8 vendored grpc-go `handleStream` (stream context passed through any stats handler's `TagRPC`, then set with `SetContext`): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1800-L1853 - Loki v3.7.8 vendored grpc-go `ServerStream.SetContext` (replaces the stream's context): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/server_stream.go#L121-L125 - Loki v3.7.8 vendored grpc-go `ServeHTTP` (a separate transport on Go's own HTTP/2 server): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1114-L1124 - Loki v3.7.8 main and internal servers built by dskit's server constructors: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L188 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L248 - Loki v3.7.8 vendored dskit `New` and `NewWithMetrics` (both call `newServer`): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/vendor/github.com/grafana/dskit/server/server.go#L284-L295 - Loki v3.7.8 vendored dskit `grpc.NewServer` call and HTTP server whose handler wraps only the HTTP router: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/vendor/github.com/grafana/dskit/server/server.go#L534-L547 - Loki v3.7.8 vendored dskit `Run` (HTTP-over-gRPC registration, then `Serve` on the gRPC listener): https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/vendor/github.com/grafana/dskit/server/server.go#L678-L683 - Loki v3.7.8 main HTTP server handler rewrapped, with cleartext HTTP/2 enabled on that server: https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/loki/modules.go#L209-L222 ; https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/util/server/unencrypted_http2.go#L6-L14 - Loki v3.7.8 vendored grpc-go `ErrConnClosing`: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/transport.go#L818-L820 - Loki v3.7.8 vendored grpc-go `ContextErr`: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/transport.go#L863-L872 - Loki v3.7.8 vendored grpc-go `toRPCErr`: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/rpc_util.go#L1129-L1157 - Loki v3.7.8 vendored grpc-go `status.FromError`: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/status/status.go#L96-L127 - Loki v3.7.8 vendored grpc-go status `Error` type: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/status/status.go#L205-L228 - Loki v3.7.8 vendored grpc-go write quota (`init` keeps the done channel it is given; `get` waits for quota or that channel): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/flowcontrol.go#L44-L67 - Loki v3.7.8 vendored grpc-go server stream write quota, initialized with the done channel of the context the transport created: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L623-L624 - Loki v3.7.8 vendored grpc-go control buffer `put` and `executeAndPut` (`ErrConnClosing` only once the buffer is closed): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/controlbuf.go#L347-L398 - Loki v3.7.8 vendored grpc-go control buffer `finish` (closes the buffer): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/controlbuf.go#L451-L483 - Loki v3.7.8 vendored grpc-go loopy writer `run`, which finishes the control buffer when it exits, and the server transport goroutine that runs it: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/controlbuf.go#L584-L593 ; https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L348-L350 - Loki v3.7.8 vendored grpc-go `NewServer` stats handler (`istats.NewCombinedHandler` of the configured handlers): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L712-L721 - Loki v3.7.8 vendored dskit server stats handlers (otelgrpc's server handler always; the in-flight limiter when `GrpcMethodLimiter` is set; dskit's stats handler when configured): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L483-L485 ; https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L491-L523 - Loki v3.7.8 vendored otelgrpc server handler `TagRPC` (a `context.WithValue` child of the extracted, span-started context): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/stats_handler.go#L98-L158 - Loki v3.7.8 vendored dskit in-flight limiter `TagRPC` (returns its input): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/limits.go#L151-L153 - Loki v3.7.8 vendored dskit stats handler `TagRPC` (a `context.WithValue` child): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_stats.go#L56-L58 - Loki v3.7.8 vendored grpc-go `NewCombinedHandler` and combined `TagRPC` (each handler's `TagRPC` chained in order): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/stats/stats.go#L35-L51 - Loki v3.7.8 vendored grpc-go server tap handle (its context becomes the stream's context): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L572-L574 - Loki v3.7.8 vendored dskit in-flight limiter tap handle (a `context.WithValue` child of what `RPCCallStarting` returns): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/limits.go#L63-L88 - Loki v3.7.8 vendored otelgrpc propagator extraction from incoming metadata: https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/metadata_supplier.go#L51-L60 - Loki v3.7.8 vendored OpenTelemetry global propagator (forwards to its delegate or an empty composite): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/propagator.go#L27-L66 - Loki v3.7.8 vendored OpenTelemetry composite propagator `Extract` (members chained in order): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/propagation/propagation.go#L136-L141 - Loki v3.7.8 vendored OpenTelemetry TraceContext `Extract` (input or a remote span context child): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/propagation/trace_context.go#L72-L78 - Loki v3.7.8 vendored OpenTelemetry Baggage `Extract` (input or a baggage child): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/propagation/baggage.go#L50-L78 - Loki v3.7.8 vendored OpenTelemetry `baggage.ContextWithBaggage`: https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/baggage/context.go#L13-L16 - Loki v3.7.8 vendored OpenTelemetry internal `ContextWithList` (a `context.WithValue` child, then any set hook): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/baggage/context.go#L55-L68 - Loki v3.7.8 vendored contrib Jaeger propagator `Extract` and header parsing: https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/propagators/jaeger/jaeger_propagator.go#L74-L151 - Loki v3.7.8 vendored contrib Jaeger `withDebug` (a `context.WithValue` child): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/propagators/jaeger/context.go#L15-L17 - Loki v3.7.8 vendored dskit `JaegerDebuggingPropagator.Extract` (input or a `context.WithValue` child): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel_jaeger.go#L330-L338 - Loki v3.7.8 vendored OpenTelemetry `ContextWithSpan` and `ContextWithRemoteSpanContext` (`context.WithValue` children): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/trace/context.go#L13-L31 - Loki v3.7.8 vendored OpenTelemetry global tracer `Start` (delegate, auto-instrumentation path or a span child): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/trace.go#L144-L188 - Loki v3.7.8 vendored OpenTelemetry SDK tracer `Start`: https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/sdk/trace/tracer.go#L32-L78 - Loki v3.7.8 vendored OpenTelemetry SDK span `runtimeTrace` (`runtime/trace.NewTask` when enabled): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/sdk/trace/span.go#L970-L982 - Loki v3.7.8 vendored OpenTelemetry noop tracer `Start`: https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/trace/noop.go#L40-L47 - Loki v3.7.8 vendored OpenTelemetry `noop.Tracer.Start`: https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/trace/noop/noop.go#L54-L73 - Loki v3.7.8 vendored OpenTelemetry global tracer delegation (`SetTracerProvider` makes the installed provider the default provider's delegate, and each of its tracers takes that provider's tracer): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/state.go#L70-L91 ; https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/trace.go#L59-L74 ; https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/trace.go#L138-L139 - Loki v3.7.8 vendored grpc-go server stream metadata context (`metadata.NewIncomingContext` when the request carries metadata): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L541-L544 - Loki v3.7.8 vendored grpc-go `metadata.NewIncomingContext` (a `context.WithValue` child): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/metadata/metadata.go#L164-L166 - Loki v3.7.8 vendored grpc-go `contextWithServer` (a `context.WithValue` child): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L2053-L2055 - Loki v3.7.8 vendored grpc-go `EnableTracing` package variable (a `bool`, so `false` unless code assigns it): https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/trace.go#L31-L33 - Loki v3.7.8 vendored grpc-go `handleStream` `x/net/trace` context, added only when `EnableTracing` is true: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1809-L1823 - Loki v3.7.8 vendored grpc-go `NewServer` and client `newAttemptLocked` reads of `EnableTracing`: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L725-L728 ; https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/stream.go#L482-L495 - Loki v3.7.8 vendored grpc-go `GRPC_GO_SERVER_GOROUTINE_LABELS` setting, read from the environment: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/envconfig/envconfig.go#L62-L69 - Loki v3.7.8 vendored grpc-go `handleStream` `pprof.WithLabels` branch, taken only when that setting's server-method bit is set: https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1803-L1808 - Loki v3.7.8 vendored dskit `GRPCTapHandles` field (`yaml:"-"`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L125 - Loki v3.7.8 vendored dskit server tap handles (`GRPCTapHandles`, then the in-flight limiter's when `GrpcMethodLimiter` is set, composed with `grpcutil.ComposeTapHandles`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L503-L511 - Loki v3.7.8 vendored dskit `grpcutil.ComposeTapHandles` (a lone handle as is, otherwise each in order): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/grpcutil/taphandle.go#L10-L25 - Loki v3.7.8 vendored otelgrpc default propagator and tracer provider (OpenTelemetry's global ones): https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/config.go#L78-L79 - Loki v3.7.8 `main` tracing setup (`NewOTelOrJaegerFromEnv` with no option when tracing is enabled): https://github.com/grafana/loki/blob/v3.7.8/cmd/loki/main.go#L113-L127 - Loki v3.7.8 `tracing.enabled` flag (default true): https://github.com/grafana/loki/blob/v3.7.8/pkg/tracing/config.go#L11-L13 - Loki v3.7.8 `Tracing` configuration field and its flag registration: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L126 ; https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L226 - Loki v3.7.8 vendored dskit `NewOTelOrJaegerFromEnv` (Jaeger path, OpenTelemetry path or no tracing, by environment variable): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel_jaeger.go#L47-L69 - Loki v3.7.8 vendored dskit Jaeger-path tracer provider and propagator installation (`otelpyroscope` wrapper unless disabled): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel_jaeger.go#L296-L307 - Loki v3.7.8 vendored dskit `NewOTelFromEnv` (OpenTelemetry-path tracer provider and propagator installation, `otelpyroscope` wrapper unless disabled): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel.go#L37-L83 - Loki v3.7.8 vendored dskit `OTelPropagatorsFromEnv` (`OTEL_PROPAGATORS`, defaulting to TraceContext, Baggage, Jaeger and Jaeger debugging): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel.go#L277-L306 - Loki v3.7.8 recovery stream interceptor (built by the vendored go-grpc-middleware recovery package): https://github.com/grafana/loki/blob/v3.7.8/pkg/util/server/recovery.go#L39 - Loki v3.7.8 vendored go-grpc-middleware recovery `StreamServerInterceptor` (original stream passed to the handler; its error returned, replaced only after a panic) and `recoverFrom`: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery/interceptors.go#L39-L59 - Loki v3.7.8 vendored go-grpc-middleware recovery `WithRecoveryHandler` (returns the handler's result): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery/options.go#L31-L37 - Loki v3.7.8 query-tags stream interceptor and its `Context`-only wrapper: https://github.com/grafana/loki/blob/v3.7.8/pkg/util/server/grpc_query_tags.go#L66-L81 - Loki v3.7.8 HTTP-header stream interceptor: https://github.com/grafana/loki/blob/v3.7.8/pkg/util/server/grpc_headers.go#L56-L61 - Loki v3.7.8 ingestion-policy stream interceptor and its `Context`-only wrapper: https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/ingestion_policies.go#L256-L312 - Loki v3.7.8 query-limits stream interceptor and its `Context`-only wrapper (a metadata parse error ends the call): https://github.com/grafana/loki/blob/v3.7.8/pkg/util/querylimits/grpc.go#L63-L122 - Loki v3.7.8 `fakeauth.SetupAuthMiddleware` stream interceptors and the fake-tenant wrapper: https://github.com/grafana/loki/blob/v3.7.8/pkg/util/fakeauth/fake_auth.go#L15-L78 - Loki v3.7.8 vendored OpenTracing stream server interceptor and its `Context`-only wrapper: https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/opentracing-contrib/go-grpc/server.go#L91-L135 - Loki v3.7.8 vendored OpenTracing interceptor options (`LogPayloads`, `IncludingSpans` and `SpanDecorator`): https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/opentracing-contrib/go-grpc/options.go#L14-L80 - Loki v3.7.8 ingester log query batch limit and send call: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1121-L1129 - Loki v3.7.8 ingester sample query send call: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1193-L1195 - Loki v3.7.8 log batch read: https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/entry_iterator.go#L681-L716 - Loki v3.7.8 sample batch read: https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/sample_iterator.go#L718-L753 - Loki v3.7.8 ingester querier log stream iterator: https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/entry_iterator.go#L362-L388 - Loki v3.7.8 ingester querier sample stream iterator: https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/sample_iterator.go#L480-L505 - Loki v3.7.8 querier log iterator merge: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L213-L216 - Loki v3.7.8 querier sample iterator merge: https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L274 - Loki v3.7.8 ingester `Wrapper` field (`yaml:"-"`): https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L126-L127 - Loki v3.7.8 ingester `Wrapper` interface: https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L210-L212 - Loki v3.7.8 internal-server memberlist route: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1814-L1816 - Loki v3.7.8 compactor handler prefix on both servers: https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1928-L1933 - [curl curl-8_14_1 custom headers (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md). - [curl curl-8_14_1 configuration from stdin (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md). - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - node: node_exporter (v1.12.1) - alert: Alertmanager (v0.34.1) - push: Pushgateway (v1.11.3) - jaeger: Jaeger (v2.21.0) - loki: Loki (v3.7.8) - jaeger-docs: Jaeger documentation (4d150659ee4ed3ccc69253ec77c368392f59e625) - toolkit: Exporter toolkit (v0.17.1) - security: Prometheus security model (a0d29881382ad1ea20597d34fc4229984b326576) - tls: OpenTelemetry configtls (v1.66.0) - basic: OpenTelemetry basicauth extension (v0.160.0) - dskit: dskit (8d1c6d34bb5a42b04caa982d68403c5a643bb742) - go: Go documentation (unknown) - curl: curl (curl-8_14_1) Claims (full source mapping in the JSON block below): - node-bind (REASONED; node, go): node_exporter defaults to :9100 on all interfaces; private binds are required. - node-open (DEMONSTRATED; node): Unconfigured loopback metrics and pprof both returned 200 without credentials. - toolkit-config (REASONED; toolkit, node, alert, push): --web.config.file enables TLS and bcrypt Basic auth across HTTP paths; the format is experimental and not every exporter uses it. - node-tls (DEMONSTRATED; node, toolkit): Loopback HTTP returned 400; HTTPS metrics/pprof rejected absent credentials and accepted correct ones. - toolkit-mtls (REASONED; toolkit): Use RequireAndVerifyClientCert with client_ca_file for verified clients; other client_auth_type values are called insecure by the toolkit. - toolkit-reload (REASONED; toolkit): The toolkit rereads web configuration per request; password/certificate changes apply without restart. - toolkit-clients (REASONED; toolkit, alert, push): Basic auth suits a few users; supply matching credentials/CA to scrapers, pushers and Alertmanager clients, or use client certificates/proxy login. - alert-bind (REASONED; alert, go): Alertmanager HTTP defaults :9093; HA gossip defaults 0.0.0.0:9094 and needs both TCP and UDP. - alert-api (DEMONSTRATED; alert, security): Loopback anonymous status, silence creation, reload and pprof succeeded; status carried wildcard CORS. - alert-auth (DEMONSTRATED; alert, toolkit): Web configuration rejected anonymous silence creation and absent/wrong status passwords; valid status credentials succeeded. - alert-gossip-off (DEMONSTRATED; alert): An empty --cluster.listen-address disabled both gossip socket protocols in the loopback run. - alert-gossip-tls (REASONED; alert): HA gossip is plaintext without experimental --cluster.tls-config; restrict it to peers and configure server/client TLS sections. - alert-proxy (REASONED; security, alert): Proxy mutating routes/CORS to reduce CSRF; send Prometheus traffic to every Alertmanager rather than load-balancing it. - push-bind (REASONED; push, security): Pushgateway defaults :9091; reachable users can forge trusted series, particularly with honor_labels. - push-write (DEMONSTRATED; push): Anonymous loopback POST wrote a series visible on metrics; an anonymous DELETE of the group returned 202. - push-pprof (DEMONSTRATED; push): Anonymous loopback GET /debug/pprof/ returned 200. - push-pprof-registration (REASONED; push): The pinned source registers /debug/pprof/ outside every flag check. - push-admin (DEMONSTRATED; push): Admin wipe defaults off; loopback PUT /api/v1/admin/wipe returned 404 without --web.enable-admin-api. - push-lifecycle (REASONED; push): Lifecycle shutdown defaults off; leave --web.enable-lifecycle and the admin API disabled. - push-auth (DEMONSTRATED; push, toolkit): With web configuration, anonymous push returned 401 and authenticated push 200. - push-auth-scope (REASONED; push): The README says web configuration affects all HTTP endpoints: metrics, push API, admin API and web UI. - jaeger-config (REASONED; jaeger, jaeger-docs): No --config selects in-memory all-in-one; receiver/diagnostic hosts use JAEGER_LISTEN_HOST with localhost fallback. - jaeger-receivers (REASONED; jaeger): All-in-one maps OTLP 4317/4318, Jaeger 14250/14268 and UDP 6831/6832, Zipkin 9411 and sampling 5778/5779. - jaeger-diagnostics (REASONED; jaeger): Health 13133, expvar 27777, zpages 27778 and metrics 8888 follow JAEGER_LISTEN_HOST. - jaeger-loopback (DEMONSTRATED; jaeger): The overridden loopback run showed every listed receiver/diagnostic listener, including UDP, on 127.0.0.1. - jaeger-image (REASONED; jaeger): The official image sets JAEGER_LISTEN_HOST=0.0.0.0, exposing these listeners to its interfaces and published ports. - jaeger-query (REASONED; jaeger, jaeger-docs): Query HTTP 16686 and gRPC 16685 default wildcard independently of JAEGER_LISTEN_HOST; bind both explicitly. - jaeger-mcp (REASONED; jaeger): All-in-one enables ai.mcp on the query HTTP port at /api/ai/mcp/; remove mcp from ai when unused. - jaeger-open (DEMONSTRATED; jaeger): Anonymous loopback v3 services, UI and MCP initialize returned 200; removed v1 services returned 404, not proof of protection. - jaeger-basic (DEMONSTRATED; jaeger, basic): Query basicauth/server with htpasswd rejected absent/wrong credentials on services, UI and MCP; correct credentials returned 200. - jaeger-tls (DEMONSTRATED; jaeger, tls): Query HTTP TLS cert_file/key_file gave HTTPS 401/200 and plaintext 400; inferred wiring was tested locally and needs upgrade retesting. - jaeger-grpc (REASONED; jaeger, jaeger-docs): Query gRPC auth exists but was not demonstrated; restrict it, authenticate remote collectors separately and front human UI access. - loki-listeners (REASONED; loki, dskit, go): From source reading and Go's net.Listen documentation, empty TCP listen addresses yield wildcard HTTP 3100 and gRPC 9095; IPv4/IPv6 depends on Go/OS support. - loki-samples (REASONED; loki): Local sample uses gRPC 9096/auth_enabled false; production Docker config uses wildcard 3100/9095/auth_enabled true. - memberlist-bind (REASONED; loki, dskit): With the memberlist store, TCP gossip defaults 0.0.0.0:7946; an empty list selects wildcard and an empty list entry is invalid. - memberlist-label (REASONED; dskit): Cluster labels reject mismatches unless verification is disabled, but matching labels are not credentials; no SecretKey/keyring setting is wired. - memberlist-updates (REASONED; dskit): Without TLS, reachable matching-label peers can submit KV/ring state subject to codecs/merge rules; disruption is inferred, not demonstrated. - memberlist-tls (REASONED; dskit, go): TLS defaults off; enabled transport verifies outgoing servers but leaves incoming ClientAuth at NoClientCert, so it is not mutual peer authentication. - memberlist-policy (REASONED; dskit): Keep tls-insecure-skip-verify false, set expected server name as needed, bind privately and restrict trusted peers even with TLS. - memberlist-advertise (REASONED; dskit): Advertise address/port affect discovery, not bind; absent overrides derive an address from the first bind and actual port. - loki-tenants (REASONED; loki, dskit): From source reading, auth_enabled defaults true and requires tenant headers, not credentials; false uses fake, and tenant validation checks syntax/length only. - loki-grpc-auth (REASONED; dskit, loki): From source reading, default dskit interceptors lack credentials; Loki has tenant exemptions; complete interceptor behaviour was not re-traced. - loki-push-query (REASONED; loki): From source reading, reachable clients can push/query without credentials subject to tenant headers and ingest/query policies. - loki-config (REASONED; loki): From source reading, GET /config exposes configuration without a tenant-auth wrapper. - loki-diagnostics (REASONED; dskit): From source reading, metrics and pprof routes are registered by default without a tenant-auth wrapper. - loki-maintenance (REASONED; loki): From source reading, ingester flush/shutdown accept GET/POST; prepare_shutdown accepts POST/GET/DELETE, without tenant-auth wrappers. - loki-delete (REASONED; loki): Non-worker compactor deletion routes require a supported shipper index, retention and a delete-request store; tenant and deletion-policy middleware gate create/list/cancel. After tenant validation, filter-only and filter-and-delete pass, disabled gets 403 and an unrecognized mode gets 400, by source reasoning, not observed responses; admission does not establish eventual deletion. - loki-delete-mode (REASONED; loki): In v3.7.8, validDeletionLimit parses the tenant's DeletionMode; only the exact strings disabled, filter-only and filter-and-delete parse, and DeleteEnabled is true for the latter two. TenantMiddleware returns 400 for tenant or parsing errors, 403 for disabled, otherwise invokes the handler; this is source reasoning, not observed responses. - loki-grpc-bridge (REASONED; loki, dskit): From source reading, single-binary ingester RPC and HTTP-over-gRPC expose paths that HTTP-only TLS/proxying does not protect. - loki-mtls (REASONED; dskit): From source reading, HTTP/gRPC TLS defaults empty and requires a cert/key pair; configure client CA plus RequireAndVerifyClientCert, not CA alone. - loki-ring (REASONED; loki): This guide's single-binary overlay uses inmemory ring, loopback instance address and replication_factor 1 instead of default 3 (from source reading); check no gossip listener remains. - loki-internal (REASONED; loki): From source reading, querier worker init gets server gRPC listen address/port, target flags, scheduler ring and worker config; ingester lifecycler listen port is the gRPC listen port. - loki-internal-dials (REASONED; loki, dskit): Worker and distributor/querier ingester dial sites are traced by source reading; loopback gRPC is this guide's configuration-specific advice; verify advertised/dial addresses, internal worker/ingester success, any compactor calls and live reachability. - loki-worker-init (REASONED; loki): From source reading, with frontend, scheduler, read and all targets off, InitWorkerService returns no worker if the scheduler ring and both addresses are unset. Otherwise it passes worker config and ring to NewQuerierWorker; outside that standalone case, unset ring/addresses trigger a frontend address using the gRPC port and a nonempty listen address, else 127.0.0.1. - loki-worker-dials (REASONED; loki): From source reading, workers prefer scheduler ring, scheduler address, then frontend address; AddressAdded calls grpc.DialContext with that client's options, handing it to a processor manager. Frontend Process and scheduler QuerierLoop open on a supplied connection; scheduler results go to the message's FrontendAddress via grpc.Dial with frontend options; success unverified. - loki-instance-port (REASONED; loki, dskit): From source reading, GetInstancePort returns a positive configured lifecycler port, else the listen port, which Loki sets to the server gRPC listen port or the bound port when that is 0; a configured common ring copies its instance_port into the lifecycler port. The advertised address is the configured address, else one chosen from the configured interfaces. - loki-ring-kv (REASONED; dskit): From source reading, lifecyclers CAS their entry, with the advertised address, into the ring key at initialization and on heartbeat; KV backends are consul, etcd, process-wide inmemory, memberlist or multi, plus test-only mock. - loki-ring-reads (REASONED; dskit, loki): From source reading, ring clients read then watch the ring key; distributors take per-stream write sets and queriers, outside partition-ingester queries, the healthy read set via ring.DoUntilQuorum. - loki-client-pools (REASONED; loki, dskit): From source reading, dskit pools cache one client per address, created on first use. With health checks on (frontend pool; ingester pools by default), one failed check removes a client, as Loki sets no grace period. - loki-client-pools-discovery (REASONED; loki, dskit): From source reading, ingester pools drop addresses missing from healthy ring entries; the frontend pool has no discovery and checks every 5s. Pooling does not establish effective addresses or successful calls. - loki-worker-watchers (REASONED; loki, dskit): From source reading, ring watchers take the scheduler ring's key-0 Write set each lookup period and reconcile only non-empty results; DNS watchers poll at that period with no SRV name, report an IP target once and re-resolve host names, with dskit's default port 443 if none is given. - loki-worker-managers (REASONED; loki): From source reading, addresses that the worker's ring or DNS watchers add get processor managers sharing max concurrency, at least one each; removal notifies the peer and closes the connection. - loki-request-callers (REASONED; loki): From source reading, push HTTP handlers call PushWithResolver; outside all, write and ingester targets the distributor also registers gRPC Pusher. Kafka writes default off, ingester writes on; Kafka writes need a partition ring and produce to an active partition. - loki-ingester-query-window (REASONED; loki): From source reading, without query_store_only, Loki queriers query ingesters for selections ending within the ingester store lookback if it is nonzero when the querier module initializes, else query_ingesters_within (0: all); a -1 lookback, documented as infinite, sends every selection to ingesters and none directly to the store. - loki-ingester-lookback-all (REASONED; loki, dskit): From source reading, when Loki's target list includes all but none of querier, ruler, read, index-gateway or backend, and the active or next schema period uses a boltdb-shipper or TSDB index, the store module turns a configured 0 ingester store lookback into a computed minimum that replaces query_ingesters_within. Per that reading, without all, 0 stays 0. - loki-ingester-lookback-minimum (REASONED; loki): From source reading, Loki's computed minimum ingester store lookback is the ingester's maximum chunk age plus 21 minutes (a 15-minute boltdb shard period, 1-minute index upload interval and 5-minute buffer), the index cache validity and three resync intervals of the active or next period's shipper index. - loki-ingester-lookback-store (REASONED; loki): From source reading, Loki's query_store_max_look_back_period flag help limits it to boltdb-shipper/TSDB indexes with the filesystem object store; the all target's boltdb-shipper/TSDB store setup rejects a nonzero value with any other object store. - loki-ingester-store-all (REASONED; loki, dskit): From source reading, when Loki's target list includes all but none of querier, ruler, read, index-gateway or backend, and the active or next schema period uses a boltdb-shipper or TSDB index, the ingester also queries the store for the log and sample selections it serves unless the lookback-adjusted start is after the selection end. - loki-ingester-store-range (REASONED; loki): From source reading, when a Loki ingester queries the store for a log or sample selection, it moves the start of the store request up to now minus a positive store lookback, leaves it unchanged for -1 and other negative values, and skips the store query when that adjusted start is after the selection end. - loki-ingester-lookback-bounds (REASONED; loki): From source reading, when Loki's target list includes all but none of querier, ruler, read, index-gateway or backend, its boltdb-shipper or TSDB store setup with the filesystem object store rejects a positive query_store_max_look_back_period below the computed minimum. Per that reading, the same setup passes -1 and any other negative value through calculateMaxLookBack unchanged. - loki-querier-store-window (REASONED; loki): From source reading, when the ingester store lookback that the Loki querier module copies at initialization is positive, the querier limits its own store query to the part of a selection before now minus that lookback, querying only ingesters when the selection starts after that point. Per that reading, a copy of exactly -1 instead skips the store and sends the whole selection to ingesters. - loki-querier-store-negative (REASONED; loki): From source reading, when the ingester store lookback the Loki querier module copies at initialization is negative but not -1, now minus that lookback lies in the future, and the querier sets the end of any store request to that point without clamping it to the selection end. Per that reading, the Loki ingester, by contrast, leaves its store request start unchanged for every negative lookback. - loki-querier-ingester-inverted (REASONED; loki): From source reading, if a Loki selection passing request validation reaches interval construction and the querier's initialization copy of the ingester store lookback is negative but not -1 with now minus it past the selection end, the querier starts its ingester interval there, ends it at the selection end without an order check and sends it to its ingester querier unless query_store_only is set. - loki-ingester-inverted-call (REASONED; loki): From source reading, Loki's ingester querier passes the log or sample request it receives, including an inverted interval from the querier, to each queried ingester's gRPC Query or QuerySample call. Per that reading, the ingester module registers the ingester, or a wrapper Loki-embedding Go code may set, as the Querier service whose generated handlers decode the request and call those methods. - loki-ingester-inverted-store (REASONED; loki): From source reading, without a configured wrapper, a Loki ingester serving a log or sample request whose start is after its end makes no store query for it, whatever its store lookback: its store-request code moves a start only later, to now minus a positive lookback, and returns no store request when that adjusted start is after the end. - loki-ingester-inverted-memory (REASONED; loki): From source reading, without a configured wrapper, a Loki ingester's in-memory log or sample query skips every chunk of each index-matched stream unless the chunk's bounds start no later than the request end and end no earlier than the request start, so for an inverted interval it keeps only chunks spanning from that end to that start. - loki-ingester-inverted-lookup (REASONED; loki): From source reading, a Loki v3.7.8 ingester adds every stream to each index it builds, and its index lookup uses an inverted interval's start only to pick the index of the schema period containing it, for a future start the latest period starting at or before it. Per that reading, the lookup matches label matchers and shard without reading time, so the interval's order causes no lookup error. - loki-ingester-inverted-iterators (REASONED; loki): From source reading, Loki v3.7.8's ingester stream, chunk and head-block iterators neither swap nor clamp an inverted interval: they read a chunk, block or head block only if it starts no later than the interval's end and ends no earlier than its start. Per that reading, the v3.7.8 ordered head block's log iterator drops each entry before the interval's start or at or after its end. - loki-ingester-inverted-time-filter (REASONED; loki): From source reading, Loki v3.7.8's chunk time-range filters, used for block entries in both directions, the head block in forward log queries and all sample queries, skip entries before the start but accept one timestamped exactly at it before testing the end; in this guide's assessment, they pass only such entries for an inverted interval. - loki-push-future-bound (REASONED; loki): From source reading, Loki v3.7.8's distributor validates each pushed entry against a time.Now() context: a timestamp strictly newer than now plus the tenant's creation_grace_period (default 10m) is rejected as too far in the future, so one exactly at that bound passes. Per that reading, reject_old_samples (default true) rejects entries older than now minus reject_old_samples_max_age (default 7d). - loki-ingester-push-validation (REASONED; loki): From source reading, Loki v3.7.8's ingester gRPC push chain compares timestamps with stream state and schema configuration, never the clock, with no future timestamp bound. Unordered writes (default true) drop entries below the stream's highest timestamp minus half max_chunk_age (default 2h); ordered appends reject those older than the head block's newest or, head empty, the last block's maximum. - loki-ingester-inverted-hold (REASONED; loki): Without a Loki run, in this guide's source assessment, an ingester can hold an entry dated exactly at the inverted interval's future start when the lookback's magnitude is at most creation_grace_period, on one clock, grace unchanged. For a larger magnitude a direct ingester gRPC push can; in-tree Go Kafka producers add no route of their own; duplicate shifts are open. - loki-ingester-inverted-unordered (REASONED; loki): From source reading, Loki v3.7.8's backward log queries do not time-filter a chunk's head block, and the unordered head block queries its vendored go-datastructures v1.1.7 range tree from an interval's start to one nanosecond before its end, both inclusive. For an inverted interval that query returns no entry, so the head block yields nothing. - loki-ingester-empty-send (REASONED; loki): From source reading, Loki v3.7.8's ingester Query or QuerySample sends one empty batch with stats and warnings for an empty, error-free iterator if the context is not done, failing only on a Send error besides context.Canceled, like grpc-go's status error from Write on a done stream; no interceptor read wraps SendMsg. The ingester querier's iterator records those, yields nothing and ends cleanly. - loki-stream-context (REASONED; loki): From source reading, Loki v3.7.8's vendored grpc-go chains stats handlers' TagRPC in order; the dskit server's TagRPC methods, the propagator Extract paths and the SDK, global and noop tracers' Start return their input or a context.WithValue descendant, outside paths named as unread. The status code of a send after a failed write-quota wait is not established. - loki-tracing-install (REASONED; loki): From source reading, Loki v3.7.8's main calls dskit's NewOTelOrJaegerFromEnv when tracing.enabled (default true) is set. If a JAEGER_AGENT_HOST, JAEGER_ENDPOINT, JAEGER_SAMPLER_MANAGER_HOST_PORT, OTEL_TRACES_EXPORTER or OTLP endpoint variable is non-empty and setup succeeds, it installs an otelpyroscope-wrapped SDK tracer provider and a composite propagator set by OTEL_PROPAGATORS; else none. - loki-querier-inverted-merge (REASONED; loki): From source reading, a Loki v3.7.8 querier merges the ingester querier's per-ingester iterators for a log or sample selection with the iterator of any store query it makes for that selection, which it skips when query_ingester_only is set or it builds no store interval; a log selection with only one iterator gets that iterator unmerged. - loki-ingester-wrapper-search (REASONED; loki): From source reading, a word search for Wrapper in Loki v3.7.8 outside vendor finds only Ingester.Wrapper's declaration (no YAML key), its one-method Wrap interface and initIngester's nil check and Wrap call, besides comments and source maps, and no vendored package imports Loki's ingester or loki package; nothing in the repository sets it. - loki-querier-ingester-inverted-result (REASONED; loki): Source reading of Loki's querier, ingester querier and ingester query code, with no Loki run, does not establish what they return for the inverted ingester interval a negative store lookback besides -1 yields: in this guide's assessment ingesters can hold entries dated exactly at its start when the lookback's magnitude is within the grace period, on one clock, grace unchanged; no run observed it. - loki-internal-server (REASONED; loki, dskit): From source reading, Run initializes target modules and starts them together. internal_server is off by default; when enabled it precedes Server and its HTTP listener defaults to localhost:3101. - loki-internal-server-grpc (REASONED; loki, dskit, go): From source reading and Go's net.Listen docs, the internal server's flags and config_wrapper.go set no gRPC network, address or port, and initInternalServer passes that configuration to dskit's server.New, so unless YAML sets them, dskit opens an any-address tcp listener on an OS-chosen port carrying the HTTP bridge; not observed. - loki-internal-routes (REASONED; loki): When enabled, the internal server's HTTP router serves routes including /ready and buildinfo and, when their modules run, /ring, /memberlist and the compactor handler prefix; these are source registrations, not observed responses. - loki-target-modules (REASONED; loki, dskit): From source reading, write depends on the ingester, distributor and pattern ingester, read on the query frontend and querier, backend on the query scheduler, ruler, compactor, index gateway and bloom modules, and all on read's and write's modules plus the scheduler, ruler, compactor and UI. dskit runs each init once, dependencies first. - loki-grpc-services (REASONED; loki): From source reading, Loki's init functions register gRPC services only on the main server, some only when enabled: distributor and ingester Pusher, ingester Querier and StreamData, pattern, ingest limits, frontend, scheduler, ruler, compactor, job queue, bloom gateway, index gateway and bloom planner. A non-test search finds their callers only in Loki's own clients and a stream generator tool. - loki-grpc-tls (REASONED; dskit, loki): Inferred from dskit's server TLS source, gRPC TLS requires compatible TLS on every internal client; local users can reach plaintext loopback and, per Loki's authentication documentation, mTLS does not supply tenant headers. - loki-proxy (REASONED; loki): An authenticating proxy must overwrite X-Scope-OrgID with its identity and be the only caller that can reach Loki. - verify-inventory (REASONED; node, alert, push, jaeger, dskit): Inventory every TCP/UDP listener; wildcard defaults, all Loki listeners and off-host isolation were not observed. - verify-external (REASONED; security, toolkit, jaeger-docs): External 200 exposes unauthenticated HTTP; HTTPS 401 shows auth, not isolation. Confirm connection failures and a permitted positive control. - verify-loki (REASONED; loki, curl): A tenant-header rejection is not authentication; repeat with an arbitrary X-Scope-OrgID and test both families, gRPC and internal RPCs. - verify-basic (DEMONSTRATED; node, push, alert, jaeger, toolkit): Loopback HTTPS GETs on node/Pushgateway metrics, Alertmanager status and Jaeger v3 services gave no-credential 401 and credentialed 200. The runs covered an earlier form, whose guard refusals ended with status 0 and whose results set no exit status; gate bash -n and ShellCheck passes cover the current form. - verify-loki-mtls (REASONED; loki, dskit): Authorized HTTP mTLS should succeed, untrusted/missing client certs fail handshake and plaintext fail; no Loki listener/TLS run occurred. - verify-secret (REASONED; toolkit, curl): Basic-auth stdin closes argv exposure only; shell history, tracing and your own account's view of the process can still leak the password. ````json { "schema": 1, "checked": "2026-10-20", "documentation_checked": "2026-09", "body_sha256": "441ffdd2b896159a7c9044785b1ef1956ea1a11fd556afcb8b299c7bc91693b5", "components": { "node": { "name": "node_exporter", "basis": "v1.12.1", "sources": { "s9cc780c8cd13": "https://github.com/prometheus/node_exporter/blob/v1.12.1/README.md", "s896899f5a500": "https://github.com/prometheus/node_exporter/blob/v1.12.1/node_exporter.go" } }, "alert": { "name": "Alertmanager", "basis": "v0.34.1", "sources": { "s0fd66cba968e": "https://github.com/prometheus/alertmanager/blob/v0.34.1/README.md", "s120d6ecf3fcd": "https://github.com/prometheus/alertmanager/blob/v0.34.1/docs/https.md", "s2f25ecba24e9": "https://github.com/prometheus/alertmanager/blob/v0.34.1/docs/management_api.md" } }, "push": { "name": "Pushgateway", "basis": "v1.11.3", "sources": { "s5c9f9745f42d": "https://github.com/prometheus/pushgateway/blob/v1.11.3/README.md", "s17fa4021d868": "https://github.com/prometheus/pushgateway/blob/v1.11.3/main.go" } }, "jaeger": { "name": "Jaeger", "basis": "v2.21.0", "sources": { "s5619bba2be59": "https://github.com/jaegertracing/jaeger/blob/v2.21.0/cmd/jaeger/internal/all-in-one.yaml", "sc6f47d82b947": "https://github.com/jaegertracing/jaeger/blob/v2.21.0/cmd/jaeger/internal/extension/jaegerquery/internal/flags.go", "s15354f575fde": "https://github.com/jaegertracing/jaeger/blob/v2.21.0/ports/ports.go", "sb0d71eec9578": "https://github.com/jaegertracing/jaeger/blob/v2.21.0/cmd/jaeger/Dockerfile", "s9cba9ed883db": "https://github.com/jaegertracing/jaeger/releases/tag/v2.21.0" } }, "loki": { "name": "Loki", "basis": "v3.7.8", "sources": { "s70975c2655fa": "https://github.com/grafana/loki/blob/v3.7.8/docs/sources/operations/authentication.md", "s303881c903ca": "https://github.com/grafana/loki/blob/v3.7.8/docs/sources/reference/loki-http-api.md", "sd13fa86b20d0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L150-L162", "s8f06ddfa5291": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/common/common.go", "s8cf14a90ec61": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/config_wrapper.go#L162-L174", "s924fd8ffd310": "https://github.com/grafana/loki/blob/v3.7.8/cmd/loki/loki-local-config.yaml#L1-L16", "s9fd7d8bd80e3": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L241-L269", "s985dfb0ea97c": "https://github.com/grafana/loki/blob/v3.7.8/production/docker/config/loki.yaml#L1-L8", "s057e686b00bb": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L495-L509", "sb5e56f0d6348": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L208-L222", "s9f316e1a5fef": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L399-L422", "s39c270ed878c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1307-L1378", "sbe0aa939a44d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L533-L540", "sc2b013001317": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L751-L790", "s4f3a19f7f08c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1866-L1869", "s3d51e1213e70": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1880-L1883", "s38c1e77fe01e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1899-L1908", "s1f38431776f2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1935-L1952", "s765907d2b4ee": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L571-L580", "sf3955ae90da4": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L734-L740", "sfb3d52071e7c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/config_wrapper.go#L193-L248", "scf12ae06cdc4": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L373-L390", "s0ce56d38618e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L271-L272", "s970473c2dcdb": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1130-L1138", "s6b2f01173a97": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1241-L1262", "s44e7547da37d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2532-L2561", "s33f320afa73a": "https://github.com/grafana/loki/blob/v3.7.8/go.mod#L55", "s3ff15746c2c9": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/tenant_request_handler.go#L10-L34", "s14f65483dd4d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/request_handler.go#L49-L110", "s0bde2f8abe97": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/request_handler.go#L134-L190", "saf8f37e04b71": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/request_handler.go#L259-L303", "s6feced2aa907": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L450-L453", "se1440b6eb37a": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L1128-L1132", "s79ea72562a9f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L117-L182", "s634e6599df5e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L222-L245", "s10b95cd4cab8": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L297-L310", "s82a976e6b93c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/frontend_processor.go#L49-L80", "s47c05209cd31": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L87-L164", "s18cd3a035b38": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L183-L244", "s1d59e1184191": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L246-L335", "s5b3705652693": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L57", "s9d106cc379de": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/client/client.go#L65-L91", "s8b3d57f01355": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L358-L365", "s88661a4514b3": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L58-L74", "sb0ae3e30f690": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L187-L220", "s074f98761044": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L249-L253", "s00520a857797": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L344", "s7b1d1f183610": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L231-L236", "sc5574f33c47c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L900-L921", "s4554d31a5d15": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L1216-L1231", "s51e0f640a169": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L879-L893", "sa19fa92e5d02": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L1258-L1271", "sfc4788e520b8": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L346", "s7786b9105acc": "https://raw.githubusercontent.com/grafana/loki/v3.7.8/pkg/util/fakeauth/fake_auth.go", "sae57ccde5dc6": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletion/util.go#L24-L36", "s86b57bdedee7": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletionmode/mode.go#L15-L24", "sc37c5a5a63b4": "https://github.com/grafana/loki/blob/v3.7.8/pkg/compactor/deletionmode/mode.go#L38-L56", "s99ac9c7561c2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker_service.go#L18-L42", "s61ecb8af773e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker_service.go#L56-L112", "sde741c787b71": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/scheduler_processor.go#L37-L59", "seea78f4f5adb": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L224-L230", "sb7c90a61ae40": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L562-L664", "scf7d98f51caa": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L750-L761", "sf4c3469d7ee1": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L816-L867", "sd4f78f9689f6": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L923-L941", "sd46d55125044": "https://github.com/grafana/loki/blob/v3.7.8/pkg/server/internal_server.go#L10-L33", "sf19909de84b7": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L245-L268", "s309318e5a59c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2646-L2664", "s319f6b7b5340": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L276-L279", "s4b5a89431203": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2684-L2687", "se7ef0218d7c5": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L753-L813", "s3eb2a64252dc": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L869-L921", "s80b37692fda0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L952-L956", "s6b2a91bbd70f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L452-L477", "sd4bf25cf533c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L508-L528", "s6804d0b9ca37": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L794-L810", "saa25f09d653e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1288-L1296", "s2b784ff2e354": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1670-L1677", "s11c2c89339af": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1954-L1966", "seaa8a690e518": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2008-L2015", "s4a0aa7492160": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2064-L2096", "s9a1dda234f64": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2144-L2151", "s6ae16575c268": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L266-L492", "s543d4de072bb": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/http.go#L28-L38", "s3d6bf0891af0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/http.go#L151", "sd28855900a87": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L552-L558", "sa59140ca5c4c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L127-L136", "s9a87cce410e5": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L293-L308", "s846c370c416f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L860-L867", "s51bcdc31e69d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L1296-L1326", "s8f53a03e764f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L77-L81", "sdc0d0e6ad95d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L176-L198", "s10870cc18ddc": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L242-L261", "scba306dcdc0c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L302-L304", "s2e89e4162342": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L168-L186", "s5ab3c47cb0fc": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/clientpool/ingester_client_pool.go#L30-L46", "sb8f98248ef1a": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/processor_manager.go#L35-L89", "scc6c338ac167": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L157-L196", "s6a02e080cabb": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/worker/worker.go#L247-L295", "s02c83c5d9dda": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/ring_watcher.go#L14-L117", "sc26dc660fa21": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/dns_watcher.go#L30-L83", "s21675bf85b75": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/clientpool/ingester_client_pool.go#L26", "s529cd9a944d9": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/intervals.go#L9-L104", "sf2a4759294c3": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L540-L542", "s23d3deffe096": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L163", "s86fc597288e2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L200-L212", "se3f01b0b8f64": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L263-L272", "s23599866ad90": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/intervals.go#L23-L25", "se82f6c344ae2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L910-L918", "sa4685cf34525": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1060-L1112", "s937747b2754f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2573-L2588", "s362660480c60": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2607-L2639", "s7e25cfebaa93": "https://github.com/grafana/loki/blob/v3.7.8/pkg/storage/stores/shipper/indexshipper/boltdb/table.go#L27", "s425030dbbf09": "https://github.com/grafana/loki/blob/v3.7.8/pkg/storage/stores/shipper/indexshipper/shipper.go#L45", "s086d15529b4c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/storage/config/schema_config.go#L345-L378", "sc311d25f20e8": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L770-L771", "s3e8d9a35ec33": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L943-L947", "s9a971cf72e10": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L387-L389", "s21c205d034b9": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1102-L1119", "sd6184d6a8a46": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1175-L1191", "s51ab6a62a6bd": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1640-L1660", "sb5b69814231d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L158-L169", "s23b8da8ac6be": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L224-L235", "s61e16a1ac1f0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/ingester_querier.go#L234-L264", "sf253d44e375f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L768-L773", "sfb58949fa28c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/logproto/logproto.pb.go#L6166-L6193", "se2f420694eb9": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1093-L1100", "se913abf5bf85": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1160-L1168", "se1f0d83f115c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L519-L532", "s2a80da1a36e8": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L587-L610", "sadb084375296": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L962-L975", "s0b40d7d25a3f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L588-L594", "se34169c8b133": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L635-L641", "sb6c261fe940d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1814-L1816", "s81d8d94c3cea": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L1928-L1933", "sd5967b880c33": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L152-L163", "sc87fdc20cb58": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L312-L314", "s11cbfc4fcf89": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L404-L405", "sf674ae88a12c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/multi.go#L26-L73", "s6bc46c6500c2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/multi.go#L84-L117", "s140b0adc6723": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/index.go#L159-L183", "s19918b3b217b": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/index/bitprefix.go#L131-L169", "se3affc4818e6": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L601-L607", "s693d6421c2f0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L648-L650", "sd5181aa115e4": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1005-L1087", "scc80bfb6f547": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1090-L1147", "s05d6c5fc01a2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1310-L1328", "s291928ddc0b1": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L1380-L1397", "s33683997e787": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/unordered.go#L185-L210", "s69faadc4a015": "https://github.com/grafana/loki/blob/v3.7.8/go.mod#L19", "s18bc063957e1": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/unordered.go#L69-L75", "sf185bec82940": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/unordered.go#L260-L310", "s015710936d80": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/interface.go#L38-L47", "saf4f45f2d5c7": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/orderedtree.go#L259-L263", "sd8ad9fae3598": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/orderedtree.go#L180-L225", "sd82568caaafc": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/ordered.go#L25-L30", "s996300b7bdcf": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/ordered.go#L81-L98", "s9a133bb92cd3": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/Workiva/go-datastructures/rangetree/entries.go#L21-L45", "s0dbdc425ddbb": "https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/entry_iterator.go#L478-L509", "s05d6bac1c352": "https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/sample_iterator.go#L681-L716", "s76e4e638e936": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L1104-L1143", "s5732ba30414b": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L1145-L1179", "sa1a1efa48033": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1121-L1129", "s00b4ce2d9a6a": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1193-L1195", "s9f438349ea61": "https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/entry_iterator.go#L681-L716", "sf89b7c829a53": "https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/sample_iterator.go#L718-L753", "s6c74d0f4adb3": "https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/entry_iterator.go#L362-L388", "sdf678b8f143f": "https://github.com/grafana/loki/blob/v3.7.8/pkg/iter/sample_iterator.go#L480-L505", "s2d763a8942ef": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L213-L216", "s6f04b102b391": "https://github.com/grafana/loki/blob/v3.7.8/pkg/querier/querier.go#L274", "s24cd2d4a2bce": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L126-L127", "sd7efa15100d2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L210-L212", "sa66ae191eb87": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L589-L592", "s883c99aaf259": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L690-L695", "s03a9eb3c1800": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/validator.go#L66-L71", "s7be82cdc7279": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/validator.go#L94-L113", "sc1c59c06ac21": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L327", "s9acb77638396": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L351-L354", "s7e5e17b46be6": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L751-L754", "sfc254eaeb3c3": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L362", "s19e37d99684b": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L162", "sa5963eda217e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L1001-L1024", "s02173d6919e0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L231-L261", "s6d778dd956ad": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L186-L247", "s3683552f47e7": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L401-L461", "s48eb47bcb74e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/stream.go#L339-L384", "s1d09c7438f6c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L186-L200", "s93219a6da66c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L749-L758", "s9c1fabf63f70": "https://github.com/grafana/loki/blob/v3.7.8/pkg/distributor/distributor.go#L686", "se9b1d3173a5a": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/limits.go#L328", "s9044377b42e9": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/ingester.go#L395", "sd2ef570a75c2": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/kafka_consumer.go#L53-L71", "sd01827189b1e": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/kafka_consumer.go#L150-L159", "s6259f9f6561b": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L316", "sef39e72a9288": "https://github.com/grafana/loki/blob/v3.7.8/pkg/ingester/instance.go#L423-L440", "s631c8b46da80": "https://github.com/grafana/loki/blob/v3.7.8/pkg/chunkenc/memchunk.go#L872-L874", "s39d479c8ba94": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/config_wrapper.go", "se2deb786e148": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/stream.go#L1726-L1807", "s4be52dc17660": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/stream.go#L1902-L1919", "sbe53a886169a": "https://github.com/grafana/loki/blob/v3.7.8/pkg/logproto/logproto.pb.go#L6179-L6206", "sfa17c8b9744c": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1575-L1588", "s6bbef377347c": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1590-L1779", "s958c3fc5fde9": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L455-L493", "s6621f46a53f1": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_logging.go#L84-L103", "s72e8d6673510": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_instrumentation.go#L57-L66", "s807df01bc8a9": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_auth.go#L46-L66", "sf19870535c9b": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L486-L514", "s86a537881f1c": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/modules.go#L2192-L2217", "s2fdef6108709": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L601-L616", "s4011490e4c44": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L837-L842", "sc764d609c18a": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1010-L1047", "sc7f949d05c7e": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1157-L1188", "sbc58e5ae2f15": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1290-L1313", "s88b28c9fa838": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L1337-L1379", "s86f5e4dbf938": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/transport.go#L818-L820", "sf1dbb24b6f89": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/transport.go#L863-L872", "s347ea47da1d9": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/rpc_util.go#L1129-L1157", "scedf32486af2": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/status/status.go#L96-L127", "s09f50c15300b": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/status/status.go#L205-L228", "scf2ca760e412": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/server_stream.go#L69-L72", "s658bfc32b613": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L402-L407", "s1b47472c83c8": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/limits.go#L140-L150", "s0540308a9bbf": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L170", "s369533e16eb3": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L80-L82", "s334ac63c0892": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L704-L711", "s68c48fdded52": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L504-L523", "sbf9e7851df52": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L124", "s326c544e4f05": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L529-L534", "sc2705694b8c1": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ingester/client/client.go#L66-L92", "s34e985ffc25f": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/ratestore.go#L288-L309", "scdd1efa4b625": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/ring_client.go#L45-L55", "s9637e24d4a76": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/tee_service.go#L306-L317", "sfbbe8d6f0c8b": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/pattern/ingester_querier.go#L52-L59", "sb7905b55bf8a": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/frontend/frontend.go#L68-L85", "sde152f1440c8": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/distributor.go#L267-L278", "s158db2e4f90c": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/lokifrontend/frontend/v2/frontend_scheduler_worker.go#L49-L64", "s5391a2d500b5": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/scheduler/scheduler.go#L553-L589", "s2c42813fbbc1": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ruler/base/ruler.go#L968-L1001", "s19b349e08fd1": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/compactor/client/grpc.go#L42-L146", "s8c054d8b740d": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/indexgateway/client.go#L166-L223", "sfca9a77e69b1": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/bloomgateway/client.go#L124-L260", "s9de56a5b8b27": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/bloombuild/builder/builder.go#L211-L244", "s61779f8269a0": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/distributor/client/client.go#L63-L98", "s1086bb06662d": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/distributor.go#L839-L869", "s861664803499": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/partition/reader.go#L216-L251", "sf9ef7591755e": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/ingester/kafka_consumer.go#L139-L232", "sa050ceb04cea": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/config.go#L83-L87", "s11d04b8a83cd": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/distributor/dataobj_tee.go#L39-L54", "s6e92710b6c7d": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/dataobj/consumer/service.go#L63-L74", "s15fcd48516e5": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/producer.go#L62-L85", "sc6b2e0b6d26a": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/tools/stream-generator/generator/kafka.go#L97-L114", "s7832fbb96765": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/encoding.go#L178-L190", "s27e4e7b322b7": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/push/types.go#L246-L359", "sa05dadacc9a7": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/encoding.go#L45-L58", "sdfe8e95abe48": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/kafka/encoding.go#L118-L131", "sb999c70880c3": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/dataobj/consumer/metastore.go#L35-L54", "s104fbc7a699f": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/dataobj/metastore/metastore.pb.go#L180-L207", "s9b3d31b2b6c1": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/limit.go#L58-L85", "sd72e6a5f3e04": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/proto/limits.pb.go#L1489-L1505", "s9dbe31d6448e": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/proto/limits.pb.go#L1449-L1472", "s04c8e8dc2ec3": "https://github.com/grafana/loki/blob/09e6ce2ff1bdc19763a10265b870c86f51c98655/pkg/limits/store.go#L211-L269", "s4b1dc6d8d7ec": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery/interceptors.go#L39-L59", "sb50c698e0afb": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery/options.go#L31-L37", "sb5f9683bc990": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/stats/stats.go#L35-L51", "s6f56d1ed6b3f": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L572-L574", "sfdb7d43a2980": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/limits.go#L63-L88", "s833a9f682fd8": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/metadata_supplier.go#L51-L60", "s191e0362b7d4": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/propagator.go#L27-L66", "s2c80d5c08178": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/propagation/propagation.go#L136-L141", "seadda9b48aa2": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/propagation/trace_context.go#L72-L78", "s55353854b212": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/propagation/baggage.go#L50-L78", "s2ff883be9ce6": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/baggage/context.go#L13-L16", "sdb0262c336d5": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/baggage/context.go#L55-L68", "sd28c548a76d6": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/propagators/jaeger/jaeger_propagator.go#L74-L151", "s3bd2ef28f5eb": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/propagators/jaeger/context.go#L15-L17", "sf5441c22b843": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel_jaeger.go#L330-L338", "s13c2f2df2b51": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/trace/context.go#L13-L31", "s5fbd8b2f869a": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/trace.go#L144-L188", "se5dc791277fb": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/sdk/trace/tracer.go#L32-L78", "sb85966a1647c": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/sdk/trace/span.go#L970-L982", "s8caa30d7268e": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/trace/noop.go#L40-L47", "s1d5e4de8178c": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/trace/noop/noop.go#L54-L73", "s816ec3b28f7a": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1800-L1853", "s87a488ab017d": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/server_stream.go#L121-L125", "s195be863483a": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L712-L721", "sc721ca2d10be": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L483-L485", "s98ea18f5a30e": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L491-L523", "s6dea674467e6": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/stats_handler.go#L98-L158", "sfe1b650e2542": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/limits.go#L151-L153", "sec27b6b62b83": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/middleware/grpc_stats.go#L56-L58", "s7f484d800206": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/state.go#L70-L91", "s1f761db14084": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/trace.go#L59-L74", "s5f45265cff52": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/otel/internal/global/trace.go#L138-L139", "s50e474e6a856": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/transport/http2_server.go#L541-L544", "sdd4428846203": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/metadata/metadata.go#L164-L166", "sdf285f2c696c": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L2053-L2055", "sda7c216e072c": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L125", "s8e15e7dac6b5": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/server/server.go#L503-L511", "s537caf6030ab": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/grpcutil/taphandle.go#L10-L25", "sfca43a4aca4a": "https://github.com/grafana/loki/blob/v3.7.8/vendor/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc/config.go#L78-L79", "sde5d4b0f4af8": "https://github.com/grafana/loki/blob/v3.7.8/cmd/loki/main.go#L113-L127", "se0352cf5607d": "https://github.com/grafana/loki/blob/v3.7.8/pkg/tracing/config.go#L11-L13", "sbc50b29a9eb0": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L126", "s61aa9f686df5": "https://github.com/grafana/loki/blob/v3.7.8/pkg/loki/loki.go#L226", "sdbb2abd16b84": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel_jaeger.go#L47-L69", "s98651a70b4fa": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel_jaeger.go#L296-L307", "s339be8c4f2aa": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel.go#L37-L83", "s5b16e21a3e40": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/grafana/dskit/tracing/otel.go#L277-L306", "sb8b9a8832798": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/server/recovery.go#L39", "s434d9489a883": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/server/grpc_query_tags.go#L66-L81", "s093f0844b29b": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/server/grpc_headers.go#L56-L61", "sd0a99a08256a": "https://github.com/grafana/loki/blob/v3.7.8/pkg/validation/ingestion_policies.go#L256-L312", "s00fcdec5c4ec": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/querylimits/grpc.go#L63-L122", "sf18867aee842": "https://github.com/grafana/loki/blob/v3.7.8/pkg/util/fakeauth/fake_auth.go#L15-L78", "sfc08964ae1ac": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/opentracing-contrib/go-grpc/server.go#L91-L135", "s0f81a8e67d8a": "https://github.com/grafana/loki/blob/v3.7.8/vendor/github.com/opentracing-contrib/go-grpc/options.go#L14-L80", "s997c8175cf06": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/trace.go#L31-L33", "s5f6956abae16": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1809-L1823", "s40b82e4c0abc": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L725-L728", "sf01b2f9a53a9": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/stream.go#L482-L495", "s2157d4c926e7": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/internal/envconfig/envconfig.go#L62-L69", "s16dba5c6e972": "https://github.com/grafana/loki/blob/v3.7.8/vendor/google.golang.org/grpc/server.go#L1803-L1808" } }, "jaeger-docs": { "name": "Jaeger documentation", "basis": "4d150659ee4ed3ccc69253ec77c368392f59e625", "sources": { "s2c0a96684979": "https://github.com/jaegertracing/documentation/blob/4d150659ee4ed3ccc69253ec77c368392f59e625/content/docs/v2/2.21/deployment/configuration.md", "s4969c5ffaa59": "https://github.com/jaegertracing/documentation/blob/4d150659ee4ed3ccc69253ec77c368392f59e625/content/docs/v2/2.21/deployment/security.md" } }, "toolkit": { "name": "Exporter toolkit", "basis": "v0.17.1", "sources": { "sbb86207a38e5": "https://github.com/prometheus/exporter-toolkit/blob/v0.17.1/docs/web-configuration.md" } }, "security": { "name": "Prometheus security model", "basis": "a0d29881382ad1ea20597d34fc4229984b326576", "sources": { "s3ffe42e99660": "https://github.com/prometheus/docs/blob/a0d29881382ad1ea20597d34fc4229984b326576/docs/operating/security.md" } }, "tls": { "name": "OpenTelemetry configtls", "basis": "v1.66.0", "sources": { "s11d519172812": "https://github.com/open-telemetry/opentelemetry-collector/blob/cd3455cf3a7f672208140b1ebb1581c542b2b0ed/config/configtls/README.md" } }, "basic": { "name": "OpenTelemetry basicauth extension", "basis": "v0.160.0", "sources": { "s881d8805b6c6": "https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.160.0/extension/basicauthextension/README.md" } }, "dskit": { "name": "dskit", "basis": "8d1c6d34bb5a42b04caa982d68403c5a643bb742", "sources": { "sb19d54a19636": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L189-L212", "s0dc9a01e591a": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L69-L78", "s7848c6311aee": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L309-L332", "sd35a28be4391": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L357-L395", "se0830c9ea1b5": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L411-L412", "s38beab6892ea": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L433-L468", "s761b46738198": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L577-L583", "s5fd7b12bfcf1": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/server/server.go#L673-L681", "s3634557bb838": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/middleware/http_auth.go#L13-L23", "saf57a4cbc682": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/middleware/grpc_auth.go#L36-L57", "s1ca109a7a016": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/tenant/tenant.go#L85-L99", "s23d81aeedf07": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/tcp_transport.go#L41-L86", "s4d546b0d82d4": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/tcp_transport.go#L133-L197", "s251174df8783": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/tcp_transport.go#L394-L451", "s052e8107edad": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L229-L232", "scf57b6b1f148": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L457-L520", "s675104c4819c": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/crypto/tls/tls.go#L27-L64", "s99e7e34182f1": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/crypto/tls/tls.go#L86-L175", "s69c68f3d3e6a": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L1383-L1470", "sb0955f4c2ebd": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcclient/grpcclient.go#L125-L134", "s74daabf65e98": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L189-L229", "s172528a7c1bf": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L709-L723", "s433134153d1d": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L778-L781", "s1786ff0496f6": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L541-L544", "sc8780cfc9e85": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L681", "s1b539b8100a3": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/memberlist/memberlist_client.go#L1577-L1672", "sd7b8bf6bbca6": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/util.go#L16-L39", "sa7d343f48dc5": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L97-L100", "s04a225bbac7b": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L602-L606", "s59c2aa3eb502": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L697-L725", "sbd4dba4be513": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/lifecycler.go#L950-L973", "s8145c89a12ee": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/kv/client.go#L137-L176", "sf68c98707951": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L319-L334", "s05d533c42177": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L379-L411", "s16cda83ea984": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L497-L544", "s48b2d449a388": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/ring.go#L711-L792", "s2a947b05938f": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/replication_set.go#L157-L316", "s0b92cab27eeb": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/pool.go#L43-L49", "sa35a10f1fa8b": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/pool.go#L95-L165", "s6224eeb93eb9": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/pool.go#L238-L318", "s79f868b8ee48": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/ring/client/ring_service_discovery.go#L9-L25", "s7077c6b93703": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcutil/dns_resolver.go#L76-L171", "s77d6a067cd74": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcutil/dns_resolver.go#L191-L268", "sfbd1b87a9a0c": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/grpcutil/dns_resolver.go#L18-L36", "s96305c045ce4": "https://github.com/grafana/dskit/blob/8d1c6d34bb5a42b04caa982d68403c5a643bb742/modules/modules.go#L116-L156" } }, "go": { "name": "Go documentation", "basis": "unknown", "sources": { "sd3b3c1c431ec": "https://pkg.go.dev/crypto/tls#ClientAuthType", "s37204ff1b27c": "https://pkg.go.dev/net#Listen" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79", "s3db522258e44": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md", "s82a34ee18632": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md" } } }, "claims": { "node-bind": {"text": "node_exporter defaults to :9100 on all interfaces; private binds are required.", "components": ["node", "go"], "sources": ["node:s9cc780c8cd13", "node:s896899f5a500", "go:s37204ff1b27c"], "status": "REASONED"}, "node-open": {"text": "Unconfigured loopback metrics and pprof both returned 200 without credentials.", "components": ["node"], "sources": ["node:s896899f5a500"], "status": "DEMONSTRATED", "evidence": "On a loopback run, both paths returned `200` with no credentials."}, "toolkit-config": {"text": "--web.config.file enables TLS and bcrypt Basic auth across HTTP paths; the format is experimental and not every exporter uses it.", "components": ["toolkit", "node", "alert", "push"], "sources": ["toolkit:sbb86207a38e5", "node:s9cc780c8cd13", "alert:s120d6ecf3fcd", "push:s5c9f9745f42d"], "status": "REASONED"}, "node-tls": {"text": "Loopback HTTP returned 400; HTTPS metrics/pprof rejected absent credentials and accepted correct ones.", "components": ["node", "toolkit"], "sources": ["node:s896899f5a500", "toolkit:sbb86207a38e5"], "status": "DEMONSTRATED", "evidence": "HTTPS without credentials got `401` on both `/metrics` and `/debug/pprof/`, and HTTPS with the right credentials got `200`."}, "toolkit-mtls": {"text": "Use RequireAndVerifyClientCert with client_ca_file for verified clients; other client_auth_type values are called insecure by the toolkit.", "components": ["toolkit"], "sources": ["toolkit:sbb86207a38e5"], "status": "REASONED"}, "toolkit-reload": {"text": "The toolkit rereads web configuration per request; password/certificate changes apply without restart.", "components": ["toolkit"], "sources": ["toolkit:sbb86207a38e5"], "status": "REASONED"}, "toolkit-clients": {"text": "Basic auth suits a few users; supply matching credentials/CA to scrapers, pushers and Alertmanager clients, or use client certificates/proxy login.", "components": ["toolkit", "alert", "push"], "sources": ["toolkit:sbb86207a38e5", "alert:s120d6ecf3fcd", "push:s5c9f9745f42d"], "status": "REASONED"}, "alert-bind": {"text": "Alertmanager HTTP defaults :9093; HA gossip defaults 0.0.0.0:9094 and needs both TCP and UDP.", "components": ["alert", "go"], "sources": ["alert:s0fd66cba968e", "go:s37204ff1b27c"], "status": "REASONED"}, "alert-api": {"text": "Loopback anonymous status, silence creation, reload and pprof succeeded; status carried wildcard CORS.", "components": ["alert", "security"], "sources": ["alert:s2f25ecba24e9", "security:s3ffe42e99660"], "status": "DEMONSTRATED", "evidence": "`GET /api/v2/status` returned `200`, `POST /api/v2/silences` created a silence and returned `200`, and `POST /-/reload` returned `200`. `GET /debug/pprof/` also returned `200`."}, "alert-auth": {"text": "Web configuration rejected anonymous silence creation and absent/wrong status passwords; valid status credentials succeeded.", "components": ["alert", "toolkit"], "sources": ["alert:s120d6ecf3fcd", "toolkit:sbb86207a38e5"], "status": "DEMONSTRATED", "evidence": "the loopback runs returned `401` to an unauthenticated silence creation and to an unauthenticated or wrong-password status read, and `200` to an authenticated status read."}, "alert-gossip-off": {"text": "An empty --cluster.listen-address disabled both gossip socket protocols in the loopback run.", "components": ["alert"], "sources": ["alert:s0fd66cba968e"], "status": "DEMONSTRATED", "evidence": "On a loopback run with it empty, Alertmanager held no TCP or UDP socket on 9094."}, "alert-gossip-tls": {"text": "HA gossip is plaintext without experimental --cluster.tls-config; restrict it to peers and configure server/client TLS sections.", "components": ["alert"], "sources": ["alert:s120d6ecf3fcd"], "status": "REASONED"}, "alert-proxy": {"text": "Proxy mutating routes/CORS to reduce CSRF; send Prometheus traffic to every Alertmanager rather than load-balancing it.", "components": ["security", "alert"], "sources": ["security:s3ffe42e99660", "alert:s0fd66cba968e"], "status": "REASONED"}, "push-bind": {"text": "Pushgateway defaults :9091; reachable users can forge trusted series, particularly with honor_labels.", "components": ["push", "security"], "sources": ["push:s5c9f9745f42d", "security:s3ffe42e99660"], "status": "REASONED"}, "push-write": {"text": "Anonymous loopback POST wrote a series visible on metrics; an anonymous DELETE of the group returned 202.", "components": ["push"], "sources": ["push:s5c9f9745f42d"], "status": "DEMONSTRATED", "evidence": "an unauthenticated `POST /metrics/job/demo` returned `200` and the series appeared on `/metrics`. An unauthenticated `DELETE` of the group returned `202`."}, "push-pprof": {"text": "Anonymous loopback GET /debug/pprof/ returned 200.", "components": ["push"], "sources": ["push:s17fa4021d868"], "status": "DEMONSTRATED", "evidence": "it answered `200`"}, "push-pprof-registration": {"text": "The pinned source registers /debug/pprof/ outside every flag check.", "components": ["push"], "sources": ["push:s17fa4021d868"], "status": "REASONED"}, "push-admin": {"text": "Admin wipe defaults off; loopback PUT /api/v1/admin/wipe returned 404 without --web.enable-admin-api.", "components": ["push"], "sources": ["push:s17fa4021d868"], "status": "DEMONSTRATED", "evidence": "`PUT /api/v1/admin/wipe` returned `404` by default."}, "push-lifecycle": {"text": "Lifecycle shutdown defaults off; leave --web.enable-lifecycle and the admin API disabled.", "components": ["push"], "sources": ["push:s17fa4021d868"], "status": "REASONED"}, "push-auth": {"text": "With web configuration, anonymous push returned 401 and authenticated push 200.", "components": ["push", "toolkit"], "sources": ["push:s5c9f9745f42d", "toolkit:sbb86207a38e5"], "status": "DEMONSTRATED", "evidence": "an unauthenticated push returned `401` and an authenticated one returned `200`."}, "push-auth-scope": {"text": "The README says web configuration affects all HTTP endpoints: metrics, push API, admin API and web UI.", "components": ["push"], "sources": ["push:s5c9f9745f42d"], "status": "REASONED"}, "jaeger-config": {"text": "No --config selects in-memory all-in-one; receiver/diagnostic hosts use JAEGER_LISTEN_HOST with localhost fallback.", "components": ["jaeger", "jaeger-docs"], "sources": ["jaeger:s5619bba2be59", "jaeger-docs:s2c0a96684979"], "status": "REASONED"}, "jaeger-receivers": {"text": "All-in-one maps OTLP 4317/4318, Jaeger 14250/14268 and UDP 6831/6832, Zipkin 9411 and sampling 5778/5779.", "components": ["jaeger"], "sources": ["jaeger:s5619bba2be59", "jaeger:s15354f575fde"], "status": "REASONED"}, "jaeger-diagnostics": {"text": "Health 13133, expvar 27777, zpages 27778 and metrics 8888 follow JAEGER_LISTEN_HOST.", "components": ["jaeger"], "sources": ["jaeger:s5619bba2be59", "jaeger:s15354f575fde"], "status": "REASONED"}, "jaeger-loopback": {"text": "The overridden loopback run showed every listed receiver/diagnostic listener, including UDP, on 127.0.0.1.", "components": ["jaeger"], "sources": ["jaeger:s5619bba2be59"], "status": "DEMONSTRATED", "evidence": "the loopback run showed each of them, UDP included, on 127.0.0.1"}, "jaeger-image": {"text": "The official image sets JAEGER_LISTEN_HOST=0.0.0.0, exposing these listeners to its interfaces and published ports.", "components": ["jaeger"], "sources": ["jaeger:sb0d71eec9578"], "status": "REASONED"}, "jaeger-query": {"text": "Query HTTP 16686 and gRPC 16685 default wildcard independently of JAEGER_LISTEN_HOST; bind both explicitly.", "components": ["jaeger", "jaeger-docs"], "sources": ["jaeger:sc6f47d82b947", "jaeger:s15354f575fde", "jaeger-docs:s2c0a96684979"], "status": "REASONED"}, "jaeger-mcp": {"text": "All-in-one enables ai.mcp on the query HTTP port at /api/ai/mcp/; remove mcp from ai when unused.", "components": ["jaeger"], "sources": ["jaeger:s5619bba2be59", "jaeger:sc6f47d82b947"], "status": "REASONED"}, "jaeger-open": {"text": "Anonymous loopback v3 services, UI and MCP initialize returned 200; removed v1 services returned 404, not proof of protection.", "components": ["jaeger"], "sources": ["jaeger:s9cba9ed883db", "jaeger:sc6f47d82b947"], "status": "DEMONSTRATED", "evidence": "`GET /api/v3/services` returned `200`, the UI at `/` returned `200`, and an MCP `initialize` POST to `/api/ai/mcp/` returned `200`."}, "jaeger-basic": {"text": "Query basicauth/server with htpasswd rejected absent/wrong credentials on services, UI and MCP; correct credentials returned 200.", "components": ["jaeger", "basic"], "sources": ["jaeger:sc6f47d82b947", "basic:s881d8805b6c6"], "status": "DEMONSTRATED", "evidence": "`/api/v3/services`, the UI and the MCP endpoint each returned `401` without credentials and `401` with a wrong password. Each returned `200` with the right credentials."}, "jaeger-tls": {"text": "Query HTTP TLS cert_file/key_file gave HTTPS 401/200 and plaintext 400; inferred wiring was tested locally and needs upgrade retesting.", "components": ["jaeger", "tls"], "sources": ["jaeger:sc6f47d82b947", "tls:s11d519172812"], "status": "DEMONSTRATED", "evidence": "HTTPS without credentials got `401`, HTTPS with them `200`, and plain HTTP `400`"}, "jaeger-grpc": {"text": "Query gRPC auth exists but was not demonstrated; restrict it, authenticate remote collectors separately and front human UI access.", "components": ["jaeger", "jaeger-docs"], "sources": ["jaeger:sc6f47d82b947", "jaeger-docs:s4969c5ffaa59"], "status": "REASONED"}, "loki-listeners": {"text": "From source reading and Go's net.Listen documentation, empty TCP listen addresses yield wildcard HTTP 3100 and gRPC 9095; IPv4/IPv6 depends on Go/OS support.", "components": ["loki", "dskit", "go"], "sources": ["loki:s9fd7d8bd80e3", "dskit:sb19d54a19636", "dskit:s7848c6311aee", "go:s37204ff1b27c"], "status": "REASONED"}, "loki-samples": {"text": "Local sample uses gRPC 9096/auth_enabled false; production Docker config uses wildcard 3100/9095/auth_enabled true.", "components": ["loki"], "sources": ["loki:s924fd8ffd310", "loki:s985dfb0ea97c"], "status": "REASONED"}, "memberlist-bind": {"text": "With the memberlist store, TCP gossip defaults 0.0.0.0:7946; an empty list selects wildcard and an empty list entry is invalid.", "components": ["loki", "dskit"], "sources": ["loki:s33f320afa73a", "dskit:s23d81aeedf07", "dskit:s4d546b0d82d4"], "status": "REASONED"}, "memberlist-label": {"text": "Cluster labels reject mismatches unless verification is disabled, but matching labels are not credentials; no SecretKey/keyring setting is wired.", "components": ["dskit"], "sources": ["dskit:s052e8107edad", "dskit:scf57b6b1f148"], "status": "REASONED"}, "memberlist-updates": {"text": "Without TLS, reachable matching-label peers can submit KV/ring state subject to codecs/merge rules; disruption is inferred, not demonstrated.", "components": ["dskit"], "sources": ["dskit:scf57b6b1f148", "dskit:s69c68f3d3e6a", "dskit:s1b539b8100a3"], "status": "REASONED"}, "memberlist-tls": {"text": "TLS defaults off; enabled transport verifies outgoing servers but leaves incoming ClientAuth at NoClientCert, so it is not mutual peer authentication.", "components": ["dskit", "go"], "sources": ["dskit:s23d81aeedf07", "dskit:s4d546b0d82d4", "dskit:s675104c4819c", "dskit:s99e7e34182f1", "go:sd3b3c1c431ec"], "status": "REASONED"}, "memberlist-policy": {"text": "Keep tls-insecure-skip-verify false, set expected server name as needed, bind privately and restrict trusted peers even with TLS.", "components": ["dskit"], "sources": ["dskit:s675104c4819c", "dskit:s99e7e34182f1"], "status": "REASONED"}, "memberlist-advertise": {"text": "Advertise address/port affect discovery, not bind; absent overrides derive an address from the first bind and actual port.", "components": ["dskit"], "sources": ["dskit:s251174df8783", "dskit:s052e8107edad"], "status": "REASONED"}, "loki-tenants": {"text": "From source reading, auth_enabled defaults true and requires tenant headers, not credentials; false uses fake, and tenant validation checks syntax/length only.", "components": ["loki", "dskit"], "sources": ["loki:sd13fa86b20d0", "loki:sb5e56f0d6348", "dskit:s3634557bb838", "dskit:s1ca109a7a016"], "status": "REASONED"}, "loki-grpc-auth": {"text": "From source reading, default dskit interceptors lack credentials; Loki has tenant exemptions; complete interceptor behaviour was not re-traced.", "components": ["dskit", "loki"], "sources": ["dskit:s38beab6892ea", "dskit:saf57a4cbc682", "loki:s057e686b00bb", "loki:s7786b9105acc"], "status": "REASONED"}, "loki-push-query": {"text": "From source reading, reachable clients can push/query without credentials subject to tenant headers and ingest/query policies.", "components": ["loki"], "sources": ["loki:s9f316e1a5fef", "loki:s39c270ed878c"], "status": "REASONED"}, "loki-config": {"text": "From source reading, GET /config exposes configuration without a tenant-auth wrapper.", "components": ["loki"], "sources": ["loki:sbe0aa939a44d", "loki:s303881c903ca"], "status": "REASONED"}, "loki-diagnostics": {"text": "From source reading, metrics and pprof routes are registered by default without a tenant-auth wrapper.", "components": ["dskit"], "sources": ["dskit:sb19d54a19636", "dskit:se0830c9ea1b5", "dskit:s761b46738198"], "status": "REASONED"}, "loki-maintenance": {"text": "From source reading, ingester flush/shutdown accept GET/POST; prepare_shutdown accepts POST/GET/DELETE, without tenant-auth wrappers.", "components": ["loki"], "sources": ["loki:sc2b013001317"], "status": "REASONED"}, "loki-delete": {"text": "Non-worker compactor deletion routes require a supported shipper index, retention and a delete-request store; tenant and deletion-policy middleware gate create/list/cancel. After tenant validation, filter-only and filter-and-delete pass, disabled gets 403 and an unrecognized mode gets 400, by source reasoning, not observed responses; admission does not establish eventual deletion.", "components": ["loki"], "sources": ["loki:s4f3a19f7f08c", "loki:s3d51e1213e70", "loki:s38c1e77fe01e", "loki:s1f38431776f2", "loki:s3ff15746c2c9", "loki:s14f65483dd4d", "loki:s0bde2f8abe97", "loki:saf8f37e04b71", "loki:s6feced2aa907", "loki:se1440b6eb37a", "loki:sae57ccde5dc6", "loki:s86b57bdedee7", "loki:sc37c5a5a63b4"], "status": "REASONED"}, "loki-delete-mode": {"text": "In v3.7.8, validDeletionLimit parses the tenant's DeletionMode; only the exact strings disabled, filter-only and filter-and-delete parse, and DeleteEnabled is true for the latter two. TenantMiddleware returns 400 for tenant or parsing errors, 403 for disabled, otherwise invokes the handler; this is source reasoning, not observed responses.", "components": ["loki"], "sources": ["loki:s3ff15746c2c9", "loki:sae57ccde5dc6", "loki:s86b57bdedee7", "loki:sc37c5a5a63b4"], "status": "REASONED"}, "loki-grpc-bridge": {"text": "From source reading, single-binary ingester RPC and HTTP-over-gRPC expose paths that HTTP-only TLS/proxying does not protect.", "components": ["loki", "dskit"], "sources": ["loki:sc2b013001317", "dskit:s5fd7b12bfcf1"], "status": "REASONED"}, "loki-mtls": {"text": "From source reading, HTTP/gRPC TLS defaults empty and requires a cert/key pair; configure client CA plus RequireAndVerifyClientCert, not CA alone.", "components": ["dskit"], "sources": ["dskit:sb19d54a19636", "dskit:s0dc9a01e591a", "dskit:sd35a28be4391"], "status": "REASONED"}, "loki-ring": {"text": "This guide's single-binary overlay uses inmemory ring, loopback instance address and replication_factor 1 instead of default 3 (from source reading); check no gossip listener remains.", "components": ["loki"], "sources": ["loki:s8f06ddfa5291", "loki:s8cf14a90ec61", "loki:sfb3d52071e7c"], "status": "REASONED"}, "loki-internal": {"text": "From source reading, querier worker init gets server gRPC listen address/port, target flags, scheduler ring and worker config; ingester lifecycler listen port is the gRPC listen port.", "components": ["loki"], "sources": ["loki:s765907d2b4ee", "loki:sf3955ae90da4", "loki:sc2b013001317"], "status": "REASONED"}, "loki-internal-dials": {"text": "Worker and distributor/querier ingester dial sites are traced by source reading; loopback gRPC is this guide's configuration-specific advice; verify advertised/dial addresses, internal worker/ingester success, any compactor calls and live reachability.", "components": ["loki", "dskit"], "sources": ["loki:s00520a857797", "dskit:s433134153d1d", "loki:s7b1d1f183610", "loki:sc5574f33c47c", "loki:s4554d31a5d15", "dskit:s1786ff0496f6", "dskit:sc8780cfc9e85", "loki:s18cd3a035b38", "loki:s5b3705652693", "loki:sfc4788e520b8", "loki:sc2b013001317", "loki:s765907d2b4ee", "loki:sf3955ae90da4", "loki:sfb3d52071e7c", "loki:s8cf14a90ec61", "loki:scf12ae06cdc4", "loki:s0ce56d38618e", "loki:s970473c2dcdb", "loki:s6b2f01173a97", "loki:s44e7547da37d", "loki:s79ea72562a9f", "loki:s634e6599df5e", "loki:s10b95cd4cab8", "loki:s82a976e6b93c", "loki:s47c05209cd31", "loki:s1d59e1184191", "loki:s9d106cc379de", "loki:s8b3d57f01355", "loki:s88661a4514b3", "loki:sb0ae3e30f690", "loki:s074f98761044", "loki:s51e0f640a169", "loki:sa19fa92e5d02", "dskit:sb0955f4c2ebd", "dskit:s74daabf65e98", "dskit:s172528a7c1bf", "dskit:scf57b6b1f148"], "status": "REASONED"}, "loki-worker-init": {"text": "From source reading, with frontend, scheduler, read and all targets off, InitWorkerService returns no worker if the scheduler ring and both addresses are unset. Otherwise it passes worker config and ring to NewQuerierWorker; outside that standalone case, unset ring/addresses trigger a frontend address using the gRPC port and a nonempty listen address, else 127.0.0.1.", "components": ["loki"], "sources": ["loki:s765907d2b4ee", "loki:sf3955ae90da4", "loki:s99ac9c7561c2", "loki:s61ecb8af773e"], "status": "REASONED"}, "loki-worker-dials": {"text": "From source reading, workers prefer scheduler ring, scheduler address, then frontend address; AddressAdded calls grpc.DialContext with that client's options, handing it to a processor manager. Frontend Process and scheduler QuerierLoop open on a supplied connection; scheduler results go to the message's FrontendAddress via grpc.Dial with frontend options; success unverified.", "components": ["loki"], "sources": ["loki:s79ea72562a9f", "loki:s634e6599df5e", "loki:s10b95cd4cab8", "loki:s82a976e6b93c", "loki:s47c05209cd31", "loki:s18cd3a035b38", "loki:s1d59e1184191", "loki:s5b3705652693", "loki:sde741c787b71", "loki:sb8f98248ef1a"], "status": "REASONED"}, "loki-instance-port": {"text": "From source reading, GetInstancePort returns a positive configured lifecycler port, else the listen port, which Loki sets to the server gRPC listen port or the bound port when that is 0; a configured common ring copies its instance_port into the lifecycler port. The advertised address is the configured address, else one chosen from the configured interfaces.", "components": ["loki", "dskit"], "sources": ["dskit:sd7b8bf6bbca6", "dskit:sa7d343f48dc5", "dskit:s74daabf65e98", "loki:seea78f4f5adb", "loki:sc2b013001317", "loki:sfb3d52071e7c", "loki:sf4c3469d7ee1", "dskit:s96305c045ce4"], "status": "REASONED"}, "loki-ring-kv": {"text": "From source reading, lifecyclers CAS their entry, with the advertised address, into the ring key at initialization and on heartbeat; KV backends are consul, etcd, process-wide inmemory, memberlist or multi, plus test-only mock.", "components": ["dskit"], "sources": ["dskit:s04a225bbac7b", "dskit:s59c2aa3eb502", "dskit:sbd4dba4be513", "dskit:s1786ff0496f6", "dskit:s8145c89a12ee"], "status": "REASONED"}, "loki-ring-reads": {"text": "From source reading, ring clients read then watch the ring key; distributors take per-stream write sets and queriers, outside partition-ingester queries, the healthy read set via ring.DoUntilQuorum.", "components": ["dskit", "loki"], "sources": ["dskit:sf68c98707951", "dskit:s05d533c42177", "dskit:s16cda83ea984", "dskit:s48b2d449a388", "dskit:s2a947b05938f", "loki:s51e0f640a169", "loki:sb0ae3e30f690", "loki:s2e89e4162342"], "status": "REASONED"}, "loki-client-pools": {"text": "From source reading, dskit pools cache one client per address, created on first use. With health checks on (frontend pool; ingester pools by default), one failed check removes a client, as Loki sets no grace period.", "components": ["loki", "dskit"], "sources": ["dskit:s0b92cab27eeb", "dskit:sa35a10f1fa8b", "dskit:s6224eeb93eb9", "loki:s5ab3c47cb0fc", "loki:sfc4788e520b8", "loki:s88661a4514b3", "loki:sde741c787b71", "loki:s21675bf85b75"], "status": "REASONED"}, "loki-client-pools-discovery": {"text": "From source reading, ingester pools drop addresses missing from healthy ring entries; the frontend pool has no discovery and checks every 5s. Pooling does not establish effective addresses or successful calls.", "components": ["loki", "dskit"], "sources": ["dskit:sa35a10f1fa8b", "dskit:s6224eeb93eb9", "dskit:s79f868b8ee48", "loki:s5ab3c47cb0fc", "loki:sfc4788e520b8", "loki:s88661a4514b3", "loki:sde741c787b71"], "status": "REASONED"}, "loki-worker-watchers": {"text": "From source reading, ring watchers take the scheduler ring's key-0 Write set each lookup period and reconcile only non-empty results; DNS watchers poll at that period with no SRV name, report an IP target once and re-resolve host names, with dskit's default port 443 if none is given.", "components": ["loki", "dskit"], "sources": ["loki:scc6c338ac167", "loki:s02c83c5d9dda", "loki:sc26dc660fa21", "dskit:s7077c6b93703", "dskit:s77d6a067cd74", "dskit:sfbd1b87a9a0c"], "status": "REASONED"}, "loki-worker-managers": {"text": "From source reading, addresses that the worker's ring or DNS watchers add get processor managers sharing max concurrency, at least one each; removal notifies the peer and closes the connection.", "components": ["loki"], "sources": ["loki:s634e6599df5e", "loki:sb8f98248ef1a", "loki:s6a02e080cabb", "loki:scc6c338ac167", "loki:s02c83c5d9dda", "loki:sc26dc660fa21"], "status": "REASONED"}, "loki-request-callers": {"text": "From source reading, push HTTP handlers call PushWithResolver; outside all, write and ingester targets the distributor also registers gRPC Pusher. Kafka writes default off, ingester writes on; Kafka writes need a partition ring and produce to an active partition.", "components": ["loki"], "sources": ["loki:s9f316e1a5fef", "loki:s543d4de072bb", "loki:s3d6bf0891af0", "loki:sd28855900a87", "loki:sa59140ca5c4c", "loki:s9a87cce410e5", "loki:s846c370c416f", "loki:s51bcdc31e69d"], "status": "REASONED"}, "loki-ingester-query-window": {"text": "From source reading, without query_store_only, Loki queriers query ingesters for selections ending within the ingester store lookback if it is nonzero when the querier module initializes, else query_ingesters_within (0: all); a -1 lookback, documented as infinite, sends every selection to ingesters and none directly to the store.", "components": ["loki"], "sources": ["loki:s8f53a03e764f", "loki:sdc0d0e6ad95d", "loki:s529cd9a944d9", "loki:sf2a4759294c3", "loki:s23d3deffe096", "loki:s23599866ad90", "loki:s86fc597288e2", "loki:se3f01b0b8f64"], "status": "REASONED"}, "loki-ingester-lookback-all": {"text": "From source reading, when Loki's target list includes all but none of querier, ruler, read, index-gateway or backend, and the active or next schema period uses a boltdb-shipper or TSDB index, the store module turns a configured 0 ingester store lookback into a computed minimum that replaces query_ingesters_within. Per that reading, without all, 0 stays 0.", "components": ["loki", "dskit"], "sources": ["loki:s086d15529b4c", "loki:se82f6c344ae2", "loki:sa4685cf34525", "loki:s9a971cf72e10", "loki:s937747b2754f", "loki:sf2a4759294c3", "loki:sf4c3469d7ee1", "loki:sc311d25f20e8", "loki:s3e8d9a35ec33", "loki:sb7c90a61ae40", "dskit:s96305c045ce4"], "status": "REASONED"}, "loki-ingester-lookback-minimum": {"text": "From source reading, Loki's computed minimum ingester store lookback is the ingester's maximum chunk age plus 21 minutes (a 15-minute boltdb shard period, 1-minute index upload interval and 5-minute buffer), the index cache validity and three resync intervals of the active or next period's shipper index.", "components": ["loki"], "sources": ["loki:sa4685cf34525", "loki:s362660480c60", "loki:s7e25cfebaa93", "loki:s425030dbbf09"], "status": "REASONED"}, "loki-ingester-lookback-store": {"text": "From source reading, Loki's query_store_max_look_back_period flag help limits it to boltdb-shipper/TSDB indexes with the filesystem object store; the all target's boltdb-shipper/TSDB store setup rejects a nonzero value with any other object store.", "components": ["loki"], "sources": ["loki:s23d3deffe096", "loki:se82f6c344ae2", "loki:sa4685cf34525", "loki:s937747b2754f"], "status": "REASONED"}, "loki-ingester-store-all": {"text": "From source reading, when Loki's target list includes all but none of querier, ruler, read, index-gateway or backend, and the active or next schema period uses a boltdb-shipper or TSDB index, the ingester also queries the store for the log and sample selections it serves unless the lookback-adjusted start is after the selection end.", "components": ["loki", "dskit"], "sources": ["loki:se82f6c344ae2", "loki:sa4685cf34525", "loki:s9a971cf72e10", "loki:s086d15529b4c", "loki:s937747b2754f", "loki:s21c205d034b9", "loki:sd6184d6a8a46", "loki:s51ab6a62a6bd", "loki:sf4c3469d7ee1", "dskit:s96305c045ce4", "loki:sc2b013001317"], "status": "REASONED"}, "loki-ingester-store-range": {"text": "From source reading, when a Loki ingester queries the store for a log or sample selection, it moves the start of the store request up to now minus a positive store lookback, leaves it unchanged for -1 and other negative values, and skips the store query when that adjusted start is after the selection end.", "components": ["loki"], "sources": ["loki:s51ab6a62a6bd", "loki:s21c205d034b9", "loki:sd6184d6a8a46"], "status": "REASONED"}, "loki-ingester-lookback-bounds": {"text": "From source reading, when Loki's target list includes all but none of querier, ruler, read, index-gateway or backend, its boltdb-shipper or TSDB store setup with the filesystem object store rejects a positive query_store_max_look_back_period below the computed minimum. Per that reading, the same setup passes -1 and any other negative value through calculateMaxLookBack unchanged.", "components": ["loki"], "sources": ["loki:se82f6c344ae2", "loki:sa4685cf34525", "loki:s937747b2754f"], "status": "REASONED"}, "loki-querier-store-window": {"text": "From source reading, when the ingester store lookback that the Loki querier module copies at initialization is positive, the querier limits its own store query to the part of a selection before now minus that lookback, querying only ingesters when the selection starts after that point. Per that reading, a copy of exactly -1 instead skips the store and sends the whole selection to ingesters.", "components": ["loki"], "sources": ["loki:sf2a4759294c3", "loki:s529cd9a944d9", "loki:sdc0d0e6ad95d", "loki:s86fc597288e2", "loki:se3f01b0b8f64"], "status": "REASONED"}, "loki-querier-store-negative": {"text": "From source reading, when the ingester store lookback the Loki querier module copies at initialization is negative but not -1, now minus that lookback lies in the future, and the querier sets the end of any store request to that point without clamping it to the selection end. Per that reading, the Loki ingester, by contrast, leaves its store request start unchanged for every negative lookback.", "components": ["loki"], "sources": ["loki:sf2a4759294c3", "loki:s529cd9a944d9", "loki:sdc0d0e6ad95d", "loki:s86fc597288e2", "loki:se3f01b0b8f64", "loki:s51ab6a62a6bd"], "status": "REASONED"}, "loki-querier-ingester-inverted": {"text": "From source reading, if a Loki selection passing request validation reaches interval construction and the querier's initialization copy of the ingester store lookback is negative but not -1 with now minus it past the selection end, the querier starts its ingester interval there, ends it at the selection end without an order check and sends it to its ingester querier unless query_store_only is set.", "components": ["loki"], "sources": ["loki:sf2a4759294c3", "loki:s529cd9a944d9", "loki:scba306dcdc0c", "loki:sdc0d0e6ad95d", "loki:s10870cc18ddc", "loki:s8f53a03e764f", "loki:sb5b69814231d", "loki:s23b8da8ac6be"], "status": "REASONED"}, "loki-ingester-inverted-call": {"text": "From source reading, Loki's ingester querier passes the log or sample request it receives, including an inverted interval from the querier, to each queried ingester's gRPC Query or QuerySample call. Per that reading, the ingester module registers the ingester, or a wrapper Loki-embedding Go code may set, as the Querier service whose generated handlers decode the request and call those methods.", "components": ["loki"], "sources": ["loki:sdc0d0e6ad95d", "loki:s10870cc18ddc", "loki:s61e16a1ac1f0", "loki:sf253d44e375f", "loki:sfb58949fa28c"], "status": "REASONED"}, "loki-ingester-inverted-store": {"text": "From source reading, without a configured wrapper, a Loki ingester serving a log or sample request whose start is after its end makes no store query for it, whatever its store lookback: its store-request code moves a start only later, to now minus a positive lookback, and returns no store request when that adjusted start is after the end.", "components": ["loki"], "sources": ["loki:s51ab6a62a6bd", "loki:s21c205d034b9", "loki:sd6184d6a8a46"], "status": "REASONED"}, "loki-ingester-inverted-memory": {"text": "From source reading, without a configured wrapper, a Loki ingester's in-memory log or sample query skips every chunk of each index-matched stream unless the chunk's bounds start no later than the request end and end no earlier than the request start, so for an inverted interval it keeps only chunks spanning from that end to that start.", "components": ["loki"], "sources": ["loki:se2f420694eb9", "loki:se913abf5bf85", "loki:se1f0d83f115c", "loki:s2a80da1a36e8", "loki:sadb084375296", "loki:s0b40d7d25a3f", "loki:se34169c8b133"], "status": "REASONED"}, "loki-ingester-inverted-lookup": {"text": "From source reading, a Loki v3.7.8 ingester adds every stream to each index it builds, and its index lookup uses an inverted interval's start only to pick the index of the schema period containing it, for a future start the latest period starting at or before it. Per that reading, the lookup matches label matchers and shard without reading time, so the interval's order causes no lookup error.", "components": ["loki"], "sources": ["loki:sd5967b880c33", "loki:sc87fdc20cb58", "loki:s11cbfc4fcf89", "loki:sf674ae88a12c", "loki:s6bc46c6500c2", "loki:s140b0adc6723", "loki:s19918b3b217b", "loki:sadb084375296"], "status": "REASONED"}, "loki-ingester-inverted-iterators": {"text": "From source reading, Loki v3.7.8's ingester stream, chunk and head-block iterators neither swap nor clamp an inverted interval: they read a chunk, block or head block only if it starts no later than the interval's end and ends no earlier than its start. Per that reading, the v3.7.8 ordered head block's log iterator drops each entry before the interval's start or at or after its end.", "components": ["loki"], "sources": ["loki:se3affc4818e6", "loki:s693d6421c2f0", "loki:sd5181aa115e4", "loki:scc80bfb6f547", "loki:s05d6c5fc01a2", "loki:s291928ddc0b1", "loki:s33683997e787", "loki:s0b40d7d25a3f", "loki:se34169c8b133"], "status": "REASONED"}, "loki-ingester-inverted-time-filter": {"text": "From source reading, Loki v3.7.8's chunk time-range filters, used for block entries in both directions, the head block in forward log queries and all sample queries, skip entries before the start but accept one timestamped exactly at it before testing the end; in this guide's assessment, they pass only such entries for an inverted interval.", "components": ["loki"], "sources": ["loki:sd5181aa115e4", "loki:scc80bfb6f547", "loki:s0dbdc425ddbb", "loki:s05d6bac1c352"], "status": "REASONED"}, "loki-push-future-bound": {"text": "From source reading, Loki v3.7.8's distributor validates each pushed entry against a time.Now() context: a timestamp strictly newer than now plus the tenant's creation_grace_period (default 10m) is rejected as too far in the future, so one exactly at that bound passes. Per that reading, reject_old_samples (default true) rejects entries older than now minus reject_old_samples_max_age (default 7d).", "components": ["loki"], "sources": ["loki:sa66ae191eb87", "loki:s883c99aaf259", "loki:s03a9eb3c1800", "loki:s7be82cdc7279", "loki:sc1c59c06ac21", "loki:s9acb77638396", "loki:s7e5e17b46be6"], "status": "REASONED"}, "loki-ingester-push-validation": {"text": "From source reading, Loki v3.7.8's ingester gRPC push chain compares timestamps with stream state and schema configuration, never the clock, with no future timestamp bound. Unordered writes (default true) drop entries below the stream's highest timestamp minus half max_chunk_age (default 2h); ordered appends reject those older than the head block's newest or, head empty, the last block's maximum.", "components": ["loki"], "sources": ["loki:sa5963eda217e", "loki:s02173d6919e0", "loki:s6d778dd956ad", "loki:s3683552f47e7", "loki:s48eb47bcb74e", "loki:s1d09c7438f6c", "loki:s631c8b46da80", "loki:s6259f9f6561b", "loki:sef39e72a9288", "loki:sfc254eaeb3c3", "loki:s19e37d99684b"], "status": "REASONED"}, "loki-ingester-inverted-hold": {"text": "Without a Loki run, in this guide's source assessment, an ingester can hold an entry dated exactly at the inverted interval's future start when the lookback's magnitude is at most creation_grace_period, on one clock, grace unchanged. For a larger magnitude a direct ingester gRPC push can; in-tree Go Kafka producers add no route of their own; duplicate shifts are open.", "components": ["loki"], "sources": ["loki:s529cd9a944d9", "loki:s7be82cdc7279", "loki:s03a9eb3c1800", "loki:s9acb77638396", "loki:sa66ae191eb87", "loki:sa5963eda217e", "loki:s3683552f47e7", "loki:s9044377b42e9", "loki:sd2ef570a75c2", "loki:sd01827189b1e", "loki:s9c1fabf63f70", "loki:s93219a6da66c", "loki:se9b1d3173a5a", "loki:s1086bb06662d", "loki:s861664803499", "loki:sf9ef7591755e", "loki:sa050ceb04cea", "loki:s11d04b8a83cd", "loki:s6e92710b6c7d", "loki:s15fcd48516e5", "loki:sc6b2e0b6d26a", "loki:s7832fbb96765", "loki:s27e4e7b322b7", "loki:sa05dadacc9a7", "loki:sdfe8e95abe48", "loki:sb999c70880c3", "loki:s104fbc7a699f", "loki:s9b3d31b2b6c1", "loki:sd72e6a5f3e04", "loki:s9dbe31d6448e", "loki:s04c8e8dc2ec3"], "status": "REASONED"}, "loki-ingester-inverted-unordered": {"text": "From source reading, Loki v3.7.8's backward log queries do not time-filter a chunk's head block, and the unordered head block queries its vendored go-datastructures v1.1.7 range tree from an interval's start to one nanosecond before its end, both inclusive. For an inverted interval that query returns no entry, so the head block yields nothing.", "components": ["loki"], "sources": ["loki:sd5181aa115e4", "loki:s33683997e787", "loki:s69faadc4a015", "loki:s18bc063957e1", "loki:sf185bec82940", "loki:s015710936d80", "loki:saf4f45f2d5c7", "loki:sd8ad9fae3598", "loki:sd82568caaafc", "loki:s996300b7bdcf", "loki:s9a133bb92cd3"], "status": "REASONED"}, "loki-ingester-empty-send": {"text": "From source reading, Loki v3.7.8's ingester Query or QuerySample sends one empty batch with stats and warnings for an empty, error-free iterator if the context is not done, failing only on a Send error besides context.Canceled, like grpc-go's status error from Write on a done stream; no interceptor read wraps SendMsg. The ingester querier's iterator records those, yields nothing and ends cleanly.", "components": ["loki"], "sources": ["loki:s4b1dc6d8d7ec", "loki:sb50c698e0afb", "loki:s76e4e638e936", "loki:s5732ba30414b", "loki:sa1a1efa48033", "loki:s00b4ce2d9a6a", "loki:s9f438349ea61", "loki:sf89b7c829a53", "loki:s6c74d0f4adb3", "loki:sdf678b8f143f", "loki:s61e16a1ac1f0", "loki:se2deb786e148", "loki:s4be52dc17660", "loki:s3eb2a64252dc", "loki:sbe53a886169a", "loki:sfa17c8b9744c", "loki:s6bbef377347c", "loki:s958c3fc5fde9", "loki:s6621f46a53f1", "loki:s72e8d6673510", "loki:s807df01bc8a9", "loki:sf19870535c9b", "loki:s86a537881f1c", "loki:s2fdef6108709", "loki:s4011490e4c44", "loki:sc764d609c18a", "loki:sc7f949d05c7e", "loki:sbc58e5ae2f15", "loki:s88b28c9fa838", "loki:s86f5e4dbf938", "loki:sf1dbb24b6f89", "loki:s347ea47da1d9", "loki:scedf32486af2", "loki:s09f50c15300b", "loki:scf2ca760e412", "loki:s658bfc32b613", "loki:s1b47472c83c8", "loki:s0540308a9bbf", "loki:s369533e16eb3", "loki:s334ac63c0892", "loki:s68c48fdded52", "loki:sbf9e7851df52", "loki:s326c544e4f05", "loki:sb8b9a8832798", "loki:s434d9489a883", "loki:s093f0844b29b", "loki:sd0a99a08256a", "loki:s00fcdec5c4ec", "loki:sf18867aee842", "loki:sfc08964ae1ac", "loki:s0f81a8e67d8a"], "status": "REASONED"}, "loki-stream-context": {"text": "From source reading, Loki v3.7.8's vendored grpc-go chains stats handlers' TagRPC in order; the dskit server's TagRPC methods, the propagator Extract paths and the SDK, global and noop tracers' Start return their input or a context.WithValue descendant, outside paths named as unread. The status code of a send after a failed write-quota wait is not established.", "components": ["loki"], "sources": ["loki:sb5f9683bc990", "loki:s6f56d1ed6b3f", "loki:sfdb7d43a2980", "loki:s833a9f682fd8", "loki:s191e0362b7d4", "loki:s2c80d5c08178", "loki:seadda9b48aa2", "loki:s55353854b212", "loki:s2ff883be9ce6", "loki:sdb0262c336d5", "loki:sd28c548a76d6", "loki:s3bd2ef28f5eb", "loki:sf5441c22b843", "loki:s13c2f2df2b51", "loki:s5fbd8b2f869a", "loki:se5dc791277fb", "loki:sb85966a1647c", "loki:s8caa30d7268e", "loki:s1d5e4de8178c", "loki:s816ec3b28f7a", "loki:s87a488ab017d", "loki:s195be863483a", "loki:sc721ca2d10be", "loki:s98ea18f5a30e", "loki:s6dea674467e6", "loki:sfe1b650e2542", "loki:sec27b6b62b83", "loki:s7f484d800206", "loki:s1f761db14084", "loki:s5f45265cff52", "loki:s50e474e6a856", "loki:sdd4428846203", "loki:sdf285f2c696c", "loki:sda7c216e072c", "loki:s8e15e7dac6b5", "loki:s537caf6030ab", "loki:sfca43a4aca4a", "loki:s997c8175cf06", "loki:s5f6956abae16", "loki:s40b82e4c0abc", "loki:sf01b2f9a53a9", "loki:s2157d4c926e7", "loki:s16dba5c6e972"], "status": "REASONED"}, "loki-tracing-install": {"text": "From source reading, Loki v3.7.8's main calls dskit's NewOTelOrJaegerFromEnv when tracing.enabled (default true) is set. If a JAEGER_AGENT_HOST, JAEGER_ENDPOINT, JAEGER_SAMPLER_MANAGER_HOST_PORT, OTEL_TRACES_EXPORTER or OTLP endpoint variable is non-empty and setup succeeds, it installs an otelpyroscope-wrapped SDK tracer provider and a composite propagator set by OTEL_PROPAGATORS; else none.", "components": ["loki"], "sources": ["loki:sde5d4b0f4af8", "loki:se0352cf5607d", "loki:sbc50b29a9eb0", "loki:s61aa9f686df5", "loki:sdbb2abd16b84", "loki:s98651a70b4fa", "loki:s339be8c4f2aa", "loki:s5b16e21a3e40", "loki:sfca43a4aca4a"], "status": "REASONED"}, "loki-querier-inverted-merge": {"text": "From source reading, a Loki v3.7.8 querier merges the ingester querier's per-ingester iterators for a log or sample selection with the iterator of any store query it makes for that selection, which it skips when query_ingester_only is set or it builds no store interval; a log selection with only one iterator gets that iterator unmerged.", "components": ["loki"], "sources": ["loki:s8f53a03e764f", "loki:sdc0d0e6ad95d", "loki:s86fc597288e2", "loki:s2d763a8942ef", "loki:s10870cc18ddc", "loki:se3f01b0b8f64", "loki:s6f04b102b391", "loki:s61e16a1ac1f0", "loki:s529cd9a944d9"], "status": "REASONED"}, "loki-ingester-wrapper-search": {"text": "From source reading, a word search for Wrapper in Loki v3.7.8 outside vendor finds only Ingester.Wrapper's declaration (no YAML key), its one-method Wrap interface and initIngester's nil check and Wrap call, besides comments and source maps, and no vendored package imports Loki's ingester or loki package; nothing in the repository sets it.", "components": ["loki"], "sources": ["loki:s24cd2d4a2bce", "loki:sd7efa15100d2", "loki:sf253d44e375f"], "status": "REASONED"}, "loki-querier-ingester-inverted-result": {"text": "Source reading of Loki's querier, ingester querier and ingester query code, with no Loki run, does not establish what they return for the inverted ingester interval a negative store lookback besides -1 yields: in this guide's assessment ingesters can hold entries dated exactly at its start when the lookback's magnitude is within the grace period, on one clock, grace unchanged; no run observed it.", "components": ["loki"], "sources": ["loki:s529cd9a944d9", "loki:sdc0d0e6ad95d", "loki:s10870cc18ddc", "loki:s61e16a1ac1f0", "loki:sadb084375296", "loki:s0b40d7d25a3f", "loki:se34169c8b133", "loki:sd5181aa115e4", "loki:s33683997e787", "loki:s69faadc4a015", "loki:sa66ae191eb87", "loki:s7be82cdc7279", "loki:s9acb77638396"], "status": "REASONED"}, "loki-internal-server": {"text": "From source reading, Run initializes target modules and starts them together. internal_server is off by default; when enabled it precedes Server and its HTTP listener defaults to localhost:3101.", "components": ["loki", "dskit"], "sources": ["loki:sb7c90a61ae40", "loki:scf7d98f51caa", "loki:sf4c3469d7ee1", "loki:sd4f78f9689f6", "loki:sd46d55125044", "loki:s9fd7d8bd80e3", "loki:sf19909de84b7", "loki:s319f6b7b5340", "dskit:s7848c6311aee"], "status": "REASONED"}, "loki-internal-server-grpc": {"text": "From source reading and Go's net.Listen docs, the internal server's flags and config_wrapper.go set no gRPC network, address or port, and initInternalServer passes that configuration to dskit's server.New, so unless YAML sets them, dskit opens an any-address tcp listener on an OS-chosen port carrying the HTTP bridge; not observed.", "components": ["loki", "dskit", "go"], "sources": ["loki:scf7d98f51caa", "loki:sd46d55125044", "loki:s9fd7d8bd80e3", "loki:sf19909de84b7", "loki:s4b5a89431203", "loki:s309318e5a59c", "loki:s39d479c8ba94", "dskit:s7848c6311aee", "dskit:s5fd7b12bfcf1", "go:s37204ff1b27c"], "status": "REASONED"}, "loki-internal-routes": {"text": "When enabled, the internal server's HTTP router serves routes including /ready and buildinfo and, when their modules run, /ring, /memberlist and the compactor handler prefix; these are source registrations, not observed responses.", "components": ["loki"], "sources": ["loki:sb7c90a61ae40", "loki:s319f6b7b5340", "loki:sb6c261fe940d", "loki:s81d8d94c3cea"], "status": "REASONED"}, "loki-target-modules": {"text": "From source reading, write depends on the ingester, distributor and pattern ingester, read on the query frontend and querier, backend on the query scheduler, ruler, compactor, index gateway and bloom modules, and all on read's and write's modules plus the scheduler, ruler, compactor and UI. dskit runs each init once, dependencies first.", "components": ["loki", "dskit"], "sources": ["loki:se7ef0218d7c5", "loki:sf4c3469d7ee1", "loki:s3eb2a64252dc", "loki:s80b37692fda0", "loki:sb7c90a61ae40", "dskit:s96305c045ce4"], "status": "REASONED"}, "loki-grpc-services": {"text": "From source reading, Loki's init functions register gRPC services only on the main server, some only when enabled: distributor and ingester Pusher, ingester Querier and StreamData, pattern, ingest limits, frontend, scheduler, ruler, compactor, job queue, bloom gateway, index gateway and bloom planner. A non-test search finds their callers only in Loki's own clients and a stream generator tool.", "components": ["loki"], "sources": ["loki:s9f316e1a5fef", "loki:sc2b013001317", "loki:s6b2a91bbd70f", "loki:sd4bf25cf533c", "loki:s6804d0b9ca37", "loki:saa25f09d653e", "loki:s2b784ff2e354", "loki:s1f38431776f2", "loki:s11c2c89339af", "loki:seaa8a690e518", "loki:s4a0aa7492160", "loki:s9a1dda234f64", "loki:sa19fa92e5d02", "loki:s61e16a1ac1f0", "loki:s6ae16575c268", "loki:s82a976e6b93c", "loki:s47c05209cd31", "loki:sc2705694b8c1", "loki:s34e985ffc25f", "loki:scdd1efa4b625", "loki:s9637e24d4a76", "loki:sfbbe8d6f0c8b", "loki:sb7905b55bf8a", "loki:sde152f1440c8", "loki:s158db2e4f90c", "loki:s5391a2d500b5", "loki:s2c42813fbbc1", "loki:s19b349e08fd1", "loki:s8c054d8b740d", "loki:sfca9a77e69b1", "loki:s9de56a5b8b27", "loki:s61779f8269a0"], "status": "REASONED"}, "loki-grpc-tls": {"text": "Inferred from dskit's server TLS source, gRPC TLS requires compatible TLS on every internal client; local users can reach plaintext loopback and, per Loki's authentication documentation, mTLS does not supply tenant headers.", "components": ["dskit", "loki"], "sources": ["dskit:sd35a28be4391", "loki:s70975c2655fa"], "status": "REASONED"}, "loki-proxy": {"text": "An authenticating proxy must overwrite X-Scope-OrgID with its identity and be the only caller that can reach Loki.", "components": ["loki"], "sources": ["loki:s70975c2655fa"], "status": "REASONED"}, "verify-inventory": {"text": "Inventory every TCP/UDP listener; wildcard defaults, all Loki listeners and off-host isolation were not observed.", "components": ["node", "alert", "push", "jaeger", "dskit"], "sources": ["node:s896899f5a500", "alert:s0fd66cba968e", "push:s5c9f9745f42d", "jaeger:s5619bba2be59", "dskit:sb19d54a19636", "dskit:s23d81aeedf07"], "status": "REASONED", "verify": [1]}, "verify-external": {"text": "External 200 exposes unauthenticated HTTP; HTTPS 401 shows auth, not isolation. Confirm connection failures and a permitted positive control.", "components": ["security", "toolkit", "jaeger-docs"], "sources": ["security:s3ffe42e99660", "toolkit:sbb86207a38e5", "jaeger-docs:s4969c5ffaa59"], "status": "REASONED", "verify": [2]}, "verify-loki": {"text": "A tenant-header rejection is not authentication; repeat with an arbitrary X-Scope-OrgID and test both families, gRPC and internal RPCs.", "components": ["loki", "curl"], "sources": ["loki:s70975c2655fa", "loki:s057e686b00bb", "curl:s3db522258e44"], "status": "REASONED", "verify": [2]}, "verify-basic": {"text": "Loopback HTTPS GETs on node/Pushgateway metrics, Alertmanager status and Jaeger v3 services gave no-credential 401 and credentialed 200. The runs covered an earlier form, whose guard refusals ended with status 0 and whose results set no exit status; gate bash -n and ShellCheck passes cover the current form.", "components": ["node", "push", "alert", "jaeger", "toolkit"], "sources": ["node:s896899f5a500", "push:s5c9f9745f42d", "alert:s120d6ecf3fcd", "jaeger:sc6f47d82b947", "toolkit:sbb86207a38e5"], "status": "DEMONSTRATED", "evidence": "The loopback runs gave exactly that pair over HTTPS for GETs of node_exporter `/metrics`, Pushgateway `/metrics`, Alertmanager `/api/v2/status` and Jaeger `/api/v3/services`.", "verify": [3]}, "verify-loki-mtls": {"text": "Authorized HTTP mTLS should succeed, untrusted/missing client certs fail handshake and plaintext fail; no Loki listener/TLS run occurred.", "components": ["loki", "dskit"], "sources": ["loki:s70975c2655fa", "dskit:sd35a28be4391"], "status": "REASONED"}, "verify-secret": {"text": "Basic-auth stdin closes argv exposure only; shell history, tracing and your own account's view of the process can still leak the password.", "components": ["toolkit", "curl"], "sources": ["toolkit:sbb86207a38e5", "curl:s5e1383feef7d", "curl:s82a34ee18632"], "status": "REASONED"} } } ```` ====================================================================== ==> devops-uis.md ====================================================================== # DevOps panels: Portainer, Coolify, Dokploy, Nginx Proxy Manager, Vaultwarden, Kubernetes Dashboard, Jenkins, Gitea, Uptime Kuma, and the Docker API Portainer, Coolify, Dokploy, Nginx Proxy Manager, Vaultwarden, Kubernetes Dashboard, Jenkins, Gitea, GitLab CE, Uptime Kuma, Dozzle, Docker Registry, Filebrowser, Node-RED and Docker's API handle infrastructure, monitoring or secrets. This guide helps your AI assistant secure each, including private access, authentication, human MFA and any first-visit admin account claimed before exposure. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/devops-uis.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Portainer (no recorded version), Dashboard arguments v2.7.0, Gitea source v28.3.0, Gitea 1.x source v1.27.3, Gitea MFA minimum 1.24, and Apache htpasswd 2.4; 2 further sources are pinned to a commit and 14 carry no recorded version. Metadata reviewed 2026-11-04; documentation checked September 2026. The guide records 63 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These panels control hosts, containers, clusters, deploy keys, and secrets; a login to one of them is a login to everything it manages. One rule dominates everything tool-specific below: **a DevOps panel is never reachable from the public internet.** Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)), or Cloudflare Access ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)); anything that must be public sits behind a TLS proxy with its own authentication ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md)) and the admin login gets MFA ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). Several of these tools create their administrator on first visit, so whoever reaches a fresh install first owns it: create the account before the port is reachable by anyone else. ## Portainer - Serves the UI over HTTPS on `9443` (self-signed by default; supply your own certificate or front it per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)). `9000` is the legacy HTTP port and stays unpublished; `8000` is the Edge agent tunnel and is only published when Edge Compute is in use. - New instances require a setup token to complete first-time setup; it is in the server logs on the `setup_token=` line. The first user is an administrator, and its password must be at least 12 characters. - Authentication is internal, LDAP, Active Directory, or OAuth (Microsoft, Google, GitHub, or a custom provider). Portainer's own login has no second factor in its documentation; use OAuth against a provider that enforces MFA ([identity-providers.md](https://github.com/jposluns/secureconfig/blob/main/identity-providers.md)). - The container mounts `/var/run/docker.sock`, which is root on the host; a Portainer admin is a host root user ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). ## Coolify and Dokploy Both are one-script installs that hold your servers' SSH private keys, Git provider credentials, and application secrets, and both create their administrator on first visit. - Coolify's dashboard answers on `8000` over plain HTTP after install. Its documentation says to create the admin account immediately, because whoever reaches the registration page first can gain full control of the server. Set the instance a custom domain on the `/settings` page; the integrated proxy (Traefik by default, or Caddy) then issues and renews a Let's Encrypt certificate, and the firewall guide says ports `8000`, `6001`, and `6002` can be closed once the dashboard is reached through that domain. Docker's iptables rules bypass UFW, so restrict these ports with the cloud provider's firewall ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md)). Coolify requires the server SSH key to have no passphrase, so the key material inside Coolify is the whole secret. - Dokploy's UI answers on `3000`; ports `80` and `443` belong to its Traefik. The first visit is the setup page that creates the admin account. Configure a domain with a Let's Encrypt or custom certificate for the panel under Domains, then remove the published `3000` binding so the panel is reachable only through the proxy. ## Nginx Proxy Manager The admin UI is on port `81`; the proxy itself is on `80`/`443`. Port `81` is never published to the internet: bind it to `127.0.0.1` in the compose file (`'127.0.0.1:81:81'`) and reach it through a tunnel. A default admin user is created on the first run; change the initial admin credentials on first login, before anything else. Since it terminates TLS for every site behind it, treat its login like a root password. ## Vaultwarden - The web vault needs HTTPS (browsers expose the crypto APIs it uses only in secure contexts). The wiki recommends a reverse proxy for TLS ([caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)) and rates the built-in `ROCKET_TLS` as not recommended. Set `DOMAIN=https://vault.example.com`. - `SIGNUPS_ALLOWED=false` (the default is `true`, letting anyone who reaches the instance register). Organization owners and admins can still invite users while `INVITATIONS_ALLOWED=true`; `SIGNUPS_DOMAINS_WHITELIST` admits specific email domains. - The admin page is disabled unless `ADMIN_TOKEN` is set. Store it as an argon2id PHC string generated with `vaultwarden hash` (or `docker run --rm -it vaultwarden/server /vaultwarden hash`), never plaintext; in a compose `environment:` block every `$` in the hash becomes `$$`. Enter the password at the tool's prompt; Sources has no reference for `vaultwarden hash` prompting (its Vaultwarden entry is described as covering the admin page and `ADMIN_TOKEN`, registration, HTTPS and the `.env.template`), so confirm that it does before relying on it. A prompt keeps the password out of the command text, shell history and shell tracing; the account owner and root can still read it in the `vaultwarden` process while it runs, and in the `docker run -it` form the `docker` client relays it, so anyone who can use the Docker daemon can read it too. Enable HTTPS before enabling the admin page. Admin sessions expire after 20 minutes by default. - Inside a container it listens on `80` (`8000` outside Docker); publish it only to loopback or the proxy network. ## Kubernetes Dashboard - As of September 2026 the Kubernetes documentation marks the Dashboard deprecated and unmaintained (the repository was archived in January 2026) and points new installs to Headlamp; the pattern below applies to any cluster UI. - Do not expose it with a LoadBalancer or Ingress. Reach it from the operator's machine with `kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443` and open `https://localhost:8443`; the UI is then reachable only from that machine. - Login is by bearer token of a ServiceAccount with a minimal RBAC role; the tutorial's sample user is cluster-admin and is for demonstration only. On the older 2.x releases, `--enable-skip-login` and `--enable-insecure-login` both default to `false`; never turn them on. - A ServiceAccount token is a machine credential, so configuring OIDC on the API server puts no MFA on this login. Human MFA comes from the access path (a port-forward over SSH from a machine that already required it, a tailnet per [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md), or Cloudflare Access per [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)) or from a UI that performs an identity-provider login itself; see [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md). ## Jenkins - Authentication (the security realm: Jenkins' own user database, LDAP, and others) and authorization (the strategy) are configured separately. The setup wizard leaves a single admin in the local database; do not enable account signup for that database, since new accounts inherit whatever the strategy grants to authenticated users. - Use the Matrix Authorization Strategy (global or project-based) and grant nothing significant to `anonymous` or to `authenticated`; granting Overall/Administer to anonymous is the same as "Anyone can do anything". - Leave CSRF protection on. It has no UI switch and is only disabled by the `hudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION` system property; the documentation says to keep it enabled even on private networks. - Inbound agents use a fixed or random TCP port, or WebSocket over the same HTTPS port with no extra listener; prefer WebSocket or keep the agent port on the private network. - Jenkins has no native second factor; put the web UI behind SSO with MFA at the provider or behind an identity layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). ## Gitea - It listens on `HTTP_ADDR = 0.0.0.0` and `HTTP_PORT = 3000` by default (both as of v28.3.0, and the same at v1.27.3); set `HTTP_ADDR = 127.0.0.1` behind a proxy, or `PROTOCOL = https` with `CERT_FILE` and `KEY_FILE` to terminate TLS itself. - In `[service]`, set `DISABLE_REGISTRATION = true` and, for a private forge, `REQUIRE_SIGNIN_VIEW = true` (default `false`). The code default of `DISABLE_REGISTRATION` differs between the two releases this guide covers. At v28.3.0 Gitea reads it with `sec.Key("DISABLE_REGISTRATION").MustBool(true)`, a default of `true`, although that release's `app.example.ini` still shows `;DISABLE_REGISTRATION = false`. At v1.27.3 it reads `sec.Key("DISABLE_REGISTRATION").MustBool()`, which this guide reads as a default of `false`, the value that release's `app.example.ini` shows. Which release after v1.27.3 first defaults to `true` is not established. - At both releases the install wizard starts its registration checkbox from the loaded setting (`form.DisableRegistration = setting.Service.DisableRegistration`) and writes the submitted value to `app.ini` as an explicit setting (`cfg.Section("service").Key("DISABLE_REGISTRATION").SetValue(strconv.FormatBool(form.DisableRegistration))`); it handles `REQUIRE_SIGNIN_VIEW` the same way. At both releases the wizard then applies the environment settings to the configuration again before saving it, which a v28.3.0 code comment says must override the install form, so by that comment an environment setting for `DISABLE_REGISTRATION` overrides the submitted value. As this guide's inference, a wizard install that leaves the checkbox as first shown writes `false` at v1.27.3 and `true` at v28.3.0, unless an existing setting changed what it showed or an environment setting overrides the submitted value, so read `DISABLE_REGISTRATION` in `app.ini` after installing instead of relying on either default. - Users enrol TOTP or a WebAuthn key under Settings > Security; `TWO_FACTOR_AUTH = enforced` in `[security]` (Gitea 1.24 and later) requires it. Gitea's source reads `TWO_FACTOR_AUTH` at v1.24.0 and v28.3.0 and not at v1.23.8; whether a later 1.23.x release added it is not established. With MFA on, Git over HTTP uses an access token instead of the password, and tokens bypass MFA, so scope them and revoke unused ones ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)). - At v28.3.0, with the same token-first order and the same 2FA checks at v1.27.3, Gitea's Basic authentication method, which its code comment describes as for API requests only, first tries the credential as an OAuth2, personal access or Actions task token, a path with no 2FA check. Only after that, and only while `ENABLE_BASIC_AUTHENTICATION` (default `true`) is on, does it try a password. Unless the user's sign-in source skips 2FA, it then refuses the password while a WebAuthn key is enrolled and, with TOTP enrolled, requires a valid code in an `X-Gitea-OTP` header. Whether Git over HTTP uses this method is not established, and neither is whether `TWO_FACTOR_AUTH = enforced` refuses the tokens of users who have not enrolled; the v28.3.0 `app.example.ini` says users without 2FA "have no access to repositories via API or web". ## GitLab CE - First run auto-generates the root password into `/etc/gitlab/initial_root_password` (read it with `docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password`); GitLab deletes the file in the first container restart after 24 hours. Change it immediately on first sign-in, since until you do the generated password is the only thing guarding the root account; or preset it with `gitlab_rails['initial_root_password']` in `gitlab.rb` or `GITLAB_OMNIBUS_CONFIG`. - `signup_enabled` defaults to `true`, so any visitor can register. New instances hold registrants pending approval (`require_admin_approval_after_user_signup` is on by default), but for a private forge clear "Allow new user accounts" under Admin > Settings > General > New user account restrictions (`signup_enabled = false`) rather than relying on approval discipline. - `default_project_visibility` and `default_group_visibility` both default to `private`; keep them there. "Internal" means visible to every signed-in non-external user, so on an instance that allows sign-up, internal is not private. - GitLab can enforce two-factor authentication for every user (all tiers) from Admin > Settings > General > Sign-in restrictions; turn it on so local accounts are not password-only. ## Uptime Kuma Listens on `3001` and is WebSocket-based, so a reverse proxy in front needs the `Upgrade` and `Connection` headers. Open it right after the first start and finish the account setup before anyone else can; then enable the 2FA the project lists as a feature for that account. Status pages can be public; the dashboard is not. ## The Docker API The daemon socket is root on the host: anyone who can talk to it can run a privileged container. Never start `dockerd` with `-H tcp://0.0.0.0:2375`; Docker's documentation calls remote access without TLS not recommended, and scanners find open 2375 within minutes. Two acceptable remote paths: ```bash # 1. SSH to the Unix socket (nothing new listens on the network) export DOCKER_HOST=ssh://docker-user@host1.example.com docker context create remote --docker host=ssh://docker-user@host1.example.com # 2. TLS with client certificates on 2376, all four flags set dockerd --tlsverify --tlscacert=ca.pem --tlscert=server-cert.pem --tlskey=server-key.pem -H=0.0.0.0:2376 export DOCKER_HOST=tcp://$HOST:2376 DOCKER_TLS_VERIFY=1 DOCKER_CERT_PATH=~/.docker/zone1/ ``` Without `--tlsverify` the daemon does not check client certificates. Firewall 2376 to the operator addresses even with TLS ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)). ## Dozzle - Dozzle reads the Docker socket to show logs, the same host-level access the Docker API section above describes ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)); a Dozzle login is a login to the host. - Authentication is off unless configured. Generate a `users.yml` with `docker run -it --rm amir20/dozzle generate admin > users.yml`, mount that file into the container, and set `DOZZLE_AUTH_PROVIDER=simple`; or set `DOZZLE_AUTH_PROVIDER=forward-proxy` to delegate login to a fronting proxy such as Authelia, Authentik, or Cloudflare Access. The `users.yml` generation step is to omit `--password`, and Dozzle then prompts for the password on stdin, so the password never lands in shell history or `set -x` tracing. Stdin closes the argv channel only, so the password stays visible to your own account and root while the `docker` client relays it, and to anyone who can use the Docker daemon. - Container actions (start, stop, recreate) and shell access into a running container can be turned on; leave both off unless a specific workflow needs them, since either turns a log viewer into remote command execution on the host. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer, like every panel above. ## Docker Registry (`registry:2`) - The reference registry image ships with no authentication at all: anyone who reaches the port can push and pull every image, and TLS must be configured before any authentication scheme works, since credentials would otherwise cross the wire in clear text. - Restrict access with htpasswd basic authentication, a token server, or a registry distribution that has its own authentication built in. If using the registry's own native htpasswd auth provider (set directly in the registry's `config.yml`), credentials must be bcrypt-hashed at a strong cost (bare `-B` is cost 5, below the OWASP minimum of 10); the registry rejects any non-bcrypt hash format. Generate each entry with `htpasswd -B -C 12`, using its interactive password prompt and the intended password-file path and username, never `-b`, which takes the password as an argument. The prompt keeps the password out of the command text, shell history and shell tracing; the account owner and root can still read it in the `htpasswd` process while it runs. If instead a reverse proxy sits in front and does its own basic authentication from its own htpasswd file, that proxy's own hashing rules apply, not the registry's. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer. ## Filebrowser - Ships with a default administrator account created on first run (historically `admin`/`admin`). Change it immediately, before the instance is reachable by anyone else. The project's own repository (archived on September 1, 2026) says not to expose it directly to the internet. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer; never publish a file-serving admin panel. ## Node-RED - The editor and admin API on `1880` have no authentication at all by default; anyone who reaches the port can view, deploy, and modify flows. - Set `adminAuth` in `settings.js` with bcrypt-hashed user passwords (`node-red admin hash-pw` generates the hash), and set `credentialSecret` to a value you control, since Node-RED otherwise generates one for you and stored credentials are only as protected as that secret. Give `hash-pw` the password at the tool's prompt, not as an argument; Sources has no reference for `hash-pw` prompting (its Node-RED entry is described as covering `adminAuth` and `credentialSecret`), so confirm that it does before relying on it. A prompt keeps the password out of the command text, shell history and shell tracing; the account owner and root can still read it in the `node-red` process while it runs. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer; the editor is equivalent to a shell on whatever the flows can reach. ## Verify REASONED: following block; listener check, per the panel, Docker daemon and GitLab sources below, which also support the panel-login, 2375/2376 and password-file checks in the three blocks after it; each of the four blocks carries its own marker and exit status. No deployed panels, certificate fixture or external test network is available here. Comments give expected outcomes and limits; no live run is recorded. The panel-login curl takes one panel's host name on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains; that guard and the Docker block's address and certificate-file guards have been run only under stubs, without network access. ```bash ( # a subshell, so the exits below end only this check command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0; a missing ss has already exited 2 above), then the listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the listener table, then ss's exit status printf '%s' "$2" # read every listener; 9443/9000/8000/3000/81/3001/2375/2376: 127.0.0.1 or absent, never 0.0.0.0 [ "$3" = 0 ] || { echo "ss -tlnp failed; inconclusive"; set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:(9443|9000|8000|3000|81|3001|2375|2376)$/ && (index($4, "0.0.0.0:") == 1 || index($4, "*:") == 1 || index($4, "[::]:") == 1) { found = 1 } END { exit !found }'; then echo "FINDING: a listener on port 9443, 9000, 8000, 3000, 81, 3001, 2375 or 2376 is bound to a wildcard address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi echo "block exit $1; a listener on those ports bound to a specific non-loopback address, and every listener on other ports, is not classified, so judge it yourself" exit "$1" ) ``` ```bash # REASONED: panel-login check from the cited Portainer, Jenkins and Gitea sources; no deployed panel or external test network is available, the guard has been run only under stubs, and no live result is recorded. ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PANEL_HOST' # the public host name your proxy serves one panel on, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them, and run the block once per panel [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" "$(if curl -q -g -s -o /dev/null -w '%{http_code}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%%$'\n'*}" # the host, curl's exit status, then the HTTP code printf '%s\n' "$3" # 401, 403, or a login redirect, never a dashboard. No -k: # this panel is behind a proxy holding a real certificate, so # a check that skips verification proves nothing about it case "$2:$3" in 0:401|0:403) set -- "$1" 0 ;; 0:3??) echo "HTTP $3, a redirect: unclassified observation; it passes only when it leads to the panel's login page, so follow it yourself"; set -- "$1" 0 ;; 0:2??) echo "HTTP $3: unclassified observation; open the page yourself, because a dashboard, or host, container or repository data shown without a login, is a finding"; set -- "$1" 0 ;; 0:*) echo "HTTP $3 is neither a 401 or 403 refusal, a redirect nor a page to judge; inconclusive"; set -- "$1" 3 ;; *) echo "curl exit $2: the request failed, a certificate that did not verify included, since this check keeps certificate verification on; inconclusive"; set -- "$1" 3 ;; esac echo "block exit $2; a redirect or a 2xx page is not classified, so judge it as the line above says" exit "$2" ) ``` ```bash # REASONED: Docker 2375 refusal and 2376 client-certificate comparisons from the cited Docker daemon sources; no daemon, certificate fixture or external test network is available, and no live result is recorded. # clears a pre-set declare -i or -l attribute, and any stale # value; copy this whole block, not just the command below ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' # the address the daemon answers on, for both 2375 and 2376: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 # the 2nd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (the 2376 part refused), which outranks 0 if env -u DOCKER_HOST -u DOCKER_TLS_VERIFY -u DOCKER_CERT_PATH docker -H "tcp://$1:2375" info; then # must fail: connection refused or filtered; env -u drops the TLS settings exported in the setup above echo "FINDING: the Docker API answered on 2375 without TLS"; set -- "$1" 1 else set -- "$1" "$2" "$?" # the address, the status so far, then docker's exit status # that is, refused or filtered BY THE REMOTE HOST. A local error or a docker client failure that # never opened a connection is inconclusive, not a pass case "$3" in 126|127) echo "docker exit $3: the docker client could not run, so no connection was opened; inconclusive"; set -- "$1" 3 ;; *) echo "docker exit $3: unclassified observation; it is the pass only if the error above shows the remote host refused or filtered the connection"; set -- "$1" "$2" ;; esac fi if [ -r ca.pem ] && [ -r client-cert.pem ] && [ -r client-key.pem ]; then set -- "$1" "$2" "$(if env -u DOCKER_HOST -u DOCKER_TLS_VERIFY -u DOCKER_CERT_PATH \ curl -q -g -sS -o /dev/null -w '%{http_code}\n' --cacert ca.pem "https://$1:2376/_ping"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # the address, the status so far, curl's exit status, then the HTTP code printf '%s\n' "$4" # must fail the handshake on the CLIENT certificate. Do not use # `docker ... info` here: without `--tlsverify` it fails for the # wrong reason, and `DOCKER_CERT_PATH` exported in the setup above # can silently supply the very certificate the check is meant to lack case "$3" in 0) echo "FINDING: 2376 answered HTTP $4 without a client certificate, so the daemon does not require one"; set -- "$1" 1 ;; 6|7|28) echo "curl exit $3: the request without a client certificate never reached the handshake; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: unclassified observation; it is the pass only if curl's error above shows the handshake failed on the client certificate"; set -- "$1" "$2" ;; esac set -- "$1" "$2" "$(if curl -q -g -sS -o /dev/null -w '%{http_code}\n' --cacert ca.pem --cert client-cert.pem --key client-key.pem \ "https://$1:2376/_ping"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # positive control: 200 with the right client certificate set -- "$1" "$2" "${3##*$'\n'}" "${3%%$'\n'*}" # the address, the status so far, curl's exit status, then the HTTP code printf '%s\n' "$4" case "$3:$4" in 0:200) ;; 0:*) echo "HTTP $4 with the client certificate is not the 200 positive control; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; *) echo "curl exit $3: the request with the client certificate failed, so the positive control failed; inconclusive"; [ "$2" = 1 ] || set -- "$1" 3 ;; esac set -- "$1" "$2" else # a guard: the 2376 checks rely on the setup's certificate files in this directory echo "ca.pem, client-cert.pem and client-key.pem are not all readable in this directory, so the 2376 checks did not run; not probing 2376" [ "$2" = 0 ] && set -- "$1" 2 fi echo "block exit $2; docker's error on 2375 and curl's error on 2376 without a client certificate are not classified, so read them as the lines above say" exit "$2" ) ``` ```bash # GitLab CE: initial root password file check (needs Docker access; the container is named gitlab) # absence does not prove the password was rotated; presence means rotate root now # status: 1 when the file is present, 3 when the check did not run, 0 when the file is absent ( # a subshell, so the exit below ends only this check set -- "$(if docker exec gitlab sh -c 'test -f /etc/gitlab/initial_root_password && echo PRESENT || echo ABSENT' 2>/dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1%%$'\n'*}" "${1##*$'\n'}" # the container's reply, then docker exec's exit status if [ "$2" = 0 ]; then case "$1" in PRESENT) echo 'initial_root_password present: rotate root and remove it'; exit 1 ;; ABSENT) echo 'initial_root_password absent' ;; *) echo 'the check did not run (unexpected reply from the gitlab container): not a result'; exit 3 ;; esac else echo 'the check did not run (docker exec failed for the gitlab container): fix the container, this is not a result' exit 3 fi ) ``` REASONED: preceding block; the GitLab initial-password-file check follows the cited GitLab source, and the listener and panel-login checks, Docker 2375 refusal and 2376 client-certificate comparisons in the blocks above follow the cited panel and Docker daemon sources; no deployed panels, certificate fixture or external test network is available, and no live result is recorded. From outside the network, every panel URL is unreachable or shows a login; a page that renders host, container, or repository data without one is a finding. Each of the four blocks exits 1 for a finding, 3 for a failed or inconclusive check and otherwise 0, and a guard refusal exits 2, as the listener block does when ss is not installed; the Docker block also reports 2 when its certificate files are missing and nothing else in it was found or inconclusive. The last line of the listener, panel-login and Docker blocks names what each leaves for you to judge. ## Sources (checked September 2026) - Portainer CE install on Docker (ports 9443, 9000, 8000): https://docs.portainer.io/start/install-ce/server/docker/linux - Portainer initial setup (setup token, first admin, 12-character password): https://docs.portainer.io/start/install-ce/server/setup - Portainer authentication and OAuth providers: https://docs.portainer.io/admin/settings/authentication and https://docs.portainer.io/admin/settings/authentication/oauth - Coolify installation, firewall, proxy, and DNS pages: https://coolify.io/docs/start-with-self-hosted , https://coolify.io/docs/core/infrastructure/servers/firewall , https://coolify.io/docs/core/networking/proxy/overview , https://coolify.io/docs/core/networking/dns , https://coolify.io/docs/core/infrastructure/servers/openssh - Dokploy installation (ports 80, 443, 3000; admin setup; panel domain): https://docs.dokploy.com/docs/core/installation - Nginx Proxy Manager setup (port 81, default admin user): https://nginxproxymanager.com/setup/ - Vaultwarden wiki: admin page and ADMIN_TOKEN https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page , registration https://github.com/dani-garcia/vaultwarden/wiki/Disable-registration-of-new-users , and HTTPS https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS. The Vaultwarden `.env.template`: https://github.com/dani-garcia/vaultwarden/blob/3347698712d3e99e652ad4394ef2fc0ce2800fdd/.env.template - Kubernetes Dashboard (deprecation, port-forward, token login): https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/ ; 2.x arguments: https://github.com/kubernetes-retired/dashboard/blob/v2.7.0/docs/common/dashboard-arguments.md - Jenkins security: https://www.jenkins.io/doc/book/security/managing-security/ , https://www.jenkins.io/doc/book/security/access-control/ , https://www.jenkins.io/doc/book/security/csrf-protection/ - Gitea config cheat sheet and MFA (`TWO_FACTOR_AUTH=enforced` requires Gitea 1.24 or later): https://docs.gitea.com/administration/config-cheat-sheet/ and https://docs.gitea.com/usage/user-setting/multi-factor-authentication/ - GitLab CE initial root password (Docker install): https://docs.gitlab.com/install/docker/installation/ - GitLab CE sign-up restrictions: https://docs.gitlab.com/administration/settings/sign_up_restrictions/ - GitLab CE default visibility and access controls: https://docs.gitlab.com/administration/settings/visibility_and_access_controls/ - GitLab CE enforce two-factor authentication for all users: https://docs.gitlab.com/security/two_factor_authentication/ - Uptime Kuma README and reverse proxy wiki: https://github.com/louislam/uptime-kuma and https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy - Docker: protect the daemon socket https://docs.docker.com/engine/security/protect-access/ and remote access https://docs.docker.com/engine/daemon/remote-access/ - Dozzle authentication (DOZZLE_AUTH_PROVIDER, users.yml, actions and shell): https://dozzle.dev/guide/authentication - Docker Registry deployment (default authentication, TLS requirement; rolling documentation, checked September 2026): https://distribution.github.io/distribution/about/deploying/ - Filebrowser: https://github.com/filebrowser/filebrowser - Node-RED securing the runtime (adminAuth, credentialSecret): https://nodered.org/docs/user-guide/runtime/securing-node-red - Gitea listener at pinned tag v28.3.0 (checked November 2026): `HTTP_ADDR` default `0.0.0.0` and `HTTP_PORT` default `3000` https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/server.go#L151-L152 and `PROTOCOL`, `CERT_FILE` and `KEY_FILE` https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/server.go#L163-L203 - Gitea `[service]` settings at pinned tag v28.3.0 (checked November 2026): `DISABLE_REGISTRATION` default https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/service.go#L148 , `REQUIRE_SIGNIN_VIEW` and `ENABLE_BASIC_AUTHENTICATION` https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/service.go#L168-L179 , and the example config's `TWO_FACTOR_AUTH` and `DISABLE_REGISTRATION` entries https://github.com/go-gitea/gitea/blob/v28.3.0/custom/conf/app.example.ini#L524-L526 and https://github.com/go-gitea/gitea/blob/v28.3.0/custom/conf/app.example.ini#L841-L842 - Gitea install wizard at pinned tag v28.3.0 (checked November 2026): checkbox prefill https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L106-L108 and explicit writes https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L363-L365 - Gitea install wizard environment settings applied after the form at pinned tags v28.3.0 and v1.27.3 (checked November 2026): https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L305-L306 , https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L402 and https://github.com/go-gitea/gitea/blob/v1.27.3/routers/install/install.go#L452 - Gitea `TWO_FACTOR_AUTH` and Basic authentication at pinned tag v28.3.0 (checked November 2026): https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/security.go#L197-L204 and https://github.com/go-gitea/gitea/blob/v28.3.0/services/auth/basic.go#L37-L195 - Gitea 1.x source at pinned tag v1.27.3 (checked November 2026): `DISABLE_REGISTRATION` read https://github.com/go-gitea/gitea/blob/v1.27.3/modules/setting/service.go#L151 , example value https://github.com/go-gitea/gitea/blob/v1.27.3/custom/conf/app.example.ini#L809-L810 , and install wizard prefill and explicit writes https://github.com/go-gitea/gitea/blob/v1.27.3/routers/install/install.go#L109-L112 and https://github.com/go-gitea/gitea/blob/v1.27.3/routers/install/install.go#L384-L387 - Gitea listener and Basic method at pinned tag v1.27.3 (checked November 2026): listener defaults https://github.com/go-gitea/gitea/blob/v1.27.3/modules/setting/server.go#L121-L122 , Basic method https://github.com/go-gitea/gitea/blob/v1.27.3/services/auth/basic.go#L34-L187 and `ENABLE_BASIC_AUTHENTICATION` https://github.com/go-gitea/gitea/blob/v1.27.3/modules/setting/service.go#L182 - Gitea MFA minimum 1.24, `TWO_FACTOR_AUTH` read at tag v1.24.0 and absent from tag v1.23.8 (checked November 2026): https://github.com/go-gitea/gitea/blob/v1.24.0/modules/setting/security.go#L146-L153 and https://github.com/go-gitea/gitea/blob/v1.23.8/modules/setting/security.go - Apache 2.4 `htpasswd` bcrypt default cost and `-C` override (checked October 2026): https://httpd.apache.org/docs/2.4/programs/htpasswd.html - OWASP password storage at commit 1a3f58ed2714f258a1d713966457f1a0f426e841 (bcrypt minimum work factor 10) (checked October 2026): https://github.com/OWASP/CheatSheetSeries/blob/1a3f58ed2714f258a1d713966457f1a0f426e841/cheatsheets/Password_Storage_Cheat_Sheet.md#L139 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - portainer: Portainer (unknown) - dashboard-old: Dashboard arguments (v2.7.0) - gitea-pin: Gitea source (v28.3.0) - gitea-1: Gitea 1.x source (v1.27.3) - gitea-mfa-min: Gitea MFA minimum (1.24) - coolify: Coolify (unknown) - dokploy: Dokploy (unknown) - npm: Nginx Proxy Manager (unknown) - vaultwarden: Vaultwarden docs (unknown) - vaultwarden-pin: Vaultwarden config (3347698712d3e99e652ad4394ef2fc0ce2800fdd) - dashboard: Dashboard docs (unknown) - jenkins: Jenkins (unknown) - gitea: Gitea docs (unknown) - kuma: Uptime Kuma (unknown) - docker: Docker Engine (unknown) - gitlab: GitLab CE (unknown) - dozzle: Dozzle (unknown) - filebrowser: Filebrowser (unknown) - nodered: Node-RED (unknown) - registry-docs: Docker Registry documentation (rolling) (unknown) - htpasswd: Apache htpasswd (2.4) - owasp-passwords: OWASP password storage (1a3f58ed2714f258a1d713966457f1a0f426e841) Claims (full source mapping in the JSON block below): - private (REASONED; portainer, docker): Keep panels private; public front doors need proxy TLS/auth and MFA. Complete first-run administration before exposure. - portainer-ports (REASONED; portainer): UI HTTPS 9443 is self-signed by default; supply a certificate/proxy. Leave legacy HTTP 9000 unpublished; publish tunnel 8000 only for Edge Compute. - portainer-setup (REASONED; portainer): Setup requires the logged setup_token; first user is admin with a password of at least 12 characters. - portainer-auth (REASONED; portainer): Internal, LDAP, AD or OAuth login; no documented native second factor. Enforce MFA at the OAuth provider. - portainer-socket (REASONED; portainer, docker): Mounted docker.sock grants host-root power; treat a Portainer admin accordingly. - coolify-setup (REASONED; coolify): HTTP dashboard starts on 8000; first registration claims server control, so create admin immediately. - coolify-tls (REASONED; coolify): Custom domain in /settings lets Traefik/Caddy issue/renew TLS; dashboard ports 8000/6001/6002 can then close. - coolify-firewall (REASONED; coolify): Docker bypasses UFW; restrict dashboard ports at the cloud firewall. - coolify-ssh (REASONED; coolify): Server SSH keys require no passphrase; stored key material is the whole secret. - dokploy-ports (REASONED; dokploy): UI is 3000, Traefik 80/443; first setup creates admin. - dokploy-tls (REASONED; dokploy): Configure a panel domain and Let's Encrypt/custom certificate, then remove published 3000. - npm-ports (REASONED; npm): Admin is 81, proxy 80/443; publish 127.0.0.1:81:81 and tunnel in. - npm-admin (REASONED; npm): Change default first-run admin credentials before other work. - vaultwarden-tls (REASONED; vaultwarden, vaultwarden-pin): Web-vault crypto needs HTTPS; prefer proxy TLS over ROCKET_TLS and set DOMAIN=https://vault.example.com. - vaultwarden-signup (REASONED; vaultwarden, vaultwarden-pin): SIGNUPS_ALLOWED defaults true; set false to close registration. - vaultwarden-invites (REASONED; vaultwarden, vaultwarden-pin): INVITATIONS_ALLOWED=true still permits owner/admin invitations; SIGNUPS_DOMAINS_WHITELIST admits selected domains. - vaultwarden-admin (REASONED; vaultwarden, vaultwarden-pin): ADMIN_TOKEN enables the disabled admin page; enable HTTPS first, use vaultwarden hash argon2id PHC, and escape Compose dollars as $$. - vaultwarden-session (REASONED; vaultwarden, vaultwarden-pin): Admin sessions expire after 20 minutes by default. - vaultwarden-port (REASONED; vaultwarden-pin): Container port 80 or non-Docker 8000; publish only to loopback/proxy network. - dashboard-lifecycle (REASONED; dashboard): Recorded September 2026: deprecated/unmaintained, archived January 2026; Kubernetes points new installs to Headlamp. - dashboard-forward (REASONED; dashboard): Avoid public LoadBalancer/Ingress; port-forward kong-proxy 8443:443 and use https://localhost:8443 on the operator machine. - dashboard-token (REASONED; dashboard): Use minimally privileged ServiceAccount bearer tokens; sample cluster-admin is demonstration-only. - dashboard-old-flags (REASONED; dashboard-old): Older 2.x enable-skip-login and enable-insecure-login default false; keep both false. - dashboard-mfa (REASONED; dashboard): API-server OIDC gives ServiceAccount tokens no MFA; enforce human MFA on the access path or an IdP-aware UI. - jenkins-auth (REASONED; jenkins): Realm/authorization are separate; setup creates one local admin. Disable signup because strategy grants apply to new users too. - jenkins-matrix (REASONED; jenkins): Use Matrix Authorization; grant nothing significant to anonymous/authenticated. Anonymous Overall/Administer is full control. - jenkins-csrf (REASONED; jenkins): Keep CSRF enabled even privately; disabling uses hudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION, not a UI switch. - jenkins-agents (REASONED; jenkins): Inbound agents use fixed/random TCP or WebSocket on HTTPS; prefer WebSocket or isolate the extra port. - jenkins-mfa (REASONED; jenkins): No native second factor; enforce MFA through SSO/fronting identity. - gitea-bind (REASONED; gitea-pin, gitea-1): HTTP_ADDR=0.0.0.0 and HTTP_PORT=3000 default at v28.3.0, the same at v1.27.3; set 127.0.0.1 behind a proxy. - gitea-tls (REASONED; gitea, gitea-pin): Native TLS uses PROTOCOL=https, CERT_FILE and KEY_FILE. - gitea-registration (REASONED; gitea, gitea-pin, gitea-1): Set service DISABLE_REGISTRATION=true. Code default is true at v28.3.0, whose example ini still shows false, and false at v1.27.3; which release changed it is not established. Both install wizards write an explicit value, which an environment setting overrides, so read it in app.ini. - gitea-private (REASONED; gitea, gitea-pin): Set service REQUIRE_SIGNIN_VIEW=true for a private forge; default false. The install wizard writes an explicit value. - gitea-mfa (REASONED; gitea, gitea-mfa-min, gitea-pin): Enrol TOTP/WebAuthn; security TWO_FACTOR_AUTH=enforced requires MFA in Gitea 1.24 and later. Source reads it at v1.24.0 and v28.3.0, not at v1.23.8; a later 1.23.x is not established. - gitea-tokens (REASONED; gitea): With MFA, Git HTTP uses tokens that bypass MFA; scope and revoke unused tokens. - gitea-basic-auth (REASONED; gitea-pin, gitea-1): At v28.3.0, and the same at v1.27.3, Basic auth tries tokens first, with no 2FA check; a password needs ENABLE_BASIC_AUTHENTICATION and, unless its sign-in source skips 2FA, is refused with WebAuthn enrolled and needs a valid X-Gitea-OTP with TOTP. Git HTTP use and enforced-2FA token refusal are not established. - gitlab-root (REASONED; gitlab): Rotate generated initial_root_password; file deletion is on first restart after 24 hours. Alternatively preset through gitlab.rb/OMNIBUS_CONFIG. - gitlab-signup (REASONED; gitlab): signup_enabled defaults true; disable for a private forge. - gitlab-approval (REASONED; gitlab): New instances require admin approval after signup by default; do not rely on approval alone. - gitlab-visibility (REASONED; gitlab): Project/group visibility default private; internal admits every signed-in non-external user, including allowed registrants. - gitlab-mfa (REASONED; gitlab): Enforce two-factor authentication for every user; available on all tiers. - kuma-proxy (REASONED; kuma): Port 3001 uses WebSockets; proxy Upgrade/Connection headers are required. - kuma-admin (REASONED; kuma): Finish account setup before others, enable 2FA and keep dashboard private even if status pages are public. - docker-root (REASONED; docker): Docker socket grants host-root power; never expose unauthenticated 0.0.0.0:2375. - docker-ssh (REASONED; docker): DOCKER_HOST/context can select SSH to the Unix socket without a new listener. - docker-mtls (REASONED; docker): 2376 requires tlsverify, CA, server cert/key and client TLS settings. Without tlsverify clients are not authenticated; firewall source addresses too. - dozzle-socket (REASONED; dozzle, docker): Docker-socket access has host-level power; keep Dozzle private with fronting MFA. - dozzle-auth (REASONED; dozzle): Auth defaults off; generate users.yml with stdin password prompt and set simple auth, or delegate with forward-proxy. - dozzle-actions (REASONED; dozzle): Leave optional container actions/shell off unless needed; either enables remote command execution. - registry-auth (REASONED; registry-docs): registry:2 defaults to unauthenticated push/pull; configure TLS before htpasswd/token auth or use an authenticated distribution. - registry-bcrypt (REASONED; registry-docs, htpasswd, owasp-passwords): Native htpasswd accepts bcrypt only; use -B -C 12. Proxy hashing rules differ. - registry-private (REASONED; registry-docs): Use private networking and tunnels/Access with fronting MFA. - filebrowser-admin (REASONED; filebrowser): Change first-run admin credentials, historically admin/admin. Guide records September 1, 2026 archival and the warning against internet exposure. - filebrowser-private (REASONED; filebrowser): Keep Filebrowser private through tunnel/tailnet/Access with fronting MFA. - nodered-default (REASONED; nodered): Editor/admin API on 1880 defaults unauthenticated, permitting flow viewing/deployment/modification. - nodered-auth (REASONED; nodered): Set adminAuth with bcrypt passwords from node-red admin hash-pw. - nodered-secret (REASONED; nodered): Set credentialSecret yourself; otherwise generated, and stored credentials depend on it. - nodered-private (REASONED; nodered): Keep editor private with fronting MFA; it has the power of its flows. - verify-listeners (REASONED; portainer, dokploy, npm, kuma, docker): Read every panel/API listener; expect loopback or absence, never wildcard exposure. - verify-panel (REASONED; portainer, jenkins, gitea): Expect denial/login redirect with valid TLS; anonymous host/container/repository data is a finding. No live panel outcome recorded. - verify-docker-plain (REASONED; docker): Public-IP 2375 must be refused/filtered by the remote host; local client failure is inconclusive. - verify-docker-mtls (REASONED; docker): 2376 /_ping without a client certificate must fail client-certificate authentication; correct certificate/key must return 200. - verify-gitlab (REASONED; gitlab): Present initial_root_password requires rotation; absence does not prove rotation. Failed/unexpected docker exec output is not a result. ````json { "schema": 1, "checked": "2026-11-04", "documentation_checked": "2026-09", "body_sha256": "d5ef4cd66ecbd15111a0b168f137b7851be6e0e73373b7e1964e6522e290b0bb", "components": { "portainer": { "name": "Portainer", "basis": "unknown", "sources": { "s3e163c7d3c38": "https://docs.portainer.io/start/install-ce/server/docker/linux", "s115489d2f86f": "https://docs.portainer.io/start/install-ce/server/setup", "s59297757a042": "https://docs.portainer.io/admin/settings/authentication", "s8901bbe7c3d3": "https://docs.portainer.io/admin/settings/authentication/oauth" } }, "dashboard-old": { "name": "Dashboard arguments", "basis": "v2.7.0", "sources": { "sacea2944651e": "https://github.com/kubernetes-retired/dashboard/blob/v2.7.0/docs/common/dashboard-arguments.md" } }, "gitea-pin": { "name": "Gitea source", "basis": "v28.3.0", "sources": { "s01438ea0392a": "https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/server.go#L151-L152", "s94fcc58b53a7": "https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/server.go#L163-L203", "s5f900af6c58a": "https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/service.go#L148", "sa1a585c22f58": "https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/service.go#L168-L179", "s57a09b15fc30": "https://github.com/go-gitea/gitea/blob/v28.3.0/custom/conf/app.example.ini#L524-L526", "sb47c6c0b545b": "https://github.com/go-gitea/gitea/blob/v28.3.0/custom/conf/app.example.ini#L841-L842", "s4b700228467a": "https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L106-L108", "s2029804191d3": "https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L363-L365", "s25f54cf927cd": "https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L305-L306", "s94cf9187cdb6": "https://github.com/go-gitea/gitea/blob/v28.3.0/routers/install/install.go#L402", "s17f07be814d4": "https://github.com/go-gitea/gitea/blob/v28.3.0/modules/setting/security.go#L197-L204", "s11c58c9a7056": "https://github.com/go-gitea/gitea/blob/v28.3.0/services/auth/basic.go#L37-L195" } }, "gitea-1": { "name": "Gitea 1.x source", "basis": "v1.27.3", "sources": { "scc62ce4e5d4b": "https://github.com/go-gitea/gitea/blob/v1.27.3/modules/setting/service.go#L151", "s99677826f27f": "https://github.com/go-gitea/gitea/blob/v1.27.3/custom/conf/app.example.ini#L809-L810", "s9c7fa227cc6c": "https://github.com/go-gitea/gitea/blob/v1.27.3/routers/install/install.go#L109-L112", "s0ecc49b4eb51": "https://github.com/go-gitea/gitea/blob/v1.27.3/routers/install/install.go#L384-L387", "s9e03e5dfe7ae": "https://github.com/go-gitea/gitea/blob/v1.27.3/routers/install/install.go#L452", "s31dd622b1c12": "https://github.com/go-gitea/gitea/blob/v1.27.3/modules/setting/server.go#L121-L122", "s44ea7417ec5a": "https://github.com/go-gitea/gitea/blob/v1.27.3/services/auth/basic.go#L34-L187", "saed8434d7dff": "https://github.com/go-gitea/gitea/blob/v1.27.3/modules/setting/service.go#L182" } }, "gitea-mfa-min": { "name": "Gitea MFA minimum", "basis": "1.24", "sources": { "sfec17fde7bb0": "https://docs.gitea.com/administration/config-cheat-sheet/", "sa5869c041fc5": "https://github.com/go-gitea/gitea/blob/v1.24.0/modules/setting/security.go#L146-L153", "s023053a8ef10": "https://github.com/go-gitea/gitea/blob/v1.23.8/modules/setting/security.go" } }, "coolify": { "name": "Coolify", "basis": "unknown", "sources": { "s41e2d21202d9": "https://coolify.io/docs/start-with-self-hosted", "sf2ae62425b35": "https://coolify.io/docs/core/infrastructure/servers/firewall", "sf5d819d5d87d": "https://coolify.io/docs/core/networking/proxy/overview", "s90addf9d8d5d": "https://coolify.io/docs/core/networking/dns", "s683244a61c48": "https://coolify.io/docs/core/infrastructure/servers/openssh" } }, "dokploy": { "name": "Dokploy", "basis": "unknown", "sources": { "sc2cc854c9b7e": "https://docs.dokploy.com/docs/core/installation" } }, "npm": { "name": "Nginx Proxy Manager", "basis": "unknown", "sources": { "se553d384e783": "https://nginxproxymanager.com/setup/" } }, "vaultwarden": { "name": "Vaultwarden docs", "basis": "unknown", "sources": { "s0bbe8ad856cd": "https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page", "s6d3d0d02dcc9": "https://github.com/dani-garcia/vaultwarden/wiki/Disable-registration-of-new-users", "s60d064cae50e": "https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS" } }, "vaultwarden-pin": { "name": "Vaultwarden config", "basis": "3347698712d3e99e652ad4394ef2fc0ce2800fdd", "sources": { "s31c5fc79f5a0": "https://github.com/dani-garcia/vaultwarden/blob/3347698712d3e99e652ad4394ef2fc0ce2800fdd/.env.template" } }, "dashboard": { "name": "Dashboard docs", "basis": "unknown", "sources": { "s73a36a43f6b5": "https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/" } }, "jenkins": { "name": "Jenkins", "basis": "unknown", "sources": { "s524c8d5871f1": "https://www.jenkins.io/doc/book/security/managing-security/", "sdfee751aeeee": "https://www.jenkins.io/doc/book/security/access-control/", "s6f5287fd36d4": "https://www.jenkins.io/doc/book/security/csrf-protection/" } }, "gitea": { "name": "Gitea docs", "basis": "unknown", "sources": { "sfec17fde7bb0": "https://docs.gitea.com/administration/config-cheat-sheet/", "s3b7060d58383": "https://docs.gitea.com/usage/user-setting/multi-factor-authentication/" } }, "kuma": { "name": "Uptime Kuma", "basis": "unknown", "sources": { "s05b853ba300b": "https://github.com/louislam/uptime-kuma", "sed52bfca4039": "https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" } }, "docker": { "name": "Docker Engine", "basis": "unknown", "sources": { "sfe15a8156c18": "https://docs.docker.com/engine/security/protect-access/", "s1b5ec54421b0": "https://docs.docker.com/engine/daemon/remote-access/" } }, "gitlab": { "name": "GitLab CE", "basis": "unknown", "sources": { "s3b7ac561a92e": "https://docs.gitlab.com/install/docker/installation/", "s6e8d16cad2ef": "https://docs.gitlab.com/administration/settings/sign_up_restrictions/", "s6b8a68a77e17": "https://docs.gitlab.com/administration/settings/visibility_and_access_controls/", "s0347014ce554": "https://docs.gitlab.com/security/two_factor_authentication/" } }, "dozzle": { "name": "Dozzle", "basis": "unknown", "sources": { "s45b3eab2515b": "https://dozzle.dev/guide/authentication" } }, "filebrowser": { "name": "Filebrowser", "basis": "unknown", "sources": { "s6386c9e2356b": "https://github.com/filebrowser/filebrowser" } }, "nodered": { "name": "Node-RED", "basis": "unknown", "sources": { "sa94f329023fe": "https://nodered.org/docs/user-guide/runtime/securing-node-red" } }, "registry-docs": { "name": "Docker Registry documentation (rolling)", "basis": "unknown", "sources": { "sa315b3f8bfa6": "https://distribution.github.io/distribution/about/deploying/" } }, "htpasswd": { "name": "Apache htpasswd", "basis": "2.4", "sources": { "scde9004bb967": "https://httpd.apache.org/docs/2.4/programs/htpasswd.html" } }, "owasp-passwords": { "name": "OWASP password storage", "basis": "1a3f58ed2714f258a1d713966457f1a0f426e841", "sources": { "s0f70ec4aa271": "https://github.com/OWASP/CheatSheetSeries/blob/1a3f58ed2714f258a1d713966457f1a0f426e841/cheatsheets/Password_Storage_Cheat_Sheet.md#L139" } } }, "claims": { "private": {"text": "Keep panels private; public front doors need proxy TLS/auth and MFA. Complete first-run administration before exposure.", "components": ["portainer", "docker"], "sources": ["portainer:s3e163c7d3c38", "docker:sfe15a8156c18"], "status": "REASONED"}, "portainer-ports": {"text": "UI HTTPS 9443 is self-signed by default; supply a certificate/proxy. Leave legacy HTTP 9000 unpublished; publish tunnel 8000 only for Edge Compute.", "components": ["portainer"], "sources": ["portainer:s3e163c7d3c38"], "status": "REASONED"}, "portainer-setup": {"text": "Setup requires the logged setup_token; first user is admin with a password of at least 12 characters.", "components": ["portainer"], "sources": ["portainer:s115489d2f86f"], "status": "REASONED"}, "portainer-auth": {"text": "Internal, LDAP, AD or OAuth login; no documented native second factor. Enforce MFA at the OAuth provider.", "components": ["portainer"], "sources": ["portainer:s59297757a042", "portainer:s8901bbe7c3d3"], "status": "REASONED"}, "portainer-socket": {"text": "Mounted docker.sock grants host-root power; treat a Portainer admin accordingly.", "components": ["portainer", "docker"], "sources": ["portainer:s3e163c7d3c38", "docker:sfe15a8156c18"], "status": "REASONED"}, "coolify-setup": {"text": "HTTP dashboard starts on 8000; first registration claims server control, so create admin immediately.", "components": ["coolify"], "sources": ["coolify:s41e2d21202d9"], "status": "REASONED"}, "coolify-tls": {"text": "Custom domain in /settings lets Traefik/Caddy issue/renew TLS; dashboard ports 8000/6001/6002 can then close.", "components": ["coolify"], "sources": ["coolify:sf2ae62425b35", "coolify:sf5d819d5d87d", "coolify:s90addf9d8d5d"], "status": "REASONED"}, "coolify-firewall": {"text": "Docker bypasses UFW; restrict dashboard ports at the cloud firewall.", "components": ["coolify"], "sources": ["coolify:sf2ae62425b35"], "status": "REASONED"}, "coolify-ssh": {"text": "Server SSH keys require no passphrase; stored key material is the whole secret.", "components": ["coolify"], "sources": ["coolify:s683244a61c48"], "status": "REASONED"}, "dokploy-ports": {"text": "UI is 3000, Traefik 80/443; first setup creates admin.", "components": ["dokploy"], "sources": ["dokploy:sc2cc854c9b7e"], "status": "REASONED"}, "dokploy-tls": {"text": "Configure a panel domain and Let's Encrypt/custom certificate, then remove published 3000.", "components": ["dokploy"], "sources": ["dokploy:sc2cc854c9b7e"], "status": "REASONED"}, "npm-ports": {"text": "Admin is 81, proxy 80/443; publish 127.0.0.1:81:81 and tunnel in.", "components": ["npm"], "sources": ["npm:se553d384e783"], "status": "REASONED"}, "npm-admin": {"text": "Change default first-run admin credentials before other work.", "components": ["npm"], "sources": ["npm:se553d384e783"], "status": "REASONED"}, "vaultwarden-tls": {"text": "Web-vault crypto needs HTTPS; prefer proxy TLS over ROCKET_TLS and set DOMAIN=https://vault.example.com.", "components": ["vaultwarden", "vaultwarden-pin"], "sources": ["vaultwarden:s60d064cae50e", "vaultwarden-pin:s31c5fc79f5a0"], "status": "REASONED"}, "vaultwarden-signup": {"text": "SIGNUPS_ALLOWED defaults true; set false to close registration.", "components": ["vaultwarden", "vaultwarden-pin"], "sources": ["vaultwarden:s6d3d0d02dcc9", "vaultwarden-pin:s31c5fc79f5a0"], "status": "REASONED"}, "vaultwarden-invites": {"text": "INVITATIONS_ALLOWED=true still permits owner/admin invitations; SIGNUPS_DOMAINS_WHITELIST admits selected domains.", "components": ["vaultwarden", "vaultwarden-pin"], "sources": ["vaultwarden:s6d3d0d02dcc9", "vaultwarden-pin:s31c5fc79f5a0"], "status": "REASONED"}, "vaultwarden-admin": {"text": "ADMIN_TOKEN enables the disabled admin page; enable HTTPS first, use vaultwarden hash argon2id PHC, and escape Compose dollars as $$.", "components": ["vaultwarden", "vaultwarden-pin"], "sources": ["vaultwarden:s0bbe8ad856cd", "vaultwarden-pin:s31c5fc79f5a0"], "status": "REASONED"}, "vaultwarden-session": {"text": "Admin sessions expire after 20 minutes by default.", "components": ["vaultwarden", "vaultwarden-pin"], "sources": ["vaultwarden:s0bbe8ad856cd", "vaultwarden-pin:s31c5fc79f5a0"], "status": "REASONED"}, "vaultwarden-port": {"text": "Container port 80 or non-Docker 8000; publish only to loopback/proxy network.", "components": ["vaultwarden-pin"], "sources": ["vaultwarden-pin:s31c5fc79f5a0"], "status": "REASONED"}, "dashboard-lifecycle": {"text": "Recorded September 2026: deprecated/unmaintained, archived January 2026; Kubernetes points new installs to Headlamp.", "components": ["dashboard"], "sources": ["dashboard:s73a36a43f6b5"], "status": "REASONED"}, "dashboard-forward": {"text": "Avoid public LoadBalancer/Ingress; port-forward kong-proxy 8443:443 and use https://localhost:8443 on the operator machine.", "components": ["dashboard"], "sources": ["dashboard:s73a36a43f6b5"], "status": "REASONED"}, "dashboard-token": {"text": "Use minimally privileged ServiceAccount bearer tokens; sample cluster-admin is demonstration-only.", "components": ["dashboard"], "sources": ["dashboard:s73a36a43f6b5"], "status": "REASONED"}, "dashboard-old-flags": {"text": "Older 2.x enable-skip-login and enable-insecure-login default false; keep both false.", "components": ["dashboard-old"], "sources": ["dashboard-old:sacea2944651e"], "status": "REASONED"}, "dashboard-mfa": {"text": "API-server OIDC gives ServiceAccount tokens no MFA; enforce human MFA on the access path or an IdP-aware UI.", "components": ["dashboard"], "sources": ["dashboard:s73a36a43f6b5"], "status": "REASONED"}, "jenkins-auth": {"text": "Realm/authorization are separate; setup creates one local admin. Disable signup because strategy grants apply to new users too.", "components": ["jenkins"], "sources": ["jenkins:s524c8d5871f1", "jenkins:sdfee751aeeee"], "status": "REASONED"}, "jenkins-matrix": {"text": "Use Matrix Authorization; grant nothing significant to anonymous/authenticated. Anonymous Overall/Administer is full control.", "components": ["jenkins"], "sources": ["jenkins:sdfee751aeeee"], "status": "REASONED"}, "jenkins-csrf": {"text": "Keep CSRF enabled even privately; disabling uses hudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION, not a UI switch.", "components": ["jenkins"], "sources": ["jenkins:s6f5287fd36d4"], "status": "REASONED"}, "jenkins-agents": {"text": "Inbound agents use fixed/random TCP or WebSocket on HTTPS; prefer WebSocket or isolate the extra port.", "components": ["jenkins"], "sources": ["jenkins:s524c8d5871f1"], "status": "REASONED"}, "jenkins-mfa": {"text": "No native second factor; enforce MFA through SSO/fronting identity.", "components": ["jenkins"], "sources": ["jenkins:s524c8d5871f1", "jenkins:sdfee751aeeee"], "status": "REASONED"}, "gitea-bind": {"text": "HTTP_ADDR=0.0.0.0 and HTTP_PORT=3000 default at v28.3.0, the same at v1.27.3; set 127.0.0.1 behind a proxy.", "components": ["gitea-pin", "gitea-1"], "sources": ["gitea-pin:s01438ea0392a", "gitea-1:s31dd622b1c12"], "status": "REASONED"}, "gitea-tls": {"text": "Native TLS uses PROTOCOL=https, CERT_FILE and KEY_FILE.", "components": ["gitea", "gitea-pin"], "sources": ["gitea:sfec17fde7bb0", "gitea-pin:s94fcc58b53a7"], "status": "REASONED"}, "gitea-registration": {"text": "Set service DISABLE_REGISTRATION=true. Code default is true at v28.3.0, whose example ini still shows false, and false at v1.27.3; which release changed it is not established. Both install wizards write an explicit value, which an environment setting overrides, so read it in app.ini.", "components": ["gitea", "gitea-pin", "gitea-1"], "sources": ["gitea:sfec17fde7bb0", "gitea-pin:s5f900af6c58a", "gitea-pin:sb47c6c0b545b", "gitea-pin:s4b700228467a", "gitea-pin:s2029804191d3", "gitea-pin:s25f54cf927cd", "gitea-pin:s94cf9187cdb6", "gitea-1:scc62ce4e5d4b", "gitea-1:s99677826f27f", "gitea-1:s9c7fa227cc6c", "gitea-1:s0ecc49b4eb51", "gitea-1:s9e03e5dfe7ae"], "status": "REASONED"}, "gitea-private": {"text": "Set service REQUIRE_SIGNIN_VIEW=true for a private forge; default false. The install wizard writes an explicit value.", "components": ["gitea", "gitea-pin"], "sources": ["gitea:sfec17fde7bb0", "gitea-pin:sa1a585c22f58", "gitea-pin:s4b700228467a", "gitea-pin:s2029804191d3"], "status": "REASONED"}, "gitea-mfa": {"text": "Enrol TOTP/WebAuthn; security TWO_FACTOR_AUTH=enforced requires MFA in Gitea 1.24 and later. Source reads it at v1.24.0 and v28.3.0, not at v1.23.8; a later 1.23.x is not established.", "components": ["gitea", "gitea-mfa-min", "gitea-pin"], "sources": ["gitea:sfec17fde7bb0", "gitea:s3b7060d58383", "gitea-mfa-min:sfec17fde7bb0", "gitea-mfa-min:sa5869c041fc5", "gitea-mfa-min:s023053a8ef10", "gitea-pin:s17f07be814d4"], "status": "REASONED"}, "gitea-tokens": {"text": "With MFA, Git HTTP uses tokens that bypass MFA; scope and revoke unused tokens.", "components": ["gitea"], "sources": ["gitea:s3b7060d58383"], "status": "REASONED"}, "gitea-basic-auth": {"text": "At v28.3.0, and the same at v1.27.3, Basic auth tries tokens first, with no 2FA check; a password needs ENABLE_BASIC_AUTHENTICATION and, unless its sign-in source skips 2FA, is refused with WebAuthn enrolled and needs a valid X-Gitea-OTP with TOTP. Git HTTP use and enforced-2FA token refusal are not established.", "components": ["gitea-pin", "gitea-1"], "sources": ["gitea-pin:s11c58c9a7056", "gitea-pin:sa1a585c22f58", "gitea-pin:s57a09b15fc30", "gitea-1:s44ea7417ec5a", "gitea-1:saed8434d7dff"], "status": "REASONED"}, "gitlab-root": {"text": "Rotate generated initial_root_password; file deletion is on first restart after 24 hours. Alternatively preset through gitlab.rb/OMNIBUS_CONFIG.", "components": ["gitlab"], "sources": ["gitlab:s3b7ac561a92e"], "status": "REASONED"}, "gitlab-signup": {"text": "signup_enabled defaults true; disable for a private forge.", "components": ["gitlab"], "sources": ["gitlab:s6e8d16cad2ef"], "status": "REASONED"}, "gitlab-approval": {"text": "New instances require admin approval after signup by default; do not rely on approval alone.", "components": ["gitlab"], "sources": ["gitlab:s6e8d16cad2ef"], "status": "REASONED"}, "gitlab-visibility": {"text": "Project/group visibility default private; internal admits every signed-in non-external user, including allowed registrants.", "components": ["gitlab"], "sources": ["gitlab:s6b8a68a77e17"], "status": "REASONED"}, "gitlab-mfa": {"text": "Enforce two-factor authentication for every user; available on all tiers.", "components": ["gitlab"], "sources": ["gitlab:s0347014ce554"], "status": "REASONED"}, "kuma-proxy": {"text": "Port 3001 uses WebSockets; proxy Upgrade/Connection headers are required.", "components": ["kuma"], "sources": ["kuma:s05b853ba300b", "kuma:sed52bfca4039"], "status": "REASONED"}, "kuma-admin": {"text": "Finish account setup before others, enable 2FA and keep dashboard private even if status pages are public.", "components": ["kuma"], "sources": ["kuma:s05b853ba300b"], "status": "REASONED"}, "docker-root": {"text": "Docker socket grants host-root power; never expose unauthenticated 0.0.0.0:2375.", "components": ["docker"], "sources": ["docker:sfe15a8156c18", "docker:s1b5ec54421b0"], "status": "REASONED"}, "docker-ssh": {"text": "DOCKER_HOST/context can select SSH to the Unix socket without a new listener.", "components": ["docker"], "sources": ["docker:sfe15a8156c18"], "status": "REASONED"}, "docker-mtls": {"text": "2376 requires tlsverify, CA, server cert/key and client TLS settings. Without tlsverify clients are not authenticated; firewall source addresses too.", "components": ["docker"], "sources": ["docker:sfe15a8156c18"], "status": "REASONED"}, "dozzle-socket": {"text": "Docker-socket access has host-level power; keep Dozzle private with fronting MFA.", "components": ["dozzle", "docker"], "sources": ["dozzle:s45b3eab2515b", "docker:sfe15a8156c18"], "status": "REASONED"}, "dozzle-auth": {"text": "Auth defaults off; generate users.yml with stdin password prompt and set simple auth, or delegate with forward-proxy.", "components": ["dozzle"], "sources": ["dozzle:s45b3eab2515b"], "status": "REASONED"}, "dozzle-actions": {"text": "Leave optional container actions/shell off unless needed; either enables remote command execution.", "components": ["dozzle"], "sources": ["dozzle:s45b3eab2515b"], "status": "REASONED"}, "registry-auth": {"text": "registry:2 defaults to unauthenticated push/pull; configure TLS before htpasswd/token auth or use an authenticated distribution.", "components": ["registry-docs"], "sources": ["registry-docs:sa315b3f8bfa6"], "status": "REASONED"}, "registry-bcrypt": {"text": "Native htpasswd accepts bcrypt only; use -B -C 12. Proxy hashing rules differ.", "components": ["registry-docs", "htpasswd", "owasp-passwords"], "sources": ["registry-docs:sa315b3f8bfa6", "htpasswd:scde9004bb967", "owasp-passwords:s0f70ec4aa271"], "status": "REASONED"}, "registry-private": {"text": "Use private networking and tunnels/Access with fronting MFA.", "components": ["registry-docs"], "sources": ["registry-docs:sa315b3f8bfa6"], "status": "REASONED"}, "filebrowser-admin": {"text": "Change first-run admin credentials, historically admin/admin. Guide records September 1, 2026 archival and the warning against internet exposure.", "components": ["filebrowser"], "sources": ["filebrowser:s6386c9e2356b"], "status": "REASONED"}, "filebrowser-private": {"text": "Keep Filebrowser private through tunnel/tailnet/Access with fronting MFA.", "components": ["filebrowser"], "sources": ["filebrowser:s6386c9e2356b"], "status": "REASONED"}, "nodered-default": {"text": "Editor/admin API on 1880 defaults unauthenticated, permitting flow viewing/deployment/modification.", "components": ["nodered"], "sources": ["nodered:sa94f329023fe"], "status": "REASONED"}, "nodered-auth": {"text": "Set adminAuth with bcrypt passwords from node-red admin hash-pw.", "components": ["nodered"], "sources": ["nodered:sa94f329023fe"], "status": "REASONED"}, "nodered-secret": {"text": "Set credentialSecret yourself; otherwise generated, and stored credentials depend on it.", "components": ["nodered"], "sources": ["nodered:sa94f329023fe"], "status": "REASONED"}, "nodered-private": {"text": "Keep editor private with fronting MFA; it has the power of its flows.", "components": ["nodered"], "sources": ["nodered:sa94f329023fe"], "status": "REASONED"}, "verify-listeners": {"text": "Read every panel/API listener; expect loopback or absence, never wildcard exposure.", "components": ["portainer", "dokploy", "npm", "kuma", "docker"], "sources": ["portainer:s3e163c7d3c38", "dokploy:sc2cc854c9b7e", "npm:se553d384e783", "kuma:s05b853ba300b", "docker:s1b5ec54421b0"], "status": "REASONED", "verify": [1]}, "verify-panel": {"text": "Expect denial/login redirect with valid TLS; anonymous host/container/repository data is a finding. No live panel outcome recorded.", "components": ["portainer", "jenkins", "gitea"], "sources": ["portainer:s3e163c7d3c38", "jenkins:sdfee751aeeee", "gitea:sfec17fde7bb0"], "status": "REASONED", "verify": [2]}, "verify-docker-plain": {"text": "Public-IP 2375 must be refused/filtered by the remote host; local client failure is inconclusive.", "components": ["docker"], "sources": ["docker:s1b5ec54421b0"], "status": "REASONED", "verify": [3]}, "verify-docker-mtls": {"text": "2376 /_ping without a client certificate must fail client-certificate authentication; correct certificate/key must return 200.", "components": ["docker"], "sources": ["docker:sfe15a8156c18"], "status": "REASONED", "verify": [3]}, "verify-gitlab": {"text": "Present initial_root_password requires rotation; absence does not prove rotation. Failed/unexpected docker exec output is not a result.", "components": ["gitlab"], "sources": ["gitlab:s3b7ac561a92e"], "status": "REASONED", "verify": [4]} } } ```` ====================================================================== ==> cors.md ====================================================================== # CORS: allow your origins, not everyone's CORS is how your API tells browsers which other websites' pages may read its responses. This guide helps your AI assistant configure it securely, including exact allowed origins for authenticated APIs, a wildcard only for public, unauthenticated, read-only resources and never with credentials, and endpoint authentication still required. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/cors.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MDN CORS documentation (no recorded version); 3 further sources carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 13 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. CORS misconfiguration does not expose a port; it lets hostile websites use your users' browsers, cookies included, against your API. AI assistants reach for `Access-Control-Allow-Origin: *` the moment a browser console shows a CORS error; that is the wrong fix for any API that authenticates. ## Rules 1. **List exact origins.** `Access-Control-Allow-Origin` names the site(s) allowed to call the API from a browser: ``` Access-Control-Allow-Origin: https://app.example.com ``` 2. **Never combine `*` with credentials.** Browsers refuse `Access-Control-Allow-Origin: *` together with `Access-Control-Allow-Credentials: true`; configurations that "fix" this by reflecting whatever `Origin` header arrives recreate `*` for credentialed requests, which is worse. Reflect only origins checked against an explicit allow list. 3. **`*` is acceptable** only for genuinely public, unauthenticated, read-only resources. 4. **CORS is not authentication.** It controls browsers, not attackers with curl; every endpoint still authenticates per [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md). ## Framework examples Express (`cors` package): ```js const cors = require('cors'); app.use(cors({ origin: ['https://app.example.com'], credentials: true })); ``` FastAPI: ```python from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"], ) ``` Keep the origin list in configuration per environment rather than hardcoding localhost origins into production. ## Verify **REASONED:** not demonstrated against a deployed API: no Express or FastAPI deployment or browser session is available here, so which origins real CORS middleware echoes rests on the Fetch Standard and MDN's CORS guide (Sources). An exposed API echoes `https://evil.example` in `Access-Control-Allow-Origin`; the fixed API omits it for that origin and returns only your listed origin, with `Access-Control-Allow-Credentials: true` only where the browser must send credentials. For an earlier change both pipelines ran with curl 8.18.0: against a loopback listener answering 200 with the listed origin's headers, each printed the status line with both Access-Control headers and exited 0. Both pipelines also ran for an earlier change with curl 8.18.0 against a closed loopback port, where each printed `curl: (7) Failed to connect ...` on stderr and its inconclusive line (the same pipelines without the pipefail subshell exited 1 with empty output, and silently under the old `-s`). Both pipelines also ran for an earlier change with curl 8.18.0 against a stub curl that printed `HTTP/1.1 200 OK`, wrote a transfer error to stderr and returned 18: the pipeline without pipefail exited 0 as if it had passed, while the `set -o pipefail` subshell (the convention litellm.md and search-engines.md use) exited 18, so its `|| echo` printed the inconclusive line. The runs against the loopback listener, the closed loopback port and the stub curl record the pipelines' behaviour, not any deployed middleware's CORS decisions. Those curl 8.18.0 runs covered the earlier form of both pipelines, which named api.example.com and an app.example.com origin directly; the pipelines now take the API host and your app's origin host on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains, and those curl 8.18.0 runs did not cover the guarded form. Those runs also used a form without the block's classified status: the block now exits with status 1 when `Access-Control-Allow-Origin` echoes the hostile origin, otherwise with status 3 when either probe is inconclusive or your own origin is not returned, and otherwise with status 0, leaving `Access-Control-Allow-Credentials` and any other origin value for you to judge. Stub runs on 2026-11-01 covered the current form under Bash 5.3.9, with curl replaced by a local stub that prints one line and exits 0, and no network, but reached only the block's status 2 and status 3 exits: no run reached its status 1 or status 0 arm. In those runs the block refused an unsubstituted placeholder, a scheme in either host value, a third value on the `set --` line and a paste without the `set --` line, with or without caller arguments, each with status 2, its own message and no curl call. It refused example.com, EXAMPLE.COM, Example.Org, example.NET, app.example.net, APP.EXAMPLE.NET, Api.Example.Com and deep.sub.EXAMPLE.org as the API host and, in separate runs, as the origin host, each with status 2, the reserved-name message and no curl call. EXAMPLE.COM and App.Example.ORG in either position were refused the same way with `tr` absent from PATH and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and an inherited `nocasematch` was still set after the block. The host names status.acme-widgets.io and app.acme-widgets.io, also with `tr` absent and under each of those preludes, and Status.Acme-Widgets.IO as the API host, were not refused: the block made its two curl calls, printed its inconclusive line for each probe against the stub's output and exited 3. Under dash 0.5.12 and BusyBox 1.37.0 sh, the block refused the eight reserved names in either position and a real-looking pair of host names with status 2 and its message that the block needs bash, with no curl call; the placeholder and the partial paste got their own messages instead. No other shell, including Bash 3.2, zsh or ksh, was run, and these stub runs record the block's refusals and its exit statuses 2 and 3, not any deployed middleware's CORS decisions. ```bash ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a command that ends nonzero is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_API_HOST' 'REPLACE_WITH_YOUR_APP_HOST' # the API's public host name, then the host name of the https:// origin your app is served from, each with no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your API's public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your app's origin host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$2" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own origin host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again # Both probes request /data: change that path to a route your app's browser code calls. # The hostile origin https://evil.example is deliberately foreign; keep it as written. set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$3" "$(if curl -q -g -sS -o /dev/null -D - "https://$1/data" -H "Origin: https://evil.example"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "$(if printf '%s\n' "${4%$'\n'*}" | awk '{ sub(/\r$/, "") } tolower($0) ~ /http\/|access-control/ { print } /^HTTP\// { c = $2; v = "" } tolower(substr($0, 1, 28)) == "access-control-allow-origin:" { v = substr($0, 29); gsub(/^( |\t)+|( |\t)+$/, "", v) } END { printf "\n%s %s", c, v }'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%$'\n'*}" # curl's exit status, awk's exit status, then awk's output printf '%s' "${6%$'\n'*}" # the status and access-control header lines set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" set -- "$1" "$2" "$3" "$4" "$5" "${6%% *}" "${6#* }" # curl's exit status, awk's exit status, the final HTTP status code, then the last Access-Control-Allow-Origin value if [ "$5" != 0 ]; then echo "awk could not read the response headers; inconclusive"; set -- "$1" "$2" 3 "$6" elif [ "$7" = https://evil.example ]; then echo "FINDING: Access-Control-Allow-Origin echoes the hostile origin https://evil.example"; set -- "$1" "$2" 1 "$6" elif [ "$4" != 0 ] || [ -z "$6" ]; then echo "inconclusive: the transfer failed or printed no status line; not a pass"; set -- "$1" "$2" 3 "$6" else set -- "$1" "$2" "$3" "$6" # the 4th value is now this probe's HTTP status code case "$4" in 2??) ;; *) echo "HTTP $4 for the hostile origin is not a 2xx status; inconclusive"; set -- "$1" "$2" 3 "$4" ;; esac fi # expect: a 2xx status line matching the own-origin probe below, and no Access-Control-Allow-Origin echoing the hostile origin; any other status, a proxy's lone "HTTP/1.1 200 Connection established" line, the inconclusive line, or empty output is inconclusive, not a pass set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS -o /dev/null -D - "https://$1/data" -H "Origin: https://$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "$(if printf '%s\n' "${5%$'\n'*}" | awk '{ sub(/\r$/, "") } tolower($0) ~ /http\/|access-control/ { print } /^HTTP\// { c = $2; v = "" } tolower(substr($0, 1, 28)) == "access-control-allow-origin:" { v = substr($0, 29); gsub(/^( |\t)+|( |\t)+$/, "", v) } END { printf "\n%s %s", c, v }'; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6##*$'\n'}" "${6%$'\n'*}" # the hostile probe's HTTP status code, curl's exit status, awk's exit status, then awk's output printf '%s' "${7%$'\n'*}" # the status and access-control header lines set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7##*$'\n'}" set -- "$1" "$2" "$3" "$4" "$5" "$6" "${7%% *}" "${7#* }" # then this probe's final HTTP status code and last Access-Control-Allow-Origin value if [ "$6" != 0 ]; then echo "awk could not read the response headers; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 elif [ "$5" != 0 ] || [ -z "$7" ]; then echo "inconclusive: the transfer failed or printed no status line; not a pass"; [ "$3" = 1 ] || set -- "$1" "$2" 3 elif [ "$8" != "https://$2" ]; then echo "Access-Control-Allow-Origin does not return your origin https://$2, so this positive control failed; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 else case "$4:$7" in 2??:2??) [ "$4" = "$7" ] || { echo "the hostile-origin probe answered HTTP $4 and yours HTTP $7, so the status lines do not match; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3; } ;; *:2??) ;; *) echo "HTTP $7 for your origin is not a 2xx status; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac fi # expect: a 2xx status line, your origin in Access-Control-Allow-Origin, and Allow-Credentials only if the browser must send credentials: cookies, HTTP authentication, or TLS client certificates # The block exits with status 1 if the hostile origin is echoed, otherwise 3 if either probe printed an inconclusive line, and otherwise 0. echo "block exit $3; Access-Control-Allow-Credentials and any Access-Control-Allow-Origin value other than the two origins, such as *, are not classified, so judge them yourself" exit "$3" ) ``` ## Sources (checked September 2026) - MDN: Cross-Origin Resource Sharing, for the protocol itself: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS - Fetch Standard, definition of credentials: https://fetch.spec.whatwg.org/#credentials - Express `cors` middleware, for the `origin` and `credentials` options used above: https://expressjs.com/en/resources/middleware/cors/ - FastAPI CORS, for `CORSMiddleware` and its parameters: https://fastapi.tiangolo.com/tutorial/cors/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - protocol: MDN CORS documentation (unknown) - fetch: Fetch Standard credentials (unknown) - express: Express cors middleware (unknown) - fastapi: FastAPI CORSMiddleware (unknown) Claims (full source mapping in the JSON block below): - exact-origins (REASONED; protocol): Allow exact browser origins, illustrated by Access-Control-Allow-Origin: https://app.example.com; keep environment-specific lists in configuration instead of production localhost entries. - wildcard-credentials (REASONED; protocol): Browsers refuse wildcard Access-Control-Allow-Origin with credentials; the guide restricts wildcard use to public, unauthenticated, read-only resources. - origin-reflection (REASONED; protocol): Reflect only origins checked against an explicit allowlist; reflecting arbitrary Origin values permits hostile sites to read credentialed responses. - authentication (REASONED; protocol, express): CORS controls browsers rather than curl or other attackers, does not expose a port and does not replace endpoint authentication. - express-origins (REASONED; express): Express cors uses origin: ['https://app.example.com'] as the allowed origin list. - express-credentials (REASONED; express): The Express example enables credentials with credentials: true. - fastapi-origins (REASONED; fastapi): FastAPI adds CORSMiddleware with allow_origins restricted to https://app.example.com. - fastapi-credentials (REASONED; fastapi): The FastAPI example enables allow_credentials=True. - fastapi-methods (REASONED; fastapi): The FastAPI example allows only GET and POST through allow_methods. - fastapi-headers (REASONED; fastapi): The FastAPI example allows Authorization and Content-Type through allow_headers. - verify-hostile (REASONED; protocol, express, fastapi, fetch): The exposed API echoes https://evil.example in Access-Control-Allow-Origin; the fixed API omits that hostile origin. No deployed API or browser run is recorded. - verify-allowed (REASONED; protocol, express, fastapi, fetch): The allowed-Origin probe should return the listed app origin, with Allow-Credentials only where the browser must send cookies, HTTP authentication or TLS client certificates; no actual response is recorded. - credential-types (REASONED; fetch, protocol): Browser credentials include cookies, HTTP authentication and TLS client certificates; Access-Control-Allow-Credentials: true is needed only where the browser must send credentials. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "fbd626460a42276389a3b92e19db79c3f6b6e8e35270a5ac65dfe39d39872ee8", "components": { "protocol": { "name": "MDN CORS documentation", "basis": "unknown", "sources": { "s6265cf028385": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" } }, "fetch": { "name": "Fetch Standard credentials", "basis": "unknown", "sources": { "s028c38ebd452": "https://fetch.spec.whatwg.org/#credentials" } }, "express": { "name": "Express cors middleware", "basis": "unknown", "sources": { "sb6448b9a443d": "https://expressjs.com/en/resources/middleware/cors/" } }, "fastapi": { "name": "FastAPI CORSMiddleware", "basis": "unknown", "sources": { "s4f1cea92e7bc": "https://fastapi.tiangolo.com/tutorial/cors/" } } }, "claims": { "exact-origins": {"text": "Allow exact browser origins, illustrated by Access-Control-Allow-Origin: https://app.example.com; keep environment-specific lists in configuration instead of production localhost entries.", "components": ["protocol"], "sources": ["protocol:s6265cf028385"], "status": "REASONED"}, "wildcard-credentials": {"text": "Browsers refuse wildcard Access-Control-Allow-Origin with credentials; the guide restricts wildcard use to public, unauthenticated, read-only resources.", "components": ["protocol"], "sources": ["protocol:s6265cf028385"], "status": "REASONED"}, "origin-reflection": {"text": "Reflect only origins checked against an explicit allowlist; reflecting arbitrary Origin values permits hostile sites to read credentialed responses.", "components": ["protocol"], "sources": ["protocol:s6265cf028385"], "status": "REASONED"}, "authentication": {"text": "CORS controls browsers rather than curl or other attackers, does not expose a port and does not replace endpoint authentication.", "components": ["protocol", "express"], "sources": ["protocol:s6265cf028385", "express:sb6448b9a443d"], "status": "REASONED"}, "express-origins": {"text": "Express cors uses origin: ['https://app.example.com'] as the allowed origin list.", "components": ["express"], "sources": ["express:sb6448b9a443d"], "status": "REASONED"}, "express-credentials": {"text": "The Express example enables credentials with credentials: true.", "components": ["express"], "sources": ["express:sb6448b9a443d"], "status": "REASONED"}, "fastapi-origins": {"text": "FastAPI adds CORSMiddleware with allow_origins restricted to https://app.example.com.", "components": ["fastapi"], "sources": ["fastapi:s4f1cea92e7bc"], "status": "REASONED"}, "fastapi-credentials": {"text": "The FastAPI example enables allow_credentials=True.", "components": ["fastapi"], "sources": ["fastapi:s4f1cea92e7bc"], "status": "REASONED"}, "fastapi-methods": {"text": "The FastAPI example allows only GET and POST through allow_methods.", "components": ["fastapi"], "sources": ["fastapi:s4f1cea92e7bc"], "status": "REASONED"}, "fastapi-headers": {"text": "The FastAPI example allows Authorization and Content-Type through allow_headers.", "components": ["fastapi"], "sources": ["fastapi:s4f1cea92e7bc"], "status": "REASONED"}, "verify-hostile": {"text": "The exposed API echoes https://evil.example in Access-Control-Allow-Origin; the fixed API omits that hostile origin. No deployed API or browser run is recorded.", "components": ["protocol", "express", "fastapi", "fetch"], "sources": ["protocol:s6265cf028385", "express:sb6448b9a443d", "fastapi:s4f1cea92e7bc", "fetch:s028c38ebd452"], "status": "REASONED", "verify": [1]}, "verify-allowed": {"text": "The allowed-Origin probe should return the listed app origin, with Allow-Credentials only where the browser must send cookies, HTTP authentication or TLS client certificates; no actual response is recorded.", "components": ["protocol", "express", "fastapi", "fetch"], "sources": ["protocol:s6265cf028385", "express:sb6448b9a443d", "fastapi:s4f1cea92e7bc", "fetch:s028c38ebd452"], "status": "REASONED", "verify": [1]}, "credential-types": {"text": "Browser credentials include cookies, HTTP authentication and TLS client certificates; Access-Control-Allow-Credentials: true is needed only where the browser must send credentials.", "components": ["fetch", "protocol"], "sources": ["fetch:s028c38ebd452", "protocol:s6265cf028385"], "status": "REASONED"} } } ```` ====================================================================== ==> headers.md ====================================================================== # Security headers for your application Security headers are instructions a web server adds to responses, telling the browser to enforce protections such as HTTPS-only access or limits on script sources. This guide helps your AI assistant set them securely, including Strict-Transport-Security, Content-Security-Policy, and cache rules that keep logged-in pages out of shared and browser caches. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/headers.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against MDN HTTP documentation (no recorded version), nginx source release-1.26.3, Apache HTTP Server 2.4.69, Caddy source v2.11.4, Helmet v8.1.0, Django 5.2, and curl 8.23.0; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-10-17; documentation checked September 2026. The guide records 24 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. TLS protects the transport; these response headers protect the page. Set them at the proxy ([`add_header`](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L110-L116) in [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [`Header`](https://github.com/apache/httpd/blob/2.4.69/docs/manual/mod/mod_headers.xml#L311-L328) in [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md), [`header`](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/headers/caddyfile.go#L27-L48) in Caddy), in app middleware (helmet for Express per [nodejs.md](https://github.com/jposluns/secureconfig/blob/main/nodejs.md), Django's security settings per [python.md](https://github.com/jposluns/secureconfig/blob/main/python.md)), or in a [`_headers` file](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/pages/configuration/headers.mdx#L14) for Cloudflare Pages static asset responses. Cloudflare Pages does not apply `_headers` to Pages Functions responses; set those headers in [Functions code](https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/pages/configuration/headers.mdx#L26). Applying the three proxy directives to all six headers below is this guide's inference. ## The set worth shipping ``` Strict-Transport-Security: max-age=31536000; includeSubDomains Content-Security-Policy: default-src 'self' X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() X-Frame-Options: DENY ``` Notes that keep these correct rather than decorative: - **HSTS** only after HTTPS provably works everywhere on the domain; `includeSubDomains` commits every subdomain to HTTPS. Leave the `preload` token off unless you have read what preload-list inclusion means: removal is possible but slow (weeks, and browser-dependent as each browser ships an updated list), so treat inclusion as a long-lived commitment. - **CSP** is the one that needs tailoring. Start from `default-src 'self'`, add the sources your app actually uses, and prefer nonces or hashes over `'unsafe-inline'` for scripts. Roll out with `Content-Security-Policy-Report-Only` first on an existing app so you see what would break before enforcing. - **frame-ancestors** in CSP supersedes `X-Frame-Options` in browsers that support it. The modern equivalent of `X-Frame-Options: DENY` is `frame-ancestors 'none'` in the CSP; add it explicitly, because `default-src` does not cover framing. Sending both `X-Frame-Options` and `frame-ancestors` keeps older scanners content. - Headers belong on every response, including error pages; this guide assesses setting them only on `200 /` as a common proxy misconfiguration. In nginx, `add_header` inherits the parent's headers only if a child has no header list ([merge](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L665-L690); [list creation](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L788-L796)), and [`always`](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L843-L849) covers statuses outside its default set ([status handling](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L216-L218); [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md)). ## Do not cache authenticated responses in a shared cache A CDN or shared proxy that keys its cache on the URL alone can serve one user's authenticated response to another. Set `Cache-Control: private` on authenticated responses, or `no-store` on the most sensitive ones, and mark cacheable only what is truly public. If a shared cache must hold authenticated content, configure it explicitly to vary on the cookie or the authorization header rather than relying on its default URL-only key. ## Keep authenticated pages out of the browser's own cache `private` stops a shared cache from holding a response, but it still lets the user's own browser store it: MDN defines `private` as a response the browser's local cache may keep. So the Back button can redisplay an authenticated page after logout, and a chat transcript can sit in the history of a shared machine. Set `Cache-Control: no-store` on any response that carries authenticated content or sets a session cookie; MDN defines `no-store` as "any caches of any kind (private or shared) should not store this response", which covers the browser too. On logout, clear what the browser already holds. `Cache-Control: no-cache` does not cover this, because a history navigation such as the Back button can restore a back/forward-cache snapshot without revalidating. Send the `Clear-Site-Data` header on the logout-confirmation response instead: its `cache` directive clears the browser cache and, depending on the browser, the back/forward cache as well, and `cookies` clears the client-side session cookie (invalidate the server-side session separately, as part of logout). MDN's own logout example is `Clear-Site-Data: "cache", "cookies", "storage", "executionContexts", "prefetchCache", "prerenderCache"`. Know the scope before sending the full set: `"cookies"` clears cookies across the entire registered domain (all subdomains) and also clears HTTP authentication credentials, and `"storage"` removes origin local/session storage, IndexedDB, and service-worker registrations, so a shared parent domain or a companion app on a subdomain is affected too. ## Verify ```bash # -q (first) skips curlrc, -g disables URL globbing, and -sS hides progress but shows errors. # Fetch the FINAL page and read its response headers by eye. -L follows redirects (--proto-redir # '=https' blocks an https-to-http downgrade) so you inspect the page a browser lands on, not a 302; # --noproxy so no client proxy rewrites the headers; the body is discarded. -D - prints EVERY # response's headers (a 103 Early Hints and each redirect hop, then the final one), and -w prints where # you ended up, so read the block under the LAST "HTTP/.. " line: ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_SITE_HOST' # your site's public host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set +e # errexit off here, so a check that ends nonzero is classified below instead of ending the block set -- "$(if curl -q -g -sS -L --proto-redir '=https' --noproxy '*' -D - -o /dev/null -w 'final: %{http_code} %{url_effective}\n' "https://$1/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "${1##*$'\n'}" "${1%$'\n'*}" # curl's exit status, then every response's headers and the final: line printf '%s' "$2" # In that final response check VALUES, not just presence: Strict-Transport-Security with a large, # non-zero max-age (max-age=0 disables HSTS); an ENFORCING Content-Security-Policy, not only # Content-Security-Policy-Report-Only (which enforces nothing); X-Content-Type-Options nosniff; # Referrer-Policy strict-origin-when-cross-origin; Permissions-Policy denying camera, microphone, and # geolocation; and an effective deny-framing policy: CSP frame-ancestors 'none', or X-Frame-Options # DENY with NO enforcing frame-ancestors overriding it (an enforcing frame-ancestors always wins over # X-Frame-Options, so frame-ancestors * with X-Frame-Options: DENY still allows framing). A header # missing from the final response, or present only on an earlier hop, is a finding; a failed fetch is # inconclusive. The block exits 1 if the final response lacks Strict-Transport-Security, an enforcing # Content-Security-Policy, X-Content-Type-Options, Referrer-Policy or Permissions-Policy, or has # neither X-Frame-Options nor a frame-ancestors directive in an enforcing Content-Security-Policy; 3 if # the fetch fails or the header check cannot run; otherwise 0, leaving every header value for you # to judge. if [ "$1" != 0 ]; then echo "curl exit $1: the fetch failed; inconclusive"; set -- 3 elif printf '%s' "$2" | awk '{ sub(/\r$/, "") } /^HTTP\// { seen = 1; sts = csp = nosniff = ref = perm = xfo = fa = 0; next } { i = index($0, ":"); if (i < 2) next; n = tolower(substr($0, 1, i - 1)); v = tolower(substr($0, i + 1)) } n == "strict-transport-security" { sts = 1 } n == "content-security-policy" { csp = 1; gsub(/ /, "", v); gsub(/\t/, "", v); gsub(/,/, ";", v); if (index(";" v, ";frame-ancestors")) fa = 1 } n == "x-content-type-options" { nosniff = 1 } n == "referrer-policy" { ref = 1 } n == "permissions-policy" { perm = 1 } n == "x-frame-options" { xfo = 1 } END { if (!seen) exit 3; m = 0; if (!sts) { print "FINDING: strict-transport-security is missing from the final response"; m = 1 }; if (!csp) { print "FINDING: content-security-policy is missing from the final response"; m = 1 }; if (!nosniff) { print "FINDING: x-content-type-options is missing from the final response"; m = 1 }; if (!ref) { print "FINDING: referrer-policy is missing from the final response"; m = 1 }; if (!perm) { print "FINDING: permissions-policy is missing from the final response"; m = 1 }; if (!xfo && !fa) { print "FINDING: the final response has no deny-framing policy: neither x-frame-options nor a frame-ancestors directive in an enforcing content-security-policy"; m = 1 }; exit m }'; then set -- 0 else case "$?" in 1) set -- 1 ;; 3) echo "awk found no response in curl's output; inconclusive"; set -- 3 ;; *) echo "awk exit $?: the header check could not run; inconclusive"; set -- 3 ;; esac fi echo "block exit $1; the header values are not classified, so judge them yourself against the comments above" exit "$1" ) ``` REASONED: preceding block; final-response header inspection follows the cited MDN header documentation, and final-response header values, external scanning and CSP discrimination follow the guide's cited MDN HTTP headers reference and Security header scanner. The command options and write-out fields are documented by curl 8.23.0, while `-D -` printing each response's header block, and selecting the last one, are this guide's assessment. No deployment URL was supplied for live checks, no deployed application or controlled browser application session is available, and network access is unavailable. The block states the expected values and treats failed fetches as inconclusive. ShellCheck 0.11.0 and bash -n exited 0 without diagnostics on this block in an earlier form, before its curl took your site's host on a guarded `set --` line that refuses example.com, example.net, example.org and their subdomains, and before the block classified the final response's headers and exited with one status. On the current, classifying form, bash -n and ShellCheck 0.11.0 pass in the repository's shell-block gate (`tools/check_shell_blocks.py`), and this guide records no run of the guard or the classification. No HTTP response or browser outcome was observed. Exposed responses omit effective controls or allow the forbidden resource; fixed final responses carry the stated values and block the forbidden resource while permitting the allowed control. Then scan with https://securityheaders.com/ from outside for a second opinion (it fetches with GET). No UNEXPECTED CSP console violations during normal application use is necessary but not sufficient: a permissive CSP produces none, and a correct policy still logs a violation when it blocks something (the negative test below, an attack, or an unwanted load). Confirm the policy actually blocks by loading a page with a deliberately disallowed resource (an inline script the policy forbids, or an off-origin image) and checking it is refused while an allowed resource still loads. REASONED: shared-cache caller separation, per the guide's cited MDN Cache-Control. No deployed shared cache or authenticated A/B accounts are available; this prose supplies no local command or configuration to execute. No response sequence was observed. Exposed caching replays a previous caller's private content; fixed responses after warming the same URL belong only to their current authenticated or anonymous caller. For an authenticated route behind a shared cache, request the same URL as user A, then as user B, then anonymously, after warming the cache; each response must reflect only its own caller, never the one before it. REASONED: logout, browser history and response-header inspection, per the guide's cited MDN Cache-Control, MDN Clear-Site-Data and MDN back/forward cache. No application session or supported-browser deployment matrix is available; this prose supplies no local command or configuration to execute. No logout sequence was observed. Exposed history restores authenticated content after logout; the expected fixed outcome is a new request reaching login, with no-store and Clear-Site-Data checked independently in every supported browser. In a real browser, log in, open an authenticated page, then log out and press the Back button: the authenticated page must not reappear from history. The browser should re-request it and land on the login screen. That confirms the logout outcome, not which header produced it, so also inspect the authenticated and logout responses for the intended `no-store` and `Clear-Site-Data` headers. Because the back/forward-cache clearing is browser-dependent, run the check in each browser you support. ## Sources (checked September 2026) - MDN HTTP headers reference (Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-Frame-Options): https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers - MDN Cache-Control (`no-store` forbids any cache including the browser; `private` still permits the browser's local cache): https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control - MDN Clear-Site-Data (the `cache` directive clears the browser cache and, depending on the browser, the back/forward cache; the logout example): https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Clear-Site-Data - MDN back/forward cache (a history navigation restores a snapshot without revalidating): https://developer.mozilla.org/en-US/docs/Glossary/bfcache - Security header scanner: https://securityheaders.com/ - Helmet v8.1.0 response-header middleware (checked October 2026): https://github.com/helmetjs/helmet/blob/v8.1.0/README.md#L3 - Django 5.2 SecurityMiddleware (checked October 2026): https://docs.djangoproject.com/en/5.2/ref/middleware/#module-django.middleware.security - nginx release-1.26.3 add_header directive (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L110-L116 - nginx release-1.26.3 add_header list creation (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L788-L796 - nginx release-1.26.3 header inheritance (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L665-L690 - nginx release-1.26.3 always status handling (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L216-L218 - nginx release-1.26.3 always option (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L843-L849 - Apache 2.4.69 Header response-header directive (checked October 2026): https://github.com/apache/httpd/blob/2.4.69/docs/manual/mod/mod_headers.xml#L311-L328 - Caddy v2.11.4 header response-header directive (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/headers/caddyfile.go#L27-L48 - Cloudflare Pages _headers on static assets (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104) (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/pages/configuration/headers.mdx#L14 - Cloudflare Pages _headers Pages Functions limit (pinned commit c896795b8e8bab68d05ae78d64c9cbcda1fdb104) (checked October 2026): https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/pages/configuration/headers.mdx#L26 - curl 8.23.0 --disable (first argument skips curlrc) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L18-L20 - curl 8.23.0 --globoff (disables URL globbing) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/globoff.md#L19-L22 - curl 8.23.0 --silent (quiet mode with --show-error) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/silent.md#L20-L25 - curl 8.23.0 --show-error (reports errors with --silent) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/show-error.md#L19 - curl 8.23.0 --location (follows redirects) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/location.md#L23-L26 - curl 8.23.0 --proto-redir (restricts redirect protocols) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/proto-redir.md#L19-L20 - curl 8.23.0 --proto (= permits only the named protocol) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/proto.md#L30-L33 - curl 8.23.0 --noproxy (* disables proxy use) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/noproxy.md#L18-L23 - curl 8.23.0 --dump-header (- writes headers to stdout) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/dump-header.md#L21-L23 - curl 8.23.0 --output (/dev/null discards the body) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/output.md#L53-L55 - curl 8.23.0 --write-out (format and variables) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L20-L29 - curl 8.23.0 --write-out http_code (last response code) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L112-L114 - curl 8.23.0 --write-out url_effective (last fetched URL) (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L358-L360 - curl 8.23.0 --disable short form -q (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L5 - curl 8.23.0 --globoff short form -g (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/globoff.md#L5 - curl 8.23.0 --silent short form -s (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/silent.md#L5 - curl 8.23.0 --show-error short form -S (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/show-error.md#L5 - curl 8.23.0 --location short form -L (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/location.md#L5 - curl 8.23.0 --dump-header short form -D (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/dump-header.md#L5 - curl 8.23.0 --output short form -o (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/output.md#L6 - curl 8.23.0 --write-out short form -w (checked October 2026): https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L5 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - mdn: MDN HTTP documentation (unknown) - nginx-source: nginx source (release-1.26.3) - apache-source: Apache HTTP Server (2.4.69) - caddy-source: Caddy source (v2.11.4) - cloudflare-pages: Cloudflare Pages documentation (c896795b8e8bab68d05ae78d64c9cbcda1fdb104) - helmet: Helmet (v8.1.0) - django: Django (5.2) - scanner: Security Headers scanner (unknown) - curl: curl (8.23.0) Claims (full source mapping in the JSON block below): - placement (REASONED; mdn, helmet, django, nginx-source, apache-source, caddy-source, cloudflare-pages): Set response headers at a proxy with nginx add_header, Apache Header or Caddy header, in application middleware, or in a Cloudflare Pages _headers file for static asset responses; Pages Functions responses will likely need headers set in Functions code. Applying the three proxy directives to all six listed headers is this guide's inference. - hsts (REASONED; mdn): Use HSTS max-age=31536000 with includeSubDomains only after HTTPS works across the domain; every subdomain is committed to HTTPS and max-age=0 disables HSTS. - hsts-preload (REASONED; mdn): Omit preload unless its commitment is understood; removal is slow, takes weeks and depends on browser list updates. - csp (REASONED; mdn): Start CSP at default-src 'self', tailor sources to the app and prefer script nonces or hashes over unsafe-inline; Verify requires an enforcing policy. - csp-report-only (REASONED; mdn): Roll out Content-Security-Policy-Report-Only first on existing apps to find breakage; it enforces nothing and is not a substitute for enforcing CSP. - nosniff (REASONED; mdn): Send X-Content-Type-Options: nosniff and verify that value on the final response. - referrer (REASONED; mdn): Send Referrer-Policy: strict-origin-when-cross-origin and verify that final-response value. - permissions (REASONED; mdn): Permissions-Policy disables camera, microphone and geolocation with empty allowlists; verify all three restrictions. - framing (REASONED; mdn): X-Frame-Options: DENY or enforcing CSP frame-ancestors 'none' denies framing; supported frame-ancestors supersedes X-Frame-Options, and default-src does not cover it. frame-ancestors * overrides DENY. - all-responses (REASONED; mdn, nginx-source): Send headers on every response, including errors; nginx add_header inherits parent headers only when the child has no header list, and always covers statuses outside its default set. This guide assesses setting headers only on 200 / as a common proxy misconfiguration. - shared-cache (REASONED; mdn): Use Cache-Control: private for authenticated responses or no-store for sensitive ones; cache only public content unless authenticated caching is explicitly configured. - cache-key (REASONED; mdn): A URL-only shared cache can leak one user's response to another; explicitly vary authenticated cache entries on the cookie or authorization header. - browser-cache (REASONED; mdn): private still permits browser storage; no-store forbids private and shared storage and belongs on authenticated responses and responses setting session cookies. - history-cache (REASONED; mdn): no-cache does not prevent a Back navigation from restoring a back/forward-cache snapshot without revalidation. - logout-cache (REASONED; mdn): Send Clear-Site-Data on logout confirmation; cache clears browser cache and may clear back/forward cache depending on the browser. - logout-cookies (REASONED; mdn): Clear-Site-Data cookies clears cookies across the registered domain and all subdomains, plus HTTP authentication credentials; invalidate the server session separately. - logout-storage (REASONED; mdn): Clear-Site-Data storage removes origin local/session storage, IndexedDB and service-worker registrations; understand effects on companion applications before clearing. - logout-header (REASONED; mdn): The cited MDN logout example includes cache, cookies, storage, executionContexts, prefetchCache and prerenderCache. - verify-fetch (REASONED; mdn, curl): Fetch the final page with redirects restricted to HTTPS, bypass client proxies, discard the body and inspect the last response's headers and final status/URL; curl 8.23.0 documents the command options and write-out fields, while `-D -` printing each response's header block, and selecting the last one, are this guide's assessment. - verify-values (REASONED; mdn): Judge header values on the final response, not presence on an earlier hop; missing final headers are findings and a failed fetch is inconclusive. - verify-scanner (REASONED; scanner): Use the external Security Headers scanner for a second opinion; the guide states that it fetches with GET. - verify-csp (REASONED; mdn): Unexpected console violations must be absent in normal use, but this alone is insufficient; confirm a deliberately disallowed resource is blocked while an allowed one loads. - verify-cache (REASONED; mdn): Warm an authenticated URL in the shared cache, then request it as user A, user B and anonymously; each response must belong only to its caller. - verify-logout (REASONED; mdn): After logout, Back must not restore authenticated content; verify a request to the login screen and inspect no-store and Clear-Site-Data separately in every supported browser. ````json { "schema": 1, "checked": "2026-10-17", "documentation_checked": "2026-09", "body_sha256": "39ca21a6acd6f5e964df85181ee154aa3168d8cb4fc6335f88a465ae8739629b", "components": { "mdn": { "name": "MDN HTTP documentation", "basis": "unknown", "sources": { "s40155272e6bf": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers", "s9166d803cf3a": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control", "s7f290c45ad37": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Clear-Site-Data", "s46957d9dc1d5": "https://developer.mozilla.org/en-US/docs/Glossary/bfcache" } }, "nginx-source": { "name": "nginx source", "basis": "release-1.26.3", "sources": { "s24b0a72bc894": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L110-L116", "s0ae4e9bc8d07": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L216-L218", "sedd333de1a8c": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L665-L690", "sf6eac8f6749d": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L788-L796", "s70d54f325a93": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_headers_filter_module.c#L843-L849" } }, "apache-source": { "name": "Apache HTTP Server", "basis": "2.4.69", "sources": { "s2f07cea83845": "https://github.com/apache/httpd/blob/2.4.69/docs/manual/mod/mod_headers.xml#L311-L328" } }, "caddy-source": { "name": "Caddy source", "basis": "v2.11.4", "sources": { "s0fa8b969b205": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/headers/caddyfile.go#L27-L48" } }, "cloudflare-pages": { "name": "Cloudflare Pages documentation", "basis": "c896795b8e8bab68d05ae78d64c9cbcda1fdb104", "sources": { "s38b6aa1f9566": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/pages/configuration/headers.mdx#L14", "sba79d4023eca": "https://github.com/cloudflare/cloudflare-docs/blob/c896795b8e8bab68d05ae78d64c9cbcda1fdb104/src/content/docs/pages/configuration/headers.mdx#L26" } }, "helmet": { "name": "Helmet", "basis": "v8.1.0", "sources": { "sf42121cfc014": "https://github.com/helmetjs/helmet/blob/v8.1.0/README.md#L3" } }, "django": { "name": "Django", "basis": "5.2", "sources": { "se79954c08919": "https://docs.djangoproject.com/en/5.2/ref/middleware/#module-django.middleware.security" } }, "scanner": { "name": "Security Headers scanner", "basis": "unknown", "sources": { "s7a94d0fcb6ad": "https://securityheaders.com/" } }, "curl": { "name": "curl", "basis": "8.23.0", "sources": { "s2278b86c08a6": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L18-L20", "s3b9e19f23f2d": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/globoff.md#L19-L22", "s38391c89b58d": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/silent.md#L20-L25", "sc1670c4672b4": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/show-error.md#L19", "s891e2566ea41": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/location.md#L23-L26", "sd386dc0317da": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/proto-redir.md#L19-L20", "sf7e7834afa89": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/proto.md#L30-L33", "s508454c45cf1": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/noproxy.md#L18-L23", "s62990c766776": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/dump-header.md#L21-L23", "seb2e6bc373fe": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/output.md#L53-L55", "s3aefa5f74fd4": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L20-L29", "se50d33437ccf": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L112-L114", "sc8658f8a2e89": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/disable.md#L5", "s3dbfd02b9aa2": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/globoff.md#L5", "s5b5b25c9a3fe": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/silent.md#L5", "s24da6d0f30ba": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/show-error.md#L5", "s6bee713841f0": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/location.md#L5", "se89c7271eda2": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/dump-header.md#L5", "s93be9b76f8e2": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/output.md#L6", "s3633dd0e6b3e": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L5", "s9a3d23bc00be": "https://github.com/curl/curl/blob/curl-8_23_0/docs/cmdline-opts/write-out.md#L358-L360" } } }, "claims": { "placement": {"text": "Set response headers at a proxy with nginx add_header, Apache Header or Caddy header, in application middleware, or in a Cloudflare Pages _headers file for static asset responses; Pages Functions responses will likely need headers set in Functions code. Applying the three proxy directives to all six listed headers is this guide's inference.", "components": ["mdn", "helmet", "django", "nginx-source", "apache-source", "caddy-source", "cloudflare-pages"], "sources": ["mdn:s40155272e6bf", "helmet:sf42121cfc014", "django:se79954c08919", "nginx-source:s24b0a72bc894", "nginx-source:sf6eac8f6749d", "apache-source:s2f07cea83845", "caddy-source:s0fa8b969b205", "cloudflare-pages:s38b6aa1f9566", "cloudflare-pages:sba79d4023eca"], "status": "REASONED"}, "hsts": {"text": "Use HSTS max-age=31536000 with includeSubDomains only after HTTPS works across the domain; every subdomain is committed to HTTPS and max-age=0 disables HSTS.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "hsts-preload": {"text": "Omit preload unless its commitment is understood; removal is slow, takes weeks and depends on browser list updates.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED"}, "csp": {"text": "Start CSP at default-src 'self', tailor sources to the app and prefer script nonces or hashes over unsafe-inline; Verify requires an enforcing policy.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "csp-report-only": {"text": "Roll out Content-Security-Policy-Report-Only first on existing apps to find breakage; it enforces nothing and is not a substitute for enforcing CSP.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "nosniff": {"text": "Send X-Content-Type-Options: nosniff and verify that value on the final response.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "referrer": {"text": "Send Referrer-Policy: strict-origin-when-cross-origin and verify that final-response value.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "permissions": {"text": "Permissions-Policy disables camera, microphone and geolocation with empty allowlists; verify all three restrictions.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "framing": {"text": "X-Frame-Options: DENY or enforcing CSP frame-ancestors 'none' denies framing; supported frame-ancestors supersedes X-Frame-Options, and default-src does not cover it. frame-ancestors * overrides DENY.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "all-responses": {"text": "Send headers on every response, including errors; nginx add_header inherits parent headers only when the child has no header list, and always covers statuses outside its default set. This guide assesses setting headers only on 200 / as a common proxy misconfiguration.", "components": ["mdn", "nginx-source"], "sources": ["mdn:s40155272e6bf", "nginx-source:sedd333de1a8c", "nginx-source:sf6eac8f6749d", "nginx-source:s0ae4e9bc8d07", "nginx-source:s70d54f325a93"], "status": "REASONED"}, "shared-cache": {"text": "Use Cache-Control: private for authenticated responses or no-store for sensitive ones; cache only public content unless authenticated caching is explicitly configured.", "components": ["mdn"], "sources": ["mdn:s9166d803cf3a"], "status": "REASONED"}, "cache-key": {"text": "A URL-only shared cache can leak one user's response to another; explicitly vary authenticated cache entries on the cookie or authorization header.", "components": ["mdn"], "sources": ["mdn:s9166d803cf3a", "mdn:s40155272e6bf"], "status": "REASONED"}, "browser-cache": {"text": "private still permits browser storage; no-store forbids private and shared storage and belongs on authenticated responses and responses setting session cookies.", "components": ["mdn"], "sources": ["mdn:s9166d803cf3a"], "status": "REASONED"}, "history-cache": {"text": "no-cache does not prevent a Back navigation from restoring a back/forward-cache snapshot without revalidation.", "components": ["mdn"], "sources": ["mdn:s9166d803cf3a", "mdn:s46957d9dc1d5"], "status": "REASONED"}, "logout-cache": {"text": "Send Clear-Site-Data on logout confirmation; cache clears browser cache and may clear back/forward cache depending on the browser.", "components": ["mdn"], "sources": ["mdn:s7f290c45ad37", "mdn:s46957d9dc1d5"], "status": "REASONED"}, "logout-cookies": {"text": "Clear-Site-Data cookies clears cookies across the registered domain and all subdomains, plus HTTP authentication credentials; invalidate the server session separately.", "components": ["mdn"], "sources": ["mdn:s7f290c45ad37"], "status": "REASONED"}, "logout-storage": {"text": "Clear-Site-Data storage removes origin local/session storage, IndexedDB and service-worker registrations; understand effects on companion applications before clearing.", "components": ["mdn"], "sources": ["mdn:s7f290c45ad37"], "status": "REASONED"}, "logout-header": {"text": "The cited MDN logout example includes cache, cookies, storage, executionContexts, prefetchCache and prerenderCache.", "components": ["mdn"], "sources": ["mdn:s7f290c45ad37"], "status": "REASONED"}, "verify-fetch": {"text": "Fetch the final page with redirects restricted to HTTPS, bypass client proxies, discard the body and inspect the last response's headers and final status/URL; curl 8.23.0 documents the command options and write-out fields, while `-D -` printing each response's header block, and selecting the last one, are this guide's assessment.", "components": ["mdn", "curl"], "sources": ["mdn:s40155272e6bf", "curl:s2278b86c08a6", "curl:s3b9e19f23f2d", "curl:s38391c89b58d", "curl:sc1670c4672b4", "curl:s891e2566ea41", "curl:sd386dc0317da", "curl:sf7e7834afa89", "curl:s508454c45cf1", "curl:s62990c766776", "curl:seb2e6bc373fe", "curl:s3aefa5f74fd4", "curl:se50d33437ccf", "curl:s9a3d23bc00be", "curl:sc8658f8a2e89", "curl:s3dbfd02b9aa2", "curl:s5b5b25c9a3fe", "curl:s24da6d0f30ba", "curl:s6bee713841f0", "curl:se89c7271eda2", "curl:s93be9b76f8e2", "curl:s3633dd0e6b3e"], "status": "REASONED", "verify": [1]}, "verify-values": {"text": "Judge header values on the final response, not presence on an earlier hop; missing final headers are findings and a failed fetch is inconclusive.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED", "verify": [1]}, "verify-scanner": {"text": "Use the external Security Headers scanner for a second opinion; the guide states that it fetches with GET.", "components": ["scanner"], "sources": ["scanner:s7a94d0fcb6ad"], "status": "REASONED"}, "verify-csp": {"text": "Unexpected console violations must be absent in normal use, but this alone is insufficient; confirm a deliberately disallowed resource is blocked while an allowed one loads.", "components": ["mdn"], "sources": ["mdn:s40155272e6bf"], "status": "REASONED"}, "verify-cache": {"text": "Warm an authenticated URL in the shared cache, then request it as user A, user B and anonymously; each response must belong only to its caller.", "components": ["mdn"], "sources": ["mdn:s9166d803cf3a"], "status": "REASONED"}, "verify-logout": {"text": "After logout, Back must not restore authenticated content; verify a request to the login screen and inspect no-store and Clear-Site-Data separately in every supported browser.", "components": ["mdn"], "sources": ["mdn:s9166d803cf3a", "mdn:s7f290c45ad37", "mdn:s46957d9dc1d5"], "status": "REASONED"} } } ```` ====================================================================== ==> firebase-supabase.md ====================================================================== # Firebase and Supabase: the rules are the security Firebase and Supabase are hosted app backends whose database, storage, sign-in and functions are called directly from client code. This guide helps your AI assistant configure each securely, including Firebase Security Rules or Supabase row-level security with grants, separately authorized functions and storage, and server keys kept off clients. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/firebase-supabase.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Firebase documentation (no recorded version), PostgreSQL invoker-view minimum 15, curl curl-8_14_1, and ShellCheck v0.11.0; 2 further sources carry no recorded version. Metadata reviewed 2026-10-19; documentation checked September 2026. The guide records 70 claims: 2 demonstrated by a check that ran and 68 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These platforms handle TLS for you on their hosted endpoints; the exposure works differently. Client SDKs talk to the backend using keys that ship in your frontend code and are **public by design**: the Firebase API key and Supabase publishable or legacy `anon` key. Client data access needs Firebase Security Rules or Postgres row-level security (RLS) alongside grants, IAM, endpoint authorization, and App Check where applicable. AI-generated apps repeatedly ship with that layer open because "it worked in testing". On Supabase the gate also depends on how exposed views and functions execute, not only on the table policies. Documentation was checked in September 2026. Defaults below describe the documentation at the time of writing; inspect the effective settings of each project. Live behavior has not been demonstrated in this authoring environment. The Verify section records the missing prerequisites and the vendor documentation supporting the REASONED checks. ## Firebase ### 1. Set ownership rules in all three products Every Firestore, Realtime Database, and Storage instance needs explicit security rules. Never deploy the all-open rule (`allow read, write: if true;` or `".read": true, ".write": true`); it exposes the entire datastore to anyone with your public config. Require authentication and scope by user: `request.auth != null` establishes authentication, not authorization to another user's data. See [Firebase Security Rules](https://firebase.google.com/docs/rules) and [ownership examples](https://firebase.google.com/docs/rules/basics). Do not assume a new instance denies access. Creating a Firestore database offers a **test mode** that lets anyone read and overwrite your data alongside locked production mode. Firestore production mode and RTDB locked mode deny client reads and writes. Test-mode templates grant public access for about a month; their expiry ends that access, but does not protect data during the trial. Inspect the actual expiry and replace the template before using sensitive data. See [Firestore starting modes](https://firebase.google.com/docs/firestore/quickstart), [RTDB starting modes](https://firebase.google.com/docs/database/web/start), and [Firebase's test-rule expiry announcement](https://firebase.google.com/support/releases). For Firestore, this is a complete ownership ruleset: ```text rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /users/{userId}/{document=**} { allow read, write: if request.auth != null && request.auth.uid == userId; } } } ``` Version 2 matters: the recursive wildcard matches zero or more path items, so this includes `/users/{userId}` itself and its descendant documents. Under version 1, the same recursive match excludes the user document itself. Unmatched paths remain denied. Remove overlapping permissive rules; a matching denial does not override another matching grant. See [Firestore rule structure](https://firebase.google.com/docs/firestore/security/rules-structure). RTDB uses a separate JSON rules engine. Put ownership checks under `users.$uid`: ```json { "rules": { ".read": false, ".write": false, "users": { "$uid": { ".read": "auth !== null && auth.uid === $uid", ".write": "auth !== null && auth.uid === $uid" } } } } ``` Remove any root or ancestor `.read` or `.write` grant that would authorize everyone. RTDB child rules cannot revoke a parent grant. Read individual authorized user paths; RTDB rules do not filter a parent read into its permitted children. See [RTDB rule syntax and cascading](https://firebase.google.com/docs/database/security/core-syntax). Storage has its own rules; Firestore rules do **not** secure uploaded objects. Firebase's basic rules documentation describes authenticated Storage access in its default-rules prose while showing a deny-all Storage ruleset. Establish deny-all explicitly instead of relying on a starting-mode label: ```text rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /{allPaths=**} { allow read, write: if false; } } } ``` Then replace that ruleset with deliberate ownership access, for example: ```text rules_version = '2'; service firebase.storage { match /b/{bucket}/o { match /users/{userId}/{allPaths=**} { allow read, write: if request.auth != null && request.auth.uid == userId; } } } ``` This authorizes objects beneath the caller's UID path. Other paths remain denied unless another rule grants access. See [basic rules](https://firebase.google.com/docs/rules/basics) and [Storage rule structure](https://firebase.google.com/docs/storage/security/core-syntax). A distributed Storage download URL is a bearer credential: it does not expire, and anyone holding it can access the object. Tightening ownership Rules does not revoke that URL. For sensitive objects, revoke previously distributed download tokens in the Firebase console and use authenticated SDK direct downloads such as `getBlob()` or `getBytes()` so Rules govern access. See [Admin shareable URLs](https://firebase.google.com/docs/storage/admin/start), [download-token revocation](https://firebase.google.com/docs/reference/kotlin/com/google/firebase/storage/StorageReference), and [SDK direct downloads](https://firebase.google.com/docs/storage/web/download-files). Inspect the deployed rules for every Firestore database, bucket, and RTDB instance; keep production locked-by-default, open specific paths deliberately, and test with the Rules Playground and emulator before deploying. Keep local and deployed rule definitions in sync. See [getting started with rules](https://firebase.google.com/docs/firestore/security/get-started) and [Storage rule management](https://firebase.google.com/docs/storage/security/get-started). ### 2. Authorize Functions before privileged work Server-side credentials, including service accounts for the Admin SDK, bypass client Security Rules; they stay on servers only, handled per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). IAM and the server's own authorization checks therefore matter. A function using the Admin SDK must not treat successful SDK access as evidence that its caller was entitled to the data. See [Firestore server access](https://firebase.google.com/docs/firestore/security/rules-structure). Callable authentication is optional: the callable protocol validates a supplied ID token, but does not require the caller to supply one. In second-generation `onCall` handlers, reject missing `request.auth` before privileged work and derive identity from `request.auth.uid`. Treat `request.data` as untrusted input, including any UID, role, document path, or ownership claim. First-generation handlers use `context.auth` instead. See the [callable protocol](https://firebase.google.com/docs/functions/callable-reference), [second-generation handlers](https://firebase.google.com/docs/functions/callable), and [first-generation handlers](https://firebase.google.com/docs/functions/1st-gen/callable-1st). This second-generation example identifies the authenticated caller and also enforces App Check: ```javascript const { onCall, HttpsError } = require("firebase-functions/v2/https"); exports.whoAmI = onCall({ enforceAppCheck: true }, (request) => { if (!request.auth) { throw new HttpsError("unauthenticated", "Sign in first."); } return { uid: request.auth.uid }; }); ``` Before extending it to read or change protected resources, authorize that UID for the requested operation and validate the submitted data. App Check does not replace those checks. The enforcement option requires `firebase-functions` 4.0.0 or newer. See [callable App Check enforcement](https://firebase.google.com/docs/app-check/cloud-functions). An `onRequest` HTTP handler does not inherit callable authentication. For end-user endpoints, extract the bearer ID token, validate it with the Admin SDK's `verifyIdToken()`, reject missing or invalid tokens, and authorize the verified UID. For IAM-controlled endpoints, restrict invocation with `invoker: "private"` or an explicit invoker list and configure the intended principals. Firebase user ID tokens and IAM invocation credentials serve different checks. See [ID-token verification](https://firebase.google.com/docs/auth/admin/verify-id-tokens), [HTTP handlers](https://firebase.google.com/docs/functions/http-events), and [`HttpsOptions.invoker`](https://firebase.google.com/docs/reference/functions/2nd-gen/node/firebase-functions.https.httpsoptions). ### 3. Enforce App Check and restrict public API keys Register each app with its appropriate App Check provider, deploy compatible clients, and inspect request metrics before enabling enforcement. Registration alone does not enforce anything. In the App Check console, enable enforcement separately for Firestore, Realtime Database, and Storage; configure `enforceAppCheck: true` separately on each protected callable. See [provider registration and client rollout](https://firebase.google.com/docs/app-check/web/recaptcha-enterprise-provider) and [per-product enforcement](https://firebase.google.com/docs/app-check/enable-enforcement). In Google Cloud Console > APIs & Services > Credentials, restrict the public Web API key to the intended HTTP referrers. Use Android application restrictions for Android keys and iOS application restrictions for iOS keys. A key supports one application-restriction type, so use separate keys for different types. Apply API restrictions as well. See [application and API restrictions](https://docs.cloud.google.com/api-keys/docs/add-restrictions-api-keys). Build the API allowlist from Firebase's full product table. For example, Authentication uses `identitytoolkit.googleapis.com` and `securetoken.googleapis.com`, while App Check uses `firebaseappcheck.googleapis.com`; those three are not a complete list for every Firebase app. Retest each product after changing restrictions. Keep unrelated, especially billable, APIs on separate restricted keys. See [Firebase API-key restrictions and the product table](https://firebase.google.com/docs/projects/api-keys). The public Firebase config, including `databaseURL`, identifies resources; it grants no authorization. Hiding or changing it does not repair open rules. See [Firebase configuration objects](https://firebase.google.com/docs/web/learn-more). ### 4. Limit sign-in methods and keep emulators private Disable unused sign-in providers in Firebase Authentication, including Anonymous when the app does not need it. Anonymous authentication is opt-in, but an anonymous account still has an authenticated UID and satisfies an authentication-only Rules check. See [anonymous authentication](https://firebase.google.com/docs/auth/web/anonymous-auth). Enable **Email enumeration protection** under Authentication Settings > User account management > User actions. At the time of writing, it is enabled by default only for projects created on or after September 15, 2023. It changes sign-in errors and account-discovery behavior; update clients that rely on the old responses. Signup can still return `EMAIL_EXISTS`, so this is not complete protection against discovering registered addresses. See [email enumeration protection](https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection). Emulators listen on localhost by default. Keep their listeners, UI, and hub private: no public ports, reverse-proxy routes, or public tunnels. A private listener is especially important because Firestore, RTDB, and Storage emulators run with **open data security** when Rules configuration is missing. Merge these entries into `firebase.json`, with the referenced files containing the intended rules: ```json { "firestore": { "rules": "firestore.rules" }, "database": { "rules": "database.rules.json" }, "storage": { "rules": "storage.rules" } } ``` Inspect existing emulator host overrides and restore localhost-only binding where necessary. See [emulator configuration](https://firebase.google.com/docs/emulator-suite/install_and_configure) and [the documented localhost default](https://firebase.google.com/docs/emulator-suite/use_hosting). ## Supabase For the hosted direct-Postgres endpoint and database pooler, restrict allowed client IP ranges with Dashboard **Network Restrictions**, enable **Enforce SSL on incoming connections**, and keep the `postgres` and other database-role passwords secret; see [Network Restrictions](https://supabase.com/docs/guides/platform/network-restrictions), [SSL enforcement](https://supabase.com/docs/guides/platform/ssl-enforcement), and [database roles and passwords](https://supabase.com/docs/guides/database/postgres/roles). ### 1. Restrict the Data API, grants, and rows Enable RLS on **every** table exposed through the API, then write policies for intended access. An API-exposed table without RLS is readable or writable by a role only when that role also has the corresponding grants. Projects retaining Supabase's automatic grants can therefore expose new tables to the public `anon` key. At the time of writing, Supabase documents a transition towards opt-in grants; do not assume identical defaults across projects. Review grants and RLS separately. Dashboard Table Editor creation enables RLS; SQL-created tables require explicit enablement. See [Data API security](https://supabase.com/docs/guides/api/securing-your-api). For an existing `public.profiles` table whose `user_id` column contains the owner's Auth UUID, this baseline permits authenticated owners to select their rows: ```sql alter table public.profiles enable row level security; revoke all privileges on table public.profiles from public, anon, authenticated; grant select on table public.profiles to authenticated; create policy "own rows" on public.profiles for select to authenticated using ((select auth.uid()) = user_id); ``` Review existing policies and inherited or column-level grants as well; adding a narrow policy does not cancel an existing broad permissive policy. See [Supabase RLS](https://supabase.com/docs/guides/database/postgres/row-level-security) and [PostgreSQL policy combination](https://www.postgresql.org/docs/current/sql-createpolicy.html). Write separate policies per operation (`select`, `insert`, `update`, `delete`). No policy means no row access once RLS is on for ordinary roles subject to RLS, which is the correct starting point. Policies grant intended access; they are not required to make an RLS-enabled table closed. Table owners normally bypass RLS, and superusers and `BYPASSRLS` roles bypass it. `TRUNCATE` is outside RLS, so do not grant it to client roles. See [PostgreSQL row security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html). Install only the write policies and matching grants the app intentionally needs. For an app that permits owners to create, update, and delete their profiles: ```sql grant insert, update, delete on table public.profiles to authenticated; create policy "insert own rows" on public.profiles for insert to authenticated with check ((select auth.uid()) = user_id); create policy "update own rows" on public.profiles for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); create policy "delete own rows" on public.profiles for delete to authenticated using ((select auth.uid()) = user_id); ``` The UPDATE policy checks both the existing row and its proposed replacement, preventing a caller from taking another user's row or changing ownership away from themselves. For roles subject to RLS, UPDATE also needs applicable SELECT (or ALL) policies when it reads table columns, for example in WHERE, RETURNING, or a SET expression. Data API updates that filter on existing rows therefore need the SELECT policy too. See [PostgreSQL CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html) and [operation-specific RLS policies](https://supabase.com/docs/guides/database/postgres/row-level-security). `public` is exposed by default. Restrict **Exposed schemas** to the schemas intended for client access, or use a dedicated `api` schema and keep internal tables and helpers outside it. Grant schema usage and object privileges deliberately; changing schemas is not a substitute for RLS. See [custom API schemas](https://supabase.com/docs/guides/api/using-custom-schemas). If the application does not use the Data API, turn **Enable Data API** off in the Dashboard's Data API integration settings. This closes its generated REST endpoints regardless of table policies. It does not disable every other Supabase service. See [disabling the Data API](https://supabase.com/docs/guides/api/securing-your-api). ### 2. Separate client keys, server keys, and JWT signing keys Use `sb_publishable_...` keys in clients. With no signed-in session, a publishable key uses the `anon` role; with a signed-in user's session, requests use `authenticated`. Keep `sb_secret_...` keys only in trusted backends: they resolve to `service_role`, which has `BYPASSRLS`. The legacy `service_role` key likewise bypasses RLS; it is a server-only secret that must never reach the client bundle or the repository. Browser rejection of a secret key does not make a leaked key safe: another client can use it. See [API keys](https://supabase.com/docs/guides/getting-started/api-keys). Creating replacement publishable and secret keys does **not** disable legacy keys. Migrate every consumer, confirm it uses the replacement, then explicitly deactivate the legacy keys in Settings > API Keys. For a compromised secret key, replace it in the trusted consumers and delete the compromised key. Fix the exposure as well as retiring the credential. See [key migration](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys). JWT signing keys have a separate lifecycle. **Rotate keys** changes the key used to sign new JWTs; the previous key remains trusted until it is revoked. Plan revocation and verifier-cache propagation explicitly. API-key replacement is not evidence that an old JWT signing key has lost trust. See [JWT signing keys](https://supabase.com/docs/guides/auth/signing-keys). ### 3. Check views and privileged functions A view checks underlying relations with **its owner's** privileges by default and applies the owner's row-level security policies rather than the caller's. On Supabase a view is typically created by the `postgres` user, so it can disclose rows hidden by the caller's table policies. That requires a reachable schema, suitable grants, and a view query and owner privileges that permit the disclosure; possession of an `anon` key alone does not guarantee it. See [PostgreSQL view security](https://www.postgresql.org/docs/current/sql-createview.html) and [Supabase RLS and views](https://supabase.com/docs/guides/database/postgres/row-level-security). On PostgreSQL 15 and later, make every API-exposed view apply the caller's policies instead: ```sql alter view public.REPLACE_WITH_VIEW_NAME set (security_invoker = true); ``` The caller also needs appropriate privileges on the underlying relations. See [`ALTER VIEW`](https://www.postgresql.org/docs/current/sql-alterview.html) and the [PostgreSQL 15 release notes](https://www.postgresql.org/docs/release/15.0/). On PostgreSQL 14 and earlier there is no `security_invoker`: revoke ALL privileges on the view from `public` as well as from `anon` and `authenticated`, or keep it in a schema the API does not expose. A simple view is automatically updatable, so revoking only `select` can leave `insert`/`update`/`delete` that write through the owner and bypass the table's RLS. Revoking the two API roles alone leaves access they inherit from `PUBLIC`, so confirm with `select has_table_privilege('anon', 'public.REPLACE_WITH_VIEW_NAME', 'select, insert, update, delete');`, which must return `f`. ```sql -- PostgreSQL 14 or earlier: revoke ALL privileges on an API-exposed view from all three, then confirm both API roles lost every one. -- Run the REVOKE as the view's owner; a check still returning t means another grant or role provides access. -- A simple view is automatically updatable, so revoking only SELECT can leave INSERT/UPDATE/DELETE that write through the owner. revoke all privileges on table public.REPLACE_WITH_VIEW_NAME from public, anon, authenticated; select has_table_privilege('anon', 'public.REPLACE_WITH_VIEW_NAME', 'select, insert, update, delete'), has_table_privilege('authenticated', 'public.REPLACE_WITH_VIEW_NAME', 'select, insert, update, delete'); -- both must be f ``` See [`REVOKE`](https://www.postgresql.org/docs/current/sql-revoke.html) and [privilege inquiry functions](https://www.postgresql.org/docs/current/functions-info.html). The comma-separated privilege check returns true if **any** listed privilege remains. Prefer `SECURITY INVOKER` for functions. A `SECURITY DEFINER` function likewise runs as its owner and can return rows RLS would hide. Keep such functions out of API-exposed schemas unless they enforce their own authorization, and revoke execution by default: ```sql revoke execute on function public.REPLACE_WITH_FUNCTION_NAME from public, anon, authenticated; ``` Include the argument types where the function name is overloaded. Where DEFINER is necessary, set `SET search_path = ''`, schema-qualify every referenced relation and helper, and enforce caller authorization inside the function, including MFA where required. An empty search path does not supply authorization. See [database functions](https://supabase.com/docs/guides/database/functions). For an existing function with one UUID argument, the settings and selective grant take this shape. Substitute its actual signature; grant execution only after reviewing its body: ```sql alter function public.REPLACE_WITH_FUNCTION_NAME(uuid) set search_path = ''; revoke execute on function public.REPLACE_WITH_FUNCTION_NAME(uuid) from public, anon, authenticated; grant execute on function public.REPLACE_WITH_FUNCTION_NAME(uuid) to authenticated; ``` New PostgreSQL functions normally grant execution to `PUBLIC`. Create privileged functions and set their privileges in the same transaction. To change future defaults for functions created by `postgres`, remove the global PUBLIC grant and any schema-specific API-role defaults: ```sql alter default privileges for role postgres revoke execute on functions from public; alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated; ``` Repeat for the actual function-creating roles and relevant schemas. Defaults do not change existing functions; a schema-scoped revocation cannot cancel a global default grant. See [`CREATE FUNCTION`](https://www.postgresql.org/docs/current/sql-createfunction.html), [`ALTER FUNCTION`](https://www.postgresql.org/docs/current/sql-alterfunction.html), and [`ALTER DEFAULT PRIVILEGES`](https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html). Audit API-callable wrappers around `http`, `net.http_get`, and `net.http_post`. Revoke execution from `PUBLIC`, `anon`, and `authenticated` on unneeded entry points, using each installed signature. Where needed, constrain callers, destinations, and transmitted data. The documented ability to issue outbound requests implies a possible SSRF or data-exfiltration path if an exposed wrapper accepts attacker-controlled destinations or forwards privileged data; enabling an extension alone is not proof of an exploitable endpoint. See [`http`](https://supabase.com/docs/guides/database/extensions/http) and [`pg_net`](https://supabase.com/docs/guides/database/extensions/pg_net). Restrict `vault.decrypted_secrets` and every DEFINER function that reads it: ```sql revoke all privileges on table vault.decrypted_secrets from public, anon, authenticated; ``` Keep Vault outside exposed schemas and review indirect access through views and functions. Vault decrypts values through this view; encryption at rest does not prevent an authorized query or privileged wrapper from disclosing plaintext through an API. See [Vault](https://supabase.com/docs/guides/database/vault). ### 4. Authorize Storage separately Keep sensitive buckets **Private**. Private downloads require authorized access or a deliberately issued, time-limited signed URL. A **Public** bucket bypasses retrieval checks for anyone with the asset URL; upload, delete, move, and copy operations still undergo access checks. See [bucket access models](https://supabase.com/docs/guides/storage/buckets/fundamentals). Application-table policies do not secure Storage objects. Define policies on `storage.objects`, constrained by both `bucket_id` and the caller's path or ownership. For a private bucket whose object names begin with the owner's UUID: ```sql create policy "read own files" on storage.objects for select to authenticated using ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (storage.foldername(name))[1] = (select auth.uid())::text ); create policy "upload own files" on storage.objects for insert to authenticated with check ( bucket_id = 'REPLACE_WITH_PRIVATE_BUCKET' and (storage.foldername(name))[1] = (select auth.uid())::text ); ``` See [Storage helpers](https://supabase.com/docs/guides/storage/schema/helper-functions) and [Storage access control](https://supabase.com/docs/guides/storage/security/access-control). Upload needs INSERT. Upsert additionally needs SELECT and UPDATE policies. If overwrites are intended, give UPDATE both `USING` and `WITH CHECK` with the same bucket and owner-path condition; add DELETE only if intended. Remove broader existing policies that would admit other users. The example above deliberately does not permit overwrites or deletion. ### 5. Require private Realtime channels At the time of writing, **Allow public access to channels** is enabled by default. Disable it in Realtime Settings to require private channels. Set `config.private` on clients as well; a client's private flag alone does not disable public channels for the project. See [Realtime settings](https://supabase.com/docs/guides/realtime/settings). For channels named `user:` followed by the authenticated user's UUID: ```javascript const channel = supabase.channel('user:REPLACE_WITH_AUTHENTICATED_USER_ID', { config: { private: true } }); ``` Authorize receiving and sending separately on `realtime.messages`, checking both the topic and the message extension: ```sql create policy "receive own channel" on realtime.messages for select to authenticated using ( (select realtime.topic()) = 'user:' || (select auth.uid())::text and extension in ('broadcast', 'presence') ); create policy "send own channel" on realtime.messages for insert to authenticated with check ( (select realtime.topic()) = 'user:' || (select auth.uid())::text and extension in ('broadcast', 'presence') ); ``` These policies govern Broadcast and Presence; replace the ownership expression with an explicit membership check for shared rooms. See [Realtime authorization](https://supabase.com/docs/guides/realtime/authorization). Postgres Changes uses source-table authorization. Private-channel policies are not a universal Realtime filter. Test that subscription path separately; in particular, the documented DELETE event limitations differ from normal row reads. See [Postgres Changes security and limitations](https://supabase.com/docs/guides/realtime/postgres-changes). ### 6. Limit registration and enforce MFA For invite-only projects, disable **Allow new users to sign up**. Disable **Allow anonymous sign-ins** and unused providers unless the application deliberately needs them. Inspect the hosted project's settings rather than assuming a universal hosted default. See [Auth configuration](https://supabase.com/docs/guides/auth/general-configuration). For CLI-managed local configuration in `supabase/config.toml`: ```toml [auth] enable_signup = false enable_anonymous_sign_ins = false ``` At the time of writing, the CLI defaults are `true` and `false`, respectively. Editing this local file is not evidence that hosted settings changed. See [the CLI configuration reference](https://supabase.com/docs/guides/local-development/cli/config). Supabase Auth supports MFA, with TOTP on every plan at the time of writing. Enrolment alone changes nothing about table authorization: enforce it in your policies by requiring the `aal2` assurance level, so a session that has not completed the second factor cannot read protected rows. See [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [TOTP availability](https://supabase.com/docs/guides/auth/auth-mfa/totp), and [MFA enforcement](https://supabase.com/docs/guides/auth/auth-mfa). ```sql create policy "mfa required" on public.profiles as restrictive to authenticated using ((select auth.jwt()->>'aal') = 'aal2') with check ((select auth.jwt()->>'aal') = 'aal2'); ``` Keep the applicable permissive ownership policies alongside this restrictive policy. It narrows their permissions; it does not grant access itself. Repeat the assurance requirement on other protected surfaces where needed. See [restrictive policies](https://www.postgresql.org/docs/current/sql-createpolicy.html). ### 7. Authorize Edge Functions before privileged work [Edge Functions](https://supabase.com/docs/guides/functions) expose server-side handlers. At the time of writing, platform JWT verification is configured per function and enabled by default (`verify_jwt = true`). Deploying with `supabase functions deploy --no-verify-jwt` disables that check, as commonly needed for external webhooks. The handler MUST then authenticate and authorize callers itself before protected work, for example by verifying the provider's webhook signature. See the [deployment CLI reference](https://supabase.com/docs/reference/cli/supabase-functions-deploy) and [function authentication](https://supabase.com/docs/guides/functions/auth). Even with JWT verification enabled, authorize the verified caller for the requested resource and operation. Passing the platform check alone does not establish user authorization: Supabase also documents API-key compatibility paths through that check. See [authorization headers and JWT verification](https://supabase.com/docs/guides/functions/auth-headers). The function environment carries privileged secrets, including the legacy `SUPABASE_SERVICE_ROLE_KEY`, which bypasses RLS. Treat an unauthenticated function with access to those credentials as a privileged surface. Keep secrets in managed environment variables, exclude local secret files from source control, and never expose secret values in responses, logs, or client bundles. Secret hygiene and in-function authorization are both required. See [Edge Function secrets](https://supabase.com/docs/guides/functions/secrets). ## Verify REASONED: disposable-fixture comparisons require controlled projects and accounts; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. The bash -n, ShellCheck and stub runs recorded here covered the Verify shell blocks in an earlier form, whose guard refusals left the subshell with status 0 and whose bundle scan result arms ended with status 0, and the stub runs were not repeated. On the current form, whose guard refusals leave the subshell with status 2, whose bundle scan candidate and scan-failure arms exit 1 and 3 and whose failed Supabase GET request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. For identity-based requests, exposed access admits unauthorized fixture data; fixed access denies it while the owner control succeeds, per the guide's cited Firebase security rules and Supabase row level security. Use disposable projects and harmless fixtures for exposed/fixed comparisons. Do not temporarily open production data. No disposable cloud project, controlled user accounts, deployed client bundle, or authorized writable service-fixture environment was supplied during authoring. No disposable Firebase or Supabase project or controlled A/B sessions were available for these comparisons; a live Firebase Emulator Suite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The live authorization comparisons below are REASONED, not demonstrated, from the cited Firebase and Supabase documentation; local shell and scan observations are scoped at their steps. ### 1. Compare unauthenticated, owner, and unrelated-user access **REASONED:** No Firebase/Supabase test project or controlled A/B sessions was supplied. Seed a known row owned by user A and one owned by user B first, so an empty result is not mistaken for enforcement. Seed equivalent Firebase documents, RTDB nodes, and Storage objects. REASONED: identity and ownership comparisons need disposable projects and A/B sessions, which were unavailable; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. The bash -n, ShellCheck and stub runs recorded here covered the Supabase GET block in an earlier form, whose guard refusals left the subshell with status 0; on the current form, whose guard refusals leave the subshell with status 2 and whose failed request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the stub runs were not repeated. Open rules or disabled RLS can admit unsigned and unrelated users; fixed rules and policies reject them and retain the intended owner control, per the guide's cited Firebase security rules, Supabase row level security and Supabase multi-factor authentication (aal1, aal2, enforcement policy). With only the public key/config and no signed-in user, reads and writes against protected tables/paths must be refused or return no rows and change nothing. Signed in as user A, reading and writing user B's rows must be refused. An authorized owner read on the same path MUST succeed, so denial is attributable to access controls rather than a broken request or wrong endpoint. Require owner-write success only for operations the app intentionally permits after installing their grants and policies. The baseline Supabase policy above permits SELECT only. With the MFA restriction installed, the positive authenticated test also needs `aal2`. REASONED: the request matrix needs disposable projects, objects and A/B sessions, which were unavailable; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. The bash -n, ShellCheck and stub runs recorded here covered the Supabase GET block in an earlier form, whose guard refusals left the subshell with status 0; on the current form, whose guard refusals leave the subshell with status 2 and whose failed request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the stub runs were not repeated. For identity-based requests, exposed rules admit unauthorized requests; ownership rules deny them and permit the owner, per the guide's cited Firebase security rules and Supabase row level security. Rules tightening leaves access through an existing download token intact; token revocation denies that access while the authorized SDK direct-download control still succeeds, per Firebase Storage Admin shareable download URLs, Firebase StorageReference download-token revocation and Firebase Storage SDK direct downloads and browser CORS. Compare the following requests against a disposable exposed fixture and its fixed counterpart: | Surface | Concrete request and expected distinction | | --- | --- | | Firestore | **REASONED:** In the Rules Playground or client test harness, get and write `/users/REPLACE_WITH_USER_B_ID` and `/users/REPLACE_WITH_USER_B_ID/items/probe` without authentication, as A, and as B. Open rules admit the first two identities; the ownership rules deny them and admit B for these operations. Test the user document itself to catch a version-1 recursive-match mistake. See [Firestore rules](https://firebase.google.com/docs/firestore/security/rules-structure). | | RTDB | **REASONED:** Read and write `/users/REPLACE_WITH_USER_B_ID/probe` with the same identities. An ancestor grant admits unauthorized requests; the fixed rules deny them and admit B. See [RTDB cascading](https://firebase.google.com/docs/database/security/core-syntax). | | Firebase Storage | **REASONED:** Read and upload `users/REPLACE_WITH_USER_B_ID/probe.txt` through the Storage client. Open or authentication-only rules admit unauthorized access; ownership rules admit B and reject A and unsigned requests. See [Storage rules](https://firebase.google.com/docs/storage/security/core-syntax). | | Firebase Storage download tokens | **REASONED:** No disposable object, existing download URL, token-revocation access, or owner session was supplied. Save an existing download URL for `users/REPLACE_WITH_USER_B_ID/probe.txt`. With caching disabled and no session, cookies, or Authorization header, send HTTP GET to that exact URL before and after tightening ownership Rules: both should return the object's bytes. Revoke its download token in the Firebase console, keep the object and Rules unchanged, and repeat GET to the same saved URL: require an HTTP denial with no object bytes. A network or CORS failure is inconclusive. Before and after revocation, B's authenticated `getBytes(ref(storage, 'users/REPLACE_WITH_USER_B_ID/probe.txt'))` must still return the bytes; configure browser CORS for the test origin. See [shareable URLs](https://firebase.google.com/docs/storage/admin/start), [token revocation](https://firebase.google.com/docs/reference/kotlin/com/google/firebase/storage/StorageReference), and [direct downloads](https://firebase.google.com/docs/storage/web/download-files). | | Supabase tables | **REASONED:** Request `GET /rest/v1/profiles?select=user_id&user_id=eq.REPLACE_WITH_USER_B_ID`. With matching grants and RLS disabled, B's seeded row is exposed. With the fixed grants/policies, unsigned requests and A receive no B row; B at the required assurance level receives it. For intentionally enabled writes, use POST to create a disposable profile, PATCH to change it, and DELETE to remove it; inspect persisted state as well as response status. See [select requests](https://supabase.com/docs/reference/javascript/select) and [row-security semantics](https://www.postgresql.org/docs/current/ddl-rowsecurity.html). | REASONED: header-file preparation was exercised with a nonsecret fixture: the curl stub printed stub curl: headers=apikey: harmless-fixture. The stub run recorded here predates the current form of the Supabase GET block; the current form, whose guard refusals leave the subshell with status 2, has not been run. No disposable Supabase project or controlled user tokens were available; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Public keys alone must not authorize protected rows, while the intended owner session must succeed, per the guide's cited Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings) and Supabase row level security. For the Supabase GET comparison, prepare protected header files privately, outside source control. The unsigned file contains an `apikey` header with the publishable or legacy `anon` key; the signed-in file also contains `Authorization: Bearer` with the test user's access token. Do not use a secret or service-role key for an ordinary-user test. REASONED: live response interpretation requires an actual disposable HTTPS endpoint and controlled sessions; quoting and stdin delivery were exercised only with a stub. No disposable HTTPS endpoint or controlled sessions were available; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. A harmless header fixture reached stub stdin and printed stub curl: headers=apikey: harmless-fixture. The bash -n, ShellCheck and stub runs recorded here covered the Supabase GET block in an earlier form, whose guard refusals left the subshell with status 0; on the current form, whose guard refusals leave the subshell with status 2 and whose failed request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the stub runs were not repeated. Exposed requests may return another user's rows; fixed requests must withhold them while the owner control succeeds, per the guide's cited Supabase JavaScript select requests, Supabase row level security, curl curl-8_14_1 write-out fields and version requirements (checked October 2026) and curl curl-8_14_1 headers from stdin (checked October 2026). Paste the whole block and substitute inside the single quotes. Use an unsigned HTTPS endpoint URL, never a credential-bearing or signed URL. The response must contain only your harmless fixture data. Credentials enter curl through stdin, not process arguments; that does not protect a carelessly created header file, shell history, or tracing, and it does not hide the credentials from your own account's view of the process. The write-out fields require curl 7.75.0 or newer. REASONED: following block; ordinary-user profiles and MFA comparisons; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. The bash -n, ShellCheck and stub runs recorded here covered the Supabase GET block in an earlier form, whose guard refusals left the subshell with status 0; on the current form, whose guard refusals leave the subshell with status 2 and whose failed request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the stub runs were not repeated. No disposable Supabase project or controlled user sessions were available. Exposed profiles may return another user's rows; fixed policies must deny them while the owner at the required assurance level succeeds, per the guide's cited Supabase JavaScript select requests, Supabase row level security, Supabase multi-factor authentication (aal1, aal2, enforcement policy) and PostgreSQL row-security bypasses and operations outside RLS. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_REQUEST_URL' 'REPLACE_WITH_PROTECTED_HEADERS_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the request URL; not probing"; exit 2 ;; https://*) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the protected headers file; not probing"; exit 2 ;; *) if [ -r "$2" ]; then if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ --header @- --write-out '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --url "$1" < "$2"; then echo "block exit 0; unclassified observation: read the body and http= line above, since only you know whether its rows belong to this request's user" else echo "curl exit $?: the request failed, which proves nothing about authorization; block exit 3, inconclusive"; exit 3 fi else echo "headers file is unreadable; no conclusion"; exit 2 fi ;; esac ;; *) echo "use the project's HTTPS endpoint; not probing"; exit 2 ;; esac ) ``` REASONED: preceding block; this Supabase GET request block and interpretation need a disposable Supabase project and owner sessions, which were unavailable; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. The Firebase ownership note in this paragraph also needs valid App Check tokens, which were unavailable. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. Non-HTTPS input printed use the project's HTTPS endpoint; not probing, and an absent header file printed headers file is unreadable; no conclusion. The bash -n, ShellCheck and stub runs recorded here covered the Supabase GET block in an earlier form, whose guard refusals left the subshell with status 0; on the current form, whose guard refusals leave the subshell with status 2 and whose failed request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run, and the stub runs were not repeated. Neither synthetic success nor transport failure demonstrates authorization: exposed reads return unauthorized fixture data; fixed reads deny it while owner reads succeed. That authorization reading is per the guide's cited Supabase JavaScript select requests, Supabase row level security, Per-product App Check enforcement, curl curl-8_14_1 write-out fields and version requirements (checked October 2026) and curl curl-8_14_1 headers from stdin (checked October 2026). A timeout, DNS failure, malformed request, or missing fixture proves nothing about authorization. Retain the positive owner control. Supply valid App Check tokens when testing Firebase ownership so an unrelated App Check rejection does not hide an open rule. ### 2. Test views, functions, and indirect access - **REASONED:** No deployed view inventory or A/B sessions was supplied. Reads through every API-exposed view must respect the intended table boundary: request `/rest/v1/REPLACE_WITH_VIEW_NAME` with only the public key, then, signed in as user A, request user B's rows using the view's actual ownership column. An exposed owner-privileged view can return those rows while the table denies them; an invoker view or revoked endpoint must not. If it returns them, check owner privileges and overly broad policies. Confirm intended owner reads still succeed, or confirm deliberate endpoint removal. See [view security](https://www.postgresql.org/docs/current/sql-createview.html). - **REASONED:** No deployed function inventory or disposable RPC fixtures was supplied. Every API-exposed function is checked the same way, because the view check cannot see it: call `POST /rest/v1/rpc/REPLACE_WITH_FUNCTION_NAME` with its documented JSON arguments, first with only the public key, then as A requesting B's data. A `SECURITY DEFINER` function **can return rows** while the table and view checks both look clean; this separate check catches that path. Repeat for every overload and use valid, harmless arguments so argument errors do not masquerade as authorization. Fixed functions must deny unauthorized data or operations while permitting their intended caller. See [RPC calls](https://supabase.com/docs/reference/javascript/rpc) and [function privileges](https://supabase.com/docs/guides/database/functions). - **REASONED:** No outbound-request collector or disposable Vault secret was supplied. For each HTTP wrapper, call its RPC with a controlled destination and harmless marker. An exposed wrapper may cause an outbound request; after revocation or caller checks, an unauthorized request must cause none. Commit an authorized `pg_net` fixture transaction before expecting its request. For each Vault-reading wrapper, use a disposable nonsecret canary: an exposed function may return it, while the fixed endpoint must withhold it from unauthorized callers. Check `has_function_privilege` for each signature and `has_table_privilege` on `vault.decrypted_secrets` for both API roles as supporting evidence. See [`pg_net`](https://supabase.com/docs/guides/database/extensions/pg_net), [Vault](https://supabase.com/docs/guides/database/vault), and [privilege checks](https://www.postgresql.org/docs/current/functions-info.html). ### 3. Test Storage and Realtime independently | Surface | Required exposed/fixed comparison | | --- | --- | | Supabase Storage | **REASONED:** No bucket, file fixture, or A/B sessions was supplied. Download B's known file with `supabase.storage.from('REPLACE_WITH_PRIVATE_BUCKET').download('REPLACE_WITH_USER_B_ID/probe.txt')` as A and B; also request its public asset URL without a session. A public bucket serves the public URL; the private, restricted bucket must withhold unauthorized bytes while B's authorized download succeeds. Test fresh uploads and, only if intended, upserts using `{ upsert: true }`; A must not write B's path. See [downloads](https://supabase.com/docs/guides/storage/serving/downloads), [uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads), and [bucket access](https://supabase.com/docs/guides/storage/buckets/fundamentals). | | Broadcast and Presence | **REASONED:** No Realtime project or connected A/B clients was supplied. Join `user:REPLACE_WITH_USER_B_ID` as A and B, attempt a Broadcast send and Presence tracking, and observe from B's client. Public channels admit unintended participation when allowed. After disabling public channels, a client without `private: true` must be rejected; the private policies must withhold B's channel operations from A while allowing B. A socket connection alone is not proof of authorized message delivery. See [settings](https://supabase.com/docs/guides/realtime/settings) and [authorization](https://supabase.com/docs/guides/realtime/authorization). | | Postgres Changes | **REASONED:** No publication or live subscription fixture was supplied. Subscribe as A to INSERT/UPDATE changes on `public.profiles`, then change seeded A and B rows through the controlled fixture. An exposed source table can disclose B's changes; fixed source-table authorization must withhold them while A's intended events arrive. Evaluate DELETE separately against its documented limitations. See [Postgres Changes](https://supabase.com/docs/guides/realtime/postgres-changes). | ### 4. Test Functions, App Check, and emulator exposure - **REASONED:** No deployed Supabase Edge Function, disposable operation fixture, or caller credentials was supplied. Send `POST /functions/v1/REPLACE_WITH_FUNCTION_NAME` to the disposable project's function with a valid fixture payload, first without `Authorization` or `apikey`, then with `Authorization: Bearer invalid-test-token` and no `apikey`. With JWT verification disabled and handler authorization absent, these requests can reach privileged work; the corrected handler must reject unauthorized requests before that work. With JWT verification enabled, both requests must receive HTTP 401 before the handler runs. Omit API keys in these two probes because API-key compatibility can pass the platform check. For a webhook, also compare missing/invalid provider signatures with a valid signed request. For a user endpoint, compare A attempting B's operation with the authorized owner's request. The authorized positive control must succeed; inspect persisted effects as well as response status, and check that responses and logs disclose no secrets. See [JWT verification](https://supabase.com/docs/guides/functions/auth-headers), [handler authentication](https://supabase.com/docs/guides/functions/auth), and [secret handling](https://supabase.com/docs/guides/functions/secrets). - **REASONED:** No deployed callable, HTTP endpoint, or test credentials was supplied. Send `POST` with `{"data":{}}` to each callable, first with valid App Check but no user ID token. A handler lacking its own authentication check can enter privileged work; the fixed handler must reject it. Repeat with A attempting B's operation and with the intended owner. For each `onRequest` endpoint, send its valid application request without credentials, with invalid credentials, and with the intended verified user or IAM principal. Only the authorized request may perform the operation. See [callable protocol](https://firebase.google.com/docs/functions/callable-reference) and [ID-token verification](https://firebase.google.com/docs/auth/admin/verify-id-tokens). - **REASONED:** No registered Firebase app, provider attestation, or deployed clients was supplied. Repeat the permitted owner read separately for Firestore, RTDB, and Storage, and the permitted callable invocation, with valid user authentication but no App Check token. Registration without enforcement allows otherwise permitted requests; enforcement must reject them. The compatible client with valid App Check must still succeed. Allow for documented enforcement propagation. Test each used Firebase product from the intended restricted app and an excluded app/referrer; intended traffic must work and applicable key restrictions must reject excluded traffic. See [App Check enforcement](https://firebase.google.com/docs/app-check/enable-enforcement) and [API-key restrictions](https://firebase.google.com/docs/projects/api-keys). - **REASONED:** No writable emulator fixture or second-host network fixture was supplied during authoring; a live Firebase Emulator Suite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local JSON parsing of the firebase.json fragment passed; live open-rule access should succeed and fixed ownership rules should deny unauthorized access while retaining owner access, per the guide's cited Firebase Emulator Suite configuration, open-rule behavior, and Hub API and Emulator localhost default. On the emulator host, inspect `ss -ltn` and request `GET /emulators` on the local Emulator Hub to enumerate actual listeners. In an isolated fixture with Rules configuration omitted, attempt an unsigned read/write of a seeded path; repeat after loading the ownership rules. Open emulators admit the operation; configured rules must deny the unauthorized operation and permit the owner. From another host, attempt connections to every enumerated port: listeners exposed on that interface can answer, while the fixed localhost-only deployment must not. Inspect proxy and tunnel routes as well. A failed connection without a working local positive control is inconclusive. See [emulator configuration and Hub API](https://firebase.google.com/docs/emulator-suite/install_and_configure) and [localhost binding](https://firebase.google.com/docs/emulator-suite/use_hosting). ### 5. Test account controls, MFA, and key retirement - **REASONED:** No controlled signup accounts or mailboxes was supplied. Use a signed-out Firebase Auth context for EACH Anonymous-provider comparison: explicitly `await signOut(auth)` and confirm `auth.currentUser === null` before each `await signInAnonymously(auth)` call, including the retry after disabling Anonymous. With Anonymous enabled, require a successful result and `getAdditionalUserInfo(result)?.isNewUser === true` to confirm account creation. With Anonymous disabled, require rejection of new-account creation due to the disabled provider; a network failure is inconclusive. An already signed-in anonymous user can be returned without creating an account. This tests signup restrictions, not revocation of existing sessions. See the [Auth JS reference](https://firebase.google.com/docs/reference/js/auth), [new-user indicator](https://firebase.google.com/docs/reference/js/auth.additionaluserinfo), and [session revocation](https://firebase.google.com/docs/auth/admin/manage-sessions). Test each other disabled provider. For email enumeration protection, compare password-sign-in requests for a nonexistent address and a known account with a wrong password; protected sign-in responses must not reveal that distinction. Do not expect signup to stop returning `EMAIL_EXISTS`. See [anonymous authentication](https://firebase.google.com/docs/auth/web/anonymous-auth) and [email enumeration behavior](https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection). - **REASONED:** No Supabase signup fixture or invited account was supplied. Attempt `supabase.auth.signUp()` with a fresh controlled address and `supabase.auth.signInAnonymously()`. Enabled methods can create users; the invite-only and anonymous-disabled configuration must refuse those creations while an existing invited account can still sign in. See [Auth settings](https://supabase.com/docs/guides/auth/general-configuration), [password signup](https://supabase.com/docs/guides/auth/passwords), and [anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous). - **REASONED:** No MFA-enrolled account was supplied. Repeat the owner's `GET /rest/v1/profiles` request with fresh `aal1` and `aal2` sessions. Without the restrictive policy, ownership alone permits the read; with it, `aal1` must receive no protected rows and `aal2` must receive the owner's seeded row. See [MFA enforcement](https://supabase.com/docs/guides/auth/auth-mfa). - **REASONED:** No API-key inventory, signing-key controls, or disposable backend was supplied. Repeat a known successful fixture request using the replaced and replacement API keys. Merely creating replacements leaves legacy keys valid; explicit deactivation must invalidate legacy-key use while replacement credentials still work. Separately test JWTs signed before and after signing-key rotation: rotation alone retains both trust relationships; revocation must remove trust in the old signing key after the documented propagation and verifier-cache behavior. Keep keys and JWTs in protected files or SDK memory. See [API-key migration](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys) and [JWT signing-key lifecycle](https://supabase.com/docs/guides/auth/signing-keys). - **REASONED:** No hosted Data API settings or schema fixture was supplied. Repeat a previously successful `GET /rest/v1/profiles` request after removing its schema from Exposed schemas, or after disabling the unused Data API. It must no longer return the fixture through that endpoint. Test retained API schemas separately with authorized positive requests. See [Data API controls](https://supabase.com/docs/guides/api/securing-your-api). ### 6. Review the client bundle for privileged credentials **REASONED:** No deployed client bundle or credential inventory was supplied. Search the client bundle for possible leaked secrets: `grep -rlIE 'service_role|eyJ|sb_secret_' dist/` lists only files to inspect, so a full key never lands in a shared terminal or CI log. Use the guarded form below for the actual bundle path. REASONED: candidate interpretation needs the deployed bundle and credential inventory, which were unavailable. Local synthetic JWT payload decoding printed role=anon and role=service_role; actual grep reported a harmless marker's filename and then exited 1 without output after removal. A public anon key may remain; a confirmed privileged credential must be removed and revoked, per the guide's cited Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings). Open each flagged file privately and interpret the match rather than expecting none. A literal `service_role` reference, an `eyJ` substring, or an `sb_secret_` prefix is a **review candidate**, not proof of a live credential. The public legacy `anon` key is itself a JWT that legitimately ships in the frontend; new `sb_publishable_...` keys are also intended for clients. For a complete JWT candidate, decode its payload locally and inspect `role`: `anon` is the expected legacy public-key role, while a confirmed project `service_role` credential is a leak. A prefix match alone does not establish that a string is a JWT or a valid key. REASONED: credential removal and retirement require an actual bundle, confirmed credential and key-lifecycle controls, which were unavailable. Local grep reported a harmless marker's filename and then exited 1 without output after removal; this does not demonstrate revocation. Exposed privileged credentials authorize privileged access; revoked credentials must fail while replacement credentials work, per the guide's cited Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings) and Supabase API-key migration and legacy-key deactivation. A confirmed `sb_secret_...` key, legacy service-role credential, or private key must not be there: remove the exposure and rotate or revoke the confirmed credential, not just delete it from the tree. Do not rotate credentials merely because a source-code word matched. See [API-key handling](https://supabase.com/docs/guides/getting-started/api-keys) and [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). REASONED: following block; credential-candidate review. No deployed client bundle or credential inventory was available. Local bash -n and ShellCheck 0.11.0 exited 0 without output; stub grep statuses 0, 1 and 2 exercised candidate, clean and failure messages. The bash -n, ShellCheck and stub runs recorded here covered the bundle scan block in an earlier form, whose guard refusals left the subshell with status 0 and whose result arms ended with status 0; none was re-run on the current form, whose guard refusals leave the subshell with status 2, whose candidate arm exits 1 and whose scan-failure arm exits 3. Actual grep reported a harmless marker filename and then exited 1 without output after removal; exposed markers should be found and removed markers absent, per the guide's cited Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings). ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CLIENT_BUNDLE_DIRECTORY' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not scanning"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not scanning"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the client bundle directory; not scanning"; exit 2 ;; *) if grep -rlIE 'service_role|eyJ|sb_secret_' -- "$1"; then echo "Review candidates in the files listed above; a match is not proof of a leak."; exit 1 else case "$?" in 1) echo "No candidate strings found; encoded or split secrets can evade this scan." ;; *) echo "Scan failed; no conclusion."; exit 3 ;; esac fi ;; esac ) ``` REASONED: preceding block; the guarded scan and review instructions need the actual bundle, build inputs, source maps and credential inventory, which were unavailable. Local bash -n and ShellCheck 0.11.0 exited 0 without output; guard fixtures printed not scanning refusals, and stub grep statuses 0, 1 and 2 printed the candidate-review, no-candidate and Scan failed; no conclusion messages. The bash -n, ShellCheck and stub runs recorded here covered the bundle scan block in an earlier form, whose guard refusals left the subshell with status 0 and whose result arms ended with status 0; none was re-run on the current form, whose guard refusals leave the subshell with status 2, whose candidate arm exits 1 and whose scan-failure arm exits 3. Actual grep reported the harmless marker file and then exited 1 without output after removal. A marker should be found before removal and absent afterwards; a clean scan cannot establish absence of secrets, per the guide's cited Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings). For the demonstration, place a harmless marker containing a searched prefix in a disposable bundle and confirm that its filename is reported; remove it and repeat. A clean result does not prove the absence of encoded, split, binary, or differently shaped credentials. Inspect build inputs, source maps, and private-key material separately. ### Local validation and REASONED service checks Both bash blocks passed ShellCheck 0.11.0 and `bash -n` during authoring. Local guard tests refused embedded `REPLACE_WITH_` placeholders, `example.com`, angle brackets, empty values, and omitted or shortened `set --` lines. Both URL and header-file placeholder positions were checked. A ShellCheck canary produced the expected SC2086 finding. The RTDB JSON, `firebase.json` fragment, Auth TOML, and callable JavaScript passed local parsing checks. These checks establish syntax and the stated shell behavior only; they do not compile Firebase Rules, execute SQL policies, or demonstrate service authorization. The replacement was not installed in the repository, and no whole-corpus gate result is claimed. REASONED: tool-version provenance for the historical local runs above; the [ShellCheck v0.11.0 changelog](https://github.com/koalaman/shellcheck/blob/v0.11.0/CHANGELOG.md#L1) identifies the release named as ShellCheck 0.11.0 in this guide's run record, but does not establish which binary the authoring host ran. The bash -n, ShellCheck and guard-test runs recorded above covered both bash blocks in an earlier form, whose guard refusals left the subshell with status 0 and whose bundle scan result arms ended with status 0, and the guard tests were not repeated. On the current form, whose guard refusals leave the subshell with status 2, whose bundle scan candidate and scan-failure arms exit 1 and 3 and whose failed Supabase GET request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. Bash and other tool/parser versions were not recorded and remain unrecorded. This guide's assessment of the recorded checks is limited to syntax and the stated shell behaviour, not Firebase Rules compilation, SQL execution or service authorization. | Scope | Exposed/fixed checks and prerequisites | Status | | --- | --- | --- | | Firebase and Supabase | Check every REASONED comparison above using disposable Firebase and Supabase projects, controlled A/B accounts and mailboxes, MFA sessions, registered App Check clients, a writable emulator/service fixture, a second host, Storage objects, Realtime clients, view/RPC inventories, an outbound-request collector, a Vault canary, key-lifecycle controls, and the actual client build. Record versions, effective rules/grants/settings, requests, responses, persisted write results, positive controls, propagation intervals, and cleanup. Include (1 of 3): all three Firebase Rules engines, Firebase Functions, Supabase Edge Functions (using deployed operation fixtures and caller/webhook credentials to compare JWT verification enabled/disabled, handler authorization, positive controls, persisted effects, and secret hygiene), emulators, API restrictions. The items to include (2 of 3): signup (including signed-out Anonymous-provider comparisons that confirm new-account creation when enabled and rejection when disabled), Data API removal, views, every function overload, HTTP wrappers, Vault. The items to include (3 of 3): Storage (including sessionless GET of the same saved download URL before and after Rules tightening and token revocation, with an authorized SDK direct-download positive control), both Realtime authorization paths, MFA, API-key retirement, JWT-signing-key revocation, and bundle-scan controls. | REASONED: this comparison record requires every project, account, service, second-host, collector and lifecycle capability enumerated in this row; those live fixtures were unavailable. No disposable Firebase or Supabase project or controlled A/B sessions were available for these comparisons; a live Firebase Emulator Suite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; a local Supabase CLI stack opens listeners; the authoring host forbids that without an isolated network namespace, and none was available. Local bash -n and ShellCheck 0.11.0 runs exited 0 without output; stub runs refused placeholders and malformed set lines, and the valid request printed synthetic http=200 exit=0 err=. The bash -n, ShellCheck and stub runs recorded here covered the Verify shell blocks in an earlier form, whose guard refusals left the subshell with status 0 and whose bundle scan result arms ended with status 0, and the stub runs were not repeated. On the current form, whose guard refusals leave the subshell with status 2, whose bundle scan candidate and scan-failure arms exit 1 and 3 and whose failed Supabase GET request exits 3, only the repository gate suite's bash -n and ShellCheck 0.11.0 checks run. Local grep reported a harmless marker file and exited 1 without output after its removal; synthetic JWT decoding printed role=anon and role=service_role. The identity-based authorization comparisons require unauthorized access in the exposed state and rejection in the fixed state with intended positive controls; key retirement additionally requires old credentials to fail while replacements work. The requirements for the identity-based authorization comparisons and for key retirement are per the guide's cited Firebase security rules, Supabase row level security, Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings) and Supabase JWT signing-key rotation and revocation. Rules tightening leaves access through an existing download token intact; token revocation denies that access while the authorized SDK direct-download control still succeeds, per Firebase Storage Admin shareable download URLs, Firebase StorageReference download-token revocation and Firebase Storage SDK direct downloads and browser CORS. Each other surface named in this row rests on the Sources entry cited at its step above. Live behavior is not demonstrated. | ## Sources (checked September 2026) - Firebase security rules: https://firebase.google.com/docs/rules - Supabase API keys (legacy `service_role` is a JWT starting `eyJ`; new `sb_publishable_` and `sb_secret_` keys are short strings): https://supabase.com/docs/guides/getting-started/api-keys - Supabase row level security: https://supabase.com/docs/guides/database/postgres/row-level-security - Supabase multi-factor authentication (aal1, aal2, enforcement policy): https://supabase.com/docs/guides/auth/auth-mfa - PostgreSQL `CREATE VIEW` (base relations checked against the view owner's permissions by default; the view owner's RLS policies applied by default; `security_invoker`): https://www.postgresql.org/docs/current/sql-createview.html - PostgreSQL `ALTER VIEW` (`SET ( security_invoker = ... )` on an existing view): https://www.postgresql.org/docs/current/sql-alterview.html - PostgreSQL `REVOKE`: https://www.postgresql.org/docs/current/sql-revoke.html - Supabase RLS and views (`security_invoker = true` on PostgreSQL 15 and above; revoke or unexpose on older versions; views are typically created by the `postgres` user): https://supabase.com/docs/guides/database/postgres/row-level-security - PostgreSQL 15 release notes (view accesses "were always treated as being done by the view's owner. That's still the default."): https://www.postgresql.org/docs/release/15.0/ - [Firebase basic rules, ownership examples, and conflicting Storage default prose/example](https://firebase.google.com/docs/rules/basics). - [Firestore starting modes](https://firebase.google.com/docs/firestore/quickstart). - [RTDB starting modes and test-rule expiry](https://firebase.google.com/docs/database/web/start). - [Firebase release notes: month-long test-rule access](https://firebase.google.com/support/releases). - [Firestore rule structure, recursive wildcards, overlapping grants, and server access](https://firebase.google.com/docs/firestore/security/rules-structure). - [RTDB rule syntax and cascading](https://firebase.google.com/docs/database/security/core-syntax). - [Storage rule syntax](https://firebase.google.com/docs/storage/security/core-syntax). - [Firestore rule testing and deployment](https://firebase.google.com/docs/firestore/security/get-started). - [Storage rule management](https://firebase.google.com/docs/storage/security/get-started). - [Second-generation callable handlers](https://firebase.google.com/docs/functions/callable). - [Callable protocol and optional authentication](https://firebase.google.com/docs/functions/callable-reference). - [First-generation callable handlers](https://firebase.google.com/docs/functions/1st-gen/callable-1st). - [Firebase ID-token verification](https://firebase.google.com/docs/auth/admin/verify-id-tokens). - [Firebase HTTP handlers](https://firebase.google.com/docs/functions/http-events). - [Second-generation HTTPS invoker options](https://firebase.google.com/docs/reference/functions/2nd-gen/node/firebase-functions.https.httpsoptions). - [Callable App Check enforcement and SDK requirement](https://firebase.google.com/docs/app-check/cloud-functions). - [App Check provider registration and client rollout](https://firebase.google.com/docs/app-check/web/recaptcha-enterprise-provider). - [Per-product App Check enforcement](https://firebase.google.com/docs/app-check/enable-enforcement). - [Firebase API-key restrictions and required API table](https://firebase.google.com/docs/projects/api-keys). - [Google Cloud API-key application restrictions](https://docs.cloud.google.com/api-keys/docs/add-restrictions-api-keys). - [Public Firebase configuration identifiers](https://firebase.google.com/docs/web/learn-more). - [Firebase anonymous authentication](https://firebase.google.com/docs/auth/web/anonymous-auth). - [Email enumeration protection, defaults, and remaining signup errors](https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection). - [Firebase Emulator Suite configuration, open-rule behavior, and Hub API](https://firebase.google.com/docs/emulator-suite/install_and_configure). - [Emulator localhost default](https://firebase.google.com/docs/emulator-suite/use_hosting). - [Supabase Data API security, grants transition, explicit RLS, and API disablement](https://supabase.com/docs/guides/api/securing-your-api). - [Supabase exposed and custom schemas](https://supabase.com/docs/guides/api/using-custom-schemas). - [PostgreSQL row-security bypasses and operations outside RLS](https://www.postgresql.org/docs/current/ddl-rowsecurity.html). - [PostgreSQL policy syntax and permissive/restrictive policy combination](https://www.postgresql.org/docs/current/sql-createpolicy.html). - [Supabase API-key migration and legacy-key deactivation](https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys). - [Supabase JWT signing-key rotation and revocation](https://supabase.com/docs/guides/auth/signing-keys). - [Supabase function security and privileges](https://supabase.com/docs/guides/database/functions). - [PostgreSQL function security and default PUBLIC execution](https://www.postgresql.org/docs/current/sql-createfunction.html). - [PostgreSQL function settings](https://www.postgresql.org/docs/current/sql-alterfunction.html). - [PostgreSQL default privileges and global versus schema-specific grants](https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html). - [PostgreSQL privilege inquiry functions](https://www.postgresql.org/docs/current/functions-info.html). - [Supabase HTTP extension](https://supabase.com/docs/guides/database/extensions/http). - [Supabase pg_net signatures and transaction behavior](https://supabase.com/docs/guides/database/extensions/pg_net). - [Supabase Vault and its decrypted view](https://supabase.com/docs/guides/database/vault). - [Supabase Storage bucket access models](https://supabase.com/docs/guides/storage/buckets/fundamentals). - [Supabase Storage policies and upsert requirements](https://supabase.com/docs/guides/storage/security/access-control). - [Supabase Storage path helpers](https://supabase.com/docs/guides/storage/schema/helper-functions). - [Supabase Storage downloads](https://supabase.com/docs/guides/storage/serving/downloads). - [Supabase Storage uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads). - [Supabase Realtime settings and public-channel default](https://supabase.com/docs/guides/realtime/settings). - [Supabase Broadcast and Presence authorization](https://supabase.com/docs/guides/realtime/authorization). - [Supabase Postgres Changes security and limitations](https://supabase.com/docs/guides/realtime/postgres-changes). - [Supabase Auth signup and anonymous-access settings](https://supabase.com/docs/guides/auth/general-configuration). - [Supabase CLI Auth configuration and defaults](https://supabase.com/docs/guides/local-development/cli/config). - [Supabase password signup](https://supabase.com/docs/guides/auth/passwords). - [Supabase anonymous sign-ins](https://supabase.com/docs/guides/auth/auth-anonymous). - [Supabase TOTP availability and assurance-level transitions](https://supabase.com/docs/guides/auth/auth-mfa/totp). - [Supabase JavaScript select requests](https://supabase.com/docs/reference/javascript/select). - [Supabase JavaScript RPC requests](https://supabase.com/docs/reference/javascript/rpc). - [Supabase Edge Functions overview](https://supabase.com/docs/guides/functions). - [Supabase Edge Function deployment CLI and JWT-verification flag](https://supabase.com/docs/reference/cli/supabase-functions-deploy). - [Supabase Edge Function authentication and webhook verification](https://supabase.com/docs/guides/functions/auth). - [Supabase Edge Function authorization headers, per-function JWT verification, and API-key compatibility](https://supabase.com/docs/guides/functions/auth-headers). - [Supabase Edge Function secrets and privileged environment credentials](https://supabase.com/docs/guides/functions/secrets). - [Supabase database Network Restrictions](https://supabase.com/docs/guides/platform/network-restrictions). - [Supabase Postgres SSL enforcement](https://supabase.com/docs/guides/platform/ssl-enforcement). - [Supabase database roles and password handling](https://supabase.com/docs/guides/database/postgres/roles). - [Firebase Authentication JavaScript reference (anonymous sign-in and existing-user reuse)](https://firebase.google.com/docs/reference/js/auth). - [Firebase AdditionalUserInfo isNewUser indicator](https://firebase.google.com/docs/reference/js/auth.additionaluserinfo). - [Firebase Admin session revocation](https://firebase.google.com/docs/auth/admin/manage-sessions). - [Firebase Storage Admin shareable download URLs](https://firebase.google.com/docs/storage/admin/start). - [Firebase StorageReference download-token revocation](https://firebase.google.com/docs/reference/kotlin/com/google/firebase/storage/StorageReference). - [Firebase Storage SDK direct downloads and browser CORS](https://firebase.google.com/docs/storage/web/download-files). - [curl curl-8_14_1 write-out fields and version requirements (checked October 2026)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79). - [curl curl-8_14_1 headers from stdin (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md). - [ShellCheck v0.11.0 changelog release entry (checked October 2026)](https://github.com/koalaman/shellcheck/blob/v0.11.0/CHANGELOG.md#L1). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - firebase: Firebase documentation (unknown) - supabase: Supabase documentation (unknown) - postgres: PostgreSQL documentation (unknown) - postgres-view: PostgreSQL invoker-view minimum (15) - curl: curl (curl-8_14_1) - shellcheck: ShellCheck (v0.11.0) Claims (full source mapping in the JSON block below): - public-config (REASONED; firebase, supabase): Firebase API keys/config and Supabase publishable/anon keys are public identifiers, not authorization; hosted endpoints handle TLS. - firebase-ownership (REASONED; firebase): Require authentication and ownership separately in Firestore, RTDB and Storage; authentication-only rules do not isolate users. - firebase-starting (REASONED; firebase): Firestore production and RTDB locked modes deny clients; test templates expose data for about a month. Inspect expiry and replace open rules. - firestore-rules (REASONED; firebase): Rules version 2 recursive wildcards include the user document and descendants; version 1 excludes the parent. Unmatched paths deny and overlapping grants can reopen access. - rtdb-rules (REASONED; firebase): Owner rules under users.$uid need ancestor grants removed; child rules cannot revoke parent grants or filter parent reads. - firebase-storage (REASONED; firebase): Storage has separate rules; conflicting default prose/example warrants explicit deny-all followed by deliberate owner-path access. - firebase-download (REASONED; firebase): Storage download URLs are non-expiring bearer credentials unaffected by tighter Rules; revoke tokens and use authorized getBlob/getBytes direct downloads. - firebase-rule-deploy (REASONED; firebase): Inspect and test deployed rules for every database/bucket/instance; keep local and deployed definitions synchronized. - firebase-server (REASONED; firebase): Admin SDK/server credentials bypass client Rules; keep credentials server-side and apply IAM and caller authorization. - firebase-callable (REASONED; firebase): Callable ID-token validation is optional authentication; reject missing request.auth, use its UID and validate resource access. First-generation handlers use context.auth. - firebase-callable-appcheck (REASONED; firebase): onCall enforceAppCheck needs firebase-functions 4.0.0 or newer; App Check does not replace caller/resource authorization. - firebase-http (REASONED; firebase): onRequest does not inherit callable auth; verify end-user tokens with verifyIdToken or restrict IAM invokers, then authorize the intended principal. - firebase-appcheck (REASONED; firebase): Register compatible clients and inspect metrics, then enforce App Check per product and per callable; registration alone enforces nothing. - firebase-key-restrictions (REASONED; firebase): Apply one app-restriction type per key plus API restrictions; separate platforms and unrelated billable APIs, using the full Firebase product table. - firebase-anonymous (REASONED; firebase): Anonymous sign-in is opt-in; disable unused providers because anonymous accounts still satisfy authentication-only Rules. - firebase-enumeration (REASONED; firebase): Email enumeration protection defaults on only for projects created from September 15, 2023; signup can still return EMAIL_EXISTS. - firebase-emulators (REASONED; firebase): Emulators default to localhost but missing Firestore/RTDB/Storage Rules configuration means open data; keep listeners, UI, hub, proxies and tunnels private. - supabase-network (REASONED; supabase): Restrict hosted database/pooler client IPs with Network Restrictions, enforce SSL and protect database-role passwords. - supabase-grants (REASONED; supabase): Data API access needs grants plus RLS; automatic-grant defaults are transitioning. Table Editor enables RLS, while SQL creation needs explicit enablement. - supabase-select (REASONED; supabase, postgres): The profiles baseline enables RLS, revokes broad grants and permits authenticated owners SELECT; audit inherited/column grants and existing policies. - supabase-bypass (REASONED; postgres): With RLS enabled and no policy, ordinary subject roles get no rows; owners normally bypass, as do superusers/BYPASSRLS roles. TRUNCATE is outside RLS. - supabase-writes (REASONED; supabase, postgres): Grant and policy INSERT/UPDATE/DELETE only when intended; UPDATE checks old/new ownership and needs applicable SELECT when reading columns. - supabase-schemas (REASONED; supabase): public is exposed by default; restrict exposed schemas and deliberately grant schema/object access. Schema selection does not replace RLS. - supabase-api-disable (REASONED; supabase): Disable an unused Data API to close generated REST endpoints; this does not disable all other services. - supabase-keys (REASONED; supabase): Publishable keys select anon or authenticated by session; secret and legacy service_role keys bypass RLS and belong only on trusted servers. - supabase-key-retire (REASONED; supabase): Replacement keys do not disable legacy keys; migrate consumers then explicitly deactivate legacy keys, or delete compromised secret keys. - supabase-jwt-retire (REASONED; supabase): Rotation changes the signing key but retains previous trust until revocation; account for verifier caches and propagation. - supabase-view-owner (REASONED; postgres, supabase): Views use owner privileges/policies by default; disclosure depends on reachable schemas, grants, query and owner privileges, not anon-key possession alone. - supabase-view-invoker (REASONED; postgres-view, postgres): PostgreSQL 15+ security_invoker views apply caller policies and require underlying privileges. - supabase-view-legacy (REASONED; postgres): PostgreSQL 14 and earlier require revoking all view privileges from PUBLIC/anon/authenticated or unexposing the schema; updatable views can bypass RLS for writes. - supabase-functions (REASONED; supabase, postgres): Prefer INVOKER; restrict DEFINER execution, use empty search_path with qualified references and enforce caller authorization including required MFA. - supabase-function-defaults (REASONED; postgres): New functions grant PUBLIC execution; create privileged functions and restrict grants transactionally. Global/default revocations are creator-specific and do not fix existing functions. - supabase-http (REASONED; supabase): Audit http/pg_net wrappers and revoke unneeded execute grants; constrain callers/destinations/data. Extension enablement alone does not prove exploitable SSRF. - supabase-vault (REASONED; supabase): Restrict vault.decrypted_secrets and privileged wrappers and keep Vault unexposed; at-rest encryption cannot prevent authorized plaintext queries. - supabase-buckets (REASONED; supabase): Private downloads need authorized access or time-limited signed URLs; public buckets bypass retrieval checks but not upload/delete/move/copy controls. - supabase-storage-policies (REASONED; supabase): Authorize storage.objects separately by bucket and owner path; upload needs INSERT, upsert also SELECT/UPDATE, and deletion needs deliberate permission. - supabase-realtime-default (REASONED; supabase): Public Realtime channels are allowed by default; disable them project-wide and also set client config.private. - supabase-realtime-policy (REASONED; supabase): Broadcast/Presence receive/send need separate SELECT/INSERT policies on realtime.messages, constrained by topic ownership and extension. - supabase-changes (REASONED; supabase): Postgres Changes uses source-table authorization; private-channel policies are not universal and DELETE has separate limitations. - supabase-signup (REASONED; supabase): Disable signup, anonymous sign-ins and unused providers when unwanted; inspect hosted settings instead of assuming universal defaults. - supabase-cli-auth (REASONED; supabase): CLI enable_signup defaults true and enable_anonymous_sign_ins false; local config edits do not prove hosted settings changed. - supabase-mfa (REASONED; supabase, postgres): TOTP is available on every plan at writing; enrollment alone does not constrain data. Require aal2 using a restrictive policy alongside permissive ownership policies. - supabase-edge-jwt (REASONED; supabase): Per-function verify_jwt defaults true; --no-verify-jwt disables platform checking and requires handler authentication before protected work. - supabase-edge-auth (REASONED; supabase): Platform JWT success is not resource authorization and API-key compatibility can pass it; separately authorize users or verify webhook signatures. - supabase-edge-secrets (REASONED; supabase): Edge environments contain privileged credentials including SUPABASE_SERVICE_ROLE_KEY; protect managed/local secrets, responses, logs and client bundles. - verify-firestore (REASONED; firebase): Compare unsigned/A/B reads and writes to user documents and descendants, with a successful owner control; test parent documents to distinguish recursive-rule versions. - verify-rtdb (REASONED; firebase): Compare unsigned/A/B reads/writes to seeded RTDB owner paths; ancestor grants expose them while ownership rules deny unauthorized callers. - verify-firebase-storage (REASONED; firebase): Compare unsigned/A/B object reads and uploads; ownership rules must deny unrelated callers while owner operations succeed. - verify-download-token (REASONED; firebase): Same sessionless download URL works after Rules tightening but must lose bytes after token revocation; owner getBytes remains successful and network/CORS failures are inconclusive. - verify-tables (REASONED; supabase, postgres, curl): Pair unsigned/A/B profiles GETs against seeded rows, using ordinary credentials and required MFA; test intended writes and persisted effects separately. - verify-views (REASONED; postgres): Compare view reads with table boundaries; owner-privileged exposure must close under invoker/revoked access while intended reads or deliberate removal are confirmed. - verify-rpc (REASONED; supabase): Test every API function overload with valid harmless arguments as unsigned/A/B; unauthorized data or operations must be denied while the intended caller succeeds. - verify-http (REASONED; supabase): Probe HTTP wrappers with a controlled collector and marker; unauthorized fixed calls must emit no request. Commit pg_net fixture transactions before expecting delivery. - verify-vault (REASONED; supabase, postgres): Use a disposable Vault canary to compare exposed/fixed wrappers; inspect function and table privileges as supporting evidence. - verify-storage (REASONED; supabase): Compare public URL and A/B private downloads plus intended uploads/upserts; unauthorized callers must receive no bytes or cross-owner writes. - verify-realtime (REASONED; supabase): Compare A/B join, Broadcast and Presence delivery; public-disabled clients lacking private=true must fail, and connection alone is insufficient. - verify-changes (REASONED; supabase): Observe A/B INSERT/UPDATE subscription events against seeded rows; fixed table authorization withholds unrelated events. Evaluate DELETE separately. - verify-edge (REASONED; supabase): Compare no/invalid Bearer without apikey, authorized owners and valid webhook signatures; distinguish platform 401 from handler authorization and inspect effects/secrets. - verify-firebase-functions (REASONED; firebase): Compare callable requests with valid App Check but missing user auth and A/B operations; HTTP endpoints need separate invalid/valid user or IAM comparisons. - verify-appcheck (REASONED; firebase): Repeat owner requests with/without App Check per product/callable; enforcement must deny missing attestation while compatible clients work. - verify-api-restrictions (REASONED; firebase): Test intended versus excluded apps/referrers after key restrictions, retaining successful intended product traffic. - verify-emulators (REASONED; firebase): Enumerate hub /emulators and sockets, compare missing versus loaded Rules and second-host connectivity, and retain local owner controls. - verify-firebase-accounts (REASONED; firebase): Sign out before each Anonymous signup test and check isNewUser; disabled providers must reject creation. Enumeration tests compare sign-in errors, not EMAIL_EXISTS signup behavior. - verify-supabase-accounts (REASONED; supabase): Fresh signup and anonymous creation must fail under invite-only/anonymous-disabled settings while an invited account still signs in. - verify-mfa (REASONED; supabase): Owner profiles GET with aal1 must yield no protected rows under the restrictive policy, while fresh aal2 returns the seeded owner row. - verify-key-retirement (REASONED; supabase): Compare old/new API keys after explicit deactivation and old/new JWTs after revocation, accounting for propagation and caches. - verify-api-removal (REASONED; supabase): Previously successful profiles requests must stop returning fixtures after schema removal or Data API disabling; retained schemas need authorized controls. - verify-bundle (REASONED; supabase): Filename-only service_role/eyJ/sb_secret_ scanning yields review candidates, not proof of leaks; public anon JWTs are legitimate. Use a marker control and inspect encoded/split secrets separately. - local-guards (DEMONSTRATED; supabase, shellcheck): Historical local guard tests rejected placeholders and malformed set lines; this guide's assessment is shell behavior only, not live authorization. The recorded ShellCheck 0.11.0 has a release citation, not host-binary evidence; bash and other tool/parser versions are unrecorded. Runs covered earlier forms, whose guard refusals and bundle scan result arms exited 0; current forms were not re-run. - local-syntax (DEMONSTRATED; firebase, supabase, shellcheck): Recorded ShellCheck 0.11.0/bash and JSON/TOML/JavaScript parsing passed; this guide's assessment is that these checks do not compile Firebase Rules, execute SQL or demonstrate services. Only the ShellCheck release is cited; other versions are unrecorded. Bash runs covered earlier forms, whose guard refusals and bundle scan result arms exited 0; current forms get only gate bash -n and ShellCheck. ````json { "schema": 1, "checked": "2026-10-19", "documentation_checked": "2026-09", "body_sha256": "123ba439da8cac4dcfc5cbd4168affb2597f99eb8b09e31193a4e9523bab3428", "components": { "firebase": { "name": "Firebase documentation", "basis": "unknown", "sources": { "s100c2cdbdd59": "https://firebase.google.com/docs/rules", "s53bfe8b21868": "https://firebase.google.com/docs/rules/basics", "s622d3bb0665b": "https://firebase.google.com/docs/firestore/quickstart", "sb2c933c841ef": "https://firebase.google.com/docs/database/web/start", "s75f018e0fac7": "https://firebase.google.com/support/releases", "s7bd11c22c7ce": "https://firebase.google.com/docs/firestore/security/rules-structure", "sa6b1c4933e6a": "https://firebase.google.com/docs/database/security/core-syntax", "s0a7f54463abd": "https://firebase.google.com/docs/storage/security/core-syntax", "sd6071548f6fe": "https://firebase.google.com/docs/firestore/security/get-started", "s14872143a9e8": "https://firebase.google.com/docs/storage/security/get-started", "sc8f214d059d2": "https://firebase.google.com/docs/functions/callable", "s79da8f65f90a": "https://firebase.google.com/docs/functions/callable-reference", "s9a86df9623a6": "https://firebase.google.com/docs/functions/1st-gen/callable-1st", "s49f78d054708": "https://firebase.google.com/docs/auth/admin/verify-id-tokens", "s1bb711c9afeb": "https://firebase.google.com/docs/functions/http-events", "sc9f86ea271f6": "https://firebase.google.com/docs/reference/functions/2nd-gen/node/firebase-functions.https.httpsoptions", "sb0ff8f1726f7": "https://firebase.google.com/docs/app-check/cloud-functions", "sa71e958c99df": "https://firebase.google.com/docs/app-check/web/recaptcha-enterprise-provider", "sba7129c83405": "https://firebase.google.com/docs/app-check/enable-enforcement", "safeed4cd2b67": "https://firebase.google.com/docs/projects/api-keys", "sc78d5eb59b65": "https://docs.cloud.google.com/api-keys/docs/add-restrictions-api-keys", "sb0d26775f3b4": "https://firebase.google.com/docs/web/learn-more", "s77e2cbd8a29a": "https://firebase.google.com/docs/auth/web/anonymous-auth", "s06808e4b6965": "https://docs.cloud.google.com/identity-platform/docs/admin/email-enumeration-protection", "sd8a1bda29f5c": "https://firebase.google.com/docs/emulator-suite/install_and_configure", "s69d9721c7f29": "https://firebase.google.com/docs/emulator-suite/use_hosting", "s8c90f9f6c7fa": "https://firebase.google.com/docs/reference/js/auth", "s8c8336ec252e": "https://firebase.google.com/docs/reference/js/auth.additionaluserinfo", "s142e56472093": "https://firebase.google.com/docs/auth/admin/manage-sessions", "s9b7e57b472c7": "https://firebase.google.com/docs/storage/admin/start", "sa0584b797732": "https://firebase.google.com/docs/reference/kotlin/com/google/firebase/storage/StorageReference", "sfd1e7412abf6": "https://firebase.google.com/docs/storage/web/download-files" } }, "supabase": { "name": "Supabase documentation", "basis": "unknown", "sources": { "s65d5b8043bad": "https://supabase.com/docs/guides/getting-started/api-keys", "s46cff2590323": "https://supabase.com/docs/guides/database/postgres/row-level-security", "s741468ff35a9": "https://supabase.com/docs/guides/auth/auth-mfa", "s6d5cf2d4dbf3": "https://supabase.com/docs/guides/api/securing-your-api", "s20ef046ac646": "https://supabase.com/docs/guides/api/using-custom-schemas", "sa946354db383": "https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys", "sfc2f9ae0232b": "https://supabase.com/docs/guides/auth/signing-keys", "s8405a9fb4b74": "https://supabase.com/docs/guides/database/functions", "s13da01bbbba5": "https://supabase.com/docs/guides/database/extensions/http", "s76740bffcb32": "https://supabase.com/docs/guides/database/extensions/pg_net", "s47bb8fa3e14e": "https://supabase.com/docs/guides/database/vault", "s1567fb838f20": "https://supabase.com/docs/guides/storage/buckets/fundamentals", "sdba7d7b6dcf8": "https://supabase.com/docs/guides/storage/security/access-control", "s9e1484f46eff": "https://supabase.com/docs/guides/storage/schema/helper-functions", "sc29febbb230e": "https://supabase.com/docs/guides/storage/serving/downloads", "see2d3be99ebd": "https://supabase.com/docs/guides/storage/uploads/standard-uploads", "s5674b1057e14": "https://supabase.com/docs/guides/realtime/settings", "s84efad0ba39d": "https://supabase.com/docs/guides/realtime/authorization", "s694eab64f2ec": "https://supabase.com/docs/guides/realtime/postgres-changes", "sc797f0b63472": "https://supabase.com/docs/guides/auth/general-configuration", "s3a5e39fe7b4d": "https://supabase.com/docs/guides/local-development/cli/config", "sa2dd4637969e": "https://supabase.com/docs/guides/auth/passwords", "sad79d4d1b6ee": "https://supabase.com/docs/guides/auth/auth-anonymous", "s182755725d74": "https://supabase.com/docs/guides/auth/auth-mfa/totp", "s92b405dc3c7e": "https://supabase.com/docs/reference/javascript/select", "s087c8cf42ab0": "https://supabase.com/docs/reference/javascript/rpc", "s7dfbb47b5d20": "https://supabase.com/docs/reference/cli/supabase-functions-deploy", "sf7d438b8c080": "https://supabase.com/docs/guides/functions/auth", "se52bbd097033": "https://supabase.com/docs/guides/functions/auth-headers", "sb3c4c0993e14": "https://supabase.com/docs/guides/functions/secrets", "sb787b5d63633": "https://supabase.com/docs/guides/platform/network-restrictions", "s83e15676e1ff": "https://supabase.com/docs/guides/platform/ssl-enforcement", "sf69cafb4bc6c": "https://supabase.com/docs/guides/database/postgres/roles" } }, "postgres": { "name": "PostgreSQL documentation", "basis": "unknown", "sources": { "s13c54ab9cc6b": "https://www.postgresql.org/docs/current/sql-createview.html", "saa99ef28f60f": "https://www.postgresql.org/docs/current/sql-alterview.html", "sb6224f4ac866": "https://www.postgresql.org/docs/current/sql-revoke.html", "se9846041ce1f": "https://www.postgresql.org/docs/current/ddl-rowsecurity.html", "s8e65a2f3f2bb": "https://www.postgresql.org/docs/current/sql-createpolicy.html", "sec39c317b06f": "https://www.postgresql.org/docs/current/sql-createfunction.html", "s0fe66d71aa10": "https://www.postgresql.org/docs/current/sql-alterfunction.html", "s04e81888353f": "https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html", "sc39eceeac711": "https://www.postgresql.org/docs/current/functions-info.html" } }, "postgres-view": { "name": "PostgreSQL invoker-view minimum", "basis": "15", "sources": { "s14443e063ef1": "https://www.postgresql.org/docs/release/15.0/" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79", "s3db522258e44": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md" } }, "shellcheck": { "name": "ShellCheck", "basis": "v0.11.0", "sources": { "sf994764bf270": "https://github.com/koalaman/shellcheck/blob/v0.11.0/CHANGELOG.md#L1" } } }, "claims": { "public-config": {"text": "Firebase API keys/config and Supabase publishable/anon keys are public identifiers, not authorization; hosted endpoints handle TLS.", "components": ["firebase", "supabase"], "sources": ["firebase:safeed4cd2b67", "firebase:sb0d26775f3b4", "supabase:s65d5b8043bad"], "status": "REASONED"}, "firebase-ownership": {"text": "Require authentication and ownership separately in Firestore, RTDB and Storage; authentication-only rules do not isolate users.", "components": ["firebase"], "sources": ["firebase:s100c2cdbdd59", "firebase:s53bfe8b21868"], "status": "REASONED"}, "firebase-starting": {"text": "Firestore production and RTDB locked modes deny clients; test templates expose data for about a month. Inspect expiry and replace open rules.", "components": ["firebase"], "sources": ["firebase:s622d3bb0665b", "firebase:sb2c933c841ef", "firebase:s75f018e0fac7"], "status": "REASONED"}, "firestore-rules": {"text": "Rules version 2 recursive wildcards include the user document and descendants; version 1 excludes the parent. Unmatched paths deny and overlapping grants can reopen access.", "components": ["firebase"], "sources": ["firebase:s7bd11c22c7ce"], "status": "REASONED"}, "rtdb-rules": {"text": "Owner rules under users.$uid need ancestor grants removed; child rules cannot revoke parent grants or filter parent reads.", "components": ["firebase"], "sources": ["firebase:sa6b1c4933e6a"], "status": "REASONED"}, "firebase-storage": {"text": "Storage has separate rules; conflicting default prose/example warrants explicit deny-all followed by deliberate owner-path access.", "components": ["firebase"], "sources": ["firebase:s53bfe8b21868", "firebase:s0a7f54463abd"], "status": "REASONED"}, "firebase-download": {"text": "Storage download URLs are non-expiring bearer credentials unaffected by tighter Rules; revoke tokens and use authorized getBlob/getBytes direct downloads.", "components": ["firebase"], "sources": ["firebase:s9b7e57b472c7", "firebase:sa0584b797732", "firebase:sfd1e7412abf6"], "status": "REASONED"}, "firebase-rule-deploy": {"text": "Inspect and test deployed rules for every database/bucket/instance; keep local and deployed definitions synchronized.", "components": ["firebase"], "sources": ["firebase:sd6071548f6fe", "firebase:s14872143a9e8"], "status": "REASONED"}, "firebase-server": {"text": "Admin SDK/server credentials bypass client Rules; keep credentials server-side and apply IAM and caller authorization.", "components": ["firebase"], "sources": ["firebase:s7bd11c22c7ce"], "status": "REASONED"}, "firebase-callable": {"text": "Callable ID-token validation is optional authentication; reject missing request.auth, use its UID and validate resource access. First-generation handlers use context.auth.", "components": ["firebase"], "sources": ["firebase:s79da8f65f90a", "firebase:sc8f214d059d2", "firebase:s9a86df9623a6"], "status": "REASONED"}, "firebase-callable-appcheck": {"text": "onCall enforceAppCheck needs firebase-functions 4.0.0 or newer; App Check does not replace caller/resource authorization.", "components": ["firebase"], "sources": ["firebase:sb0ff8f1726f7"], "status": "REASONED"}, "firebase-http": {"text": "onRequest does not inherit callable auth; verify end-user tokens with verifyIdToken or restrict IAM invokers, then authorize the intended principal.", "components": ["firebase"], "sources": ["firebase:s49f78d054708", "firebase:s1bb711c9afeb", "firebase:sc9f86ea271f6"], "status": "REASONED"}, "firebase-appcheck": {"text": "Register compatible clients and inspect metrics, then enforce App Check per product and per callable; registration alone enforces nothing.", "components": ["firebase"], "sources": ["firebase:sa71e958c99df", "firebase:sba7129c83405", "firebase:sb0ff8f1726f7"], "status": "REASONED"}, "firebase-key-restrictions": {"text": "Apply one app-restriction type per key plus API restrictions; separate platforms and unrelated billable APIs, using the full Firebase product table.", "components": ["firebase"], "sources": ["firebase:sc78d5eb59b65", "firebase:safeed4cd2b67"], "status": "REASONED"}, "firebase-anonymous": {"text": "Anonymous sign-in is opt-in; disable unused providers because anonymous accounts still satisfy authentication-only Rules.", "components": ["firebase"], "sources": ["firebase:s77e2cbd8a29a"], "status": "REASONED"}, "firebase-enumeration": {"text": "Email enumeration protection defaults on only for projects created from September 15, 2023; signup can still return EMAIL_EXISTS.", "components": ["firebase"], "sources": ["firebase:s06808e4b6965"], "status": "REASONED"}, "firebase-emulators": {"text": "Emulators default to localhost but missing Firestore/RTDB/Storage Rules configuration means open data; keep listeners, UI, hub, proxies and tunnels private.", "components": ["firebase"], "sources": ["firebase:sd8a1bda29f5c", "firebase:s69d9721c7f29"], "status": "REASONED"}, "supabase-network": {"text": "Restrict hosted database/pooler client IPs with Network Restrictions, enforce SSL and protect database-role passwords.", "components": ["supabase"], "sources": ["supabase:sb787b5d63633", "supabase:s83e15676e1ff", "supabase:sf69cafb4bc6c"], "status": "REASONED"}, "supabase-grants": {"text": "Data API access needs grants plus RLS; automatic-grant defaults are transitioning. Table Editor enables RLS, while SQL creation needs explicit enablement.", "components": ["supabase"], "sources": ["supabase:s6d5cf2d4dbf3"], "status": "REASONED"}, "supabase-select": {"text": "The profiles baseline enables RLS, revokes broad grants and permits authenticated owners SELECT; audit inherited/column grants and existing policies.", "components": ["supabase", "postgres"], "sources": ["supabase:s46cff2590323", "postgres:s8e65a2f3f2bb"], "status": "REASONED"}, "supabase-bypass": {"text": "With RLS enabled and no policy, ordinary subject roles get no rows; owners normally bypass, as do superusers/BYPASSRLS roles. TRUNCATE is outside RLS.", "components": ["postgres"], "sources": ["postgres:se9846041ce1f"], "status": "REASONED"}, "supabase-writes": {"text": "Grant and policy INSERT/UPDATE/DELETE only when intended; UPDATE checks old/new ownership and needs applicable SELECT when reading columns.", "components": ["supabase", "postgres"], "sources": ["supabase:s46cff2590323", "postgres:s8e65a2f3f2bb"], "status": "REASONED"}, "supabase-schemas": {"text": "public is exposed by default; restrict exposed schemas and deliberately grant schema/object access. Schema selection does not replace RLS.", "components": ["supabase"], "sources": ["supabase:s20ef046ac646"], "status": "REASONED"}, "supabase-api-disable": {"text": "Disable an unused Data API to close generated REST endpoints; this does not disable all other services.", "components": ["supabase"], "sources": ["supabase:s6d5cf2d4dbf3"], "status": "REASONED"}, "supabase-keys": {"text": "Publishable keys select anon or authenticated by session; secret and legacy service_role keys bypass RLS and belong only on trusted servers.", "components": ["supabase"], "sources": ["supabase:s65d5b8043bad"], "status": "REASONED"}, "supabase-key-retire": {"text": "Replacement keys do not disable legacy keys; migrate consumers then explicitly deactivate legacy keys, or delete compromised secret keys.", "components": ["supabase"], "sources": ["supabase:sa946354db383"], "status": "REASONED"}, "supabase-jwt-retire": {"text": "Rotation changes the signing key but retains previous trust until revocation; account for verifier caches and propagation.", "components": ["supabase"], "sources": ["supabase:sfc2f9ae0232b"], "status": "REASONED"}, "supabase-view-owner": {"text": "Views use owner privileges/policies by default; disclosure depends on reachable schemas, grants, query and owner privileges, not anon-key possession alone.", "components": ["postgres", "supabase"], "sources": ["postgres:s13c54ab9cc6b", "supabase:s46cff2590323"], "status": "REASONED"}, "supabase-view-invoker": {"text": "PostgreSQL 15+ security_invoker views apply caller policies and require underlying privileges.", "components": ["postgres-view", "postgres"], "sources": ["postgres-view:s14443e063ef1", "postgres:saa99ef28f60f", "postgres:s13c54ab9cc6b"], "status": "REASONED"}, "supabase-view-legacy": {"text": "PostgreSQL 14 and earlier require revoking all view privileges from PUBLIC/anon/authenticated or unexposing the schema; updatable views can bypass RLS for writes.", "components": ["postgres"], "sources": ["postgres:s13c54ab9cc6b", "postgres:sb6224f4ac866", "postgres:sc39eceeac711"], "status": "REASONED"}, "supabase-functions": {"text": "Prefer INVOKER; restrict DEFINER execution, use empty search_path with qualified references and enforce caller authorization including required MFA.", "components": ["supabase", "postgres"], "sources": ["supabase:s8405a9fb4b74", "postgres:sec39c317b06f", "postgres:s0fe66d71aa10"], "status": "REASONED"}, "supabase-function-defaults": {"text": "New functions grant PUBLIC execution; create privileged functions and restrict grants transactionally. Global/default revocations are creator-specific and do not fix existing functions.", "components": ["postgres"], "sources": ["postgres:sec39c317b06f", "postgres:s04e81888353f"], "status": "REASONED"}, "supabase-http": {"text": "Audit http/pg_net wrappers and revoke unneeded execute grants; constrain callers/destinations/data. Extension enablement alone does not prove exploitable SSRF.", "components": ["supabase"], "sources": ["supabase:s13da01bbbba5", "supabase:s76740bffcb32"], "status": "REASONED"}, "supabase-vault": {"text": "Restrict vault.decrypted_secrets and privileged wrappers and keep Vault unexposed; at-rest encryption cannot prevent authorized plaintext queries.", "components": ["supabase"], "sources": ["supabase:s47bb8fa3e14e"], "status": "REASONED"}, "supabase-buckets": {"text": "Private downloads need authorized access or time-limited signed URLs; public buckets bypass retrieval checks but not upload/delete/move/copy controls.", "components": ["supabase"], "sources": ["supabase:s1567fb838f20"], "status": "REASONED"}, "supabase-storage-policies": {"text": "Authorize storage.objects separately by bucket and owner path; upload needs INSERT, upsert also SELECT/UPDATE, and deletion needs deliberate permission.", "components": ["supabase"], "sources": ["supabase:sdba7d7b6dcf8", "supabase:s9e1484f46eff", "supabase:see2d3be99ebd"], "status": "REASONED"}, "supabase-realtime-default": {"text": "Public Realtime channels are allowed by default; disable them project-wide and also set client config.private.", "components": ["supabase"], "sources": ["supabase:s5674b1057e14"], "status": "REASONED"}, "supabase-realtime-policy": {"text": "Broadcast/Presence receive/send need separate SELECT/INSERT policies on realtime.messages, constrained by topic ownership and extension.", "components": ["supabase"], "sources": ["supabase:s84efad0ba39d"], "status": "REASONED"}, "supabase-changes": {"text": "Postgres Changes uses source-table authorization; private-channel policies are not universal and DELETE has separate limitations.", "components": ["supabase"], "sources": ["supabase:s694eab64f2ec"], "status": "REASONED"}, "supabase-signup": {"text": "Disable signup, anonymous sign-ins and unused providers when unwanted; inspect hosted settings instead of assuming universal defaults.", "components": ["supabase"], "sources": ["supabase:sc797f0b63472"], "status": "REASONED"}, "supabase-cli-auth": {"text": "CLI enable_signup defaults true and enable_anonymous_sign_ins false; local config edits do not prove hosted settings changed.", "components": ["supabase"], "sources": ["supabase:s3a5e39fe7b4d"], "status": "REASONED"}, "supabase-mfa": {"text": "TOTP is available on every plan at writing; enrollment alone does not constrain data. Require aal2 using a restrictive policy alongside permissive ownership policies.", "components": ["supabase", "postgres"], "sources": ["supabase:s182755725d74", "supabase:s741468ff35a9", "postgres:s8e65a2f3f2bb"], "status": "REASONED"}, "supabase-edge-jwt": {"text": "Per-function verify_jwt defaults true; --no-verify-jwt disables platform checking and requires handler authentication before protected work.", "components": ["supabase"], "sources": ["supabase:s7dfbb47b5d20", "supabase:sf7d438b8c080", "supabase:se52bbd097033"], "status": "REASONED"}, "supabase-edge-auth": {"text": "Platform JWT success is not resource authorization and API-key compatibility can pass it; separately authorize users or verify webhook signatures.", "components": ["supabase"], "sources": ["supabase:sf7d438b8c080", "supabase:se52bbd097033"], "status": "REASONED"}, "supabase-edge-secrets": {"text": "Edge environments contain privileged credentials including SUPABASE_SERVICE_ROLE_KEY; protect managed/local secrets, responses, logs and client bundles.", "components": ["supabase"], "sources": ["supabase:sb3c4c0993e14"], "status": "REASONED"}, "verify-firestore": {"text": "Compare unsigned/A/B reads and writes to user documents and descendants, with a successful owner control; test parent documents to distinguish recursive-rule versions.", "components": ["firebase"], "sources": ["firebase:s7bd11c22c7ce"], "status": "REASONED"}, "verify-rtdb": {"text": "Compare unsigned/A/B reads/writes to seeded RTDB owner paths; ancestor grants expose them while ownership rules deny unauthorized callers.", "components": ["firebase"], "sources": ["firebase:sa6b1c4933e6a"], "status": "REASONED"}, "verify-firebase-storage": {"text": "Compare unsigned/A/B object reads and uploads; ownership rules must deny unrelated callers while owner operations succeed.", "components": ["firebase"], "sources": ["firebase:s0a7f54463abd"], "status": "REASONED"}, "verify-download-token": {"text": "Same sessionless download URL works after Rules tightening but must lose bytes after token revocation; owner getBytes remains successful and network/CORS failures are inconclusive.", "components": ["firebase"], "sources": ["firebase:s9b7e57b472c7", "firebase:sa0584b797732", "firebase:sfd1e7412abf6"], "status": "REASONED"}, "verify-tables": {"text": "Pair unsigned/A/B profiles GETs against seeded rows, using ordinary credentials and required MFA; test intended writes and persisted effects separately.", "components": ["supabase", "postgres", "curl"], "sources": ["supabase:s92b405dc3c7e", "postgres:se9846041ce1f", "supabase:s741468ff35a9", "curl:s5e1383feef7d", "curl:s3db522258e44"], "status": "REASONED", "verify": [1]}, "verify-views": {"text": "Compare view reads with table boundaries; owner-privileged exposure must close under invoker/revoked access while intended reads or deliberate removal are confirmed.", "components": ["postgres"], "sources": ["postgres:s13c54ab9cc6b"], "status": "REASONED"}, "verify-rpc": {"text": "Test every API function overload with valid harmless arguments as unsigned/A/B; unauthorized data or operations must be denied while the intended caller succeeds.", "components": ["supabase"], "sources": ["supabase:s087c8cf42ab0", "supabase:s8405a9fb4b74"], "status": "REASONED"}, "verify-http": {"text": "Probe HTTP wrappers with a controlled collector and marker; unauthorized fixed calls must emit no request. Commit pg_net fixture transactions before expecting delivery.", "components": ["supabase"], "sources": ["supabase:s76740bffcb32"], "status": "REASONED"}, "verify-vault": {"text": "Use a disposable Vault canary to compare exposed/fixed wrappers; inspect function and table privileges as supporting evidence.", "components": ["supabase", "postgres"], "sources": ["supabase:s47bb8fa3e14e", "postgres:sc39eceeac711"], "status": "REASONED"}, "verify-storage": {"text": "Compare public URL and A/B private downloads plus intended uploads/upserts; unauthorized callers must receive no bytes or cross-owner writes.", "components": ["supabase"], "sources": ["supabase:sc29febbb230e", "supabase:see2d3be99ebd", "supabase:s1567fb838f20"], "status": "REASONED"}, "verify-realtime": {"text": "Compare A/B join, Broadcast and Presence delivery; public-disabled clients lacking private=true must fail, and connection alone is insufficient.", "components": ["supabase"], "sources": ["supabase:s5674b1057e14", "supabase:s84efad0ba39d"], "status": "REASONED"}, "verify-changes": {"text": "Observe A/B INSERT/UPDATE subscription events against seeded rows; fixed table authorization withholds unrelated events. Evaluate DELETE separately.", "components": ["supabase"], "sources": ["supabase:s694eab64f2ec"], "status": "REASONED"}, "verify-edge": {"text": "Compare no/invalid Bearer without apikey, authorized owners and valid webhook signatures; distinguish platform 401 from handler authorization and inspect effects/secrets.", "components": ["supabase"], "sources": ["supabase:se52bbd097033", "supabase:sf7d438b8c080", "supabase:sb3c4c0993e14"], "status": "REASONED"}, "verify-firebase-functions": {"text": "Compare callable requests with valid App Check but missing user auth and A/B operations; HTTP endpoints need separate invalid/valid user or IAM comparisons.", "components": ["firebase"], "sources": ["firebase:s79da8f65f90a", "firebase:s49f78d054708"], "status": "REASONED"}, "verify-appcheck": {"text": "Repeat owner requests with/without App Check per product/callable; enforcement must deny missing attestation while compatible clients work.", "components": ["firebase"], "sources": ["firebase:sba7129c83405", "firebase:sb0ff8f1726f7"], "status": "REASONED"}, "verify-api-restrictions": {"text": "Test intended versus excluded apps/referrers after key restrictions, retaining successful intended product traffic.", "components": ["firebase"], "sources": ["firebase:safeed4cd2b67", "firebase:sc78d5eb59b65"], "status": "REASONED"}, "verify-emulators": {"text": "Enumerate hub /emulators and sockets, compare missing versus loaded Rules and second-host connectivity, and retain local owner controls.", "components": ["firebase"], "sources": ["firebase:sd8a1bda29f5c", "firebase:s69d9721c7f29"], "status": "REASONED"}, "verify-firebase-accounts": {"text": "Sign out before each Anonymous signup test and check isNewUser; disabled providers must reject creation. Enumeration tests compare sign-in errors, not EMAIL_EXISTS signup behavior.", "components": ["firebase"], "sources": ["firebase:s8c90f9f6c7fa", "firebase:s8c8336ec252e", "firebase:s142e56472093", "firebase:s77e2cbd8a29a", "firebase:s06808e4b6965"], "status": "REASONED"}, "verify-supabase-accounts": {"text": "Fresh signup and anonymous creation must fail under invite-only/anonymous-disabled settings while an invited account still signs in.", "components": ["supabase"], "sources": ["supabase:sc797f0b63472", "supabase:sa2dd4637969e", "supabase:sad79d4d1b6ee"], "status": "REASONED"}, "verify-mfa": {"text": "Owner profiles GET with aal1 must yield no protected rows under the restrictive policy, while fresh aal2 returns the seeded owner row.", "components": ["supabase"], "sources": ["supabase:s741468ff35a9"], "status": "REASONED", "verify": [1]}, "verify-key-retirement": {"text": "Compare old/new API keys after explicit deactivation and old/new JWTs after revocation, accounting for propagation and caches.", "components": ["supabase"], "sources": ["supabase:sa946354db383", "supabase:sfc2f9ae0232b"], "status": "REASONED"}, "verify-api-removal": {"text": "Previously successful profiles requests must stop returning fixtures after schema removal or Data API disabling; retained schemas need authorized controls.", "components": ["supabase"], "sources": ["supabase:s6d5cf2d4dbf3", "supabase:s20ef046ac646"], "status": "REASONED"}, "verify-bundle": {"text": "Filename-only service_role/eyJ/sb_secret_ scanning yields review candidates, not proof of leaks; public anon JWTs are legitimate. Use a marker control and inspect encoded/split secrets separately.", "components": ["supabase"], "sources": ["supabase:s65d5b8043bad"], "status": "REASONED", "verify": [2]}, "local-guards": {"text": "Historical local guard tests rejected placeholders and malformed set lines; this guide's assessment is shell behavior only, not live authorization. The recorded ShellCheck 0.11.0 has a release citation, not host-binary evidence; bash and other tool/parser versions are unrecorded. Runs covered earlier forms, whose guard refusals and bundle scan result arms exited 0; current forms were not re-run.", "components": ["supabase", "shellcheck"], "sources": ["supabase:s92b405dc3c7e", "supabase:s65d5b8043bad", "shellcheck:sf994764bf270"], "status": "DEMONSTRATED", "evidence": "Local guard tests refused embedded `REPLACE_WITH_` placeholders, `example.com`, angle brackets, empty values, and omitted or shortened `set --` lines."}, "local-syntax": {"text": "Recorded ShellCheck 0.11.0/bash and JSON/TOML/JavaScript parsing passed; this guide's assessment is that these checks do not compile Firebase Rules, execute SQL or demonstrate services. Only the ShellCheck release is cited; other versions are unrecorded. Bash runs covered earlier forms, whose guard refusals and bundle scan result arms exited 0; current forms get only gate bash -n and ShellCheck.", "components": ["firebase", "supabase", "shellcheck"], "sources": ["firebase:sa6b1c4933e6a", "firebase:sd8a1bda29f5c", "supabase:s3a5e39fe7b4d", "firebase:sc8f214d059d2", "shellcheck:sf994764bf270"], "status": "DEMONSTRATED", "evidence": "The RTDB JSON, `firebase.json` fragment, Auth TOML, and callable JavaScript passed local parsing checks."} } } ```` ====================================================================== ==> supabase-self-hosted.md ====================================================================== # Self-hosted Supabase Supabase is an app backend that bundles a PostgreSQL database with sign-in, file storage and ready-made APIs. This guide helps your AI assistant configure a self-hosted Docker deployment securely, including every demo secret replaced before first start, published ports kept off the internet, and the `service_role` key guarded. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/supabase-self-hosted.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Supabase documentation (no recorded version) and curl curl-8_14_1; 4 further sources are pinned to a commit. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 28 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. The self-hosted Supabase Docker stack ships a `.env.example` full of demo secrets ([source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L30-L36)), and its own documentation says plainly: "you should never start your self-hosted Supabase using these defaults." An unedited `docker compose up` publishes two surfaces to the host. The API gateway (Envoy by default, Kong as an optional override) publishes port `8000`, which fronts the REST API, Auth, Storage, Edge Functions and the Studio dashboard as plain HTTP; and the Supavisor connection pooler publishes `5432` and `6543`, which are pooled PostgreSQL connections. Both are reachable with credentials printed in a public example file, so the whole database is exposed until you replace them. This guide covers that deployment; writing the row-level security policies that protect the data once it is reachable is [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md), and the database itself is [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md). ## 1. Regenerate every secret before the first start At eabe06be5b36cf57f2b158bd5093b396606bf801, `.env.example` sets `POSTGRES_PASSWORD` to `your-super-secret-and-long-postgres-password` and `JWT_SECRET` to `your-super-secret-jwt-token-with-at-least-32-characters-long`. Its `ANON_KEY` and `SERVICE_ROLE_KEY` are HS256 JWTs; a local HMAC-SHA256 check verifies both against that `JWT_SECRET` (roles `anon` and `service_role`, issuer `supabase-demo`, issued 1641769200, expires 1799535600). [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L30-L36) It sets `DASHBOARD_USERNAME=supabase` and `DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated`. [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L57-L59) The `S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` keys are published example values. [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L84-L89) The file comments require `SECRET_KEY_BASE` to be at least 64 characters for Realtime and Supavisor communications, `REALTIME_DB_ENC_KEY` to be exactly 16 for sensitive Realtime fields, `VAULT_ENC_KEY` to be exactly 32 for Supavisor configuration, and `PG_META_CRYPTO_KEY` to be at least 32 for Studio-to-postgres-meta connection strings. Replace all four example values with keys meeting those stated lengths. [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L61-L75) Rotating the two API keys is not enough on its own: because the demo `JWT_SECRET` is public, anyone can mint a token for any role, including `service_role`, until the secret itself is replaced and every key derived from it is reissued. Use the vendor's scripts to regenerate the JWT secret and the keys together (`generate-keys.sh`, and `add-new-auth-keys.sh` for the JWT key pair; an existing asymmetric setup also carries the secret in `JWT_KEYS`/`JWT_JWKS`, so change those too), set a strong `DASHBOARD_PASSWORD`, `POSTGRES_PASSWORD` and S3 keys, and store the resulting `.env` as [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) describes, never in the repository. Editing `.env` does not change running containers: after regenerating secrets and keys, recreate the affected services (`sh run.sh recreate`) and confirm the old credentials are rejected. Removing the old HS256 secret from every verifier invalidates the legacy API JWTs and user access tokens signed by it, and regenerating the asymmetric key pair invalidates tokens signed by the previous pair; rotating only the newer opaque `sb_publishable_*`/`sb_secret_*` keys does not rotate signing keys or invalidate user sessions. On a stack that has already initialized its database, changing `POSTGRES_PASSWORD` in `.env` alone does not take effect; follow the vendor's separate database-password change procedure (`sh utils/db-passwd.sh`, then recreate services). Supabase's own "secure your services" checklist is the authoritative list. ## 2. Keep the published ports off the public internet The compose file publishes the API gateway on `8000` and the Supavisor pooler on `5432` and `6543` to the host, on every interface. The `db` service has no host mapping of its own, but that is not isolation: the Docker host can reach a container's ports directly, and the pooler exposes the database anyway. The optional Kong override additionally publishes HTTPS on `8443`, so include it when Kong is enabled. Map the published services to loopback (`127.0.0.1:8000:8000`, `127.0.0.1:5432:5432`, `127.0.0.1:6543:6543`, and `127.0.0.1:8443:8443` under Kong) or firewall them, and reach them over a private network or a tunnel ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md), [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md)). Docker publishes ports through its own NAT rules, which bypass a host firewall such as UFW and do not always leave a matching host listening socket, so confirm the effective mapping with `docker` and test each port from an external vantage rather than trusting the host's socket table. In the default Envoy deployment, neither the gateway nor Supavisor terminates TLS; the optional Kong override additionally provides an HTTPS listener on `8443`, so put a reverse proxy in front for HTTPS on the API ([nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md)); a web proxy does not protect the PostgreSQL wire protocol on `5432`/`6543`, which needs its own TLS or an encrypted tunnel. If Edge Functions or Storage integrations fetch user-controlled URLs, restrict the workload's egress to the destinations it needs and block cloud metadata and unrelated internal services per [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md); inbound JWT verification does not constrain outbound requests. ## 3. Treat the service_role key as full database access The `SERVICE_ROLE_KEY` is not an API convenience: the role it names has the PostgreSQL `BYPASSRLS` attribute, so it "skips every Row Level Security policy" and has full read and write access to your data (it is not a database superuser, but for handling purposes treat it as one). It is a server-side secret only; Supabase's documentation says never to put one in a browser, a shipped application, or source control, because disclosure of a secret key compromises the project's data. The `anon` key is the opposite: it is meant to be public and is constrained by row-level security. That protection depends on SQL privileges and, when enabled, RLS. For an ordinary API role subject to RLS, reading rows requires the applicable schema and table or column privileges plus a policy permitting those rows. With those privileges present, RLS enabled, and no applicable policy, a `SELECT` returns no rows; missing privileges instead produce a permission error. With RLS never enabled, the operations the role is granted are unrestricted by row policy and readable or writable through the `anon` key. `service_role` bypasses RLS but is still subject to SQL privileges. Enable RLS on every exposed table and write policies per [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md); a view can still serve its owner's rows past RLS, which that guide also covers. The same `service_role` and `anon` roles apply on the self-hosted stack. ## 4. Close the authentication defaults you did not choose Three defaults need review. Compose passes `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` to the default Envoy gateway. In the optional Kong configuration, those variables supply Basic auth for the Studio catch-all route. The shipped pair is `supabase` / `this_password_is_insecure_and_should_be_updated`; set a strong password in section 1 and keep the gateway off the public internet. The fetched files do not show Envoy's dashboard route configuration. [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L57-L59) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L68-L72) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L97-L106) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/volumes/api/kong.yml#L27-L33) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/volumes/api/kong.yml#L454-L467) Gateway authentication is route-specific, not blanket: Kong's Studio catch-all route uses the dashboard Basic-auth pair, REST/GraphQL/Realtime and the protected Auth routes require an API key, and some Auth routes (verify, callback, JWKS, SAML metadata) are open by design; Storage and Edge Functions bypass the gateway's API-key check and enforce their own controls. At eabe06be5b36cf57f2b158bd5093b396606bf801, `.env.example` sets `FUNCTIONS_VERIFY_JWT=false`; Compose passes it to `VERIFY_JWT` for the Edge Functions main service and says per-function configuration is pending. As `.env.example` notes, `VERIFY_JWT` applies to all functions; with this default, the main service's JWT verification is disabled, though an individual function may still authenticate. Inventory your functions and choose a verification policy. [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L301-L305) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L435-L472) Studio also listens internally on `3000` with no host mapping of its own: never publish that port or otherwise route around dashboard authentication. The example sets `DISABLE_SIGNUP=false`, `ENABLE_PHONE_SIGNUP=true`, and `ENABLE_PHONE_AUTOCONFIRM=true`; Compose maps these to Auth's `GOTRUE_DISABLE_SIGNUP`, `GOTRUE_EXTERNAL_PHONE_ENABLED`, and `GOTRUE_SMS_AUTOCONFIRM`. This configuration enables registration and phone auto-confirmation unless other controls intervene; rotating secrets does not change these settings. [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L174-L196) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L109-L111) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L138-L177) Set the registration policy you actually want. Supabase Auth supports TOTP MFA for application users (enforce the required assurance level in your policies per [firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md) and [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)); that does not add a factor to the dashboard's Basic auth, so put administrative Studio access behind a private path or an identity-aware proxy that enforces MFA. ## 5. Verify REASONED: following block; listener/publication inventory and external versus allowed-vantage controls, per the guide's cited Supabase self-hosting with Docker and Supabase docker-compose.yml. No Supabase deployment or container runtime is available, and local listeners are denied. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. Those runs covered the inventory in an earlier form, a bare ss at top level with no status of its own; the current form passes bash -n and ShellCheck 0.11.0 in the repository's gate suite, which runs both over every block. Stub runs of the current form with no network, in a clean shell and under an inherited `set -e`, `readonly` and `declare -i`, exited 0 when the stub ss passed, 3 when it failed and 2 when ss was missing. The inventory block exits 2 when ss is not installed, so nothing is listed, 3 when ss fails and otherwise 0, leaving every listener for you to judge. No deployed listener was inspected: exposed ports answer externally; fixed ports are private and still work from an allowed vantage, corroborated by effective publication mappings. Credential outcomes described below also remain unobserved. Each probe below is reasoned, not demonstrated: the authoring environment has no container runtime, so none was stood up in its exposed and fixed states. Each names its expected exposed and fixed result so it discriminates against a live instance. These expectations follow the cited vendor documentation, including the check that forges a token with the demo `JWT_SECRET`, which is the check that proves the secret itself was rotated rather than just the shipped keys swapped. There are two layers to check. First, from an external host none of these ports should answer at all: any HTTP or database response means the port is published and is itself the finding, whatever the credentials do next. A refused connection is a failed connection from that vantage rather than proof of durable firewall enforcement, and a timeout is inconclusive (a wrong destination or dropped route looks the same), so corroborate a blocked port with the effective Docker mapping, and confirm the service is actually live from an allowed vantage. The credential checks below apply to a port you can still reach, because you have not isolated it yet or are testing from an allowed internal vantage, and show whether the demo credentials were replaced. If you front the gateway with a reverse proxy for HTTPS, aim the HTTP checks at that proxy rather than the raw `8000`. Read the `err` field, since a transport failure is only conclusive when it is a connection refusal from that vantage, and the write-out fields need curl 7.75.0 or newer. ```bash # Listener expectations, REASONED as the paragraph above records: they follow the cited vendor documentation; no container runtime is available. # On the host: these published listeners should be bound to loopback or an internal interface. # Docker publishes through NAT, so also test each port from an external host over IPv4 and IPv6. ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 # this block's status: 2 if ss is missing (the line above), 3 if ss fails, otherwise 0, because you judge the listeners it prints if ss -tlnp; then :; else # read every listener; 8000/5432/6543: loopback or internal only echo "ss -tlnp exit $?: the listener inventory failed; inconclusive"; set -- 3 fi echo "block exit $1; the listeners above are not classified, so judge them yourself" exit "$1" ) ``` ### Function and credential checks REASONED: listener isolation and the canary function comparison, per the guide's cited Supabase docker-compose.yml and Supabase docker .env.example. No Supabase deployment or container runtime is available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; five rejected guard cases reached no curl stub, while substituted canary inputs produced two STUB curl lines. These runs covered the blocks in earlier forms, in which the inventory had no status of its own and the canary block printed both results unclassified and ended with the second curl's status. The current forms of both blocks pass bash -n and ShellCheck 0.11.0 in the repository's gate suite, which runs both over every block, and the inventory's current form was also stub-run, as the Verify introduction records. No live outcome was observed: fixed listeners are private, and the function's exposed state accepts both requests while JWT enforcement denies the credential-free request and admits the current-token control. The gateway serves plain HTTP on `8000`. Substitute the base URL, function name, and current legacy `ANON_KEY` JWT inside the single quotes. Use a canary function with a known response and no independent authentication. With `FUNCTIONS_VERIFY_JWT=false`, both requests should reach it; with verification enabled, the credential-free request should receive `401` and the Bearer-authenticated request should return its known response. An arbitrary function can return its own `401`, so that status alone does not identify the main worker's policy. The block exits 1 when the credential-free request returns a `2xx`; 3 when either request fails, the Bearer request returns anything but a `2xx`, or the credential-free request returns anything but `401` or a `2xx`; and otherwise 0, leaving the Bearer response body for you to compare with the canary's known response. REASONED: following block; the canary function comparison follows the cited environment and Compose sources; no container runtime was available for exposed/fixed service runs. ```bash ( # a subshell, so your own script arguments are untouched trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias # The anon JWT you substitute on the set -- line enters shell history; clear that history line afterward. # Stdin closes the argv channel only: it does not hide the JWT from set -x tracing, your own account # or root. set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BASE_URL' 'REPLACE_WITH_A_FUNCTION_NAME' 'REPLACE_WITH_CURRENT_ANON_JWT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 3 ] || { echo "the set -- line needs exactly 3 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute the base URL (https://your-proxy, or http://127.0.0.1:8000 for a private-origin check); not probing"; exit 2 ;; esac case "$2" in *REPLACE_WITH_*|*FUNCTION_NAME*|"") echo "substitute a function name; not probing"; exit 2 ;; esac case "$3" in *REPLACE_WITH_*|"") echo "substitute a current legacy anon JWT; not probing"; exit 2 ;; esac case "$3" in *[[:cntrl:]]*) echo "the anon JWT must be one line with no control characters; not probing"; exit 2 ;; esac # Compare the credential-free request with a current legacy anon JWT sent as Bearer. # Use a canary with a known response and no independent authentication. set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nno-key http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${1%/}/functions/v1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then its output printf '%s' "$5" set -- "$1" "$2" "$3" "$4" "${5%$'\n'}" set -- "$1" "$2" "$3" "$4:${5##*$'\n'}" # the credential-free result: curl's exit status, then its no-key line set -- "$1" "$2" "$3" "$4" "$(if printf 'Authorization: Bearer %s\n' "$3" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -H @- -w '\nwith-key http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "${1%/}/functions/v1/$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- 0 "$4" "${5##*$'\n'}" "${5%$'\n'*}" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), the credential-free result, then the Bearer request's exit status and output; the JWT is no longer held printf '%s' "$4" set -- "$1" "$2" "$3" "${4%$'\n'}" set -- "$1" "$2" "$3:${4##*$'\n'}" # status, the credential-free result, then the Bearer result case "$2" in "0:no-key http=401 "*) ;; "0:no-key http=2"??" "*) echo "FINDING: the credential-free request reached the function, so JWT verification is off for it"; set -- 1 "$2" "$3" ;; 0:*) echo "the credential-free request returned neither 401 nor a 2xx; inconclusive"; set -- 3 "$2" "$3" ;; *) echo "curl exit ${2%%:*}: the credential-free request failed; inconclusive"; set -- 3 "$2" "$3" ;; esac case "$3" in "0:with-key http=2"??" "*) ;; 0:*) echo "the Bearer request did not return a 2xx, so the positive control failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; *) echo "curl exit ${3%%:*}: the Bearer request failed, so the positive control failed; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac echo "block exit $1; the response bodies are not classified, so confirm the Bearer request returned the canary's known response" exit "$1" ) ``` REASONED: preceding block; function authentication and retirement of shipped API, signing, dashboard and database credentials, per the guide's cited Supabase self-hosting with Docker, Supabase docker .env.example, Supabase API keys and curl curl-8_14_1 headers from stdin. No Supabase deployment, container runtime or current credentials are available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; guarded canary inputs reached two curl stubs and rejected inputs reached none. On 2026-10-13 UTC a QA review ran the block, extracted to a file and unchanged except for placeholder substitution, against a recording curl stub: it exited 2 with no curl call for an anon JWT with an embedded newline, a carriage return and a tab, and for a placeholder, and exited 0 after two curl calls for a valid dummy JWT, which reached curl only on stdin. The block as it stood before its control-character guard, in the same 2026-10-13 UTC QA review, passed an embedded newline to curl (two calls), and the stub received an injected second header line (`X-Injected: yes`) on its stdin. Every run of the block recorded above covered an earlier form of the block, which printed both results unclassified and ended with the second curl's status; none was repeated on the current form, which the repository's gate suite checks with bash -n and ShellCheck 0.11.0 like every block. Synthetic JSON/base64url tokens parsed and demo/current fixture keys produced different HMACs; no service verification ran. Exposed services accept the old credentials or unsigned function request; fixed services reject them while current credentials return the stated positive controls. For a port you can still reach, check whether the shipped credentials were replaced, feeding every credential through stdin rather than argv (`curl --config -` for Basic auth, `curl --header @-` for `apikey`/`Authorization`; disable shell tracing). Stdin closes the argv channel only: it does not hide a credential typed into a command from shell history, or the credential from your own account's view of the process. Test each shipped API key against `/auth/v1/settings`, then repeat with a current key that must return settings JSON. This checks gateway acceptance, not retirement of backend signing keys. Separately, test the same harmless REST canary with two correctly formed, unexpired `service_role` JWTs carrying equivalent claims: one signed with the current key and one with the demo `JWT_SECRET`. Keep a valid current `apikey` on both requests. Require the current token to return the known canary result and the demo-signed token to receive a PostgREST JWT signature-verification rejection; transport errors, gateway denials, and SQL permission failures are inconclusive. This checks the REST verifier only; check other JWT-verifying services separately. For the optional Kong configuration, the Studio dashboard still accepts the example Basic credentials if the gateway answers `supabase:this_password_is_insecure_and_should_be_updated` ([source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L57-L59); via `--config -`) with `http=200` rather than `401`, retrieving a known Studio response as the positive control. The database still accepts the demo password if `psql` connects to the Supavisor pooler on `5432`/`6543` with the demo `POSTGRES_PASSWORD` ([source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L30-L36)), as user `postgres.your-tenant-id` (from the example `POOLER_TENANT_ID` passed to Supavisor) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L142-L145) [source](https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L532-L573) and database `postgres`, running `SELECT current_user, current_database();` before the demo-password rejection counts. That pooler connection fails authentication once the password is regenerated. ## Sources (checked September 2026) The guide's `self-hosted/v0.8.1` version basis cites Compose at e693f206f5050b0004a86e12e533bb75ba2a9c76 (Envoy v1.39.1, Auth v2.196.0). The `.env.example` defaults, corresponding Compose wiring, and optional Kong configuration cited below come from eabe06be5b36cf57f2b158bd5093b396606bf801; its Compose file pins Envoy v1.39.0 and Auth v2.189.0. Compare your installed release and every Compose override before applying these defaults. - Supabase self-hosting with Docker (Envoy gateway, secure your services, changing the database password): https://supabase.com/docs/guides/self-hosting/docker - Supabase API keys (anon vs service_role, BYPASSRLS, keep secret): https://supabase.com/docs/guides/getting-started/api-keys - Supabase Row Level Security (a table without RLS is unprotected): https://supabase.com/docs/guides/database/postgres/row-level-security - Supabase docker .env.example (default secrets, keys, FUNCTIONS_VERIFY_JWT, signup and S3 defaults): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example - Supabase docker .env.example (keys; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L30-L36 - Supabase docker .env.example (dashboard; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L57-L59 - Supabase docker .env.example (crypto; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L61-L75 - Supabase docker .env.example (s3; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L84-L89 - Supabase docker .env.example (pool; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L142-L145 - Supabase docker .env.example (signup; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L174-L196 - Supabase docker .env.example (functions; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L301-L305 - Supabase docker-compose.yml (published ports for the gateway and pooler): https://github.com/supabase/supabase/blob/e693f206f5050b0004a86e12e533bb75ba2a9c76/docker/docker-compose.yml - Supabase docker-compose.yml (dashboard; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L97-L106 - Supabase docker-compose.yml (auth; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L138-L177 - Supabase docker-compose.yml (functions; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L435-L472 - Supabase docker-compose.yml (pool; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L532-L573 - Supabase docker-compose.yml (versions old; e693f206f5050b0004a86e12e533bb75ba2a9c76): https://github.com/supabase/supabase/blob/e693f206f5050b0004a86e12e533bb75ba2a9c76/docker/docker-compose.yml#L68-L72 - Supabase docker-compose.yml (auth version old; e693f206f5050b0004a86e12e533bb75ba2a9c76): https://github.com/supabase/supabase/blob/e693f206f5050b0004a86e12e533bb75ba2a9c76/docker/docker-compose.yml#L109-L111 - Supabase docker-compose.yml (versions new; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L68-L72 - Supabase docker-compose.yml (auth version new; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L109-L111 - Supabase docker/volumes/api/kong.yml (credentials; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/volumes/api/kong.yml#L27-L33 - Supabase docker/volumes/api/kong.yml (dashboard; eabe06be5b36cf57f2b158bd5093b396606bf801): https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/volumes/api/kong.yml#L454-L467 - [curl curl-8_14_1 write-out fields and version requirements (checked October 2026)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79). - [curl curl-8_14_1 headers from stdin (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md). - [curl curl-8_14_1 configuration from stdin (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - docs: Supabase documentation (unknown) - env: Supabase environment source (eabe06be5b36cf57f2b158bd5093b396606bf801) - compose: Supabase Compose source (e693f206f5050b0004a86e12e533bb75ba2a9c76) - compose-env: Supabase Compose source at environment commit (eabe06be5b36cf57f2b158bd5093b396606bf801) - kong: Supabase Kong configuration source (eabe06be5b36cf57f2b158bd5093b396606bf801) - curl: curl (curl-8_14_1) Claims (full source mapping in the JSON block below): - release (REASONED; docs, env, compose, compose-env): Guide version basis self-hosted/v0.8.1 has Envoy v1.39.1 and Auth v2.196.0 at e693f206; environment defaults at eabe06be have different Compose images (Envoy v1.39.0, Auth v2.189.0). Compare installed releases and overrides. - jwt-secrets (REASONED; docs, env): Replace demo JWT_SECRET and derived ANON_KEY/SERVICE_ROLE_KEY together; the public signing secret permits forged roles even after merely replacing API keys. - dashboard-secret (REASONED; docs, env): Replace shipped supabase / this_password_is_insecure_and_should_be_updated dashboard credentials before first start. - postgres-secret (REASONED; docs, env): Replace your-super-secret-and-long-postgres-password; initialized databases require utils/db-passwd.sh and service recreation, not an env-only edit. - s3-secrets (REASONED; docs, env): Replace both demo S3_PROTOCOL_ACCESS_KEY_ID and S3_PROTOCOL_ACCESS_KEY_SECRET. - encryption-secrets (REASONED; docs, env): Replace four example encryption keys to the lengths and purposes stated in .env.example: SECRET_KEY_BASE at least 64 for Realtime and Supavisor communications; REALTIME_DB_ENC_KEY exactly 16 for Realtime fields; VAULT_ENC_KEY exactly 32 for Supavisor configuration; PG_META_CRYPTO_KEY at least 32 for Studio connection strings. - key-generation (REASONED; docs): Use generate-keys.sh and add-new-auth-keys.sh, including JWT_KEYS/JWT_JWKS where present, and protect the resulting env file outside source control. - key-retirement (REASONED; docs): Recreate affected services; remove old signing keys from every verifier. Opaque API-key rotation alone does not retire JWT signing keys or user sessions. - gateway-port (REASONED; docs, compose): Default Envoy gateway publishes HTTP 8000 on every interface and fronts API services plus Studio; bind host publication to loopback or firewall it. - pooler-ports (REASONED; compose): Supavisor publishes 5432 and 6543 on every interface; unpublished db ports remain reachable from the Docker host. Isolate database access independently. - kong-port (REASONED; docs): Optional Kong additionally publishes HTTPS 8443; include that mapping in isolation checks. - network-check (REASONED; docs, compose): Docker NAT can bypass UFW and lack matching host sockets; inspect effective mappings and test IPv4/IPv6 externally with a working allowed-vantage control. - transport (REASONED; docs, compose): Default Envoy and Supavisor supply no TLS termination; HTTPS proxy protects the API, while PostgreSQL wire traffic needs its own TLS or encrypted tunnel. - egress (REASONED; docs): Constrain Edge Function and Storage URL-fetching egress; inbound JWT verification does not restrict outbound requests. - role-secret (REASONED; docs): service_role bypasses RLS but remains subject to SQL privileges; keep service credentials server-side and never ship them to clients. - anon-rls (REASONED; docs): Public anon access requires SQL privileges and applicable RLS policies; enabled RLS without a policy returns no rows, missing grants error, and disabled RLS does not constrain granted operations. - views (REASONED; docs): Enable RLS on every exposed table and review owner-privileged views that can disclose rows beyond caller policies. - gateway-routes (REASONED; docs, compose, env, compose-env, kong): The environment commit passes dashboard credentials to default Envoy; optional Kong configuration applies those credentials as Basic auth to its catch-all Studio route. Other gateway routes have distinct policies. - functions (REASONED; env, compose, compose-env): At the environment commit, FUNCTIONS_VERIFY_JWT=false maps to VERIFY_JWT in the Edge Functions main service, with per-function configuration marked TODO; individual functions may authenticate. - studio-port (REASONED; compose): Studio listens internally on 3000 without host publication; never route around gateway dashboard authentication. - signup (REASONED; env, compose, compose-env): At the environment commit, DISABLE_SIGNUP=false, ENABLE_PHONE_SIGNUP=true and ENABLE_PHONE_AUTOCONFIRM=true map to Auth settings; registration and phone auto-confirmation are enabled absent other controls. - mfa (REASONED; docs): Application-user TOTP needs assurance-level enforcement; it adds no factor to Studio Basic auth, which needs a private or MFA-enforcing administrative boundary. - verify-isolation (REASONED; docs, compose): Any external HTTP/database response proves publication; refusal is vantage-specific and timeout inconclusive. Corroborate with mappings and a live allowed control. - verify-function (REASONED; env, compose, curl, compose-env): Known unauthenticated canary responds to both requests with JWT checking off; with it on, missing Bearer gets 401 and current legacy anon JWT returns the known response. - verify-api-keys (REASONED; docs, curl): Compare shipped and current API keys at /auth/v1/settings; current key must return settings JSON. This tests gateway acceptance, not signing-key retirement. - verify-signing (REASONED; docs, curl): Compare equivalent unexpired service_role JWTs signed by current versus demo secret against one REST canary, retaining a valid apikey; require JWT signature rejection. - verify-dashboard (REASONED; docs, env, curl, compose-env, kong): The optional Kong configuration uses the example dashboard Basic credentials; compare rejection of that pair with a working current credential on a known Studio response. - verify-database (REASONED; docs, env, compose, compose-env): Check pooler 5432/6543 as postgres.your-tenant-id against postgres using SELECT current_user, current_database(); require demo-password authentication failure and a working control. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "bfdc42556a63914e42d11f1ca26273c73e1497bda656592bd1931fd0421769f1", "components": { "docs": { "name": "Supabase documentation", "basis": "unknown", "sources": { "s399d0fbba196": "https://supabase.com/docs/guides/self-hosting/docker", "s65d5b8043bad": "https://supabase.com/docs/guides/getting-started/api-keys", "s46cff2590323": "https://supabase.com/docs/guides/database/postgres/row-level-security" } }, "env": { "name": "Supabase environment source", "basis": "eabe06be5b36cf57f2b158bd5093b396606bf801", "sources": { "sb8e42c6de990": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example", "sed49ce6bde76": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L30-L36", "sc520e7d9db9f": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L57-L59", "sb241a3887d73": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L61-L75", "sb600d65cc47b": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L84-L89", "s7e03d1d4ecba": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L142-L145", "s8f596f22364d": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L174-L196", "s8d1a17b9f899": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/.env.example#L301-L305" } }, "compose": { "name": "Supabase Compose source", "basis": "e693f206f5050b0004a86e12e533bb75ba2a9c76", "sources": { "s3f1258414a91": "https://github.com/supabase/supabase/blob/e693f206f5050b0004a86e12e533bb75ba2a9c76/docker/docker-compose.yml", "sa2c33e4a4845": "https://github.com/supabase/supabase/blob/e693f206f5050b0004a86e12e533bb75ba2a9c76/docker/docker-compose.yml#L68-L72", "s6c3ca4c63967": "https://github.com/supabase/supabase/blob/e693f206f5050b0004a86e12e533bb75ba2a9c76/docker/docker-compose.yml#L109-L111" } }, "compose-env": { "name": "Supabase Compose source at environment commit", "basis": "eabe06be5b36cf57f2b158bd5093b396606bf801", "sources": { "saf9bb5a285e5": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L97-L106", "s17a0fbd83600": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L138-L177", "s960299690b0e": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L435-L472", "sd9608b0eac9b": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L532-L573", "s3eafc1cb4171": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L68-L72", "s083aa9fc8028": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/docker-compose.yml#L109-L111" } }, "kong": { "name": "Supabase Kong configuration source", "basis": "eabe06be5b36cf57f2b158bd5093b396606bf801", "sources": { "s09329687c791": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/volumes/api/kong.yml#L27-L33", "s4e5dfdd18d97": "https://github.com/supabase/supabase/blob/eabe06be5b36cf57f2b158bd5093b396606bf801/docker/volumes/api/kong.yml#L454-L467" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79", "s3db522258e44": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md", "s82a34ee18632": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md" } } }, "claims": { "release": {"text": "Guide version basis self-hosted/v0.8.1 has Envoy v1.39.1 and Auth v2.196.0 at e693f206; environment defaults at eabe06be have different Compose images (Envoy v1.39.0, Auth v2.189.0). Compare installed releases and overrides.", "components": ["docs", "env", "compose", "compose-env"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "compose:s3f1258414a91", "compose:sa2c33e4a4845", "compose:s6c3ca4c63967", "compose-env:s3eafc1cb4171", "compose-env:s083aa9fc8028"], "status": "REASONED"}, "jwt-secrets": {"text": "Replace demo JWT_SECRET and derived ANON_KEY/SERVICE_ROLE_KEY together; the public signing secret permits forged roles even after merely replacing API keys.", "components": ["docs", "env"], "sources": ["docs:s399d0fbba196", "docs:s65d5b8043bad", "env:sb8e42c6de990", "env:sed49ce6bde76"], "status": "REASONED"}, "dashboard-secret": {"text": "Replace shipped supabase / this_password_is_insecure_and_should_be_updated dashboard credentials before first start.", "components": ["docs", "env"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "env:sc520e7d9db9f"], "status": "REASONED"}, "postgres-secret": {"text": "Replace your-super-secret-and-long-postgres-password; initialized databases require utils/db-passwd.sh and service recreation, not an env-only edit.", "components": ["docs", "env"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "env:sed49ce6bde76"], "status": "REASONED"}, "s3-secrets": {"text": "Replace both demo S3_PROTOCOL_ACCESS_KEY_ID and S3_PROTOCOL_ACCESS_KEY_SECRET.", "components": ["docs", "env"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "env:sb600d65cc47b"], "status": "REASONED"}, "encryption-secrets": {"text": "Replace four example encryption keys to the lengths and purposes stated in .env.example: SECRET_KEY_BASE at least 64 for Realtime and Supavisor communications; REALTIME_DB_ENC_KEY exactly 16 for Realtime fields; VAULT_ENC_KEY exactly 32 for Supavisor configuration; PG_META_CRYPTO_KEY at least 32 for Studio connection strings.", "components": ["docs", "env"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "env:sb241a3887d73"], "status": "REASONED"}, "key-generation": {"text": "Use generate-keys.sh and add-new-auth-keys.sh, including JWT_KEYS/JWT_JWKS where present, and protect the resulting env file outside source control.", "components": ["docs"], "sources": ["docs:s399d0fbba196"], "status": "REASONED"}, "key-retirement": {"text": "Recreate affected services; remove old signing keys from every verifier. Opaque API-key rotation alone does not retire JWT signing keys or user sessions.", "components": ["docs"], "sources": ["docs:s399d0fbba196", "docs:s65d5b8043bad"], "status": "REASONED"}, "gateway-port": {"text": "Default Envoy gateway publishes HTTP 8000 on every interface and fronts API services plus Studio; bind host publication to loopback or firewall it.", "components": ["docs", "compose"], "sources": ["docs:s399d0fbba196", "compose:s3f1258414a91"], "status": "REASONED", "verify": [1]}, "pooler-ports": {"text": "Supavisor publishes 5432 and 6543 on every interface; unpublished db ports remain reachable from the Docker host. Isolate database access independently.", "components": ["compose"], "sources": ["compose:s3f1258414a91"], "status": "REASONED", "verify": [1]}, "kong-port": {"text": "Optional Kong additionally publishes HTTPS 8443; include that mapping in isolation checks.", "components": ["docs"], "sources": ["docs:s399d0fbba196"], "status": "REASONED"}, "network-check": {"text": "Docker NAT can bypass UFW and lack matching host sockets; inspect effective mappings and test IPv4/IPv6 externally with a working allowed-vantage control.", "components": ["docs", "compose"], "sources": ["docs:s399d0fbba196", "compose:s3f1258414a91"], "status": "REASONED"}, "transport": {"text": "Default Envoy and Supavisor supply no TLS termination; HTTPS proxy protects the API, while PostgreSQL wire traffic needs its own TLS or encrypted tunnel.", "components": ["docs", "compose"], "sources": ["docs:s399d0fbba196", "compose:s3f1258414a91"], "status": "REASONED"}, "egress": {"text": "Constrain Edge Function and Storage URL-fetching egress; inbound JWT verification does not restrict outbound requests.", "components": ["docs"], "sources": ["docs:s399d0fbba196"], "status": "REASONED"}, "role-secret": {"text": "service_role bypasses RLS but remains subject to SQL privileges; keep service credentials server-side and never ship them to clients.", "components": ["docs"], "sources": ["docs:s65d5b8043bad", "docs:s46cff2590323"], "status": "REASONED"}, "anon-rls": {"text": "Public anon access requires SQL privileges and applicable RLS policies; enabled RLS without a policy returns no rows, missing grants error, and disabled RLS does not constrain granted operations.", "components": ["docs"], "sources": ["docs:s65d5b8043bad", "docs:s46cff2590323"], "status": "REASONED"}, "views": {"text": "Enable RLS on every exposed table and review owner-privileged views that can disclose rows beyond caller policies.", "components": ["docs"], "sources": ["docs:s46cff2590323"], "status": "REASONED"}, "gateway-routes": {"text": "The environment commit passes dashboard credentials to default Envoy; optional Kong configuration applies those credentials as Basic auth to its catch-all Studio route. Other gateway routes have distinct policies.", "components": ["docs", "compose", "env", "compose-env", "kong"], "sources": ["docs:s399d0fbba196", "compose:s3f1258414a91", "env:sc520e7d9db9f", "compose-env:s3eafc1cb4171", "compose-env:saf9bb5a285e5", "kong:s09329687c791", "kong:s4e5dfdd18d97"], "status": "REASONED"}, "functions": {"text": "At the environment commit, FUNCTIONS_VERIFY_JWT=false maps to VERIFY_JWT in the Edge Functions main service, with per-function configuration marked TODO; individual functions may authenticate.", "components": ["env", "compose", "compose-env"], "sources": ["env:sb8e42c6de990", "compose:s3f1258414a91", "env:s8d1a17b9f899", "compose-env:s960299690b0e"], "status": "REASONED"}, "studio-port": {"text": "Studio listens internally on 3000 without host publication; never route around gateway dashboard authentication.", "components": ["compose"], "sources": ["compose:s3f1258414a91"], "status": "REASONED"}, "signup": {"text": "At the environment commit, DISABLE_SIGNUP=false, ENABLE_PHONE_SIGNUP=true and ENABLE_PHONE_AUTOCONFIRM=true map to Auth settings; registration and phone auto-confirmation are enabled absent other controls.", "components": ["env", "compose", "compose-env"], "sources": ["env:sb8e42c6de990", "compose:s3f1258414a91", "env:s8f596f22364d", "compose-env:s083aa9fc8028", "compose-env:s17a0fbd83600"], "status": "REASONED"}, "mfa": {"text": "Application-user TOTP needs assurance-level enforcement; it adds no factor to Studio Basic auth, which needs a private or MFA-enforcing administrative boundary.", "components": ["docs"], "sources": ["docs:s399d0fbba196", "docs:s46cff2590323"], "status": "REASONED"}, "verify-isolation": {"text": "Any external HTTP/database response proves publication; refusal is vantage-specific and timeout inconclusive. Corroborate with mappings and a live allowed control.", "components": ["docs", "compose"], "sources": ["docs:s399d0fbba196", "compose:s3f1258414a91"], "status": "REASONED"}, "verify-function": {"text": "Known unauthenticated canary responds to both requests with JWT checking off; with it on, missing Bearer gets 401 and current legacy anon JWT returns the known response.", "components": ["env", "compose", "curl", "compose-env"], "sources": ["env:sb8e42c6de990", "compose:s3f1258414a91", "curl:s5e1383feef7d", "curl:s3db522258e44", "env:s8d1a17b9f899", "compose-env:s960299690b0e"], "status": "REASONED", "verify": [2]}, "verify-api-keys": {"text": "Compare shipped and current API keys at /auth/v1/settings; current key must return settings JSON. This tests gateway acceptance, not signing-key retirement.", "components": ["docs", "curl"], "sources": ["docs:s399d0fbba196", "docs:s65d5b8043bad", "curl:s3db522258e44"], "status": "REASONED"}, "verify-signing": {"text": "Compare equivalent unexpired service_role JWTs signed by current versus demo secret against one REST canary, retaining a valid apikey; require JWT signature rejection.", "components": ["docs", "curl"], "sources": ["docs:s399d0fbba196", "docs:s65d5b8043bad", "docs:s46cff2590323", "curl:s3db522258e44"], "status": "REASONED"}, "verify-dashboard": {"text": "The optional Kong configuration uses the example dashboard Basic credentials; compare rejection of that pair with a working current credential on a known Studio response.", "components": ["docs", "env", "curl", "compose-env", "kong"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "curl:s82a34ee18632", "env:sc520e7d9db9f", "compose-env:s3eafc1cb4171", "compose-env:saf9bb5a285e5", "kong:s09329687c791", "kong:s4e5dfdd18d97"], "status": "REASONED"}, "verify-database": {"text": "Check pooler 5432/6543 as postgres.your-tenant-id against postgres using SELECT current_user, current_database(); require demo-password authentication failure and a working control.", "components": ["docs", "env", "compose", "compose-env"], "sources": ["docs:s399d0fbba196", "env:sb8e42c6de990", "compose:s3f1258414a91", "env:sed49ce6bde76", "env:s7e03d1d4ecba", "compose-env:sd9608b0eac9b"], "status": "REASONED"} } } ```` ====================================================================== ==> web-exposure.md ====================================================================== # Files a web server must never serve: dotfiles, .git, dumps, backups, and client secrets Web exposure means files a website serves by mistake, like configuration files, source history, database dumps and backups. This guide helps your AI assistant make sure nothing sensitive is served, including deny rules in nginx, Apache and Caddy, backups kept outside served folders, and no secrets built into browser code. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/web-exposure.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against nginx documentation (no recorded version), Apache documentation 2.4, Caddy documentation v2.11.4, Next.js browser source maps v16.3.6, Vite production source maps v8.3.1, Debian apache2 packaging 2.4.69-1, ACME HTTP-01 RFC 8555, and GNU grep 3.12; 1 further source is pinned to a commit and 5 carry no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 26 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Scanners request `/.env`, `/.git/config`, `/config.php.bak`, and `/db.sql` continuously, and any of these under a web root hands over credentials or source regardless of what the application itself authenticates. Anything a deploy step leaves inside the document root is served too, unless the server is told otherwise. ## nginx Deny dotfiles by regex location, with the ACME challenge path carved out first, because `.well-known` also starts with a dot. Also deny common backup and dump suffixes (case-insensitively): ```nginx location ^~ /.well-known/acme-challenge/ { allow all; } location ~ /\. { deny all; } location ~* (\.(bak|old|orig|sql(\.gz)?|dump|tar\.gz)|~)$ { deny all; } ``` Once nginx picks the `^~` location as the longest matching prefix, it skips regex locations entirely, so the challenge path is served before the dotfile deny is reached (`allow`/`deny`: `ngx_http_access_module`; `location` order and `^~`: `ngx_http_core_module`). Outside that challenge prefix, these rules deny dot-prefixed path segments and paths ending in `.bak`, `.old`, `.orig`, `~`, `.sql`, `.sql.gz`, `.dump`, or `.tar.gz`. Put these regex locations before other regex locations that could serve the same paths; an exact location or another `^~` prefix can bypass them. Keep the challenge directory free of secrets. Unlike the Apache and Caddy configs below, which exempt all of `/.well-known/`, this config exempts only the ACME challenge prefix, so `/.well-known/security.txt` is denied; if you serve that file, add `location = /.well-known/security.txt {}`, because nginx selects an exact match before it checks regex locations. ## Apache ```apache Require all denied Require all denied Options -Indexes ``` `` matches the request's basename, not its full path, so it alone does not catch `/.git/config`: the matched name is `config`, which does not start with a dot (per the core module documentation). The `` rule above matches any dot-prefixed path segment (`.git`, `.svn`, and similar) as a substring of the filesystem path, because Apache's regex is not anchored unless the pattern itself anchors it (per the core module documentation). A bare `(?!well-known)` lookahead only rules out that literal substring, so it would still allow a directory that merely starts with "well-known", such as `/.well-known-backup/config`. The `(?:/|$)` boundary requires the exempted segment to be `well-known` exactly, ending at a slash or the path's end, so only a genuine `.well-known/` path is exempt: the whole directory (acme-challenge, and anything else legitimately under it such as `security.txt`), while a lookalike segment like `.well-known-backup` is not. Keep the `` rule as a case-insensitive backstop for the same backup and dump suffixes as nginx, and for dotfile basenames like `/.env`. The suffix rule still applies inside `.well-known/`. One default weakens this. Debian apache2 2.4.69-1's `apache2.conf` template sets `Options Indexes FollowSymLinks` on ``; `dir.conf` contains `DirectoryIndex index.html index.cgi index.pl index.php index.xhtml index.htm`, and `postinst` enables `dir` and `autoindex` on fresh installs (pinned Debian packaging in Sources). With these defaults, when a directory below the document root has no `DirectoryIndex` file, `mod_autoindex` returns a browsable listing of its contents, per the Apache core `Options` and mod_autoindex references. The earlier assertion of the same defaults on Ubuntu remains unchecked: this guide did not check Ubuntu's packaging. The deny rules above still hold, and the listing even omits the files they forbid, because `mod_autoindex` hides an entry whose subrequest returns 403 unless `IndexOptions ShowForbidden` is set. This guide's assessment of the exposure is everything else in the directory: a backup or export the deny rules do not match by name, an `archive.zip`, a `customers.csv`, or a datestamped dump, is listed for anyone who requests the directory, and the listing itself confirms the directory and reveals filenames you were relying on nobody guessing. Turn listing off with `Options -Indexes`, shown above; write it in the relative `-` form so it removes only `Indexes` and keeps the inherited `FollowSymLinks`, and never mix `+`/`-` options with bare ones in a single `Options` line, which Apache 2.4 rejects at startup. The web servers nginx (`autoindex` is `off` by default) and Caddy (`file_server` lists only with `browse`) do not need this; Apache with these Debian defaults does; this guide advises checking Ubuntu's effective configuration because its packaging was not checked here. ## Caddy Matcher syntax checked against Caddy v2.11.4 source and the matcher reference; server execution was not run in the authoring environment. Put these directives in the site's Caddyfile block: ```caddyfile @dotfiles { path_regexp dotfiles /\. not path /.well-known/* } respond @dotfiles 404 @backups path *.bak *.old *.orig *~ *.sql *.sql.gz *.dump *.tar.gz respond @backups 404 ``` `path_regexp` uses Go regular expressions on the decoded path: `/\.` catches a dot-prefixed segment at any depth, including `/.git`, `/.git/config`, `/.env`, and `/nested/.env`. Matchers within a named set are ANDed, so `not path` exempts the root `/.well-known/` subtree from the dotfile rule, but not `/.well-known-backup/`. Keep that public subtree free of secrets. The separate `path` matcher ORs its case-insensitive suffix patterns and denies backups and dumps even under `/.well-known/`. Thus `/config.php.bak` and `/db.sql` are denied too. Caddy serves its own ACME challenges outside the file server. These filename rules cannot recognize every secret or backup; keep them out of the served directory even when their names match none of these patterns. ## Keep dumps and backups out of the served directory `.sql`, `.dump`, and backup archives should never land inside a directory a web root points at; the deny rules above are a backstop, not the control. Write exports and backups outside the document root, or to object storage ([object-storage.md](https://github.com/jposluns/secureconfig/blob/main/object-storage.md)), never `/var/www/html`. These rules also block intentionally public files with these suffixes, including release archives. Narrow the suffix policy for your site and verify that intended downloads remain accessible. ## Client bundles: secrets compiled into the browser `NEXT_PUBLIC_`-prefixed variables in Next.js and `VITE_`-prefixed variables in Vite are inlined into the browser JavaScript at build time; Create React App's `REACT_APP_` prefix does the same (Create React App is deprecated as of this writing, but the convention persists in older projects). An LLM provider key pasted into frontend code under one of these prefixes ships to every visitor's browser, not just your server. Only genuinely public values belong behind these prefixes; call the provider from a backend route and keep the key server-side. See [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) and [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md) for the platform version of this. ECMA-426 defines `sources` as original-source URLs (or null) and `sourcesContent` as optional original source strings, with null entries allowed for sources retrieved by name. This guide's assessment is that a production source map can expose original source when it embeds that content or its sources are retrievable. A map may be a separate `.map` file; a `//# sourceMappingURL` comment links to a map URL, including a `data:` URL for an inline map (pinned ECMA-426 fields and linking passages in Sources). Next.js v16.3.6 disables production browser source maps unless `productionBrowserSourceMaps` is enabled; when enabled, maps are output beside the JavaScript files and automatically served on request. Vite v8.3.1 `build.sourcemap` defaults to `false` for production; `true` creates a separate map, `'inline'` appends a data URI, and `'hidden'` creates a separate map while suppressing the bundle's map comment. These defaults cover Next.js browser maps and Vite production maps only; this guide did not check Create React App's source-map defaults. This guide's reasoning: removing the comment does not unpublish a `.map` you already deployed, because removing a reference does not remove the served file. This guide's advice: do not ship a source map, or leave one served, for code not already meant to be public. ## Verify REASONED: following block; planted-file denial, directory listing and ACME/lookalike paths; the client-bundle scan has its own block after it. No configured web-server deployment or run outcome is recorded here; expectations are reasoned from the guide and its cited server and framework sources. The block exits 1 when a probe returns `PLANTED-SECRET` or a 2xx, `/uploads/` answers 200, the challenge token answers 403, or `/.well-known-backup/config` answers a 2xx while a real file is there. Otherwise it exits 3 when a step is inconclusive or a removal fails, including a bare 404 from that lookalike path with no file behind it. If neither applies, it exits 2 when the document root has no `.well-known/acme-challenge` directory, as with a server that answers challenges itself, so the challenge-token checks did not run, and 0 when every classified probe passes. ```bash # Plant a real file at a denied dotfile path so a 403/404 proves the RULE fired, not that the file is # absent (Caddy's `respond ... 404` is 404 BY DESIGN, and /.env may simply not exist). Put THIS host's real # document root (nginx `root` / Apache DocumentRoot) on the set -- line; the check is INCONCLUSIVE unless the plant # lands. mktemp creates the probe exclusively so it never clobbers a real file, and only that file is # removed. --noproxy so no client proxy answers; the body is shown so leaked content is visible: ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a probe that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_YOUR_DOCUMENT_ROOT' # this host's served host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then its document root as an absolute path: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|[!/]*|*[[:cntrl:]]*) echo "substitute this host's document root, an absolute path, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac denied_probes() ( # the host, then each path: every response must be 403 or 404 without PLANTED-SECRET set -- "$1" 0 "${@:2}" # the host, these probes' status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the paths still to probe while [ "$#" -gt 2 ]; do set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -w " <= %{http_code} $3\n" "https://$1$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" "${@:4}" set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" "${@:5}" # the host, the status so far, the path, what curl printed, curl's exit status, then the paths still to probe printf '%s' "$4" case "$5:$4" in 0:*PLANTED-SECRET*) echo "FINDING: $3 returned PLANTED-SECRET"; set -- "$1" 1 "${@:3}" ;; 0:*' <= 403 '*|0:*' <= 404 '*) ;; 0:*' <= 2'??' '*) echo "FINDING: $3 answered with a 2xx, so the deny rule did not fire"; set -- "$1" 1 "${@:3}" ;; 0:*) echo "inconclusive: $3 answered neither 403 nor 404"; [ "$2" = 1 ] || set -- "$1" 3 "${@:3}" ;; *) echo "inconclusive: curl exit $5 for $3"; [ "$2" = 1 ] || set -- "$1" 3 "${@:3}" ;; esac set -- "$1" "$2" "${@:6}" done exit "$2" ) acme_probe() ( # the host, the scheme, then the token's name: the response must be probe, never 403 set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -w '\n%{http_code}' "$2://$1/.well-known/acme-challenge/$3"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # the host, the scheme, the name, curl's exit status, then the body and HTTP code set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # ..., curl's exit status, the body, then the HTTP code printf '%s\n' "$5" case "$4:$6:$5" in 0:200:probe) exit 0 ;; 0:403:*) echo "FINDING: the challenge token answered 403 over $2, so the deny rules block the ACME exemption"; exit 1 ;; 0:*) echo "inconclusive: the challenge token answered $6 over $2 without the body probe"; exit 3 ;; *) echo "inconclusive: curl exit $4 for the challenge token over $2"; exit 3 ;; esac ) set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 2 (the challenge-token checks refused), which outranks 0; the block exits with it after its cleanup set -- "$1" "$2" "$3" "$(if sudo mktemp "$2/.env.probeXXXXXX" 2>/dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%%$'\n'*}" "${4##*$'\n'}" # the host, the document root, the status so far, the probe's path, then mktemp's exit status if [ "$5" = 0 ]; then # mktemp makes it root-only (0600); chmod so the web-server worker can READ it, or its 403 is a FILE # PERMISSION denial (not the deny rule) and the check would false-pass with no rule configured: if printf 'PLANTED-SECRET' | sudo tee "$4" >/dev/null && sudo chmod 0644 "$4"; then denied_probes "$1" "/${4##*/}" /.git/config /config.php.bak /db.sql case "$?:$3" in 0:*) ;; 1:*) set -- "$1" "$2" 1 "$4" ;; *:1) ;; *) set -- "$1" "$2" 3 "$4" ;; esac # fold those probes' status into the block's, ranked as above # every line must be 403 or 404 and must NOT print PLANTED-SECRET. The .env.probe* line is the POSITIVE # CONTROL (a world-readable real file under the deny rules), so its 403/404 is the rule, not permissions # or absence; a bare 404 on the others could be absence, so plant one at each to be sure else echo "inconclusive: could not prepare the probe file" [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4" fi sudo rm -f "$4" || { [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4"; } else echo "inconclusive: could not create a probe under $2; point it at the writable document root" set -- "$1" "$2" 3 fi set -- "$1" "$2" "$3" # uploads/backups directory: an existing, non-empty, index-less dir must NOT return a 200 autoindex under # Options -Indexes. Plant a throwaway file so a listing would actually reveal something; inconclusive if the # directory does not exist: set -- "$1" "$2" "$3" "$(if sudo mktemp "$2/uploads/listcheckXXXXXX" 2>/dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%%$'\n'*}" "${4##*$'\n'}" # ..., the throwaway file's path, then mktemp's exit status if [ "$5" = 0 ]; then set -- "$1" "$2" "$3" "$4" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' "https://$1/uploads/"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "${5##*$'\n'}" "${5%%$'\n'*}" # ..., curl's exit status, then the HTTP code printf '%s\n' "$6" # 403/404, never a 200 listing case "$5:$6" in 0:403|0:404) ;; 0:200) echo "FINDING: /uploads/ answered 200, where an index-less directory must give 403 or 404; this check discards the body, so it did not see whether the 200 is a listing"; set -- "$1" "$2" 1 "$4" ;; 0:*) echo "inconclusive: /uploads/ answered $6, neither 403 or 404 nor a 200 listing"; [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4" ;; *) echo "inconclusive: curl exit $5 for /uploads/"; [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4" ;; esac sudo rm -f "$4" || { [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4"; } else echo "inconclusive: $2/uploads does not exist or is not writable" [ "$3" = 1 ] || set -- "$1" "$2" 3 fi set -- "$1" "$2" "$3" if sudo test -d "$2/.well-known/acme-challenge"; then # a guard: the challenge-token checks rely on a webroot challenge directory, which a server that answers challenges itself, such as Caddy, does not have set -- "$1" "$2" "$3" "$(if sudo mktemp "$2/.well-known/acme-challenge/probeXXXXXX" 2>/dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%%$'\n'*}" "${4##*$'\n'}" # ..., the token's path, then mktemp's exit status if [ "$5" = 0 ]; then if printf 'probe' | sudo tee "$4" >/dev/null && sudo chmod 0644 "$4"; then # readable, or a 403 is a permission denial not the exemption acme_probe "$1" https "${4##*/}" # must return "probe", never 403: the exemption lets a real challenge file through (HTTPS webroot access) case "$?:$3" in 0:*) ;; 1:*) set -- "$1" "$2" 1 "$4" ;; *:1) ;; *) set -- "$1" "$2" 3 "$4" ;; esac acme_probe "$1" http "${4##*/}" # ACME HTTP-01 runs over HTTP:80, so fetch the SAME token over http to confirm that route case "$?:$3" in 0:*) ;; 1:*) set -- "$1" "$2" 1 "$4" ;; *:1) ;; *) set -- "$1" "$2" 3 "$4" ;; esac else echo "inconclusive: could not prepare the challenge file" [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4" fi sudo rm -f "$4" || { [ "$3" = 1 ] || set -- "$1" "$2" 3 "$4"; } else echo "inconclusive: could not create a challenge token under $2/.well-known/acme-challenge" [ "$3" = 1 ] || set -- "$1" "$2" 3 fi else echo "sudo test -d found no directory at $2/.well-known/acme-challenge, or sudo failed, so the challenge-token checks did not run; not probing the challenge token" [ "$3" = 0 ] && set -- "$1" "$2" 2 fi set -- "$1" "$2" "$3" set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' -o /dev/null -w '%{http_code}\n' "https://$1/.well-known-backup/config"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%%$'\n'*}" # ..., curl's exit status, then the HTTP code printf '%s\n' "$5" # must be 403 or 404: a directory that only starts with "well-known" must not inherit the exemption. A bare # 404 is inconclusive here (nothing is there); create a real .well-known-backup/config to be certain a # served 200 would be the finding, then remove it case "$4:$5" in 0:403) ;; 0:404|0:2??) if sudo test -f "$2/.well-known-backup/config"; then case "$5" in 404) ;; *) echo "FINDING: /.well-known-backup/config answered $5, so the lookalike directory inherited the exemption"; set -- "$1" "$2" 1 ;; esac else echo "inconclusive: sudo test -f found no file at $2/.well-known-backup/config, or sudo failed, so its $5 does not show whether the rule fired"; [ "$3" = 1 ] || set -- "$1" "$2" 3 fi ;; 0:*) echo "inconclusive: /.well-known-backup/config answered $5, neither 403 nor 404"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; *) echo "inconclusive: curl exit $4 for /.well-known-backup/config"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac echo "block exit $3; the response bodies, and a 404 from /.git/config, /config.php.bak or /db.sql, where nothing was planted, are not classified, so judge them yourself" exit "$3" # after every cleanup attempt above: 1 if a probe found a planted secret, a listing or a 403 on the challenge token, else 3 if any step was inconclusive or a removal failed, else 2 if the challenge-token checks did not run, else 0 ) ``` ```bash # Client-bundle scan per the guide's cited Next.js environment variables and GNU grep 3.12 exit status and error handling; no built bundle was scanned here, and no deployment outcome is recorded. ( # a subshell, so the exit below ends only this check set -- 0 0 .next/static build dist # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), whether any directory was scanned, then the directories still to scan while [ "$#" -gt 2 ]; do if [ -d "$3" ]; then set -- "$1" 1 "${@:3}" if grep -rn "sk-\|AKIA\|ghp_\|sb_secret_\|-----BEGIN" "$3"; then set -- "$1" "$2" "$3" 0 "${@:4}"; else set -- "$1" "$2" "$3" "$?" "${@:4}"; fi; echo "$3 exit: $4" case "$4" in 0) set -- 1 "${@:2}" ;; # a match is a finding, and it outranks an error 1) ;; *) [ "$1" = 1 ] || set -- 3 "${@:2}" ;; # a grep error is inconclusive esac set -- "$1" "$2" "${@:5}" else echo "$3: absent, nothing scanned" set -- "$1" "$2" "${@:4}" fi done [ "$2" = 1 ] || { echo "none of .next/static, build or dist is here, so nothing was scanned; run this from the directory that holds the build output"; set -- 2 "$2"; } # a guard refusal: where the block was run is the reader's setup exit "$1" ) # run against the built client bundle, not the source. Exit 1 with no output is clean, exit 0 # is a match, and exit 2 is a grep failure that is NOT clean. Do not send the errors to # /dev/null and read silence as a pass: a missing directory is an error (exit 2), and # discarding its error message leaves silence, which looks exactly like success. A clean result is evidence, not proof: # a bundler can split or encode a value, so scan for the literal secret as well # The bundle scan above exits 1 when any directory matched, 3 when a grep failed, 2 when no directory # existed, so nothing was scanned, and 0 otherwise. ``` REASONED: preceding block; client-bundle scan, per the guide's cited Next.js environment variables and GNU grep 3.12 exit status and error handling, with the dotfile/backup denial, listing and ACME/lookalike checks of the block above it, per the cited nginx core module, nginx access module, Apache core Options, Caddy matchers and RFC 8555 section 8.3 HTTP-01 validation. No web-server deployment is available and local listeners are denied. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; the block with a scratch document root and curl stub exited 0, printed eight STUB curl lines and removed its planted files. Those runs covered the planted-file checks and the bundle scan as one block, before its host guard was added, and were not repeated on the guarded block. That earlier form ended its result arms with status 0. Those runs were not repeated on the form after the guarded block either, in which an inconclusive step or a failed removal exits 3 after its cleanup is attempted and a bundle match exits 1. On the form in which the two checks became separate blocks, the planted-file block takes its document root on its `set --` line and exits 1 on a finding, and both keep their values in positional parameters, bash -n in Bash 5.3.9 exited 0 and stub runs without network access checked its exit statuses. `tools/run_all_checks.sh` ran ShellCheck 0.11.0 over that form with no findings; no server run is recorded, and no demonstration was re-run. For the planted-file block, those runs covered the form whose reserved-name check lowercased the host with `tr`. Its current form, which matches reserved names in any letter case through bash's `nocasematch` option, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by November 2026 stub runs under bash 5.3.9 with curl stubbed, sudo replaced by a pass-through, a scratch document root and no network. In those runs eight reserved names in lower, upper and mixed case, bare and as subdomains, each exited 2 with the reserved-name message before any plant or request; two reserved names in upper or mixed case also did with `tr` absent from PATH, and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and a host outside those names reached the plants and requests. Under dash, which lacks bash's `shopt`, stub runs of the current form refused reserved and other host names alike with status 2 and its message that the block needs bash, before any plant or request. Real grep returned 1 silently for clean input, 0 with a matching fixture line and 2 with No such file or directory for missing input. Exposed servers disclose planted content or listings; fixed servers deny them, still serve challenge tokens and keep secrets out of bundles. HTTP outcomes remain unobserved. Any backup path known to have existed on the server should also return 403 or 404, without the file contents, at the deployed URL. REASONED: following block; suffix and dotfile denial, per the guide's cited GNU coreutils mktemp, nginx access module, Apache mod_authz_core and Caddy matchers. No web-server deployment is available and local listeners are denied. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics. With a scratch document root and curl stub the block exited 0, printed nine STUB curl lines and removed the plants; independent fixtures confirmed all nine suffix/dotfile cases held PLANTED-SECRET at mode 0644. Those runs covered the block before its host guard was added and were not repeated on the guarded block. That earlier form ended its inconclusive arms with status 0. Those runs were not repeated on the form after the guarded block either, which exits 3 after attempting its cleanup when a plant or curl transfer was inconclusive or a removal failed. On the form after that, which also exits 1 when a probe returns PLANTED-SECRET, takes the document root on its `set --` line and keeps its values in positional parameters, bash -n in Bash 5.3.9 exited 0 and stub runs without network access checked its exit statuses. `tools/run_all_checks.sh` ran ShellCheck 0.11.0 over that form with no findings; no server run is recorded, and no demonstration was re-run. Those runs covered that form while its reserved-name check lowercased the host with `tr`. The current form, which matches reserved names in any letter case through bash's `nocasematch` option, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by November 2026 stub runs under bash 5.3.9 with curl stubbed, sudo replaced by a pass-through, a scratch document root and no network. In those runs eight reserved names in lower, upper and mixed case, bare and as subdomains, each exited 2 with the reserved-name message before any plant or request; two reserved names in upper or mixed case also did with `tr` absent from PATH, and under an inherited `nocasematch`, readonly and `declare -i` variables, or `set -e`, and a host outside those names reached the plants and requests. Under dash, which lacks bash's `shopt`, stub runs of the current form refused reserved and other host names alike with status 2 and its message that the block needs bash, before any plant or request. Live exposed 200 responses with that body and fixed 403/404 responses without it remain unobserved. Also plant a readable file for each suffix and a generic dotfile, so the added classes are tested against real content. Use GNU `mktemp`, and put this host's served host name and its document root on the block's `set --` line. On an isolated test host, run before and after enabling the deny rules: the exposed static server should return 200 with `PLANTED-SECRET`; the fixed server must return 403 or 404 without that body. A failed plant or curl transfer is inconclusive, and so is a 200 without that body or any other code. The block attempts to remove every plant, and it exits 1 when a probe returned `PLANTED-SECRET`, otherwise 3 when a step was inconclusive or a removal failed, and otherwise 0. ```bash # The following probes were not run against a server: no Caddy, nginx, Apache, or container # runtime is available, and network restrictions prevent installing one. Expected exposed/fixed # outcomes follow the Caddy path/path_regexp, nginx location/deny, and Apache FilesMatch/Require # references in Sources. Local shell syntax and GNU mktemp suffix creation were checked only, on the # block before its host guard was added. That form's inconclusive arms ended with status 0; the later # forms, which exit 3 after attempting their cleanup when a step is inconclusive or a removal fails, # were not re-checked, except that bash -n, stub runs and the ShellCheck 0.11.0 run in # tools/run_all_checks.sh, which had no findings, checked the one that also exits 1 on PLANTED-SECRET # while its host check lowercased with tr. This one, matching reserved names through nocasematch and # refusing with status 2 outside bash, is covered by that suite's bash -n and ShellCheck 0.11.0 passes # and by later stub runs. ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a plant that ends nonzero is folded into the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_HOST' 'REPLACE_WITH_YOUR_DOCUMENT_ROOT' # this host's served host name, no scheme, user name, port, path, query, fragment, percent sign or trailing dot, then its document root as an absolute path: replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your public host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "$1" in example.com|example.net|example.org|*.example.com|*.example.net|*.example.org) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own host name on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again case "$2" in ""|*REPLACE_WITH_*|[!/]*|*[[:cntrl:]]*) echo "substitute this host's document root, an absolute path, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac plant_probe() ( # the host, the document root, then one suffix ('' for the generic dotfile): plant, probe, then remove set -- "$1" "$2" "$3" "$2/probeXXXXXX" [ -n "$3" ] || set -- "$1" "$2" "$3" "$2/.probeXXXXXX" set -- "$1" "$2" "$3" "$(if sudo mktemp --suffix="$3" "$4" 2>/dev/null; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4%%$'\n'*}" "${4##*$'\n'}" # the host, the document root, the suffix, the plant's path, then mktemp's exit status [ "$5" = 0 ] || { echo "inconclusive: could not plant suffix '$3' under $2"; exit 3; } set -- "$1" "$2" "$3" "$4" 0 # the 5th value is this plant's status so far, ranked as below if printf 'PLANTED-SECRET' | sudo tee "$4" >/dev/null && sudo chmod 0644 "$4"; then set -- "$1" "$2" "$3" "$4" "$5" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w " <= %{http_code} /${4##*/}\n" "https://$1/${4##*/}"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "$4" "$5" "${6%$'\n'*}" "${6##*$'\n'}" # ..., what curl printed, then curl's exit status printf '%s' "$6" # Fixed: 403/404 and no PLANTED-SECRET. Exposed: 200 and PLANTED-SECRET. case "$7:$6" in 0:*PLANTED-SECRET*) echo "FINDING: /${4##*/} returned PLANTED-SECRET"; set -- "$1" "$2" "$3" "$4" 1 ;; 0:*' <= 403 '*|0:*' <= 404 '*) ;; 0:*) echo "inconclusive: /${4##*/} answered without PLANTED-SECRET but with neither 403 nor 404"; set -- "$1" "$2" "$3" "$4" 3 ;; *) echo "inconclusive: curl failed"; set -- "$1" "$2" "$3" "$4" 3 ;; esac else echo "inconclusive: could not prepare $4" set -- "$1" "$2" "$3" "$4" 3 fi sudo rm -f "$4" || { [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3; } exit "$5" ) set -- "$1" "$2" 0 '' .bak .old .orig '~' .sql .sql.gz .dump .tar.gz # the host, the document root, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the suffixes still to plant while [ "$#" -gt 3 ]; do plant_probe "$1" "$2" "$4" case "$?:$3" in 0:*) ;; 1:*) set -- "$1" "$2" 1 "${@:4}" ;; *:1) ;; *) set -- "$1" "$2" 3 "${@:4}" ;; esac # fold that plant's status into the block's, ranked as above set -- "$1" "$2" "$3" "${@:5}" done echo "block exit $3" exit "$3" # after every removal was attempted: 1 if a probe returned PLANTED-SECRET, else 3 if any step was inconclusive or a removal failed, else 0 ) ``` ## Sources (checked September 2026) - nginx core module (`location`, `^~` modifier, matching order): https://nginx.org/en/docs/http/ngx_http_core_module.html - nginx access module (`allow`, `deny`): https://nginx.org/en/docs/http/ngx_http_access_module.html - Apache mod_authz_core (`Require all denied`): https://httpd.apache.org/docs/2.4/mod/mod_authz_core.html - Apache core module (``, ``): https://httpd.apache.org/docs/2.4/mod/core.html#filesmatch, https://httpd.apache.org/docs/2.4/mod/core.html#directorymatch - Apache core `Options` (the `Indexes` option triggers a listing when there is no `DirectoryIndex`; the `-Indexes` relative form and the +/- merge rules): https://httpd.apache.org/docs/2.4/mod/core.html#options - Apache mod_autoindex (the generated listing, and `ShowForbidden`, which by default hides entries a subrequest forbids): https://httpd.apache.org/docs/2.4/mod/mod_autoindex.html - nginx autoindex module (`autoindex` is `off` by default): https://nginx.org/en/docs/http/ngx_http_autoindex_module.html - Caddy `respond` directive (rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/directives/respond - Caddy matchers, `path`, `path_regexp`, `not` and named matcher sets (rolling documentation, checked September 2026): https://caddyserver.com/docs/caddyfile/matchers - Caddy v2.11.4 matcher implementation (`MatchPath`, `MatchPathRE`, `MatchNot`): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/matchers.go - GNU coreutils `mktemp` (`--suffix`): https://www.gnu.org/software/coreutils/manual/html_node/mktemp-invocation.html - Next.js environment variables (`NEXT_PUBLIC_`): https://nextjs.org/docs/pages/guides/environment-variables - Vite env variables (`VITE_`): https://vite.dev/guide/env-and-mode - Create React App environment variables (`REACT_APP_`, deprecation notice): https://create-react-app.dev/docs/adding-custom-environment-variables/ - Caddy v2.11.4 file listings require the `browse` configuration (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/fileserver/staticfiles.go#L140-L142 ; https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/fileserver/staticfiles.go#L371-L374 - Caddy v2.11.4 ACME HTTP challenges precede user-defined handlers (checked October 2026): https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/server.go#L384-L388 - RFC 8555 section 8.3 HTTP-01 validation (checked October 2026): https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3 - GNU grep 3.12 manual, Exit Status, lines 1110-1115 (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1110 - GNU grep 3.12 manual, `-s`/`--no-messages`, lines 425-431 (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n425 - GNU grep 3.12 source, failed input open, lines 1756-1772 (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/src/grep.c?h=v3.12#n1756 - GNU grep 3.12 source, input error diagnosis, lines 661-668 (checked October 2026): https://git.savannah.gnu.org/cgit/grep.git/tree/src/grep.c?h=v3.12#n661 - Debian apache2 packaging version 2.4.69-1 (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1 - Debian apache2 2.4.69-1 document-root Options template (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/apache2.conf.in#L170-L174 - Debian apache2 2.4.69-1 DirectoryIndex list (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/mods-available/dir.conf#L1 - Debian apache2 2.4.69-1 default modules on fresh installs and configure invocation (checked October 2026): https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.postinst#L38-L52 ; https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.postinst#L257-L266 - ECMA-426 c724f0f494b44b21b651fdbd6412d3437eef0e72 sources and optional sourcesContent (checked October 2026): https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L584-L585 - ECMA-426 c724f0f494b44b21b651fdbd6412d3437eef0e72 data URI linking and sourceMappingURL comments (checked October 2026). ECMA-426 c724f0f494b44b21b651fdbd6412d3437eef0e72 data URI linking and sourceMappingURL comment sources: https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L1294 ; https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L1323 ; https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L1415 - Next.js v16.3.6 production browser source maps default and opt-in serving (checked October 2026): https://github.com/vercel/next.js/blob/v16.3.6/docs/01-app/03-api-reference/05-config/01-next-config-js/productionBrowserSourceMaps.mdx#L8-L18 - Vite v8.3.1 build.sourcemap default and output modes (checked October 2026): https://github.com/vitejs/vite/blob/v8.3.1/docs/config/build-options.md#L145-L150 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - nginx: nginx documentation (unknown) - apache: Apache documentation (2.4) - caddy: Caddy documentation (v2.11.4) - caddy-rolling: Caddy documentation (rolling) (unknown) - next: Next.js documentation (unknown) - vite: Vite documentation (unknown) - cra: Create React App documentation (unknown) - next-maps: Next.js browser source maps (v16.3.6) - vite-maps: Vite production source maps (v8.3.1) - debian-packaging: Debian apache2 packaging (2.4.69-1) - acme-http01: ACME HTTP-01 (RFC 8555) - ecma426: ECMA-426 (c724f0f494b44b21b651fdbd6412d3437eef0e72) - coreutils: GNU coreutils documentation (unknown) - grep: GNU grep (3.12) Claims (full source mapping in the JSON block below): - nginx-dotfiles (REASONED; nginx): Deny dot-prefixed path segments by regex; place deny regex locations before other matching regex locations. Exact locations or a longest matching ^~ prefix can bypass them. - nginx-backups (REASONED; nginx): Case-insensitive suffix regex denies .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz outside the ACME exemption, subject to location precedence. - nginx-acme (REASONED; nginx): Only /.well-known/acme-challenge/ is exempted: when its ^~ location is the longest matching prefix, it skips both deny regexes. Keep the challenge directory free of secrets. - nginx-security-txt (REASONED; nginx): The ACME-only exemption leaves /.well-known/security.txt denied; add location = /.well-known/security.txt {} to serve it, because exact matches precede regex locations. - apache-directories (REASONED; apache): DirectoryMatch denies dot-prefixed filesystem segments except an exact .well-known segment; .well-known-backup is not exempt. FilesMatch suffix denial still applies inside .well-known/. - apache-files (REASONED; apache): Case-insensitive FilesMatch denies dotfile basenames and .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz suffixes; DirectoryMatch is still needed for .git/config. - apache-listing (REASONED; apache, debian-packaging): Debian apache2 2.4.69-1 sets Indexes FollowSymLinks on /var/www/; DirectoryIndex lists index.html index.cgi index.pl index.php index.xhtml index.htm; dir and autoindex are enabled on fresh installs. With these defaults, index-less directories can expose other filenames. Ubuntu packaging was not checked by this guide. - apache-forbidden (REASONED; apache): mod_autoindex hides entries whose subrequests return 403 unless ShowForbidden is enabled; unmatched exports such as archive.zip and customers.csv can still be listed. - apache-options (REASONED; apache): Options -Indexes removes listing, preserving inherited options; Apache 2.4 rejects mixing relative +/- and bare options. - nginx-listing (REASONED; nginx): nginx autoindex defaults off. - caddy-listing (REASONED; caddy-rolling, caddy): Guide says Caddy lists only with file_server browse. - caddy-deny (REASONED; caddy, caddy-rolling): Caddy v2.11.4 matchers return 404 for dot-prefixed segments at any depth outside root /.well-known/*, and case-insensitive .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz suffixes everywhere. Server execution was not run. - caddy-acme (REASONED; caddy, caddy-rolling): ANDed path_regexp and not path matchers exempt the root /.well-known/ subtree only from dotfile denial; suffix denial still applies and .well-known-backup is not exempt. Keep the subtree free of secrets. - backup-storage (REASONED; nginx, apache, caddy, caddy-rolling): Keep dumps and backups outside the document root or in object storage; filename denies are a backstop and cannot recognize secrets or backups with unmatched names. - suffix-policy (REASONED; nginx, apache, caddy, caddy-rolling): Suffix denies also block intended public downloads, including release archives; narrow the policy for the site and verify intended downloads remain accessible. - next-public (REASONED; next): NEXT_PUBLIC_ embeds values in browser JavaScript; keep provider keys server-side behind backend routes. - vite-public (REASONED; vite): VITE_ embeds values in browser JavaScript; reserve for public values. - cra-public (REASONED; cra): Deprecated Create React App embeds REACT_APP_ values in browser builds. - source-maps (REASONED; next, vite, ecma426, next-maps, vite-maps): ECMA-426 sources are URLs or null; sourcesContent is optional. Guide assessment: maps can expose original source when embedded or retrievable, through separate files or sourceMappingURL data: URLs. Next.js v16.3.6 production browser maps default disabled; Vite v8.3.1 build.sourcemap defaults false. Create React App map defaults were not checked. Guide reasoning: removing the comment does not unpublish deployed maps. Guide advice: do not ship or leave served maps for code not meant to be public. - verify-dotfiles (REASONED; nginx, apache, caddy, caddy-rolling): Plant an exclusive, readable .env.probe file; expect 403 or 404 without PLANTED-SECRET. Also probe .git/config, config.php.bak and db.sql; absent files are inconclusive, so plant real files to test each rule and remove only created probes. - verify-listing (REASONED; apache): Plant a throwaway file in an existing index-less uploads directory; Options -Indexes should yield 403 or 404, never a 200 listing. An absent or unwritable directory is inconclusive; remove the probe afterward. - verify-acme (REASONED; nginx, apache, acme-http01): Plant a readable challenge token; webroot requests over HTTPS and HTTP:80 should return probe, not 403. nginx exempts only the challenge prefix; Apache exempts .well-known segments. A missing challenge directory skips these checks (status 2); other failed preparation is inconclusive. - verify-lookalike (REASONED; nginx, apache, caddy, caddy-rolling): Probe .well-known-backup/config across nginx, Apache and Caddy; expect 403 or 404. Plant a real readable file to distinguish denial from absence, then remove it. - verify-bundle (REASONED; next, vite, cra, grep): Scan built client output for credential patterns/literal secrets; GNU grep 3.12 returns 0 for a selected line, 1 for none and 2 for an error. A missing directory is an error; discarding errors can leave silence without a clean result. Clean does not exclude transformed secrets. - verify-backup (REASONED; nginx, apache, caddy, caddy-rolling): Known previously deployed backup URLs should return 403 or 404 without the file contents. - verify-suffixes (REASONED; nginx, apache, caddy, coreutils, caddy-rolling): GNU mktemp --suffix plants readable files for .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz plus a dotfile. Exposed: 200 with PLANTED-SECRET; fixed: 403 or 404 without it. Failed planting or curl is inconclusive (status 3). Syntax and suffixes were checked on a form exiting 0 when inconclusive; a tr-based form and this one had bash -n, ShellCheck 0.11.0 and stub runs, none on a server. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "0dee44da4ad5c68f6cc07b9c50b74bda93cfe86c4eff4e04232d88d83dd1a428", "components": { "nginx": { "name": "nginx documentation", "basis": "unknown", "sources": { "s0e5162d1ce2b": "https://nginx.org/en/docs/http/ngx_http_access_module.html", "sbd17bad53c9d": "https://nginx.org/en/docs/http/ngx_http_autoindex_module.html", "s40bdf1af1596": "https://nginx.org/en/docs/http/ngx_http_core_module.html" } }, "apache": { "name": "Apache documentation", "basis": "2.4", "sources": { "s24f06763d14a": "https://httpd.apache.org/docs/2.4/mod/core.html#directorymatch", "se24e9bbc0443": "https://httpd.apache.org/docs/2.4/mod/core.html#filesmatch", "s97468bf1f299": "https://httpd.apache.org/docs/2.4/mod/core.html#options", "s7313b1b624d5": "https://httpd.apache.org/docs/2.4/mod/mod_authz_core.html", "sef5ff46eddf7": "https://httpd.apache.org/docs/2.4/mod/mod_autoindex.html" } }, "caddy": { "name": "Caddy documentation", "basis": "v2.11.4", "sources": { "s450aa9ed710b": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/matchers.go", "se8dd8140d6b8": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/fileserver/staticfiles.go#L140-L142", "s86912a2959e0": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/fileserver/staticfiles.go#L371-L374", "s128797d9a40f": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/server.go#L384-L388" } }, "caddy-rolling": { "name": "Caddy documentation (rolling)", "basis": "unknown", "sources": { "se9ab71afc4ba": "https://caddyserver.com/docs/caddyfile/directives/respond", "s38b1b78ce980": "https://caddyserver.com/docs/caddyfile/matchers" } }, "next": { "name": "Next.js documentation", "basis": "unknown", "sources": { "sb98bee5b61c8": "https://nextjs.org/docs/pages/guides/environment-variables" } }, "vite": { "name": "Vite documentation", "basis": "unknown", "sources": { "s3fc0892e0f58": "https://vite.dev/guide/env-and-mode" } }, "cra": { "name": "Create React App documentation", "basis": "unknown", "sources": { "scfb24624addd": "https://create-react-app.dev/docs/adding-custom-environment-variables/" } }, "next-maps": { "name": "Next.js browser source maps", "basis": "v16.3.6", "sources": { "s6964da09fc21": "https://github.com/vercel/next.js/blob/v16.3.6/docs/01-app/03-api-reference/05-config/01-next-config-js/productionBrowserSourceMaps.mdx#L8-L18" } }, "vite-maps": { "name": "Vite production source maps", "basis": "v8.3.1", "sources": { "sf12c9114bd79": "https://github.com/vitejs/vite/blob/v8.3.1/docs/config/build-options.md#L145-L150" } }, "debian-packaging": { "name": "Debian apache2 packaging", "basis": "2.4.69-1", "sources": { "s62f6ed2866ab": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/changelog#L1", "s90898e0f03b6": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/apache2.conf.in#L170-L174", "s6f77b580cc87": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/config-dir/mods-available/dir.conf#L1", "s7ea1fcaca182": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.postinst#L38-L52", "sd7d93d2a9705": "https://salsa.debian.org/apache-team/apache2/-/blob/fc6e58fa5248fc058591097c94ac8fde34283519/debian/apache2.postinst#L257-L266" } }, "acme-http01": { "name": "ACME HTTP-01", "basis": "RFC 8555", "sources": { "se60bdd68e41d": "https://www.rfc-editor.org/rfc/rfc8555.html#section-8.3" } }, "ecma426": { "name": "ECMA-426", "basis": "c724f0f494b44b21b651fdbd6412d3437eef0e72", "sources": { "s5de29cfb3e99": "https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L584-L585", "s2af3a1c52b4a": "https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L1294", "sfeb03b2200f4": "https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L1323", "s94b6d647e4bc": "https://github.com/tc39/ecma426/blob/c724f0f494b44b21b651fdbd6412d3437eef0e72/spec.emu#L1415" } }, "coreutils": { "name": "GNU coreutils documentation", "basis": "unknown", "sources": { "s4166af5265e7": "https://www.gnu.org/software/coreutils/manual/html_node/mktemp-invocation.html" } }, "grep": { "name": "GNU grep", "basis": "3.12", "sources": { "s5170317ddea8": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n1110", "s057470125f4c": "https://git.savannah.gnu.org/cgit/grep.git/tree/doc/grep.texi?h=v3.12#n425", "sb18b36e247d7": "https://git.savannah.gnu.org/cgit/grep.git/tree/src/grep.c?h=v3.12#n1756", "s0c4ddbee9e68": "https://git.savannah.gnu.org/cgit/grep.git/tree/src/grep.c?h=v3.12#n661" } } }, "claims": { "nginx-dotfiles": {"text": "Deny dot-prefixed path segments by regex; place deny regex locations before other matching regex locations. Exact locations or a longest matching ^~ prefix can bypass them.", "components": ["nginx"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596"], "status": "REASONED"}, "nginx-backups": {"text": "Case-insensitive suffix regex denies .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz outside the ACME exemption, subject to location precedence.", "components": ["nginx"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596"], "status": "REASONED"}, "nginx-acme": {"text": "Only /.well-known/acme-challenge/ is exempted: when its ^~ location is the longest matching prefix, it skips both deny regexes. Keep the challenge directory free of secrets.", "components": ["nginx"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596"], "status": "REASONED"}, "nginx-security-txt": {"text": "The ACME-only exemption leaves /.well-known/security.txt denied; add location = /.well-known/security.txt {} to serve it, because exact matches precede regex locations.", "components": ["nginx"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596"], "status": "REASONED"}, "apache-directories": {"text": "DirectoryMatch denies dot-prefixed filesystem segments except an exact .well-known segment; .well-known-backup is not exempt. FilesMatch suffix denial still applies inside .well-known/.", "components": ["apache"], "sources": ["apache:s24f06763d14a", "apache:s7313b1b624d5", "apache:se24e9bbc0443"], "status": "REASONED"}, "apache-files": {"text": "Case-insensitive FilesMatch denies dotfile basenames and .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz suffixes; DirectoryMatch is still needed for .git/config.", "components": ["apache"], "sources": ["apache:se24e9bbc0443", "apache:s7313b1b624d5"], "status": "REASONED"}, "apache-listing": {"text": "Debian apache2 2.4.69-1 sets Indexes FollowSymLinks on /var/www/; DirectoryIndex lists index.html index.cgi index.pl index.php index.xhtml index.htm; dir and autoindex are enabled on fresh installs. With these defaults, index-less directories can expose other filenames. Ubuntu packaging was not checked by this guide.", "components": ["apache", "debian-packaging"], "sources": ["apache:s97468bf1f299", "apache:sef5ff46eddf7", "debian-packaging:s62f6ed2866ab", "debian-packaging:s90898e0f03b6", "debian-packaging:s6f77b580cc87", "debian-packaging:s7ea1fcaca182", "debian-packaging:sd7d93d2a9705"], "status": "REASONED"}, "apache-forbidden": {"text": "mod_autoindex hides entries whose subrequests return 403 unless ShowForbidden is enabled; unmatched exports such as archive.zip and customers.csv can still be listed.", "components": ["apache"], "sources": ["apache:sef5ff46eddf7"], "status": "REASONED"}, "apache-options": {"text": "Options -Indexes removes listing, preserving inherited options; Apache 2.4 rejects mixing relative +/- and bare options.", "components": ["apache"], "sources": ["apache:s97468bf1f299"], "status": "REASONED"}, "nginx-listing": {"text": "nginx autoindex defaults off.", "components": ["nginx"], "sources": ["nginx:sbd17bad53c9d"], "status": "REASONED"}, "caddy-listing": {"text": "Guide says Caddy lists only with file_server browse.", "components": ["caddy-rolling", "caddy"], "sources": ["caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:se8dd8140d6b8", "caddy:s86912a2959e0"], "status": "REASONED"}, "caddy-deny": {"text": "Caddy v2.11.4 matchers return 404 for dot-prefixed segments at any depth outside root /.well-known/*, and case-insensitive .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz suffixes everywhere. Server execution was not run.", "components": ["caddy", "caddy-rolling"], "sources": ["caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b"], "status": "REASONED"}, "caddy-acme": {"text": "ANDed path_regexp and not path matchers exempt the root /.well-known/ subtree only from dotfile denial; suffix denial still applies and .well-known-backup is not exempt. Keep the subtree free of secrets.", "components": ["caddy", "caddy-rolling"], "sources": ["caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b", "caddy:s128797d9a40f"], "status": "REASONED"}, "backup-storage": {"text": "Keep dumps and backups outside the document root or in object storage; filename denies are a backstop and cannot recognize secrets or backups with unmatched names.", "components": ["nginx", "apache", "caddy", "caddy-rolling"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596", "apache:se24e9bbc0443", "apache:s7313b1b624d5", "caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b"], "status": "REASONED"}, "suffix-policy": {"text": "Suffix denies also block intended public downloads, including release archives; narrow the policy for the site and verify intended downloads remain accessible.", "components": ["nginx", "apache", "caddy", "caddy-rolling"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596", "apache:se24e9bbc0443", "apache:s7313b1b624d5", "caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b"], "status": "REASONED"}, "next-public": {"text": "NEXT_PUBLIC_ embeds values in browser JavaScript; keep provider keys server-side behind backend routes.", "components": ["next"], "sources": ["next:sb98bee5b61c8"], "status": "REASONED"}, "vite-public": {"text": "VITE_ embeds values in browser JavaScript; reserve for public values.", "components": ["vite"], "sources": ["vite:s3fc0892e0f58"], "status": "REASONED"}, "cra-public": {"text": "Deprecated Create React App embeds REACT_APP_ values in browser builds.", "components": ["cra"], "sources": ["cra:scfb24624addd"], "status": "REASONED"}, "source-maps": {"text": "ECMA-426 sources are URLs or null; sourcesContent is optional. Guide assessment: maps can expose original source when embedded or retrievable, through separate files or sourceMappingURL data: URLs. Next.js v16.3.6 production browser maps default disabled; Vite v8.3.1 build.sourcemap defaults false. Create React App map defaults were not checked. Guide reasoning: removing the comment does not unpublish deployed maps. Guide advice: do not ship or leave served maps for code not meant to be public.", "components": ["next", "vite", "ecma426", "next-maps", "vite-maps"], "sources": ["next:sb98bee5b61c8", "vite:s3fc0892e0f58", "ecma426:s5de29cfb3e99", "ecma426:s2af3a1c52b4a", "ecma426:sfeb03b2200f4", "ecma426:s94b6d647e4bc", "next-maps:s6964da09fc21", "vite-maps:sf12c9114bd79"], "status": "REASONED"}, "verify-dotfiles": {"text": "Plant an exclusive, readable .env.probe file; expect 403 or 404 without PLANTED-SECRET. Also probe .git/config, config.php.bak and db.sql; absent files are inconclusive, so plant real files to test each rule and remove only created probes.", "components": ["nginx", "apache", "caddy", "caddy-rolling"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596", "apache:s24f06763d14a", "apache:s7313b1b624d5", "apache:se24e9bbc0443", "caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b"], "status": "REASONED", "verify": [1]}, "verify-listing": {"text": "Plant a throwaway file in an existing index-less uploads directory; Options -Indexes should yield 403 or 404, never a 200 listing. An absent or unwritable directory is inconclusive; remove the probe afterward.", "components": ["apache"], "sources": ["apache:s97468bf1f299", "apache:sef5ff46eddf7"], "status": "REASONED", "verify": [1]}, "verify-acme": {"text": "Plant a readable challenge token; webroot requests over HTTPS and HTTP:80 should return probe, not 403. nginx exempts only the challenge prefix; Apache exempts .well-known segments. A missing challenge directory skips these checks (status 2); other failed preparation is inconclusive.", "components": ["nginx", "apache", "acme-http01"], "sources": ["nginx:s40bdf1af1596", "nginx:s0e5162d1ce2b", "apache:s24f06763d14a", "acme-http01:se60bdd68e41d"], "status": "REASONED", "verify": [1]}, "verify-lookalike": {"text": "Probe .well-known-backup/config across nginx, Apache and Caddy; expect 403 or 404. Plant a real readable file to distinguish denial from absence, then remove it.", "components": ["nginx", "apache", "caddy", "caddy-rolling"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596", "apache:s24f06763d14a", "apache:s7313b1b624d5", "caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b"], "status": "REASONED", "verify": [1]}, "verify-bundle": {"text": "Scan built client output for credential patterns/literal secrets; GNU grep 3.12 returns 0 for a selected line, 1 for none and 2 for an error. A missing directory is an error; discarding errors can leave silence without a clean result. Clean does not exclude transformed secrets.", "components": ["next", "vite", "cra", "grep"], "sources": ["next:sb98bee5b61c8", "vite:s3fc0892e0f58", "cra:scfb24624addd", "grep:s5170317ddea8", "grep:s057470125f4c", "grep:sb18b36e247d7", "grep:s0c4ddbee9e68"], "status": "REASONED", "verify": [2]}, "verify-backup": {"text": "Known previously deployed backup URLs should return 403 or 404 without the file contents.", "components": ["nginx", "apache", "caddy", "caddy-rolling"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596", "apache:se24e9bbc0443", "apache:s7313b1b624d5", "caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b"], "status": "REASONED"}, "verify-suffixes": {"text": "GNU mktemp --suffix plants readable files for .bak, .old, .orig, ~, .sql, .sql.gz, .dump and .tar.gz plus a dotfile. Exposed: 200 with PLANTED-SECRET; fixed: 403 or 404 without it. Failed planting or curl is inconclusive (status 3). Syntax and suffixes were checked on a form exiting 0 when inconclusive; a tr-based form and this one had bash -n, ShellCheck 0.11.0 and stub runs, none on a server.", "components": ["nginx", "apache", "caddy", "coreutils", "caddy-rolling"], "sources": ["nginx:s0e5162d1ce2b", "nginx:s40bdf1af1596", "apache:se24e9bbc0443", "apache:s7313b1b624d5", "caddy-rolling:se9ab71afc4ba", "caddy-rolling:s38b1b78ce980", "caddy:s450aa9ed710b", "coreutils:s4166af5265e7"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> realtime-webhooks.md ====================================================================== # WebSockets, server-sent events, and webhooks: authenticating the non-page endpoints WebSockets and server-sent events keep a connection open so a server can stream data to the browser; webhooks are requests other services send to notify your app. This guide helps your AI assistant secure these endpoints, including authenticated streams, verified webhook senders, bounded expensive endpoints, and restricted relay destinations. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/realtime-webhooks.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against WebSocket protocol RFC 6455, stripe-node v18.5.0, stripe-python v12.5.0, Traefik timeout reference v3.5, Caddy source v2.11.4, nginx source release-1.26.3, HAProxy 3.0, and HAProxy source v3.0.0, plus 1 more recorded basis; 7 further sources carry no recorded version. Metadata reviewed 2026-10-17; documentation checked September 2026. The guide records 36 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Streaming an LLM response commonly rides a WebSocket or an SSE stream, and integrations commonly call back through a webhook. All three are transports, not authentication mechanisms, and each is routinely shipped wide open. [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) rule 15 already says each transport needs its own check; this guide is that check for these three. ## 1. Restrict listeners and encrypt each transport Bind application listeners to loopback or a private network restricted to the intended proxy, and inventory container-published ports, IPv6 listeners, and management endpoints separately. Require `wss://` for browser WebSockets and HTTPS for SSE and webhook delivery, and keep webhook certificate verification on; a session cookie or first-message token sent over `ws://` or plain `http://` crosses the network in clear text. If a proxy-to-application hop crosses an untrusted network, protect that hop with authenticated TLS too, since TLS termination at the public proxy does not encrypt it. Disable anonymous access and any vendor sample or default credentials on every deployed component, and give publishers, subscribers, webhook senders and administrators separate credentials with the smallest supported scopes; never hand an administrative API key to browser clients. Keep any admin UI private and require MFA through its identity provider or an authenticated fronting layer ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)); machine webhook deliveries keep using signature authentication rather than interactive MFA. ## 2. WebSocket authentication The browser `WebSocket()` constructor takes only a URL and an optional `protocols` list; it has no argument for request headers, so a page cannot attach `Authorization: Bearer ...` to the handshake (per MDN's WebSocket API reference). Two verified patterns fill the gap: - **Cookie plus Origin check.** The handshake is still an HTTP request, so eligible session cookies can ride it automatically, subject to their domain, path, Secure and SameSite attributes and the browser's cookie policy. Where cookies accompany a cross-origin handshake, accepting the connection without validating Origin permits cross-site WebSocket hijacking (CSWSH). OWASP's WebSocket Security Cheat Sheet is explicit: validate the `Origin` header on every handshake against an allowlist, not a denylist, since wildcard or substring matching is error-prone. Reject the upgrade server-side before any application logic runs if `Origin` is missing or not on the list. - **A token in the first message after the socket opens.** OWASP recommends token-based authentication as the stronger option for exactly this case: pass a short-lived token as a message right after the socket opens, verify it before treating the connection as authenticated, and rotate tokens on long-lived connections. Keep the token out of the URL query string and out of `Sec-WebSocket-Protocol` (the `protocols` argument, meant for subprotocol negotiation, not credentials); both are more likely than a message payload to end up in access logs or proxy logs. Require authentication independently of Origin validation, because a non-browser client can forge `Origin`; a first-message token protects against cookie theft only if possession of the cookie alone cannot obtain the token. Permit no protected reads, subscriptions, publications or job submissions before authentication, enforce a bounded authentication deadline, authorize each channel, tenant and operation separately (including SSE subscriptions), and revalidate long-lived access, terminating it when the session or token expires or is revoked. ## 3. Server-sent events (SSE) An `EventSource` performs an HTTP GET (confirmed on MDN) and exposes no option for an `Authorization` header, so a token-in-header scheme does not reach it. Same-origin requests can use eligible session cookies; cross-origin credentials require `withCredentials: true` and remain subject to the browser's cookie policy, and CORS controls browser access to the response. Authenticate and authorize the subscription server-side, and for credentialed cross-origin SSE allow only explicitly trusted origins through CORS. Do not copy the WebSocket missing-Origin rejection unchanged: a legitimate same-origin `EventSource` GET can omit `Origin`. Keep the GET side-effect free. If a client needs a header-based token, use `fetch()` with a `ReadableStream` reader instead of `EventSource`. ## 4. Webhooks: verify the sender, not just the shape of the payload A webhook has no session and no browser to enforce Origin, so the check is a signature over the raw request body: - **GitHub** signs deliveries with `X-Hub-Signature-256`: an HMAC-SHA256 hex digest of the payload using your webhook's secret, prefixed `sha256=`. Recompute it yourself and compare with a constant-time function (`crypto.timingSafeEqual` in Node.js, `secure_compare` in Ruby); a plain `==` leaks timing. Reject a missing or malformed signature header before comparing: in Node.js check the `sha256=` prefix and exactly 64 hexadecimal digits, decode the digest, and confirm equal buffer lengths before calling `crypto.timingSafeEqual` (which throws on unequal lengths), treating any validation exception as an authentication failure with no handler side effect. - **Stripe** signs events with `Stripe-Signature`, formatted `t=,v1=`. The signed payload is the timestamp concatenated with `.` and the raw body, HMAC-SHA256 with the endpoint's `whsec_...` secret. Verify with an official library where one exists; reject if the timestamp is older than your tolerance (the default tolerance is 300 seconds in stripe-node v18.5.0 and stripe-python v12.5.0; configure a positive tolerance and test stale signed requests, since zero handling differs between SDKs and entry points). Apply the shared lesson everywhere: use a distinct secret per endpoint (rotating one does not break every integration at once), and reject a request whose signature does not match before your handler touches it. Replay handling itself is provider specific, not a single universal timestamp check. GitHub's signed payload carries no timestamp, and `X-GitHub-Delivery` is not part of what the signature covers: `X-Hub-Signature-256` is computed over the raw body alone (per GitHub's guidance on validating webhook deliveries), so a captured signed request replayed with a different or reused delivery id still produces a valid signature. Deduplicating `X-GitHub-Delivery` catches repeated delivery ids, but an attacker can change that unsigned header, so use atomic, durable idempotency based on authenticated payload data where available. A cache of verified-body digests suppresses identical bodies only for its retention period and can also suppress legitimate identical deliveries; it does not establish freshness. GitHub's signature supplies no general timestamp-based freshness guarantee. Stripe's signed timestamp limits acceptance age, but replay within that window remains possible, so deduplicate authenticated event ids as well. Check signatures and timestamp policy before recording a delivery as processed. Both providers require the exact raw bytes; a framework that parses and re-serializes JSON before your verification code runs will break the check, so verify signatures against the body as received. Where a webhook route must be exempt from a site-wide CSRF filter, scope that exemption to that one route and method only, never to a whole controller or prefix. Configure a strong GitHub webhook secret explicitly; without one, GitHub omits the signature header, so refuse to start a protected receiver without its secret and reject unsigned deliveries. Load secrets from a secret manager or protected runtime environment, keep them out of URLs and logs, and rotate them; Stripe's secret for a CLI-forwarded endpoint differs from the secret for a Dashboard-managed endpoint, and an endpoint used with both test and live API keys has a different secret for each, so do not mix them up. Allowlisting the provider's published sender IP ranges is a supplement, not a substitute, for signatures (every customer of the provider shares those addresses, so only the signature says who sent the payload), and behind a proxy derive the sender address only through a configured trusted-proxy chain, never from an arbitrary forwarded header. ## 5. Bound the expensive endpoints too Inference, upload, and job-submission endpoints are usually the ones behind these transports, and an authenticated caller can still exhaust them. Two layers need separate limits. At the proxy in front of the app: a maximum request or body size, a connection concurrency cap per client, and a request timeout matched to realistic response time bound the HTTP connection itself. The [nginx](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [Caddy](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [HAProxy](https://github.com/jposluns/secureconfig/blob/main/haproxy.md) and [Traefik](https://github.com/jposluns/secureconfig/blob/main/traefik.md) guides here each have a "Bound the expensive endpoints" section covering what that proxy's guide documents natively. This guide assesses the nginx, Traefik and HAProxy guides' coverage as follows: nginx's carries all four controls, including [`proxy_read_timeout`](https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_proxy_module.c#L467-L472), Traefik's carries body size, concurrency and rate, and HAProxy's carries [`timeout client`](https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13551-L13552), [`timeout server`](https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13792-L13793) and [`timeout http-request`](https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13715-L13716) plus an aggregate per-process [`maxconn`](https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L2952-L2967) connection cap with a header-based body check. This guide assesses that Caddy's guide carries body size: at v2.11.4, [`request_body` `max_size`](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/requestbody/requestbody.go#L35-L39) bounds bytes read from the body using [`http.MaxBytesReader`](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/requestbody/requestbody.go#L83-L84) and returns 413 on excess. The [standard HTTP module list](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/standard/imports.go#L3-L25), [imported by the standard module list](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/standard/imports.go#L3-L18), imports no rate-limit handler at v2.11.4. Confirm the current timeout and rate options in each proxy's own documentation before relying on their absence. After a WebSocket upgrade, a single accepted connection can still carry unlimited messages or job submissions, which the connection-level limits above do not touch. OWASP's WebSocket Security Cheat Sheet calls for message-level authorization (checking that each individual message is allowed, not only the handshake) and message-level rate limiting (capping messages per connection per time window) as the separate control that applies once the socket is open. The [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) guide covers the identity side these limits key off. ## 6. Restrict relay destinations If a receiver fetches payload URLs or forwards webhooks to user-selected destinations, treat those URLs as an SSRF boundary. Prefer an explicit destination allowlist and enforce egress restrictions from the worker itself, permitting only required schemes and ports and blocking unintended loopback, private, link-local, IPv6-local and cloud-metadata destinations, including addresses reached through DNS aliases or redirects. Disable redirects unless each destination is revalidated, bind validation to the address actually used for the connection so a DNS change cannot bypass it, and never forward inbound credentials to another destination ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). ## Verify REASONED: the scope of every live check in this section and the secret handling of its blocks, per the guide's cited OWASP WebSocket Security Cheat Sheet, GitHub validating webhook deliveries, Stripe verify webhook signatures and curl manual entries, with the paste-alone warning per CONTRIBUTING rule 7's `read` exception. No endpoint, WebSocket client, event fixture or second vantage point was supplied, so no exposed or fixed state was observed. During marking review (October 2026) all three blocks passed bash -n under bash 5.3.9, and runs with a stub curl, dummy values and no network sent the cookie and webhook signature headers only on stdin, never in argv. Those runs covered an earlier form of the webhook signature block, whose Python transport failure exited with status 1; none was re-run on the current form, whose transport failure exits with status 3. These checks are REASONED from the cited vendor documentation, not demonstrated: no endpoint, WebSocket client, or event fixtures were supplied. Use Bash and curl 7.75.0 or newer. Substitute inside the single quotes and paste each complete block; a value containing an apostrophe needs proper shell escaping. Each secret-bearing block assumes a clean shell. Enter the complete Cookie header value at the hidden prompt, without the `Cookie:` prefix; do not export `AUDIT_COOKIE`. Paste the cookie value alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. The block clears inherited attributes before reading, keeps the cookie in an unexported subshell variable, sends headers through curl stdin, and unsets the variable on exit. This avoids putting the new input in history, `set -x` tracing (the block turns tracing off), argv or the environment; it does not hide process memory from the same account or root, or erase an earlier export. REASONED: following block; the cookie-and-Origin WebSocket handshake pair and the cookie SSE pair in the following block, per the guide's cited OWASP WebSocket Security Cheat Sheet, RFC 6455 and MDN Using server-sent events entries. No endpoint or event fixture was supplied. During marking review (October 2026) the block ran with a stub curl and no network: unchanged, it printed replace placeholders and exited 2; an example.com endpoint and an http:// endpoint were refused; and an empty cookie and a cookie containing a tab were refused. The same marking review (October 2026), with the stub curl and no network, found that the ws run sent its positive and three negatives in order with the cookie only on stdin, that the sse run sent its positive and negative, and that the fixed Sec-WebSocket-Key decoded to 16 bytes. The expected exposed and fixed results are those stated here. Run the first block with `ws` only for endpoints that require a session cookie during the handshake, and with `sse` for cookie-authenticated SSE endpoints. For WebSocket endpoints authenticated solely by a first-message token, use the application-client checks below: a cookie-free `101` is expected and does not by itself establish application authentication. Each negative uses the same URL and deployment as its successful control, and a failed positive control invalidates the comparison. For the handshake, require `101` with a valid cookie and allowed Origin, then a rejection (`401`/`403`, or a login redirect behind an identity-aware proxy, never `101`) without the cookie and for each invalid-Origin case. For SSE, require `200` with `text/event-stream` and the expected protected event with the cookie, then a rejection or login redirect without it. A login page, `404`, unrelated `403`, or server error proves nothing about the intended control, and a timeout alone is inconclusive (an observed `101` or protected event still counts even if curl then times out on the open stream). ```bash # REASONED: WebSocket authentication and Origin outcomes follow the cited vendor guidance; no endpoint, client, or event fixtures were supplied. ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_ENDPOINT' 'REPLACE_WITH_ALLOWED_HTTPS_ORIGIN' 'ws' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'expected endpoint, allowed Origin, and ws or sse'; exit 2; } case "$1|$2" in *REPLACE_WITH_*|*example.com*|*example.net*|*example.org*) echo 'replace placeholders'; exit 2 ;; esac case "$1" in https://?*) ;; *) echo 'HTTPS endpoint required'; exit 2 ;; esac case "$2" in https://?*) ;; *) echo 'HTTPS Origin required'; exit 2 ;; esac case "$3" in ws|sse) ;; *) echo 'choose ws or sse'; exit 2 ;; esac { unset -n AUDIT_COOKIE && unset -v AUDIT_COOKIE; } 2>/dev/null || { echo 'cannot clear AUDIT_COOKIE in this shell'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell'; exit 2; } trap 'unset -v AUDIT_COOKIE' EXIT printf 'Cookie header value (input hidden): ' IFS= read -r -s AUDIT_COOKIE || { printf '\n'; echo 'no secret read'; exit 2; } printf '\n' case "$AUDIT_COOKIE" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty secret, placeholder or control character'; exit 2 ;; esac probe() { if curl -q -g -sS -i -N --http1.1 --noproxy '*' \ --connect-timeout 5 --max-time 10 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$@"; then : else printf 'curl exit=%s; inspect the response; timeout is not a security pass\n' "$?" fi } handshake() { probe -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ -H 'Sec-WebSocket-Version: 13' "$@" } if [ "$3" = ws ]; then echo 'POSITIVE: valid cookie and allowed Origin' printf 'Cookie: %s\n' "$AUDIT_COOKIE" | handshake -H @- -H "Origin: $2" "$1" echo 'NEGATIVE: same endpoint and Origin, no cookie' handshake -H "Origin: $2" "$1" echo 'NEGATIVE: same endpoint and cookie, disallowed Origin' printf 'Cookie: %s\n' "$AUDIT_COOKIE" | handshake -H @- -H 'Origin: https://untrusted.example.com' "$1" echo 'NEGATIVE: same endpoint and cookie, missing Origin' printf 'Cookie: %s\n' "$AUDIT_COOKIE" | handshake -H @- "$1" else echo 'POSITIVE: valid cookie on the SSE endpoint' printf 'Cookie: %s\n' "$AUDIT_COOKIE" | probe -H @- "$1" echo 'NEGATIVE: same SSE endpoint, no cookie' probe "$1" fi echo 'Observations only: evaluate the paired results and application effects.' ) ``` REASONED: preceding block; the cookie handshake and SSE pairs above, plus the first-message-token, channel-authorization, revocation and default-credential checks in this paragraph, per the guide's cited OWASP WebSocket Security Cheat Sheet, OWASP Authorization Cheat Sheet, OWASP Secrets Management Cheat Sheet and RFC 6455 entries. The application's real WebSocket client, message schema and component default credentials were not supplied, so these checks have no local part beyond the block runs recorded in the previous paragraph. The expected exposed and fixed results are those stated here. First-message-token and default-credential authentication need the application's real WebSocket client and message schema, which were not supplied; the expected outcomes are REASONED from the cited authentication guidance. Against the same endpoint, first demonstrate a valid token and an authorized protected operation, then repeat with no token, an invalid token, an expired token, and a protected operation attempted before authentication; require explicit rejection or an observed authentication-deadline close with no protected data or side effect (silence alone is inconclusive). The channel-authorization test, against the same endpoint, is to first demonstrate the operation on the target channel with an authorized user's token, then repeat the same operation on the same channel and endpoint with a valid token belonging to an unauthorized user, requiring authorization rejection with no protected data or side effect. The revocation check, against the same endpoint, is to verify revocation on an already-open connection. For each installed component with documented default credentials, pair a successful legitimate login with the same request using the defaults, which must fail; do not infer this from a no-credential request, and do not invent a universal default username or password. REASONED: the webhook signature, replay and stale-timestamp checks, per the guide's cited GitHub validating webhook deliveries, GitHub webhook delivery headers, Stripe verify webhook signatures, stripe-node v18.5.0 and stripe-python v12.5.0 signature verification entries. No isolated test endpoint or event fixture was supplied, so no receiver effect was observed. During marking review (October 2026) the following block passed bash -n under bash 5.3.9 and ran with a stub curl, a dummy secret, a local JSON body file and no network: unchanged, it printed replace placeholders; and a missing body file and an unset AUDIT_GITHUB_EVENT were refused. The same marking review (October 2026), with the stub curl, the dummy secret, the local JSON body file and no network, found that the GitHub run sent wrong, unsigned, valid and valid requests, whose valid X-Hub-Signature-256 matched a separate Python HMAC-SHA256 of the body and whose wrong signature differed only in its first hex digit. The same marking review (October 2026), with the same stub curl, dummy secret, local JSON body file and no network, found that the Stripe run signed timestamp, dot and body, with the stale request dated 600 seconds earlier. The same marking review (October 2026), with the same stub curl, dummy secret, local JSON body file and no network, found that the secret never appeared in argv. The bash -n and stub runs recorded here covered an earlier form of the following block, whose Python transport failure exited with status 1; none was re-run on the current form, whose transport failure exits with status 3. For the webhook route, use an isolated test endpoint and a fresh, valid JSON event fixture that produces an observable effect. Enter its endpoint secret at the hidden prompt, and for GitHub export only the fixture's non-secret event type as `AUDIT_GITHUB_EVENT`; these are probe inputs, not vendor configuration. This block assumes a clean shell. It reads `AUDIT_WEBHOOK_SECRET` into an unexported subshell variable and unsets it on exit; Python reads the secret bytes from a pipe on file descriptor 3 and closes that descriptor before starting curl. Neither the secret nor the signed headers enter shell history, `set -x` tracing (the block turns tracing off), argv or the environment. The secret remains readable in process memory by the same account and root, and a previously exported value is not erased. The wrong-signature and unsigned requests must be rejected by signature validation with no effect, the correctly signed request must reach the handler and produce the effect, and its repetition must produce no second effect (a success acknowledgement for an already-processed event is fine). For this Stripe probe, confirm that the isolated receiver uses a 300-second tolerance and that the sender and receiver clocks are synchronized; the harness backdates the stale request by 600 seconds, so that request must fail timestamp validation. Confirm the rejecting component in the application logs; transport errors and timeouts are inconclusive. These endpoint outcomes are reasoned, not demonstrated here. REASONED: following block; signature, replay and timestamp expectations follow the cited GitHub and Stripe sources; no endpoint or event fixtures were supplied. Paste the endpoint secret alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_WEBHOOK_URL' 'REPLACE_WITH_RAW_BODY_FILE' 'github' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block'; exit 2; } shift [ "$#" -eq 3 ] || { echo 'expected URL, raw-body file, and github or stripe'; exit 2; } case "$1|$2" in *REPLACE_WITH_*|*example.com*|*example.net*|*example.org*) echo 'replace placeholders'; exit 2 ;; esac case "$1" in https://?*) ;; *) echo 'HTTPS required'; exit 2 ;; esac if [ -f "$2" ] && [ -r "$2" ]; then :; else echo 'readable raw-body file required'; exit 2; fi case "$3" in github|stripe) ;; *) echo 'choose github or stripe'; exit 2 ;; esac { unset -n AUDIT_WEBHOOK_SECRET && unset -v AUDIT_WEBHOOK_SECRET; } 2>/dev/null || { echo 'cannot clear AUDIT_WEBHOOK_SECRET in this shell'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell'; exit 2; } trap 'unset -v AUDIT_WEBHOOK_SECRET' EXIT printf 'Webhook endpoint secret (input hidden): ' IFS= read -r -s AUDIT_WEBHOOK_SECRET || { printf '\n'; echo 'no secret read'; exit 2; } printf '\n' case "$AUDIT_WEBHOOK_SECRET" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'empty secret, placeholder or control character'; exit 2 ;; esac python3 - "$1" "$2" "$3" 3< <(printf '%s' "$AUDIT_WEBHOOK_SECRET") <<'PY' import hashlib, hmac, os, pathlib, subprocess, sys, time, uuid body = pathlib.Path(sys.argv[2]).read_bytes() with os.fdopen(3, "rb") as secret_input: secret = secret_input.read() if not secret: print("No webhook secret received; skipped") raise SystemExit(2) provider = sys.argv[3] if provider == "github" and (not os.environ.get("AUDIT_GITHUB_EVENT") or any(c in os.environ["AUDIT_GITHUB_EVENT"] for c in "\r\n")): print("Export the fixture event type as AUDIT_GITHUB_EVENT; skipped") raise SystemExit(2) def request(mode): timestamp = int(time.time()) - (600 if mode == "stale" else 0) signed = body if provider == "github" else str(timestamp).encode() + b"." + body digest = hmac.new(secret, signed, hashlib.sha256).hexdigest() if mode == "wrong": digest = ("0" if digest[0] != "0" else "1") + digest[1:] header = ("X-Hub-Signature-256: sha256=" + digest if provider == "github" else "Stripe-Signature: t=" + str(timestamp) + ",v1=" + digest) headers = b"" if mode == "unsigned" else (header + "\n").encode() if provider == "github": headers += ("X-GitHub-Event: " + os.environ["AUDIT_GITHUB_EVENT"] + "\nX-GitHub-Delivery: " + str(uuid.uuid4()) + "\n").encode() print(mode.upper(), flush=True) result = subprocess.run( ("curl", "-q", "-g", "-sS", "-i", "--noproxy", "*", "--connect-timeout", "5", "--max-time", "10", "-H", "Content-Type: application/json", "-H", "@-", "--data-binary", "@" + sys.argv[2], "-w", "\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n", sys.argv[1]), input=headers, check=False) if result.returncode: print("Transport failure: inconclusive", file=sys.stderr) sys.exit(3) for mode in (("wrong", "unsigned", "stale", "valid", "valid") if provider == "stripe" else ("wrong", "unsigned", "valid", "valid")): request(mode) PY ) ``` REASONED: following block; origin reachability follows the cited curl documentation; both vantage points and the real listener inventory are unavailable. The webhook signature, replay and stale-timestamp block above, whose declaration is the paragraph before that block, and the direct listener reachability comparison in this paragraph are also reasoned, per the guide's cited GitHub validating webhook deliveries, Stripe verify webhook signatures and curl manual entries. Neither vantage point, the real origin inventory nor a webhook fixture was supplied. During marking review (October 2026) the reachability block's guard ran with a stub curl and no network: unchanged, it printed replace placeholders; an empty mapping drew connect-to mapping required; an ftp URL drew HTTP or HTTPS URL required; and substituted values reached the stub with the mapping and URL as given. The webhook block's local runs are recorded in the paragraph above it. The expected exposed and fixed results are those stated here. Finally, authentication rejection is not evidence of network isolation, so test direct reachability of each listener. Inventory every application and management listener, including published container ports and IPv6, and for each HTTP listener set the connect-to mapping to `URL_HOST:URL_PORT:ACTUAL_ORIGIN_ADDRESS:ACTUAL_LISTENER_PORT` (bracket IPv6 addresses where curl requires them) while the URL keeps the intended Host and TLS server name. Run it first from an allowed location to establish that the origin answers, then from an external one against the same origin. Any HTTP response from the external location (including `401`, `403` or `404`), or a TLS handshake or certificate error from that location, proves external reachability, while a timeout, DNS error, or local socket error from that location does not prove isolation and must be corroborated with the listener and network-policy configuration. Never use `-k`. This comparison stays reasoned until both vantage points and the real origin inventory are available. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORIGIN_URL' 'REPLACE_WITH_CONNECT_TO_MAPPING' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'expected URL and connect-to mapping'; exit 2; } case "$1|$2" in *REPLACE_WITH_*|*example.com*|*example.net*|*example.org*) echo 'replace placeholders'; exit 2 ;; esac [ -n "$2" ] || { echo 'connect-to mapping required'; exit 2; } case "$1" in http://?*|https://?*) ;; *) echo 'HTTP or HTTPS URL required'; exit 2 ;; esac curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 10 \ --connect-to "$2" \ -w 'peer=%{remote_ip} http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" ) ``` ## Common mistakes - Treating the page login as coverage for the WebSocket or SSE endpoint it opens; each needs its own check. - Comparing webhook signatures with `==` instead of a constant-time comparison. - Letting a framework's body parser touch the request before webhook signature verification runs, which breaks the raw-body check. ## Sources (checked September 2026) Scope and revisions: browser behavior follows RFC 6455 and the HTML and Fetch living standards retrieved on 2026-09-18; SDK-specific tolerance behavior was checked against stripe-node v18.5.0 and stripe-python v12.5.0; the Verify diagnostics require curl 7.75.0 or newer; Traefik timeout syntax was checked against v3.5. Other linked live documentation was retrieved on 2026-09-18 without an available immutable revision, and these source checks do not demonstrate deployment behavior. - MDN: WebSocket API, `WebSocket()` constructor (no header support, `protocols` argument): https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket - MDN: Using server-sent events (`EventSource`, plain GET, `withCredentials`): https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events - OWASP WebSocket Security Cheat Sheet (Origin allowlisting, token-based authentication): https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html - GitHub: Validating webhook deliveries (`X-Hub-Signature-256`, constant-time comparison): https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries - Stripe: Verify webhook signatures (`Stripe-Signature`, timestamp tolerance, official libraries, a different endpoint secret for test and live API keys, and the manual-verification `signed_payload` of timestamp, `.` and request body; checked October 2026): https://docs.stripe.com/webhooks#verify-official-libraries - RFC 6455, The WebSocket Protocol (Origin semantics, wire security): https://www.rfc-editor.org/rfc/rfc6455.html - HTML standard, server-sent events (`EventSource` GET semantics): https://html.spec.whatwg.org/multipage/server-sent-events.html - Fetch standard, request Origin-header algorithm: https://fetch.spec.whatwg.org/#append-a-request-origin-header - Node.js crypto (`crypto.timingSafeEqual` throws on unequal lengths): https://nodejs.org/docs/latest-v22.x/api/crypto.html#cryptotimingsafeequala-b - stripe-node v18.5.0 signature verification (`constructEvent`, default 300s tolerance): https://raw.githubusercontent.com/stripe/stripe-node/v18.5.0/src/Webhooks.ts - stripe-python v12.5.0 signature verification: https://raw.githubusercontent.com/stripe/stripe-python/v12.5.0/stripe/_webhook.py - GitHub: webhook best practices (require a secret; unsigned deliveries): https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks - GitHub: webhook delivery headers (`X-GitHub-Event`, `X-GitHub-Delivery`): https://docs.github.com/en/webhooks/webhook-events-and-payloads - Stripe: signature-error troubleshooting (wrong endpoint secret, CLI-forwarded versus Dashboard-managed endpoint secrets, exact request body; checked October 2026): https://docs.stripe.com/events/manage-webhook-endpoints - OWASP SSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html - OWASP Authorization Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html - OWASP Multifactor Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html - OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html - Traefik v3.5 entryPoint timeouts: https://doc.traefik.io/traefik/v3.5/reference/install-configuration/entrypoints/ - Caddy server timeouts: https://caddyserver.com/docs/caddyfile/options#timeouts - [Caddy v2.11.4 request body max_size and 413 behavior (checked October 2026)](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/requestbody/requestbody.go#L35-L39), [MaxBytesReader application](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/requestbody/requestbody.go#L83-L84). - [Caddy v2.11.4 standard HTTP module imports (checked October 2026)](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/standard/imports.go#L3-L25), [standard module imports](https://github.com/caddyserver/caddy/blob/v2.11.4/modules/standard/imports.go#L3-L18). - nginx `proxy_read_timeout`: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout - curl manual (`--connect-to`, `--noproxy`, write-out variables require 7.75.0+): https://curl.se/docs/manpage.html - Python `os.fdopen`, for reading and closing the secret pipe: https://docs.python.org/3/library/os.html#os.fdopen - nginx release-1.26.3 body-size, connection-limit and request-rate directives (checked October 2026). The nginx release-1.26.3 source-file sources: https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_core_module.c#L348-L353, https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_limit_conn_module.c#L100-L105, https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_limit_req_module.c#L113-L118 - nginx release-1.26.3 `proxy_read_timeout` directive (checked October 2026): https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_proxy_module.c#L467-L472 - Traefik v3.5 buffering, in-flight request limits and rate limits (checked October 2026): https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/middlewares/buffering/, https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/middlewares/inflightreq/, https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/middlewares/ratelimit/ - HAProxy 3.0 configuration: req.body_size advertised-length semantics (checked October 2026): https://docs.haproxy.org/3.0/configuration.html#7.3.6-req.body_size - HAProxy v3.0.0 `timeout client`, `timeout server`, `timeout http-request` and per-process `maxconn` (checked October 2026). The HAProxy v3.0.0 `doc/configuration.txt` sources: https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13551-L13552, https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13792-L13793, https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13715-L13716, https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L2952-L2967 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - websocket: WebSocket protocol (RFC 6455) - browser: Browser API and living standards (unknown) - github: GitHub webhooks documentation (unknown) - stripe: Stripe webhooks documentation (unknown) - stripe-node: stripe-node (v18.5.0) - stripe-python: stripe-python (v12.5.0) - node: Node.js crypto documentation (unknown) - traefik: Traefik timeout reference (v3.5) - proxy: Caddy and nginx timeout documentation (unknown) - caddy-source: Caddy source (v2.11.4) - nginx-source: nginx source (release-1.26.3) - haproxy: HAProxy (3.0) - haproxy-source: HAProxy source (v3.0.0) - owasp: OWASP guidance (unknown) - curl: curl minimum write-out version (7.75.0) - python: Python os documentation (unknown) Claims (full source mapping in the JSON block below): - private (REASONED; owasp): Restrict application origins to intended proxies; separately inventory containers, IPv6 and management endpoints. - tls (REASONED; websocket, owasp, github): Use WSS and HTTPS with certificate verification; protect untrusted proxy-to-app hops with authenticated TLS. - credentials (REASONED; owasp): Disable anonymous/default credentials and separate minimally scoped publisher, subscriber, sender and admin identities. - mfa (REASONED; owasp, github): Keep admin UIs private with IdP or fronting MFA; machine webhook deliveries use signatures. - ws-headers (REASONED; browser): Browser WebSocket accepts a URL and protocols, with no custom Authorization-header argument. - ws-origin (REASONED; owasp, websocket): Eligible cookies may accompany handshakes; reject missing or non-allowlisted Origin before protected logic to prevent CSWSH. - ws-token (REASONED; owasp, browser): Authenticate a short-lived first-message token before protected use; rotate it and keep credentials out of URLs and subprotocols. - ws-identity (REASONED; owasp, websocket): Origin is forgeable outside browsers; independently authenticate, and do not let a stolen cookie alone obtain the first-message token. - ws-deadline (REASONED; owasp): Allow no protected operation before authentication and impose a bounded authentication deadline. - authorization (REASONED; owasp): Authorize each channel, tenant and operation, including SSE subscriptions; revalidate and terminate expired or revoked long-lived access. - sse (REASONED; browser): EventSource uses GET without custom Authorization headers; eligible cookies and cross-origin withCredentials remain browser-policy dependent. - sse-origin (REASONED; browser, owasp): Authenticate subscriptions and allow only trusted credentialed CORS origins; legitimate same-origin GET may omit Origin. - sse-fetch (REASONED; browser): Keep SSE GET side-effect free; use fetch with a ReadableStream when header tokens are needed. - github-signature (REASONED; github): Verify raw-body HMAC-SHA256 using X-Hub-Signature-256 and constant-time comparison. - signature-shape (REASONED; github, node): Reject missing/malformed sha256= plus 64-hex signatures; timingSafeEqual needs equal buffer lengths and exceptions must fail closed. - stripe-signature (REASONED; stripe): Stripe signs timestamp, dot and raw body with the endpoint secret; use official verification libraries. - stripe-tolerance (REASONED; stripe-node, stripe-python): stripe-node v18.5.0 and stripe-python v12.5.0 default to 300 seconds; set a positive tolerance and test stale requests because zero handling differs. - github-replay (REASONED; github): GitHub signs neither a timestamp nor X-GitHub-Delivery; changed delivery IDs retain valid signatures, so ID deduplication cannot establish freshness. - idempotency (REASONED; github): Use atomic durable idempotency from authenticated payload data; body-digest caches only suppress identical bodies during retention and can suppress legitimate repeats. - stripe-replay (REASONED; stripe): A signed timestamp limits age, not replay inside the window; deduplicate authenticated event IDs after signature and timestamp validation. - raw-body (REASONED; github, stripe): Verify exact received bytes before JSON reserialization or handler effects. - csrf (REASONED; stripe): Scope any webhook CSRF exemption to the one route and method. - webhook-secret (REASONED; github, owasp): Require a configured strong GitHub secret, reject unsigned delivery, and use distinct protected, rotated endpoint secrets kept out of URLs and logs. - stripe-secrets (REASONED; stripe): Stripe CLI-forwarded and Dashboard-managed endpoint secrets differ, and an endpoint used with both test and live API keys has a different secret for each. - sender-ip (REASONED; github): Provider IP allowlists supplement signatures; trust forwarded client addresses only through a configured proxy chain. - proxy-limits (REASONED; traefik, proxy, nginx-source, haproxy, caddy-source, haproxy-source): Bound HTTP body size, concurrency and timeouts separately; nginx release-1.26.3 defines proxy_read_timeout; HAProxy v3.0.0 documents timeout client, server and http-request and per-process maxconn. At Caddy v2.11.4 request_body max_size returns 413 on excess and the standard HTTP module list imports no rate-limit handler; confirm other proxy capabilities in their guides and current vendor sources. - message-limits (REASONED; owasp): After WebSocket upgrade, enforce per-message authorization and rate limits; connection limits do not bound messages or jobs. - ssrf (REASONED; owasp): Allowlist relay destinations and enforce worker egress for schemes, ports and resolved addresses; revalidate redirects and never forward inbound credentials. - verify-handshake (REASONED; owasp, websocket): Cookie handshake positive is 101; missing cookie and disallowed/missing Origin must reject, never 101. Failed controls and unrelated errors are inconclusive. - verify-sse (REASONED; browser, owasp): With a cookie require 200, text/event-stream and the protected event; without it require rejection. A timeout alone proves nothing. - verify-messages (REASONED; owasp): Using the real client, compare valid, missing, invalid and expired tokens, pre-auth operations, cross-channel denial and revocation on an open connection. - verify-defaults (REASONED; owasp): Pair a legitimate login with the deployed component default credentials; a no-credential request does not test defaults. - verify-webhook (REASONED; github, stripe): Wrong/unsigned signatures must have no effect; valid events must act once and replay must not act twice, even with changed GitHub delivery IDs. - verify-stale (REASONED; stripe-node, stripe-python): On a synchronized Stripe fixture with 300-second tolerance, a request backdated 600 seconds must fail timestamp validation. - verify-secrets (REASONED; curl, python): Hidden prompts and stdin headers avoid new secret argv/history/environment exposure and set +x avoids tracing; Python reads and closes secret pipe fd 3. Process memory remains readable. - verify-origin (REASONED; curl): Compare allowed and external vantage points using connect-to and correct Host/TLS name; HTTP or TLS responses prove reachability, errors alone do not prove isolation. ````json { "schema": 1, "checked": "2026-10-17", "documentation_checked": "2026-09", "body_sha256": "c506c8c1b755deae0f02d2cb17fb5e0fefa463e0ce5396e1e63c98c9d650eea2", "components": { "websocket": { "name": "WebSocket protocol", "basis": "RFC 6455", "sources": { "sbdde15b3166d": "https://www.rfc-editor.org/rfc/rfc6455.html" } }, "browser": { "name": "Browser API and living standards", "basis": "unknown", "sources": { "sb2011b7b2751": "https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket", "scc66a82750c7": "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events", "sf47a21cc6a1c": "https://html.spec.whatwg.org/multipage/server-sent-events.html", "s0fe3abea9d8d": "https://fetch.spec.whatwg.org/#append-a-request-origin-header" } }, "github": { "name": "GitHub webhooks documentation", "basis": "unknown", "sources": { "s54f0b41284b0": "https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries", "seccc4a52ea14": "https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks", "s31c140d2cfa9": "https://docs.github.com/en/webhooks/webhook-events-and-payloads" } }, "stripe": { "name": "Stripe webhooks documentation", "basis": "unknown", "sources": { "s106b78b02797": "https://docs.stripe.com/webhooks#verify-official-libraries", "s05d6f0955de8": "https://docs.stripe.com/events/manage-webhook-endpoints" } }, "stripe-node": { "name": "stripe-node", "basis": "v18.5.0", "sources": { "s6cef5da6ee15": "https://raw.githubusercontent.com/stripe/stripe-node/v18.5.0/src/Webhooks.ts" } }, "stripe-python": { "name": "stripe-python", "basis": "v12.5.0", "sources": { "sa38abcfb0132": "https://raw.githubusercontent.com/stripe/stripe-python/v12.5.0/stripe/_webhook.py" } }, "node": { "name": "Node.js crypto documentation", "basis": "unknown", "sources": { "s215f593c0770": "https://nodejs.org/docs/latest-v22.x/api/crypto.html#cryptotimingsafeequala-b" } }, "traefik": { "name": "Traefik timeout reference", "basis": "v3.5", "sources": { "sd6ad276040b7": "https://doc.traefik.io/traefik/v3.5/reference/install-configuration/entrypoints/", "scad09024d298": "https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/middlewares/buffering/", "s5888553b0970": "https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/middlewares/inflightreq/", "s437d7eb2ec16": "https://doc.traefik.io/traefik/v3.5/reference/routing-configuration/http/middlewares/ratelimit/" } }, "proxy": { "name": "Caddy and nginx timeout documentation", "basis": "unknown", "sources": { "sf22449866dac": "https://caddyserver.com/docs/caddyfile/options#timeouts", "s7cc695452ecc": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout" } }, "caddy-source": { "name": "Caddy source", "basis": "v2.11.4", "sources": { "sd8185d2844da": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/requestbody/requestbody.go#L35-L39", "se8aeb08b0cf1": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/requestbody/requestbody.go#L83-L84", "s4ca9ad45ef73": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/caddyhttp/standard/imports.go#L3-L25", "sf9da74db9a07": "https://github.com/caddyserver/caddy/blob/v2.11.4/modules/standard/imports.go#L3-L18" } }, "nginx-source": { "name": "nginx source", "basis": "release-1.26.3", "sources": { "s80c7f2e4622c": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/ngx_http_core_module.c#L348-L353", "s0ac063f54b7d": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_limit_conn_module.c#L100-L105", "s86004e697595": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_limit_req_module.c#L113-L118", "s3c415d40a90d": "https://github.com/nginx/nginx/blob/release-1.26.3/src/http/modules/ngx_http_proxy_module.c#L467-L472" } }, "haproxy": { "name": "HAProxy", "basis": "3.0", "sources": { "se90dfdfcf910": "https://docs.haproxy.org/3.0/configuration.html#7.3.6-req.body_size" } }, "haproxy-source": { "name": "HAProxy source", "basis": "v3.0.0", "sources": { "s6e3367dea9ab": "https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13551-L13552", "sa25bc059aa2e": "https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13792-L13793", "sdd5d6df6d618": "https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L13715-L13716", "se9d9b22f9c27": "https://github.com/haproxy/haproxy/blob/v3.0.0/doc/configuration.txt#L2952-L2967" } }, "owasp": { "name": "OWASP guidance", "basis": "unknown", "sources": { "sfbb0cddfdc41": "https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html", "s7eb820e1e53b": "https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html", "s10c2347db109": "https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html", "sd95014c37d6f": "https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html", "s26a987a1053b": "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" } }, "curl": { "name": "curl minimum write-out version", "basis": "7.75.0", "sources": { "s2b2686afaf41": "https://curl.se/docs/manpage.html" } }, "python": { "name": "Python os documentation", "basis": "unknown", "sources": { "s767fa9623dba": "https://docs.python.org/3/library/os.html#os.fdopen" } } }, "claims": { "private": {"text": "Restrict application origins to intended proxies; separately inventory containers, IPv6 and management endpoints.", "components": ["owasp"], "sources": ["owasp:sfbb0cddfdc41"], "status": "REASONED"}, "tls": {"text": "Use WSS and HTTPS with certificate verification; protect untrusted proxy-to-app hops with authenticated TLS.", "components": ["websocket", "owasp", "github"], "sources": ["websocket:sbdde15b3166d", "owasp:sfbb0cddfdc41", "github:seccc4a52ea14"], "status": "REASONED"}, "credentials": {"text": "Disable anonymous/default credentials and separate minimally scoped publisher, subscriber, sender and admin identities.", "components": ["owasp"], "sources": ["owasp:s10c2347db109", "owasp:s26a987a1053b"], "status": "REASONED"}, "mfa": {"text": "Keep admin UIs private with IdP or fronting MFA; machine webhook deliveries use signatures.", "components": ["owasp", "github"], "sources": ["owasp:sd95014c37d6f", "github:s54f0b41284b0"], "status": "REASONED"}, "ws-headers": {"text": "Browser WebSocket accepts a URL and protocols, with no custom Authorization-header argument.", "components": ["browser"], "sources": ["browser:sb2011b7b2751"], "status": "REASONED"}, "ws-origin": {"text": "Eligible cookies may accompany handshakes; reject missing or non-allowlisted Origin before protected logic to prevent CSWSH.", "components": ["owasp", "websocket"], "sources": ["owasp:sfbb0cddfdc41", "websocket:sbdde15b3166d"], "status": "REASONED"}, "ws-token": {"text": "Authenticate a short-lived first-message token before protected use; rotate it and keep credentials out of URLs and subprotocols.", "components": ["owasp", "browser"], "sources": ["owasp:sfbb0cddfdc41", "browser:sb2011b7b2751"], "status": "REASONED"}, "ws-identity": {"text": "Origin is forgeable outside browsers; independently authenticate, and do not let a stolen cookie alone obtain the first-message token.", "components": ["owasp", "websocket"], "sources": ["owasp:sfbb0cddfdc41", "websocket:sbdde15b3166d"], "status": "REASONED"}, "ws-deadline": {"text": "Allow no protected operation before authentication and impose a bounded authentication deadline.", "components": ["owasp"], "sources": ["owasp:sfbb0cddfdc41"], "status": "REASONED"}, "authorization": {"text": "Authorize each channel, tenant and operation, including SSE subscriptions; revalidate and terminate expired or revoked long-lived access.", "components": ["owasp"], "sources": ["owasp:sfbb0cddfdc41", "owasp:s10c2347db109"], "status": "REASONED"}, "sse": {"text": "EventSource uses GET without custom Authorization headers; eligible cookies and cross-origin withCredentials remain browser-policy dependent.", "components": ["browser"], "sources": ["browser:scc66a82750c7", "browser:sf47a21cc6a1c"], "status": "REASONED"}, "sse-origin": {"text": "Authenticate subscriptions and allow only trusted credentialed CORS origins; legitimate same-origin GET may omit Origin.", "components": ["browser", "owasp"], "sources": ["browser:sf47a21cc6a1c", "browser:s0fe3abea9d8d", "owasp:s10c2347db109"], "status": "REASONED"}, "sse-fetch": {"text": "Keep SSE GET side-effect free; use fetch with a ReadableStream when header tokens are needed.", "components": ["browser"], "sources": ["browser:scc66a82750c7", "browser:sf47a21cc6a1c"], "status": "REASONED"}, "github-signature": {"text": "Verify raw-body HMAC-SHA256 using X-Hub-Signature-256 and constant-time comparison.", "components": ["github"], "sources": ["github:s54f0b41284b0"], "status": "REASONED"}, "signature-shape": {"text": "Reject missing/malformed sha256= plus 64-hex signatures; timingSafeEqual needs equal buffer lengths and exceptions must fail closed.", "components": ["github", "node"], "sources": ["github:s54f0b41284b0", "node:s215f593c0770"], "status": "REASONED"}, "stripe-signature": {"text": "Stripe signs timestamp, dot and raw body with the endpoint secret; use official verification libraries.", "components": ["stripe"], "sources": ["stripe:s106b78b02797"], "status": "REASONED"}, "stripe-tolerance": {"text": "stripe-node v18.5.0 and stripe-python v12.5.0 default to 300 seconds; set a positive tolerance and test stale requests because zero handling differs.", "components": ["stripe-node", "stripe-python"], "sources": ["stripe-node:s6cef5da6ee15", "stripe-python:sa38abcfb0132"], "status": "REASONED"}, "github-replay": {"text": "GitHub signs neither a timestamp nor X-GitHub-Delivery; changed delivery IDs retain valid signatures, so ID deduplication cannot establish freshness.", "components": ["github"], "sources": ["github:s54f0b41284b0", "github:s31c140d2cfa9"], "status": "REASONED"}, "idempotency": {"text": "Use atomic durable idempotency from authenticated payload data; body-digest caches only suppress identical bodies during retention and can suppress legitimate repeats.", "components": ["github"], "sources": ["github:s54f0b41284b0", "github:seccc4a52ea14"], "status": "REASONED"}, "stripe-replay": {"text": "A signed timestamp limits age, not replay inside the window; deduplicate authenticated event IDs after signature and timestamp validation.", "components": ["stripe"], "sources": ["stripe:s106b78b02797"], "status": "REASONED"}, "raw-body": {"text": "Verify exact received bytes before JSON reserialization or handler effects.", "components": ["github", "stripe"], "sources": ["github:s54f0b41284b0", "stripe:s05d6f0955de8"], "status": "REASONED"}, "csrf": {"text": "Scope any webhook CSRF exemption to the one route and method.", "components": ["stripe"], "sources": ["stripe:s106b78b02797"], "status": "REASONED"}, "webhook-secret": {"text": "Require a configured strong GitHub secret, reject unsigned delivery, and use distinct protected, rotated endpoint secrets kept out of URLs and logs.", "components": ["github", "owasp"], "sources": ["github:seccc4a52ea14", "owasp:s26a987a1053b"], "status": "REASONED"}, "stripe-secrets": {"text": "Stripe CLI-forwarded and Dashboard-managed endpoint secrets differ, and an endpoint used with both test and live API keys has a different secret for each.", "components": ["stripe"], "sources": ["stripe:s05d6f0955de8", "stripe:s106b78b02797"], "status": "REASONED"}, "sender-ip": {"text": "Provider IP allowlists supplement signatures; trust forwarded client addresses only through a configured proxy chain.", "components": ["github"], "sources": ["github:seccc4a52ea14"], "status": "REASONED"}, "proxy-limits": {"text": "Bound HTTP body size, concurrency and timeouts separately; nginx release-1.26.3 defines proxy_read_timeout; HAProxy v3.0.0 documents timeout client, server and http-request and per-process maxconn. At Caddy v2.11.4 request_body max_size returns 413 on excess and the standard HTTP module list imports no rate-limit handler; confirm other proxy capabilities in their guides and current vendor sources.", "components": ["traefik", "proxy", "nginx-source", "haproxy", "caddy-source", "haproxy-source"], "sources": ["traefik:sd6ad276040b7", "proxy:sf22449866dac", "proxy:s7cc695452ecc", "nginx-source:s80c7f2e4622c", "nginx-source:s0ac063f54b7d", "nginx-source:s86004e697595", "nginx-source:s3c415d40a90d", "traefik:scad09024d298", "traefik:s5888553b0970", "traefik:s437d7eb2ec16", "haproxy:se90dfdfcf910", "haproxy-source:s6e3367dea9ab", "haproxy-source:sa25bc059aa2e", "haproxy-source:sdd5d6df6d618", "haproxy-source:se9d9b22f9c27", "caddy-source:sd8185d2844da", "caddy-source:se8aeb08b0cf1", "caddy-source:s4ca9ad45ef73", "caddy-source:sf9da74db9a07"], "status": "REASONED"}, "message-limits": {"text": "After WebSocket upgrade, enforce per-message authorization and rate limits; connection limits do not bound messages or jobs.", "components": ["owasp"], "sources": ["owasp:sfbb0cddfdc41"], "status": "REASONED"}, "ssrf": {"text": "Allowlist relay destinations and enforce worker egress for schemes, ports and resolved addresses; revalidate redirects and never forward inbound credentials.", "components": ["owasp"], "sources": ["owasp:s7eb820e1e53b"], "status": "REASONED"}, "verify-handshake": {"text": "Cookie handshake positive is 101; missing cookie and disallowed/missing Origin must reject, never 101. Failed controls and unrelated errors are inconclusive.", "components": ["owasp", "websocket"], "sources": ["owasp:sfbb0cddfdc41", "websocket:sbdde15b3166d"], "status": "REASONED", "verify": [1]}, "verify-sse": {"text": "With a cookie require 200, text/event-stream and the protected event; without it require rejection. A timeout alone proves nothing.", "components": ["browser", "owasp"], "sources": ["browser:scc66a82750c7", "owasp:s10c2347db109"], "status": "REASONED", "verify": [1]}, "verify-messages": {"text": "Using the real client, compare valid, missing, invalid and expired tokens, pre-auth operations, cross-channel denial and revocation on an open connection.", "components": ["owasp"], "sources": ["owasp:sfbb0cddfdc41", "owasp:s10c2347db109"], "status": "REASONED"}, "verify-defaults": {"text": "Pair a legitimate login with the deployed component default credentials; a no-credential request does not test defaults.", "components": ["owasp"], "sources": ["owasp:s26a987a1053b"], "status": "REASONED"}, "verify-webhook": {"text": "Wrong/unsigned signatures must have no effect; valid events must act once and replay must not act twice, even with changed GitHub delivery IDs.", "components": ["github", "stripe"], "sources": ["github:s54f0b41284b0", "github:s31c140d2cfa9", "stripe:s106b78b02797"], "status": "REASONED", "verify": [2]}, "verify-stale": {"text": "On a synchronized Stripe fixture with 300-second tolerance, a request backdated 600 seconds must fail timestamp validation.", "components": ["stripe-node", "stripe-python"], "sources": ["stripe-node:s6cef5da6ee15", "stripe-python:sa38abcfb0132"], "status": "REASONED", "verify": [2]}, "verify-secrets": {"text": "Hidden prompts and stdin headers avoid new secret argv/history/environment exposure and set +x avoids tracing; Python reads and closes secret pipe fd 3. Process memory remains readable.", "components": ["curl", "python"], "sources": ["curl:s2b2686afaf41", "python:s767fa9623dba"], "status": "REASONED", "verify": [1, 2]}, "verify-origin": {"text": "Compare allowed and external vantage points using connect-to and correct Host/TLS name; HTTP or TLS responses prove reachability, errors alone do not prove isolation.", "components": ["curl"], "sources": ["curl:s2b2686afaf41"], "status": "REASONED", "verify": [3]} } } ```` ====================================================================== ==> transactional-email-posture.md ====================================================================== # Transactional email posture: authenticating the mail your deployment sends Transactional email is the automatic mail an app sends to individual users, such as account confirmations and reset links. This guide helps your AI assistant make that mail trustworthy, including SPF, DKIM and enforced DMARC, TLS before SMTP login with no open relay, and MTA-STS for mail your domain receives. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/transactional-email-posture.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against SPF RFC 7208, DKIM RFC 6376, DKIM cryptography RFC 8301, DMARC RFC 9989, DMARC aggregate reporting RFC 9990, DMARC failure reporting RFC 9991, Submission TLS RFC 8314, and SMTP submission roles RFC 6409, plus 7 more recorded bases; 1 further source carries no recorded version. Metadata reviewed 2026-10-05; documentation checked September 2026. The guide records 31 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Password resets, sign-in links, and invitations are part of your authentication path: a recipient who trusts a forged one hands an attacker the account. The controls that stop that forgery are not a single service's settings but three DNS-published standards (SPF, DKIM, and DMARC), the submission channel your application sends through, and the transport policy between mail servers. This guide covers that posture for a deployment that sends transactional mail. It is standards-based rather than product-based, so [authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md) covers the account side and [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) covers the SMTP credential and DKIM private key you will handle here. The through-line: a spoofable sender undermines every message your users are trained to act on, an open relay burns your sending reputation and gets your mail blocked, and an authenticated but plaintext submission leaks the credential that lets an attacker send as you. All values below (domains, IPs, selectors, keys) are illustrative; replace them with your own deployment inventory. ## 1. SPF: authorize the sending IPs SPF publishes, as a DNS TXT record, the IP addresses allowed to send for a domain. A receiver checks the record against the connecting IP and the envelope `MAIL FROM` domain (the `HELO` name for a null return path); it does not check the visible `From:` header, which is why SPF alone does not stop header spoofing and only becomes useful once DMARC ties it to `From:`. ```dns bounce.example.com. 3600 IN TXT "v=spf1 ip4:192.0.2.10 include:_spf.provider.example -all" ``` The final mechanism decides unmatched senders: `-all` fails them, `~all` softfails (many receivers still deliver a softfail), and `+all` or a bare `all` passes everyone, which destroys the record's meaning. Publish exactly one SPF record per evaluated domain; a domain with two `v=spf1` records is a `permerror`. SPF also caps the terms that trigger DNS lookups (`include`, `a`, `mx`, `ptr`, `exists`, and the `redirect` modifier) at ten across the whole recursion, and exceeding that is a `permerror` that voids the result; `ip4`, `ip6`, and `all` do not count against the ten (RFC 7208 sections 4.5 and 4.6.4). A long chain of provider `include`s is the usual way a domain silently blows the budget: any evaluation that has to walk the whole chain returns `permerror`, though a sender matched by an earlier mechanism such as a listed `ip4` still passes, because evaluation stops at the first match. A `permerror` is not an SPF pass, so legitimate mail that reached it and relied on SPF alignment fails your own DMARC. It breaks your authentication, not an attacker's. What remains unverified until you inspect your own zone: the real envelope domain, the provider `include` targets, the live recursive lookup count, and whether a second SPF record was left behind by an earlier vendor. ## 2. DKIM: sign with a rotatable key DKIM attaches a cryptographic signature to each message and publishes the matching public key at a selector under the signing domain. The signature names its domain as `d=` and its selector as `s=`; the receiver fetches the key from the selector record under `_domainkey` (here `tx202609._domainkey.example.com`) to verify it. DKIM proves the signed headers and body were not altered and were authorized by the `d=` domain; it does not encrypt the message. ```dns tx202609._domainkey.example.com. 3600 IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..." ``` The `p=` value above is a truncated illustrative key; publish the full base64 your signer generates. Sign with `rsa-sha256`: RFC 8301 prohibits `rsa-sha1` outright, sets a hard floor of 1024-bit RSA keys, and recommends 2048-bit or larger, so treat 2048-bit as the working minimum rather than the 1024-bit floor. Rotate by publishing a new selector's public key first, switching signing to it, and only then retiring the old selector once mail signed with it has cleared the network; an empty `p=` revokes a key. Publishing a key is not enough on its own: the sending service must actually sign, and a receiver must actually verify, before DKIM contributes to a DMARC pass. Unverified until checked in your deployment: whether signing is enabled, the live `d=` and selector, the key length, whether the key is a TXT record or a provider CNAME delegation, and who owns rotation. ## 3. DMARC: align authentication to the visible From and enforce DMARC is the record that finally protects the `From:` your users see. A message passes DMARC when at least one of SPF or DKIM both passes and is aligned: SPF alignment compares the envelope domain, DKIM alignment compares the verified signature's `d=`, each against the visible `From:` domain. Relaxed alignment (`adkim=r`, `aspf=r`, the default) accepts the same organizational domain; strict (`s`) requires an exact match. ```dns _dmarc.example.com. 3600 IN TXT "v=DMARC1; p=reject; sp=reject; np=reject; adkim=r; aspf=r; rua=mailto:dmarc-agg@example.com" ``` `p=` sets the requested handling of failures: `none` asks for no change (monitor only, and still spoofable), `quarantine` asks receivers to treat failures as suspicious, and `reject` asks them to reject at SMTP time. `sp=` sets the policy for existing subdomains and `np=` for non-existent ones; where a tag is absent, policy falls back `np` to `sp` to `p`, so a `p=reject` domain already rejects for its own subdomains without them. Publish `sp` and `np` when you want subdomains held to a policy of their own, whether it repeats or differs from `p`, and inventory any subdomain that publishes its own DMARC record, since a receiver's tree walk finds that record first. DMARC discovers the applicable policy and organizational domain by a DNS tree walk (RFC 9989 section 4.10), which replaced the old public-suffix-list method. `t=y` weakens enforcement for testing and `t=n` is the default; the enforcement record above omits it. Reach `p=reject` only after a monitoring period under `p=none` with aggregate reports confirms every legitimate stream aligns, or you will reject your own mail. Missing DMARC or `p=none` means no enforcement is requested, not that mail is guaranteed to arrive; receivers keep their own local policy. One legacy note: RFC 9989 (May 2026), which now defines DMARC and obsoletes RFC 7489, removed the `pct` tag, so a `pct` value is not a current rollout control, and a legacy `p=reject; pct=25` was partial application rather than full enforcement. Unverified until checked: which streams actually align, the inherited and explicit subdomain policies, and whether your receivers implement the current specification. ## 4. DMARC reporting: aggregate now, failure reports later Aggregate reporting (`rua=`, RFC 9990) sends a daily XML summary of authentication results and is how you see what is sending as your domain before you enforce; turn it on first. A destination in another domain must authorize the reports, or receivers will not send them: ```dns example.com._report._dmarc.collector.example.net. IN TXT "v=DMARC1" ``` Failure reporting (`ruf=`, RFC 9991) sends per-message forensic copies that can include message content and recipient data, which for password-reset mail can mean leaking a live reset token to whatever inbox collects the reports. Omit `ruf` at first, and enable it only after you have settled collection, redaction, retention, and access, and authorized any external destination the same way. Publication alone is not evidence reports arrive: verify the mailbox actually receives and processes them. ## 5. SMTP submission and relay: TLS before AUTH, and never an open relay Where your application hands mail to a mail server, three ports carry different contracts. Port 587 is submission with STARTTLS: the client upgrades to TLS and then authenticates, but the STARTTLS offer alone does not force encryption, so an opportunistic client that falls back to plaintext is exposed to a STARTTLS-stripping downgrade that captures the credential. Port 465 is submission with implicit TLS: the connection is encrypted from the first byte and is immune to stripping, which is why RFC 8314 prefers it going forward. Require successful TLS with certificate chain and hostname validation before any credential is sent on either, and treat TLS 1.2 as the floor (RFC 8997). Port 25 is inter-server relay or, at some providers, submission; "port 25 means no authentication" is wrong (RFC 6409 section 3.1), so identify which role your endpoint plays rather than assuming. An open relay forwards mail from an unauthenticated, untrusted client to a destination the server is not responsible for; it will be found and abused, and your domain blocked. Reject that forwarding while still allowing the two legitimate cases: ordinary unauthenticated inbound delivery to your own local recipients, and explicitly authorized relay paths (RFC 2505 section 2.1). After TLS, an unauthenticated or invalidly authenticated submission must fail and an authorized one succeed. Unverified until checked: the live endpoints and ports, whether the client enforces TLS rather than merely offering it, the authentication mechanism, and the relay access rules. ## 6. Transport and display: MTA-STS, TLS-RPT, and BIMI MTA-STS (RFC 8461) lets a receiving domain demand TLS for mail sent to it. A TXT record points to an HTTPS-hosted policy: ```dns _mta-sts.example.com. IN TXT "v=STSv1; id=2026091501" ``` The policy is served as `text/plain` at `https://mta-sts.example.com/.well-known/mta-sts.txt`: ```text version: STSv1 mode: enforce mx: mx1.example.com max_age: 604800 ``` Only `mode: enforce` constrains delivery (`testing` and `none` do not), and the crucial caveat is direction: MTA-STS protects mail arriving at the policy domain, so publishing it does not secure your own outbound mail to other domains, and it depends on your sending server validating recipients' policies. Update the `id` whenever the policy changes. TLS-RPT (RFC 8460) is reporting only, never enforcement: ```dns _smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:tls-rpt@example.com" ``` BIMI is optional display metadata that shows a brand logo in supporting clients; it depends on DMARC enforcement already being in place and is not an authentication mechanism or a guarantee the logo renders. Keep it last, after the records above are enforcing. ## Verify REASONED: following block; DNS publication, submission TLS and plaintext AUTH refusal, per the guide's cited BIND 9.18.39 dig manual, OpenSSL 3.0 s_client, RFC 8314: Cleartext Considered Obsolete and RFC 8997 section 3. No controlled DNS zone or SMTP endpoint is available, and local listeners are denied. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics on the block before its name guard and its whole-block exit status were added; openssl s_client -help exited 0 and listed -starttls, -verify_hostname, -verify_return_error and -min_protocol. The form before this change, with the name guard and the whole-block exit status, lowercased the three names with `tr` before refusing a reserved one; the gate suite's bash -n and ShellCheck 0.11.0 passes covered it, and no stub run of it is recorded. The current form refuses a reserved name under bash's `nocasematch` option instead of through `tr`, and refuses with 2, saying the block needs bash, when `shopt` fails, as it does in dash. It is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by dig and openssl stub runs on 2026-11-01 with no network. In bash, the block refused `example.com`, `EXAMPLE.NET`, `App.Example.COM`, `api.EXAMPLE.org` and seven other reserved spellings as the mail domain, the bounce domain or the submission host with 2 and no dig or openssl call, also with `tr` absent from PATH or failing and under an inherited `nocasematch`, `readonly`, `declare -i` or `set -e`. Names outside the reserved names reached all seven lookups and both TLS probes, and under dash the block refused every set of names with 2 and the bash message before any lookup. Published records should match the configured policies; exposed submission accepts plaintext AUTH, while fixed submission refuses it and supports validated TLS before authorized AUTH. These live outcomes remain unobserved. Every probe below is reasoned, not demonstrated: the authoring environment has no DNS control, no SMTP endpoint, and no container runtime, so the outcomes are derived from the cited RFCs rather than observed. For the submission probes, a transport failure or a TLS error is inconclusive, never the fixed state. The DNS/TLS inspection, and the message alignment and relay discrimination checks at the end of this paragraph, also follow the guide's cited RFC 9989, RFC 8601 and RFC 2505, with RFC 8314. The four RFCs are RFC 8314: Cleartext Considered Obsolete, RFC 9989: Domain-based Message Authentication, Reporting, and Conformance (DMARC), RFC 8601: Message Header Field for Indicating Message Authentication Status and RFC 2505: Anti-Spam Recommendations for SMTP MTAs. No controlled sender or recipient is available either. Live outcomes remain unobserved: exposed submission accepts plaintext AUTH or unauthorized relay and lacks aligned enforcement; fixed submission validates TLS, shows receiver-trusted alignment and enforcing policy, rejects external relay with 5xx and preserves authorized submission and local delivery. After running the block, prove alignment by sending one benign message to a controlled recipient and reading the receiving system's trusted `Authentication-Results` header (RFC 8601), never a header the message itself supplied: require a passing, aligned SPF or DKIM result. Confirm the enforcement target is really `p=reject` with subdomain coverage and no testing or legacy sampling. Test open-relay rejection only in an operator-controlled environment (an external `MAIL FROM` and a non-local `RCPT TO` should draw a 5xx), and compare it against an authorized submission and ordinary local delivery. These checks inspect DNS, TLS, and message authentication, not an application listener; their expected exposed and fixed outcomes are REASONED from the cited RFCs. ```bash # Inspect the published records. Substitute your own names inside the quotes on the set -- line below, # taking the DKIM selector from the s= tag of a real signature. The block refuses example.com, # example.net, example.org and their subdomains: example.com publishes real DNS records of its own, so # an unsubstituted run would show another domain's policy. # These read DNS and TLS state; they do not evaluate SPF or DMARC or prove a service listener. ( # a subshell, so your own script arguments are untouched set +e # errexit off here, so a lookup or probe that fails is recorded in the status below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_MAIL_DOMAIN' 'REPLACE_WITH_YOUR_BOUNCE_DOMAIN' 'REPLACE_WITH_YOUR_DKIM_SELECTOR' 'REPLACE_WITH_YOUR_SUBMISSION_HOST' # replace inside the quotes, keeping them [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 4 ] || { echo "the set -- line needs exactly 4 values: mail domain, bounce domain, DKIM selector and submission host; not probing"; exit 2; } case "$1" in ""|*REPLACE_WITH_*|-*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your mail domain (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$2" in ""|*REPLACE_WITH_*|-*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your bounce (Return-Path) domain, the name that carries its SPF record, with no scheme, user name, port, path, query, fragment, percent sign or trailing dot, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$3" in ""|*REPLACE_WITH_*|-*|*/*|*:*|*.|*[[:space:][:cntrl:]]*) echo "substitute your DKIM selector, the s= tag of a real signature, inside the quotes on the set -- line above; not probing"; exit 2 ;; esac case "$4" in ""|*REPLACE_WITH_*|-*|*/*|*:*|*.|*[@?%]*|*'#'*|*[[:space:][:cntrl:]]*) echo "substitute your submission host name (no scheme, user name, port, path, query, fragment, percent sign or trailing dot) inside the quotes on the set -- line above; not probing"; exit 2 ;; esac shopt -s nocasematch || { echo "this block needs bash; run it in bash; not probing"; exit 2; } # host names ignore letter case, so this case matches them in any case through a bash option, not an outside command that could fail case "|$1|$2|$4|" in *'|example.com|'*|*'|example.net|'*|*'|example.org|'*|*'.example.com|'*|*'.example.net|'*|*'.example.org|'*) echo "example.com, example.net, example.org and their subdomains are not your deployment: substitute your own names on the set -- line above; not probing"; exit 2 ;; esac shopt -u nocasematch # every match below compares letter case exactly again set -- "$1" "$2" "$3" "$4" 0 # the 5th value is the block's status so far: 3 (inconclusive) outranks 0 dig +noall +answer TXT "$2" || set -- "$1" "$2" "$3" "$4" 3 dig +noall +answer TXT "$3._domainkey.$1" || set -- "$1" "$2" "$3" "$4" 3 dig +noall +answer CNAME "$3._domainkey.$1" || set -- "$1" "$2" "$3" "$4" 3 dig +noall +answer TXT "_dmarc.$1" || set -- "$1" "$2" "$3" "$4" 3 dig +noall +answer TXT "_mta-sts.$1" || set -- "$1" "$2" "$3" "$4" 3 dig +noall +answer TXT "_smtp._tls.$1" || set -- "$1" "$2" "$3" "$4" 3 dig +noall +answer TXT "default._bimi.$1" || set -- "$1" "$2" "$3" "$4" 3 [ "$5" = 0 ] || echo "a dig lookup above failed, so the DNS reading is inconclusive" # Submission must complete TLS with a validated certificate before AUTH. -verify_return_error # matters because s_client otherwise continues past a verification failure. This confirms TLS is # available, not that AUTH is gated on it; the discriminating check, reasoned here, is the negative # one: a plaintext or post-stripped-STARTTLS AUTH attempt must be refused, where the exposed state # accepts it. openssl s_client -connect "$4:587" -starttls smtp \ -servername "$4" -verify_hostname "$4" \ -verify_return_error -min_protocol TLSv1.2 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - spf: SPF (RFC 7208) - dkim: DKIM (RFC 6376) - crypto: DKIM cryptography (RFC 8301) - dmarc: DMARC (RFC 9989) - aggregate: DMARC aggregate reporting (RFC 9990) - failure: DMARC failure reporting (RFC 9991) - submission: Submission TLS (RFC 8314) - roles: SMTP submission roles (RFC 6409) - relay: SMTP relay controls (RFC 2505) - sts: MTA-STS (RFC 8461) - rpt: TLS-RPT (RFC 8460) - results: Authentication-Results (RFC 8601) - bimi: BIMI documentation (unknown) - submission-update: Submission TLS update (RFC 8997) - bind: BIND dig manual (9.18.39) - openssl: OpenSSL command documentation (3.0) Claims (full source mapping in the JSON block below): - spf-identity (REASONED; spf, dmarc): SPF TXT authorizes connecting IPs for MAIL FROM, or HELO with a null return path; it does not authenticate visible From without DMARC alignment. - spf-all (REASONED; spf): -all fails unmatched senders, ~all softfails and may still be delivered, while +all or bare all passes everyone. - spf-record (REASONED; spf): Publish exactly one v=spf1 record per evaluated domain; multiple records produce permerror. - spf-lookups (REASONED; spf): Ten DNS-lookup terms across recursion include include/a/mx/ptr/exists/redirect but exclude ip4/ip6/all; exceeding the limit gives permerror. An earlier matching mechanism stops evaluation; permerror is no SPF pass. - dkim-signing (REASONED; dkim, dmarc): DKIM d= and s= select the _domainkey public key; signatures authenticate signed content for the signing domain, not encryption. Actual signing and verification are required for DMARC. - dkim-crypto (REASONED; crypto): Use rsa-sha256, never rsa-sha1; RFC 8301 sets a 1024-bit RSA floor and recommends at least 2048 bits, used here as the working minimum. Publish the full generated key, not the truncated example. - dkim-rotation (REASONED; dkim): Publish a new selector before switching signing, retire the old selector after in-flight mail clears, and revoke with empty p=; inspect actual selector, delegation and rotation ownership. - dmarc-alignment (REASONED; dmarc): DMARC requires at least one passing and aligned SPF or DKIM result; relaxed adkim=r/aspf=r defaults to organizational-domain alignment, strict s requires exact domain matching. - dmarc-policy (REASONED; dmarc): p=none requests monitoring only, quarantine suspicious handling, and reject SMTP-time rejection; receivers retain local policy, so missing enforcement does not guarantee delivery. - dmarc-subdomains (REASONED; dmarc): sp covers existing subdomains and np non-existent ones, falling back np to sp to p; a subdomain-specific record can take precedence. Inventory explicit and inherited policies. - dmarc-discovery (REASONED; dmarc): RFC 9989 DNS tree walk discovers policy and organizational domain, replacing the former public-suffix-list method. - dmarc-testing (REASONED; dmarc): t=y weakens enforcement and t=n is the default; the illustrated enforcing record omits the tag. - dmarc-rollout (REASONED; dmarc, aggregate): Monitor aggregate reports under p=none until legitimate streams align before moving to p=reject, or legitimate mail can be rejected. - dmarc-pct (REASONED; dmarc): RFC 9989, May 2026, obsoletes RFC 7489 and removes pct; legacy p=reject;pct=25 was partial application, not a current rollout control. - aggregate (REASONED; aggregate): Enable rua daily aggregate XML reporting to observe sending streams; an external reporting domain must authorize reports, and actual receipt/processing must be checked. - failure-reports (REASONED; failure): ruf forensic reports can expose content, recipients and reset tokens; omit initially, settle redaction/retention/access before enabling, and authorize external destinations. - starttls (REASONED; submission): 587 submission upgrades with STARTTLS before authentication; an opportunistic fallback can expose credentials to stripping, so require successful validated TLS. - implicit-tls (REASONED; submission): 465 submission starts with TLS and avoids STARTTLS stripping; RFC 8314 prefers implicit TLS going forward. - tls-validation (REASONED; submission, submission-update): Validate certificate chain and hostname before credentials on either submission port; the guide states TLS 1.2 as the floor and attributes that update to RFC 8997. - port-25 (REASONED; roles): 25 is inter-server relay or provider submission; identify the endpoint role instead of assuming that port 25 implies no authentication. - relay-policy (REASONED; relay): Reject unauthenticated untrusted forwarding to non-local recipients while allowing ordinary inbound local delivery and explicitly authorized relay paths. - submission-auth (REASONED; roles, submission): After TLS, unauthenticated or invalidly authenticated submission must fail while authorized submission succeeds. - sts-publication (REASONED; sts): MTA-STS TXT advertises the HTTPS text/plain policy at /.well-known/mta-sts.txt on mta-sts.example.com; the example selects enforce, an MX host and max_age 604800. - sts-enforcement (REASONED; sts): Only enforce constrains delivery; testing and none do not. MTA-STS protects mail arriving at the policy domain and depends on sending-server validation, not protection of the publishing domain outbound mail. - sts-refresh (REASONED; sts): Update the MTA-STS TXT id whenever the policy changes. - tls-reports (REASONED; rpt): TLS-RPT _smtp._tls TXT with TLSRPTv1 and rua supplies reports, never enforcement. - bimi (REASONED; bimi): BIMI is optional logo display metadata requiring DMARC enforcement, not authentication or guaranteed rendering; deploy it last. - verify-dns (REASONED; spf, dkim, dmarc, sts, rpt, bimi, bind): Inspect SPF, DKIM TXT/CNAME, DMARC, MTA-STS, TLS-RPT and BIMI records using actual inventory; DNS inspection alone does not evaluate SPF/DMARC or prove a listener. No DNS control was available. - verify-tls (REASONED; submission, submission-update, openssl): 587 STARTTLS and 465 implicit-TLS probes require validated TLS 1.2 or newer; TLS availability does not establish AUTH gating. Plaintext or stripped-STARTTLS AUTH must be refused; transport/TLS failures are inconclusive. No SMTP endpoint or container runtime was available. - verify-alignment (REASONED; results, dmarc): Send a benign message to a controlled recipient and inspect receiver-trusted Authentication-Results, not a supplied header; require passing aligned SPF or DKIM and p=reject with subdomain coverage and no testing or legacy sampling. - verify-relay (REASONED; relay, roles): In an operator-controlled environment, external MAIL FROM and non-local RCPT TO should receive 5xx; compare with authorized submission and ordinary local delivery. These expected outcomes remain unobserved. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-09", "body_sha256": "0c9afe559fced06e08a4ecc11d575dc4b0554047fda2d4389dea62fdc9ac229f", "components": { "spf": { "name": "SPF", "basis": "RFC 7208", "sources": { "sea6ac206b034": "https://www.rfc-editor.org/rfc/rfc7208.html" } }, "dkim": { "name": "DKIM", "basis": "RFC 6376", "sources": { "s13ee1bc66dfc": "https://www.rfc-editor.org/rfc/rfc6376.html" } }, "crypto": { "name": "DKIM cryptography", "basis": "RFC 8301", "sources": { "sb675e6772778": "https://www.rfc-editor.org/rfc/rfc8301.html" } }, "dmarc": { "name": "DMARC", "basis": "RFC 9989", "sources": { "s5da292ff1b92": "https://www.rfc-editor.org/rfc/rfc9989.html" } }, "aggregate": { "name": "DMARC aggregate reporting", "basis": "RFC 9990", "sources": { "s5e0e418f74dd": "https://www.rfc-editor.org/rfc/rfc9990.html" } }, "failure": { "name": "DMARC failure reporting", "basis": "RFC 9991", "sources": { "s136bbcd8e31a": "https://www.rfc-editor.org/rfc/rfc9991.html" } }, "submission": { "name": "Submission TLS", "basis": "RFC 8314", "sources": { "sd78efc15555c": "https://www.rfc-editor.org/rfc/rfc8314.html" } }, "roles": { "name": "SMTP submission roles", "basis": "RFC 6409", "sources": { "s3f9972faf8ff": "https://www.rfc-editor.org/rfc/rfc6409.html" } }, "relay": { "name": "SMTP relay controls", "basis": "RFC 2505", "sources": { "s1b9349dd99bc": "https://www.rfc-editor.org/rfc/rfc2505.html" } }, "sts": { "name": "MTA-STS", "basis": "RFC 8461", "sources": { "s39819f3ea93a": "https://www.rfc-editor.org/rfc/rfc8461.html" } }, "rpt": { "name": "TLS-RPT", "basis": "RFC 8460", "sources": { "se80da0f5e0c8": "https://www.rfc-editor.org/rfc/rfc8460.html" } }, "results": { "name": "Authentication-Results", "basis": "RFC 8601", "sources": { "s46f8392d93fd": "https://www.rfc-editor.org/rfc/rfc8601.html" } }, "bimi": { "name": "BIMI documentation", "basis": "unknown", "sources": { "sd18b1e0134bd": "https://bimigroup.org/how-and-why-to-implement-bimi-selectors/" } }, "submission-update": { "name": "Submission TLS update", "basis": "RFC 8997", "sources": { "sb5ca35fe0d1b": "https://www.rfc-editor.org/rfc/rfc8997.html#section-3" } }, "bind": { "name": "BIND dig manual", "basis": "9.18.39", "sources": { "s54cff06bb882": "https://bind9.readthedocs.io/en/v9.18.39/manpages.html" } }, "openssl": { "name": "OpenSSL command documentation", "basis": "3.0", "sources": { "s00aaf106164f": "https://docs.openssl.org/3.0/man1/openssl-s_client/", "s2a2127a2c79b": "https://docs.openssl.org/3.0/man3/SSL_CONF_cmd/" } } }, "claims": { "spf-identity": {"text": "SPF TXT authorizes connecting IPs for MAIL FROM, or HELO with a null return path; it does not authenticate visible From without DMARC alignment.", "components": ["spf", "dmarc"], "sources": ["spf:sea6ac206b034", "dmarc:s5da292ff1b92"], "status": "REASONED"}, "spf-all": {"text": "-all fails unmatched senders, ~all softfails and may still be delivered, while +all or bare all passes everyone.", "components": ["spf"], "sources": ["spf:sea6ac206b034"], "status": "REASONED"}, "spf-record": {"text": "Publish exactly one v=spf1 record per evaluated domain; multiple records produce permerror.", "components": ["spf"], "sources": ["spf:sea6ac206b034"], "status": "REASONED"}, "spf-lookups": {"text": "Ten DNS-lookup terms across recursion include include/a/mx/ptr/exists/redirect but exclude ip4/ip6/all; exceeding the limit gives permerror. An earlier matching mechanism stops evaluation; permerror is no SPF pass.", "components": ["spf"], "sources": ["spf:sea6ac206b034"], "status": "REASONED"}, "dkim-signing": {"text": "DKIM d= and s= select the _domainkey public key; signatures authenticate signed content for the signing domain, not encryption. Actual signing and verification are required for DMARC.", "components": ["dkim", "dmarc"], "sources": ["dkim:s13ee1bc66dfc", "dmarc:s5da292ff1b92"], "status": "REASONED"}, "dkim-crypto": {"text": "Use rsa-sha256, never rsa-sha1; RFC 8301 sets a 1024-bit RSA floor and recommends at least 2048 bits, used here as the working minimum. Publish the full generated key, not the truncated example.", "components": ["crypto"], "sources": ["crypto:sb675e6772778"], "status": "REASONED"}, "dkim-rotation": {"text": "Publish a new selector before switching signing, retire the old selector after in-flight mail clears, and revoke with empty p=; inspect actual selector, delegation and rotation ownership.", "components": ["dkim"], "sources": ["dkim:s13ee1bc66dfc"], "status": "REASONED"}, "dmarc-alignment": {"text": "DMARC requires at least one passing and aligned SPF or DKIM result; relaxed adkim=r/aspf=r defaults to organizational-domain alignment, strict s requires exact domain matching.", "components": ["dmarc"], "sources": ["dmarc:s5da292ff1b92"], "status": "REASONED"}, "dmarc-policy": {"text": "p=none requests monitoring only, quarantine suspicious handling, and reject SMTP-time rejection; receivers retain local policy, so missing enforcement does not guarantee delivery.", "components": ["dmarc"], "sources": ["dmarc:s5da292ff1b92"], "status": "REASONED"}, "dmarc-subdomains": {"text": "sp covers existing subdomains and np non-existent ones, falling back np to sp to p; a subdomain-specific record can take precedence. Inventory explicit and inherited policies.", "components": ["dmarc"], "sources": ["dmarc:s5da292ff1b92"], "status": "REASONED"}, "dmarc-discovery": {"text": "RFC 9989 DNS tree walk discovers policy and organizational domain, replacing the former public-suffix-list method.", "components": ["dmarc"], "sources": ["dmarc:s5da292ff1b92"], "status": "REASONED"}, "dmarc-testing": {"text": "t=y weakens enforcement and t=n is the default; the illustrated enforcing record omits the tag.", "components": ["dmarc"], "sources": ["dmarc:s5da292ff1b92"], "status": "REASONED"}, "dmarc-rollout": {"text": "Monitor aggregate reports under p=none until legitimate streams align before moving to p=reject, or legitimate mail can be rejected.", "components": ["dmarc", "aggregate"], "sources": ["dmarc:s5da292ff1b92", "aggregate:s5e0e418f74dd"], "status": "REASONED"}, "dmarc-pct": {"text": "RFC 9989, May 2026, obsoletes RFC 7489 and removes pct; legacy p=reject;pct=25 was partial application, not a current rollout control.", "components": ["dmarc"], "sources": ["dmarc:s5da292ff1b92"], "status": "REASONED"}, "aggregate": {"text": "Enable rua daily aggregate XML reporting to observe sending streams; an external reporting domain must authorize reports, and actual receipt/processing must be checked.", "components": ["aggregate"], "sources": ["aggregate:s5e0e418f74dd"], "status": "REASONED"}, "failure-reports": {"text": "ruf forensic reports can expose content, recipients and reset tokens; omit initially, settle redaction/retention/access before enabling, and authorize external destinations.", "components": ["failure"], "sources": ["failure:s136bbcd8e31a"], "status": "REASONED"}, "starttls": {"text": "587 submission upgrades with STARTTLS before authentication; an opportunistic fallback can expose credentials to stripping, so require successful validated TLS.", "components": ["submission"], "sources": ["submission:sd78efc15555c"], "status": "REASONED"}, "implicit-tls": {"text": "465 submission starts with TLS and avoids STARTTLS stripping; RFC 8314 prefers implicit TLS going forward.", "components": ["submission"], "sources": ["submission:sd78efc15555c"], "status": "REASONED"}, "tls-validation": {"text": "Validate certificate chain and hostname before credentials on either submission port; the guide states TLS 1.2 as the floor and attributes that update to RFC 8997.", "components": ["submission", "submission-update"], "sources": ["submission:sd78efc15555c", "submission-update:sb5ca35fe0d1b"], "status": "REASONED"}, "port-25": {"text": "25 is inter-server relay or provider submission; identify the endpoint role instead of assuming that port 25 implies no authentication.", "components": ["roles"], "sources": ["roles:s3f9972faf8ff"], "status": "REASONED"}, "relay-policy": {"text": "Reject unauthenticated untrusted forwarding to non-local recipients while allowing ordinary inbound local delivery and explicitly authorized relay paths.", "components": ["relay"], "sources": ["relay:s1b9349dd99bc"], "status": "REASONED"}, "submission-auth": {"text": "After TLS, unauthenticated or invalidly authenticated submission must fail while authorized submission succeeds.", "components": ["roles", "submission"], "sources": ["roles:s3f9972faf8ff", "submission:sd78efc15555c"], "status": "REASONED"}, "sts-publication": {"text": "MTA-STS TXT advertises the HTTPS text/plain policy at /.well-known/mta-sts.txt on mta-sts.example.com; the example selects enforce, an MX host and max_age 604800.", "components": ["sts"], "sources": ["sts:s39819f3ea93a"], "status": "REASONED"}, "sts-enforcement": {"text": "Only enforce constrains delivery; testing and none do not. MTA-STS protects mail arriving at the policy domain and depends on sending-server validation, not protection of the publishing domain outbound mail.", "components": ["sts"], "sources": ["sts:s39819f3ea93a"], "status": "REASONED"}, "sts-refresh": {"text": "Update the MTA-STS TXT id whenever the policy changes.", "components": ["sts"], "sources": ["sts:s39819f3ea93a"], "status": "REASONED"}, "tls-reports": {"text": "TLS-RPT _smtp._tls TXT with TLSRPTv1 and rua supplies reports, never enforcement.", "components": ["rpt"], "sources": ["rpt:se80da0f5e0c8"], "status": "REASONED"}, "bimi": {"text": "BIMI is optional logo display metadata requiring DMARC enforcement, not authentication or guaranteed rendering; deploy it last.", "components": ["bimi"], "sources": ["bimi:sd18b1e0134bd"], "status": "REASONED"}, "verify-dns": {"text": "Inspect SPF, DKIM TXT/CNAME, DMARC, MTA-STS, TLS-RPT and BIMI records using actual inventory; DNS inspection alone does not evaluate SPF/DMARC or prove a listener. No DNS control was available.", "components": ["spf", "dkim", "dmarc", "sts", "rpt", "bimi", "bind"], "sources": ["spf:sea6ac206b034", "dkim:s13ee1bc66dfc", "dmarc:s5da292ff1b92", "sts:s39819f3ea93a", "rpt:se80da0f5e0c8", "bimi:sd18b1e0134bd", "bind:s54cff06bb882"], "status": "REASONED", "verify": [1]}, "verify-tls": {"text": "587 STARTTLS and 465 implicit-TLS probes require validated TLS 1.2 or newer; TLS availability does not establish AUTH gating. Plaintext or stripped-STARTTLS AUTH must be refused; transport/TLS failures are inconclusive. No SMTP endpoint or container runtime was available.", "components": ["submission", "submission-update", "openssl"], "sources": ["submission:sd78efc15555c", "submission-update:sb5ca35fe0d1b", "openssl:s00aaf106164f", "openssl:s2a2127a2c79b"], "status": "REASONED", "verify": [1]}, "verify-alignment": {"text": "Send a benign message to a controlled recipient and inspect receiver-trusted Authentication-Results, not a supplied header; require passing aligned SPF or DKIM and p=reject with subdomain coverage and no testing or legacy sampling.", "components": ["results", "dmarc"], "sources": ["results:s46f8392d93fd", "dmarc:s5da292ff1b92"], "status": "REASONED"}, "verify-relay": {"text": "In an operator-controlled environment, external MAIL FROM and non-local RCPT TO should receive 5xx; compare with authorized submission and ordinary local delivery. These expected outcomes remain unobserved.", "components": ["relay", "roles"], "sources": ["relay:s1b9349dd99bc", "roles:s3f9972faf8ff"], "status": "REASONED"} } } ```` ====================================================================== ==> bi-dashboards.md ====================================================================== # BI dashboards: Metabase, Superset, Redash Metabase, Apache Superset and Redash are business intelligence tools that turn database queries into charts and dashboards. This guide helps your AI assistant deploy them securely, including private access with MFA at the fronting layer, secured setup and signing secrets, and read-only database accounts unless a dashboard must write data. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/bi-dashboards.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Metabase v0.65.3, Redash v26.9.0, environ 1.2.0, Ring Jetty adapter 1.15.3, and Jetty 12.1.13; 1 further source is pinned to a commit and 4 carry no recorded version. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 25 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These tools hold live connections to your production databases and cache query results in their own storage. An exposed instance, or one still running default or example credentials, leaks both the dashboards themselves and the databases behind them. One rule dominates everything tool-specific below, the same as [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md): **never public.** Reach it over SSH port forwarding, a tailnet ([tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md)), or Cloudflare Access ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md)), with MFA enforced at that fronting layer ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)), and connect it to the database with a least-privilege, read-only account wherever the dashboards do not need to write back. ## Metabase The first account created during setup becomes the admin account, so an instance reachable on the network before you finish the setup wizard lets whoever gets there first claim admin. Complete setup before the instance is reachable from anywhere but you. Metabase binds a wildcard address by default (as of v0.65.3). It reads each `MB_` setting from a `.lein-env` file in the working directory (and a `.boot-env` classpath resource), then the environment, then JVM options such as `-Dmb.jetty.host`, each overriding the one before. The jar leaves Jetty's host unset unless the last of those layers to set `MB_JETTY_HOST` gives it a non-blank value (an empty value in a later layer masks an address in an earlier one), and Jetty binds the wildcard address when no host is set; the official image's entrypoint instead exports `MB_JETTY_HOST=0.0.0.0`, every IPv4 address, whenever the variable is unset or empty. The port is 3000 unless the last layer to set `MB_JETTY_PORT` gives it a non-blank value. For a jar behind a local proxy set `MB_JETTY_HOST=127.0.0.1`, and in Docker publish only to host loopback (`127.0.0.1:3000:3000`), before the first start. Public links and public embeds are enabled by default and let admins share a question, dashboard, or document with anyone holding the URL; visitors get view-only results with no login. Metabase's own docs warn that the public link URL is recoverable from a public embed, so an embed is not a stronger boundary than a plain public link. Row and column security (per-user sandboxing of the underlying data) is a Pro/Enterprise feature, not available on the open-source edition, so on the free tier a public link or a shared question exposes whatever rows and columns the question already queries. Disable public sharing in Admin Settings unless you specifically need it, and treat every public link as a permanent, unauthenticated data release. ## Apache Superset Superset ships a `SECRET_KEY` that signs session cookies; its own docs call it "very important to keep the `SECRET_KEY` secret and set to a secure unique complex random value." Set it, and change the admin password created at first run, before the instance is reachable by anyone else. Do not treat `AUTH_ROLE_PUBLIC` being absent from your config as proof that anonymous access is off; confirm it from the effective Public-role permissions instead. What keeps anonymous visitors out of your data is that the Public role grants no data access by default (Superset: "Data access is still required by default"), so inspect that role and remove any anonymous dashboard or dataset grants. `PUBLIC_ROLE_LIKE` copies another role's permissions to Public at `superset init` (choosing a broad role such as `Gamma` grants access rather than restricting it), and it does not scope access to particular datasets or dashboards, which stay separately controlled. Superset's own `docker-compose.yml` states plainly that the stack is not supported for production and that a real deployment needs its own environment file with unique passwords and `SECRET_KEY`; do not run the example or dev compose file against a production database. ## Redash Setup creates the admin account on first run: "it will ask you to create your admin account. Once this is done, you can start using Redash." Redash's own setup guidance calls out both HTTPS and its signing secrets as things you must set: "If this is a production setup, you should enforce HTTPS and make sure you set the cookie secret." For a manual deployment set a strong, instance-specific `REDASH_COOKIE_SECRET` and `REDASH_SECRET_KEY` (the latter encrypts stored data-source credentials), keep them out of version control, and never reuse them across instances. Front it the same as the other tools here rather than relying on anything Redash provides natively. Redash's `server` command, which the official image runs by default, binds gunicorn to `REDASH_GUNICORN_BIND`, or to `[::]:5000`, the IPv6 wildcard (which may also accept IPv4 on a dual-stack host), when that variable is unset or empty (as of v26.9.0). Behind a local proxy set `REDASH_GUNICORN_BIND=127.0.0.1:5000`; in Docker, publish only to host loopback. ## Verify REASONED: the Metabase, Superset, Redash and Docker port-publishing sources below support this host-side inventory, which is prose rather than part of the block below, because it runs on each tool's host and the block runs from outside your network. On that host, run `ss -tlnp` and read every application AND proxy listener; Metabase/Superset/Redash default to 3000/8088/5000 (ports vary by install). `ss` shows host listeners, not Docker's NAT: for a container also inspect the published address/port and the forwarding rules, and probe each direct origin and backend port from another host (IPv4, IPv6, LAN/VPC); absence from `ss` is not proof of isolation. No BI deployment or external test network is available here, so no listing is recorded. REASONED: authorized-data positive control and anonymous comparison, per the guide's cited Metabase data permissions and Superset security. No BI deployment, authorized account or external probe host is available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; original, missing-assignment, short, empty and embedded-placeholder cases printed refusal messages without reaching curl, while substituted URLs reached two curl stubs. The bash -n, ShellCheck and stub runs recorded here covered the listener and data-route probe block in an earlier form, whose guard refusals left the subshell with status 0; none covered the later form, whose guard refusals left the subshell with status 2, or the current form, whose checks are recorded after the block. Live outcomes remain unobserved: exposed access returns dashboard data anonymously; fixed access denies or challenges it while the authorized request still returns actual data. First, with valid authorization, confirm your chosen protected route returns actual dashboard DATA (a real data or API route, not the SPA HTML shell or a health endpoint): that is the positive control. Then, from OUTSIDE your network with no cookies, tokens, or Access credentials, run the whole block (substitute inside the quotes): REASONED: following block; Metabase, Superset and Redash sources below support the anonymous front-page and data-route checks. No BI deployment or external test network is available here; no live run is recorded. Expected exposed, denied and inconclusive outcomes surround the block. The block records each outcome and exits once: 2 if its guard refuses, 3 if a request fails or the protected route answers 404 or a server error, and otherwise 0. A 401 or 403 is the pass; any other answer is printed for you to judge, because only its body shows dashboard data or a login page, so the block reports no finding itself and its last line says so. ```bash ( # a subshell, so your own script arguments are untouched and one exit status covers the whole block set +e # errexit off, so a request that fails is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK \ 'https://REPLACE_WITH_BI_HOST/' \ 'https://REPLACE_WITH_BI_HOST/REPLACE_WITH_KNOWN_PROTECTED_ROUTE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo "substitute both URLs on the set -- line above; not probing"; exit 2; fi case "$1$2" in *REPLACE_WITH_*|*example.com*) echo "substitute your own URLs on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 3 (inconclusive) outranks 0; the block classifies no finding (see its last line) set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -i -w '\n%{http_code}' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the tool's front page set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then the response with the HTTP code on its last line set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$5" case "$4:$6" in 0:401|0:403) ;; 0:*) echo "unclassified: HTTP $6 from the front page is not a 401 or 403 denial, so read its body against the paragraph after this block" ;; *) echo "curl exit $4: the front-page request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 -i -w '\n%{http_code}' "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" # a known protected route set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # curl's exit status, then the response with the HTTP code on its last line set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # curl's exit status, the response, then the HTTP code printf '%s\n' "$5" case "$4:$6" in 0:401|0:403) ;; 0:404|0:5??) echo "HTTP $6 from the protected route is a 404 or a server error; inconclusive"; set -- "$1" "$2" 3 ;; 0:*) echo "unclassified: HTTP $6 from the protected route is not a 401 or 403 denial, so read its body against the paragraph after this block" ;; *) echo "curl exit $4: the protected-route request failed; inconclusive"; set -- "$1" "$2" 3 ;; esac echo "block exit $3; a front-page answer other than 401 or 403, and a protected-route answer other than 401, 403, 404 or a server error, are not classified, so judge them yourself: the protected route returning dashboard data with no authorization is a fail, a 200 login page or SPA shell proves nothing, and only a verified redirect into your login layer passes" exit "$3" ) ``` REASONED: preceding block; paired data-route authorization, with the listener and Docker-publication inventory and origin isolation in the section's first paragraph, per the guide's cited Docker port publishing, Metabase data permissions and Superset security. No BI deployment or external probe host is available. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics; five rejected guard cases reached no curl stub and substituted URLs reached two. The bash -n, ShellCheck and stub runs recorded here covered the listener and data-route probe block in an earlier form, whose guard refusals left the subshell with status 0; none was re-run on the later form, whose guard refusals left the subshell with status 2. The current form leaves the listener inventory to the section's first paragraph and gives the whole block one exit status. It was checked on 2026-10-31 with bash -n and with curl stubs and no network, covering each status, a failed or inconclusive first request followed by a passing second one, and an inherited `readonly`, `declare -i` or `set -e`. The stub runs covered the refusals of a missing marker, too few or too many values, an empty first or second URL, a placeholder left in either URL and an example.com URL, each with no curl call. ShellCheck 0.11.0 passed on the current form in the repository's gate suite. No live outcome is observed: exposed origins answer or anonymous requests disclose dashboard data; fixed origins are isolated and protected data requires authorization, with an authorized data response as the control. Accept an access denial from WHATEVER protects the route (the tool's or the reverse proxy's 401/403, a Cloudflare Access challenge, or a verified redirect into that login layer), not only the tool's own denial. Read the body, not just the status: a 200 login page or SPA shell proves nothing, and an arbitrary redirect is inconclusive; the protected route returning dashboard data with no authorization is a fail, and a 404, server error, or DNS/TLS/proxy error is inconclusive. For an SSH-forward or tailnet-only deployment the public URL is unreachable by design, so confirm network isolation separately and test the tool's own authorization over the permitted private path. REASONED: default-credential rejection and effective database privileges, per the guide's cited Superset security, Superset docker-compose.yml and Metabase database users, roles, and privileges. No BI account, database role or disposable database is available; this prose supplies no local command or configuration to execute. Live outcomes remain unobserved: exposed credentials permit login or the data-source role writes beyond its needs; fixed credentials reject defaults and the actual role permits intended reads but denies the disposable write, with any required write-back separately scoped. Also confirm no default `admin/admin` or example credentials still work, and that the database principal each data source uses is genuinely least-privilege. The least-privilege check is to identify the actual DB role, inspect its EFFECTIVE grants and inherited roles at the database (a role named `readonly` can still hold write, ownership, or admin privileges), confirm reads work while writes are denied (test with a disposable object), and keep any needed write-back access separately scoped. Checking only the connection string or the tool's own login is not enough. ## Sources (checked September 2026) - Metabase documentation home: https://www.metabase.com/docs/latest/ - Metabase setting up Metabase (first account is admin): https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase - Metabase public links and embeds: https://www.metabase.com/docs/latest/embedding/public-links - Metabase data permissions (row and column security is Pro/Enterprise): https://www.metabase.com/docs/latest/permissions/data - Metabase Jetty host and port (`:mb-jetty-port` default "3000", no host default, blank values treated as unset, nil values dropped from the Jetty options), ring-jetty-adapter 1.15.3 passing the host to `setHost`, and Jetty 12.1.13 binding the wildcard address for a null host (pinned tags v0.65.3, ring 1.15.3, jetty-12.1.13, environ 1.2.0). The same Sources item also covers environ 1.2.0 merging `.lein-env`, `.boot-env`, the environment and JVM properties in that order, keeping empty values, so the last layer to set a key wins, and the image entrypoint's `MB_JETTY_HOST=0.0.0.0` default (pinned tags v0.65.3, ring 1.15.3, jetty-12.1.13, environ 1.2.0). The item's sources (1 of 4): https://github.com/metabase/metabase/blob/v0.65.3/src/metabase/config/core.clj#L54, https://github.com/metabase/metabase/blob/v0.65.3/src/metabase/config/core.clj#L76-L88, https://github.com/metabase/metabase/blob/v0.65.3/src/metabase/server/instance.clj#L36-L41, https://github.com/metabase/metabase/blob/v0.65.3/deps.edn#L82. The item's sources (2 of 4): https://github.com/weavejester/environ/blob/1.2.0/environ/src/environ/core.cljc#L32-L43, https://github.com/weavejester/environ/blob/1.2.0/environ/src/environ/core.cljc#L64-L79, https://github.com/metabase/metabase/blob/v0.65.3/deps.edn#L181, https://github.com/metabase/metabase/blob/v0.65.3/deps.edn#L213. The item's sources (3 of 4): https://github.com/ring-clojure/ring/blob/1.15.3/ring-jetty-adapter/src/ring/adapter/jetty.clj#L202-L207, https://github.com/jetty/jetty.project/blob/jetty-12.1.13/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/ServerConnector.java#L338-L344. The item's sources (4 of 4): https://github.com/metabase/metabase/blob/v0.65.3/bin/docker/run_metabase.sh#L2-L5 and https://github.com/metabase/metabase/blob/v0.65.3/Dockerfile#L72-L75 - Superset security: https://superset.apache.org/admin-docs/security/ - Superset docker-compose.yml (production warning): https://github.com/apache/superset/blob/3d01094d231712e3470eb625be3f9925aeaeadb5/docker-compose.yml - Redash help center: https://redash.io/help/ - Redash setting up a Redash instance: https://redash.io/help/open-source/setup/ - Redash `server` binding gunicorn to `${REDASH_GUNICORN_BIND:-[::]:5000}`, and the image's default `server` command (pinned tag v26.9.0): https://github.com/getredash/redash/blob/v26.9.0/bin/docker-entrypoint#L49-L50 and https://github.com/getredash/redash/blob/v26.9.0/Dockerfile#L133-L134 - Redash secret keys (cookie signing, data-source-credential encryption, do not reuse across instances or commit): https://redash.io/help/open-source/admin-guide/secrets/ - Metabase database users, roles, and privileges (a dedicated least-privilege read-only account): https://www.metabase.com/docs/latest/databases/users-roles-privileges - Docker port publishing (a published container port is served through NAT/forwarding rules, so the host may show no matching listener): https://docs.docker.com/engine/network/port-publishing/ ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - metabase: Metabase (v0.65.3) - redash: Redash (v26.9.0) - metabase-docs: Metabase docs (unknown) - superset: Superset docs (unknown) - superset-compose: Superset Compose (3d01094d231712e3470eb625be3f9925aeaeadb5) - redash-docs: Redash docs (unknown) - environ: environ (1.2.0) - ring: Ring Jetty adapter (1.15.3) - jetty: Jetty (12.1.13) - docker: Docker publishing (unknown) Claims (full source mapping in the JSON block below): - private (REASONED; redash-docs, metabase-docs): Keep BI tools private with fronting MFA and least-privilege read-only DB accounts unless write-back is needed. - metabase-setup (REASONED; metabase-docs): First account is admin; finish setup before anyone else can reach it. - metabase-precedence (REASONED; metabase, environ): Settings merge .lein-env, .boot-env, environment and JVM properties in order; later empty values mask earlier settings. - metabase-bind (REASONED; metabase, ring, jetty): Final nonblank MB_JETTY_HOST sets the jar host; otherwise Ring passes no host and Jetty binds wildcard. Use loopback behind a local proxy. - metabase-image (REASONED; metabase, docker): Image exports MB_JETTY_HOST=0.0.0.0 when unset/empty; publish 127.0.0.1:3000:3000 before first start. - metabase-port (REASONED; metabase): Port defaults 3000 unless the final MB_JETTY_PORT is nonblank. - metabase-sharing (REASONED; metabase-docs): Public links/embeds default enabled and expose view-only question/dashboard/document results without login; disable unneeded sharing. - metabase-embed (REASONED; metabase-docs): Public embed URLs are recoverable; embeds do not strengthen the public-link boundary. - metabase-sandbox (REASONED; metabase-docs): Row/column security is Pro/Enterprise; free-tier sharing exposes the rows/columns the question queries. - superset-secret (REASONED; superset): Set a unique complex random SECRET_KEY before exposure; it signs session cookies. - superset-admin (REASONED; superset, superset-compose): Replace the first-run admin password before exposure. - superset-public (REASONED; superset): Absent AUTH_ROLE_PUBLIC does not prove denial; inspect effective Public-role grants, which provide no data access by default. - superset-copy (REASONED; superset): PUBLIC_ROLE_LIKE copies permissions at superset init; broad roles grant access without scoping datasets/dashboards. - superset-compose (REASONED; superset-compose): Example Compose is unsupported for production; use unique passwords/SECRET_KEY, not the example against production data. - redash-setup (REASONED; redash-docs): Create the first-run admin before others reach setup. - redash-https (REASONED; redash-docs): Enforce HTTPS and the private-access pattern. - redash-cookie (REASONED; redash-docs): Use an instance-specific REDASH_COOKIE_SECRET; never commit or reuse it. - redash-encryption (REASONED; redash-docs): REDASH_SECRET_KEY encrypts data-source credentials; use a strong unique value outside version control. - redash-bind (REASONED; redash): Default image server uses REDASH_GUNICORN_BIND or [::]:5000 when unset/empty; dual-stack may accept IPv4. Bind/publish privately. - verify-credentials (REASONED; metabase-docs, superset-compose, redash-docs): Confirm no default admin/admin or example credentials work. - verify-db (REASONED; metabase-docs): Inspect effective/inherited DB grants; confirm reads and denied writes on a disposable object; scope needed write-back separately. - verify-listeners (REASONED; metabase, superset-compose, redash, docker): Inventory application/proxy sockets and Docker NAT publications; probe direct origins over IPv4/IPv6/LAN/VPC. Absent ss entries do not prove isolation. - verify-ports (REASONED; metabase, superset-compose, redash): Verify lists Metabase/Superset/Redash defaults 3000/8088/5000, qualified as install-dependent. - verify-data (REASONED; metabase-docs, superset, redash-docs): Authorized data is the control; anonymous data fails. Denial/verified login challenge passes; SPA shells, 404/errors and arbitrary redirects are inconclusive. - verify-private (REASONED; redash-docs, docker): For tunnel-only access, confirm public isolation separately and test authorization on the permitted private path. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "180c4ae543cf1f23864fea3ca297582455c76d2365c721cb440b1da09da168fd", "components": { "metabase": { "name": "Metabase", "basis": "v0.65.3", "sources": { "sdc9a71065756": "https://github.com/metabase/metabase/blob/v0.65.3/src/metabase/config/core.clj#L54", "sc404f3a2e81b": "https://github.com/metabase/metabase/blob/v0.65.3/src/metabase/config/core.clj#L76-L88", "sfacbf36d3c3a": "https://github.com/metabase/metabase/blob/v0.65.3/src/metabase/server/instance.clj#L36-L41", "s6a973772ebe5": "https://github.com/metabase/metabase/blob/v0.65.3/deps.edn#L82", "sefe4e2a91acd": "https://github.com/metabase/metabase/blob/v0.65.3/deps.edn#L181", "s58ab8269b060": "https://github.com/metabase/metabase/blob/v0.65.3/deps.edn#L213", "s40c188997580": "https://github.com/metabase/metabase/blob/v0.65.3/bin/docker/run_metabase.sh#L2-L5", "s83a1fd1e4dd6": "https://github.com/metabase/metabase/blob/v0.65.3/Dockerfile#L72-L75" } }, "redash": { "name": "Redash", "basis": "v26.9.0", "sources": { "se93d7c47b037": "https://github.com/getredash/redash/blob/v26.9.0/bin/docker-entrypoint#L49-L50", "s9f1ef7237804": "https://github.com/getredash/redash/blob/v26.9.0/Dockerfile#L133-L134" } }, "metabase-docs": { "name": "Metabase docs", "basis": "unknown", "sources": { "s5e2251b3184d": "https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase", "s521b3063e9f0": "https://www.metabase.com/docs/latest/embedding/public-links", "s02b7797b6ffe": "https://www.metabase.com/docs/latest/permissions/data", "s434abdb1aaef": "https://www.metabase.com/docs/latest/databases/users-roles-privileges" } }, "superset": { "name": "Superset docs", "basis": "unknown", "sources": { "se97be7353585": "https://superset.apache.org/admin-docs/security/" } }, "superset-compose": { "name": "Superset Compose", "basis": "3d01094d231712e3470eb625be3f9925aeaeadb5", "sources": { "s0ceca7af7f79": "https://github.com/apache/superset/blob/3d01094d231712e3470eb625be3f9925aeaeadb5/docker-compose.yml" } }, "redash-docs": { "name": "Redash docs", "basis": "unknown", "sources": { "s4c72252b5447": "https://redash.io/help/open-source/setup/", "sbaf5b44079a0": "https://redash.io/help/open-source/admin-guide/secrets/" } }, "environ": { "name": "environ", "basis": "1.2.0", "sources": { "s37ed6de2d80d": "https://github.com/weavejester/environ/blob/1.2.0/environ/src/environ/core.cljc#L32-L43", "s9754f7c47384": "https://github.com/weavejester/environ/blob/1.2.0/environ/src/environ/core.cljc#L64-L79" } }, "ring": { "name": "Ring Jetty adapter", "basis": "1.15.3", "sources": { "s0455b1b1fb12": "https://github.com/ring-clojure/ring/blob/1.15.3/ring-jetty-adapter/src/ring/adapter/jetty.clj#L202-L207" } }, "jetty": { "name": "Jetty", "basis": "12.1.13", "sources": { "s8f9e4989175e": "https://github.com/jetty/jetty.project/blob/jetty-12.1.13/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/ServerConnector.java#L338-L344" } }, "docker": { "name": "Docker publishing", "basis": "unknown", "sources": { "s1e53417c513d": "https://docs.docker.com/engine/network/port-publishing/" } } }, "claims": { "private": {"text": "Keep BI tools private with fronting MFA and least-privilege read-only DB accounts unless write-back is needed.", "components": ["redash-docs", "metabase-docs"], "sources": ["redash-docs:s4c72252b5447", "metabase-docs:s434abdb1aaef"], "status": "REASONED"}, "metabase-setup": {"text": "First account is admin; finish setup before anyone else can reach it.", "components": ["metabase-docs"], "sources": ["metabase-docs:s5e2251b3184d"], "status": "REASONED"}, "metabase-precedence": {"text": "Settings merge .lein-env, .boot-env, environment and JVM properties in order; later empty values mask earlier settings.", "components": ["metabase", "environ"], "sources": ["metabase:sdc9a71065756", "metabase:sc404f3a2e81b", "environ:s37ed6de2d80d", "environ:s9754f7c47384"], "status": "REASONED"}, "metabase-bind": {"text": "Final nonblank MB_JETTY_HOST sets the jar host; otherwise Ring passes no host and Jetty binds wildcard. Use loopback behind a local proxy.", "components": ["metabase", "ring", "jetty"], "sources": ["metabase:sfacbf36d3c3a", "metabase:s6a973772ebe5", "metabase:sefe4e2a91acd", "metabase:s58ab8269b060", "ring:s0455b1b1fb12", "jetty:s8f9e4989175e"], "status": "REASONED"}, "metabase-image": {"text": "Image exports MB_JETTY_HOST=0.0.0.0 when unset/empty; publish 127.0.0.1:3000:3000 before first start.", "components": ["metabase", "docker"], "sources": ["metabase:s40c188997580", "metabase:s83a1fd1e4dd6", "docker:s1e53417c513d"], "status": "REASONED"}, "metabase-port": {"text": "Port defaults 3000 unless the final MB_JETTY_PORT is nonblank.", "components": ["metabase"], "sources": ["metabase:sdc9a71065756", "metabase:sc404f3a2e81b", "metabase:sfacbf36d3c3a"], "status": "REASONED"}, "metabase-sharing": {"text": "Public links/embeds default enabled and expose view-only question/dashboard/document results without login; disable unneeded sharing.", "components": ["metabase-docs"], "sources": ["metabase-docs:s521b3063e9f0"], "status": "REASONED"}, "metabase-embed": {"text": "Public embed URLs are recoverable; embeds do not strengthen the public-link boundary.", "components": ["metabase-docs"], "sources": ["metabase-docs:s521b3063e9f0"], "status": "REASONED"}, "metabase-sandbox": {"text": "Row/column security is Pro/Enterprise; free-tier sharing exposes the rows/columns the question queries.", "components": ["metabase-docs"], "sources": ["metabase-docs:s02b7797b6ffe"], "status": "REASONED"}, "superset-secret": {"text": "Set a unique complex random SECRET_KEY before exposure; it signs session cookies.", "components": ["superset"], "sources": ["superset:se97be7353585"], "status": "REASONED"}, "superset-admin": {"text": "Replace the first-run admin password before exposure.", "components": ["superset", "superset-compose"], "sources": ["superset:se97be7353585", "superset-compose:s0ceca7af7f79"], "status": "REASONED"}, "superset-public": {"text": "Absent AUTH_ROLE_PUBLIC does not prove denial; inspect effective Public-role grants, which provide no data access by default.", "components": ["superset"], "sources": ["superset:se97be7353585"], "status": "REASONED"}, "superset-copy": {"text": "PUBLIC_ROLE_LIKE copies permissions at superset init; broad roles grant access without scoping datasets/dashboards.", "components": ["superset"], "sources": ["superset:se97be7353585"], "status": "REASONED"}, "superset-compose": {"text": "Example Compose is unsupported for production; use unique passwords/SECRET_KEY, not the example against production data.", "components": ["superset-compose"], "sources": ["superset-compose:s0ceca7af7f79"], "status": "REASONED"}, "redash-setup": {"text": "Create the first-run admin before others reach setup.", "components": ["redash-docs"], "sources": ["redash-docs:s4c72252b5447"], "status": "REASONED"}, "redash-https": {"text": "Enforce HTTPS and the private-access pattern.", "components": ["redash-docs"], "sources": ["redash-docs:s4c72252b5447"], "status": "REASONED"}, "redash-cookie": {"text": "Use an instance-specific REDASH_COOKIE_SECRET; never commit or reuse it.", "components": ["redash-docs"], "sources": ["redash-docs:sbaf5b44079a0"], "status": "REASONED"}, "redash-encryption": {"text": "REDASH_SECRET_KEY encrypts data-source credentials; use a strong unique value outside version control.", "components": ["redash-docs"], "sources": ["redash-docs:sbaf5b44079a0"], "status": "REASONED"}, "redash-bind": {"text": "Default image server uses REDASH_GUNICORN_BIND or [::]:5000 when unset/empty; dual-stack may accept IPv4. Bind/publish privately.", "components": ["redash"], "sources": ["redash:se93d7c47b037", "redash:s9f1ef7237804"], "status": "REASONED"}, "verify-credentials": {"text": "Confirm no default admin/admin or example credentials work.", "components": ["metabase-docs", "superset-compose", "redash-docs"], "sources": ["metabase-docs:s5e2251b3184d", "superset-compose:s0ceca7af7f79", "redash-docs:s4c72252b5447"], "status": "REASONED"}, "verify-db": {"text": "Inspect effective/inherited DB grants; confirm reads and denied writes on a disposable object; scope needed write-back separately.", "components": ["metabase-docs"], "sources": ["metabase-docs:s434abdb1aaef"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory application/proxy sockets and Docker NAT publications; probe direct origins over IPv4/IPv6/LAN/VPC. Absent ss entries do not prove isolation.", "components": ["metabase", "superset-compose", "redash", "docker"], "sources": ["metabase:sdc9a71065756", "superset-compose:s0ceca7af7f79", "redash:se93d7c47b037", "docker:s1e53417c513d"], "status": "REASONED"}, "verify-ports": {"text": "Verify lists Metabase/Superset/Redash defaults 3000/8088/5000, qualified as install-dependent.", "components": ["metabase", "superset-compose", "redash"], "sources": ["metabase:sdc9a71065756", "superset-compose:s0ceca7af7f79", "redash:se93d7c47b037"], "status": "REASONED"}, "verify-data": {"text": "Authorized data is the control; anonymous data fails. Denial/verified login challenge passes; SPA shells, 404/errors and arbitrary redirects are inconclusive.", "components": ["metabase-docs", "superset", "redash-docs"], "sources": ["metabase-docs:s521b3063e9f0", "superset:se97be7353585", "redash-docs:s4c72252b5447"], "status": "REASONED", "verify": [1]}, "verify-private": {"text": "For tunnel-only access, confirm public isolation separately and test authorization on the permitted private path.", "components": ["redash-docs", "docker"], "sources": ["redash-docs:s4c72252b5447", "docker:s1e53417c513d"], "status": "REASONED", "verify": [1]} } } ```` ====================================================================== ==> low-code-builders.md ====================================================================== # Low-code internal-tool builders: NocoDB, Baserow, Appsmith, Budibase, and Windmill NocoDB, Baserow, Appsmith, Budibase and Windmill are low-code platforms for building internal tools and automations; Windmill also runs scripts. This guide helps your AI assistant deploy them securely, including private instances behind a login proxy, first admin accounts secured before exposure, sign-up restricted where open, and shipped default secrets replaced. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/low-code-builders.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against NocoDB 2026.09.0, Baserow 2.3.4, Appsmith v2.4.1, Budibase v3.46.0, Windmill v1.864.1, Docker CLI v27.2.0 and v27.1.2, Node.js v22.22.1, and CouchDB 3.5.2; 1 further source is pinned to a commit and 1 carries no recorded version. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 51 claims: 7 demonstrated by a check that ran and 44 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These tools sit on top of your databases and APIs and let people build internal apps, forms and automations quickly. That is also the exposure. Each one stores credentials for the systems it connects to, and Windmill runs arbitrary code by design. Three first-run patterns recur. On a fresh instance, the first visitor becomes the administrator. Open signup is on by default. And the vendor's own sample configuration ships fixed secrets that too many deployments keep. Keep every one of them private ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md), [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md)), claim the admin account before the instance is reachable, and front the web UI per [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). Treat the database behind each tool as a store of every connected system's credentials ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). Versions checked: NocoDB 2026.09.0, Baserow 2.3.4, Appsmith v2.4.1, Budibase v3.46.0 and Windmill v1.864.1. ## NocoDB NocoDB listens on port 8080 (`process.env.PORT || '8080'`), and its entry point calls `listen` with no host. For an omitted host, Node "will accept connections on the unspecified IPv6 address (`::`) when IPv6 is available, or the unspecified IPv4 address (`0.0.0.0`) otherwise", so the server listens on every interface; its code passes no host, so NocoDB offers no bind setting. Restrict it with the container publication or a firewall. The first account to sign up gets the super-admin role, and later self-signups are allowed unless invite-only signup is on; `invite_only_signup` defaults to `false`. Claim the admin account first, or seed it with `NC_ADMIN_EMAIL` and `NC_ADMIN_PASSWORD`, then turn on invite-only signup. Two secrets need your attention: - **`NC_CONNECTION_ENCRYPT_KEY`.** When it is unset, the encryption helper returns the value unchanged, so the credentials of every external database you connect are stored in plaintext in NocoDB's meta database. Set it before connecting a data source; when it is set later, NocoDB encrypts the existing sources on startup. - **`NC_AUTH_JWT_SECRET`.** When it is unset, NocoDB generates a UUID, stores it in the meta database as `nc_auth_jwt_secret` and uses it, which is fine. The README's Docker example hard-codes one literal value (`569a1821-0a93-45e8-87ab-eb857f20a010`), so everyone who copies that example signs sessions with the same key. Generate your own. The entry file allows every CORS origin. It also enables Express's `trust proxy`, but startup then resets that from `NC_TRUST_PROXY`, which trusts no proxy unless you set it; once a proxy is in front, set it to that proxy's hop count or subnet, and never to `true` on a server clients can reach directly. The 2026.09.0 tag's README says binaries are only for quick local testing ([README, line 95](https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L95)). The GitHub release API record listed no assets when checked in October 2026 ([release record](https://api.github.com/repos/nocodb/nocodb/releases/tags/2026.09.0)); this guide assesses that record as showing no 2026.09.0 GitHub release binary assets, without establishing what other download services provide. The tag carries NocoDB package version `0.301.3` ([package manifest](https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/package.json#L3)); `2026.09.0` is the tag name, not that package version. The README documents the `nocodb/nocodb:latest` Docker image ([Docker examples](https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L56-L75)) and calls Auto-upstall a server production installation that generates Docker Compose ([Auto-upstall section](https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L77-L90)). For production, use the container image or the vendor's install script (this guide's advice based on those README paths). The README's Auto-upstall command fetches the script over plain `http://` and runs it with Bash ([README command](https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L82)); its source link points to the `develop` branch ([README source link](https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L90)). This guide advises fetching the script over HTTPS from a pinned, reviewed source and reading it before running it, rather than running the plain-HTTP response directly with Bash. ## Baserow Baserow's default Compose file publishes the bundled Caddy on 80 and 443 at `${HOST_PUBLISH_IP:-0.0.0.0}`, so every interface unless you set `HOST_PUBLISH_IP`. The vendor's install guide warns that "docker when exposing ports on 0.0.0.0 will bypass any ufw firewall rules", and suggests `-p 127.0.0.1:80:80 -p 127.0.0.1:443:443` if public exposure is not intended. The first user to sign up is made staff (`is_staff=not User.objects.exists()`), and new signups are allowed by default (`allow_new_signups` defaults to `True`). Sign up first, then turn off new signups in the admin settings; a refused signup then fails with "Sign up is disabled." The Compose file refuses to start without `SECRET_KEY`, `DATABASE_PASSWORD` and `REDIS_PASSWORD` (`${SECRET_KEY:?}`), and `.env.example` leaves them empty rather than shipping examples, so the secrets are yours to generate. Baserow's core registers a TOTP two-factor authentication provider. ## Appsmith Appsmith's Java server binds to `127.0.0.1` on 8080 by default (`APPSMITH_SERVER_ADDRESS`), inside a container fronted by a bundled Caddy on port 80. The vendor's development Compose file publishes that on host port 8080, and its AWS example on 80 and 443. Signup is enabled by default (`APPSMITH_SIGNUP_DISABLED` defaults to `false`). On a fresh instance, the first user to sign up claims the super-user slot while no users exist. Claim it before exposing the instance, then set `APPSMITH_SIGNUP_DISABLED=true` or restrict signup with `APPSMITH_SIGNUP_ALLOWED_DOMAINS`; a refused signup gets the `SIGNUP_DISABLED` error, "Signup is restricted on this instance of Appsmith". Datasource credentials are encrypted with `APPSMITH_ENCRYPTION_PASSWORD` and `APPSMITH_ENCRYPTION_SALT`. On first start, the image's entrypoint generates random 13-character values for both. But the vendor's development Compose file sets both to `abcd`, and values passed from outside override the generated ones. Anyone who copies that file encrypts every stored datasource credential with a published key. Remove those two lines, or set long random values, before the first start. On an instance that already stored credentials under `abcd`, changing the pair presumably leaves them unreadable, so plan to re-enter them (inferred from the encryption design, not tested). ## Budibase Budibase's Compose file publishes its nginx proxy on `MAIN_PORT` (10000 in the sample `.env`) and its LiteLLM service on `${LITELLM_PORT:-4000}`. The proxy forwards `/db/` to CouchDB (CouchDB upstream variable and proxy location sources below). The sample `hosting/.env` sets `JWT_SECRET` and `API_ENCRYPTION_KEY` to `testsecret`, and the CouchDB, MinIO and Redis passwords, `INTERNAL_API_KEY` and `LITELLM_MASTER_KEY` to `budibase`, under a comment that says "These should be updated" (sample secret assignments source below). This guide's October 2026 search of the full v3.46.0 tag with `git grep -l testsecret v3.46.0` found exactly these five files: `hosting/.env`, `hosting/digitalocean/files/var/lib/cloud/scripts/per-instance/001_onboot`, `hosting/hosting.properties`, `packages/backend-core/tests/jestEnv.ts` and `scripts/dev/manage.js`. This guide inspected those five files: sample assignments, first-boot substitution, test assignments and development entries. This guide's assessment is that those hits do not establish that production code never rejects the sample values; no live safeguard test ran. Compose maps `COUCH_DB_USER` and `COUCH_DB_PASSWORD` to the `COUCHDB_USER` and `COUCHDB_PASSWORD` environment of `budibase/database:2.1.0`; the image version file also says `2.1.0`. The Dockerfile installs the entrypoint and selects the runner, which invokes that entrypoint for CouchDB. With both values non-empty, the entrypoint appends the admin credentials only if its existing-admin check finds no entry for that name (CouchDB image and credential mapping, entrypoint installation, runner command, image version file, runner call and conditional admin creation sources below). With the sample file unchanged and that creation condition met, anyone who can reach the proxy can try `budibase`/`budibase` against CouchDB through `/db/`. Compose pins `docker.io/litellm/litellm:main-v1.83.10-stable.patch.3` and passes `LITELLM_MASTER_KEY` (LiteLLM image and environment source below); acceptance of `budibase` by that image is not established. This guide has not established that [litellm.md](https://github.com/jposluns/secureconfig/blob/main/litellm.md)'s pinned source `6ef7b86748118ceecd95271727c2fa167ce55fec` corresponds to that image; no LiteLLM source for that image was fetched. Replace every one of those values before the first start. The first-run admin route `POST /api/global/users/init` is public (public init endpoint entry and public authentication middleware chain sources below). Its controller calls `checkAnyUserExists()` inside `tenancy.doInTenant(tenantId, ...)` using the request's `tenantId`; a truthy result produces HTTP 403 with "You cannot initialise once an global user has been created." (admin-init controller and refusal source below). The helper is imported from `packages/worker/src/utilities/users.ts`, which was not fetched; which users count and any multi-tenancy exception in that helper remain unestablished. The route's `buildAdminInitValidation()` runs before `controller.adminUser`; it requires string `email` and `tenantId` fields, allows an optional password and rejects unknown fields (admin-init body validator and middleware order sources below). This guide assesses this POST as unsafe for read-only claimed-state probing: invalid bodies can fail validation in both states, while accepted initialization creates an admin. Create the admin before exposure, manually or with `BB_ADMIN_USER_EMAIL` and `BB_ADMIN_USER_PASSWORD` (this guide's advice; admin environment definitions and app-service mapping sources below). Startup attempts seeding only when `SELF_HOSTED` is truthy, `MULTI_TENANCY` is falsy and both values are non-empty; in the `tenancy.getTenantId()` context, it calls `createAdminUser` when `!checklist?.adminUser?.checked || !exists`, where `exists` is `users.doesUserExist(bbAdminEmail)`. That call sets `hashPassword`, `requirePassword` and `skipPasswordValidation` to `true` (startup admin seeding conditions source below). ## Windmill Windmill runs scripts in Python, TypeScript, Go, Bash and more on its workers, so a Windmill login is code execution on your worker hosts. The server listens on port 8000 on every interface by default (`DEFAULT_SERVER_BIND_ADDR` is `0.0.0.0`; `SERVER_BIND_ADDR` changes it when you run the binary). In the vendor's Compose file the server only `expose`s 8000 to the Compose network, and the bundled Caddy publishes `80:80` and `25:25` on every host interface, so port 80 is where the login page is reachable. A fresh database has one password user, `admin@windmill.dev`, a super admin whose password is `changeme`; the README gives them as the default credentials. On a v1.817.0 loopback run of the release binary with `SERVER_BIND_ADDR=127.0.0.1`, that was the only password user in the fresh database, and it was a super admin. Logging in with `changeme` returned `200` and a token, and `whoami` confirmed super-admin rights. After the password was changed, `changeme` got `400` and the new password `200`. The server's only listener was on 127.0.0.1, at the test port the run set. Change the password before anything else can reach the server. In Compose, bind Caddy's `ports:` entries to `127.0.0.1` or a private address ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)); `SERVER_BIND_ADDR=127.0.0.1` inside the server container would presumably cut Caddy off from it (inferred, not tested). The worker sandbox is not on by default either. `DISABLE_NSJAIL` defaults to `true` in the worker, and on the loopback run, without nsjail installed, the worker logged "Nsjail sandboxing will NOT be available". The vendor's Compose file runs the worker with `privileged: true` and publishes port 25 for email triggers. Its debugger setting carries the vendor's warning: `REQUIRE_SIGNED_DEBUG_REQUESTS` "Do NOT set to false on any internet-reachable deployment: it exposes an unauthenticated code-execution debugger". Do not publish 25 unless you use email triggers. ## Verify REASONED: Historical run summary, not a new demonstration. All six shell blocks passed bash -n, and ShellCheck 0.11.0 reported no findings. Local stubs exercised inventory, key-presence, login-request, TCP control and per-port paths; sample and replaced file fixtures printed a line beginning "FOUND vendor sample tokens:" and "no vendor sample tokens found". The inventory block had no guard refusal until its check for a missing `ss` was added, and the runs recorded here covered it in an earlier form, two top-level commands without a status of their own; for the five other blocks, the bash -n, ShellCheck, stub and fixture runs recorded here covered an earlier form, whose guard refusals ended with status 0. The current forms each end with one status for the whole fence: 2 for a guard refusal, 1 for a finding, 3 for a failed or inconclusive check and otherwise 0. The current forms passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0, except that the bash -n and local stub runs recorded here for the inventory block covered a form without its check for a missing `ss`; its current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing. No deployment was available for this review; the authoring host forbids opening listeners without an isolated network namespace, and none was available. Exposed/fixed expectations below that are not recorded as demonstrated remain source reasoning, including the guide's cited Docker packet filtering (`DOCKER-USER`). The following host and run statements describe the earlier authoring environment. Three checks here were demonstrated: the sample-token check, the Windmill default-login pair, and the TCP reachability probe's outcomes on loopback; the NocoDB key check's logic was exercised with a stand-in for `docker exec`. Those demonstrations and the stand-in run used earlier forms of their blocks, whose guard refusals ended with status 0; the current forms, whose guard refusals exit with status 2, have had no demonstration or stand-in run. Everything else is reasoned. NocoDB 2026.09.0, Baserow, Appsmith and Budibase ship for production as container images and the authoring host has no container runtime. NocoDB also cannot run here outside a container: its server calls `listen` with no host, so it can only bind every interface, which the host forbids; the same applies to Windmill's default bind. The remaining checks are REASONED from the cited vendor documentation and pinned source readings. REASONED: The inventory block passed bash -n; sudo and docker stubs printed wildcard and loopback publications. Those runs covered an earlier form of the block, two top-level commands without a status of their own; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing. No deployment was available for inventory; the authoring host has no container runtime and forbids opening listeners without an isolated network namespace, and none was available. Expect wildcard addresses when exposed and loopback or private addresses when fixed, per the guide's cited Docker packet filtering (`DOCKER-USER`) and Docker CLI port display (`[::]:` from v27.2.0, `:::` through v27.1.2). On the host, list the listeners, then read Docker's own publications, because a port published through Docker's NAT may have no host socket at all, so absence from `ss` is not proof of isolation: REASONED: following block; pinned Compose and Docker sources support listener/publication inventory, and the `sudo` caveat below rests on the guide's cited sudo sudoers manual, secure_path entry. The recorded host has no container runtime and forbids wildcard binds; exposed/fixed expectations follow. After the runs above, bash -n and local stub runs covered a form of the block without its check for a missing `ss`; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2 while `docker ps` succeeded. Exposed, the reasoned expectation is a wildcard address, on the host or in a Docker publication, for any of the ports the block's comments list; fixed means a loopback or private address only. The block leaves both lists for you to judge, because a firewall rule can filter a wildcard publication, and it exits 3 if `ss` or `docker ps` fails, otherwise 2 if `ss` is not installed, and otherwise 0. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. ```bash ( # one subshell, so one exit status covers both lists: 3 if either command fails, otherwise 2 if ss is not installed, otherwise 0 set +e # errexit off here, so a failed command is recorded below instead of ending the block set -- 0 # the block's status so far: 3 (inconclusive) outranks 2 (ss is not installed), which outranks 0 if ! command -v ss >/dev/null; then echo "ss is not installed here; not listing sockets"; set -- 2 elif sudo ss -tlnp; then :; else # 8080 (NocoDB; Appsmith dev Compose), 80/443 (Baserow, Windmill Caddy), 10000 and 4000 (Budibase), 8000 and 25 (Windmill): loopback or private only echo "sudo ss -tlnp exit $?: the listener list did not print; inconclusive"; set -- 3 fi if docker ps --format '{{.Names}}\t{{.Ports}}'; then :; else # every "0.0.0.0:" or "[::]:" (":::" before Docker CLI 27.2) publication accepts connections on every host address: reachable from outside unless a firewall rule (for example in DOCKER-USER) filters it echo "docker ps exit $?: the publication list did not print; inconclusive"; set -- 3 fi echo "block exit $1; the listeners and publications above are not classified, so judge them yourself" exit "$1" ) ``` REASONED: following block; the file-check block passed bash -n; local sample and replaced fixtures printed a line beginning "FOUND vendor sample tokens:" and "no vendor sample tokens found"; a missing path printed "cannot read", the path, then "as a regular file; not checked". Placeholder, missing-marker, short-assignment and empty inputs stopped locally. The bash -n and fixture runs recorded here covered the file-check block in an earlier form, whose guard refusals and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for a sample token in value position and 3 for a failed read. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. Original vendor files were unavailable as fixtures in this offline run. Expected sample hits and their absence after replacement follow the guide's cited Budibase v3.46.0 sample environment, Appsmith v2.4.1 development Compose file and Windmill v1.864.1 Compose file. Check each environment and Compose file the deployment uses for the vendor sample values: run the block once per file, including every file an `env_file:` line names and the `.env` Compose reads for variable substitution. Substitute the file path inside the single quotes (a path containing an apostrophe needs other quoting), and paste the whole block. The block prints every whole-token hit but exits 1 only when one sits in value position, after `=`, `:`, `:-` or a quote and ending the value (followed by the line's end, whitespace, including a CRLF line's carriage return, a quote or one of `,;&@})`); hits only elsewhere, such as `image: budibase/apps`, end with status 0 and an unclassified-observation line. DEMONSTRATED: following block; recorded sample-token runs found vendor values and syntax variants, found none in replaced copies and refused an unreadable path, as detailed below. The sample-token runs used an earlier form of the block, whose guard refusals and result arms ended with status 0; the current form exits 2 for a guard refusal, 1 for a sample token in value position and 3 for a failed read. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. External environment values are outside this file check. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ENV_OR_COMPOSE_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not checking"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not checking"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute your file on the set -- line above; not checking"; exit 2 ;; esac { [ -f "$1" ] && [ -r "$1" ]; } || { echo "cannot read $1 as a regular file; not checked"; exit 2; } if grep -n -E '(^|[^ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_])(testsecret|budibase|abcd|changeme|569a1821-0a93-45e8-87ab-eb857f20a010)([^ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]|$)' -- "$1"; then # every whole-token hit if grep -q -E '(=|:-?|"|'"'"')[[:blank:]]*(testsecret|budibase|abcd|changeme|569a1821-0a93-45e8-87ab-eb857f20a010)($|[[:space:]]|"|'"'"'|,|;|&|@|\}|\))' -- "$1"; then # a hit in value position: after =, :, :- or a quote, and ending the value echo "FOUND vendor sample tokens: inspect each line above; replace every secret still set to one"; exit 1 else case "$?" in 1) echo "no hit above is in a value position this search classifies; unclassified observation: inspect each line above"; exit 0 ;; *) echo "grep could not read $1; not checked"; exit 3 ;; esac fi else case "$?" in 1) echo "no vendor sample tokens found" ;; *) echo "grep could not read $1; not checked"; exit 3 ;; esac fi ) ``` DEMONSTRATED: preceding block; the sample-token runs recorded below used an earlier form of the block, whose result arms ended with status 0. The search matches each sample value as a whole token wherever it appears, so it cannot report a file clean while one of them is still in it; the cost is that it also reports harmless lines such as `image: budibase/apps`, so inspect each hit. Such a line alone ends with status 0 and an unclassified-observation line, not with the finding's status 1. Values supplied from outside the files, such as the shell environment Compose runs in, are beyond its reach. This was demonstrated with the earlier form. On Budibase's sample `hosting/.env` it reported all eleven sample lines, on Appsmith's development Compose file the two `abcd` lines, and on Windmill's Compose file its `changeme` Postgres password. It also reported Compose defaults (`${JWT_SECRET:-testsecret}`), flow mappings and lists (`{COUCH_DB_PASSWORD: budibase, X: 1}`), URL parameters (`&password=changeme&`) and trailing punctuation. On copies with the values replaced it printed "no vendor sample tokens found", and on an unreadable path it reported that it did not check. In local stub runs of the current form, a flow mapping, a `:-` default and a URL parameter ended with status 1, while a list item such as `command: [redis-server, --requirepass, budibase]`, a Compose default without a colon such as `${JWT_SECRET-testsecret}` and a value followed by other punctuation, such as `changeme.`, were printed but ended with status 0 and the unclassified-observation line. REASONED: The key-presence block passed bash -n; a docker stub executing the inner shell test printed "non-empty" for a set key, "EMPTY or unset" for empty and absent keys, and "not checked" for a missing container. Placeholder, missing-marker, short-assignment, empty and option-like inputs stopped locally. Round-2 Bash stub runs also stopped tab, LF, CR and DEL inputs with "substitute the container name on the set -- line above; not checking", without calling docker. The bash -n and stub runs recorded here covered the key-presence block in an earlier form, whose guard refusals and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for an empty or unset key and 3 for a failed check. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. No deployed NocoDB container was available; the authoring host has no container runtime and forbids opening listeners without an isolated network namespace, and none was available. Expect "EMPTY or unset" when exposed and "non-empty" when fixed; this tests presence only, per the guide's cited Docker `docker exec` environment and NocoDB 2026.09.0 container start scripts. For NocoDB, check the encryption key in the container rather than in a file: Compose variable substitution, `env_file:` precedence and YAML values all decide the value, and the container's configured environment is their result. `docker exec` runs with that configured environment, not with the server process's own, so the two differ if the image's startup changes the variable. NocoDB's pinned start scripts, `docker/start.sh` and `docker/start-litestream.sh`, run `node docker/main.js` without touching it, but the pinned tree has no Dockerfile, so which script the image runs was not verified. The block reports only whether the key is non-empty and never prints it. Substitute the container name inside the single quotes. REASONED: Presence-check assumptions and limits, per the guide's cited Docker `docker exec` environment, NocoDB 2026.09.0 container start scripts and NocoDB 2026.09.0 credential encryption. The key-presence block passed bash -n; a docker stub executing the inner shell test printed "non-empty" for a set key, "EMPTY or unset" for empty and absent keys, and "not checked" for a missing container. Placeholder, missing-marker, short-assignment, empty and option-like inputs stopped locally. Round-2 Bash stub runs also stopped tab, LF, CR and DEL inputs with "substitute the container name on the set -- line above; not checking", without calling docker. The bash -n and stub runs recorded here covered the key-presence block in an earlier form, whose guard refusals and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for an empty or unset key and 3 for a failed check. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. No deployed NocoDB container was available; the authoring host has no container runtime and forbids opening listeners without an isolated network namespace, and none was available. Expect "EMPTY or unset" when exposed and "non-empty" when fixed; this tests presence only, per the guide's cited Docker `docker exec` environment and NocoDB 2026.09.0 container start scripts. Process-environment visibility, image startup selection and encryption were not tested against a deployed container. This check assumes a clean host Bash shell, trusted container image and startup files, and a container shell whose `[` is a builtin. It intentionally tests an existing container environment value: prompting for a key or supplying a file would test that replacement, not the deployed configuration. Docker's documented `exec` environment inheritance and the pinned NocoDB startup scripts cited below are the trace for this assumption. The host sends only the literal variable name and test program, never the key, in argv. The container shell's builtin `[` tests the value without starting a child process with it in argv. The key remains in the container's configured environment and is inherited by this extra shell; the same account and root may read it through `/proc//environ` for those processes' lifetimes, and Docker-socket holders can read the configured value with `docker inspect`. This presence check does not remove that exposure, prove encryption is working, or establish what the server process actually received. Do not use `docker exec -e` to inject a key for this check. REASONED: following block; Docker exec inheritance and pinned NocoDB start scripts support key-presence checking. No real container ran because the recorded host has no container runtime; only stand-in logic outcomes below were observed. Those stand-in runs covered an earlier form of the block, whose result arms ended with status 0; the current form, which reads docker's status through an if condition, passed bash -n and local stub runs, and the stand-in runs were not repeated on it. ```bash ( trap - DEBUG RETURN ERR set +x +a +e set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NOCODB_CONTAINER_NAME' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not checking"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not checking"; exit 2; } case "$1" in *REPLACE_WITH_*|""|-*|*[[:cntrl:]]*) echo "substitute the container name on the set -- line above; not checking"; exit 2 ;; esac # shellcheck disable=SC2016 # the single-quoted script is meant to expand inside the container if docker exec "$1" sh -c 'set +x; if [ -n "${NC_CONNECTION_ENCRYPT_KEY-}" ]; then exit 0; else exit 3; fi'; then echo "NC_CONNECTION_ENCRYPT_KEY is non-empty in the container's configured environment" else case "$?" in 3) echo "NC_CONNECTION_ENCRYPT_KEY is EMPTY or unset in the container's configured environment"; exit 1 ;; *) echo "could not run the check in container $1; not checked"; exit 3 ;; esac fi ) ``` REASONED: preceding block; The key-presence block passed bash -n; a docker stub executing the inner shell test printed "non-empty" for a set key, "EMPTY or unset" for empty and absent keys, and "not checked" for a missing container. Placeholder, missing-marker, short-assignment, empty and option-like inputs stopped locally. Round-2 Bash stub runs also stopped tab, LF, CR and DEL inputs with "substitute the container name on the set -- line above; not checking", without calling docker. The bash -n and stub runs recorded here covered the key-presence block in an earlier form, whose guard refusals and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for an empty or unset key and 3 for a failed check. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. No deployed NocoDB container was available; the authoring host has no container runtime and forbids opening listeners without an isolated network namespace, and none was available. Expect "EMPTY or unset" when exposed and "non-empty" when fixed; this tests presence only, per the guide's cited Docker `docker exec` environment and NocoDB 2026.09.0 container start scripts. The following stand-in account is the guide's earlier record. Exposed: "EMPTY or unset". Fixed: "non-empty". Anything else means the check did not run. With a stand-in for `docker exec`, the block printed "non-empty" for a set key, "EMPTY or unset" for an empty and an unset one, and "not checked" when the container was missing; it was not run against a real NocoDB container (no container runtime on the authoring host). REASONED: The login block passed bash -n, its JSON parsed, and a curl stub received the default credentials on stdin and the substituted /api/auth/login URL. Placeholder, missing-marker, short-assignment and empty inputs stopped locally. Round-2 Bash stub runs also stopped tab, LF, CR and DEL inputs with "substitute the base URL on the set -- line above; not probing", without calling curl. The bash -n and stub runs recorded here covered the login block in an earlier form, whose guard refusals ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for a `200`, 0 for a `400` and 3 otherwise. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. No live Windmill deployment was available for credential rotation in this review; the authoring host forbids opening listeners without an isolated network namespace, and none was available. Expected default-login success before rotation and rejection afterwards follow the guide's cited Windmill v1.864.1 README (default credentials); precise HTTP observations below belong to the earlier recorded run. For Windmill, check whether the default login still works. The password below is the vendor's published default, not a secret, so it is fine in the request body. Substitute the base URL (for example the Caddy address on port 80) inside the single quotes. DEMONSTRATED: following block; Windmill v1.817.0 loopback returned 200 for changeme before rotation, 400 afterwards and 200 for the replacement password. This does not demonstrate external isolation. The loopback run used an earlier form of the block, whose guard refusals ended with status 0 and whose result was curl's own exit status; the current form exits 2 for a guard refusal, 1 for a `200`, 0 for a `400` and 3 otherwise. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_WINDMILL_BASE_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the base URL on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$(if printf '%s' '{"email":"admin@windmill.dev","password":"changeme"}' | \ curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 --max-time 15 \ -H 'Content-Type: application/json' --data-binary @- \ -w 'default login: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{http_code}' "$1/api/auth/login"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the URL, curl's exit status, then its result line and HTTP code set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the URL, curl's exit status, its result line, then the HTTP code printf '%s\n' "$3" case "$2:$4" in 0:200) echo "FINDING: the default login was accepted with 200"; exit 1 ;; 0:400) echo "the default login was refused with 400; log in once with your real credentials before trusting it, so that a dead service is not read as fixed"; exit 0 ;; *) echo "neither the 200 finding nor the 400 pass; inconclusive"; exit 3 ;; esac ) ``` DEMONSTRATED: preceding block; Windmill v1.817.0 loopback run with an earlier form of the block, whose result was curl's own exit status. Exposed, the default login gets `200` with a token; fixed, it gets `400` (both observed with that earlier form of this block on the loopback run). Before trusting a `400`, log in once with your real credentials, so that a dead service is not read as fixed. The block exits 1 on a `200`, 0 on a `400` and 3 on anything else. REASONED: The two sample-value requests use Budibase v3.46.0 sample environment for the sample values and Budibase v3.46.0 proxy configuration (`/db/`) for the path. The Apache CouchDB 3.5.2 `/_session` reference supports the CouchDB 401 for a wrong password; the LiteLLM 401 for a non-`sk-` key when a database is connected and the 400 without one are supported by LiteLLM authentication source: non-`sk-` keys rejected with 401 when a database is connected; 400 without one (checked October 2026). These LiteLLM outcomes remain comparison expectations: this guide has not established that litellm.md's source pin `6ef7b86748118ceecd95271727c2fa167ce55fec` corresponds to Compose image `docker.io/litellm/litellm:main-v1.83.10-stable.patch.3` (LiteLLM image and environment source below); no source for that image was fetched. Exposed: either line 200; fixed: CouchDB 401, and LiteLLM returns a 401 (or a 400 if the proxy has no database), with real credentials still accepted. These are expectations, not live observations. The recorded host has no container runtime. A live Budibase or LiteLLM stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. For Budibase, two reasoned requests use the published sample values, which are not secrets. Substitute the proxy URL (`http://HOST:10000` in the sample setup) and the LiteLLM URL inside the single quotes. REASONED: following block; Budibase v3.46.0 sample environment supplies the sample values, and Budibase v3.46.0 proxy configuration (`/db/`) supplies the path. Apache CouchDB 3.5.2 `/_session` reference supports the CouchDB 401 for a wrong password. For the LiteLLM 401 with a database connected or 400 without one, see LiteLLM authentication source: non-`sk-` keys rejected with 401 when a database is connected; 400 without one (checked October 2026). These LiteLLM outcomes remain comparison expectations: this guide has not established that litellm.md's source pin `6ef7b86748118ceecd95271727c2fa167ce55fec` corresponds to Compose image `docker.io/litellm/litellm:main-v1.83.10-stable.patch.3` (LiteLLM image and environment source below); no source for that image was fetched. Exposed: either line 200; fixed: CouchDB 401, and LiteLLM returns a 401 (or a 400 if the proxy has no database), with real credentials still accepted. The exposed 200 outcomes still require live comparison. No Budibase/LiteLLM containers ran; the recorded host has no container runtime. A live Budibase or LiteLLM stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review (October 2026, Bash 5.3.9 and ShellCheck 0.11.0) checked syntax and guard fixtures with a curl stub; no service response was observed. Bash -n and ShellCheck 0.11.0 each exited 0 with no output. That local marking review covered an earlier form of the block, whose guard refusals ended with status 0; that review's bash -n, ShellCheck and guard fixtures were not repeated on the current form, whose guard refusals exit with status 2. The current form, which exits 2 for a guard refusal, 1 if either sample credential gets a `200`, 0 if CouchDB answers `401` and LiteLLM `401` or `400`, and 3 otherwise, passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. In that local marking review, missing marker printed "paste the whole block, including its set -- line; not probing" (exit 0, 0 stub calls); missing set -- line printed "paste the whole block, including its set -- line; not probing" (exit 0, 0 stub calls). That local marking review's short assignment printed "the set -- line needs exactly 2 values; not probing" (exit 0, 0 stub calls); that review's long assignment printed "the set -- line needs exactly 2 values; not probing" (exit 0, 0 stub calls). Placeholder, first empty, second empty, first option-like, second option-like, first spaced, second spaced, embedded placeholder, tab, LF, CR and DEL each printed "substitute both URLs on the set -- line above; not probing" (each exit 0, 0 stub calls). The valid fixture exited 0 with 2 stub calls and printed, in order, `stub couchdb url=http://203.0.113.10:10000/db/_session?basic=true`, `couchdb sample login: http=200 exit=0 err=fixture ok`, `stub litellm url=http://203.0.113.11:4000/v1/models` and `litellm sample key: http=200 exit=0 err=fixture ok`. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_BUDIBASE_PROXY_URL' 'REPLACE_WITH_LITELLM_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1|$2" in *REPLACE_WITH_*|"|"*|*"|"|-*|*'|-'*|*" "*|*[[:cntrl:]]*) echo "substitute both URLs on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" "$2" "$(if printf 'user = "budibase:budibase"\n' | curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 \ --max-time 15 --config - -w 'couchdb sample login: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{http_code}' \ "$1/db/_session?basic=true"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # the two URLs, curl's exit status, then its result line and HTTP code set -- "$1" "$2" "$3" "${4%$'\n'*}" "${4##*$'\n'}" # the two URLs, curl's exit status, its result line, then the HTTP code printf '%s\n' "$4" case "$3:$5" in 0:200) echo "FINDING: the CouchDB sample login was accepted with 200"; set -- "$1" "$2" 1 ;; 0:401) set -- "$1" "$2" 0 ;; *) echo "the CouchDB sample login got neither the 200 finding nor the 401 pass; inconclusive"; set -- "$1" "$2" 3 ;; esac # the 3rd value is now the block's status so far: 1 (a finding) outranks 3 (inconclusive), which outranks 0 set -- "$1" "$2" "$3" "$(if printf 'Authorization: Bearer budibase\n' | curl -q -g -sS -o /dev/null --noproxy '*' --connect-timeout 5 \ --max-time 15 -H @- -w 'litellm sample key: http=%{http_code} exit=%{exitcode} err=%{errormsg}\n%{http_code}' \ "$2/v1/models"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "$3" "${4##*$'\n'}" "${4%$'\n'*}" # the URLs, the status so far, curl's exit status, then its result line and HTTP code set -- "$1" "$2" "$3" "$4" "${5%$'\n'*}" "${5##*$'\n'}" # the URLs, the status so far, curl's exit status, its result line, then the HTTP code printf '%s\n' "$5" case "$4:$6" in 0:200) echo "FINDING: the LiteLLM sample key was accepted with 200"; set -- "$1" "$2" 1 ;; 0:401|0:400) ;; *) echo "the LiteLLM sample key got neither the 200 finding nor a 401 or 400 pass; inconclusive"; [ "$3" = 1 ] || set -- "$1" "$2" 3 ;; esac echo "block exit $3; confirm that each service accepts your real credentials, the positive control this block does not run" exit "$3" ) ``` REASONED: Exposed and fixed outcomes below are expectations, not live observations. Budibase v3.46.0 sample environment supplies the sample values, and Budibase v3.46.0 proxy configuration (`/db/`) supplies the path. Apache CouchDB 3.5.2 `/_session` reference supports the CouchDB 401 for a wrong password. For the LiteLLM 401 with a database connected or 400 without one, see LiteLLM authentication source: non-`sk-` keys rejected with 401 when a database is connected; 400 without one (checked October 2026). These LiteLLM outcomes remain comparison expectations: this guide has not established that litellm.md's source pin `6ef7b86748118ceecd95271727c2fa167ce55fec` corresponds to Compose image `docker.io/litellm/litellm:main-v1.83.10-stable.patch.3` (LiteLLM image and environment source below); no source for that image was fetched. No Budibase/LiteLLM containers ran; the recorded host has no container runtime. A live Budibase or LiteLLM stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Exposed: either line 200; fixed: CouchDB 401, and LiteLLM returns a 401 (or a 400 if the proxy has no database), with real credentials still accepted. Exposed, either line gets `200`, which means the sample secret is live. Fixed, the CouchDB sample login gets `401` ("Username or password wasn't recognized", per CouchDB's `/_session` reference) and the LiteLLM sample key gets a 401 (or a 400 if the proxy has no database), per the LiteLLM authentication source; compare the `/v1/models` check in [litellm.md](https://github.com/jposluns/secureconfig/blob/main/litellm.md). Confirm each service answers your real credentials as the positive control. This guide assesses Budibase's first-run POST as unsafe for read-only claimed-state probing: the body validator precedes the controller's tenant-context `checkAnyUserExists()` call (admin-init body validator, middleware order and controller sources below); invalid bodies can fail validation in both states, while accepted initialization creates an admin. The helper definition was not fetched, so which users count and any multi-tenancy exception remain unestablished. Claim the admin yourself before the proxy is reachable (this guide's advice). REASONED: Manual signup procedure, per the guide's cited NocoDB 2026.09.0 first user, signup and its refusal message, NocoDB 2026.09.0 app settings (`invite_only_signup`), Baserow 2.3.4 first user, signup and its refusal message, Baserow 2.3.4 settings model (`allow_new_signups`), Appsmith v2.4.1 server properties (bind address, signup) and Appsmith v2.4.1 `SIGNUP_DISABLED` error. No browser-accessible NocoDB, Baserow or Appsmith deployment was available for the account lifecycle checks; the authoring host has no container runtime and forbids opening listeners without an isolated network namespace, and none was available. This paragraph contains no shell or configuration fragment to execute locally. Expect account creation when exposed and backend refusal when fixed, with administrator login still working. For NocoDB, Baserow and Appsmith, check signup by hand, reasoned from source. In a private browser window, open the instance's sign-up page and try to create an account with an address you control. Exposed: the account is created, or, on a fresh instance, you become its administrator. Fixed: the signup is refused. The backends' refusal messages are NocoDB's "Not allowed to signup, contact super admin.", Baserow's "Sign up is disabled." and Appsmith's `SIGNUP_DISABLED` error ("Signup is restricted on this instance of Appsmith"); the web UIs may word them differently. A hidden sign-up link is inconclusive, because the frontend does not enforce the setting: check the backend setting itself (NocoDB's `invite_only_signup` app setting, Baserow's `allow_new_signups` admin setting, Appsmith's `APPSMITH_SIGNUP_DISABLED`). Delete any test account the exposed state let you create, then sign in as your admin as the positive control. REASONED: The TCP block passed bash -n; timeout stubs printed "connected" for the control and every tested port, while refusal, timeout and fixture error at the control stopped further probes. Round-2 timeout stubs connected the control and exercised all seven tested ports in each branch: printed outcomes were "connected: reachable from this host", "timed out from this host", "refused from this host" and "inconclusive (not a connection, refusal or timeout): fixture error", each prefixed by the fixture IP address and tested port. Placeholder, missing-marker, short-assignment, empty, malformed-address and invalid-port inputs stopped locally. The bash -n and stub runs recorded here covered the TCP block in an earlier form, whose guard refusals, failed control and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 3 for a failed control, and after its probes 1 if any port connected, otherwise 3 if any port was inconclusive, otherwise 0. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. No public probing host or live deployment was available; the authoring host forbids opening listeners without an isolated network namespace, and none was available. Exposed means "connected"; refusal or timeout is only consistent with fixed, subject to the controls below, per the guide's cited Docker packet filtering (`DOCKER-USER`) and Baserow 2.3.4 install with Docker (ufw warning). Finally, from a host that should not have access, try a TCP connection to each published port. The block uses bash's `/dev/tcp`, so it works for SMTP on 25 as well as the web ports. It takes one IP address rather than a host name, so that a name with both IPv4 and IPv6 addresses cannot hide one behind a timeout on the other: run it once for each public address of the host. It also takes a port you know is open on that address from this host (for example SSH on 22) as the positive control, and stops if the control does not connect. Substitute both inside the single quotes. DEMONSTRATED: following block; recorded loopback tests observed connected, refused, timeout/full accept queue, failed-control stopping and the input cases below. Public isolation remains reasoned; the timeout fixture was not a firewall. The loopback tests used an earlier form of the block, whose guard refusals, failed control and result arms ended with status 0; the current form exits 2 for a guard refusal, 3 for a failed control, and after its probes 1 if any port connected, otherwise 3 if any port was inconclusive, otherwise 0, and it holds its values in positional parameters. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ONE_IP_ADDRESS' 'REPLACE_WITH_A_KNOWN_OPEN_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute one IP address on the set -- line above; not probing"; exit 2 ;; esac case "$1" in 0.0.0.0) echo "$1 reaches this host itself; give the public address of the service; not probing"; exit 2 ;; esac set -- "$1" "$2" '^((25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])[.]){3}(25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])$' # the address, the control port, then the IPv4 pattern, held here rather than in a named variable case "$1" in *:*:*) case "$1" in *[!0123456789ABCDEFabcdef.:]*) echo "$1 is not an IPv6 address; not probing"; exit 2 ;; esac case "$1" in *.*) echo "give the IPv4 address itself rather than $1; not probing"; exit 2 ;; esac case "$1" in *[!0:]*) ;; *) echo "$1 is all zeros (this host itself, or not an address); give the public address of the service; not probing"; exit 2 ;; esac ;; *) [[ $1 =~ $3 ]] || { echo "give one IPv4 address (four numbers, 0 to 255, joined by dots) or one IPv6 address, with no host name, port or brackets; not probing"; exit 2; } ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a known-open control port on the set -- line above; not probing"; exit 2 ;; esac { [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; } 2>/dev/null || { echo "the control port must be 1 to 65535; not probing"; exit 2; } command -v timeout >/dev/null || { echo "timeout is not installed here; not probing"; exit 2; } # shellcheck disable=SC2016 # the single-quoted script is meant to expand $1 and $2 in the child shell set -- "$1" "$2" "$(if LC_ALL=C timeout 5 bash -c ': > "/dev/tcp/$1/$2"' probe "$1" "$2" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the address, the control port, the control's messages, then its exit status set -- "$1" "$2" "${3%$'\n'}" "$4" # the messages without their final line break, as a plain capture prints them [ "$4" = 0 ] || { echo "control $1:$2 did not connect (${3:-timed out}); not probing"; exit 3; } echo "control $1:$2 connected" set -- "$1" 0 80 443 8080 10000 4000 8000 25 # the address, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports still to probe while [ "$#" -gt 2 ]; do # shellcheck disable=SC2016 # the single-quoted script is meant to expand $1 and $2 in the child shell set -- "$(if LC_ALL=C timeout 5 bash -c ': > "/dev/tcp/$1/$2"' probe "$1" "$3" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1##*$'\n'}" "${1%$'\n'*}" "${@:2}" # this port's exit status, its messages, the address, the status so far, this port, then the ports after it set -- "$1" "${2%$'\n'}" "${@:3}" # the messages without their final line break case "$1:$2" in 0:*) echo "$3:$5 connected: reachable from this host"; set -- "$1" "$2" "$3" 1 "${@:5}" ;; 124:*) echo "$3:$5 timed out from this host" ;; *"Connection refused"*) echo "$3:$5 refused from this host" ;; *) echo "$3:$5 inconclusive (not a connection, refusal or timeout): ${2:-no message}"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 "${@:5}" ;; esac set -- "$3" "$4" "${@:6}" # the address, the status so far, then the ports still to probe done exit "$2" ) ``` DEMONSTRATED: preceding block; the loopback runs and refusals recorded below used an earlier form of the block, whose guard refusals, failed control and result arms ended with status 0. Exposed, a port reports "connected"; a transparent proxy on the probing host's network can also complete the handshake, so confirm a surprising "connected" with `ss` on the host. "refused" and "timed out" show only that this host could not reach the port: a firewall in front of the service produces either, but so can filtering on the probing host's own network (many providers block outbound SMTP on 25), and the control proves only its own port. Treat them as consistent with fixed, and take the `ss` and `docker ps` check on the host as the authority. "inconclusive" is any other result and says nothing about the port. The block runs the probe in the C locale so that bash's refusal message is matched in English. On loopback it printed "connected" for the control and an open port, "timed out" for a port whose accept queue was full (standing in for a filtered one) and "refused" for a closed port, and it stopped when the control was closed. It refused to run for the host names `localhost`, `cafe.be`, `fe01`, `db1` and `face`, for `1.2.3.4.5`, `999.1.1.1`, `01.2.3.4` and `1.2.3`, for `1.2.3.4:22`, `[::1]` and `[::1]:22`, for a leading space, for control ports `0` and `65536`, and on a machine without `timeout`. It refuses `0.0.0.0` and any IPv6 value made only of zeros and colons (measured: `::`, `::0`, `0::`, `0000::`, `0:0:0:0:0:0:0:0`), because those reach the probing host itself or are not addresses, and any IPv6 value containing a dot (measured: `::ffff:0.0.0.0`, `::ffff:127.0.0.1`, `::0.0.0.0`), asking for the IPv4 address instead. It does not refuse every value that reaches the probing host: loopback addresses pass, and hex spellings such as `::ffff:0:0` and `::ffff:7f00:1` passed and connected to a listener bound to 127.0.0.1, so give the service's public address. Any other value with two or more colons, made only of hex digits and colons, is passed on as IPv6, so a port appended without brackets changes the address: `2001:db8::1:22` is still a valid address and was probed as that different address, while `2001:db8::1:10000` and `dead::beef::` are not, and each failed the name lookup at the control and stopped. A "connected" on a port you did not mean to publish is the finding. ## Sources (checked September 2026) - NocoDB 2026.09.0 port default, JWT secret generation and proxy-trust reset: https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/Noco.ts - NocoDB 2026.09.0 proxy trust default (`NC_TRUST_PROXY`): https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/utils/trustProxy.ts - NocoDB 2026.09.0 entry point (listen with no host, CORS): https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/run/dockerEntry.ts - NocoDB 2026.09.0 first user, signup and its refusal message: https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/services/users/users.service.ts - NocoDB 2026.09.0 app settings (`invite_only_signup`): https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/interface/AppSettings.ts - NocoDB 2026.09.0 admin from environment (`NC_ADMIN_EMAIL`, `NC_ADMIN_PASSWORD`): https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/helpers/initAdminFromEnv.ts - NocoDB 2026.09.0 credential encryption: https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/utils/encryptDecrypt.ts and https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/helpers/initDataSourceEncryption.ts - NocoDB 2026.09.0 container start scripts: https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/docker/start.sh and https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/docker/start-litestream.sh - Docker `docker exec` environment: https://docs.docker.com/reference/cli/docker/container/exec/ - Docker packet filtering (`DOCKER-USER`): https://docs.docker.com/engine/network/firewall-iptables/ - Docker CLI port display (`[::]:` from v27.2.0, `:::` through v27.1.2): https://github.com/docker/cli/blob/v27.2.0/cli/command/formatter/container.go and https://github.com/docker/cli/blob/v27.1.2/cli/command/formatter/container.go - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 - NocoDB 2026.09.0 README (Docker example JWT secret, Docker image, Auto-upstall and binaries note; checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/README.md - NocoDB 2026.09.0 README, Docker with SQLite and Docker with PG sections (checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L56-L75 - NocoDB 2026.09.0 README, Auto-upstall section (checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L77-L90 - NocoDB 2026.09.0 README, Auto-upstall command (checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L82 - NocoDB 2026.09.0 README, Auto-upstall script source link (checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L90 - NocoDB 2026.09.0 README, binaries note (checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L95 - NocoDB 2026.09.0 GitHub release API record (no assets listed when checked October 2026): https://api.github.com/repos/nocodb/nocodb/releases/tags/2026.09.0 - NocoDB 2026.09.0 tag package manifest (`0.301.3`; checked October 2026): https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/package.json#L3 - NocoDB 2026.09.0 sample environment without an encryption key: https://github.com/nocodb/nocodb/blob/2026.09.0/docker-compose/examples/external-postgres-and-redis/docker.env - Node.js v22 `server.listen` with an omitted host: https://github.com/nodejs/node/blob/v22.22.1/doc/api/net.md - Baserow 2.3.4 Compose file: https://github.com/baserow/baserow/blob/2.3.4/docker-compose.yml - Baserow 2.3.4 install with Docker (ufw warning): https://github.com/baserow/baserow/blob/2.3.4/docs/installation/install-with-docker.md - Baserow 2.3.4 first user, signup and its refusal message: https://github.com/baserow/baserow/blob/2.3.4/backend/src/baserow/core/user/handler.py - Baserow 2.3.4 settings model (`allow_new_signups`): https://github.com/baserow/baserow/blob/2.3.4/backend/src/baserow/core/models.py - Baserow 2.3.4 TOTP provider registration: https://github.com/baserow/baserow/blob/2.3.4/backend/src/baserow/core/apps.py - Baserow 2.3.4 sample environment: https://github.com/baserow/baserow/blob/2.3.4/.env.example - Appsmith v2.4.1 server properties (bind address, signup): https://github.com/appsmithorg/appsmith/blob/v2.4.1/app/server/appsmith-server/src/main/resources/application-ce.properties - Appsmith v2.4.1 first super user: https://github.com/appsmithorg/appsmith/blob/v2.4.1/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/UserSignupCEImpl.java - Appsmith v2.4.1 `SIGNUP_DISABLED` error: https://github.com/appsmithorg/appsmith/blob/v2.4.1/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java - Appsmith v2.4.1 image entrypoint (generated encryption values): https://github.com/appsmithorg/appsmith/blob/v2.4.1/deploy/docker/fs/opt/appsmith/entrypoint.sh - Appsmith v2.4.1 development Compose file: https://github.com/appsmithorg/appsmith/blob/v2.4.1/deploy/docker/docker-compose.yml - Appsmith v2.4.1 AWS Compose example: https://github.com/appsmithorg/appsmith/blob/v2.4.1/deploy/aws_ami/docker-compose.yml - Budibase v3.46.0 sample environment: https://github.com/Budibase/budibase/blob/v3.46.0/hosting/.env - Budibase v3.46.0 Compose file: https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml - Budibase v3.46.0 proxy configuration (`/db/`): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/proxy/nginx.prod.conf - Budibase v3.46.0 public worker routes: https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/index.ts - Budibase v3.46.0 admin init: https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/controllers/global/users.ts - Budibase v3.46.0 admin init route and its validation: https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/routes/global/users.ts - Budibase v3.46.0 sample secret assignments (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/.env#L4-L16 - Budibase v3.46.0 CouchDB image and credential mapping (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml#L111-L120 - Budibase v3.46.0 LiteLLM image and environment (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml#L129-L144 - Budibase v3.46.0 CouchDB upstream variable (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/proxy/nginx.prod.conf#L86 - Budibase v3.46.0 CouchDB proxy location (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/proxy/nginx.prod.conf#L236-L239 - Budibase v3.46.0 public init endpoint entry (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/index.ts#L40-L43 - Budibase v3.46.0 public authentication middleware chain (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/index.ts#L133-L167 - Budibase v3.46.0 admin-init body validator (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/routes/global/users.ts#L13-L28 - Budibase v3.46.0 admin-init middleware order (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/routes/global/users.ts#L75-L79 - Budibase v3.46.0 admin-init controller and refusal (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/controllers/global/users.ts#L229-L274 - Budibase v3.46.0 startup admin seeding conditions (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/server/src/startup/index.ts#L226-L266 - Budibase v3.46.0 admin environment definitions (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/backend-core/src/environment.ts#L267-L268 - Budibase v3.46.0 app-service admin environment mapping (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml#L27-L28 - Budibase v3.46.0 CouchDB entrypoint installation (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/Dockerfile#L89-L91 - Budibase v3.46.0 CouchDB runner command (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/Dockerfile#L151-L154 - Budibase v3.46.0 database image version file (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/VERSION#L1 - Budibase v3.46.0 runner calls CouchDB entrypoint (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/runner.sh#L165-L166 - Budibase v3.46.0 conditional CouchDB admin creation (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/docker-entrypoint.sh#L70-L75 - Budibase v3.46.0 hosting sample secrets (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/hosting.properties#L4-L18 - Budibase v3.46.0 first-boot sample substitution (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/hosting/digitalocean/files/var/lib/cloud/scripts/per-instance/001_onboot#L11-L15 - Budibase v3.46.0 test secret assignments (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/packages/backend-core/tests/jestEnv.ts#L9-L10 - Budibase v3.46.0 development secret entries (checked October 2026): https://github.com/Budibase/budibase/blob/v3.46.0/scripts/dev/manage.js#L11-L12 - Apache CouchDB 3.5.2 `/_session` reference: https://github.com/apache/couchdb/blob/3.5.2/src/docs/src/api/server/authn.rst - LiteLLM authentication source: non-`sk-` keys rejected with 401 when a database is connected; 400 without one (checked October 2026): https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/user_api_key_auth.py - Windmill v1.864.1 README (default credentials): https://github.com/windmill-labs/windmill/blob/v1.864.1/README.md - Windmill v1.864.1 server defaults: https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/src/main.rs - Windmill v1.864.1 worker (`DISABLE_NSJAIL`): https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/windmill-worker/src/worker.rs - Windmill v1.864.1 Compose file: https://github.com/windmill-labs/windmill/blob/v1.864.1/docker-compose.yml - Windmill v1.864.1 seeded-user migrations: https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/migrations/20220123221903_first.up.sql and https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/migrations/20220816185849_remove_non_admin_users.up.sql ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - nocodb: NocoDB (2026.09.0) - baserow: Baserow (2.3.4) - appsmith: Appsmith (v2.4.1) - budibase: Budibase (v3.46.0) - windmill: Windmill (v1.864.1) - docker: Docker docs (unknown) - docker-new: Docker CLI (v27.2.0) - docker-old: Docker CLI (v27.1.2) - node: Node.js (v22.22.1) - couchdb: CouchDB (3.5.2) - litellm: LiteLLM authentication source (6ef7b86748118ceecd95271727c2fa167ce55fec) Claims (full source mapping in the JSON block below): - private (REASONED; nocodb, baserow, appsmith, budibase, windmill): Keep builders private with fronting auth, claim admin early and protect databases storing connected-system credentials. - nocodb-bind (REASONED; nocodb, node): PORT defaults 8080; listen omits host, so Node binds unspecified IPv6/IPv4. No application bind setting; restrict publication/firewall. - nocodb-first (REASONED; nocodb): First signup becomes super-admin; claim first or seed NC_ADMIN_EMAIL/NC_ADMIN_PASSWORD. - nocodb-signup (REASONED; nocodb): invite_only_signup defaults false; enable to stop later self-signups. - nocodb-encryption (REASONED; nocodb): Unset NC_CONNECTION_ENCRYPT_KEY stores external DB credentials unchanged; set before adding sources, or startup encrypts existing sources when added later. - nocodb-jwt (REASONED; nocodb): Unset NC_AUTH_JWT_SECRET generates/stores a UUID as nc_auth_jwt_secret; replace README sample 569a1821-0a93-45e8-87ab-eb857f20a010. - nocodb-cors (REASONED; nocodb): Entry file allows every CORS origin. - nocodb-proxy (REASONED; nocodb): Startup resets trust proxy from NC_TRUST_PROXY, default no trust; restrict hops/subnets, never true with direct client access. - nocodb-packaging (REASONED; nocodb): README marks binaries for quick local testing and documents Docker image and Auto-upstall paths; the October 2026 release API record lists no assets; the 2026.09.0 tag carries package version 0.301.3. Use the image or vendor install script for production (guide advice). The README command fetches the script over plain HTTP and executes it with Bash; its source link points to develop. Fetch over HTTPS from a pinned, reviewed source and read before running (guide advice). - baserow-bind (REASONED; baserow): Caddy publishes 80/443 on HOST_PUBLISH_IP default 0.0.0.0; set it or publish loopback. Docker wildcard publication bypasses UFW. - baserow-first (REASONED; baserow): First signup becomes staff; claim before exposure. - baserow-signup (REASONED; baserow): allow_new_signups defaults True; disable in admin settings, yielding Sign up is disabled. - baserow-secrets (REASONED; baserow): Compose requires SECRET_KEY/DATABASE_PASSWORD/REDIS_PASSWORD; .env.example leaves them empty. Generate your own. - baserow-mfa (REASONED; baserow): Core registers a TOTP provider. - appsmith-bind (REASONED; appsmith): Java defaults APPSMITH_SERVER_ADDRESS=127.0.0.1 and port 8080 inside the container. - appsmith-publish (REASONED; appsmith): Bundled Caddy fronts port 80; development Compose publishes host 8080, AWS example 80/443. - appsmith-first (REASONED; appsmith): First signup claims super-user while no users exist; claim before exposure. - appsmith-signup (REASONED; appsmith): APPSMITH_SIGNUP_DISABLED defaults false; set true or restrict APPSMITH_SIGNUP_ALLOWED_DOMAINS. Refusal is SIGNUP_DISABLED. - appsmith-encryption (REASONED; appsmith): Entrypoint generates 13-character encryption password/salt; external values override them. Development Compose sets both abcd; replace before first start. - appsmith-rotation (REASONED; appsmith): Changing an existing abcd encryption pair presumably makes stored credentials unreadable; plan re-entry. Inferred, not tested. - budibase-publish (REASONED; budibase): Proxy publishes MAIN_PORT, sample 10000; LiteLLM publishes LITELLM_PORT default 4000. - budibase-couchdb (REASONED; budibase): Proxy /db/ reaches CouchDB. Compose maps COUCH_DB_USER/PASSWORD to COUCHDB_USER/PASSWORD; the image entrypoint adds that admin only when both are non-empty and its existing-admin check finds none for that name. With unchanged samples and that condition, anyone reaching the proxy can try budibase/budibase. - budibase-secrets (REASONED; budibase): Sample JWT_SECRET/API_ENCRYPTION_KEY are testsecret; service passwords, INTERNAL_API_KEY and LITELLM_MASTER_KEY are budibase. Replace all before first start. Compose pins docker.io/litellm/litellm:main-v1.83.10-stable.patch.3 and passes LITELLM_MASTER_KEY; acceptance of budibase by that image is not established. - budibase-sample-check (REASONED; budibase): This guide's October 2026 search of the full v3.46.0 tag with git grep -l testsecret v3.46.0 found exactly hosting/.env, hosting/digitalocean/files/var/lib/cloud/scripts/per-instance/001_onboot, hosting/hosting.properties, packages/backend-core/tests/jestEnv.ts and scripts/dev/manage.js. This guide inspected those hits. This guide's assessment is that these hits do not establish absence of production rejection; no live safeguard test ran. - budibase-init (REASONED; budibase): POST /api/global/users/init is public; its body validator requires string email and tenantId fields, permits an optional password, rejects unknown fields and precedes adminUser. In the supplied tenant context, checkAnyUserExists() truthy causes 403 with the exact vendor error quoted in the body. The helper definition was not fetched, so which users count and any multi-tenancy exception remain unestablished. This guide assesses this POST as unsafe for read-only claimed-state probing: invalid bodies can fail validation in both states, while accepted initialization creates an admin. - budibase-seed (REASONED; budibase): Claim admin before exposure (guide advice), manually or with BB_ADMIN_USER_EMAIL/BB_ADMIN_USER_PASSWORD. Startup attempts seeding only with SELF_HOSTED truthy, MULTI_TENANCY falsy and both values non-empty; in tenancy.getTenantId() context, it calls createAdminUser when the admin checklist is not checked OR the email does not exist, with hashPassword/requirePassword/skipPasswordValidation true. - windmill-code (REASONED; windmill): Workers execute user scripts; a login enables code execution on worker hosts. - windmill-bind (REASONED; windmill): Default 0.0.0.0:8000; SERVER_BIND_ADDR changes binary binding. Default wildcard bind was not observed. - windmill-compose (REASONED; windmill): Compose exposes server 8000 internally; Caddy publishes 80/25 on all host interfaces. Bind publications privately; container loopback cutting Caddy off is inferred. - windmill-sandbox (REASONED; windmill): DISABLE_NSJAIL defaults true; sandboxing is not enabled by default. - windmill-privileged (REASONED; windmill): Shipped worker uses privileged=true. - windmill-smtp (REASONED; windmill): Port 25 serves email triggers; do not publish unless used. - windmill-debugger (REASONED; windmill): Vendor warns REQUIRE_SIGNED_DEBUG_REQUESTS=false exposes an unauthenticated code-execution debugger on reachable deployments. - verify-nocodb-signup (REASONED; nocodb): Controlled signup creation is exposed; invite-only refusal is fixed. Hidden links prove nothing; inspect backend setting and confirm admin login. - verify-baserow-signup (REASONED; baserow): Controlled signup creation is exposed; Sign up is disabled is fixed. Inspect allow_new_signups and confirm admin login. - verify-appsmith-signup (REASONED; appsmith): Controlled signup creation is exposed; SIGNUP_DISABLED is fixed. Inspect backend settings, confirm admin login and delete test accounts. - verify-external (REASONED; baserow, budibase, windmill): Probe each public IP; control proves only its port, refusal/timeout may be local filtering, and unexpected connections need host confirmation. - windmill-default-login (DEMONSTRATED; windmill): Fresh loopback DB had only admin@windmill.dev as a password user/super-admin; changeme returned 200/token, then 400 after rotation while the new password returned 200. Runs covered an earlier form whose result was curl's own exit status; the current form was not re-run. - windmill-loopback (DEMONSTRATED; windmill): SERVER_BIND_ADDR=127.0.0.1 produced only a loopback listener on the test port. - windmill-sandbox-run (DEMONSTRATED; windmill): Loopback worker without nsjail logged sandboxing unavailable; this does not demonstrate isolation. - verify-inventory (REASONED; docker, baserow, appsmith, budibase, windmill): Inventory host sockets and Docker publications; NAT may have no socket, so absent ss entries do not establish isolation. - verify-port-display (REASONED; docker, docker-new, docker-old): IPv6 wildcard displays [::]: from v27.2.0 versus ::: through v27.1.2; both mean all host addresses unless filtered. - verify-sample-tokens (DEMONSTRATED; nocodb, appsmith, budibase, windmill): Whole-token search found sample secrets/variants, clean replaced copies and unreadable paths; harmless matches and external environment values limit scope. Runs covered an earlier form whose result arms ended with status 0; the current form was not re-run. - verify-nocodb-key (REASONED; nocodb, docker): docker exec tests configured key presence without printing it or passing its value in argv; no real container run. Startup selection/process value remain unverified. - nocodb-key-limits (REASONED; nocodb, docker): Trusted shells/startup assumed; environment remains readable to same-account/root or Docker-socket holders. Presence does not prove encryption; do not inject a replacement key. - nocodb-key-logic (DEMONSTRATED; docker): A docker exec stand-in exercised set/empty/unset/missing-container branches only; no NocoDB deployment demonstrated. Runs covered an earlier form whose result arms ended with status 0; the current form was not re-run. - verify-windmill-control (REASONED; windmill): Before trusting default-login 400, confirm real credentials work so a dead service is not treated as fixed. - verify-couchdb (REASONED; budibase, couchdb): Sample login at /db/_session?basic=true should return 200 exposed and 401 after replacement; real credentials are the positive control. - verify-litellm (REASONED; budibase, litellm): Sample Bearer budibase at /v1/models should return 200 exposed; fixed, a 401 (or a 400 if the proxy has no database); confirm real credentials. The pinned authentication source rejects non-`sk-` keys with 401 when a database is connected and returns 400 without one; the exposed 200 still requires live comparison. These remain comparison expectations: this guide has not established that litellm.md's source pin corresponds to Compose image docker.io/litellm/litellm:main-v1.83.10-stable.patch.3; no source for that image was fetched. - verify-tcp-outcomes (DEMONSTRATED; baserow, budibase, windmill): Loopback observed connected/open, timeout/full accept queue, refused/closed and failed-control stopping; no real firewall demonstrated. Runs covered an earlier form whose control and result arms ended with status 0; the current form was not re-run. - verify-tcp-inputs (DEMONSTRATED; baserow, budibase, windmill): Guard tests reject named hosts, malformed IPv4, zero/dotted IPv6, invalid ports and absent timeout; loopback/hex-mapped IPv6 pass. Invalid hex may fail at control. Runs covered an earlier form whose guard, control and result arms ended with status 0; the current form was not re-run. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "1da684dd864542dd06ebf482c7556680a8d38a782a84fd9b4ddb2709dac0cad5", "components": { "nocodb": { "name": "NocoDB", "basis": "2026.09.0", "sources": { "sa19c6557b712": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/Noco.ts", "s635b35ce4689": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/utils/trustProxy.ts", "sd74ca4df55b0": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/run/dockerEntry.ts", "s5ea55dc5f0d9": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/services/users/users.service.ts", "sd0e9f451673f": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/interface/AppSettings.ts", "s8a1de0bc97b0": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/helpers/initAdminFromEnv.ts", "sca40040b40cc": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/utils/encryptDecrypt.ts", "saf056bb4ff37": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/src/helpers/initDataSourceEncryption.ts", "sbfbf630008e1": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/docker/start.sh", "se5da9707c684": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/docker/start-litestream.sh", "s536899ecd4a5": "https://github.com/nocodb/nocodb/blob/2026.09.0/README.md", "sca0c9c0ecf24": "https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L56-L75", "sb4d5b659f7b8": "https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L77-L90", "s2769b9054778": "https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L82", "s2b16e2c93606": "https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L90", "s118384fa1d9f": "https://github.com/nocodb/nocodb/blob/2026.09.0/README.md#L95", "s9bee4adc612e": "https://api.github.com/repos/nocodb/nocodb/releases/tags/2026.09.0", "s3f296c702bc5": "https://github.com/nocodb/nocodb/blob/2026.09.0/packages/nocodb/package.json#L3" } }, "baserow": { "name": "Baserow", "basis": "2.3.4", "sources": { "s1b31314982f4": "https://github.com/baserow/baserow/blob/2.3.4/docker-compose.yml", "s3f61036aca03": "https://github.com/baserow/baserow/blob/2.3.4/docs/installation/install-with-docker.md", "s34b87f60dcc6": "https://github.com/baserow/baserow/blob/2.3.4/backend/src/baserow/core/user/handler.py", "s4588448964a2": "https://github.com/baserow/baserow/blob/2.3.4/backend/src/baserow/core/models.py", "sd858f9ee45e0": "https://github.com/baserow/baserow/blob/2.3.4/backend/src/baserow/core/apps.py", "s1a472d828699": "https://github.com/baserow/baserow/blob/2.3.4/.env.example" } }, "appsmith": { "name": "Appsmith", "basis": "v2.4.1", "sources": { "s2650629933a1": "https://github.com/appsmithorg/appsmith/blob/v2.4.1/app/server/appsmith-server/src/main/resources/application-ce.properties", "sd1c55be91e30": "https://github.com/appsmithorg/appsmith/blob/v2.4.1/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/UserSignupCEImpl.java", "se3cd894dc579": "https://github.com/appsmithorg/appsmith/blob/v2.4.1/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java", "sa68daf87cde7": "https://github.com/appsmithorg/appsmith/blob/v2.4.1/deploy/docker/fs/opt/appsmith/entrypoint.sh", "s9fdcff5a28a4": "https://github.com/appsmithorg/appsmith/blob/v2.4.1/deploy/docker/docker-compose.yml", "sfa551c344e8b": "https://github.com/appsmithorg/appsmith/blob/v2.4.1/deploy/aws_ami/docker-compose.yml" } }, "budibase": { "name": "Budibase", "basis": "v3.46.0", "sources": { "s735ead81ce9c": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/.env", "s74d858053d12": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml", "s7fbf706a4c8f": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/proxy/nginx.prod.conf", "s45379b35a980": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/index.ts", "sc0a3ac85466f": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/controllers/global/users.ts", "s4902d93986c7": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/routes/global/users.ts", "s5209f2549c9b": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/.env#L4-L16", "s9afbeb2063b5": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml#L111-L120", "s32775c45ba51": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml#L129-L144", "s4d349b4d4ac3": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/proxy/nginx.prod.conf#L86", "s2a033b3a4117": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/proxy/nginx.prod.conf#L236-L239", "s2db467c4f1cf": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/index.ts#L40-L43", "sbd761572fef0": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/index.ts#L133-L167", "sb8f5a18ab4ee": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/routes/global/users.ts#L13-L28", "s7883460cabbf": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/routes/global/users.ts#L75-L79", "se18a9c7182fb": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/worker/src/api/controllers/global/users.ts#L229-L274", "s816a78b83956": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/server/src/startup/index.ts#L226-L266", "sedd5e0682bf0": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/backend-core/src/environment.ts#L267-L268", "s35ae42bd7a1c": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/docker-compose.yaml#L27-L28", "scdcd8b6e7071": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/Dockerfile#L89-L91", "sc74f36aa8340": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/Dockerfile#L151-L154", "sf6e2a660380f": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/VERSION#L1", "sfdc76e71aff6": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/runner.sh#L165-L166", "s851ad716e105": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/couchdb/docker-entrypoint.sh#L70-L75", "se993218560de": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/hosting.properties#L4-L18", "s6179ec0d9abd": "https://github.com/Budibase/budibase/blob/v3.46.0/hosting/digitalocean/files/var/lib/cloud/scripts/per-instance/001_onboot#L11-L15", "s9cc27222e133": "https://github.com/Budibase/budibase/blob/v3.46.0/packages/backend-core/tests/jestEnv.ts#L9-L10", "sbe7e0469e7a8": "https://github.com/Budibase/budibase/blob/v3.46.0/scripts/dev/manage.js#L11-L12" } }, "windmill": { "name": "Windmill", "basis": "v1.864.1", "sources": { "s3934703e8ba2": "https://github.com/windmill-labs/windmill/blob/v1.864.1/README.md", "s9186f1630378": "https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/src/main.rs", "s6a71d3938c7a": "https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/windmill-worker/src/worker.rs", "s6a7eeac413b7": "https://github.com/windmill-labs/windmill/blob/v1.864.1/docker-compose.yml", "sdcc9601405df": "https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/migrations/20220123221903_first.up.sql", "s8927ade6b336": "https://github.com/windmill-labs/windmill/blob/v1.864.1/backend/migrations/20220816185849_remove_non_admin_users.up.sql" } }, "docker": { "name": "Docker docs", "basis": "unknown", "sources": { "sd34e76a09f6c": "https://docs.docker.com/reference/cli/docker/container/exec/", "s2fecb6db5480": "https://docs.docker.com/engine/network/firewall-iptables/" } }, "docker-new": { "name": "Docker CLI", "basis": "v27.2.0", "sources": { "sca9a0e1e4b6a": "https://github.com/docker/cli/blob/v27.2.0/cli/command/formatter/container.go" } }, "docker-old": { "name": "Docker CLI", "basis": "v27.1.2", "sources": { "sa202ad11d4ed": "https://github.com/docker/cli/blob/v27.1.2/cli/command/formatter/container.go" } }, "node": { "name": "Node.js", "basis": "v22.22.1", "sources": { "sfb3e306c102d": "https://github.com/nodejs/node/blob/v22.22.1/doc/api/net.md" } }, "couchdb": { "name": "CouchDB", "basis": "3.5.2", "sources": { "s34292991c4ec": "https://github.com/apache/couchdb/blob/3.5.2/src/docs/src/api/server/authn.rst" } }, "litellm": { "name": "LiteLLM authentication source", "basis": "6ef7b86748118ceecd95271727c2fa167ce55fec", "sources": { "sbe6fa0b81017": "https://raw.githubusercontent.com/BerriAI/litellm/6ef7b86748118ceecd95271727c2fa167ce55fec/litellm/proxy/auth/user_api_key_auth.py" } } }, "claims": { "private": {"text": "Keep builders private with fronting auth, claim admin early and protect databases storing connected-system credentials.", "components": ["nocodb", "baserow", "appsmith", "budibase", "windmill"], "sources": ["nocodb:sd74ca4df55b0", "baserow:s1b31314982f4", "appsmith:s9fdcff5a28a4", "budibase:s74d858053d12", "windmill:s6a7eeac413b7"], "status": "REASONED"}, "nocodb-bind": {"text": "PORT defaults 8080; listen omits host, so Node binds unspecified IPv6/IPv4. No application bind setting; restrict publication/firewall.", "components": ["nocodb", "node"], "sources": ["nocodb:sa19c6557b712", "nocodb:sd74ca4df55b0", "node:sfb3e306c102d"], "status": "REASONED"}, "nocodb-first": {"text": "First signup becomes super-admin; claim first or seed NC_ADMIN_EMAIL/NC_ADMIN_PASSWORD.", "components": ["nocodb"], "sources": ["nocodb:s5ea55dc5f0d9", "nocodb:s8a1de0bc97b0"], "status": "REASONED"}, "nocodb-signup": {"text": "invite_only_signup defaults false; enable to stop later self-signups.", "components": ["nocodb"], "sources": ["nocodb:s5ea55dc5f0d9", "nocodb:sd0e9f451673f"], "status": "REASONED"}, "nocodb-encryption": {"text": "Unset NC_CONNECTION_ENCRYPT_KEY stores external DB credentials unchanged; set before adding sources, or startup encrypts existing sources when added later.", "components": ["nocodb"], "sources": ["nocodb:sca40040b40cc", "nocodb:saf056bb4ff37"], "status": "REASONED"}, "nocodb-jwt": {"text": "Unset NC_AUTH_JWT_SECRET generates/stores a UUID as nc_auth_jwt_secret; replace README sample 569a1821-0a93-45e8-87ab-eb857f20a010.", "components": ["nocodb"], "sources": ["nocodb:sa19c6557b712", "nocodb:s536899ecd4a5"], "status": "REASONED"}, "nocodb-cors": {"text": "Entry file allows every CORS origin.", "components": ["nocodb"], "sources": ["nocodb:sd74ca4df55b0"], "status": "REASONED"}, "nocodb-proxy": {"text": "Startup resets trust proxy from NC_TRUST_PROXY, default no trust; restrict hops/subnets, never true with direct client access.", "components": ["nocodb"], "sources": ["nocodb:sa19c6557b712", "nocodb:s635b35ce4689", "nocodb:sd74ca4df55b0"], "status": "REASONED"}, "nocodb-packaging": {"text": "README marks binaries for quick local testing and documents Docker image and Auto-upstall paths; the October 2026 release API record lists no assets; the 2026.09.0 tag carries package version 0.301.3. Use the image or vendor install script for production (guide advice). The README command fetches the script over plain HTTP and executes it with Bash; its source link points to develop. Fetch over HTTPS from a pinned, reviewed source and read before running (guide advice).", "components": ["nocodb"], "sources": ["nocodb:s536899ecd4a5", "nocodb:sca0c9c0ecf24", "nocodb:sb4d5b659f7b8", "nocodb:s118384fa1d9f", "nocodb:s9bee4adc612e", "nocodb:s3f296c702bc5", "nocodb:s2769b9054778", "nocodb:s2b16e2c93606"], "status": "REASONED"}, "baserow-bind": {"text": "Caddy publishes 80/443 on HOST_PUBLISH_IP default 0.0.0.0; set it or publish loopback. Docker wildcard publication bypasses UFW.", "components": ["baserow"], "sources": ["baserow:s1b31314982f4", "baserow:s3f61036aca03"], "status": "REASONED"}, "baserow-first": {"text": "First signup becomes staff; claim before exposure.", "components": ["baserow"], "sources": ["baserow:s34b87f60dcc6"], "status": "REASONED"}, "baserow-signup": {"text": "allow_new_signups defaults True; disable in admin settings, yielding Sign up is disabled.", "components": ["baserow"], "sources": ["baserow:s34b87f60dcc6", "baserow:s4588448964a2"], "status": "REASONED"}, "baserow-secrets": {"text": "Compose requires SECRET_KEY/DATABASE_PASSWORD/REDIS_PASSWORD; .env.example leaves them empty. Generate your own.", "components": ["baserow"], "sources": ["baserow:s1b31314982f4", "baserow:s1a472d828699"], "status": "REASONED"}, "baserow-mfa": {"text": "Core registers a TOTP provider.", "components": ["baserow"], "sources": ["baserow:sd858f9ee45e0"], "status": "REASONED"}, "appsmith-bind": {"text": "Java defaults APPSMITH_SERVER_ADDRESS=127.0.0.1 and port 8080 inside the container.", "components": ["appsmith"], "sources": ["appsmith:s2650629933a1"], "status": "REASONED"}, "appsmith-publish": {"text": "Bundled Caddy fronts port 80; development Compose publishes host 8080, AWS example 80/443.", "components": ["appsmith"], "sources": ["appsmith:sa68daf87cde7", "appsmith:s9fdcff5a28a4", "appsmith:sfa551c344e8b"], "status": "REASONED"}, "appsmith-first": {"text": "First signup claims super-user while no users exist; claim before exposure.", "components": ["appsmith"], "sources": ["appsmith:sd1c55be91e30"], "status": "REASONED"}, "appsmith-signup": {"text": "APPSMITH_SIGNUP_DISABLED defaults false; set true or restrict APPSMITH_SIGNUP_ALLOWED_DOMAINS. Refusal is SIGNUP_DISABLED.", "components": ["appsmith"], "sources": ["appsmith:s2650629933a1", "appsmith:sd1c55be91e30", "appsmith:se3cd894dc579"], "status": "REASONED"}, "appsmith-encryption": {"text": "Entrypoint generates 13-character encryption password/salt; external values override them. Development Compose sets both abcd; replace before first start.", "components": ["appsmith"], "sources": ["appsmith:sa68daf87cde7", "appsmith:s9fdcff5a28a4"], "status": "REASONED"}, "appsmith-rotation": {"text": "Changing an existing abcd encryption pair presumably makes stored credentials unreadable; plan re-entry. Inferred, not tested.", "components": ["appsmith"], "sources": ["appsmith:sa68daf87cde7", "appsmith:s9fdcff5a28a4"], "status": "REASONED"}, "budibase-publish": {"text": "Proxy publishes MAIN_PORT, sample 10000; LiteLLM publishes LITELLM_PORT default 4000.", "components": ["budibase"], "sources": ["budibase:s735ead81ce9c", "budibase:s74d858053d12"], "status": "REASONED"}, "budibase-couchdb": {"text": "Proxy /db/ reaches CouchDB. Compose maps COUCH_DB_USER/PASSWORD to COUCHDB_USER/PASSWORD; the image entrypoint adds that admin only when both are non-empty and its existing-admin check finds none for that name. With unchanged samples and that condition, anyone reaching the proxy can try budibase/budibase.", "components": ["budibase"], "sources": ["budibase:s735ead81ce9c", "budibase:s7fbf706a4c8f", "budibase:s5209f2549c9b", "budibase:s9afbeb2063b5", "budibase:s4d349b4d4ac3", "budibase:s2a033b3a4117", "budibase:scdcd8b6e7071", "budibase:sc74f36aa8340", "budibase:sf6e2a660380f", "budibase:sfdc76e71aff6", "budibase:s851ad716e105"], "status": "REASONED"}, "budibase-secrets": {"text": "Sample JWT_SECRET/API_ENCRYPTION_KEY are testsecret; service passwords, INTERNAL_API_KEY and LITELLM_MASTER_KEY are budibase. Replace all before first start. Compose pins docker.io/litellm/litellm:main-v1.83.10-stable.patch.3 and passes LITELLM_MASTER_KEY; acceptance of budibase by that image is not established.", "components": ["budibase"], "sources": ["budibase:s735ead81ce9c", "budibase:s74d858053d12", "budibase:s5209f2549c9b", "budibase:s32775c45ba51"], "status": "REASONED"}, "budibase-sample-check": {"text": "This guide's October 2026 search of the full v3.46.0 tag with git grep -l testsecret v3.46.0 found exactly hosting/.env, hosting/digitalocean/files/var/lib/cloud/scripts/per-instance/001_onboot, hosting/hosting.properties, packages/backend-core/tests/jestEnv.ts and scripts/dev/manage.js. This guide inspected those hits. This guide's assessment is that these hits do not establish absence of production rejection; no live safeguard test ran.", "components": ["budibase"], "sources": ["budibase:s735ead81ce9c", "budibase:s5209f2549c9b", "budibase:se993218560de", "budibase:s6179ec0d9abd", "budibase:s9cc27222e133", "budibase:sbe7e0469e7a8"], "status": "REASONED"}, "budibase-init": {"text": "POST /api/global/users/init is public; its body validator requires string email and tenantId fields, permits an optional password, rejects unknown fields and precedes adminUser. In the supplied tenant context, checkAnyUserExists() truthy causes 403 with the exact vendor error quoted in the body. The helper definition was not fetched, so which users count and any multi-tenancy exception remain unestablished. This guide assesses this POST as unsafe for read-only claimed-state probing: invalid bodies can fail validation in both states, while accepted initialization creates an admin.", "components": ["budibase"], "sources": ["budibase:s45379b35a980", "budibase:sc0a3ac85466f", "budibase:s4902d93986c7", "budibase:s2db467c4f1cf", "budibase:sbd761572fef0", "budibase:sb8f5a18ab4ee", "budibase:s7883460cabbf", "budibase:se18a9c7182fb"], "status": "REASONED"}, "budibase-seed": {"text": "Claim admin before exposure (guide advice), manually or with BB_ADMIN_USER_EMAIL/BB_ADMIN_USER_PASSWORD. Startup attempts seeding only with SELF_HOSTED truthy, MULTI_TENANCY falsy and both values non-empty; in tenancy.getTenantId() context, it calls createAdminUser when the admin checklist is not checked OR the email does not exist, with hashPassword/requirePassword/skipPasswordValidation true.", "components": ["budibase"], "sources": ["budibase:sc0a3ac85466f", "budibase:sedd5e0682bf0", "budibase:s35ae42bd7a1c", "budibase:s816a78b83956"], "status": "REASONED"}, "windmill-code": {"text": "Workers execute user scripts; a login enables code execution on worker hosts.", "components": ["windmill"], "sources": ["windmill:s3934703e8ba2", "windmill:s6a71d3938c7a"], "status": "REASONED"}, "windmill-bind": {"text": "Default 0.0.0.0:8000; SERVER_BIND_ADDR changes binary binding. Default wildcard bind was not observed.", "components": ["windmill"], "sources": ["windmill:s9186f1630378"], "status": "REASONED"}, "windmill-compose": {"text": "Compose exposes server 8000 internally; Caddy publishes 80/25 on all host interfaces. Bind publications privately; container loopback cutting Caddy off is inferred.", "components": ["windmill"], "sources": ["windmill:s6a7eeac413b7"], "status": "REASONED"}, "windmill-sandbox": {"text": "DISABLE_NSJAIL defaults true; sandboxing is not enabled by default.", "components": ["windmill"], "sources": ["windmill:s6a71d3938c7a"], "status": "REASONED"}, "windmill-privileged": {"text": "Shipped worker uses privileged=true.", "components": ["windmill"], "sources": ["windmill:s6a7eeac413b7"], "status": "REASONED"}, "windmill-smtp": {"text": "Port 25 serves email triggers; do not publish unless used.", "components": ["windmill"], "sources": ["windmill:s6a7eeac413b7"], "status": "REASONED"}, "windmill-debugger": {"text": "Vendor warns REQUIRE_SIGNED_DEBUG_REQUESTS=false exposes an unauthenticated code-execution debugger on reachable deployments.", "components": ["windmill"], "sources": ["windmill:s6a7eeac413b7"], "status": "REASONED"}, "verify-nocodb-signup": {"text": "Controlled signup creation is exposed; invite-only refusal is fixed. Hidden links prove nothing; inspect backend setting and confirm admin login.", "components": ["nocodb"], "sources": ["nocodb:s5ea55dc5f0d9", "nocodb:sd0e9f451673f"], "status": "REASONED"}, "verify-baserow-signup": {"text": "Controlled signup creation is exposed; Sign up is disabled is fixed. Inspect allow_new_signups and confirm admin login.", "components": ["baserow"], "sources": ["baserow:s34b87f60dcc6", "baserow:s4588448964a2"], "status": "REASONED"}, "verify-appsmith-signup": {"text": "Controlled signup creation is exposed; SIGNUP_DISABLED is fixed. Inspect backend settings, confirm admin login and delete test accounts.", "components": ["appsmith"], "sources": ["appsmith:s2650629933a1", "appsmith:sd1c55be91e30", "appsmith:se3cd894dc579"], "status": "REASONED"}, "verify-external": {"text": "Probe each public IP; control proves only its port, refusal/timeout may be local filtering, and unexpected connections need host confirmation.", "components": ["baserow", "budibase", "windmill"], "sources": ["baserow:s1b31314982f4", "budibase:s74d858053d12", "windmill:s6a7eeac413b7"], "status": "REASONED"}, "windmill-default-login": {"text": "Fresh loopback DB had only admin@windmill.dev as a password user/super-admin; changeme returned 200/token, then 400 after rotation while the new password returned 200. Runs covered an earlier form whose result was curl's own exit status; the current form was not re-run.", "components": ["windmill"], "sources": ["windmill:s3934703e8ba2", "windmill:sdcc9601405df", "windmill:s8927ade6b336"], "status": "DEMONSTRATED", "evidence": "After the password was changed, `changeme` got `400` and the new password `200`.", "verify": [4]}, "windmill-loopback": {"text": "SERVER_BIND_ADDR=127.0.0.1 produced only a loopback listener on the test port.", "components": ["windmill"], "sources": ["windmill:s9186f1630378"], "status": "DEMONSTRATED", "evidence": "The server's only listener was on 127.0.0.1, at the test port the run set."}, "windmill-sandbox-run": {"text": "Loopback worker without nsjail logged sandboxing unavailable; this does not demonstrate isolation.", "components": ["windmill"], "sources": ["windmill:s6a71d3938c7a"], "status": "DEMONSTRATED", "evidence": "on the loopback run, without nsjail installed, the worker logged \"Nsjail sandboxing will NOT be available\"."}, "verify-inventory": {"text": "Inventory host sockets and Docker publications; NAT may have no socket, so absent ss entries do not establish isolation.", "components": ["docker", "baserow", "appsmith", "budibase", "windmill"], "sources": ["docker:s2fecb6db5480", "baserow:s1b31314982f4", "appsmith:s9fdcff5a28a4", "budibase:s74d858053d12", "windmill:s6a7eeac413b7"], "status": "REASONED", "verify": [1]}, "verify-port-display": {"text": "IPv6 wildcard displays [::]: from v27.2.0 versus ::: through v27.1.2; both mean all host addresses unless filtered.", "components": ["docker", "docker-new", "docker-old"], "sources": ["docker:s2fecb6db5480", "docker-new:sca9a0e1e4b6a", "docker-old:sa202ad11d4ed"], "status": "REASONED", "verify": [1]}, "verify-sample-tokens": {"text": "Whole-token search found sample secrets/variants, clean replaced copies and unreadable paths; harmless matches and external environment values limit scope. Runs covered an earlier form whose result arms ended with status 0; the current form was not re-run.", "components": ["nocodb", "appsmith", "budibase", "windmill"], "sources": ["nocodb:s536899ecd4a5", "appsmith:s9fdcff5a28a4", "budibase:s735ead81ce9c", "windmill:s6a7eeac413b7"], "status": "DEMONSTRATED", "evidence": "On copies with the values replaced it printed \"no vendor sample tokens found\", and on an unreadable path it reported that it did not check.", "verify": [2]}, "verify-nocodb-key": {"text": "docker exec tests configured key presence without printing it or passing its value in argv; no real container run. Startup selection/process value remain unverified.", "components": ["nocodb", "docker"], "sources": ["nocodb:sbfbf630008e1", "nocodb:se5da9707c684", "docker:sd34e76a09f6c"], "status": "REASONED", "verify": [3]}, "nocodb-key-limits": {"text": "Trusted shells/startup assumed; environment remains readable to same-account/root or Docker-socket holders. Presence does not prove encryption; do not inject a replacement key.", "components": ["nocodb", "docker"], "sources": ["nocodb:sca40040b40cc", "nocodb:sbfbf630008e1", "nocodb:se5da9707c684", "docker:sd34e76a09f6c"], "status": "REASONED", "verify": [3]}, "nocodb-key-logic": {"text": "A docker exec stand-in exercised set/empty/unset/missing-container branches only; no NocoDB deployment demonstrated. Runs covered an earlier form whose result arms ended with status 0; the current form was not re-run.", "components": ["docker"], "sources": ["docker:sd34e76a09f6c"], "status": "DEMONSTRATED", "evidence": "With a stand-in for `docker exec`, the block printed \"non-empty\" for a set key, \"EMPTY or unset\" for an empty and an unset one, and \"not checked\" when the container was missing;"}, "verify-windmill-control": {"text": "Before trusting default-login 400, confirm real credentials work so a dead service is not treated as fixed.", "components": ["windmill"], "sources": ["windmill:s3934703e8ba2"], "status": "REASONED"}, "verify-couchdb": {"text": "Sample login at /db/_session?basic=true should return 200 exposed and 401 after replacement; real credentials are the positive control.", "components": ["budibase", "couchdb"], "sources": ["budibase:s735ead81ce9c", "budibase:s7fbf706a4c8f", "couchdb:s34292991c4ec", "budibase:s5209f2549c9b", "budibase:s9afbeb2063b5", "budibase:s2a033b3a4117", "budibase:s851ad716e105"], "status": "REASONED", "verify": [5]}, "verify-litellm": {"text": "Sample Bearer budibase at /v1/models should return 200 exposed; fixed, a 401 (or a 400 if the proxy has no database); confirm real credentials. The pinned authentication source rejects non-`sk-` keys with 401 when a database is connected and returns 400 without one; the exposed 200 still requires live comparison. These remain comparison expectations: this guide has not established that litellm.md's source pin corresponds to Compose image docker.io/litellm/litellm:main-v1.83.10-stable.patch.3; no source for that image was fetched.", "components": ["budibase", "litellm"], "sources": ["budibase:s735ead81ce9c", "budibase:s74d858053d12", "litellm:sbe6fa0b81017", "budibase:s32775c45ba51"], "status": "REASONED", "verify": [5]}, "verify-tcp-outcomes": {"text": "Loopback observed connected/open, timeout/full accept queue, refused/closed and failed-control stopping; no real firewall demonstrated. Runs covered an earlier form whose control and result arms ended with status 0; the current form was not re-run.", "components": ["baserow", "budibase", "windmill"], "sources": ["baserow:s1b31314982f4", "budibase:s74d858053d12", "windmill:s6a7eeac413b7"], "status": "DEMONSTRATED", "evidence": "On loopback it printed \"connected\" for the control and an open port, \"timed out\" for a port whose accept queue was full (standing in for a filtered one) and \"refused\" for a closed port, and it stopped when the control was closed.", "verify": [6]}, "verify-tcp-inputs": {"text": "Guard tests reject named hosts, malformed IPv4, zero/dotted IPv6, invalid ports and absent timeout; loopback/hex-mapped IPv6 pass. Invalid hex may fail at control. Runs covered an earlier form whose guard, control and result arms ended with status 0; the current form was not re-run.", "components": ["baserow", "budibase", "windmill"], "sources": ["baserow:s1b31314982f4", "budibase:s74d858053d12", "windmill:s6a7eeac413b7"], "status": "DEMONSTRATED", "evidence": "`::ffff:0:0` and `::ffff:7f00:1` passed and connected to a listener bound to 127.0.0.1", "verify": [6]} } } ```` ====================================================================== ==> server-admin-panels.md ====================================================================== # Server administration panels: Cockpit, Webmin, and Proxmox VE Cockpit, Webmin, Usermin and Proxmox VE are web consoles for managing servers and virtualization clusters. This guide helps your AI assistant configure them securely, including reach limited to admin networks (access lists and firewall rules on Proxmox clusters) and second factors through Cockpit's PAM stack or Webmin's and Proxmox's settings. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/server-admin-panels.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Cockpit 368, Webmin 2.670, Proxmox VE 9.2 docs 9.2.12, pve-manager 9.2.20, Usermin 2.570, and systemd v262; 3 further sources are pinned to a commit. Metadata reviewed 2026-10-18; documentation checked September 2026. The guide records 38 claims: 4 demonstrated by a check that ran and 34 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. A server administration panel is root on the host behind a login form. Cockpit gives any user who can escalate with sudo or polkit an administrative session and a terminal. Webmin's packages make the host's root password a Webmin login with every module, including a root shell. Proxmox VE's web interface is the whole cluster API, and its `root@pam` user can always log in. All three listen on every interface by default, none turns on a second factor by default, and none locks out an account after repeated wrong passwords. Keep them off the public internet: bind them to a management address, allow only management networks ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md)), reach them over a VPN or tunnel ([tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md)), and turn on each panel's second factor. Versions checked: Cockpit 368, Webmin 2.670 (Usermin 2.570), and Proxmox VE 9.2 (pve-manager 9.2.20, pve-docs 9.2.12; libpve-http-server-perl 6.0.5 and libpve-common-perl 9.1.21 for the bind and access-list review). ## Cockpit Cockpit's socket unit listens on a bare port, `ListenStream=9090`. The systemd reference reads a bare number as a port "to listen on via IPv6", "available via both IPv6 and IPv4 (default)", so it is reachable on every address. The port cannot be changed in `cockpit.conf`; restrict it with a socket drop-in, `/etc/systemd/system/cockpit.socket.d/listen.conf`, whose empty `ListenStream=` line resets the default before the new one: ```ini [Socket] ListenStream= ListenStream=10.0.0.5:9090 FreeBind=yes ``` Apply it with `sudo systemctl daemon-reload` and `sudo systemctl restart cockpit.socket`. The vendor calls `FreeBind` "highly recommended when defining specific IP addresses". Cockpit serves HTTP and HTTPS on the same port and redirects HTTP to HTTPS, except from localhost; `AllowUnencrypted` defaults to false. Without a certificate in `/etc/cockpit/ws-certs.d`, it creates a self-signed one. Any local account can log in through the `cockpit` PAM stack. Root is refused by a deny list, `/etc/cockpit/disallowed-users`, which the upstream packages write only on a first install, and the PAM line uses `onerr=succeed`, so a missing or unreadable file lets root in. A logged-in user has "exactly the same privileges as if they logged in via SSH", including the terminal, and Cockpit escalates the session to root "immediately upon login" when the user can already escalate with sudo or polkit. There is no built-in second factor; use the PAM stack, Kerberos, or client certificates. There is no lockout either: `MaxStartups` (default 10) limits concurrent login attempts, not attempts over time. If Cockpit can also reach a private network, the vendor recommends `LoginTo=false`, which "prevents unauthenticated remote attackers from scanning the internal network". ## Webmin and Usermin Webmin listens on TCP 10000 and Usermin on 20000. The installer writes no `bind=` line, and without one miniserv listens on all IPv4 and IPv6 addresses ("Listening on all IPs"). It also writes `listen=10000` (Usermin `listen=20000`), which opens a UDP socket on every IPv4 address so other Webmin servers can find this one. The vendor says Webmin "will accept connections from any IP address" by default and advises limiting access "so that an attacker from outside your network cannot even attempt to login". In `/etc/webmin/miniserv.conf`, set `bind=` to a management address, delete the `listen=` line if you do not use server discovery, add `allow=` with your management networks, then run `/etc/webmin/restart`. On a loopback run of Webmin 2.670's miniserv with `bind=127.0.0.1` and no `listen=` line, its only socket was TCP `127.0.0.1` on the test port, with no UDP socket. The deb and RPM packages turn on HTTPS (`ssl=1`) with a self-signed certificate, and create the Webmin user `root` with `crypt=x`, which means Unix authentication: the host's root password logs in to Webmin with every module. They also add `sudo=1`, which lets any Unix user whom sudo permits log in "effectively as root". The Command Shell module runs commands as root by default. Two-factor authentication (TOTP or Authy) exists but is not configured on install; turn it on and enroll every user. Vendor advisories record a two-factor bypass through basic authentication (CVE-2026-42210, CVE-2026-56022) in releases listed under "Webmin prior to 2.641", so run a current release. The installer sets `blockhost_failures=5` and `blockhost_time=60`, so a host is blocked for 60 seconds after five failed logins; per-user blocking is not set. That is a rate limit, not a lockout. Failed HTTP basic-authentication logins are counted only when `passdelay` is set, which the installer also writes (`passdelay=1`). On the loopback run with those settings, the fifth wrong password was delayed past a five-second client timeout by miniserv's growing failure delay, and the right password was then refused with `403` "Access denied for 127.0.0.1". Keep the network allow list as the real control. ## Proxmox VE `pveproxy` serves the whole Proxmox VE API over HTTPS on 8006, and `spiceproxy` listens on 3128. By default both "listen on the wildcard address and accept connections from both IPv4 and IPv6 clients" according to pve-docs 9.2.12. **Implementation qualification:** when creating a new socket without `LISTEN_IP`, libpve-common-perl 9.1.21 tries `::`, then `0.0.0.0` if that fails, rather than guaranteeing both families; its restart path can reuse an existing socket. Dual-stack acceptance and the documented IPv6-only effect of `net.ipv6.bindv6only=1` remain documentation-backed: the socket-library implementation was not fetched or tested. The fetched files also do not trace PVE::HTTPServer inheritance. Check the running listeners and each public address as below. `LISTEN_IP` in `/etc/default/pveproxy` restricts the bind, but the vendor warns that it is "not recommended" on clustered systems, whose nodes need each other's `pveproxy`, possibly across subnets. That cluster warning is documentation-backed; this guide advises management-only access lists and firewall rules. Both daemons read the same file and use its `ALLOW_FROM`, `DENY_FROM` and `POLICY`. The implementation defaults to `allow`: allow-only matches pass, deny-only matches fail, and clients matching both lists or neither list follow the policy. Thus an `ALLOW_FROM` list alone restricts nothing under the default policy; also set `DENY_FROM="all"` (the vendor's example) or `POLICY="deny"`. This guide's advice is to set one or the other, not both: under `POLICY="deny"` a client that matches both lists is denied, so adding `DENY_FROM="all"` refuses everyone. `LISTEN_IP` binds `spiceproxy` as well as `pveproxy`. The Proxmox VE firewall "is completely disabled by default"; when you enable it, allow 8006, 22 and 3128 from management addresses only. This source conjunction uses pve-manager 9.2.20, pve-docs 9.2.12, libpve-http-server-perl 6.0.5 and libpve-common-perl 9.1.21 at the Sources pins. Their changelog entries identify those versions; pve-manager's runtime dependencies require pve-docs >= 9.2.8, libpve-http-server-perl >= 6.0.3 and libpve-common-perl >= 9.1.21. This guide's inference is that the selected versions satisfy those three bounds, not that they establish one installed package set. A host may run a later pve-common; its installed versions and live behaviour remain unverified. The ACL code agrees with the documented access table; the wildcard code adds the IPv4 fallback qualification above. The system's `root` user "can always log in via the Linux PAM realm and is an unconfined administrator", and the installer sets its password for the web interface. Its node shell opens with `/bin/login -f root`, without a further password. Two-factor authentication (TOTP, WebAuthn, recovery keys, and realm-enforced TOTP or YubiKey OTP) is available and must be configured. The only lockout is on the second factor: eight failed TOTP attempts disable that user's TOTP, and after 100 failed WebAuthn or recovery-key attempts all second factors are blocked for an hour. For passwords, the API delays every unauthorized response by three seconds. Each cluster creates its own self-signed CA. API tokens have separated privileges by default, and cannot reach the VM or node consoles. ## Verify REASONED: this historical run overview preserves the original authoring record, not a new live comparison; a live Cockpit, Webmin/Usermin or Proxmox VE stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran the listener block through `bash -n` and ShellCheck 0.11.0 with no output, and a `sudo` stub printed `sudo stub: ss -tlnp` and `sudo stub: ss -ulnp`. Those runs covered an earlier form of the listener block, two top-level `ss` commands without a status of their own. The current form, which checks for `ss` before listing, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n and stub runs with `ss` present, failing and missing. Exposed listeners are wildcard TCP and Webmin/Usermin discovery UDP; fixed listeners bind management addresses with discovery UDP absent, or, in this guide's assessment, clustered Proxmox retains wildcard TCP behind management-only ACLs and firewall rules. The exposed and fixed listener descriptions are per the guide's cited Cockpit 368 socket unit; Webmin 2.670 miniserv (`bind`, `sockets` and the discovery socket); Webmin 2.670 installer (`listen=`, `blockhost_*`, `passdelay=1`, certificate); Usermin 2.570 installer; and Proxmox VE `pveproxy` (bind, `LISTEN_IP`, access lists) (Proxmox VE 9.2, pve-docs 9.2.12). The guide's cited sources for those descriptions are now supplemented for Proxmox bind and access lists by the source conjunction and implementation qualification above. Two checks here were demonstrated: the Webmin configuration check, whose parser was compared with Webmin 2.670's own on thirty test files, and the TCP reachability probe, which is the block demonstrated on loopback in [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) with this guide's ports. The Webmin parser's fixture runs and the TCP probe's loopback runs used earlier forms of those blocks, whose guard refusals ended with status 0; the current forms, whose guard refusals exit with status 2, have had no demonstration run. Everything else is reasoned: the authoring host runs neither Cockpit's systemd socket nor Proxmox VE, and it forbids binding every interface, so no default bind was observed. Those expected outcomes are REASONED from the cited vendor documentation and source. REASONED: this TCP and UDP inventory instruction; a live Cockpit, Webmin/Usermin or Proxmox VE stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran the listener block through `bash -n` and ShellCheck 0.11.0 with no output, and a `sudo` stub printed `sudo stub: ss -tlnp` and `sudo stub: ss -ulnp`. Those runs covered an earlier form of the listener block, two top-level `ss` commands without a status of their own. The form before the `ss` presence check passed bash -n and local stub runs; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n and stub runs with `ss` present, failing and missing. Exposed listeners are wildcard TCP and Webmin/Usermin discovery UDP; fixed listeners bind management addresses with discovery UDP absent, or, in this guide's assessment, clustered Proxmox retains wildcard TCP behind management-only ACLs and firewall rules. The exposed and fixed listener descriptions are per the guide's cited Cockpit 368 socket unit; Webmin 2.670 miniserv (`bind`, `sockets` and the discovery socket); Webmin 2.670 installer (`listen=`, `blockhost_*`, `passdelay=1`, certificate); Usermin 2.570 installer; and Proxmox VE `pveproxy` (bind, `LISTEN_IP`, access lists) (Proxmox VE 9.2, pve-docs 9.2.12). The guide's cited sources for those descriptions are now supplemented for Proxmox bind and access lists by the source conjunction and implementation qualification above. On the host, list the listeners, TCP and UDP: REASONED: following block; pinned Cockpit, Webmin/Usermin and Proxmox sources support this inventory. The recorded host lacks Cockpit systemd and Proxmox and forbids wildcard binds; exposed/fixed expectations follow. The cluster exception in this listener interpretation also needs a multi-node Proxmox VE cluster with management networks, access lists and firewall rules, none of which was available. A live Cockpit, Webmin/Usermin or Proxmox VE stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran the listener block through `bash -n` and ShellCheck 0.11.0 with no output, and a `sudo` stub printed `sudo stub: ss -tlnp` and `sudo stub: ss -ulnp`. Those runs covered an earlier form of the listener block, two top-level `ss` commands without a status of their own. The current form, which checks for `ss` before listing, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n and stub runs with `ss` present, failing and missing. Exposed listeners are wildcard TCP and Webmin/Usermin discovery UDP; fixed listeners bind management addresses with discovery UDP absent, or, in this guide's assessment, clustered Proxmox retains wildcard TCP behind management-only ACLs and firewall rules. The exposed and fixed listener descriptions are per the guide's cited Cockpit 368 socket unit; Webmin 2.670 miniserv (`bind`, `sockets` and the discovery socket); Webmin 2.670 installer (`listen=`, `blockhost_*`, `passdelay=1`, certificate); Usermin 2.570 installer; and Proxmox VE `pveproxy` (bind, `LISTEN_IP`, access lists) (Proxmox VE 9.2, pve-docs 9.2.12). The guide's cited sources for those descriptions are now supplemented for Proxmox bind and access lists by the source conjunction and implementation qualification above. Exposed, the reasoned expectation is a wildcard address (`*:`, `0.0.0.0:` or `[::]:`) on 9090, 10000, 20000, 8006 and 3128; fixed means a management address only. On a Proxmox VE cluster, where the vendor advises against `LISTEN_IP`, 8006 and 3128 keep their wildcard binds; this guide's assessment is that the fixed state there requires the management-only access lists and firewall below. The new-socket code tries `::` then `0.0.0.0`; dual-stack operation is documentation-backed and must be checked on the host. The block exits 2 if `ss` is not installed, 1 for a wildcard TCP listener on 9090, 10000 or 20000 or any UDP listener on 10000 or 20000, 3 if either `ss` command fails and otherwise 0, and leaves 8006, 3128 and every other listener for you to judge. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH (the guide's cited sudo sudoers manual, secure_path entry), so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. ```bash ( # one subshell, so one exit status covers both listener lists set +e # errexit off here, so a failed command is recorded below instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } # a refusal, status 2, before either list runs, so no part has found anything yet set -- 0 "$(if sudo ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0; a missing ss has already exited 2 above), then the TCP listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the TCP listener table, then ss's exit status printf '%s' "$2" # 9090 (Cockpit), 10000 (Webmin), 20000 (Usermin), 8006 and 3128 (Proxmox VE): a management address only; Proxmox cluster exception below [ "$3" = 0 ] || { echo "sudo ss -tlnp exit $3: the TCP listener list did not print; inconclusive"; set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 { p = $4; sub(/.*:/, "", p); a = substr($4, 1, length($4) - length(p) - 1); sub(/%.*/, "", a) } NR > 1 && (p == "9090" || p == "10000" || p == "20000") && (a == "*" || a == "0.0.0.0" || a == "[::]") { found = 1 } END { exit !found }'; then echo "FINDING: a TCP listener on port 9090, 10000 or 20000 is bound to a wildcard address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the TCP listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi set -- "$1" "$(if sudo ss -ulnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the status so far, then the UDP listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the UDP listener table, then ss's exit status printf '%s' "$2" # Webmin's discovery socket on UDP 10000 (Usermin 20000) should be gone [ "$3" = 0 ] || { echo "sudo ss -ulnp exit $3: the UDP listener list did not print; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 && $4 ~ /:(10000|20000)$/ { found = 1 } END { exit !found }'; then echo "FINDING: a UDP listener on port 10000 or 20000, the Webmin or Usermin discovery socket, is still open"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the UDP listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi echo "block exit $1; Proxmox VE's 8006 and 3128 and every other listener are not classified, so judge them yourself against the cluster exception below" exit "$1" ) ``` REASONED: this file-inspection procedure needs a Webmin host and running miniserv for comparison with sockets; a live Webmin stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran the parser block through `bash -n` and ShellCheck 0.11.0 without output; under `set -u`, placeholder, omitted assignment and marker-only inputs printed the respective substitution, whole-block and value-count refusals. The bash -n, ShellCheck and refusal runs recorded here covered the parser block in an earlier form, whose guard refusals and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for a file that prints `NO effective bind=`, a `listen=` line or `NO allow=` and 3 for a failed parse. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. The extracted Perl program on local exposed and fixed fixtures printed `NO effective bind=: every address`, discovery and `NO allow=`, versus `bind=10.0.0.5`, `no UDP discovery socket` and `allow=10.0.0.0/24`; extra-listener/deny/sudo and repeated-bind fixtures printed the corresponding settings and last bind. Those Perl fixture runs used an earlier form of the program, whose result arms ended with status 0; in local runs of the current form, which exits 1 after printing an exposed setting, exposed and fixed fixtures printed those lines and ended with status 1 and 0. These are file results only; on the Webmin host, exposed miniserv listens on every address with discovery UDP open and offers its login to any client; fixed miniserv listens on the management address only with no discovery socket, a management-network client reaches the login, and from a client outside the allow= networks the connection fails or miniserv refuses it. The exposed and fixed Webmin host behaviours are per the guide's cited Webmin 2.670 miniserv (`bind`, `sockets` and the discovery socket) and Webmin 2.670 sudo login, configuration parser (`read_config_file`), allow and deny lists, and basic-authentication failure counting (`passdelay`). On a Webmin host, check the configuration miniserv reads when it starts. The block parses the file with a copy of miniserv's own `read_config_file` (a line starting with `#` is a comment, spaces around the name and the value are trimmed, and the last occurrence of a setting wins), then applies miniserv's rules: `bind=*`, `bind=0`, `bind=0.0.0.0`, `bind=::`, or an empty or missing `bind=` means every address. The block reads the value with the system's numeric address parser, `inet_pton`, which never looks up a name: an all-zero IPv4 or IPv6 address is reported as every address, any other IPv4 or IPv6 address the parser accepts is printed as it is, except an IPv4-mapped IPv6 address, and every other value is flagged for checking with `ss`. The `sockets=` setting adds listeners; a `listen=` value other than empty or `0` opens the UDP discovery socket; and an empty `allow=` lets every client address try to log in, except those a `deny=` list refuses. The block describes the file, not the running process: `ss` above is the authority for what is listening now, and a change takes effect at `/etc/webmin/restart`. Substitute the file path inside the single quotes (the package default is `/etc/webmin/miniserv.conf`), and paste the whole block. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_MINISERV_CONF' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not checking"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not checking"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute your file on the set -- line above; not checking"; exit 2 ;; esac { [ -f "$1" ] && [ -r "$1" ]; } || { echo "cannot read $1 as a regular file (try sudo); not checked"; exit 2; } command -v perl >/dev/null || { echo "perl is not installed here; not checking"; exit 2; } set -- "$1" 0 # the file, then the block's status so far: 1 for a finding, 3 for a failed parse, otherwise 0 # shellcheck disable=SC2016 # the single-quoted Perl program is meant to expand its own variables if perl -e ' eval { require Socket; open(CONF, "<", $ARGV[0]) || die "cannot open the file\n"; while () { s/\r|\n//g; if (/^#/ || !/\S/) { next; } /^([^=]+)=(.*)$/; $name = $1; $val = $2; $name =~ s/^\s+//g; $name =~ s/\s+$//g; $val =~ s/^\s+//g; $val =~ s/\s+$//g; $rv{$name} = $val; } close(CONF); $bind = $rv{"bind"}; $bind = "" if ($bind eq "*"); $v4 = $bind eq "" ? undef : Socket::inet_pton(Socket::AF_INET(), $bind); $v6 = (defined($v4) || $bind !~ /:/) ? undef : Socket::inet_pton(Socket::AF_INET6(), $bind); if (!$bind || (defined($v4) && $v4 eq "\0" x 4) || (defined($v6) && $v6 eq "\0" x 16)) { print "NO effective bind=: every address\n"; $found = 1; } elsif (defined($v4) || (defined($v6) && substr($v6, 0, 12) ne ("\0" x 10) . "\xff\xff")) { print "bind=$bind\n"; } else { print "bind=$bind: not a plain IPv4 or IPv6 address; check with ss what miniserv binds\n"; } print "sockets=$rv{sockets}: extra listeners, possibly on every address; check ss\n" if ($rv{"sockets"} =~ /\S/); print $rv{"listen"} ? "listen=$rv{listen}: UDP discovery socket on every IPv4 address\n" : "no UDP discovery socket\n"; $found = 1 if ($rv{"listen"}); @allow = split(/\s+/, $rv{"allow"}); @deny = split(/\s+/, $rv{"deny"}); print "deny=@deny\n" if (@deny); print @allow ? "allow=@allow\n" : @deny ? "NO allow=: any client address not on deny= may try to log in\n" : "NO allow=: any client address may try to log in\n"; $found = 1 if (!@allow); print "sudo=$rv{sudo}: Unix users whom sudo permits can log in as root\n" if ($rv{"sudo"}); 1; } or do { print STDERR "parse failed: $@"; exit 3; }; exit($found ? 1 : 0); ' "$1"; then :; else case "$?" in 1) set -- "$1" 1 ;; *) echo "could not read $1; not checked"; set -- "$1" 3 ;; esac fi echo "block exit $2; the allow= and deny= entries as written, any sockets= or sudo= line and any bind= value flagged for ss are not classified, so judge them yourself" exit "$2" ) ``` DEMONSTRATED: preceding block; the parser matched Webmin 2.670 on thirty fixtures and printed the recorded outcomes below. The thirty-file fixture runs used an earlier form of the block, whose guard refusals and result arms ended with status 0; the current form exits 2 for a guard refusal, 1 for a file that prints `NO effective bind=`, a `listen=` line or `NO allow=`, and 3 for a failed parse, including a Perl error. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. This demonstrates file inspection, not running sockets or network isolation. Exposed: "NO effective bind=", a `listen=` line and "NO allow=". Fixed: a management address on `bind=`, "no UDP discovery socket" and only your management networks on `allow=`; the block prints the lists as written, so check each entry. A `sockets=` line means more listeners; check each with `ss`. This was demonstrated on thirty test files: the loopback run's configuration, its exposed variant, a repeated `bind=` whose last value is `*`, `bind=0.0.0.0`, `bind=::`, `bind=0:0:0:0:0:0:0:0`, four IPv6 addresses printed as they are (`2001:db8::5`, `0:0:0:0:ffff:0:0:1`, `::ffff:0` and `64:ff9b::10.0.0.5`), fourteen values flagged for `ss`, and six other files. The fourteen values flagged for `ss` were `0x0`, `0x00000000`, `0x0a000005`, `010.0.0.5`, `cafe`, `0 10.0.0.5`, `::ffff:0:0`, `::ffff:10.0.0.5`, `1::2::3`, `a:b:c`, `12345::1`, `1:2:3`, `:::1` and `2001:db8:5`. The thirty test files included six other files, with spaced settings, commented settings, `bind=0` with `listen=0` and an empty `allow=`, an empty `allow=` with `deny=127.0.0.1`, CRLF line endings with `sockets=*:10001`, and a line without `=`. On each, the copy's parsed values matched Webmin 2.670's own `read_config_file` run on the same file, and the block printed the outcomes above; it reported an unreadable path as not checked. REASONED: this Cockpit socket and root-deny inspection needs a Cockpit systemd/PAM host; a live Cockpit stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review used a `systemctl` stub printing `[Socket]`, empty `ListenStream=` and `ListenStream=10.0.0.5:9090`; the `sudo` stub printed `sudo stub: cat /etc/cockpit/disallowed-users`, not file contents. The expected bare-port exposure versus reset/private bind and missing-file root access versus a root deny entry follow per the guide's cited Cockpit 368 socket unit; systemd v262 socket unit reference (bare port); Cockpit 368 listening address and port; and Cockpit 368 PAM stack and root deny list. On a Cockpit host, reasoned: `systemctl cat cockpit.socket` shows the effective listeners; exposed is a bare `ListenStream=9090`, fixed is the drop-in's empty `ListenStream=` followed by an address and port. `sudo cat /etc/cockpit/disallowed-users` should list `root`; a missing file lets root log in. REASONED: this Proxmox bind, ACL, firewall and second-factor inspection needs a Proxmox node, cluster inventory and administrative web-interface access; a live Proxmox VE stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran the stated grep expression on scratch files: an empty fixture exited 1 without output, and a fixed fixture exited 0 printing `ALLOW_FROM="10.0.0.0/24"` and `DENY_FROM="all"`; a `pve-firewall` stub printed `Status: enabled/running (stub)`. Expected exposed wildcard/default-allow access and disabled firewall versus management-only bind or ACLs, enabled firewall with management-only rules, and an enrolled second factor for every administrator are this guide's assessment based on the source conjunction and implementation qualification above and the guide's cited Proxmox VE sources. Those cited Proxmox VE sources are Proxmox VE `pveproxy` (bind, `LISTEN_IP`, access lists) (Proxmox VE 9.2, pve-docs 9.2.12), Proxmox VE firewall (Proxmox VE 9.2, pve-docs 9.2.12) and Proxmox VE user management (Proxmox VE 9.2, pve-docs 9.2.12; root@pam, two-factor, lockout and API tokens). On a Proxmox VE node, reasoned: `grep -E '^(LISTEN_IP|ALLOW_FROM|DENY_FROM|POLICY)=' /etc/default/pveproxy` reports that the file does not exist (a diagnostic and a non-zero exit) if it is absent, or prints nothing if the file exists but contains none of those lines, consistent with the wildcard bind and `allow` policy in the pinned code. This grep is only a shortlist: the reader sources the file with Bash, so inspect the complete file with `cat /etc/default/pveproxy` for assignments the pattern misses, shell logic and sourced files; no grep output alone does not establish effective settings. Confirm both daemons' running listeners with `ss` above. This guide's assessment of fixed is `LISTEN_IP` set to a management address on a single node, or `ALLOW_FROM` with your management networks together with `DENY_FROM="all"` or `POLICY="deny"`; this guide advises one of the two, not both. An `ALLOW_FROM` line alone is not fixed: under `allow`, a client matching neither list is allowed; under `deny`, a client matching both lists is denied (the pinned access check agrees with the vendor's access table). `pve-firewall status` prints whether the firewall is enabled and running. Exposed is disabled, the default; fixed is enabled and running, with rules that allow 8006, 22 and 3128 only from management addresses, which you confirm in the datacenter and node firewall rules in the web interface. In the web interface, check that every administrative user, `root@pam` included, has a second factor. REASONED: this external-vantage procedure needs each server public address, a known-open control port and a host outside the allowed management network; a live Cockpit, Webmin/Usermin or Proxmox VE stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Local marking review ran the TCP block through `bash -n` and ShellCheck 0.11.0 with no output; under `set -u`, placeholder, omitted assignment and marker-only inputs printed the respective substitution, whole-block and value-count refusals. A `timeout` stub returning 124 for the control printed `control 198.51.100.7:22 did not connect (timeout stub invoked); not probing`. The bash -n, ShellCheck and stub runs recorded here covered the TCP block in an earlier form, whose guard refusals, failed control and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 3 for a failed control, and after its probes 1 if any port connected, otherwise 3 if any port was inconclusive, otherwise 0. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. Expected exposed TCP connects; fixed isolation may refuse or time out, which is only consistent with isolation after the positive control and server inventory checks. The exposed and fixed TCP expectations and that consistency qualification are per the guide's cited Cockpit 368 socket unit; Webmin 2.670 miniserv (`bind`, `sockets` and the discovery socket); Usermin 2.570 installer; and Proxmox VE `pveproxy` (bind, `LISTEN_IP`, access lists) (Proxmox VE 9.2, pve-docs 9.2.12). Finally, from a host that should not have access, try a TCP connection to each panel port. The block takes one IP address rather than a host name, so that a name with both IPv4 and IPv6 addresses cannot hide one behind a timeout on the other: run it once for each public address of the server. It also takes a port you know is open on that address from this host (for example SSH on 22) as the positive control, and stops if the control does not connect. Substitute both inside the single quotes. DEMONSTRATED: following block; recorded reuse of the low-code-builders.md loopback probe with these ports includes the mapped-address connection below. This is probe evidence only; live panel reachability and firewall isolation remain reasoned. The reused loopback probe runs used an earlier form of the block, whose guard refusals, failed control and result arms ended with status 0; the current form exits 2 for a guard refusal, 3 for a failed control, and after its probes 1 if any port connected, otherwise 3 if any port was inconclusive, otherwise 0, and it holds its values in positional parameters. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ONE_IP_ADDRESS' 'REPLACE_WITH_A_KNOWN_OPEN_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute one IP address on the set -- line above; not probing"; exit 2 ;; esac case "$1" in 0.0.0.0) echo "$1 reaches this host itself; give the public address of the service; not probing"; exit 2 ;; esac set -- "$1" "$2" '^((25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])[.]){3}(25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])$' # the address, the control port, then the IPv4 pattern, held here rather than in a named variable case "$1" in *:*:*) case "$1" in *[!0123456789ABCDEFabcdef.:]*) echo "$1 is not an IPv6 address; not probing"; exit 2 ;; esac case "$1" in *.*) echo "give the IPv4 address itself rather than $1; not probing"; exit 2 ;; esac case "$1" in *[!0:]*) ;; *) echo "$1 is all zeros (this host itself, or not an address); give the public address of the service; not probing"; exit 2 ;; esac ;; *) [[ $1 =~ $3 ]] || { echo "give one IPv4 address (four numbers, 0 to 255, joined by dots) or one IPv6 address, with no host name, port or brackets; not probing"; exit 2; } ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a known-open control port on the set -- line above; not probing"; exit 2 ;; esac { [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; } 2>/dev/null || { echo "the control port must be 1 to 65535; not probing"; exit 2; } command -v timeout >/dev/null || { echo "timeout is not installed here; not probing"; exit 2; } # shellcheck disable=SC2016 # the single-quoted script is meant to expand $1 and $2 in the child shell set -- "$1" "$2" "$(if LC_ALL=C timeout 5 bash -c ': > "/dev/tcp/$1/$2"' probe "$1" "$2" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the address, the control port, the control's messages, then its exit status set -- "$1" "$2" "${3%$'\n'}" "$4" # the messages without their final line break, as a plain capture prints them [ "$4" = 0 ] || { echo "control $1:$2 did not connect (${3:-timed out}); not probing"; exit 3; } echo "control $1:$2 connected" set -- "$1" 0 9090 10000 20000 8006 3128 # the address, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports still to probe while [ "$#" -gt 2 ]; do # shellcheck disable=SC2016 # the single-quoted script is meant to expand $1 and $2 in the child shell set -- "$(if LC_ALL=C timeout 5 bash -c ': > "/dev/tcp/$1/$2"' probe "$1" "$3" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1##*$'\n'}" "${1%$'\n'*}" "${@:2}" # this port's exit status, its messages, the address, the status so far, this port, then the ports after it set -- "$1" "${2%$'\n'}" "${@:3}" # the messages without their final line break case "$1:$2" in 0:*) echo "$3:$5 connected: reachable from this host"; set -- "$1" "$2" "$3" 1 "${@:5}" ;; 124:*) echo "$3:$5 timed out from this host" ;; *"Connection refused"*) echo "$3:$5 refused from this host" ;; *) echo "$3:$5 inconclusive (not a connection, refusal or timeout): ${2:-no message}"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 "${@:5}" ;; esac set -- "$3" "$4" "${@:6}" # the address, the status so far, then the ports still to probe done exit "$2" ) ``` DEMONSTRATED: preceding block; the loopback connection recorded below came from low-code-builders.md's earlier form of this probe, whose guard refusals, failed control and result arms ended with status 0. Exposed, a panel port reports "connected"; a transparent proxy on the probing host's network can also complete the handshake, so confirm a surprising "connected" with `ss` on the server. "refused" and "timed out" show only that this host could not reach the port: a firewall in front of the server produces either, but so can filtering on the probing host's own network, and the control proves only its own port. Treat them as consistent with fixed, and take `ss` on the server as the authority; the probe does not test Webmin's UDP discovery socket, which only `ss -ulnp` shows. "inconclusive" is any other result and says nothing about the port. The block is the one demonstrated in [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) with only the port list changed; that guide lists the values it was measured to refuse. It refuses `0.0.0.0`, IPv6 values made only of zeros and colons, and IPv6 values containing a dot, but not every value that reaches the probing host: loopback addresses pass, and hex spellings such as `::ffff:0:0` connected to a listener bound to 127.0.0.1, so give the server's public address. A "connected" on a port you did not mean to expose is the finding. ## Sources (checked September 2026) - Cockpit 368 socket unit: https://github.com/cockpit-project/cockpit/blob/368/src/systemd/cockpit.socket.in - Cockpit 368 listening address and port: https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/listen.adoc - Cockpit 368 HTTPS and certificates: https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/https.adoc - Cockpit 368 `cockpit.conf` reference (`AllowUnencrypted`, `MaxStartups`, `LoginTo`): https://github.com/cockpit-project/cockpit/blob/368/doc/modules/man/pages/cockpit.conf.5.adoc - Cockpit 368 authentication: https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/authentication.adoc - Cockpit 368 privileges: https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/privileges.adoc - Cockpit 368 PAM stack and root deny list: https://github.com/cockpit-project/cockpit/blob/368/tools/cockpit.pam and https://github.com/cockpit-project/cockpit/blob/368/tools/cockpit.spec - systemd v262 socket unit reference (bare port): https://github.com/systemd/systemd/blob/v262/man/systemd.socket.xml - Webmin 2.670 installer (`listen=`, `blockhost_*`, `passdelay=1`, certificate): https://github.com/webmin/webmin/blob/2.670/setup.sh - Webmin 2.670 miniserv (`bind`, `sockets` and the discovery socket): https://github.com/webmin/webmin/blob/2.670/miniserv.pl - Webmin 2.670 deb and RPM package builders (`crypt=x`, `ssl=1`, `sudo=1`): https://github.com/webmin/webmin/blob/2.670/makedebian.pl and https://github.com/webmin/webmin/blob/2.670/makerpm.pl - Webmin 2.670 sudo login, configuration parser (`read_config_file`), allow and deny lists, and basic-authentication failure counting (`passdelay`): https://github.com/webmin/webmin/blob/2.670/miniserv-lib.pl - Webmin 2.670 Command Shell default ACL: https://github.com/webmin/webmin/blob/2.670/shell/defaultacl - Webmin 2.670 two-factor providers: https://github.com/webmin/webmin/blob/2.670/webmin/twofactor-funcs-lib.pl - Webmin documentation (configuration, security advisories): https://github.com/webmin/webmin.com/blob/8ceae26c5a074053905cbcc6c0053be573633f3a/content/docs/Modules/webmin-configuration.md and https://github.com/webmin/webmin.com/blob/8ceae26c5a074053905cbcc6c0053be573633f3a/content/security.md - Usermin 2.570 installer: https://github.com/webmin/usermin/blob/2.570/setup.sh - Proxmox VE `pveproxy` documentation (Proxmox VE 9.2, pve-docs 9.2.12), API paragraph lines 26 to 28, access lists lines 38 to 60, listening-address section lines 66 to 114 (checked October 2026): https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=pveproxy.adoc - Proxmox VE user management (Proxmox VE 9.2, pve-docs 9.2.12; root@pam, two-factor, lockout and API tokens): https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=pveum.adoc - Proxmox VE firewall (Proxmox VE 9.2, pve-docs 9.2.12): https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=pve-firewall.adoc - Proxmox VE certificates (Proxmox VE 9.2, pve-docs 9.2.12): https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=certificate-management.adoc - Proxmox VE node shell (`/bin/login -f root`) (pve-manager 9.2.20): https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=PVE/API2/Nodes.pm - Proxmox VE HTTP server (three-second delay on unauthorized responses; libpve-http-server-perl 6.0.5 at 5119ff9bec08c69584c0c98bea3edd0098179e5f); access check lines 1868 to 1908, default policy line 2111 (checked October 2026): https://git.proxmox.com/?p=pve-http-server.git;a=blob_plain;hb=5119ff9bec08c69584c0c98bea3edd0098179e5f;f=src/PVE/APIServer/AnyEvent.pm - pve-manager 9.2.20 version, line 1 (checked October 2026): https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=debian/changelog - pve-docs 9.2.12 version, line 1 (checked October 2026): https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=debian/changelog - libpve-http-server-perl 6.0.5 version, line 1, at 5119ff9bec08c69584c0c98bea3edd0098179e5f (checked October 2026): https://git.proxmox.com/?p=pve-http-server.git;a=blob_plain;hb=5119ff9bec08c69584c0c98bea3edd0098179e5f;f=debian/changelog - libpve-common-perl 9.1.21 version, line 1, at 5054082fe492429fc37574985c2ca812af9a3125 (checked October 2026): https://git.proxmox.com/?p=pve-common.git;a=blob_plain;hb=5054082fe492429fc37574985c2ca812af9a3125;f=debian/changelog - pve-manager 9.2.20 runtime dependency entries, lines 65 to 67 and 91 (checked October 2026): https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=debian/control - pve-manager 9.2.20 pveproxy name, configuration, bind and ACLs, lines 41, 66 to 67, 75 to 76 and 98 to 137 (checked October 2026): https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=PVE/Service/pveproxy.pm - pve-manager 9.2.20 spiceproxy shared configuration, bind and ACLs, lines 31 to 59 (checked October 2026): https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=PVE/Service/spiceproxy.pm - libpve-http-server-perl 6.0.5 configuration reader, lines 10 to 97, at 5119ff9bec08c69584c0c98bea3edd0098179e5f (checked October 2026): https://git.proxmox.com/?p=pve-http-server.git;a=blob_plain;hb=5119ff9bec08c69584c0c98bea3edd0098179e5f;f=src/PVE/APIServer/Utils.pm - libpve-common-perl 9.1.21 socket creation and reuse, lines 803 to 854, at 5054082fe492429fc37574985c2ca812af9a3125 (checked October 2026): https://git.proxmox.com/?p=pve-common.git;a=blob_plain;hb=5054082fe492429fc37574985c2ca812af9a3125;f=src/PVE/Daemon.pm - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - cockpit: Cockpit (368) - webmin: Webmin (2.670) - pve-docs: Proxmox VE 9.2 docs (9.2.12) - pve-manager: pve-manager (9.2.20) - webmin-docs: Webmin docs (8ceae26c5a074053905cbcc6c0053be573633f3a) - pve-http: Proxmox HTTP server (5119ff9bec08c69584c0c98bea3edd0098179e5f) - pve-common: libpve-common-perl 9.1.21 (5054082fe492429fc37574985c2ca812af9a3125) - usermin: Usermin (2.570) - systemd: systemd (v262) Claims (full source mapping in the JSON block below): - private (REASONED; cockpit, webmin-docs, pve-docs): Keep root-capable panels off the internet, bound/restricted to management networks with private access and a second factor. - cockpit-bind (REASONED; cockpit, systemd): Bare ListenStream=9090 binds IPv6 and, by default, IPv4 wildcard addresses. - cockpit-dropin (REASONED; cockpit): Use a socket drop-in, not cockpit.conf: reset ListenStream, set 10.0.0.5:9090 and FreeBind=yes, then daemon-reload/restart. - cockpit-tls (REASONED; cockpit): One port serves HTTP/HTTPS, redirecting HTTP except localhost; AllowUnencrypted defaults false and missing certificates cause self-signed creation. - cockpit-pam (REASONED; cockpit): Local accounts use Cockpit PAM with normal SSH-equivalent privileges. - cockpit-root (REASONED; cockpit): First-install packages write root to disallowed-users; onerr=succeed permits root if that file is missing/unreadable. - cockpit-escalation (REASONED; cockpit): Users allowed sudo/polkit escalation get an immediately elevated session and terminal. - cockpit-mfa (REASONED; cockpit): No built-in second factor; use PAM, Kerberos or client certificates. - cockpit-rate (REASONED; cockpit): MaxStartups defaults to 10 concurrent attempts, not repeated-password lockout. - cockpit-loginto (REASONED; cockpit): Set LoginTo=false to prevent unauthenticated scanning of reachable private networks. - webmin-bind (REASONED; webmin, usermin): Webmin/Usermin TCP ports are 10000/20000; omitted bind makes miniserv listen on all IPv4/IPv6 addresses. - webmin-discovery (REASONED; webmin, usermin): Installed listen=10000/20000 opens discovery UDP on every IPv4 address; delete when unused. - webmin-access (REASONED; webmin, webmin-docs): Set management bind and allow networks, then restart; empty allow admits all except denied clients. - webmin-tls (REASONED; webmin): Deb/RPM packages enable ssl=1 with a self-signed certificate. - webmin-root (REASONED; webmin): Installed root crypt=x accepts the Unix root password with all modules; Command Shell defaults to root. - webmin-sudo (REASONED; webmin): Installed sudo=1 lets sudo-permitted Unix users log in effectively as root. - webmin-mfa (REASONED; webmin): TOTP/Authy exists but is not configured at install; enable and enrol every user. - webmin-advisories (REASONED; webmin-docs): Advisories list Basic-auth MFA bypasses CVE-2026-42210/CVE-2026-56022 under Webmin prior to 2.641; use a current release. - webmin-rate (REASONED; webmin): Defaults blockhost_failures=5/blockhost_time=60 block a host for 60 seconds, not a user lockout; Basic-auth failures count only with installed passdelay=1. - pve-bind (REASONED; pve-docs, pve-manager, pve-http, pve-common): pveproxy HTTPS API 8006 and spiceproxy 3128 share LISTEN_IP in /etc/default/pveproxy; new sockets without it try :: then 0.0.0.0. Dual-stack defaults and bindv6only=1 effects are documentation-backed; socket-library behaviour and installed versions remain unverified. - pve-cluster (REASONED; pve-docs): The vendor warns against LISTEN_IP on clustered systems: nodes need peer pveproxy access, possibly across subnets. This guide advises management-only ACLs and firewall rules. - pve-acl (REASONED; pve-docs, pve-manager, pve-http): Both daemons share ALLOW_FROM/DENY_FROM/POLICY; default allow admits unmatched clients and dual matches. Deny rejects both cases. This guide advises ALLOW_FROM with DENY_FROM=all or POLICY=deny, one of the two, not both, because deny plus DENY_FROM=all refuses everyone. - pve-firewall (REASONED; pve-docs): Firewall defaults disabled; enable and restrict 8006/22/3128 to management addresses. - pve-root (REASONED; pve-docs): root@pam can always log in as unconfined admin; installer sets the web-interface root password. - pve-shell (REASONED; pve-manager): Node shell opens /bin/login -f root without another password. - pve-mfa (REASONED; pve-docs): Configure TOTP, WebAuthn, recovery keys or realm-enforced TOTP/YubiKey OTP; no default second factor. - pve-lockout (REASONED; pve-docs): Eight TOTP failures disable TOTP; 100 WebAuthn/recovery-key failures block all factors for an hour. These are second-factor lockouts. - pve-password-delay (REASONED; pve-http): Unauthorized API responses delay three seconds; this is not password lockout. - pve-certificates (REASONED; pve-docs): Each cluster creates its own self-signed CA. - pve-tokens (REASONED; pve-docs): API tokens default to separated privileges and cannot access VM/node consoles. - verify-cockpit (REASONED; cockpit): Inspect effective socket reset/address and root deny file; bare port or missing deny file exposes documented defaults. - verify-pve (REASONED; pve-docs, pve-manager, pve-http, pve-common): Inspect the shared LISTEN_IP/ACL configuration for both daemons, enabled/running firewall and management-only rules, and every admin's second factor including root@pam. This guide assesses management bind on a single node or management ALLOW_FROM with DENY_FROM=all or POLICY=deny, one of the two, not both, as fixed; grep alone does not establish effective settings. - verify-external (REASONED; cockpit, webmin, usermin, pve-docs): Probe each public IP with a reachable control; refusal/timeout is only consistent with isolation. TCP does not test Webmin UDP. - webmin-loopback (DEMONSTRATED; webmin): Loopback bind with no listen line produced one TCP socket and no UDP. - webmin-rate-run (DEMONSTRATED; webmin): Fifth wrong password exceeded a five-second timeout; then even the right password received host-block 403. - verify-listeners (REASONED; cockpit, webmin, usermin, pve-docs, pve-manager, pve-http, pve-common): Expect management TCP binds and no discovery UDP; this guide assesses clustered Proxmox wildcards as fixed only with management-only ACLs and firewall rules. Pinned new-socket code tries :: then 0.0.0.0; dual-stack operation remains documentation-backed and must be checked on the host. - verify-webmin-parser (DEMONSTRATED; webmin): Thirty fixtures matched miniserv parsing and bind/sockets/discovery/ACL outputs; unreadable paths were not checked. File inspection is not running-state proof. Runs covered an earlier form whose result arms ended with status 0; the current form was not re-run. - verify-tcp-loopback (DEMONSTRATED; cockpit, webmin, usermin, pve-docs): Copied probe has recorded loopback provenance, not live panel-isolation evidence; loopback and mapped hex addresses pass its guard. Runs covered an earlier form whose control and result arms ended with status 0; the current form was not re-run. ````json { "schema": 1, "checked": "2026-10-18", "documentation_checked": "2026-09", "body_sha256": "6b401a44b03e5a51ee4951f89b2239e285e5cf8837dc889b86415ba2650db180", "components": { "cockpit": { "name": "Cockpit", "basis": "368", "sources": { "sc42ba09017c7": "https://github.com/cockpit-project/cockpit/blob/368/src/systemd/cockpit.socket.in", "s0c1e0db624a4": "https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/listen.adoc", "sd0cb4ea3db56": "https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/https.adoc", "sf157ab3a6f47": "https://github.com/cockpit-project/cockpit/blob/368/doc/modules/man/pages/cockpit.conf.5.adoc", "s48ebdec28e10": "https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/authentication.adoc", "s2432e599485f": "https://github.com/cockpit-project/cockpit/blob/368/doc/modules/guide/pages/privileges.adoc", "sbdcd6e74a0c7": "https://github.com/cockpit-project/cockpit/blob/368/tools/cockpit.pam", "sc20103cfb39b": "https://github.com/cockpit-project/cockpit/blob/368/tools/cockpit.spec" } }, "webmin": { "name": "Webmin", "basis": "2.670", "sources": { "sa4f7ac343e85": "https://github.com/webmin/webmin/blob/2.670/setup.sh", "sb8fec2baa0f2": "https://github.com/webmin/webmin/blob/2.670/miniserv.pl", "sdd465cac1ff8": "https://github.com/webmin/webmin/blob/2.670/makedebian.pl", "s9ecba8f15014": "https://github.com/webmin/webmin/blob/2.670/makerpm.pl", "sdb2ef2bf9978": "https://github.com/webmin/webmin/blob/2.670/miniserv-lib.pl", "s1a1dbd21dddd": "https://github.com/webmin/webmin/blob/2.670/shell/defaultacl", "s2f3504ad1328": "https://github.com/webmin/webmin/blob/2.670/webmin/twofactor-funcs-lib.pl" } }, "pve-docs": { "name": "Proxmox VE 9.2 docs", "basis": "9.2.12", "sources": { "s8538bab3d3fc": "https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=pveproxy.adoc", "sdbc681682e8f": "https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=pveum.adoc", "sc6bbbfe64d09": "https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=pve-firewall.adoc", "s31f242f10eaa": "https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=certificate-management.adoc", "s954445cf4e14": "https://git.proxmox.com/?p=pve-docs.git;a=blob_plain;hb=9370638116430c4b1ccb9707b5716eaad7c7c9d3;f=debian/changelog" } }, "pve-manager": { "name": "pve-manager", "basis": "9.2.20", "sources": { "se4c11b901cf8": "https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=PVE/API2/Nodes.pm", "s1507ce3e40b8": "https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=debian/changelog", "s67c1a8621e22": "https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=debian/control", "sb7f08e4e98ab": "https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=PVE/Service/pveproxy.pm", "s0b79b5bbd95f": "https://git.proxmox.com/?p=pve-manager.git;a=blob_plain;hb=49318c671b82f31e6b273b79447526161739b97a;f=PVE/Service/spiceproxy.pm" } }, "webmin-docs": { "name": "Webmin docs", "basis": "8ceae26c5a074053905cbcc6c0053be573633f3a", "sources": { "s7a91f9f23044": "https://github.com/webmin/webmin.com/blob/8ceae26c5a074053905cbcc6c0053be573633f3a/content/docs/Modules/webmin-configuration.md", "s8b072a3403d1": "https://github.com/webmin/webmin.com/blob/8ceae26c5a074053905cbcc6c0053be573633f3a/content/security.md" } }, "pve-http": { "name": "Proxmox HTTP server", "basis": "5119ff9bec08c69584c0c98bea3edd0098179e5f", "sources": { "s753d88abe320": "https://git.proxmox.com/?p=pve-http-server.git;a=blob_plain;hb=5119ff9bec08c69584c0c98bea3edd0098179e5f;f=src/PVE/APIServer/AnyEvent.pm", "sb8d499c6bd8c": "https://git.proxmox.com/?p=pve-http-server.git;a=blob_plain;hb=5119ff9bec08c69584c0c98bea3edd0098179e5f;f=debian/changelog", "s2f151d3facd0": "https://git.proxmox.com/?p=pve-http-server.git;a=blob_plain;hb=5119ff9bec08c69584c0c98bea3edd0098179e5f;f=src/PVE/APIServer/Utils.pm" } }, "pve-common": { "name": "libpve-common-perl 9.1.21", "basis": "5054082fe492429fc37574985c2ca812af9a3125", "sources": { "s062026297433": "https://git.proxmox.com/?p=pve-common.git;a=blob_plain;hb=5054082fe492429fc37574985c2ca812af9a3125;f=debian/changelog", "s022a9e4640ab": "https://git.proxmox.com/?p=pve-common.git;a=blob_plain;hb=5054082fe492429fc37574985c2ca812af9a3125;f=src/PVE/Daemon.pm" } }, "usermin": { "name": "Usermin", "basis": "2.570", "sources": { "s7fe74c9a3138": "https://github.com/webmin/usermin/blob/2.570/setup.sh" } }, "systemd": { "name": "systemd", "basis": "v262", "sources": { "s0a96457c713e": "https://github.com/systemd/systemd/blob/v262/man/systemd.socket.xml" } } }, "claims": { "private": {"text": "Keep root-capable panels off the internet, bound/restricted to management networks with private access and a second factor.", "components": ["cockpit", "webmin-docs", "pve-docs"], "sources": ["cockpit:s0c1e0db624a4", "webmin-docs:s7a91f9f23044", "pve-docs:s8538bab3d3fc", "pve-docs:sc6bbbfe64d09"], "status": "REASONED"}, "cockpit-bind": {"text": "Bare ListenStream=9090 binds IPv6 and, by default, IPv4 wildcard addresses.", "components": ["cockpit", "systemd"], "sources": ["cockpit:sc42ba09017c7", "systemd:s0a96457c713e"], "status": "REASONED"}, "cockpit-dropin": {"text": "Use a socket drop-in, not cockpit.conf: reset ListenStream, set 10.0.0.5:9090 and FreeBind=yes, then daemon-reload/restart.", "components": ["cockpit"], "sources": ["cockpit:s0c1e0db624a4"], "status": "REASONED"}, "cockpit-tls": {"text": "One port serves HTTP/HTTPS, redirecting HTTP except localhost; AllowUnencrypted defaults false and missing certificates cause self-signed creation.", "components": ["cockpit"], "sources": ["cockpit:sd0cb4ea3db56", "cockpit:sf157ab3a6f47"], "status": "REASONED"}, "cockpit-pam": {"text": "Local accounts use Cockpit PAM with normal SSH-equivalent privileges.", "components": ["cockpit"], "sources": ["cockpit:s48ebdec28e10"], "status": "REASONED"}, "cockpit-root": {"text": "First-install packages write root to disallowed-users; onerr=succeed permits root if that file is missing/unreadable.", "components": ["cockpit"], "sources": ["cockpit:sbdcd6e74a0c7", "cockpit:sc20103cfb39b"], "status": "REASONED"}, "cockpit-escalation": {"text": "Users allowed sudo/polkit escalation get an immediately elevated session and terminal.", "components": ["cockpit"], "sources": ["cockpit:s2432e599485f"], "status": "REASONED"}, "cockpit-mfa": {"text": "No built-in second factor; use PAM, Kerberos or client certificates.", "components": ["cockpit"], "sources": ["cockpit:s48ebdec28e10"], "status": "REASONED"}, "cockpit-rate": {"text": "MaxStartups defaults to 10 concurrent attempts, not repeated-password lockout.", "components": ["cockpit"], "sources": ["cockpit:sf157ab3a6f47"], "status": "REASONED"}, "cockpit-loginto": {"text": "Set LoginTo=false to prevent unauthenticated scanning of reachable private networks.", "components": ["cockpit"], "sources": ["cockpit:sf157ab3a6f47"], "status": "REASONED"}, "webmin-bind": {"text": "Webmin/Usermin TCP ports are 10000/20000; omitted bind makes miniserv listen on all IPv4/IPv6 addresses.", "components": ["webmin", "usermin"], "sources": ["webmin:sa4f7ac343e85", "webmin:sb8fec2baa0f2", "usermin:s7fe74c9a3138"], "status": "REASONED"}, "webmin-discovery": {"text": "Installed listen=10000/20000 opens discovery UDP on every IPv4 address; delete when unused.", "components": ["webmin", "usermin"], "sources": ["webmin:sa4f7ac343e85", "webmin:sb8fec2baa0f2", "usermin:s7fe74c9a3138"], "status": "REASONED"}, "webmin-access": {"text": "Set management bind and allow networks, then restart; empty allow admits all except denied clients.", "components": ["webmin", "webmin-docs"], "sources": ["webmin:sb8fec2baa0f2", "webmin:sdb2ef2bf9978", "webmin-docs:s7a91f9f23044"], "status": "REASONED"}, "webmin-tls": {"text": "Deb/RPM packages enable ssl=1 with a self-signed certificate.", "components": ["webmin"], "sources": ["webmin:sa4f7ac343e85", "webmin:sdd465cac1ff8", "webmin:s9ecba8f15014"], "status": "REASONED"}, "webmin-root": {"text": "Installed root crypt=x accepts the Unix root password with all modules; Command Shell defaults to root.", "components": ["webmin"], "sources": ["webmin:sdd465cac1ff8", "webmin:s9ecba8f15014", "webmin:s1a1dbd21dddd"], "status": "REASONED"}, "webmin-sudo": {"text": "Installed sudo=1 lets sudo-permitted Unix users log in effectively as root.", "components": ["webmin"], "sources": ["webmin:sdd465cac1ff8", "webmin:s9ecba8f15014", "webmin:sdb2ef2bf9978"], "status": "REASONED"}, "webmin-mfa": {"text": "TOTP/Authy exists but is not configured at install; enable and enrol every user.", "components": ["webmin"], "sources": ["webmin:s2f3504ad1328"], "status": "REASONED"}, "webmin-advisories": {"text": "Advisories list Basic-auth MFA bypasses CVE-2026-42210/CVE-2026-56022 under Webmin prior to 2.641; use a current release.", "components": ["webmin-docs"], "sources": ["webmin-docs:s8b072a3403d1"], "status": "REASONED"}, "webmin-rate": {"text": "Defaults blockhost_failures=5/blockhost_time=60 block a host for 60 seconds, not a user lockout; Basic-auth failures count only with installed passdelay=1.", "components": ["webmin"], "sources": ["webmin:sa4f7ac343e85", "webmin:sdb2ef2bf9978"], "status": "REASONED"}, "pve-bind": {"text": "pveproxy HTTPS API 8006 and spiceproxy 3128 share LISTEN_IP in /etc/default/pveproxy; new sockets without it try :: then 0.0.0.0. Dual-stack defaults and bindv6only=1 effects are documentation-backed; socket-library behaviour and installed versions remain unverified.", "components": ["pve-docs", "pve-manager", "pve-http", "pve-common"], "sources": ["pve-docs:s8538bab3d3fc", "pve-manager:s1507ce3e40b8", "pve-docs:s954445cf4e14", "pve-http:sb8d499c6bd8c", "pve-common:s062026297433", "pve-manager:s67c1a8621e22", "pve-manager:sb7f08e4e98ab", "pve-manager:s0b79b5bbd95f", "pve-http:s2f151d3facd0", "pve-common:s022a9e4640ab"], "status": "REASONED"}, "pve-cluster": {"text": "The vendor warns against LISTEN_IP on clustered systems: nodes need peer pveproxy access, possibly across subnets. This guide advises management-only ACLs and firewall rules.", "components": ["pve-docs"], "sources": ["pve-docs:s8538bab3d3fc"], "status": "REASONED"}, "pve-acl": {"text": "Both daemons share ALLOW_FROM/DENY_FROM/POLICY; default allow admits unmatched clients and dual matches. Deny rejects both cases. This guide advises ALLOW_FROM with DENY_FROM=all or POLICY=deny, one of the two, not both, because deny plus DENY_FROM=all refuses everyone.", "components": ["pve-docs", "pve-manager", "pve-http"], "sources": ["pve-docs:s8538bab3d3fc", "pve-manager:s1507ce3e40b8", "pve-docs:s954445cf4e14", "pve-http:sb8d499c6bd8c", "pve-manager:s67c1a8621e22", "pve-manager:sb7f08e4e98ab", "pve-manager:s0b79b5bbd95f", "pve-http:s2f151d3facd0", "pve-http:s753d88abe320"], "status": "REASONED"}, "pve-firewall": {"text": "Firewall defaults disabled; enable and restrict 8006/22/3128 to management addresses.", "components": ["pve-docs"], "sources": ["pve-docs:sc6bbbfe64d09"], "status": "REASONED"}, "pve-root": {"text": "root@pam can always log in as unconfined admin; installer sets the web-interface root password.", "components": ["pve-docs"], "sources": ["pve-docs:sdbc681682e8f"], "status": "REASONED"}, "pve-shell": {"text": "Node shell opens /bin/login -f root without another password.", "components": ["pve-manager"], "sources": ["pve-manager:se4c11b901cf8"], "status": "REASONED"}, "pve-mfa": {"text": "Configure TOTP, WebAuthn, recovery keys or realm-enforced TOTP/YubiKey OTP; no default second factor.", "components": ["pve-docs"], "sources": ["pve-docs:sdbc681682e8f"], "status": "REASONED"}, "pve-lockout": {"text": "Eight TOTP failures disable TOTP; 100 WebAuthn/recovery-key failures block all factors for an hour. These are second-factor lockouts.", "components": ["pve-docs"], "sources": ["pve-docs:sdbc681682e8f"], "status": "REASONED"}, "pve-password-delay": {"text": "Unauthorized API responses delay three seconds; this is not password lockout.", "components": ["pve-http"], "sources": ["pve-http:s753d88abe320"], "status": "REASONED"}, "pve-certificates": {"text": "Each cluster creates its own self-signed CA.", "components": ["pve-docs"], "sources": ["pve-docs:s31f242f10eaa"], "status": "REASONED"}, "pve-tokens": {"text": "API tokens default to separated privileges and cannot access VM/node consoles.", "components": ["pve-docs"], "sources": ["pve-docs:sdbc681682e8f"], "status": "REASONED"}, "verify-cockpit": {"text": "Inspect effective socket reset/address and root deny file; bare port or missing deny file exposes documented defaults.", "components": ["cockpit"], "sources": ["cockpit:sc42ba09017c7", "cockpit:s0c1e0db624a4", "cockpit:sbdcd6e74a0c7", "cockpit:sc20103cfb39b"], "status": "REASONED"}, "verify-pve": {"text": "Inspect the shared LISTEN_IP/ACL configuration for both daemons, enabled/running firewall and management-only rules, and every admin's second factor including root@pam. This guide assesses management bind on a single node or management ALLOW_FROM with DENY_FROM=all or POLICY=deny, one of the two, not both, as fixed; grep alone does not establish effective settings.", "components": ["pve-docs", "pve-manager", "pve-http", "pve-common"], "sources": ["pve-docs:s8538bab3d3fc", "pve-docs:sdbc681682e8f", "pve-docs:sc6bbbfe64d09", "pve-manager:s1507ce3e40b8", "pve-docs:s954445cf4e14", "pve-http:sb8d499c6bd8c", "pve-common:s062026297433", "pve-manager:s67c1a8621e22", "pve-manager:sb7f08e4e98ab", "pve-manager:s0b79b5bbd95f", "pve-http:s2f151d3facd0", "pve-common:s022a9e4640ab", "pve-http:s753d88abe320"], "status": "REASONED"}, "verify-external": {"text": "Probe each public IP with a reachable control; refusal/timeout is only consistent with isolation. TCP does not test Webmin UDP.", "components": ["cockpit", "webmin", "usermin", "pve-docs"], "sources": ["cockpit:sc42ba09017c7", "webmin:sb8fec2baa0f2", "usermin:s7fe74c9a3138", "pve-docs:s8538bab3d3fc"], "status": "REASONED"}, "webmin-loopback": {"text": "Loopback bind with no listen line produced one TCP socket and no UDP.", "components": ["webmin"], "sources": ["webmin:sb8fec2baa0f2"], "status": "DEMONSTRATED", "evidence": "its only socket was TCP `127.0.0.1` on the test port, with no UDP socket."}, "webmin-rate-run": {"text": "Fifth wrong password exceeded a five-second timeout; then even the right password received host-block 403.", "components": ["webmin"], "sources": ["webmin:sa4f7ac343e85", "webmin:sdb2ef2bf9978"], "status": "DEMONSTRATED", "evidence": "the fifth wrong password was delayed past a five-second client timeout by miniserv's growing failure delay, and the right password was then refused with `403` \"Access denied for 127.0.0.1\"."}, "verify-listeners": {"text": "Expect management TCP binds and no discovery UDP; this guide assesses clustered Proxmox wildcards as fixed only with management-only ACLs and firewall rules. Pinned new-socket code tries :: then 0.0.0.0; dual-stack operation remains documentation-backed and must be checked on the host.", "components": ["cockpit", "webmin", "usermin", "pve-docs", "pve-manager", "pve-http", "pve-common"], "sources": ["cockpit:sc42ba09017c7", "webmin:sb8fec2baa0f2", "usermin:s7fe74c9a3138", "pve-docs:s8538bab3d3fc", "pve-manager:s1507ce3e40b8", "pve-docs:s954445cf4e14", "pve-http:sb8d499c6bd8c", "pve-common:s062026297433", "pve-manager:s67c1a8621e22", "pve-manager:sb7f08e4e98ab", "pve-manager:s0b79b5bbd95f", "pve-http:s2f151d3facd0", "pve-common:s022a9e4640ab", "pve-http:s753d88abe320"], "status": "REASONED", "verify": [1]}, "verify-webmin-parser": {"text": "Thirty fixtures matched miniserv parsing and bind/sockets/discovery/ACL outputs; unreadable paths were not checked. File inspection is not running-state proof. Runs covered an earlier form whose result arms ended with status 0; the current form was not re-run.", "components": ["webmin"], "sources": ["webmin:sb8fec2baa0f2", "webmin:sdb2ef2bf9978"], "status": "DEMONSTRATED", "evidence": "On each, the copy's parsed values matched Webmin 2.670's own `read_config_file` run on the same file, and the block printed the outcomes above; it reported an unreadable path as not checked.", "verify": [2]}, "verify-tcp-loopback": {"text": "Copied probe has recorded loopback provenance, not live panel-isolation evidence; loopback and mapped hex addresses pass its guard. Runs covered an earlier form whose control and result arms ended with status 0; the current form was not re-run.", "components": ["cockpit", "webmin", "usermin", "pve-docs"], "sources": ["cockpit:sc42ba09017c7", "webmin:sb8fec2baa0f2", "usermin:s7fe74c9a3138", "pve-docs:s8538bab3d3fc"], "status": "DEMONSTRATED", "evidence": "loopback addresses pass, and hex spellings such as `::ffff:0:0` connected to a listener bound to 127.0.0.1", "verify": [3]} } } ```` ====================================================================== ==> nomad-consul.md ====================================================================== # Nomad and Consul: the scheduler and service-mesh APIs Nomad decides which machines run your jobs, and Consul is a service directory and key-value store. This guide helps your AI assistant configure the Community Edition of each securely, including private listen addresses, default-deny ACLs on every agent, Nomad TLS and gossip encryption, and only local script checks in Consul. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/nomad-consul.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Nomad Community Edition 2.0.7 and Consul Community Edition 2.0.4. Metadata reviewed 2026-09-26; documentation checked September 2026. The guide records 42 claims: 13 demonstrated by a check that ran and 29 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Nomad schedules and runs workloads, and Consul holds a cluster's service catalog, health checks and key-value store. Both are controlled through an HTTP API that also serves a web UI, and both ship with access control lists (ACLs) turned off. With ACLs off, anyone who reaches Nomad's API can register a job, which is code the cluster runs, and anyone who reaches Consul's API can read and write the catalog and the key-value store. Keep both APIs on private or management addresses ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md), [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md)), turn ACLs on with a deny default, and bootstrap them before the API is reachable. Versions checked: Nomad 2.0.7 and Consul 2.0.4, Community Edition; Consul Enterprise namespaces and admin partitions are not covered. ## Nomad A Nomad agent serves its HTTP API and web UI on 4646, RPC on 4647 and Serf gossip on 4648. The default `bind_addr` is `0.0.0.0`, and each of the three listeners falls back to it, so a stock agent listens on every address. The UI is enabled by default, at `/ui/` on the API port. `nomad agent -dev` binds 127.0.0.1 on Linux, except `-dev-connect`, which binds `0.0.0.0`. ACLs are disabled by default, and with ACLs off requests are allowed without a token, including job registration; the agent's debug endpoints still need `enable_debug`, which `-dev` turns on. A job is code: on a Linux client running as root, the `exec` driver runs a job's commands in a chroot as the task's `user`, `nobody` when the task sets none (by default the client refuses `root` for `exec`), and `raw_exec`, which runs commands with no isolation, is off by default but turned on by `-dev`. Remote exec into running tasks is enabled by default (`disable_remote_exec = false`). TLS for HTTP and RPC is off by default, and gossip is unencrypted until you set `encrypt`. Set `bind_addr` to a private or management address (setting only `addresses.http` leaves RPC and Serf on `bind_addr`), and turn ACLs on in every agent's configuration: ```hcl acl { enabled = true } ``` Then run `nomad acl bootstrap` at once, before the API is reachable. `PUT /v1/acl/bootstrap` needs no token and works once, until an operator writes the `acl-bootstrap-reset` file in `/server`: the first caller after ACLs are enabled receives the global management token. Turn on TLS for HTTP and RPC (the `tls` block's `http` and `rpc` settings) and set a gossip `encrypt` key. ## Consul Consul's HTTP API listens on 8500 and its DNS interface on 8600 (TCP and UDP), both on `client_addr`, which defaults to 127.0.0.1. Server RPC on 8300 and Serf gossip on 8301 (LAN) and 8302 (WAN) use `bind_addr`, which defaults to `0.0.0.0`. HTTPS and plaintext gRPC are off by default; servers open gRPC with TLS on 8503, also on `client_addr`. The web UI is off by default and on in `-dev`, at `/ui/` on the HTTP port. So a stock Consul API is loopback-only, and it becomes reachable when `client_addr` or `addresses.http` is widened. ACLs are disabled by default, and with ACLs off every request has management rights: the key-value store, the catalog and the agent endpoints. Enabling ACLs is not enough on its own: `default_policy` defaults to `allow`, so an anonymous request can still read and write everything except ACL management until you set `default_policy = "deny"`: ```hcl acl { enabled = true default_policy = "deny" } ``` Put the same block on every agent, servers and clients: each agent applies its own `acl` block (observed for `enabled` and `default_policy`). Then run `consul acl bootstrap`; `PUT /v1/acl/bootstrap` needs no token and works once, until an operator writes the `acl-bootstrap-reset` file. Script checks run commands on the agent. `enable_script_checks` is off by default, and Consul's own startup warning calls enabling it without ACLs and without `allow_write_http_from` "DANGEROUS"; it only warns. Use `enable_local_script_checks` instead if you need them. `consul exec` is off by default (`disable_remote_exec = true`). TLS verification is off by default, and gossip is unencrypted until you set `encrypt`. ## Verify REASONED: this overview distinguishes the historical loopback record from this marking review, per the guide's cited Nomad 2.0.7 agent defaults (`bind_addr`, ports, ACLs, `disable_remote_exec`, `-dev` binds, `raw_exec` and `enable_debug`) and Consul 2.0.4 agent defaults (`bind_addr`, `client_addr`, ports, `default_policy`, `disable_remote_exec`, `-dev`). The expected states, per the guide's cited Nomad 2.0.7 and Consul 2.0.4 agent defaults, are wildcard API binds for exposed and private or management binds for fixed. The recorded authoring limitation applies: a live Nomad or Consul stand-up opens a listener; the authoring host now forbids that without an isolated network namespace, and none was available. This review passed all five Verify blocks through `bash -n` and ShellCheck 0.11.0 with no output, exercised guards and synthetic command results, and used a local terminal to test token input; these runs do not reproduce live ACL, bootstrap, anonymous-policy, listener or external-isolation outcomes. The bash -n, ShellCheck and guard runs in this review covered the Nomad probe, Consul probe and TCP blocks in an earlier form, whose guard refusals ended with status 0, and the listener and policy blocks in earlier forms that each ended with their last command's status. Those runs were not repeated on the current forms of those five blocks, each of which ends with one status for the whole fence: 2 for a guard refusal, 1 for a finding, 3 for a failed or inconclusive check and otherwise 0. The current forms passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0, except that the listener block's runs covered its form before the `ss` presence check; its current form, which exits 2 when `ss` is not installed, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n and stub runs with `ss` present, failing and missing. The historical record follows: These checks were demonstrated on loopback, against Nomad 2.0.7 and Consul 2.0.4 agents whose every listener was bound to 127.0.0.1: the Nomad and Consul probes in each ACL state, the bootstrap and anonymous-policy checks, and the listener list. The loopback runs of the Nomad and Consul probe blocks used an earlier form of each block, whose guard refusals and results ended with status 0, and the listener run used the earlier form of the listener block, two top-level `ss` commands; the current forms have had no demonstration run. The exposed listener state is REASONED from source: the authoring host forbids binding every interface, so the default wildcard bind was not observed, and no Nomad client ran a job. The TCP reachability probe is the block demonstrated on loopback in [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) with this guide's ports. REASONED: this listener-inventory instruction, per the guide's cited Nomad 2.0.7 agent defaults (`bind_addr`, ports, ACLs, `disable_remote_exec`, `-dev` binds, `raw_exec` and `enable_debug`) and Consul 2.0.4 agent defaults (`bind_addr`, `client_addr`, ports, `default_policy`, `disable_remote_exec`, `-dev`); wildcard API binds are expected exposed and private or management binds fixed. The recorded authoring limitation applies: a live Nomad or Consul stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. In this marking review, the related block passed `bash -n` and ShellCheck 0.11.0 with no output. With sudo stubbed, the block printed `stub ss -tlnp 0.0.0.0:4646` and `stub ss -ulnp 0.0.0.0:4646`, then the corresponding `10.0.0.5:4646` fixture lines; this tests command dispatch only, not listener state. Those runs covered an earlier form of the listener block, two top-level `ss` commands without a status of their own. The current form, which checks for `ss` before listing, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n and stub runs with `ss` present, failing and missing. On each host, list the listeners, TCP and UDP: REASONED: following block; default and external listener expectations follow the pinned Nomad and Consul sources; only the reduced loopback inventory described at the end of this paragraph was observed, with an earlier form of the block, two top-level `ss` commands, and wildcard binding was forbidden. The form before the `ss` presence check passed bash -n and local stub runs; the current form is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local bash -n and stub runs with `ss` present, failing and missing. Exposed, the reasoned expectation, per the pinned Nomad and Consul agent defaults, is a wildcard address (`*:`, `0.0.0.0:` or `[::]:`) on the API ports, 4646 and 8500; fixed means a private or management address. The block exits 2 if `ss` is not installed, 1 for a wildcard TCP listener on 4646 or 8500, 3 if either `ss` command fails and otherwise 0, and leaves the other ports and the UDP list for you to judge. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH (the guide's cited sudo sudoers manual, secure_path entry), so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. On the loopback run, the agents listened on TCP on test ports standing in for Nomad's 4646, 4647 and 4648 and Consul's 8500, 8300 and 8301 (the runs turned off Consul's DNS, Serf WAN and gRPC), and on UDP on the two Serf ports. ```bash ( # one subshell, so one exit status covers both listener lists set +e # errexit off here, so a failed command is recorded below instead of ending the block command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } # a refusal, status 2, before either list runs, so no part has found anything yet set -- 0 "$(if sudo ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0; a missing ss has already exited 2 above), then the TCP listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the TCP listener table, then ss's exit status printf '%s' "$2" # 4646, 4647, 4648 (Nomad); 8500, 8600, 8300, 8301, 8302, 8503 (Consul): private or management addresses only [ "$3" = 0 ] || { echo "sudo ss -tlnp exit $3: the TCP listener list did not print; inconclusive"; set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk 'NR > 1 { p = $4; sub(/.*:/, "", p); a = substr($4, 1, length($4) - length(p) - 1); sub(/%.*/, "", a) } NR > 1 && (p == "4646" || p == "8500") && (a == "*" || a == "0.0.0.0" || a == "[::]") { found = 1 } END { exit !found }'; then echo "FINDING: a TCP listener on port 4646 or 8500, an HTTP API, is bound to a wildcard address"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the TCP listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi set -- "$1" "$(if sudo ss -ulnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the status so far, then the UDP listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the UDP listener table, then ss's exit status printf '%s' "$2" # Serf gossip on UDP 4648 (Nomad) and 8301, 8302 (Consul); Consul DNS on UDP 8600 [ "$3" = 0 ] || { echo "sudo ss -ulnp exit $3: the UDP listener list did not print; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3"; } echo "block exit $1; only wildcard TCP listeners on 4646 and 8500 are classified, so judge the other TCP ports and every UDP listener yourself" exit "$1" ) ``` REASONED: this anonymous-request instruction, per the guide's cited Nomad 2.0.7 ACL resolution when disabled and Nomad 2.0.7 anonymous token (its rights come from the policy named `anonymous`); expected ACLs-off results are self/jobs/agent HTTP 200 with self AccessorID `acls-disabled`, versus 200/403/403 and `anonymous` with ACLs on. The recorded authoring limitation applies: a live Nomad or Consul stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. In this marking review, the related block passed `bash -n` and ShellCheck 0.11.0 with no output. Placeholder, empty URL, control character, missing marker, wrong value count and omitted `set --` fixtures each printed the corresponding refusal with no external stub calls. The bash -n, ShellCheck and refusal runs recorded here covered the Nomad probe block in an earlier form, whose guard refusals ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for an ACLs-off result or a `200` on `/v1/jobs` or `/v1/agent/self`, 0 when all three paths give the ACLs-on results and 3 otherwise. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. Synthetic curl, grep and head stubs printed those HTTP/AccessorID results; they do not establish live ACL behaviour. For Nomad, send three requests without a token, to every agent's API, servers and clients. Substitute the API URL (for example `http://10.0.0.5:4646`) inside the single quotes, and paste the whole block. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_NOMAD_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the Nomad URL on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 /v1/acl/token/self /v1/jobs /v1/agent/self # the URL, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the paths still to request while [ "$#" -gt 2 ]; do set -- "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 -w '\n%{http_code} %{exitcode}' -- "$1$3" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1##*$'\n'}" "${1%$'\n'*}" "${@:2}" # curl's exit status, the response ending in its code line, the URL, the status so far, this path, then the paths after it set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" "${@:3}" # curl's exit status, the HTTP code and exit as printed, the response, the URL, the status so far, this path, then the rest echo "GET $6 (no token): http/exit=$2 $(printf '%s' "$3" | grep -o -E '"AccessorID":"[^"]*"|^Permission denied$|^curl: .*' | head -n 1)" case "$6:$1:$2" in /v1/acl/token/self:0:"200 0") case "$3" in *'"AccessorID":"acls-disabled"'*) echo "FINDING: ACLs are off on the server that answered $6"; set -- "$1" "$2" "$3" "$4" 1 "${@:6}" ;; *'"AccessorID":"anonymous"'*) ;; *) echo "a 200 from $6 with neither the acls-disabled nor the anonymous AccessorID; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" ;; esac ;; /v1/jobs:0:"200 0"|/v1/agent/self:0:"200 0") echo "FINDING: $6 answered 200 without a token"; set -- "$1" "$2" "$3" "$4" 1 "${@:6}" ;; /v1/jobs:0:"403 0"|/v1/agent/self:0:"403 0") if printf '%s\n' "$3" | grep -q -x 'Permission denied'; then :; else echo "a 403 from $6 without Permission denied, perhaps from a proxy; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" fi ;; *) echo "neither an ACLs-off nor an ACLs-on result from $6; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" ;; esac set -- "$4" "$5" "${@:7}" # the URL, the status so far, then the paths still to request done echo "block exit $2; the bootstrap and anonymous-policy checks below decide fixed, and this block does not run them" exit "$2" ) ``` DEMONSTRATED: preceding block; the recorded Nomad 2.0.7 loopback runs observed the ACL-off/on and per-agent HTTP results below on test ports, without running a client job. The loopback runs used an earlier form of the Nomad probe block, whose guard refusals and results ended with status 0; the current form exits 2 for a guard refusal, 1 for an ACLs-off result or a `200` on `/v1/jobs` or `/v1/agent/self`, 0 when all three paths give the ACLs-on results and 3 otherwise. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. Exposed (ACLs off): `/v1/acl/token/self` returns `200` with `"AccessorID":"acls-disabled"`, and `/v1/jobs` and `/v1/agent/self` return `200`. With ACLs on, `/v1/acl/token/self` returns `200` with `"AccessorID":"anonymous"`, and `/v1/jobs` and `/v1/agent/self` return `403` `Permission denied`. The first two paths are answered by the servers, so of these three paths only `/v1/agent/self` shows an agent whose own `acl.enabled` is off; any agent that answers it with `200` is exposed. Those `403`s cover listing jobs in the default namespace and reading the agent only, and they look the same before bootstrap, so fixed needs two more checks on a server. First, `nomad acl bootstrap` must fail with an error that includes "ACL bootstrap already done"; if it prints a management token instead, the cluster was claimable until that moment, so keep the token as your own. Second, with a management token, `nomad acl policy info anonymous` must report `404 (ACL policy not found)`: the anonymous token takes its rights from the policy named `anonymous`. The block after the next paragraph runs it. A connection failure prints curl's error and exit code, and says nothing about ACLs. Any other status or message is inconclusive: a redirect (for example from a trailing slash, or from HTTP to HTTPS), a proxy's own response, or a certificate error once TLS is on says nothing about the server's ACLs; run the curl block above from a host that trusts the cluster's CA. REASONED: this token-input and process-exposure explanation, per the guide's cited Nomad 2.0.7 CLI token input: `-token` (`command/meta.go` L96, L170-L171) or `NOMAD_TOKEN` (`api/api.go` L388-L389), with no stdin form; a token in argv is exposed there, while the prompted prefix form avoids argv and shell history but retains same-account/root environment access. The recorded authoring limitation applies: a live `nomad acl policy info` needs a running Nomad server, which opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No second-account inspection was used in this review. In this marking review, the related block passed `bash -n` and ShellCheck 0.11.0 with no output. A local terminal with a Nomad stub printed `no token supplied; not probing` and exited 2 for empty input, and `stub policy 404 (ACL policy not found) token=present` and exited 0 for a fixture token; readonly tok and IFS printed their refusal messages and exited 2 without invoking the stub. Those runs covered an earlier form of the block, which ended with the nomad command's own status and exited 2 when the token read failed. The current form, which exits 0 on `404 (ACL policy not found)`, 1 when nomad shows the policy, 3 for a failed token read or any other result and 2 for a guard refusal or an empty token, passed bash -n and local stub runs, including runs under a pseudo-terminal, and the gate suite's bash -n and ShellCheck 0.11.0. These observations establish local input handling only. `nomad` takes the token from `-token`, which puts it in argv, or from `NOMAD_TOKEN`, and has no stdin input for it (checked at v2.0.7). The block prompts for the token, so it stays out of shell history, and hands it to the one `nomad` command as a prefix assignment, never exported. That moves the token out of argv, not out of reach: while `nomad` runs, it can be read from `/proc//environ` by the same account and by root. REASONED: following block; token input follows the pinned Nomad 2.0.7 CLI source; the recorded policy inspection used the earlier exported-token form, not this prompt and prefix assignment. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. Paste the token alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n tok NOMAD_TOKEN && unset -v tok NOMAD_TOKEN; } 2>/dev/null || { echo 'cannot clear tok or NOMAD_TOKEN in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } IFS= read -r -s -p 'Nomad management token (input hidden): ' tok < /dev/tty || { echo 'token input failed; not probing'; exit 3; } printf '\n' [ -n "$tok" ] || { echo 'no token supplied; not probing'; exit 2; } set -- "$(if NOMAD_TOKEN="$tok" nomad acl policy info anonymous 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # nomad's output, then its exit status set -- "${1%$'\n'*}" "${1##*$'\n'}" printf '%s' "$1" case "$2:$1" in *"404 (ACL policy not found)"*) echo "no policy named anonymous, so requests without a token get no rights from one"; exit 0 ;; 0:*) echo "FINDING: a policy named anonymous exists, so requests without a token get its rights: read it above"; exit 1 ;; *) echo "nomad exit $2 without 404 (ACL policy not found); inconclusive"; exit 3 ;; esac ) ``` REASONED: preceding block; the policy check is reasoned in its current prompted form, which passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0, and no demonstration was re-run on it. DEMONSTRATED: Nomad 2.0.7 loopback runs on test ports, with earlier forms of the curl probe block, whose guard refusals and results ended with status 0, and of the policy check. All of these outcomes were observed with the curl probe block and these commands, on agents listening on test ports. In a cluster whose server had ACLs on and was bootstrapped, a client agent configured with `acl { enabled = false }` answered the first two paths with the ACLs-on results and `/v1/agent/self` with `200`, while the server answered it with `403`. Before bootstrap, the probe already printed the ACLs-on results; the first `nomad acl bootstrap` printed a management token and the second failed with `Unexpected response code: 400 (ACL bootstrap already done ...)`. `nomad acl policy info anonymous` reported `404 (ACL policy not found)`, observed with the token exported in a subshell, the form this guide used before #306. After an `anonymous` policy granting only `submit-job` was applied, the probe's results did not change, an anonymous job registration returned `200`, and `nomad acl policy info anonymous`, run in the same earlier form, showed the policy. With ACLs off, an anonymous job registration also returned `200`; with ACLs on and no `anonymous` policy, it returned `403`. REASONED: this per-agent anonymous-request instruction, per the guide's cited Consul 2.0.4 ACL resolution when disabled and Consul 2.0.4 configuration builder (listener addresses, ACL default, script checks and their warning, UI); expected ACLs-off self/agent results are 401/200, and enabled deny-policy results are 403/403 with the anonymous-permission message. The recorded authoring limitation applies: a live Nomad or Consul stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. In this marking review, the related block passed `bash -n` and ShellCheck 0.11.0 with no output. Placeholder, empty URL, control character, missing marker, wrong value count and omitted `set --` fixtures each printed the corresponding refusal with no external stub calls. The bash -n, ShellCheck and refusal runs recorded here covered the Consul probe block in an earlier form, whose guard refusals ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 1 for `ACL support disabled` or a `200` on either path, 0 for the deny-policy results on both paths and 3 otherwise. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. Synthetic curl, grep and head stubs printed those status pairs and messages; they do not establish live ACL behaviour. For Consul, send two requests without a token, to every agent's HTTP API, servers and clients; probe an agent whose `client_addr` is loopback, the default, from its own host (for example `http://127.0.0.1:8500`). Substitute the HTTP API URL (for example `http://10.0.0.5:8500`) inside the single quotes, and paste the whole block. DEMONSTRATED: following block; the recorded Consul 2.0.4 loopback runs observed disabled, allow, deny and per-agent/default-token HTTP outcomes below on test ports. The loopback runs used an earlier form of the Consul probe block, whose guard refusals and results ended with status 0; the current form exits 2 for a guard refusal, 1 for `ACL support disabled` or a `200` on either path, 0 for the deny-policy results on both paths and 3 otherwise. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_CONSUL_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 1 ] || { echo "the set -- line needs exactly 1 value; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|""|*[[:cntrl:]]*) echo "substitute the Consul URL on the set -- line above; not probing"; exit 2 ;; esac set -- "$1" 0 /v1/acl/token/self /v1/agent/self # the URL, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the paths still to request while [ "$#" -gt 2 ]; do set -- "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 15 -w '\n%{http_code} %{exitcode}' -- "$1$3" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1##*$'\n'}" "${1%$'\n'*}" "${@:2}" # curl's exit status, the response ending in its code line, the URL, the status so far, this path, then the paths after it set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" "${@:3}" # curl's exit status, the HTTP code and exit as printed, the response, the URL, the status so far, this path, then the rest echo "GET $6 (no token): http/exit=$2 $(printf '%s' "$3" | grep -o -E '^ACL support disabled$|^token does not exist: ACL not found$|^Permission denied: anonymous token lacks permission|^curl: .*' | head -n 1)" case "$6:$1:$2" in /v1/acl/token/self:0:"401 0") if printf '%s\n' "$3" | grep -q -x 'ACL support disabled'; then echo "FINDING: ACLs are off on the agent that answered $6"; set -- "$1" "$2" "$3" "$4" 1 "${@:6}" else echo "a 401 from $6 without ACL support disabled; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" fi ;; /v1/acl/token/self:0:"200 0") echo "FINDING: $6 answered 200 without a token, so a default token answers requests that carry none"; set -- "$1" "$2" "$3" "$4" 1 "${@:6}" ;; /v1/acl/token/self:0:"403 0") if printf '%s\n' "$3" | grep -q -x 'token does not exist: ACL not found'; then :; else echo "a 403 from $6 without token does not exist: ACL not found; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" fi ;; /v1/agent/self:0:"200 0") echo "FINDING: $6 answered 200 without a token"; set -- "$1" "$2" "$3" "$4" 1 "${@:6}" ;; /v1/agent/self:0:"403 0") if printf '%s\n' "$3" | grep -q '^Permission denied: anonymous token lacks permission'; then :; else echo "a 403 from $6 without the anonymous-token message; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" fi ;; *) echo "neither an exposed nor a deny-policy result from $6; inconclusive"; [ "$5" = 1 ] || set -- "$1" "$2" "$3" "$4" 3 "${@:6}" ;; esac set -- "$4" "$5" "${@:7}" # the URL, the status so far, then the paths still to request done echo "block exit $2; the bootstrap and anonymous-token checks below decide fixed, and this block does not run them" exit "$2" ) ``` DEMONSTRATED: preceding block; Consul 2.0.4 loopback runs on test ports, with an earlier form of the block whose guard refusals and results ended with status 0. Exposed with ACLs off: `/v1/acl/token/self` returns `401` `ACL support disabled`, and `/v1/agent/self` returns `200`. Exposed with ACLs on and the default `allow` policy: `/v1/acl/token/self` returns `403` `token does not exist: ACL not found`, and `/v1/agent/self` still returns `200`. Each agent applies its own `acl` block (observed for `enabled` and `default_policy`), so any agent that answers `/v1/agent/self` with `200` is exposed. With ACLs on and `default_policy = "deny"`, `/v1/agent/self` returns `403` `Permission denied: anonymous token lacks permission`, but that `403` covers `agent:read` only, and it looks the same before bootstrap, so fixed needs two more checks. First, `consul acl bootstrap` must fail with an error that includes "ACL bootstrap no longer allowed"; if it prints a token instead, keep it as your own. Second, with a management token in `CONSUL_HTTP_TOKEN`, `consul acl token read -accessor-id 00000000-0000-0000-0000-000000000002` (the anonymous token; set `CONSUL_HTTP_TOKEN` the same way, in a subshell around this command) must list no policies, roles, or service, node or templated identities. The second check also requires that no agent have a default token, set in `acl.tokens.default` or through the agent's token API: a request without a token uses it instead of the anonymous token. On the loopback run, a default token holding key write, set through the agent's token API on a deny-policy agent, made the block print `200` on `/v1/acl/token/self` and a `403` on `/v1/agent/self` without the anonymous-token message, while a key-value write without a token succeeded. A `200` on `/v1/acl/token/self` without a token is how the block shows such a default token. A connection failure prints curl's error and exit code, and says nothing about ACLs. Any other status or message is inconclusive: a redirect (for example from a trailing slash, or from HTTP to HTTPS), a proxy's own response, or a certificate error once TLS is on says nothing about the server's ACLs; run the block from a host that trusts the cluster's CA. DEMONSTRATED: Consul 2.0.4 loopback runs on test ports, with an earlier form of the probe block whose guard refusals and results ended with status 0. All of these outcomes were observed with this block and these commands, on agents listening on test ports. With a bootstrapped server on the deny policy and a client agent with ACLs off, the block printed the deny results against the server, and `401` `ACL support disabled` and `200` against the client; with the server on the `allow` policy and the client on `deny`, it printed `200` on `/v1/agent/self` against the server and the deny results against the client. Before bootstrap, the probe already printed the deny-policy results; the first `consul acl bootstrap` printed a token and the second failed with `Unexpected response code: 403 (Permission denied: ACL bootstrap no longer allowed ...)`; the anonymous token's read listed none of those sections. On the same runs an anonymous key-value write succeeded in both exposed states and was refused with `403` with the deny policy, and with script checks at their default, registering a script check through the API was refused with "Scripts are disabled on this agent from remote calls". REASONED: this external TCP-check instruction, per the guide's cited Nomad 2.0.7 agent defaults (`bind_addr`, ports, ACLs, `disable_remote_exec`, `-dev` binds, `raw_exec` and `enable_debug`) and Consul 2.0.4 agent defaults (`bind_addr`, `client_addr`, ports, `default_policy`, `disable_remote_exec`, `-dev`). The exposed state, per the guide's cited Nomad 2.0.7 and Consul 2.0.4 agent defaults, means connected, while refusal or timeout is only consistent with fixed, and a failed control is inconclusive. No external probing host or live service comparison was used in this marking review. The recorded authoring limitation applies: a live Nomad or Consul stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output; placeholder, empty address, zero address, host name, invalid IPv4, IPv6 with a non-hex character, zero IPv6, dotted IPv6, empty or text port, ports 0 and 65536, missing marker, wrong count and omitted `set --` fixtures printed their refusals with no external stub calls. A timeout stub produced the nine target lines as `connected`, `timed out`, `refused` or `inconclusive`; a failed control printed `did not connect` and stopped after one stub call. The bash -n, ShellCheck, refusal and stub runs recorded here covered the TCP block in an earlier form, whose guard refusals, failed control and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 3 for a failed control, and after its probes 1 if any port connected, otherwise 3 if any port was inconclusive, otherwise 0. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. These are synthetic dispatch and output tests, not network observations. Finally, from a host that should not have access, try a TCP connection to each port. The block takes one IP address rather than a host name, so that a name with both IPv4 and IPv6 addresses cannot hide one behind a timeout on the other: run it once for each public address of the host. It also takes a port you know is open on that address from this host (for example SSH on 22) as the positive control, and stops if the control does not connect. Substitute both inside the single quotes. REASONED: following block; port targets follow the pinned Nomad and Consul defaults; the guide cross-references a loopback demonstration elsewhere, while external isolation was not demonstrated here. That loopback demonstration covered an earlier form of the block, whose failed control and result arms ended with status 0, and was not repeated on the current form. The current form passed bash -n and local stub runs, and the repository's gate suite runs ShellCheck 0.11.0 over every block; no demonstration was re-run on it. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ONE_IP_ADDRESS' 'REPLACE_WITH_A_KNOWN_OPEN_PORT' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|"") echo "substitute one IP address on the set -- line above; not probing"; exit 2 ;; esac case "$1" in 0.0.0.0) echo "$1 reaches this host itself; give the public address of the service; not probing"; exit 2 ;; esac set -- "$1" "$2" '^((25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])[.]){3}(25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])$' # the address, the control port, then the IPv4 pattern, held here rather than in a named variable case "$1" in *:*:*) case "$1" in *[!0123456789ABCDEFabcdef.:]*) echo "$1 is not an IPv6 address; not probing"; exit 2 ;; esac case "$1" in *.*) echo "give the IPv4 address itself rather than $1; not probing"; exit 2 ;; esac case "$1" in *[!0:]*) ;; *) echo "$1 is all zeros (this host itself, or not an address); give the public address of the service; not probing"; exit 2 ;; esac ;; *) [[ $1 =~ $3 ]] || { echo "give one IPv4 address (four numbers, 0 to 255, joined by dots) or one IPv6 address, with no host name, port or brackets; not probing"; exit 2; } ;; esac case "$2" in *[!0123456789]*|"") echo "substitute a known-open control port on the set -- line above; not probing"; exit 2 ;; esac { [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; } 2>/dev/null || { echo "the control port must be 1 to 65535; not probing"; exit 2; } command -v timeout >/dev/null || { echo "timeout is not installed here; not probing"; exit 2; } # shellcheck disable=SC2016 # the single-quoted script is meant to expand $1 and $2 in the child shell set -- "$1" "$2" "$(if LC_ALL=C timeout 5 bash -c ': > "/dev/tcp/$1/$2"' probe "$1" "$2" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the address, the control port, the control's messages, then its exit status set -- "$1" "$2" "${3%$'\n'}" "$4" # the messages without their final line break, as a plain capture prints them [ "$4" = 0 ] || { echo "control $1:$2 did not connect (${3:-timed out}); not probing"; exit 3; } echo "control $1:$2 connected" set -- "$1" 0 4646 4647 4648 8500 8600 8300 8301 8302 8503 # the address, the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0), then the ports still to probe while [ "$#" -gt 2 ]; do # shellcheck disable=SC2016 # the single-quoted script is meant to expand $1 and $2 in the child shell set -- "$(if LC_ALL=C timeout 5 bash -c ': > "/dev/tcp/$1/$2"' probe "$1" "$3" 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" "$@" set -- "${1##*$'\n'}" "${1%$'\n'*}" "${@:2}" # this port's exit status, its messages, the address, the status so far, this port, then the ports after it set -- "$1" "${2%$'\n'}" "${@:3}" # the messages without their final line break case "$1:$2" in 0:*) echo "$3:$5 connected: reachable from this host"; set -- "$1" "$2" "$3" 1 "${@:5}" ;; 124:*) echo "$3:$5 timed out from this host" ;; *"Connection refused"*) echo "$3:$5 refused from this host" ;; *) echo "$3:$5 inconclusive (not a connection, refusal or timeout): ${2:-no message}"; [ "$4" = 1 ] || set -- "$1" "$2" "$3" 3 "${@:5}" ;; esac set -- "$3" "$4" "${@:6}" # the address, the status so far, then the ports still to probe done exit "$2" ) ``` REASONED: preceding block; this interpretation and the entire TCP block, per the guide's cited Nomad 2.0.7 agent defaults (`bind_addr`, ports, ACLs, `disable_remote_exec`, `-dev` binds, `raw_exec` and `enable_debug`) and Consul 2.0.4 agent defaults (`bind_addr`, `client_addr`, ports, `default_policy`, `disable_remote_exec`, `-dev`). The entire TCP block includes address and port guards, the positive control, all nine targets and all result branches. The exposed state, per the guide's cited Nomad 2.0.7 and Consul 2.0.4 agent defaults, means connected, while refusal or timeout is only consistent with fixed, and UDP remains outside the probe. No external probing host or live service comparison was used in this marking review. The recorded authoring limitation applies: a live Nomad or Consul stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The related block passed `bash -n` and ShellCheck 0.11.0 with no output; placeholder, empty address, zero address, host name, invalid IPv4, IPv6 with a non-hex character, zero IPv6, dotted IPv6, empty or text port, ports 0 and 65536, missing marker, wrong count and omitted `set --` fixtures printed their refusals with no external stub calls. A timeout stub produced the nine target lines as `connected`, `timed out`, `refused` or `inconclusive`; a failed control printed `did not connect` and stopped after one stub call. The bash -n, ShellCheck, refusal and stub runs recorded here covered the TCP block in an earlier form, whose guard refusals, failed control and result arms ended with status 0, and were not repeated on the current form, which exits 2 for a guard refusal, 3 for a failed control, and after its probes 1 if any port connected, otherwise 3 if any port was inconclusive, otherwise 0. The current form passed bash -n and local stub runs and the gate suite's bash -n and ShellCheck 0.11.0. These are synthetic dispatch and output tests; the earlier cross-guide loopback record remains historical. Exposed, a port reports "connected"; a transparent proxy on the probing host's network can also complete the handshake, so confirm a surprising "connected" with `ss` on the host. "refused" and "timed out" show only that this host could not reach the port: a firewall in front of the host produces either, but so can filtering on the probing host's own network, and the control proves only its own port. Treat them as consistent with fixed, and take `ss` on the host as the authority; the probe does not test the UDP gossip or DNS sockets, which only `ss -ulnp` shows. "inconclusive" is any other result and says nothing about the port. The block is the one demonstrated in [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) with only the port list changed; that guide lists the values it was measured to refuse. It refuses `0.0.0.0`, IPv6 values made only of zeros and colons, and IPv6 values containing a dot, but not every value that reaches the probing host: loopback addresses pass, and hex spellings such as `::ffff:0:0` connected to a listener bound to 127.0.0.1, so give the host's public address. A "connected" on a port you did not mean to expose is the finding. ## Sources (checked September 2026) - Nomad 2.0.7 agent defaults (`bind_addr`, ports, ACLs, `disable_remote_exec`, `-dev` binds, `raw_exec` and `enable_debug`): https://github.com/hashicorp/nomad/blob/v2.0.7/command/agent/config.go - Nomad 2.0.7 UI default: https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/structs/config/ui.go - Nomad 2.0.7 TLS settings: https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/structs/config/tls.go - Nomad 2.0.7 `raw_exec` driver (default off, no isolation): https://github.com/hashicorp/nomad/blob/v2.0.7/drivers/rawexec/driver.go - Nomad 2.0.7 `exec` driver (root on Linux, `nobody` when the task sets no user, chroot): https://github.com/hashicorp/nomad/blob/v2.0.7/drivers/exec/driver.go - Nomad 2.0.7 client user denylist (`root` refused for `exec`): https://github.com/hashicorp/nomad/blob/v2.0.7/client/config/config.go - Nomad 2.0.7 server data directory (`/server`, for the bootstrap reset file): https://github.com/hashicorp/nomad/blob/v2.0.7/command/agent/agent.go - Nomad 2.0.7 ACL resolution when disabled: https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/auth/auth.go and https://github.com/hashicorp/nomad/blob/v2.0.7/acl/acl.go - Nomad 2.0.7 anonymous token (its rights come from the policy named `anonymous`): https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/structs/acl.go - Nomad 2.0.7 ACL bootstrap: https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/acl_endpoint.go - Nomad 2.0.7 CLI token input: `-token` (`command/meta.go` L96, L170-L171) or `NOMAD_TOKEN` (`api/api.go` L388-L389), with no stdin form: https://github.com/hashicorp/nomad/blob/v2.0.7/command/meta.go#L96 and https://github.com/hashicorp/nomad/blob/v2.0.7/api/api.go#L388-L389 - Consul 2.0.4 agent defaults (`bind_addr`, `client_addr`, ports, `default_policy`, `disable_remote_exec`, `-dev`): https://github.com/hashicorp/consul/blob/v2.0.4/agent/config/default.go - Consul 2.0.4 configuration builder (listener addresses, ACL default, script checks and their warning, UI): https://github.com/hashicorp/consul/blob/v2.0.4/agent/config/builder.go - Consul 2.0.4 ACL resolution when disabled: https://github.com/hashicorp/consul/blob/v2.0.4/agent/consul/acl.go - Consul 2.0.4 anonymous token accessor ID: https://github.com/hashicorp/consul/blob/v2.0.4/acl/acl.go - Consul 2.0.4 default token for requests without one (`acl.tokens.default`, agent token API): https://github.com/hashicorp/consul/blob/v2.0.4/agent/http.go and https://github.com/hashicorp/consul/blob/v2.0.4/agent/token/persistence.go - Nomad 2.0.7 agent debug endpoints when ACLs are disabled (`enable_debug`): https://github.com/hashicorp/nomad/blob/v2.0.7/acl/acl.go - Consul 2.0.4 ACL bootstrap: https://github.com/hashicorp/consul/blob/v2.0.4/agent/acl_endpoint.go and https://github.com/hashicorp/consul/blob/v2.0.4/agent/consul/acl_endpoint.go - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - nomad: Nomad Community Edition (2.0.7) - consul: Consul Community Edition (2.0.4) Claims (full source mapping in the JSON block below): - nomad-bind (REASONED; nomad): Nomad defaults bind_addr=0.0.0.0 and all three listeners fall back to it; setting only addresses.http leaves RPC and gossip on bind_addr. - nomad-http (REASONED; nomad): Nomad HTTP API/UI uses 4646; UI defaults enabled at /ui/. - nomad-rpc (REASONED; nomad): Nomad RPC uses 4647; keep it on private or management addresses. - nomad-gossip (REASONED; nomad): Nomad Serf uses 4648 TCP/UDP; gossip is unencrypted until encrypt is configured. - nomad-dev (REASONED; nomad): Linux -dev binds 127.0.0.1 except -dev-connect, which binds 0.0.0.0. Wildcard behavior was not observed. - nomad-acl-default (REASONED; nomad): ACLs default disabled; enable acl.enabled on every agent before exposing the API. - nomad-debug (REASONED; nomad): Debug endpoints still require enable_debug with ACLs off; -dev enables it. - nomad-exec (REASONED; nomad): On a root Linux client, exec uses a chroot and task user, default nobody, with root refused by the default exec denylist; no client job was run. - nomad-raw-exec (REASONED; nomad): raw_exec has no isolation and defaults off, but -dev enables it. - nomad-remote-exec (REASONED; nomad): Remote task execution defaults enabled through disable_remote_exec=false. - nomad-tls (REASONED; nomad): HTTP/RPC TLS defaults off; enable both tls.http and tls.rpc. - nomad-bootstrap-reset (REASONED; nomad): Unauthenticated PUT /v1/acl/bootstrap claims the first management token after ACL enablement; repeat is blocked until the operator reset file in data_dir/server. - consul-client-bind (REASONED; consul): Consul client_addr defaults 127.0.0.1; widening it or addresses.http makes the API reachable. - consul-http (REASONED; consul): Consul HTTP API is 8500; UI defaults off and -dev enables /ui/ on that port. - consul-dns (REASONED; consul): Consul DNS uses 8600 TCP/UDP on client_addr. - consul-rpc (REASONED; consul): Consul server RPC uses 8300 on bind_addr, default 0.0.0.0. - consul-lan (REASONED; consul): Consul LAN Serf uses 8301 TCP/UDP on bind_addr. - consul-wan (REASONED; consul): Consul WAN Serf uses 8302 TCP/UDP on bind_addr. - consul-https (REASONED; consul): Consul HTTPS defaults disabled. - consul-grpc (REASONED; consul): Plaintext gRPC defaults disabled; servers open TLS gRPC on 8503 on client_addr. - consul-acl-default (REASONED; consul): Consul ACLs default off and disabled ACL resolution grants management rights; with ACLs enabled default_policy still defaults allow. - consul-bootstrap-reset (REASONED; consul): Bootstrap is unauthenticated and one-time until an operator writes acl-bootstrap-reset; bootstrap privately before exposing the API. - consul-script (REASONED; consul): enable_script_checks defaults off; enabling without ACLs or allow_write_http_from only warns. Prefer enable_local_script_checks if needed. - consul-remote-exec (REASONED; consul): consul exec defaults disabled through disable_remote_exec=true. - consul-tls-gossip (REASONED; consul): TLS verification defaults off and gossip is unencrypted without encrypt. - verify-inventory (REASONED; nomad, consul): Inventory TCP/UDP on private or management addresses; wildcard exposed binds and actual external isolation remain reasoned. - loopback-listeners (DEMONSTRATED; nomad, consul): Loopback runs used substitute TCP/Serf UDP ports; Consul DNS, WAN and gRPC were disabled, so their live listeners were not demonstrated. - nomad-acl-probe (DEMONSTRATED; nomad): Loopback anonymous self/jobs/agent probes distinguished ACLs-off from ACLs-on; self remains 200 but changes AccessorID, while jobs and agent become 403. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run. - nomad-agent-acl (DEMONSTRATED; nomad): Server-handled paths can conceal a client with ACLs off; local /v1/agent/self exposed that client with 200 while the server returned 403. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run. - nomad-bootstrap-run (DEMONSTRATED; nomad): ACL probe rejection did not prove bootstrap complete; first bootstrap issued management credentials and the second returned already-done. - nomad-anonymous-policy (DEMONSTRATED; nomad): Anonymous rights come from the named anonymous policy; historical exported-token inspection found it absent, then present with submit-job despite unchanged probe results. - nomad-job-registration (DEMONSTRATED; nomad): Anonymous registration succeeded with ACLs off and failed with ACLs on and no anonymous policy; registration is not a demonstrated job execution. - nomad-token-input (REASONED; nomad): CLI accepts -token or NOMAD_TOKEN, not stdin; the current prompted one-command prefix avoids argv/history but retains same-user/root environment visibility. That form has no recorded live Nomad run. - consul-acl-probe (DEMONSTRATED; consul): Loopback ACLs-off gave self 401/agent 200; enabled allow gave self 403/agent 200; enabled deny gave agent 403 with anonymous-permission text. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run. - consul-agent-acl (DEMONSTRATED; consul): Each agent applies its own enabled/default_policy; mixed server/client configurations gave different results on their own APIs. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run. - consul-bootstrap-run (DEMONSTRATED; consul): Deny results appeared before bootstrap; first bootstrap issued a token and the second refused with bootstrap no longer allowed. - consul-anonymous-token (DEMONSTRATED; consul): Inspect anonymous accessor 00000000-0000-0000-0000-000000000002 for policies, roles and identities; its recorded read listed none. - consul-default-token (DEMONSTRATED; consul): An agent default token substitutes for anonymous requests; recorded key-write default token yielded self 200 and agent 403 yet allowed an unauthenticated KV write. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run. - consul-kv (DEMONSTRATED; consul): Anonymous KV writes succeeded with ACLs off and allow policy, and were refused under deny. - consul-script-run (DEMONSTRATED; consul): With default script-check settings, remote script registration was refused. - verify-tcp (REASONED; nomad, consul): Probe each actual public IP with a known-open control; connected establishes reachability, refused/timeout only local nonreachability. This guide cross-references another guide's run, which covered an earlier form whose control and result arms ended with status 0; the current form was not re-run. UDP is not tested. - verify-address-limits (REASONED; nomad, consul): TCP guard refuses zero/dotted-IPv6 forms but permits loopback and some hex forms reaching the probing host; use public addresses and corroborate surprising connections with host sockets. ````json { "schema": 1, "checked": "2026-09-26", "documentation_checked": "2026-09", "body_sha256": "ae1c84224571043cfac59f7ef6afdcacb0c70a0d70bb3f909794310bbefc136c", "components": { "nomad": { "name": "Nomad Community Edition", "basis": "2.0.7", "sources": { "s52d80d2821ed": "https://github.com/hashicorp/nomad/blob/v2.0.7/command/agent/config.go", "sa1c2951d3ca6": "https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/structs/config/ui.go", "s553448c7957c": "https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/auth/auth.go", "s7496bcf129dc": "https://github.com/hashicorp/nomad/blob/v2.0.7/acl/acl.go", "sd665f15bc452": "https://github.com/hashicorp/nomad/blob/v2.0.7/drivers/exec/driver.go", "sd5c86223bdf5": "https://github.com/hashicorp/nomad/blob/v2.0.7/client/config/config.go", "sd5f2c389c6ae": "https://github.com/hashicorp/nomad/blob/v2.0.7/drivers/rawexec/driver.go", "se7ba8e4262dc": "https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/structs/config/tls.go", "sc3e36e830b67": "https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/acl_endpoint.go", "s754c582b7565": "https://github.com/hashicorp/nomad/blob/v2.0.7/command/agent/agent.go", "s8193264e48c2": "https://github.com/hashicorp/nomad/blob/v2.0.7/nomad/structs/acl.go", "sba39c1a9119a": "https://github.com/hashicorp/nomad/blob/v2.0.7/command/meta.go#L96", "sa04bf8c95cb6": "https://github.com/hashicorp/nomad/blob/v2.0.7/api/api.go#L388-L389" } }, "consul": { "name": "Consul Community Edition", "basis": "2.0.4", "sources": { "s679a306e1b08": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/config/default.go", "s8a0b5abac641": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/config/builder.go", "sc4fb30ca3702": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/consul/acl.go", "s35b783d75794": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/acl_endpoint.go", "sa77598ec3695": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/consul/acl_endpoint.go", "s00748de44382": "https://github.com/hashicorp/consul/blob/v2.0.4/acl/acl.go", "sab8a5866d9c9": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/http.go", "sfeb50129fc10": "https://github.com/hashicorp/consul/blob/v2.0.4/agent/token/persistence.go" } } }, "claims": { "nomad-bind": {"text": "Nomad defaults bind_addr=0.0.0.0 and all three listeners fall back to it; setting only addresses.http leaves RPC and gossip on bind_addr.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed"], "status": "REASONED"}, "nomad-http": {"text": "Nomad HTTP API/UI uses 4646; UI defaults enabled at /ui/.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed", "nomad:sa1c2951d3ca6"], "status": "REASONED"}, "nomad-rpc": {"text": "Nomad RPC uses 4647; keep it on private or management addresses.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed"], "status": "REASONED"}, "nomad-gossip": {"text": "Nomad Serf uses 4648 TCP/UDP; gossip is unencrypted until encrypt is configured.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed"], "status": "REASONED"}, "nomad-dev": {"text": "Linux -dev binds 127.0.0.1 except -dev-connect, which binds 0.0.0.0. Wildcard behavior was not observed.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed"], "status": "REASONED"}, "nomad-acl-default": {"text": "ACLs default disabled; enable acl.enabled on every agent before exposing the API.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed", "nomad:s553448c7957c", "nomad:s7496bcf129dc"], "status": "REASONED"}, "nomad-debug": {"text": "Debug endpoints still require enable_debug with ACLs off; -dev enables it.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed", "nomad:s7496bcf129dc"], "status": "REASONED"}, "nomad-exec": {"text": "On a root Linux client, exec uses a chroot and task user, default nobody, with root refused by the default exec denylist; no client job was run.", "components": ["nomad"], "sources": ["nomad:sd665f15bc452", "nomad:sd5c86223bdf5"], "status": "REASONED"}, "nomad-raw-exec": {"text": "raw_exec has no isolation and defaults off, but -dev enables it.", "components": ["nomad"], "sources": ["nomad:sd5f2c389c6ae", "nomad:s52d80d2821ed"], "status": "REASONED"}, "nomad-remote-exec": {"text": "Remote task execution defaults enabled through disable_remote_exec=false.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed"], "status": "REASONED"}, "nomad-tls": {"text": "HTTP/RPC TLS defaults off; enable both tls.http and tls.rpc.", "components": ["nomad"], "sources": ["nomad:se7ba8e4262dc"], "status": "REASONED"}, "nomad-bootstrap-reset": {"text": "Unauthenticated PUT /v1/acl/bootstrap claims the first management token after ACL enablement; repeat is blocked until the operator reset file in data_dir/server.", "components": ["nomad"], "sources": ["nomad:sc3e36e830b67", "nomad:s754c582b7565"], "status": "REASONED"}, "consul-client-bind": {"text": "Consul client_addr defaults 127.0.0.1; widening it or addresses.http makes the API reachable.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-http": {"text": "Consul HTTP API is 8500; UI defaults off and -dev enables /ui/ on that port.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-dns": {"text": "Consul DNS uses 8600 TCP/UDP on client_addr.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-rpc": {"text": "Consul server RPC uses 8300 on bind_addr, default 0.0.0.0.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-lan": {"text": "Consul LAN Serf uses 8301 TCP/UDP on bind_addr.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-wan": {"text": "Consul WAN Serf uses 8302 TCP/UDP on bind_addr.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-https": {"text": "Consul HTTPS defaults disabled.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-grpc": {"text": "Plaintext gRPC defaults disabled; servers open TLS gRPC on 8503 on client_addr.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "consul-acl-default": {"text": "Consul ACLs default off and disabled ACL resolution grants management rights; with ACLs enabled default_policy still defaults allow.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641", "consul:sc4fb30ca3702"], "status": "REASONED"}, "consul-bootstrap-reset": {"text": "Bootstrap is unauthenticated and one-time until an operator writes acl-bootstrap-reset; bootstrap privately before exposing the API.", "components": ["consul"], "sources": ["consul:s35b783d75794", "consul:sa77598ec3695"], "status": "REASONED"}, "consul-script": {"text": "enable_script_checks defaults off; enabling without ACLs or allow_write_http_from only warns. Prefer enable_local_script_checks if needed.", "components": ["consul"], "sources": ["consul:s8a0b5abac641"], "status": "REASONED"}, "consul-remote-exec": {"text": "consul exec defaults disabled through disable_remote_exec=true.", "components": ["consul"], "sources": ["consul:s679a306e1b08"], "status": "REASONED"}, "consul-tls-gossip": {"text": "TLS verification defaults off and gossip is unencrypted without encrypt.", "components": ["consul"], "sources": ["consul:s679a306e1b08", "consul:s8a0b5abac641"], "status": "REASONED"}, "verify-inventory": {"text": "Inventory TCP/UDP on private or management addresses; wildcard exposed binds and actual external isolation remain reasoned.", "components": ["nomad", "consul"], "sources": ["nomad:s52d80d2821ed", "consul:s679a306e1b08"], "status": "REASONED", "verify": [1]}, "loopback-listeners": {"text": "Loopback runs used substitute TCP/Serf UDP ports; Consul DNS, WAN and gRPC were disabled, so their live listeners were not demonstrated.", "components": ["nomad", "consul"], "sources": ["nomad:s52d80d2821ed", "consul:s679a306e1b08"], "status": "DEMONSTRATED", "evidence": "On the loopback run, the agents listened on TCP on test ports standing in for Nomad's 4646, 4647 and 4648 and Consul's 8500, 8300 and 8301 (the runs turned off Consul's DNS, Serf WAN and gRPC), and on UDP on the two Serf ports."}, "nomad-acl-probe": {"text": "Loopback anonymous self/jobs/agent probes distinguished ACLs-off from ACLs-on; self remains 200 but changes AccessorID, while jobs and agent become 403. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run.", "components": ["nomad"], "sources": ["nomad:s553448c7957c", "nomad:s7496bcf129dc", "nomad:s8193264e48c2"], "status": "DEMONSTRATED", "evidence": "All of these outcomes were observed with the curl probe block and these commands, on agents listening on test ports.", "verify": [2]}, "nomad-agent-acl": {"text": "Server-handled paths can conceal a client with ACLs off; local /v1/agent/self exposed that client with 200 while the server returned 403. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run.", "components": ["nomad"], "sources": ["nomad:s52d80d2821ed", "nomad:s7496bcf129dc"], "status": "DEMONSTRATED", "evidence": "In a cluster whose server had ACLs on and was bootstrapped, a client agent configured with `acl { enabled = false }` answered the first two paths with the ACLs-on results and `/v1/agent/self` with `200`, while the server answered it with `403`.", "verify": [2]}, "nomad-bootstrap-run": {"text": "ACL probe rejection did not prove bootstrap complete; first bootstrap issued management credentials and the second returned already-done.", "components": ["nomad"], "sources": ["nomad:sc3e36e830b67"], "status": "DEMONSTRATED", "evidence": "Before bootstrap, the probe already printed the ACLs-on results; the first `nomad acl bootstrap` printed a management token and the second failed with `Unexpected response code: 400 (ACL bootstrap already done ...)`."}, "nomad-anonymous-policy": {"text": "Anonymous rights come from the named anonymous policy; historical exported-token inspection found it absent, then present with submit-job despite unchanged probe results.", "components": ["nomad"], "sources": ["nomad:s8193264e48c2"], "status": "DEMONSTRATED", "evidence": "After an `anonymous` policy granting only `submit-job` was applied, the probe's results did not change, an anonymous job registration returned `200`, and `nomad acl policy info anonymous`, run in the same earlier form, showed the policy."}, "nomad-job-registration": {"text": "Anonymous registration succeeded with ACLs off and failed with ACLs on and no anonymous policy; registration is not a demonstrated job execution.", "components": ["nomad"], "sources": ["nomad:s553448c7957c", "nomad:s7496bcf129dc"], "status": "DEMONSTRATED", "evidence": "With ACLs off, an anonymous job registration also returned `200`; with ACLs on and no `anonymous` policy, it returned `403`."}, "nomad-token-input": {"text": "CLI accepts -token or NOMAD_TOKEN, not stdin; the current prompted one-command prefix avoids argv/history but retains same-user/root environment visibility. That form has no recorded live Nomad run.", "components": ["nomad"], "sources": ["nomad:sba39c1a9119a", "nomad:sa04bf8c95cb6"], "status": "REASONED", "verify": [3]}, "consul-acl-probe": {"text": "Loopback ACLs-off gave self 401/agent 200; enabled allow gave self 403/agent 200; enabled deny gave agent 403 with anonymous-permission text. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run.", "components": ["consul"], "sources": ["consul:sc4fb30ca3702", "consul:s8a0b5abac641"], "status": "DEMONSTRATED", "evidence": "All of these outcomes were observed with this block and these commands, on agents listening on test ports.", "verify": [4]}, "consul-agent-acl": {"text": "Each agent applies its own enabled/default_policy; mixed server/client configurations gave different results on their own APIs. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run.", "components": ["consul"], "sources": ["consul:s8a0b5abac641", "consul:sc4fb30ca3702"], "status": "DEMONSTRATED", "evidence": "with the server on the `allow` policy and the client on `deny`, it printed `200` on `/v1/agent/self` against the server and the deny results against the client.", "verify": [4]}, "consul-bootstrap-run": {"text": "Deny results appeared before bootstrap; first bootstrap issued a token and the second refused with bootstrap no longer allowed.", "components": ["consul"], "sources": ["consul:s35b783d75794", "consul:sa77598ec3695"], "status": "DEMONSTRATED", "evidence": "the first `consul acl bootstrap` printed a token and the second failed with `Unexpected response code: 403 (Permission denied: ACL bootstrap no longer allowed ...)`"}, "consul-anonymous-token": {"text": "Inspect anonymous accessor 00000000-0000-0000-0000-000000000002 for policies, roles and identities; its recorded read listed none.", "components": ["consul"], "sources": ["consul:s00748de44382"], "status": "DEMONSTRATED", "evidence": "the anonymous token's read listed none of those sections."}, "consul-default-token": {"text": "An agent default token substitutes for anonymous requests; recorded key-write default token yielded self 200 and agent 403 yet allowed an unauthenticated KV write. Runs covered an earlier form whose guard refusals and results ended with status 0; the current form was not re-run.", "components": ["consul"], "sources": ["consul:sab8a5866d9c9", "consul:sfeb50129fc10"], "status": "DEMONSTRATED", "evidence": "On the loopback run, a default token holding key write, set through the agent's token API on a deny-policy agent, made the block print `200` on `/v1/acl/token/self` and a `403` on `/v1/agent/self` without the anonymous-token message, while a key-value write without a token succeeded.", "verify": [4]}, "consul-kv": {"text": "Anonymous KV writes succeeded with ACLs off and allow policy, and were refused under deny.", "components": ["consul"], "sources": ["consul:sc4fb30ca3702", "consul:s679a306e1b08"], "status": "DEMONSTRATED", "evidence": "On the same runs an anonymous key-value write succeeded in both exposed states and was refused with `403` with the deny policy"}, "consul-script-run": {"text": "With default script-check settings, remote script registration was refused.", "components": ["consul"], "sources": ["consul:s8a0b5abac641"], "status": "DEMONSTRATED", "evidence": "with script checks at their default, registering a script check through the API was refused with \"Scripts are disabled on this agent from remote calls\"."}, "verify-tcp": {"text": "Probe each actual public IP with a known-open control; connected establishes reachability, refused/timeout only local nonreachability. This guide cross-references another guide's run, which covered an earlier form whose control and result arms ended with status 0; the current form was not re-run. UDP is not tested.", "components": ["nomad", "consul"], "sources": ["nomad:s52d80d2821ed", "consul:s679a306e1b08"], "status": "REASONED", "verify": [5]}, "verify-address-limits": {"text": "TCP guard refuses zero/dotted-IPv6 forms but permits loopback and some hex forms reaching the probing host; use public addresses and corroborate surprising connections with host sockets.", "components": ["nomad", "consul"], "sources": ["nomad:s52d80d2821ed", "consul:s679a306e1b08"], "status": "REASONED", "verify": [5]} } } ```` ====================================================================== ==> pocketbase.md ====================================================================== # Self-hosted backends: PocketBase and Appwrite PocketBase and Appwrite are self-hosted backends that give apps a database, user accounts and file storage through one API. This guide helps your AI assistant configure each securely, including private bootstrap, deliberate binding and TLS, access rules on every collection or resource, and server keys kept off clients. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/pocketbase.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against PocketBase v0.41.1, Appwrite 2.7.0, utopia-php/database 10.1.0, and curl curl-8_14_1; 2 further sources carry no recorded version. Metadata reviewed 2026-11-05; documentation checked November 2026. The guide records 91 claims: 2 demonstrated by a check that ran and 89 reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Like Firebase and Supabase ([firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md)), these backends hand a public API endpoint to your client code; the collection or resource rules you write gate access to your data. Files and privileged server credentials need separate attention. Both also ship an admin console, so complete bootstrap privately before exposing the instance. Appwrite's first console registrant becomes its initial operator; PocketBase's installer requires a privileged token from the startup log, not merely an ordinary user's registration. The source checks below target **PocketBase v0.41.1** and **Appwrite 2.7.0**, with documentation checked in November 2026: the Appwrite pages as served on 2026-11-04 and the PocketBase pages as served on 2026-11-05. Defaults refer to those releases unless stated otherwise. Live behavior has not been demonstrated in this authoring environment. Some pin-specific source checks remain incomplete; the source-work table below records those. Live comparisons are REASONED from the cited vendor documentation and the pinned sources that were available. From v0.41.0, PocketBase publishes its source and releases on its author's forge at `https://ganigeorgiev.com` instead of GitHub, so the PocketBase source citations below point to that forge's pages for the v0.41.1 tag, as `rev=refs/tags/v0.41.1`, with any line range given in the link text. The [v0.41.0 release notes](https://ganigeorgiev.com/pocketbase/pocketbase/releases?expr=tag%3D%22v0.41.0%22) flag one change with a warning sign, the Go module's move from GitHub to the new host. No statement in this guide depends on the module path. ## PocketBase ### 1. Bootstrap privately and protect the operator Create the superuser before opening access. The documented console syntax is `./pocketbase superuser create EMAIL PASS`; the alternative is the web-based installer linked from the server's own startup log. That CLI syntax puts the password in process arguments: do not substitute a real password there. Use the installer over a private connection to avoid that credential channel. Do not leave a fresh instance open to the network while bootstrap is unfinished. See [production setup](https://pocketbase.io/docs/going-to-production/). The installer URL contains a system-superuser authentication token valid for approximately 30 minutes. A visitor registering an ordinary application user does not thereby become a superuser. Protect startup logs and the installer URL as credentials, and complete setup before publishing the proxy route. See the [pinned installer](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/installer.go?rev=refs/tags/v0.41.1). PocketBase v0.38.0 and later can restrict superuser sessions by IP: set the allowed list under Settings > Application > Superuser IPs, or use the documented console example `./pocketbase superuser ips 127.0.0.1 10.0.0.0 --dir=/path/to/your/pb_data`, substituting your addresses and data directory. The setting is `superuserIPs`; an empty list imposes no restriction. See [production guidance](https://pocketbase.io/docs/going-to-production/) and the [pinned IP check](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares.go?rev=refs/tags/v0.41.1). This controls authenticated superuser requests, not public delivery of the dashboard's static assets. If the UI must be private, also restrict `/_/` at the proxy or network boundary. Superuser IP restrictions and MFA are both worth enabling beyond your own machine. ### 2. Bind privately, enable TLS, and constrain proxy trust PocketBase **has a native HTTPS listener with ACME integration**: `./pocketbase serve example.com` issues and renews a Let's Encrypt certificate for that domain. Alternatively, put it behind your own reverse proxy per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and terminate TLS there. See the [pinned TLS listener](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/serve.go?rev=refs/tags/v0.41.1). Without a domain, `serve` binds to `127.0.0.1:8090`. With a domain, its default listeners become `0.0.0.0:80` and `0.0.0.0:443`. Behind a TLS proxy on the same host, keep `--http=127.0.0.1:8090` and omit the domain argument. Set `--origins=https://app.example.com`, replacing that origin with your real browser application origin; multiple origins are comma-separated. The default is `*`. CORS controls browser access and does not authorize records. See the [serve flags](https://ganigeorgiev.com/pocketbase/pocketbase/files/cmd/serve.go?rev=refs/tags/v0.41.1). Configure `trustedProxy.headers` only for headers your proxy strips and overwrites, such as `X-Real-IP`. Block direct backend access. Otherwise, attacker-supplied client-IP headers can undermine IP allowlists and rate limits. Review forwarded-header ordering before using `trustedProxy.useLeftmostIP`. See the [proxy settings](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1). `--dev` enables diagnostic logging, including SQL, to stderr; it is not an authentication bypass. In the pinned CLI wrapper, `Start()` registers the `serve` and `superuser` commands on the root command, and the `--dev` flag default is the configured value. `New()` sets it to true exactly when `os.Args[0]` starts with a nonempty `os.TempDir()` or `cacheDir()` prefix, and false otherwise. In dev mode the pinned code logs executed SQL statements and lowers the logger threshold so practically all logs print. Avoid unnecessary production diagnostics and protect their output. See the [development-mode behavior](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1), [command wiring, lines 165 to 173](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1), [dev default selection, lines 73 to 79](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1), [go run detection, lines 10 to 19](https://ganigeorgiev.com/pocketbase/pocketbase/files/tools/osutils/run.go?rev=refs/tags/v0.41.1), [flag-to-app wiring, lines 129 to 139](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1), [dev SQL logging, lines 1277 to 1286](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1), and [dev logger level, lines 1489 to 1499](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1). ### 3. Cover records, auth-record management, files, and realtime separately None of the operator controls replaces collection authorization. Every collection's API rules (`listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`) decide what non-superusers can do. Their default `null` is "locked": only a superuser can perform that action. An empty string opens the action to everyone, including unauthenticated guests. A nonempty rule filters access. Superusers bypass API rules entirely, so never hand a superuser account or token to a client application; use collection rules and scoped authentication instead. See [API rules](https://pocketbase.io/docs/api-rules-and-filters/) and the [pinned access check](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/record_query.go?rev=refs/tags/v0.41.1). Auth collections additionally have a **top-level `manageRule`**, default `null`. It grants privileged management of another auth record, including changing its password without the old password and directly changing its email or verification state. It operates alongside create and update rules. Keep it locked unless that delegation is intentional. Its validator accepts `null` or a nonempty rule, **not `""`**. See the [pinned auth options and validator](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1). **File fields are unprotected by default.** Knowing a file's full URL is sufficient to download it, even when the record's API rules are locked. For sensitive files, enable the field's **Protected** option and require an authorized identity in the collection's `viewRule`. A protected field with a public `viewRule` can still be downloaded publicly. See [file handling](https://pocketbase.io/docs/files-handling/) and the [pinned download authorization](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/file.go?rev=refs/tags/v0.41.1). After authenticating as the intended ordinary user, obtain a short-lived file token with `await pb.files.getToken()` and supply it to `pb.files.getURL(record, filename, { token })`. Treat the resulting URL as a credential; do not put it in command arguments or public logs. The token supplies authentication context, while `viewRule` decides access. See [protected files](https://pocketbase.io/docs/files-handling/). Realtime uses `listRule` for collection subscriptions and `viewRule` for individual-record subscriptions. An established SSE connection, or a successful subscription request, does not prove authorization to receive a particular record's events. Test actual event delivery with different users. See the [pinned realtime rule mapping](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/realtime.go?rev=refs/tags/v0.41.1) and [Realtime API](https://pocketbase.io/docs/api-realtime/). ### 4. Enable MFA and review authentication lifetimes These are the v0.41.1 auth-option initialization defaults, not a substitute for inspecting an existing collection's effective settings. Durations are seconds. See the [pinned defaults](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1). | Option | Default | | --- | --- | | `passwordAuth.enabled` | `true` | | `passwordAuth.identityFields` | `["email"]` | | `oauth2.enabled` | `false` | | `mfa.enabled` / `mfa.duration` | `false` / `600` | | `otp.enabled` / `otp.duration` / `otp.length` | `false` / `180` / `8` | | `authAlert.enabled` | `true` | | `authToken.duration` | `432000` | | `fileToken.duration` | `180` | | `authRule` | `""` | Superuser MFA is a separate setting: open `_superusers`, retain password authentication, and enable both OTP and MFA. Leave `mfa.rule` empty to apply MFA to everyone in that collection. This requires the password plus an email-delivered one-time code. **Enabling OTP alone is not MFA**; OTP can otherwise be a standalone login method. OAuth2 is unsupported for `_superusers`. See [authentication](https://pocketbase.io/docs/authentication/) and the [MFA configuration](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1). For an application-user collection that should admit only verified accounts, set `authRule="verified = true"`. Provide working verification email before enforcing it. Keep authentication alerts enabled and choose token lifetimes appropriate to the deployment. PocketBase authentication is stateless. Clearing `pb.authStore` removes the client's copy; it does not revoke a stolen copy. Do not treat browser logout as server-side token revocation. See [authentication semantics](https://pocketbase.io/docs/authentication/) and [token validation](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/record_query.go?rev=refs/tags/v0.41.1). ### 5. Protect persisted settings and backups PocketBase stores settings, including SMTP passwords and S3 credentials, as plaintext JSON by default. Inject a random **32-character** secret through the service environment and select its name with `--encryptionEnv=PB_ENCRYPTION_KEY`: ```text PB_ENCRYPTION_KEY=REPLACE_WITH_RANDOM_32_CHARACTER_SECRET ``` `PB_ENCRYPTION_KEY` is a selectable name, not an automatically recognized switch. Setting it without `--encryptionEnv=PB_ENCRYPTION_KEY` is insufficient. This encrypts persisted settings, **not the database as a whole or uploaded files**. Protect the data directory, restrict backup access, and retain the encryption key separately. See [settings encryption](https://pocketbase.io/docs/going-to-production/) and [persistence code](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1). Backups are local by default, and `backups.cron` defaults to empty, so automatic backups are off. Configure a schedule and retention deliberately. Archives contain the main and logs databases plus the rest of `pb_data`, excluding the backups directory and tracked exclusions. The source says uploaded collection files on S3 are outside `pb_data` and need manual backup; protect that storage separately. See [backup archive implementation, lines 36 to 60](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/backup_create.go?rev=refs/tags/v0.41.1) and [backup settings](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1). Backup downloads require a **superuser file token**, and the superuser IP restriction also applies. Ordinary auth tokens and ordinary-user file tokens do not grant backup access. Keep backup URLs private. See the [pinned backup download handler](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/backup.go?rev=refs/tags/v0.41.1). ### 6. Enable rate limits and working authentication email Set `rateLimits.enabled=true`. The native limiter exists from v0.23.0, but v0.41.1 defaults it to **false**. Its seeded rules are: | Label | Requests / interval | | --- | --- | | `*:auth` | 2 / 3 seconds | | `*:create` | 20 / 5 seconds | | `/api/batch` | 3 / 1 second | | `/api/` | 300 / 10 seconds | See [limiter availability](https://pocketbase.io/docs/going-to-production/) and [pinned defaults](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1). Superusers and addresses in `rateLimits.excludedIPs` bypass the limiter. Keep exclusions narrow and test using an ordinary client from a non-excluded address. See the [bypass checks](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares_rate_limit.go?rev=refs/tags/v0.41.1). Configure SMTP before relying on OTP, alerts, verification, or recovery. `smtp.enabled` and `smtp.tls` both default to `false`; set the connection details and enforce transport protection appropriate to your mail service. With SMTP disabled, PocketBase falls back to system `sendmail`, which is not proof of deliverability. Test receipt in the intended mailbox. See [SMTP settings](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1) and [mailer selection](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1). ## Appwrite (self-hosted) ### 1. Enforce HTTPS and restrict hostnames Enforce HTTPS in production with `_APP_OPTIONS_FORCE_HTTPS=enabled`; Appwrite's own docs say to "always prefer HTTPS over HTTP in production environments." Front it per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) if you are not terminating TLS at Appwrite itself. See [production security](https://appwrite.io/docs/advanced/self-hosting/production/security). ```text _APP_OPTIONS_FORCE_HTTPS=enabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=enabled _APP_OPTIONS_ROUTER_PROTECTION=enabled ``` The first setting controls the API; the second controls function and site domains. At 2.7.0, both configuration defaults are `disabled`. Router protection also defaults to `disabled`; enabling it rejects unknown hostnames. Configure your actual deployment domains before enabling it. See the [pinned variables](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php) and [router and HTTPS enforcement](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/general.php). There is a documentation/source conflict: the environment documentation as served on 2026-11-04 calls `_APP_OPTIONS_FORCE_HTTPS` deprecated since 1.7.0 and describes an enabled default, while 2.7.0 still implements it with a disabled default. Retain the explicit `enabled` value for this release, including behind TLS termination. See the [environment reference](https://appwrite.io/docs/advanced/self-hosting/configuration/environment-variables) and [pinned enforcement](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/general.php). ### 2. Restrict console registration and protect the dashboard separately By default only the first user can register through the console; every account after that has to be invited. Keep root-only registration enabled and configure the email/IP allowlists for your operators: ```text _APP_CONSOLE_WHITELIST_ROOT=enabled _APP_CONSOLE_WHITELIST_EMAILS=operator@example.com _APP_CONSOLE_WHITELIST_IPS=203.0.113.10 ``` Replace the example email and address. The pinned defaults are respectively `enabled`, empty, and empty. These settings narrow who can **create a console account**, not who can reach or log into the dashboard. The first restricts self-registration to that one first user; the other two add registration allowlists. At 2.7.0 these checks are in the Account module's account-create handler, since 2.7.0 has no `app/controllers/api/account.php`. See the [pinned configuration](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php) and [registration checks](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Create.php#L109-L134). If the dashboard itself needs to stay unreachable from the open internet, put a separate network or access boundary in front of it, such as a firewall rule, VPN, or the reverse-proxy controls in [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) or [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). Claim the first console account while this boundary is private. Appwrite 2.3.0 changed how the console's location is configured. Its release notes say "`_APP_CONSOLE_URL` is the origin every console link is built from" and "`_APP_CONSOLE_URL_SCHEME` and the legacy `/console/...` link shapes are gone". Their upgrade section says "Remove `_APP_CONSOLE_URL_SCHEME` from your `.env`, and set `_APP_CONSOLE_URL` if your console is served from a different host than the API." The served environment reference marks `_APP_CONSOLE_URL` as `version >= 2.3.0` and says it "Replaces `_APP_CONSOLE_URL_SCHEME`, which is removed." At 2.7.0 the variable defaults to empty, and the root Compose file passes `_APP_CONSOLE_URL` to the `appwrite` service without a value. Set it to the console origin your boundary serves when that differs from the API host. See the [2.3.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.3.0), [pinned configuration](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php), [pinned Compose file](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml) and [environment reference](https://appwrite.io/docs/advanced/self-hosting/configuration/environment-variables). Enable console MFA for each operator under the account menu > Your account > Multi-factor authentication. Add a TOTP authenticator, scan its QR code, and enter an authenticator code to verify the factor. Store the recovery codes in protected storage accessible if the authenticator is lost. MFA strengthens permitted operators' password authentication; registration allowlists alone do not. At 2.7.0, MFA with a verified factor requires a second session factor for account access; the middleware now takes the required count from the user's `getMinimumFactors()`. See [console MFA](https://appwrite.io/docs/advanced/security/mfa), the [pinned MFA middleware](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php) and the [minimum factors](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L211-L218). Appwrite 2.7.0 also tightens two MFA steps that the served MFA page does not reflect as of 2026-11-04. Its release notes say "Make TOTP codes single-use, so one code can't complete a challenge on a second session; the enrolment code can't be reused either" and "Require an MFA challenge from the last few minutes to turn MFA off, as deleting an authenticator already does". As source reading of 2.7.0, a TOTP challenge records the code's time step as spent and refuses that step or an earlier one afterwards. Turning MFA off while it is in force needs a session challenge completed within `MFA_RECENT_DURATION`, 1800 seconds. Expect both when you test MFA. See the [2.7.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.7.0), [single-use TOTP steps](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/MFA/Challenge/TOTP.php#L30-L88), [challenge spend](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php#L165-L177), [MFA turn-off check](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php#L81-L84), [recent-challenge window](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L230-L244) and [window length](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/constants.php#L266). Do not inherit the repository's development `.env`: it disables root-only console registration, abuse protection, and router protection, and contains placeholder secrets. See the [pinned development environment](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/.env). Apply `.env` or Compose changes from the installation directory with `docker compose up -d`. Check effective behavior after recreation. See [applying changes](https://appwrite.io/docs/advanced/self-hosting/production/security). ### 3. Make permissions explicit and keep server keys off clients Set collection and document `permissions` deliberately. In Appwrite 2.7.0, `documentSecurity` defaults to `false`; enable it for document-level grants. Access granted at **either collection or document level** is sufficient. A broad collection grant therefore defeats an intended document restriction. See [legacy database permissions](https://appwrite.io/docs/products/databases/tablesdb/legacy/permissions). The [collection create parameter](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php#L88-L90) states the requirement for one of the two permission levels. For a document with null or omitted `permissions`, the Appwrite 2.7.0 create handler calls `Permission::aggregate` first. The pinned helper returns null for null input, so `permissions` remains null and reaches the handler's default branch. The handler assigns read/update/delete user grants when the requester has a user ID and is not privileged; otherwise it assigns an empty list. API-key requests skip subsequent role validation, but the key check alone does not set that default list. Use specific user identities or team roles, such as `Role.user(...)` or `Role.team(..., ...)`, rather than `Role.any()` for private data. See [document create parameter](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L134-L139), [requester classification](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L217-L218), [null-permissions handling](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L238-L288), [pinned dependency](https://github.com/appwrite/appwrite/blob/2.7.0/composer.lock#L4429-L4439), [pinned aggregate helper](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Permission.php#L176-L180), and [permission roles](https://appwrite.io/docs/advanced/security/permissions). For a 2.7.0 document GET, the pinned handler reads the requester class from the request's authorization roles: the `keys` role marks an API-key request, and the `owner`, `developer`, or `admin` role marks a privileged one. A disabled database or collection is reported as not found, except to API-key or privileged requesters, and, for a request without a transaction ID, the document itself is then fetched through the database layer. With an API key, the request's role and scopes come from the key, and at 2.7.0 every key also holds the `public` scope; a standard, ephemeral or `dynamic` project key for the same project sets the current authorization status to disabled. So do admin-mode console sessions, except on the console project's `/v1/projects` and `/v1/projects/:projectId` routes and GET requests to the `/v1/organization/projects` routes, which enable it. An OAuth2 key with the `owner` role on an admin-mode request by a user to a project other than the console also sets it to disabled. Route scope validation still applies to every requester. At 2.7.0 a request with an `x-appwrite-key` header that resolves to no key is refused. Collection `permissions` and `documentSecurity` are handed to utopia-php/database when the collection is created. The dependency's read checks and remaining deployment, installer and transaction-state gaps are described below; relating these checks to server-key permission bypass is this guide's source-based reasoning alongside the cited documentation, not a live demonstration. See the [document read handler](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L82-L97), [document fetch](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L105-L132), [role classification](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L82-L116), [API-key role and authorization status](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L146-L167), [admin-mode authorization status](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L328-L361), [OAuth2 owner key status](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php), [request role assignment](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L396-L400), [route scope validation](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L557-L561), [collection permission handoff](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php#L230-L236), and the [pinned dependency](https://github.com/appwrite/appwrite/blob/2.7.0/composer.lock#L4429-L4439). Appwrite 2.7.0's `composer.lock` pins utopia-php/database 10.1.0 at commit 950a4907. At that commit, `getPermissionsByType()` splits each stored permission at its first `(`, skipping one with no `(`, keys it on the action name with surrounding whitespace trimmed and strips `)`, double quotes and spaces to yield distinct role strings. Missing or null permissions yield no roles rather than throwing, since `getAttribute` returns its `[]` default for an unset or null value; a non-null value that is not an array, or an entry that is not a string, throws. The 7.3.9 `getRead()` accessor no longer exists. `Authorization` stores added roles as keys. With its status enabled, it accepts a permission string matching any role key and rejects an empty permission list or one with no match. See [role extraction](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Document.php#L273-L334), [attribute default](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Document.php#L405-L416) and [role matching](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L59-L102). At 10.1.0, `Database` takes its document methods from the `Trait\Documents` trait, whose code was not read for this guide. So the Database-level read checks in `getDocument` and `find`, and the `find` dispatch through `skip` after a successful collection check, are not established at 10.1.0. The 7.3.9 reading found those checks additive, a collection grant sufficing; this guide does not assert that for 2.7.0. The database still passes its authorization instance to the adapter. See the [trait use](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L52-L61) and [authorization handoff](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L1033-L1039). At 10.1.0 `Authorization` has no `setDefaultStatus`. `setStatus`, `enable` and `disable` change the current status, `getStatus` returns it, and `reset` restores the status the instance was constructed with, `true` by default. `skip` runs a callback with status false through a value that the class comment scopes to "the calling coroutine and the coroutines it starts"; how that value restores the prior status was not read. For a valid `Input`, disabled status makes `isValid` return true before checking an empty permission list or matching roles; invalid input is still refused. Appwrite 2.2.0 called `setDefaultStatus(false)` for keys and admin mode, and 2.7.0 calls `setStatus(false)`. This guide infers that the 2.7.0 calls change only the current status, so a later `reset()` would restore `true`; the 2.7.0 files read here never call `reset()` on an `Authorization` instance. See [status controls](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L148-L208), [shared status and constructor default](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L10-L41), and [role matching](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L59-L102). At 10.1.0, `SQL.php` supplies `getDocument` and `find` for MariaDB, Postgres and SQLite, and through MariaDB for MySQL. SQLite now extends `SQL` directly, no longer through MariaDB, and its own `find` refuses queries using aggregates it does not support before calling the parent's. See the [MariaDB](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/MariaDB.php#L46), [Postgres](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Postgres.php#L53), [MySQL](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/MySQL.php#L25), and [SQLite](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQLite.php#L71) class declarations and the [SQLite find wrapper](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQLite.php#L249-L259). Without selections or join queries, SQL `getDocument` selects by ID with a bound parameter, adds a tenant clause itself under shared tables and returns the row as a document. Neither that path nor the builder path it uses for selections without joins adds a shared-authorization status check or permission predicate. With join queries, it reads the requester roles and builds its query through `configureFindBuilder`, which adds the permission hooks under the same conditions as `find` below. See the [complete SQL document fetch](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L650-L779). SQL `find` reads roles from the shared authorization instance and defaults to the read action. As source reading of 10.1.0, it adds the main collection's document-permission hook only when that instance's status is enabled and the collection has `documentSecurity` enabled or is the metadata collection. With joins, and on its bounded-page path, it also skips that hook for a collection whose `Database::COLLECTION_GRANTED` attribute is true; where that attribute is set was not read. Separately, while the status is enabled, it wraps a permission hook for each joined table in a `Permission\Join` hook, whatever the main collection's `documentSecurity` or grant. It omits one only when the collection's `Database::JOIN_DOCUMENT_SECURITY` map gives that table a value that casts to false; an empty map, or one with no entry for the table, keeps it. Where that map is set was not read. On a join that keeps unmatched rows, it also adds a `Permission\OuterJoin` hook once any of these permission hooks is added. At 7.3.9 the predicate depended on the status alone, whatever the `documentSecurity` setting. See the [SQL find entry](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L1566-L1572), [SQL find permission gate](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5063-L5079), [joined-table permission hooks](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5081-L5112), [per-document filtering test](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5179-L5183), [joined-table security test](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5185-L5213), and [bounded-page gate](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L6080-L6086). The base permission hook selects document IDs from the collection's permissions side table where a permission matches a requester role and the requested action, with bound parameters and, under shared tables, a tenant filter on that table; it returns `1 = 0` for an empty role list. Postgres overrides it with OR-ed JSONB containment tests for action-wrapped roles on each row's permissions column, also returning `1 = 0` for an empty role list. The side table's name comes from `Storage::permissionsTable()`, which was not read; code comments call it the `_perms` side table. See the [permission hook construction](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L3658-L3678), [base permission filter](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL/Hook/Permission/Filter.php#L72-L116), and [Postgres override](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Postgres.php#L1909-L1958). This guide's reasoning from the cited 10.1.0 SQL code is that, with authorization enabled, SQL `find` filters the main collection's rows by document permissions only for a collection with `documentSecurity` enabled or the metadata collection, and not for a granted collection on a join or bounded-page path. It also filters each joined table unless the collection's join document-security map disables that table. Disabled authorization omits those filters, while query conditions, ordering and pagination remain. Whether a collection grant still makes Database-level `find` run with authorization disabled, as at 7.3.9, is not established at 10.1.0, since the dispatch was not read. These are source-based conclusions, not a live authorization demonstration. At 10.1.0, Mongo, Memory, Redis and Pool also extend `Adapter`; see the [Mongo](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L57), [Memory](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L36-L48), and [Redis](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L41-L57) class declarations and the [Pool class and delegation](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L36-L138). Memory and Redis are declared in the `Utopia\Database\Adapter` namespace and import `Utopia\Database\Adapter`, so both extend `Utopia\Database\Adapter`; see the [Memory namespace and import](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L3-L5) and [Redis namespace and import](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L5-L8). Mongo `getDocument` filters on `_uid`, applies its tenant hook and returns the first match; its method contains no shared-authorization status check or permission filter. Mongo `find` builds query filters and passes them through its tenant hook and then a `Hook\Mongo\Permission` hook built on the shared authorization instance. That hook's code was not read, so at 10.1.0 its status gate and the permission strings it matches are not established. See the [Mongo document fetch](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L1561-L1603), [Mongo find filters](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L2402-L2413), and [Mongo read hooks](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L249-L282). Memory's class comment calls it an "In-process drop-in for the SQL adapters" and says it is "Intended for tests, fixtures, and ephemeral workloads"; Redis's calls it an adapter "mirroring the Memory adapter's surface". Neither `getDocument` method contains a shared-authorization status check or permission filter; Redis `getDocument` also returns an empty document when a shared-tables row belongs to another tenant. In `find`, only while the shared authorization status is enabled, Memory keeps documents whose indexed permissions for the requested action name a requester role, and Redis keeps IDs found in its per-action sets for the requester's roles, keeping none for an empty role list. Unlike SQL `find`, neither permission step tests `documentSecurity`. See the [Memory document fetch](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L1204-L1227), [Memory find](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L1807-L1836), [Memory find filter](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L2397-L2452), [Memory permission allow set](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L3108-L3141), [Redis document fetch](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L882-L916), [Redis find](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L1482-L1519), [Redis find permission step](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L2809-L2823), and [Redis permission filter](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L1938-L1973). Pool `getDocument` and `find` delegate to a pooled adapter. Before each delegated call, including calls on the adapter it pins for a transaction, Pool sets that adapter's database, namespace, shared-tables mode, tenant, tenant-per-document mode, authorization and locks from its own state. Pool's `setAuthorization` stores the instance it receives, and `withTransaction` sets the same state on an adapter before pinning it for the transaction's calls. See the [Pool state sync](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L178-L186), [Pool document read delegation](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L802-L807), [Pool find delegation](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L884-L889), [Pool authorization setter](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L1101-L1106), and [Pool transaction pinning](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L517-L541). This guide's reasoning from these lines and the cited authorization handoff is that a Pool-wrapped adapter runs its own `getDocument` and `find` code above with the authorization instance the database set. It also reasons that Memory and Redis `getDocument` add no document-permission filter, and that disabled status omits their `find` permission filters as it omits the SQL one. Whether a collection grant omits them, and the Mongo `find` filter, are not established at 10.1.0. These are source-based conclusions, not a live authorization demonstration. In Appwrite 2.7.0, `_APP_DB_ADAPTER` selects the main database: the pinned variables give it introduction version 1.9.0, default `postgresql`, the description "Must be one of: PostgreSQL, MariaDB, or MongoDB" and the question choices `postgresql|mariadb|mongodb`. The pool registration falls back to `postgresql` when the variable is unset, accepts the `mariadb`, `mongodb`, `mysql` and `postgresql` schemes for its `console` and `database` connections, and maps them to the MariaDB, Mongo, MySQL and Postgres adapters; DocumentsDB connections accept only `mongodb`, and the mapping names no Memory, Redis or SQLite adapter. See the [pinned variables](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php), [pool DSN fallbacks and connections](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/registers.php#L45-L99), and [adapter mapping](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/registers.php#L239-L252). The 2.7.0 database factory imports utopia-php's `Utopia\Database\Adapter\Pool` as `DatabasePool` and wraps the connection pool named by a DSN host in it; its `tenant` method sets the shared authorization instance on the database it builds and turns on `setCheckGrants(true)`. The 10.1.0 docblock says that setting makes a related document given as a document to the write methods it lists, while authorization is enabled, "only add permissions for roles the caller holds", that `updateDocuments()` then refuses such a document, and that `createDocuments()` and the upsert methods read its existing permissions without a lock. Without a transaction ID, the document GET handler cited above fetches through `getDatabasesDB`, which builds its database with that `tenant` method from the database document's DSN, or the project's when that is empty; at 2.7.0 it also adds `Metadata` and `Joins` hooks, whose effect is not traced here. With a transaction ID, the 2.7.0 handler first reads the `transactions` document, inside `skip` for API-key or privileged requesters and under the requester's authorization otherwise, and reports a key or privileged requester's empty result as not found. It then calls the transaction state's `getDocument`; the request resources build that state from the project database, the request's `authorization` resource, the same `getDatabasesDB` and the request's user, and give the database factory that `authorization` resource too. As source reading of 2.7.0 without a run, the state is replayed from the transaction's `transactionLogs` entries, plus any pending operations, only when its `transactions` document exists with status `pending`. The state reads that document inside `skip` only for API-key or privileged requesters and reads the logs inside `skip`; its code comment says "Staged operations are only visible to the transaction owner, API keys and privileged users." Whether a `transactions` document's permissions limit an ordinary requester to its creator is not established here. A document staged for deletion yields an empty document, which the handler reports as not found, and one staged for creation is returned from the staged state itself. For a staged update or upsert, the committed document is fetched through the database layer and the staged state is merged over it; if that fetch is empty, an upsert returns the staged document, while an update falls through to a second committed fetch, as does a document with no staged state. This guide's reasoning from these lines is that staged creates bypass database-layer retrieval, and that when a committed fetch returns empty, the upsert branch returns the staged document without another database-layer read. The 2.7.0 handler no longer calls `processDocument`; its parent class's `stampTransactionRead` and `isTransactionTypeMismatch` helpers and any other layer are not traced. See the [factory Pool import](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L12), [factory Pool wrapping](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L208-L211), [tenant database construction](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L123-L201), [grant checking](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L1162-L1183), [getDatabasesDB resource](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L1112-L1159), [document GET transaction branch](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L109-L124), [document GET not-found response](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L134-L136), [transaction-state staged creation](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L92-L94), [transaction-state document branches](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L70-L108), [transaction and log reads](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L675-L706), [transaction-state resource](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L1161-L1165), and [factory authorization resource](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L234-L238). The repository's development `.env` (`_APP_ENV=development`) sets `_APP_DB_ADAPTER=postgresql`; that is a development default, not a production default. The repository's root Compose file, which the 2.7.0 install task reads as its Compose template, passes `_APP_DB_ADAPTER` to the `appwrite` service without a value and defines `mariadb`, `mongodb` and `postgresql` services. The 2.7.0 upgrade task keeps an installation's existing engine and, when neither its Compose services nor its `.env` set `_APP_DB_ADAPTER`, assumes MariaDB, commenting "Pre-1.9.0 installations only supported MariaDB". It then passes that engine to the install task, whose own detection, described below, runs as well and can replace the assumed MariaDB with a `mongodb` or `postgresql` service name, or an `_APP_DB_HOST` value naming one, found in the Compose file. See the [pinned development environment](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/.env), [pinned root Compose file](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml), [install task Compose template](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L563-L568), [install task Compose rendering](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L664-L671), [upgrade engine detection](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Upgrade.php#L73-L98), and [upgrade hand-off to the install task](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Upgrade.php#L108-L110). As source reading of 2.7.0 without an Appwrite run, the console variables endpoint reports `_APP_DB_ADAPTER` with its own fallback, `mariadb`, through a response model whose rule for the field also defaults to `mariadb` and describes it as "Database adapter in use", while the pool registration, cited above as the pool DSN fallbacks and connections, falls back to `postgresql`. A deployment that leaves the variable unset therefore gets Postgres `console` and `database` pools while that endpoint reports `mariadb`; where the variable is set, the endpoint and the pool registration read the same value, so for a value that registration accepts, the endpoint reports the scheme those pools use. At 2.7.0, `packages/system/src/System.php` in the Appwrite tree declares `Utopia\System\System`, whose `getEnv` returns `getenv($name) ?: $default`. The 2.3.0 release notes list `system` among Utopia libraries that "now live in `packages/` and load directly", and the 2.7.0 `composer.lock` has no utopia-php/system package entry. This guide infers that this in-tree class is the one 2.7.0 loads, so an empty value takes the same fallback as an unset one in the endpoint's and the pool registration's `System::getEnv` calls; Appwrite's `composer.json` was not read. What the filters for an older response format do to the field is not traced here. As source reading of 2.7.0, the install task starts the web installer and returns when its `interactive` parameter is `Y`, the console is interactive and no command-line argument starts with `--`; otherwise it takes its command-line path. In that path it sets the variable from an interactive answer, a question it skips when it finds an existing Compose file, or else from the database it selects, which is the `database` parameter (default `postgresql`) unless an existing installation's detected database replaces it, and writes it to `.env` unless it reuses an existing configuration. It detects that database from the first Compose service setting `_APP_DB_ADAPTER`, then from `.env`, then from a `mariadb`, `mongodb` or `postgresql` service name, checked in that order, then from a service's `_APP_DB_HOST` naming one of those. Unless its `no-start` parameter is set, it then runs `docker compose` with `--env-file` naming its environment file, assigning each nonempty prepared value, this variable included, in the command's environment unless it reuses an existing configuration. The web installer and the Compose generator are traced below, but how Compose resolves the template's value-less `_APP_DB_ADAPTER` entry from that `--env-file` and environment, or in a later `docker compose up -d` run by hand, is not established here, so whether a given installation passes the value to its `appwrite` service is still not established. This guide's assessment: do not rely on the `_APP_DB_ADAPTER` value the console reports to identify which adapter a 2.7.0 deployment uses, because a reported `mariadb` can be that fallback over Postgres pools; check whether the variable is set in the `appwrite` service's environment, and to what, instead. See the [console variables adapter fallback](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php#L118), [console variables response model adapter rule](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php#L130-L138), [install task database parameter](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L81), [install task detected-database override](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L234-L239), [install task interactive database answer](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L299-L307), [install task adapter assignment](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L318-L319), [install task existing-configuration check](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L550-L555), [install task Compose and environment writes](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L708-L721), and [install environment template](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/views/install/env.phtml). For the empty-value fallback, see the [packages/system getEnv fallback](https://github.com/appwrite/appwrite/blob/2.7.0/packages/system/src/System.php#L703-L706) and the [2.3.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.3.0). For the web-installer branch, existing-database detection and container start, see the [install task web-installer branch](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L253-L262), [install task explicit-parameter check](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1694-L1703), [install task existing-database detection order](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L214-L233), [install task database service and host detection](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1709-L1728), [install task container start condition](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L767-L773), and [install task Compose start](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1316-L1377). As source reading of the 2.7.0 web installer without a run, its first step offers PostgreSQL, MariaDB and MongoDB, each only when enabled, as radio choices. It preselects a locked database or else the `_APP_DB_ADAPTER` variable's default as the install task passes it, which is `postgresql` unless the task replaced it for an existing installation, and disables the other choices when one is locked. The page's script posts the choice as `database`, falling back to `postgresql`. The install handler accepts only those three values, requires an unlocked choice to be a supported and enabled database, and sets `_APP_DB_ADAPTER` to the locked database or else the choice. For a request without a retry step whose installation already has progress steps recorded, the handler deletes the progress file of an earlier run that ended in error or completed every step, so the request starts fresh, and otherwise sets the installer lock to an error status and answers "Installation already started" (409, or an error event on a stream) before any comparison. When a resumed installation has a saved payload and the earlier run recorded no error, the handler compares the stored fields with the request and refuses a mismatch, such as a nonempty posted `database` that differs from a stored `database`, by setting the installer lock to an error status and answering "Installation payload mismatch". If every check passes, the handler sets `_APP_DB_ADAPTER` to the locked database, else the stored `database` if present, else the value the handler set before the comparison; after an earlier error, or with no saved payload, the handler skips the comparison and keeps that earlier value. The install task's environment preparation keeps the handler's nonempty `_APP_DB_ADAPTER` value and sets the database host and port to match it, and the installation then passes it to the Compose generator and to the environment template as described above. When the installer server finds an existing Compose or environment file and has no locked database, it locks one detected from database service or container names in the Compose file's text, then from `_APP_DB_ADAPTER` or `_APP_DB_HOST` in the environment file. See the [web installer database default and lock](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/templates/steps/step-1.phtml#L11-L20), [web installer database choices](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/templates/steps/step-1.phtml#L66-L108), [web installer default database variable](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer.phtml#L12), [web installer view database settings](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/View.php#L42-L48), and [web installer posted database fallback](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/js/modules/progress.js#L408). For the install handler, see the [web installer database parameter](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L46), [web installer database validation](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L150-L161), [web installer supported database values](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Runtime/State.php#L204-L206), [web installer database adapter input](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L238-L246), [web installer resumed progress check](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L202-L225), [web installer resumed payload comparison](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L248-L285), [web installer resumed database input](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L313), [web installer environment preparation](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L320-L322), and [web installer installation call](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L380-L394). For the install task and installer server, see the [install task web installer configuration](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L337-L374), [install task existing-installation defaults](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L156-L191), [install task user input and database host](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L440-L473), [installer server upgrade and database lock detection](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Server.php#L225-L257), and [installer server database detection from files](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Server.php#L277-L315). As source reading of 2.7.0, the Compose generator replaces a `database` value other than `postgresql`, `mariadb` or `mongodb` with `postgresql`, removes the other two database services and their volumes, and replaces dependencies on any database service or on `${_APP_DB_HOST:-postgresql}` with one dependency on the selected service, under the condition `service_healthy`. Its code never refers to a service's `environment` or to `_APP_DB_ADAPTER`, so this guide reasons that the rendered Compose file keeps the template's value-less `_APP_DB_ADAPTER` entry; no rendered file was observed. When the install task reads an existing Compose file, `Appwrite\Docker\Compose` wraps each service in a `Service`, which joins its `environment` list with newlines and parses the result with `Env`, and `Env` skips a line that has no `=`. A value-less `_APP_DB_ADAPTER` entry therefore yields no key, so the install task's first detection step, which tests `isset` on that key, does not match it, and detection moves on to `.env`. See the [Compose generator](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Docker/Compose/Generator.php), [generator selectable database services and volumes](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L9-L26), [generator database dependency selector](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L71-L81), [generator parameter normalization](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L145-L153), [generator service filter](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L188-L210), [generator volume filter](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L263-L278), [generator dependency rewrite](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L283-L333), [Compose services](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose.php#L16-L27), [Compose service environment parsing](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Service.php#L32-L33), and [environment parser lines without an assignment](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Env.php#L37-L42). There is a documentation/source conflict: the environment documentation as served on 2026-11-04 says "Appwrite supports MariaDB and MongoDB as database backends" and gives `_APP_DB_ADAPTER` the default `mongodb` with possible values `mongodb` and `mariadb`, while 2.7.0 defaults to `postgresql`, which that page does not list. See the [environment reference](https://appwrite.io/docs/advanced/self-hosting/configuration/environment-variables). This guide's reasoning from these Appwrite and utopia-php lines is that, on its non-transaction retrieval path, the cited 2.7.0 document GET reads through Pool over whichever MariaDB, MySQL, Postgres or Mongo adapter its connection's DSN scheme selects. Its transaction-state path, traced above, by the same reasoning makes its committed fetches the same way and returns staged documents from the transaction's logs. Which adapter a given deployment uses depends on its own environment, DSNs and installation history and is not established here, and neither is how Compose resolves the value-less `_APP_DB_ADAPTER` entry. Those deployment and Compose gaps, and the transaction-state layers left untraced above, stay open in SELFHOSTED-BACKEND-SOURCE-1; this guide does not claim complete pinned-source verification or a live authorization demonstration. Properly scoped server API keys **bypass resource permissions**. A successful server-key test does not establish that an ordinary user is authorized. Scopes are selected per key and still restrict which API operations that key can perform. At 2.7.0 every key also holds the `public` scope, which the 2.7.0 release notes describe as letting "API keys call `public` scope endpoints". See the [pinned key and scope checks](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php). Project API keys are scoped rather than all-or-nothing; grant only the scopes a given key needs, and treat any key with `keys.write` as equivalent to an admin credential. The served API keys page says "An API key with `keys.write` can change the scopes of existing keys and delete them." Keys are meant for server SDKs and CLI use, never for client-side code; store them the way [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) describes, not in the repository or the client bundle. See [project API keys](https://appwrite.io/docs/partners/project/api-keys). The same page, as served on 2026-11-04, says "Creating API keys with the Project API is deprecated as of 17 August 2026. Create long-lived keys in the Appwrite Console. The Project API can list, update, and delete existing keys, and can issue ephemeral keys for short-lived access." The 2.3.0 to 2.7.0 release notes do not mention that date; the page links a changelog entry for 2026-08-17, which was not read here. Releases since 2.6.2 also limit what a key can grant, which the served API keys page does not reflect as of 2026-11-04. The 2.6.2 release notes say "A key can no longer create or change a key with scopes it doesn't hold or an expiry later than its own". The 2.7.0 release notes say "a key that configures a function or site can only add scopes it holds itself", and the 2.7.0 function create handler refuses such a key with "An API key cannot grant scopes it does not hold." This guide reads these sources as meaning that a key with `keys.write` cannot give another key a scope it lacks; the 2.7.0 key handlers themselves were not read, so their scope and expiry checks are not established at 2.7.0, and this guide still treats such a key as equivalent to an admin credential. See the [2.6.2 release notes](https://github.com/appwrite/appwrite/releases/tag/2.6.2), [2.7.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.7.0) and [function options](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php). ### 4. Replace encryption secrets and make recoverable backups Set a unique `_APP_OPENSSL_KEY_V1` **before storing production data**. At 2.7.0 its configuration default is empty, and a fresh install generates it: the install task passes every `token` variable except `_APP_JOBS_SECRET` through `EncryptionKey::resolve`, which replaces an empty or `your-secret-key` value only when secrets are generated, which is for neither an existing installation nor an upgrade. It passes `_APP_JOBS_SECRET`, also a `token` variable, through `JobsSecret::resolve`, which takes no generation flag and replaces such a value whether or not secrets are generated. The 2.4.0 release notes say "Fresh installs generate `_APP_OPENSSL_KEY_V1` instead of using `your-secret-key`. Upgrades keep the existing key." An upgraded installation can therefore still hold the placeholder, and the repository's development `.env` still ships it, so replace a placeholder wherever you find it. The 2.7.0 variable description says "Production refuses to start when this is empty"; where that is enforced was not traced here. As of 2026-11-04 the served environment reference still says the variable "is not set by default" and does not mention the generated value. The served security page, as of 2026-11-04, says "Appwrite does not generate a unique encryption key during a default setup.", which contradicts the key that a fresh 2.7.0 install generates. Inject the real value from protected deployment configuration: ```text _APP_OPENSSL_KEY_V1=REPLACE_WITH_UNIQUE_ENCRYPTION_SECRET ``` See the [pinned default](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php), [key resolution](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/EncryptionKey.php#L11-L69), [jobs secret resolution](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/JobsSecret.php#L20-L32), [install task secret generation](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L440-L473), [generation condition](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L331), [2.4.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.4.0) and [encryption guidance](https://appwrite.io/docs/advanced/self-hosting/production/security). Back up the exact key separately from ordinary data backups and restrict access to both. Changing or losing it strands previously encrypted secrets. Back up the database, persistent storage, and deployment configuration, and test restoration in a separate installation. See [self-hosted backups](https://appwrite.io/docs/advanced/self-hosting/production/backups). Appwrite 2.7.0 adds server key rotation, which the served security and backups pages do not reflect as of 2026-11-04; both name only `_APP_OPENSSL_KEY_V1`. The 2.7.0 release notes say "`_APP_OPENSSL_KEY_CURRENT` picks the `_APP_OPENSSL_KEY_V{n}` that signs new tokens and encrypts new data", that "Stored values keep decrypting with the version they record", and that "The new `encryption` task re-encrypts stored data under the current version". Their upgrade notes add "Files already encrypted in storage are not re-encrypted, so keep `_APP_OPENSSL_KEY_V1` configured." Rotate only by that procedure, and back up every key version that still decrypts stored data or files. The served environment reference does list the 2.7.0 key-version variables. See the [2.7.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.7.0), [key resolution](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/EncryptionKey.php#L11-L69) and [pinned variables](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php). ### 5. Keep abuse protection enabled and configure SMTP Set `_APP_OPTIONS_ABUSE=enabled`. This is the configuration default, but the repository's development `.env` overrides it to `disabled`. Server API-key requests and requests with the `owner`, `developer` or `admin` role are exempt, so test client rate limiting as an ordinary client without a server key. See the [pinned abuse check](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php) and [rate-limit documentation](https://appwrite.io/docs/advanced/security/rate-limits). Configure the `_APP_SMTP_*` settings for an actual mail service: ```text _APP_SMTP_HOST=smtp.example.com _APP_SMTP_PORT=587 _APP_SMTP_SECURE=tls _APP_SMTP_USERNAME=REPLACE_WITH_SMTP_USERNAME _APP_SMTP_PASSWORD=REPLACE_WITH_SMTP_PASSWORD ``` These five settings default to empty; an empty `_APP_SMTP_HOST` disables sending. Match the port and transport to your provider and protect the credentials. OTP, alerts, invitations, and recovery cannot be relied upon until delivery works. See the [pinned SMTP variables](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php) and [email configuration](https://appwrite.io/docs/advanced/self-hosting/configuration/email). ### 6. Constrain uploads and function execution The server storage defaults are `_APP_STORAGE_DEVICE=local`, `_APP_STORAGE_LIMIT=30000000`, and `_APP_STORAGE_ANTIVIRUS=disabled`. Enabling antivirus with `_APP_STORAGE_ANTIVIRUS=enabled` requires a reachable ClamAV service; configure its host and port for the deployment. See the [pinned storage variables](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php). New buckets have empty permissions and `fileSecurity=false`. Enable file security when using individual-file grants, and keep bucket permissions narrow: a bucket-level grant also permits access. Bucket `encryption` and `antivirus` default to `true`, but that antivirus option does not enable the server-wide scanner. See the [bucket defaults](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php). Encryption and antivirus are skipped for files **above 20,000,000 bytes**, even with their bucket options enabled. If either is mandatory, set bucket `maximumFileSize` to no more than `20000000`, enable the relevant controls, and test the boundary. The default server upload limit is larger than this threshold. See the [bucket option semantics](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php) and [size constants](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/init/constants.php). For functions, `execute` roles and the `scopes` for API keys generated for executions both default to `[]`. Grant execution only to intended callers and grant the execution key only the API scopes its code needs. Setting `enabled=false` blocks ordinary callers but does not block authorized Server SDK API keys. See the [function options](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php). Keep the executor and orchestrator private. The pinned Compose stack mounts the Docker socket into these services, giving their control plane significant host authority. It also mounts the socket into the `traefik` service, which publishes the HTTP and HTTPS ports. At 2.7.0 `_APP_EXECUTOR_SECRET` keeps the default `your-secret-key` but is a `token` variable, so by the same `EncryptionKey::resolve` call as the encryption key a fresh install replaces that placeholder with a generated value, while an existing or upgraded installation keeps its stored value. The 2.6.2 release notes say "Install generates the executor secret". As of 2026-11-04 the served environment reference still says "Make sure to change this!" and does not mention the generated value. Replace any remaining `_APP_EXECUTOR_SECRET` placeholder with a unique protected value: ```text _APP_EXECUTOR_SECRET=REPLACE_WITH_UNIQUE_EXECUTOR_SECRET ``` Container execution is not demonstrated isolation for mutually hostile tenants. Treat deployment and execution-control credentials as privileged, and review workload isolation separately. See the [Compose services](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml), [pinned variables](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php), [install task secret generation](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L440-L473), [2.6.2 release notes](https://github.com/appwrite/appwrite/releases/tag/2.6.2) and [executor configuration](https://appwrite.io/docs/advanced/self-hosting/configuration/environment-variables). ## Verify REASONED: this historical environment account is not a new host inventory; a live PocketBase or Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. All three Verify shell blocks passed bash -n and ShellCheck 0.11.0 with exit 0 and no output. With curl and grep stubbed, written and embedded placeholders, example.com, angle brackets, empty values, and omitted or shortened set -- lines printed their refusal messages without invoking either stub. The bash -n, ShellCheck and stub runs recorded here covered all three Verify shell blocks in an earlier form, whose guard refusals left the subshell with status 0 and whose scan block result arms ended with status 0, and the stub runs were not repeated on the current forms. In the current forms guard refusals leave the subshell with status 2, the GET and registration blocks exit 3 when curl fails, the registration block exits 1 when the registration returns 201, the scan block's FAIL and scan-failure arms exit 1 and 3, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three blocks. Live public-to-restricted record comparisons remain unobserved: guests can read public records, while restricted records deny or filter unauthorized reads and preserve permitted access, per the guide’s cited PocketBase API rules and filters (rolling documentation, checked November 2026) and Appwrite legacy collection/document permissions (rolling documentation, checked November 2026). The following sentences record the earlier authoring environment: The live checks below are **reasoned, not demonstrated**. This authoring environment has no PocketBase executable or container runtime, no writable service installation, and no supplied deployment, accounts, SMTP service, or second-host ingress fixture. Shell network access also failed. No available authorized environment could reproduce the live exposed and fixed states. REASONED: disposable-fixture comparison procedure; a live PocketBase or Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The GET block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output; under bash -u, written and embedded placeholders, example.com, angle brackets, empty values, omitted or shortened set -- lines, HTTP, query, fragment, userinfo, and a control character printed refusal messages without invoking the curl stub. A valid fixture printed CURL_STUB arguments including --header @- and STDIN Authorization: DUMMY_TOKEN; the dummy header did not occur in those arguments. The bash -n, ShellCheck and stub runs recorded here covered the GET block in an earlier form, whose guard refusals left the subshell with status 0, and the stub runs were not repeated on the current form, whose guard refusals leave the subshell with status 2 and which exits 3 when curl fails; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. The local stub does not establish application authorization: public records admit guests, while fixed rules deny or filter unauthorized access and retain authorized access, per the guide’s cited PocketBase API rules and filters (rolling documentation, checked November 2026) and Appwrite legacy collection/document permissions (rolling documentation, checked November 2026). Use disposable data on a private fixture to establish exposed behavior, then repeat through the deployed endpoints after hardening. Record response bodies, authorization identities, and positive controls. A timeout, malformed request, nonexistent resource, or unrelated proxy error does not establish application authorization. REASONED: console-origin and API selection; a live PocketBase or Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The signup JSON parsed as four expected string fields. The registration block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output; under bash -u, written and embedded placeholders, example.com, angle brackets, empty values, omitted or shortened set -- lines, HTTP, query, fragment, userinfo, and a control character printed refusal messages without invoking the curl stub. A valid fixture printed CURL_STUB arguments ending in https://console.internal/v1/account and STDIN containing the JSON; the password field did not occur in those arguments. The bash -n, ShellCheck and stub runs recorded here covered the registration block in an earlier form, whose guard refusals left the subshell with status 0, and the stub runs were not repeated on the current form, whose guard refusals leave the subshell with status 2 and which exits 1 for a 201 and 3 when curl fails; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Expected bootstrap access is private operator creation, not proof from static dashboard assets; Appwrite console account creation must fail after root-only bootstrap, per the guide’s cited PocketBase v0.41.1 installer and bootstrap-token lifetime and Appwrite 2.7.0 Compose console URL, persistent volumes, and Docker socket mounts and Appwrite 2.7.0 console registration handler. The former `curl -I` checks are insufficient: HEAD cannot inspect signup controls, PocketBase serves static `/_/` assets, and since 2.3.0 Appwrite builds console links from `_APP_CONSOLE_URL`, which 2.7.0 Compose passes without a value, and has dropped the legacy `/console/...` link shapes, so a `/console` path is deployment-dependent. Use the actual console origin and API endpoints below. See the [PocketBase installer](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/installer.go?rev=refs/tags/v0.41.1), [Appwrite Compose configuration](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml) and [Appwrite 2.3.0 release notes](https://github.com/appwrite/appwrite/releases/tag/2.3.0). ### 1. Test resource authorization with denied and allowed identities **REASONED:** Requires the unavailable live backend and controlled accounts described above. Use this GET block for each resource comparison below. Put request headers in an owner-readable file outside the client bundle; use an empty file for a PocketBase guest. PocketBase uses `Authorization: REPLACE_WITH_USER_TOKEN`. Appwrite uses `X-Appwrite-Project: REPLACE_WITH_PROJECT_ID` plus the intended end-user session header or cookie. Use a separate protected header file for each identity, and confirm that identity works on an allowed resource before accepting its denial elsewhere. REASONED: substitution and protected-header handling for the GET procedure; live backend fixtures and controlled accounts remain unavailable; a live PocketBase or Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The GET block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output; under bash -u, written and embedded placeholders, example.com, angle brackets, empty values, omitted or shortened set -- lines, HTTP, query, fragment, userinfo, and a control character printed refusal messages without invoking the curl stub. A valid fixture printed CURL_STUB arguments including --header @- and STDIN Authorization: DUMMY_TOKEN; the dummy header did not occur in those arguments. The bash -n, ShellCheck and stub runs recorded here covered the GET block in an earlier form, whose guard refusals left the subshell with status 0, and the stub runs were not repeated on the current form, whose guard refusals leave the subshell with status 2 and which exits 3 when curl fails; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Expected public guest reads become denied or filtered after restrictive rules, with authorized-user positive controls, per the guide’s cited PocketBase API rules and filters (rolling documentation, checked November 2026) and Appwrite legacy collection/document permissions (rolling documentation, checked November 2026) and curl curl-8_14_1 headers from stdin (checked October 2026). Substitute inside the single quotes and paste whole blocks. Do not paste literal apostrophes inside those quotes. The guards assume ordinary shell builtins. The headers reach curl from a file on its stdin and this block never expands their contents, so they stay out of curl's arguments, shell history and `set -x` tracing. The file's permissions and how it was prepared remain exposures, as does the same account or root reading the file or viewing the process. Never place a token-bearing URL in this block. REASONED: following block; resource and scope comparisons follow the cited vendor documentation and available pins; live backend fixtures and controlled accounts were unavailable. ```bash ( set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_BACKEND/REPLACE_WITH_RESOURCE_PATH' 'REPLACE_WITH_PROTECTED_HEADERS_FILE' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*|*'@'*|*'?'*|*'#'*) echo "substitute an unsigned HTTPS resource URL; not probing"; exit 2 ;; https://*) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute the protected headers file path; not probing"; exit 2 ;; *) [ -f "$2" ] || { echo "the protected headers file is not a readable file; not probing"; exit 2; } [ -r "$2" ] || { echo "the protected headers file is not a readable file; not probing"; exit 2; } if curl -q -g -sS --noproxy '*' --proto '=https' \ --connect-timeout 5 --max-time 20 --header @- \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "$1" < "$2"; then echo "unclassified observation: judge the status and body above against the comparison you are running, for the identity in this headers file" else echo "curl exit $?: the request failed; inconclusive"; exit 3 fi ;; esac ;; *) echo "use HTTPS; not probing"; exit 2 ;; esac ) ``` | Check | Exposed and fixed comparison | | --- | --- | | PocketBase record rules | **REASONED:** On the private fixture, request `/api/collections/REPLACE_WITH_COLLECTION/records` and `/api/collections/REPLACE_WITH_COLLECTION/records/REPLACE_WITH_RECORD`. Public empty rules allow guest reads. Locked rules return 403 to non-superusers. Restrictive rules must return only authorized records: an unsatisfied list filter can return 200 with empty `items`, while an unsatisfied view rule returns 404. Confirm the authorized ordinary user receives the known fixture record; separately confirm a superuser bypasses record rules. See [rule outcomes](https://pocketbase.io/docs/api-rules-and-filters/). | | Appwrite document permissions | **REASONED:** Request `/v1/databases/REPLACE_WITH_DATABASE/collections/REPLACE_WITH_COLLECTION/documents/REPLACE_WITH_DOCUMENT`. With document security explicitly enabled, first demonstrate that a broad collection read grant allows an otherwise ungranted user. Remove that broad grant: the intended document grantee must still receive the fixture, while guests and an unrelated user receive no document data. Compare omitted permissions for client-created and server-created fixtures. See [additive permissions](https://appwrite.io/docs/products/databases/tablesdb/legacy/permissions) and [creation defaults](https://appwrite.io/docs/advanced/security/permissions). | | Appwrite server-key scope | **REASONED:** Repeat the same document request from a trusted server using a protected API-key header file. A key with the required read scope can read despite empty resource permissions; a key lacking that scope must be refused. This is a separate test, never the positive control for an ordinary user's permissions. See [key authorization](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php). | ### 2. Test bootstrap and registration through the actual APIs **REASONED:** Requires an unavailable disposable Appwrite installation with its first console account already created. Prepare an owner-readable JSON file containing the following structure, then replace its placeholders with an unused controlled email and a test password satisfying the deployment's password policy: ```json { "userId": "unique()", "email": "REPLACE_WITH_TEST_EMAIL", "password": "REPLACE_WITH_TEST_PASSWORD", "name": "Registration probe" } ``` REASONED: the signup JSON structure in the block above, whose declaration is the paragraph before that block, and the protected-stdin request procedure in this paragraph for the following registration block, whose declaration is the paragraph after that block. No disposable Appwrite installation with a first console account and controlled signup fixture is available; a live Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The signup JSON parsed as four expected string fields. The registration block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output; under bash -u, written and embedded placeholders, example.com, angle brackets, empty values, omitted or shortened set -- lines, HTTP, query, fragment, userinfo, and a control character printed refusal messages without invoking the curl stub. A valid fixture printed CURL_STUB arguments ending in https://console.internal/v1/account and STDIN containing the JSON; the password field did not occur in those arguments. The bash -n, ShellCheck and stub runs recorded here covered the registration block in an earlier form, whose guard refusals left the subshell with status 0, and the stub runs were not repeated on the current form, whose guard refusals leave the subshell with status 2 and which exits 1 for a 201 and 3 when curl fails; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Expected exposed registration returns 201; root-only registration after bootstrap rejects an additional account, per the guide’s cited Appwrite 2.7.0 console registration handler. The request can create an account on a misconfigured instance. Run it only on the disposable fixture or an explicitly designated registration-test deployment. Use the API origin serving the console project, not a guessed `/console` UI path. The block feeds the signup JSON, test password included, to curl on stdin from the protected file: no password is typed into the command or held in a shell variable, so neither shell history nor `set -x` tracing records it, provided the file is prepared with an editor, not a shell command containing the password, which would enter shell history. Stdin closes the argv channel only; the account owner and root can still read the file and curl's memory. Console registration comparisons follow the pinned Appwrite handler; no disposable Appwrite installation or controlled signup fixture was available. ```bash ( set -- PASTE_WHOLE_BLOCK 'https://REPLACE_WITH_CONSOLE_API_ORIGIN' 'REPLACE_WITH_PROTECTED_SIGNUP_JSON' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*|*'@'*|*'?'*|*'#'*) echo "substitute the console API HTTPS origin; not probing"; exit 2 ;; https://*) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|""|*[[:cntrl:]]*) echo "substitute the protected signup JSON path; not probing"; exit 2 ;; *) [ -f "$2" ] || { echo "the protected signup JSON is not a readable file; not probing"; exit 2; } [ -r "$2" ] || { echo "the protected signup JSON is not a readable file; not probing"; exit 2; } set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --proto '=https' \ --connect-timeout 5 --max-time 20 \ --header 'X-Appwrite-Project: console' \ --header 'Content-Type: application/json' \ --data-binary @- \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ "${1%/}/v1/account" < "$2"; then printf '\n0'; else printf '\n%s' "$?"; fi)" set -- "$1" "$2" "${3##*$'\n'}" "${3%$'\n'*}" # curl's exit status, then its output printf '%s' "$4" case "$3:$4" in 0:*'http=201 '*) echo "FINDING: the registration returned 201 and created an account, which is the finding once root-only registration is on"; exit 1 ;; 0:*) echo "unclassified observation: read the error above; only the console-account-limit error shows the restriction, and a duplicate email or password-validation error does not" ;; *) echo "curl exit $3: the registration request failed; inconclusive"; exit 3 ;; esac ;; esac ;; *) echo "use HTTPS; not probing"; exit 2 ;; esac ) ``` REASONED: preceding block; the full registration request, guards, protected stdin, root-only and allowlist comparisons, and operator controls in this paragraph. No disposable Appwrite installation, controlled signup identities, source-IP fixture, or operator/invitation fixture is available; a live Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The signup JSON parsed as four expected string fields. The registration block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output; under bash -u, written and embedded placeholders, example.com, angle brackets, empty values, omitted or shortened set -- lines, HTTP, query, fragment, userinfo, and a control character printed refusal messages without invoking the curl stub. A valid fixture printed CURL_STUB arguments ending in https://console.internal/v1/account and STDIN containing the JSON; the password field did not occur in those arguments. The bash -n, ShellCheck and stub runs recorded here covered the registration block in an earlier form, whose guard refusals left the subshell with status 0, and the stub runs were not repeated on the current form, whose guard refusals leave the subshell with status 2 and which exits 1 for a 201 and 3 when curl fails; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. The exposed/fixed outcomes that follow are expectations, per the guide’s cited Appwrite 2.7.0 console registration handler. With root-only registration disabled and allowlists empty on the private fixture, a valid new registration should return 201. After enabling root-only registration and recreating the stack, an additional self-registration must fail with the console-account-limit error. A duplicate email or password-validation error does not establish that restriction. Test email and IP allowlists independently on a fresh private fixture: allowed bootstrap succeeds; an excluded email or source IP receives its corresponding allowlist error. The registration block's exit status classifies only the root-only comparison: in the allowlist comparison an allowed bootstrap's 201 is the positive control, so there its FINDING line and status 1 show that control succeeding. Existing operator login and intended invitation acceptance must still work. See the [registration handler](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Create.php#L109-L134). **REASONED:** Requires an unavailable fresh PocketBase fixture. Submit a valid test-superuser creation body to `POST /api/collections/_superusers/records`: a guest and an ordinary user's token must not create a superuser. On the private bootstrap fixture, the valid installer token must permit creating the operator. Confirm ordinary application-user registration never grants that privilege. After bootstrap, use a clean browser to inspect the deployed dashboard and exercise its API authorization; loading static assets is not evidence of administrative access. See the [installer](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/installer.go?rev=refs/tags/v0.41.1) and [record access checks](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/record_crud.go?rev=refs/tags/v0.41.1). ### 3. Exercise the remaining controls REASONED: safe fixture and credential-transport procedure for the remaining-control comparisons; a live PocketBase or Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Controlled service accounts, protected request files, and SDK fixtures for these live mutations are unavailable. The GET block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output; under bash -u, written and embedded placeholders, example.com, angle brackets, empty values, omitted or shortened set -- lines, HTTP, query, fragment, userinfo, and a control character printed refusal messages without invoking the curl stub. A valid fixture printed CURL_STUB arguments including --header @- and STDIN Authorization: DUMMY_TOKEN; the dummy header did not occur in those arguments. The bash -n, ShellCheck and stub runs recorded here covered the GET block in an earlier form, whose guard refusals left the subshell with status 0, and the stub runs were not repeated on the current form, whose guard refusals leave the subshell with status 2 and which exits 3 when curl fails; the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover it. Public record actions should admit guests; locked or restrictive rules should reject unauthorized mutations while intended users succeed, per the guide’s cited PocketBase v0.41.1 record CRUD handlers and curl curl-8_14_1 headers from stdin (checked October 2026). Each comparison below requires the unavailable live fixture. Perform mutations only on disposable records and functions. Send credentials and request bodies through protected files or the SDK, never command arguments. | Control | Required comparison | | --- | --- | | Appwrite console MFA | **REASONED:** Requires an unavailable live Appwrite console fixture and controlled operator account with a TOTP authenticator. Using the operator's session and `X-Appwrite-Project: console`, request `GET /v1/account` without an API key. With MFA disabled, password authentication permits this request. Enable MFA and verify the TOTP factor, then create a fresh password-only session: the same request must fail. As source reading of 2.7.0, the middleware throws `Exception::USER_MORE_FACTORS_REQUIRED` when a session holds fewer factors than the user's minimum, on routes outside its `mfa` group, and `GET /v1/account` is in the `api` and `account` groups. The error type string and status this produces are not established at 2.7.0, so record what the response returns. Complete the TOTP challenge in that session with a code not already redeemed, since 2.7.0 TOTP codes are single-use, and repeat the request: expect 200 with the operator's account data. See [console MFA](https://appwrite.io/docs/advanced/security/mfa), [MFA enforcement](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php), [account GET route groups](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Get.php#L27-L30) and [single-use TOTP steps](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/MFA/Challenge/TOTP.php#L30-L88). | | PocketBase writes and management | **REASONED:** Exercise `POST /api/collections/REPLACE_WITH_COLLECTION/records`, then `PATCH` and `DELETE` on disposable record URLs, using valid bodies. Public rules expose the actions; locked or restrictive rules must deny unauthorized changes while intended users still succeed. Separately attempt changing another auth record's email, password, and `verified` field: locked `manageRule` must not grant privileged management; an explicitly authorized manager must succeed only where intended. Confirm collection configuration rejects `manageRule=""`. See [CRUD handlers](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/record_crud.go?rev=refs/tags/v0.41.1) and [management validation](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1). | | PocketBase protected files | **REASONED:** GET a known fixture file at `/api/files/REPLACE_WITH_COLLECTION/REPLACE_WITH_RECORD/REPLACE_WITH_FILENAME`. Before protection, its unsigned URL downloads despite locked record access. Enable Protected and a `viewRule` that permits the intended ordinary user but denies guests and an unrelated ordinary user. File-token issuance does not check access to a particular file: authenticated users can obtain tokens. Confirm the unrelated user's `pb.files.getToken()` succeeds, then GET the protected fixture using that user's token-bearing SDK URL: expect 404. The permitted user's `pb.files.getToken()` must also succeed, and GET with that user's token-bearing SDK URL must return the expected fixture bytes as a positive control. Separately, an unsigned guest GET of the same protected file must return 404. Use the SDK for signed URLs, or feed a protected curl configuration through stdin; that keeps the token-bearing URL out of curl's argv only. Prepare the file with an editor, not a shell command that contains the URL, which would put it in shell history and `set -x` tracing, and the account owner and root can still read the file and curl's memory. See [download checks](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/file.go?rev=refs/tags/v0.41.1). | | PocketBase realtime | **REASONED:** Establish `GET /api/realtime`, then `POST /api/realtime` subscriptions for both `REPLACE_WITH_COLLECTION/*` and a specific fixture record. Change that record through an authorized writer. Public rules expose events; restrictive list/view rules must suppress unauthorized delivery while an authorized subscriber receives the same mutation. A `PB_CONNECT` event or subscription acknowledgement is insufficient. See [Realtime API](https://pocketbase.io/docs/api-realtime/). | | PocketBase MFA, verification, and tokens | **REASONED:** Use `POST /api/collections/_superusers/auth-with-password`, followed by the OTP flow through `/request-otp` and `/auth-with-otp`. Without MFA, password authentication completes; with password, OTP, and MFA enabled, the first factor must not complete authentication, while the correct second factor does. For application users, compare password login for otherwise valid verified and unverified accounts with `authRule="verified = true"`. Retain a test token privately, clear the SDK auth store, and retry an allowed record request with the retained token: clearing local state alone must not revoke it. See [authentication flows](https://pocketbase.io/docs/authentication/) and [authRule option](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1). | | PocketBase IP and proxy boundary | **REASONED:** Repeat an authorized superuser resource GET from allowed and excluded source addresses. With no allowlist both can work; with the allowlist the excluded address must receive 403. Forged forwarded-IP headers must not change that result through the proxy. From a second host, test the actual backend address and port as well as the proxy: direct backend access must be blocked, while the permitted proxy route works. If the UI is private, separately test GET `/_/` from both networks. See [IP enforcement](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares.go?rev=refs/tags/v0.41.1) and [proxy settings](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1). | | TLS, origins, and hostnames | **REASONED:** Request a known API resource over HTTP and HTTPS, and inspect the HTTPS certificate. Fixed deployments must redirect or reject HTTP while HTTPS remains usable. Test Appwrite function/site domains separately. Send the same Appwrite request with an unconfigured `Host`: disabled router protection can serve it; enabled protection must reject it while the configured host works. For PocketBase, compare browser requests from an allowed and disallowed origin; then repeat outside the browser to confirm CORS has not replaced record authorization. See [PocketBase serve flags](https://ganigeorgiev.com/pocketbase/pocketbase/files/cmd/serve.go?rev=refs/tags/v0.41.1) and [Appwrite routing](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/general.php). | | Abuse and email delivery | **REASONED:** From a non-excluded ordinary client, send a bounded sequence of valid PocketBase authentication requests across the configured `*:auth` threshold. Disabled limiting should not produce limiter denials; enabled limiting should produce 429, with normal requests succeeding after the window. For Appwrite, cross a documented client endpoint's threshold without an API key or privileged session and compare disabled/enabled abuse protection. Separately request OTP, verification, and recovery messages and confirm receipt and successful use; a successful request alone does not prove delivery. See [PocketBase limiter](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares_rate_limit.go?rev=refs/tags/v0.41.1), [Appwrite limits](https://appwrite.io/docs/advanced/security/rate-limits), and [email delivery](https://appwrite.io/docs/advanced/self-hosting/configuration/email). | | Settings encryption and recovery | **REASONED:** On a private PocketBase fixture, save a disposable SMTP credential and inspect persisted settings before and after selecting the encryption environment; plaintext settings must become encrypted, and a restart with the retained key must recover them. Create and restore a backup in a separate fixture, checking local uploads and separately restored S3 objects. Request `/api/backups/REPLACE_WITH_BACKUP_KEY` with no token, an ordinary-user file token, and a superuser file token: only the permitted superuser case should download. For Appwrite, restore disposable encrypted data with the original key; a separate wrong-key fixture must not recover those encrypted values. See [settings persistence](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1), [backup authorization](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/backup.go?rev=refs/tags/v0.41.1), and [Appwrite restoration](https://appwrite.io/docs/advanced/self-hosting/production/backups). | | Appwrite uploads and functions | **REASONED:** Upload harmless fixtures through `POST /v1/storage/buckets/REPLACE_WITH_BUCKET/files`, comparing permitted and unrelated users. Test files at the chosen maximum and one byte above it. With a mandatory encryption/scanning policy capped at 20,000,000 bytes, larger uploads must be rejected; accepted fixtures must show the intended encryption and scanner processing. Invoke a harmless function through `POST /v1/functions/REPLACE_WITH_FUNCTION/executions`: intended execute roles succeed, unrelated users fail, and an authorized server key remains a separate privileged case even when the function is disabled. From the second host, confirm executor/orchestrator ports are inaccessible. See [bucket controls](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php), [function controls](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php), and [service networks](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml). | ### 4. Check the client bundle for privileged credentials **REASONED:** No deployed client bundle or credential inventory was supplied. Confirm that the client carries only an end-user session or auth token, never a PocketBase superuser token or an Appwrite API key of any scope. Appwrite API keys are server credentials regardless of scope; client code should authenticate with an end-user session from the Client SDK instead. See [PocketBase superusers](https://pocketbase.io/docs/authentication/) and [Appwrite API keys](https://appwrite.io/docs/partners/project/api-keys). REASONED: exact-match scan procedure; no deployed client bundle or credential inventory is available, so deployed absence remains unobserved. The scan block passed bash -n and ShellCheck 0.11.0 with exit 0 and no output. Its guards printed refusal messages for written and embedded placeholders, example.com, angle brackets, empty values, and omitted or shortened set -- lines without invoking the grep stub. Stub exits 0, 1, and 2 printed respectively FAIL: credential matches in the files listed above; No exact matches; also inspect build inputs and source maps.; and Scan failed; no conclusion. The bash -n, ShellCheck and stub runs recorded here covered the scan block in an earlier form, whose guard refusals left the subshell with status 0 and whose result arms ended with status 0; none was re-run on the current form, whose guard refusals leave the subshell with status 2, whose FAIL arm exits 1 and whose scan-failure arm exits 3. Separately, real grep -r -F -l -f on a disposable marker fixture exited 0 and printed only the matching client.js filename; after marker removal it exited 1 with no output. Expected exposed bundles contain privileged credentials and fixed bundles do not, per the guide’s cited PocketBase authentication (rolling documentation, checked November 2026) and Appwrite project API keys (rolling documentation, checked November 2026); exact-match fixtures do not establish detection of encoded or split credentials. Grep the client bundle for the strings used by your admin credentials or API keys; they should not appear. Prepare a protected, nonempty patterns file containing one exact credential per line, outside the bundle directory. The command prints matching filenames, not secret-bearing lines. Establish a positive control using a disposable dummy credential in a test bundle, then remove it and repeat. Exact matching does not detect every encoded or split representation. REASONED: following block; privileged-credential scanning follows the cited authentication/API-key documentation; no deployed client bundle or credential inventory was supplied. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_PROTECTED_SECRET_PATTERNS_FILE' 'REPLACE_WITH_CLIENT_BUNDLE_DIRECTORY' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not scanning"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not scanning"; exit 2; } case "$1" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the protected patterns file path; not scanning"; exit 2 ;; *) case "$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example.com*|"") echo "substitute the client bundle directory; not scanning"; exit 2 ;; *) if grep -r -F -l -f "$1" -- "$2"; then echo "FAIL: credential matches in the files listed above"; exit 1 else case "$?" in 1) echo "No exact matches; also inspect build inputs and source maps." ;; *) echo "Scan failed; no conclusion."; exit 3 ;; esac fi ;; esac ;; esac ) ``` ### Verification status and source work REASONED: authoring-time shell checks of an earlier form of the three bash blocks; no live service or deployed bundle was available, and the current forms, in which the GET and registration blocks also set their exit status from their request, are covered by the repository gate suite's bash -n and ShellCheck 0.11.0 passes. All three bash blocks passed ShellCheck 0.11.0 and `bash -n` during authoring. Local guard tests refused embedded `REPLACE_WITH_` placeholders, `example.com`, angle brackets, empty arguments, and omitted or shortened `set --` lines. These checks establish shell behavior only. No live service result or deployed-bundle scan is claimed. | Check scope or source-work ID | Procedure and prerequisites | Status | | --- | --- | --- | | Service comparisons | Demonstrate every REASONED comparison above on PocketBase v0.41.1 and Appwrite 2.7.0, recording binary/image identity, commands, response bodies, denied and allowed identities, disposable-data cleanup, and effective settings. Requires writable service fixtures, a container runtime, controlled accounts and mailboxes, S3 and backup fixtures, ClamAV, function execution, a second-host ingress fixture, and the actual client build. Include all five PocketBase actions, manageRule, file-token issuance versus protected downloads (unrelated-user and unsigned-guest denial, permitted-user bytes), realtime, bootstrap-token handling, MFA, verification, retained-token behavior, additive Appwrite permissions (items, part 1 of 2). The service-comparison items (part 2 of 2): server-key scopes, registration, console MFA (fresh password-only denial and successful TOTP completion), TLS, proxy trust, limits, encrypted recovery, uploads, and executor privacy. | REASONED: full service-comparison inventory; a live PocketBase or Appwrite stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. This comparison still requires the writable service, container, account, mailbox, S3, backup, scanner, function, second-host ingress, and actual-build prerequisites named in this row. All three Verify shell blocks passed bash -n and ShellCheck 0.11.0 with exit 0 and no output. With curl and grep stubbed, written and embedded placeholders, example.com, angle brackets, empty values, and omitted or shortened set -- lines printed their refusal messages without invoking either stub. The signup JSON parsed as four expected string fields; valid curl stubs printed arguments and protected input separately. The grep stub exercised match, no-match, and error branches; real grep on a disposable marker printed only client.js with exit 0 and, after removal, no output with exit 1. None establishes the listed live outcomes. The bash -n, ShellCheck and stub runs recorded here covered all three Verify shell blocks in an earlier form, whose guard refusals left the subshell with status 0 and whose scan block result arms ended with status 0, and the stub runs were not repeated on the current forms. In the current forms guard refusals leave the subshell with status 2, the GET and registration blocks exit 3 when curl fails, the registration block exits 1 when the registration returns 201, the scan block's FAIL and scan-failure arms exit 1 and 3, and the repository gate suite's bash -n and ShellCheck 0.11.0 passes cover all three blocks. Exposed public authorization should admit unintended access and fixed authorization should deny or filter it while retaining permitted controls; the other expected outcomes remain those in the individual comparisons, per the guide’s cited sources, which this cell lists in six parts. The guide’s cited sources (1 of 6): PocketBase API rules and filters (rolling documentation, checked November 2026); Appwrite legacy collection/document permissions (rolling documentation, checked November 2026); Appwrite 2.7.0 console registration handler; PocketBase v0.41.1 auth defaults, manageRule validation, and MFA. The guide’s cited sources (2 of 6): PocketBase v0.41.1 file-token and download authorization; PocketBase v0.41.1 realtime authorization; PocketBase v0.41.1 authentication and superuser IP middleware; PocketBase v0.41.1 settings, encryption, proxy trust, SMTP, backups, and rate defaults; PocketBase v0.41.1 backup download authorization. The guide’s cited sources (3 of 6): PocketBase v0.41.1 rate limiter and exemptions; PocketBase v0.41.1 listener and origin flags; Appwrite 2.7.0 API-key authorization, scopes, MFA enforcement, and abuse exemptions; Appwrite 2.7.0 hostname routing and HTTPS enforcement; Appwrite email delivery (rolling documentation, checked November 2026). The guide’s cited sources (4 of 6): Appwrite self-hosted backups and encryption-key preservation (rolling documentation, checked November 2026); Appwrite 2.7.0 bucket defaults and upload controls; Appwrite 2.7.0 function execute roles, execution-key scopes, and enablement; Appwrite 2.7.0 Compose console URL, persistent volumes, and Docker socket mounts. The guide’s cited sources (5 of 6): PocketBase authentication (rolling documentation, checked November 2026); Appwrite project API keys (rolling documentation, checked November 2026); PocketBase v0.41.1 installer and bootstrap-token lifetime; PocketBase v0.41.1 record CRUD handlers; PocketBase v0.41.1 record authorization and token validation. The guide’s cited sources (6 of 6): PocketBase v0.41.1 native TLS and ACME listener; Appwrite console MFA enrollment and recovery codes (rolling documentation, checked November 2026); Appwrite rate limits (rolling documentation, checked November 2026); and Appwrite permissions, creation defaults, and server integrations (rolling documentation, checked November 2026). REASONED from the cited vendor documentation and available pinned sources; live behavior is not demonstrated. | | SELFHOSTED-BACKEND-SOURCE-1 | Complete pin-specific tracing for the Appwrite document read-authorization internals. The PocketBase CLI wrapper, the `--dev` flag default, the Appwrite requester privileged/API-key classification, and the Appwrite-repository document-read call sites are now cited above, alongside the earlier collection default, document-create permission branch, and backup archive pins. With the move to Appwrite 2.7.0 and utopia-php/database 10.1.0, the pinned dependency cites permission-role extraction and matching and authorization status/skip behavior. The SQL base used by MariaDB, MySQL, Postgres and SQLite is traced: without joins `getDocument` adds no permission filter, while `find` adds the main collection's document-permission hook only with authorization enabled and `documentSecurity` on, or for the metadata collection, unless a join or bounded-page path finds the collection granted; the Postgres override is described above. With authorization enabled, `find` also adds a `Permission\Join` hook for each joined table that the collection's join document-security map does not disable. Memory, Redis and Pool `getDocument`/`find` and Mongo `getDocument` are traced as described above, as are Appwrite 2.7.0's `_APP_DB_ADAPTER` default, pool scheme mapping, factory Pool wrapping on the document GET's non-transaction retrieval path and upgrade engine detection. The 2.7.0 install task's command-line selection, existing-database detection and container start, the document GET's transaction-state branches, the web installer's database selection, the Compose generator's database handling, the `Appwrite\Docker\Compose` and `Env` representation of a value-less entry and the in-tree `System::getEnv` empty-value reading are traced as described above too. Still open at 10.1.0: the Database-level `getDocument` and `find` read checks and `skip` dispatch in `Trait\Documents`, the Mongo `find` permission hook, `Storage::permissionsTable()` and where `Database::COLLECTION_GRANTED` and `Database::JOIN_DOCUMENT_SECURITY` are set. Still open at 2.7.0: which adapter a given deployment uses, how Compose resolves the value-less `_APP_DB_ADAPTER` entry, whether a `transactions` document's permissions limit an ordinary requester to its creator, the handler's `stampTransactionRead` and `isTransactionTypeMismatch` helpers, the `Metadata` and `Joins` hooks, and whether 2.7.0 loads the in-tree `Utopia\System\System`. | REASONED: pinned-source reading of PocketBase v0.41.1, Appwrite 2.7.0 and the utopia-php/database 10.1.0 excerpts above, with the Appwrite 2.3.0 to 2.7.0 release notes; the Database-level, Mongo find, deployment-adapter, Compose-resolution and remaining transaction-state gaps stay open, complete pinned-source verification is not claimed, and live authorization is not demonstrated. | ## Sources (checked November 2026) The November 2026 date in this heading is the guide-wide documentation check, which covers every rolling documentation item: the 9 Appwrite rolling documentation items were rechecked as served on 2026-11-04 and the 5 PocketBase pages as served on 2026-11-05. Items pinned to a release or commit carry no check date, except those marked "checked October 2026" or "checked November 2026", which were checked individually, and some pinned items were first cited in October or November 2026. The PocketBase source items cite its author's forge at the v0.41.1 tag, as `rev=refs/tags/v0.41.1`, because PocketBase moved its source and releases off GitHub at v0.41.0. The cited files and line ranges were read in the v0.41.1 Go module from proxy.golang.org, whose recorded origin is the forge's v0.41.1 tag at commit f70b24e186df98df2e7ebc703f103cb94dc3ca63, and whose zip and `go.mod` match their sum.golang.org h1 hashes, recomputed on 2026-11-06. Each of the 16 cited files in that module is byte-identical to the copy the forge's API served for `refs/tags/v0.41.1` on 2026-11-06. As corroboration of the tag's commit, a commit list the forge's API returned that day starts with f70b24e1, but its request was not recorded, so that the API names f70b24e1 as the tag's newest commit is this guide's inference. The forge's app script loads a file page's text in the browser from that API, passing on the `rev` value that its router decodes from the page's query. That the router passes `refs/tags/v0.41.1` on unchanged is this guide's inference, because the router script was not read. On 2026-11-06 the API answered a request for `pocketbase.go` at `rev=v0.41.1` with HTTP 400, "Invalid or missing revision.", and served it at `rev=refs/tags/v0.41.1`, encoded or not, with the module copy's size, so the links use that form. That each page shows its file's text is this guide's inference: no page in this form was requested or viewed in a browser, and the page's own requests to the API were not made. The forge's line-anchor format is not established, so line ranges stay in the link text: the file view marks its code for Prism's linkable line numbers, but the Prism script that reads an anchor was not read. The v0.41.0 release notes were read in the forge's public releases collection, in record `7vo1mkjw049veng` with tag v0.41.0. The release-notes link below already names the release by its tag, in the form the forge's releases page builds as each release's own link. That the page lists the repository's releases matching its decoded `expr` value, here `tag="v0.41.0"`, is this guide's inference, because the router script that decodes the query was not read. That it shows those notes is this guide's inference too: the page was not viewed in a browser, and that the record's release channel belongs to this repository was not established. - PocketBase going to production (rolling documentation, checked November 2026): https://pocketbase.io/docs/going-to-production/ - PocketBase API rules and filters (rolling documentation, checked November 2026): https://pocketbase.io/docs/api-rules-and-filters/ - Appwrite self-hosting production security (rolling documentation, checked November 2026): https://appwrite.io/docs/advanced/self-hosting/production/security - Appwrite project API keys (rolling documentation, checked November 2026): https://appwrite.io/docs/partners/project/api-keys - [PocketBase authentication (rolling documentation, checked November 2026)](https://pocketbase.io/docs/authentication/). - [PocketBase files and protected downloads (rolling documentation, checked November 2026)](https://pocketbase.io/docs/files-handling/). - [PocketBase Realtime API (rolling documentation, checked November 2026)](https://pocketbase.io/docs/api-realtime/). - [PocketBase v0.41.1 installer and bootstrap-token lifetime (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/installer.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 listener and origin flags (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/cmd/serve.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 native TLS and ACME listener (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/serve.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 authentication and superuser IP middleware (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 record authorization and token validation (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/record_query.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 record CRUD handlers (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/record_crud.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 auth defaults, manageRule validation, and MFA (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 settings, encryption, proxy trust, SMTP, backups, and rate defaults (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 development logging and mailer selection (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 file-token and download authorization (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/file.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 realtime authorization (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/realtime.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 backup download authorization (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/backup.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 backup archive implementation, lines 36 to 60 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/backup_create.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 rate limiter and exemptions (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares_rate_limit.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.0 release notes on the move to the new host, read for the v0.41.1 basis (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/releases?expr=tag%3D%22v0.41.0%22). - [Appwrite 2.7.0 collection create parameters (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php#L88-L90). - [Appwrite 2.7.0 document create parameters (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L134-L139). - [Appwrite 2.7.0 requester classification (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L217-L218). - [Appwrite 2.7.0 document create permission handling (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L238-L288). - [Appwrite 2.7.0 composer.lock pinned database dependency, utopia-php/database 10.1.0 (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/composer.lock#L4429-L4439). - [utopia-php/database 10.1.0 aggregate helper (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Permission.php#L176-L180). - [utopia-php/database 10.1.0 permission role extraction (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Document.php#L273-L334). - [utopia-php/database 10.1.0 document attribute default (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Document.php#L405-L416). - [utopia-php/database 10.1.0 authorization role matching (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L59-L102). - [utopia-php/database 10.1.0 Database traits, including the unread Documents trait (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L52-L61). - [utopia-php/database 10.1.0 shared authorization instance (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L1033-L1039). - [utopia-php/database 10.1.0 related-document grant checking (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L1162-L1183). - [utopia-php/database 10.1.0 authorization status and skip (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L148-L208). - [utopia-php/database 10.1.0 shared status scope and constructor default (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L10-L41). - [utopia-php/database 10.1.0 SQL document fetch (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L650-L779). - [utopia-php/database 10.1.0 SQL find roles and read default (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L1566-L1572). - [utopia-php/database 10.1.0 SQL find permission gate on status, grant and documentSecurity (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5063-L5079). - [utopia-php/database 10.1.0 SQL joined-table permission hooks (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5081-L5112). - [utopia-php/database 10.1.0 SQL per-document filtering test (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5179-L5183). - [utopia-php/database 10.1.0 SQL joined-table document-security test (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5185-L5213). - [utopia-php/database 10.1.0 SQL bounded-page permission gate (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L6080-L6086). - [utopia-php/database 10.1.0 SQL permission hook construction (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L3658-L3678). - [utopia-php/database 10.1.0 SQL base permission filter (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL/Hook/Permission/Filter.php#L72-L116). - [utopia-php/database 10.1.0 Postgres permission filter (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Postgres.php#L1909-L1958). - [utopia-php/database 10.1.0 MariaDB SQL inheritance (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/MariaDB.php#L46). - [utopia-php/database 10.1.0 MySQL MariaDB inheritance (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/MySQL.php#L25). - [utopia-php/database 10.1.0 Postgres SQL inheritance (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Postgres.php#L53). - [utopia-php/database 10.1.0 SQLite SQL inheritance (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQLite.php#L71). - [utopia-php/database 10.1.0 SQLite find wrapper (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQLite.php#L249-L259). - [utopia-php/database 10.1.0 Mongo adapter declaration (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L57). - [utopia-php/database 10.1.0 Mongo document fetch (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L1561-L1603). - [utopia-php/database 10.1.0 Mongo tenant and permission read hooks (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L249-L282). - [utopia-php/database 10.1.0 Mongo find filters (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L2402-L2413). - [utopia-php/database 10.1.0 Pool adapter declaration and delegation (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L36-L138). - [utopia-php/database 10.1.0 Pool state sync (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L178-L186). - [utopia-php/database 10.1.0 Pool transaction pinning (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L517-L541). - [utopia-php/database 10.1.0 Pool document read delegation (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L802-L807). - [utopia-php/database 10.1.0 Pool find delegation (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L884-L889). - [utopia-php/database 10.1.0 Pool authorization setter (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L1101-L1106). - [utopia-php/database 10.1.0 Memory namespace and Adapter import (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L3-L5). - [utopia-php/database 10.1.0 Memory adapter purpose and declaration (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L36-L48). - [utopia-php/database 10.1.0 Memory document fetch (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L1204-L1227). - [utopia-php/database 10.1.0 Memory find (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L1807-L1836). - [utopia-php/database 10.1.0 Memory find tenant and permission filter (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L2397-L2452). - [utopia-php/database 10.1.0 Memory permission allow set (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L3108-L3141). - [utopia-php/database 10.1.0 Redis namespace and Adapter import (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L5-L8). - [utopia-php/database 10.1.0 Redis adapter purpose and declaration (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L41-L57). - [utopia-php/database 10.1.0 Redis permission filter (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L1938-L1973). - [utopia-php/database 10.1.0 Redis document fetch and tenant check (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L882-L916). - [utopia-php/database 10.1.0 Redis find (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L1482-L1519). - [utopia-php/database 10.1.0 Redis find permission step (checked November 2026)](https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L2809-L2823). - [Appwrite permissions, creation defaults, and server integrations (rolling documentation, checked November 2026)](https://appwrite.io/docs/advanced/security/permissions). - [Appwrite legacy collection/document permissions (rolling documentation, checked November 2026)](https://appwrite.io/docs/products/databases/tablesdb/legacy/permissions). - [Appwrite environment reference, HTTPS deprecation, database selector, console URL and key variables (rolling documentation, checked November 2026)](https://appwrite.io/docs/advanced/self-hosting/configuration/environment-variables). - [Appwrite rate limits (rolling documentation, checked November 2026)](https://appwrite.io/docs/advanced/security/rate-limits). - [Appwrite email delivery (rolling documentation, checked November 2026)](https://appwrite.io/docs/advanced/self-hosting/configuration/email). - [Appwrite self-hosted backups and encryption-key preservation (rolling documentation, checked November 2026)](https://appwrite.io/docs/advanced/self-hosting/production/backups). - [Appwrite 2.7.0 configuration defaults, console URL, key versions and executor secret (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php). - [Appwrite 2.7.0 development environment and placeholder secrets (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/.env). - [Appwrite 2.7.0 console registration handler (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Create.php#L109-L134). - [Appwrite console MFA enrollment and recovery codes (rolling documentation, checked November 2026)](https://appwrite.io/docs/advanced/security/mfa). - [Appwrite 2.7.0 API-key authorization, scopes, MFA enforcement, and abuse exemptions (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php). - [Appwrite 2.7.0 MFA minimum factors (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L211-L218). - [Appwrite 2.7.0 account GET route groups (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Get.php#L27-L30). - [Appwrite 2.7.0 recent MFA challenge window (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L230-L244). - [Appwrite 2.7.0 MFA_RECENT_DURATION constant (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/constants.php#L266). - [Appwrite 2.7.0 MFA turn-off challenge check (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php#L81-L84). - [Appwrite 2.7.0 MFA challenge TOTP spend (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php#L165-L177). - [Appwrite 2.7.0 single-use TOTP steps (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/MFA/Challenge/TOTP.php#L30-L88). - [Appwrite 2.7.0 encryption key resolution and versions (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/EncryptionKey.php#L11-L69). - [Appwrite 2.7.0 jobs secret resolution (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/JobsSecret.php#L20-L32). - [Appwrite 2.7.0 install task secret generation condition (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L331). - [Appwrite 2.3.0 release notes on the console URL and packages, read for the 2.7.0 basis (checked November 2026)](https://github.com/appwrite/appwrite/releases/tag/2.3.0). - [Appwrite 2.4.0 release notes on the generated encryption key, read for the 2.7.0 basis (checked November 2026)](https://github.com/appwrite/appwrite/releases/tag/2.4.0). - [Appwrite 2.6.2 release notes on key scope limits and the generated executor secret, read for the 2.7.0 basis (checked November 2026)](https://github.com/appwrite/appwrite/releases/tag/2.6.2). - [Appwrite 2.7.0 release notes on key rotation, MFA, public scope and function scopes (checked November 2026)](https://github.com/appwrite/appwrite/releases/tag/2.7.0). - [Appwrite 2.7.0 hostname routing and HTTPS enforcement (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/general.php). - [Appwrite 2.7.0 bucket defaults and upload controls (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php). - [Appwrite 2.7.0 function execute roles, execution-key scopes, enablement and scope grants (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php). - [Appwrite 2.7.0 storage thresholds and release constant (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/init/constants.php). - [Appwrite 2.7.0 Compose console URL, persistent volumes, and Docker socket mounts (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml). - [PocketBase v0.41.1 CLI wrapper and development flag wiring, lines 234 to 239 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 dev default selection, lines 73 to 79 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 flag-to-app dev wiring, lines 129 to 139 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 root command wiring for serve and superuser, lines 165 to 173 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 go run detection, lines 10 to 19 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/tools/osutils/run.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 dev SQL logging, lines 1277 to 1286 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1). - [PocketBase v0.41.1 dev logger level, lines 1489 to 1499 (checked November 2026)](https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1). - [Appwrite 2.7.0 document read handler and requester checks (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L82-L97). - [Appwrite 2.7.0 document fetch, through the database layer without a transaction ID and through transaction state with one (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L105-L132). - [Appwrite 2.7.0 privileged and key role classification (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L82-L116). - [Appwrite 2.7.0 API-key role, scopes, and authorization status (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L146-L167). - [Appwrite 2.7.0 admin-mode roles and authorization status (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L328-L361). - [Appwrite 2.7.0 request role assignment (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L396-L400). - [Appwrite 2.7.0 route scope validation (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L557-L561). - [Appwrite 2.7.0 collection permission and documentSecurity handoff (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php#L230-L236). - [Appwrite 2.7.0 database pool DSN fallbacks and connections (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/registers.php#L45-L99). - [Appwrite 2.7.0 database adapter mapping (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/registers.php#L239-L252). - [Appwrite 2.7.0 tenant database construction, authorization and grant checking (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L123-L201). - [Appwrite 2.7.0 database factory Pool wrapping (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L208-L211). - [Appwrite 2.7.0 database factory Pool import (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L12). - [Appwrite 2.7.0 getDatabasesDB request resource (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L1112-L1159). - [Appwrite 2.7.0 document GET transaction branch (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L109-L124). - [Appwrite 2.7.0 document GET not-found response for an empty document (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L134-L136). - [Appwrite 2.7.0 transaction-state staged creation return (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L92-L94). - [Appwrite 2.7.0 upgrade database engine detection (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Upgrade.php#L73-L98). - [Appwrite 2.7.0 install task Compose template read (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L563-L568). - [Appwrite 2.7.0 install task Compose template rendering (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L664-L671). - [Appwrite 2.7.0 console variables database adapter fallback (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php#L118). - [Appwrite 2.7.0 console variables response model database adapter rule (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php#L130-L138). - [Appwrite 2.7.0 install task database parameter default (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L81). - [Appwrite 2.7.0 install task detected existing database override (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L234-L239). - [Appwrite 2.7.0 install task interactive database answer (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L299-L307). - [Appwrite 2.7.0 install task database adapter assignment (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L318-L319). - [Appwrite 2.7.0 install task existing-configuration check (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L550-L555). - [Appwrite 2.7.0 install task Compose and environment file writes (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L708-L721). - [Appwrite 2.7.0 install environment file template (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/views/install/env.phtml). - [Appwrite 2.7.0 transaction-state document branches (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L70-L108). - [Appwrite 2.7.0 transaction read by requester class and log reads under skip (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L675-L706). - [Appwrite 2.7.0 transaction-state request resource (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L1161-L1165). - [Appwrite 2.7.0 database factory authorization resource (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L234-L238). - [Appwrite 2.7.0 upgrade hand-off to the install task (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Upgrade.php#L108-L110). - [Appwrite 2.7.0 install task existing database detection order (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L214-L233). - [Appwrite 2.7.0 install task database service and host detection (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1709-L1728). - [Appwrite 2.7.0 install task web-installer branch (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L253-L262). - [Appwrite 2.7.0 install task explicit command-line parameter check (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1694-L1703). - [Appwrite 2.7.0 install task container start condition (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L767-L773). - [Appwrite 2.7.0 install task Compose start (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1316-L1377). - [Appwrite 2.7.0 web installer database default and lock (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/templates/steps/step-1.phtml#L11-L20). - [Appwrite 2.7.0 web installer database choices (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/templates/steps/step-1.phtml#L66-L108). - [Appwrite 2.7.0 web installer default database variable (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer.phtml#L12). - [Appwrite 2.7.0 web installer view database settings (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/View.php#L42-L48). - [Appwrite 2.7.0 web installer posted database fallback (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/js/modules/progress.js#L408). - [Appwrite 2.7.0 web installer database parameter (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L46). - [Appwrite 2.7.0 web installer database validation (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L150-L161). - [Appwrite 2.7.0 web installer supported database values (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Runtime/State.php#L204-L206). - [Appwrite 2.7.0 web installer database adapter input (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L238-L246). - [Appwrite 2.7.0 web installer resumed progress check (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L202-L225). - [Appwrite 2.7.0 web installer resumed payload comparison (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L248-L285). - [Appwrite 2.7.0 web installer resumed database input (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L313). - [Appwrite 2.7.0 web installer environment preparation (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L320-L322). - [Appwrite 2.7.0 web installer installation call (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L380-L394). - [Appwrite 2.7.0 install task web installer configuration (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L337-L374). - [Appwrite 2.7.0 install task existing-installation defaults (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L156-L191). - [Appwrite 2.7.0 install task user input, generated secrets and database host (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L440-L473). - [Appwrite 2.7.0 installer server upgrade and database lock detection (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Server.php#L225-L257). - [Appwrite 2.7.0 installer server database detection from files (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Server.php#L277-L315). - [Appwrite 2.7.0 Compose generator, read whole (checked November 2026)](https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Docker/Compose/Generator.php). - [Appwrite 2.7.0 Compose generator selectable database services and volumes (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L9-L26). - [Appwrite 2.7.0 Compose generator database dependency selector (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L71-L81). - [Appwrite 2.7.0 Compose generator parameter normalization (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L145-L153). - [Appwrite 2.7.0 Compose generator service filter (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L188-L210). - [Appwrite 2.7.0 Compose generator volume filter (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L263-L278). - [Appwrite 2.7.0 Compose generator dependency rewrite (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L283-L333). - [Appwrite 2.7.0 Compose services (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose.php#L16-L27). - [Appwrite 2.7.0 Compose service environment parsing (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Service.php#L32-L33). - [Appwrite 2.7.0 environment parser lines without an assignment (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Env.php#L37-L42). - [Appwrite 2.7.0 in-tree packages/system getEnv empty-value fallback (checked November 2026)](https://github.com/appwrite/appwrite/blob/2.7.0/packages/system/src/System.php#L703-L706). - [curl curl-8_14_1 write-out fields and version requirements (checked October 2026)](https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79). - [curl curl-8_14_1 headers from stdin (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md). - [curl curl-8_14_1 configuration from stdin (checked October 2026)](https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md). ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - pb: PocketBase (v0.41.1) - appwrite: Appwrite (2.7.0) - pb-docs: PocketBase documentation (rolling) (unknown) - appwrite-docs: Appwrite documentation (rolling) (unknown) - utopia-php-database: utopia-php/database (10.1.0) - curl: curl (curl-8_14_1) Claims (full source mapping in the JSON block below): - pb-bootstrap (REASONED; pb, pb-docs): Privately bootstrap through the logged installer URL; superuser create EMAIL PASS exposes passwords in argv. Ordinary registration does not grant superuser rights. - pb-installer (REASONED; pb): Installer system-superuser token lasts about 30 minutes; protect its URL and startup logs as credentials. - pb-ips (REASONED; pb, pb-docs): Superuser IP allowlists are available from v0.38.0; superuserIPs defaults empty and restricts authenticated requests, not /_/ static assets. - pb-tls (REASONED; pb): serve with a domain provides native HTTPS/ACME; alternatively terminate TLS at a reverse proxy. - pb-bind (REASONED; pb): Without a domain serve defaults to 127.0.0.1:8090; with a domain it uses 0.0.0.0:80/443. Behind a local proxy omit the domain and set --http loopback. - pb-origins (REASONED; pb): --origins defaults * and accepts comma-separated allowed origins; CORS does not authorize records. - pb-proxy (REASONED; pb): Trust only headers the proxy overwrites, review useLeftmostIP ordering and block direct backend access to protect IP/rate controls. - pb-dev (REASONED; pb): --dev adds diagnostics including SQL on stderr, not an auth bypass; its configured default comes from New(): true if os.Args[0] starts with a nonempty os.TempDir() or cacheDir() prefix, false otherwise. - pb-cli (REASONED; pb): In the pinned CLI wrapper, Start() registers the serve and superuser commands on the root command and executes it. - pb-rules (REASONED; pb, pb-docs): Each list/view/create/update/delete rule defaults null (superuser-only); empty opens to guests and nonempty filters. Superusers bypass rules. - pb-manage (REASONED; pb): Top-level manageRule defaults null and permits privileged auth-record changes alongside create/update rules; its validator rejects empty-string rules. - pb-files (REASONED; pb, pb-docs): File fields default unprotected despite locked record rules; Protected plus an authorized viewRule gates downloads, while a public viewRule still permits public access. - pb-file-token (REASONED; pb, pb-docs): Short-lived file tokens provide identity context, not file authorization; viewRule decides. Protect token-bearing file URLs. - pb-realtime (REASONED; pb, pb-docs): Collection subscriptions use listRule and individual-record subscriptions viewRule; SSE/subscription success does not prove event authorization. - pb-password (REASONED; pb): Auth initialization enables password authentication with email as the identity field. - pb-oauth (REASONED; pb, pb-docs): OAuth2 defaults disabled and is unsupported for _superusers. - pb-mfa-default (REASONED; pb): MFA defaults disabled with duration 600 seconds. - pb-otp-default (REASONED; pb): OTP defaults disabled with duration 180 seconds and length 8. - pb-alerts (REASONED; pb): Authentication alerts default enabled; retain alerts and provide working email. - pb-auth-token (REASONED; pb): Auth token duration defaults to 432000 seconds. - pb-file-duration (REASONED; pb): File token duration defaults to 180 seconds. - pb-auth-rule (REASONED; pb, pb-docs): authRule defaults empty; verified-only collections can require verified = true after email verification works. - pb-mfa (REASONED; pb, pb-docs): For superusers retain password auth and enable both OTP and MFA; empty mfa.rule applies to everyone. OTP alone is not a second factor. - pb-logout (REASONED; pb, pb-docs): Stateless authentication means clearing pb.authStore removes only the local copy, not a stolen token. - pb-encryption (REASONED; pb, pb-docs): Settings default to plaintext JSON; select a 32-character environment secret with --encryptionEnv. This encrypts settings, not the whole database or files. - pb-backups (REASONED; pb, pb-docs): Backups default local with empty cron (automatic backups off); archives contain main and logs databases plus remaining pb_data, excluding the backups directory and tracked exclusions; uploaded collection files on S3 require manual backup. Schedule/retain deliberately and protect archives. - pb-backup-auth (REASONED; pb): Backup downloads require a superuser file token and obey superuser IP restrictions; ordinary auth/file tokens do not suffice. - pb-limiter (REASONED; pb, pb-docs): Native limits exist from v0.23.0 but default disabled at v0.41.1; enable rateLimits.enabled. - pb-limit-rules (REASONED; pb): Seeded limits are *:auth 2/3s, *:create 20/5s, /api/batch 3/1s and /api/ 300/10s. - pb-limit-bypass (REASONED; pb): Superusers and rateLimits.excludedIPs bypass limits; test ordinary non-excluded clients. - pb-smtp (REASONED; pb): smtp.enabled and smtp.tls default false; disabled SMTP falls back to sendmail. Configure protected transport and test actual delivery. - pb-forge (REASONED; pb): From v0.41.0 PocketBase publishes its source and releases on its author's forge at ganigeorgiev.com, which this guide cites at v0.41.1; the v0.41.0 notes flag only one change with a warning sign, the Go module's move to the new host, and no guide statement depends on the module path. - appwrite-https (REASONED; appwrite, appwrite-docs): API FORCE_HTTPS and function/site ROUTER_FORCE_HTTPS default disabled at 2.7.0; explicitly enable both despite conflicting rolling deprecation/default documentation. - appwrite-hosts (REASONED; appwrite): ROUTER_PROTECTION defaults disabled; configure actual domains and enable rejection of unknown hostnames. - appwrite-registration (REASONED; appwrite): Console root-only registration defaults enabled with empty email/IP allowlists; these restrict account creation, not dashboard reachability or login. - appwrite-console (REASONED; appwrite, appwrite-docs): Claim the first operator privately and use a separate network/access boundary for a private dashboard. - appwrite-console-url (REASONED; appwrite, appwrite-docs): Since 2.3.0, _APP_CONSOLE_URL is the origin console links are built from and replaces the removed _APP_CONSOLE_URL_SCHEME; 2.7.0 defaults it to empty and its Compose file passes it to the appwrite service without a value, so set it when the console host differs from the API. - appwrite-mfa (REASONED; appwrite, appwrite-docs): Enroll and verify operator TOTP, protect recovery codes; at 2.7.0 enabled MFA with a verified factor requires a second session factor, a spent TOTP time step cannot be reused, and turning MFA off needs a session challenge from the last 1800 seconds. - appwrite-dev-env (REASONED; appwrite): Development env disables root-only registration, abuse and router protection and carries placeholders; do not inherit it for production. - appwrite-recreate (REASONED; appwrite-docs): Apply env/Compose changes with docker compose up -d from the install directory, then check effective behavior. - appwrite-permissions (REASONED; appwrite, appwrite-docs): Collection and document grants are additive according to legacy documentation; documentSecurity defaults false in Appwrite 2.7.0 and must be enabled for document grants. The pinned parameter description states the need for one permission level. - appwrite-creation (REASONED; appwrite, appwrite-docs, utopia-php-database): For null or omitted document permissions, the pinned aggregate helper preserves null before the handler assigns read/update/delete user grants to a nonprivileged requester with a user ID, or an empty list otherwise. API-key classification skips role validation but does not itself determine the default list. Use specific users/teams for private data. - appwrite-read-path (REASONED; appwrite): Document GET classifies requesters by authorization role (keys; owner/developer/admin privileged), reports disabled databases/collections as not found to others and, without a transaction ID, fetches through the database layer, which gets collection permissions and documentSecurity at collection creation. With one, staged creates come from the pending transaction's logs. - appwrite-requester (REASONED; appwrite): With an API key the request role and scopes come from the key, plus public at 2.7.0; a standard, ephemeral or dynamic project key for the same project sets the current authorization status to disabled, as do admin-mode console sessions outside the console and organization project read routes, while route scope validation applies to every requester. - appwrite-keys (REASONED; appwrite, appwrite-docs): Properly scoped server API keys bypass resource permissions but still obey operation scopes; treat keys.write as admin-equivalent: release notes since 2.6.2 say a key cannot grant scopes it does not hold, which the served page does not reflect, but the 2.7.0 key handlers were not read. Keys never belong in clients. - appwrite-key-creation (REASONED; appwrite-docs): The served API keys page says creating API keys with the Project API is deprecated as of 17 August 2026 and long-lived keys are created in the Console, while the Project API can still list, update and delete keys and issue ephemeral keys. - appwrite-encryption (REASONED; appwrite, appwrite-docs): Fresh 2.7.0 installs generate _APP_OPENSSL_KEY_V1 while existing and upgraded installs keep their stored value, so replace a remaining your-secret-key placeholder before production data; preserve every key version that still decrypts data, since loss strands encrypted secrets. - appwrite-backups (REASONED; appwrite-docs): Back up database, persistent storage and deployment configuration; restrict access and test restoration in a separate installation. - appwrite-abuse (REASONED; appwrite, appwrite-docs): _APP_OPTIONS_ABUSE defaults enabled but dev env disables it; server API-key and owner, developer or admin requests are exempt, so test with ordinary clients. - appwrite-smtp (REASONED; appwrite, appwrite-docs): SMTP host/port/secure/username/password default empty and empty host disables sending; configure the provider and confirm message receipt/use. - appwrite-storage (REASONED; appwrite): Storage defaults local, upload limit 30000000 and server antivirus disabled; enabling scanning requires configured reachable ClamAV. - appwrite-bucket (REASONED; appwrite): New bucket permissions are empty and fileSecurity=false; enable per-file security deliberately, since bucket grants also permit access. - appwrite-bucket-options (REASONED; appwrite): Bucket encryption and antivirus default true; bucket antivirus does not enable the server scanner. - appwrite-size (REASONED; appwrite): Encryption/scanning skip files above 20000000 bytes; cap maximumFileSize at that threshold when either is mandatory and test the boundary. - appwrite-functions (REASONED; appwrite): Function execute roles and execution-key scopes default empty; grant narrowly. Disabled functions still admit authorized Server SDK API keys. - appwrite-executor (REASONED; appwrite, appwrite-docs): Keep executor/orchestrator private and protect Docker-socket authority; fresh 2.7.0 installs generate _APP_EXECUTOR_SECRET but existing and upgraded installs keep theirs, so replace a remaining placeholder. Container execution is not demonstrated hostile-tenant isolation. - appwrite-docs-lag (REASONED; appwrite, appwrite-docs): As served on 2026-11-04, the MFA page omits 2.7.0's single-use TOTP codes and recent challenge to turn MFA off, the security and backups pages its key rotation, the environment page the generated encryption key and executor secret, the API keys page key scope limits, and the security page says no unique encryption key is generated; the guide states each from release notes and source. - verify-pb-records (REASONED; pb-docs, curl): Public rules allow guest reads; locked rules deny with 403, unsatisfied list filters can return empty 200 and view rules 404. Retain owner and separate superuser controls. - verify-appwrite-records (REASONED; appwrite-docs): Broad collection grants defeat document restrictions; removing them must deny unrelated users while document grantees succeed. Compare client/server creation defaults. - verify-appwrite-keys (REASONED; appwrite): A correctly scoped server key reads despite empty permissions, while a key without scope fails; neither substitutes for an ordinary-user control. - verify-registration (REASONED; appwrite): On disposable Appwrite fixtures compare open signup 201 with root-only account-limit failure and independent email/IP restrictions; retain login/invitation controls. - verify-pb-bootstrap (REASONED; pb): Guest and ordinary-user _superusers record creation must fail while a valid installer token creates the private operator; static dashboard assets prove no admin access. - verify-appwrite-mfa (REASONED; appwrite, appwrite-docs): Console account GET succeeds without MFA, fails for a fresh password-only MFA session, where 2.7.0 throws USER_MORE_FACTORS_REQUIRED with a response string not established here, and succeeds after TOTP completion with a code not already redeemed. - verify-pb-writes (REASONED; pb): Compare intended/unauthorized POST/PATCH/DELETE and privileged auth-record management; locked manageRule grants no management and empty-string configuration must fail. - verify-pb-files (REASONED; pb, curl): Before protection unsigned file GET works; Protected plus private viewRule rejects guests/unrelated-user file tokens with 404 while permitted-user bytes still arrive. - verify-pb-realtime (REASONED; pb, pb-docs): Mutate a fixture while comparing collection and record subscriptions; deny unauthorized event delivery, retaining authorized delivery. PB_CONNECT/acknowledgment alone is insufficient. - verify-pb-mfa (REASONED; pb, pb-docs): Compare password-only completion with password-plus-OTP MFA and verified/unverified authRule logins; clearing authStore must not revoke a retained valid token. - verify-pb-ips (REASONED; pb): Compare allowed/excluded superuser sources and forged forwarded headers; excluded sources must get 403 and direct backend access must fail with a working proxy control. - verify-transport (REASONED; pb, appwrite): Compare HTTP/HTTPS and certificates, Appwrite API/function/site domains and configured/unknown Host; compare browser origins separately from record authorization. - verify-abuse (REASONED; pb, appwrite-docs): Cross each configured threshold with ordinary non-excluded clients and compare disabled/enabled controls; PocketBase limiting yields 429 with later successful recovery. - verify-email (REASONED; pb, appwrite-docs): Request OTP, verification and recovery messages and confirm receipt and use; request success alone is not delivery evidence. - verify-pb-recovery (REASONED; pb, pb-docs): Compare plaintext/encrypted persisted settings, restart with retained key and restore local/S3 data; only permitted superuser file tokens may download backups. - verify-appwrite-recovery (REASONED; appwrite-docs): Restore encrypted fixtures with the original key; a separate wrong-key fixture must not recover encrypted values. - verify-uploads (REASONED; appwrite): Compare permitted/unrelated uploads and size-boundary fixtures; mandatory processing requires rejecting over-limit files and confirming accepted-file encryption/scanning. - verify-functions (REASONED; appwrite): Compare intended execute roles, unrelated users and privileged server keys, including disabled functions; confirm executor/orchestrator privacy from a second host. - verify-bundle (REASONED; pb-docs, appwrite-docs): Search filenames using protected exact-secret patterns with a disposable marker control; client bundles must contain neither superuser tokens nor Appwrite API keys, and encoded/split forms can evade scanning. - local-guards (DEMONSTRATED; pb-docs, appwrite-docs): Recorded guard tests rejected placeholders and omitted/shortened arguments; this demonstrates shell behavior only, not service access or deployed-bundle scanning. The tests covered earlier forms, whose guard refusals left the subshell with status 0 and whose scan block result arms ended with status 0; current forms were not re-run, and gate bash -n and ShellCheck passes cover them. - local-syntax (DEMONSTRATED; appwrite, pb-docs, appwrite-docs): All three bash fences have recorded local lint/syntax success, not live service evidence; ShellCheck version is recorded only in the body. The runs covered earlier forms, whose guard refusals left the subshell with status 0 and whose scan block result arms ended with status 0; current forms were not re-run, and gate bash -n and ShellCheck passes cover them. - source-limits (REASONED; appwrite-docs, pb-docs, appwrite, utopia-php-database): PocketBase CLI/--dev, Appwrite 2.7.0 requester/call-site and utopia-php/database 10.1.0 role matching and status/skip pins are cited; Database-level read checks and dispatch are not established at 10.1.0. SQL, Memory, Redis and Pool reads are traced; deployment adapters are unknown and live authorization is not demonstrated. - source-limits-sql-get (REASONED; utopia-php-database): At 10.1.0 MariaDB, MySQL, Postgres and SQLite use SQL getDocument; without joins it selects by ID, adds a tenant clause under shared tables and has no adapter authorization check, while join queries add the find permission hooks under the same gates. Whether Database read checks accept collection roles as at 7.3.9 is not established. - source-limits-sql-find (REASONED; utopia-php-database): At 10.1.0 SQL find defaults to read and takes shared authorization roles; with status enabled it adds the main collection's permission hook only for documentSecurity on or the metadata collection, skipping a granted collection on join or bounded-page paths, plus a hook per joined table unless the collection's join map disables it. Database-level grant skipping is not established. - source-limits-sql-predicate (REASONED; utopia-php-database): At 10.1.0 the SQL permission hook matches requester roles and action in the collection's permissions side table with bound parameters and a shared-table tenant filter, returning 1 = 0 for no roles; Postgres overrides it with OR-ed JSONB containment tests on each row's permissions column, also 1 = 0 for no roles. - source-limits-sql-disabled (REASONED; utopia-php-database): This guide reasons from the 10.1.0 SQL code that disabled authorization omits find permission filtering while query conditions, ordering and pagination remain, and that SQL getDocument without joins has no adapter authorization check to bypass; whether Database read checks are bypassed at 10.1.0 is not established. - source-limits-mongo (REASONED; utopia-php-database): At 10.1.0 Mongo getDocument filters by _uid through a tenant hook with no adapter authorization check; Mongo find passes its filters through a Mongo permission read hook whose code was not read, so its status gate and permission strings are not established at 10.1.0, with no live authorization demonstration. - source-limits-memory-redis (REASONED; utopia-php-database): At 10.1.0 Memory, intended for tests, fixtures and ephemeral workloads, and Redis, mirroring its surface, check no authorization in getDocument; their find paths keep only IDs matching requester roles for the action while authorization is enabled, without testing documentSecurity. This guide reasons that disabled status omits that filtering. - source-limits-pool (REASONED; utopia-php-database): At 10.1.0 Pool delegates getDocument and find to a pooled or pinned adapter, first setting its database, namespace, shared tables, tenant, tenant-per-document mode, authorization and locks from Pool's own; withTransaction does this before pinning. This guide reasons that a Pool-wrapped adapter reads with the authorization instance the database set. - source-limits-appwrite-adapter (REASONED; appwrite, utopia-php-database): Appwrite 2.7.0's _APP_DB_ADAPTER (since 1.9.0) defaults to postgresql; pool registration maps the mariadb, mongodb, mysql and postgresql schemes to the MariaDB, Mongo, MySQL and Postgres adapters, DocumentsDB accepts only mongodb, and the factory wraps pools in Pool, with tenant databases getting the shared authorization. Which adapter a given deployment uses is not established. - source-limits-appwrite-dev (REASONED; appwrite): Appwrite 2.7.0's development .env sets _APP_DB_ADAPTER=postgresql, a development default, not a production default; the root Compose file, which the install task reads as its Compose template, passes the variable to the appwrite service without a value. The 2.7.0 upgrade task keeps an installation's engine, assuming MariaDB when neither Compose nor .env sets _APP_DB_ADAPTER. - source-limits-appwrite-console (REASONED; appwrite): Appwrite 2.7.0's console variables endpoint reports _APP_DB_ADAPTER with a mariadb fallback while pool registration falls back to postgresql, so by source reading an unset variable, and by this guide's inference an empty one, gives Postgres pools and a reported mariadb. Both read a set variable; this guide advises not relying on the reported value to identify the adapter. - source-limits-appwrite-install-env (REASONED; appwrite): Appwrite 2.7.0's install task writes _APP_DB_ADAPTER to .env, unless reusing a configuration, from a prompt answer, its database (default postgresql, or detected) or the web installer's choice (default postgresql). Its Compose generator keeps only the chosen database service and, this guide reasons, the value-less entry, which the first detection step skips; how Compose resolves it is unknown. - source-limits-adapter-docs (REASONED; appwrite-docs, appwrite): As served on 2026-11-04, Appwrite's environment documentation lists MariaDB and MongoDB and gives _APP_DB_ADAPTER a mongodb default, conflicting with Appwrite 2.7.0, which defaults to postgresql, a value that page does not list. ````json { "schema": 1, "checked": "2026-11-05", "documentation_checked": "2026-11", "body_sha256": "798578697fb83a927482162ef9ffcbe4f908f03b5f1ec5f4df82a1af98f9fe4f", "components": { "pb": { "name": "PocketBase", "basis": "v0.41.1", "sources": { "s254c6ab00854": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/installer.go?rev=refs/tags/v0.41.1", "se57548354680": "https://ganigeorgiev.com/pocketbase/pocketbase/files/cmd/serve.go?rev=refs/tags/v0.41.1", "s172d68d13613": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/serve.go?rev=refs/tags/v0.41.1", "sa89696d9163c": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares.go?rev=refs/tags/v0.41.1", "sc74ad3d8cd5e": "https://ganigeorgiev.com/pocketbase/pocketbase/files/core/record_query.go?rev=refs/tags/v0.41.1", "sa45421be29ae": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/record_crud.go?rev=refs/tags/v0.41.1", "sf575d3a8df33": "https://ganigeorgiev.com/pocketbase/pocketbase/files/core/collection_model_auth_options.go?rev=refs/tags/v0.41.1", "sb92f4450f7a5": "https://ganigeorgiev.com/pocketbase/pocketbase/files/core/settings_model.go?rev=refs/tags/v0.41.1", "sadc66f5e4911": "https://ganigeorgiev.com/pocketbase/pocketbase/files/core/base.go?rev=refs/tags/v0.41.1", "s41f09654a785": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/file.go?rev=refs/tags/v0.41.1", "s6e47fd88f4aa": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/realtime.go?rev=refs/tags/v0.41.1", "sc8abb26b1da5": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/backup.go?rev=refs/tags/v0.41.1", "s16641df1822b": "https://ganigeorgiev.com/pocketbase/pocketbase/files/core/backup_create.go?rev=refs/tags/v0.41.1", "s3f0220ba5d12": "https://ganigeorgiev.com/pocketbase/pocketbase/files/apis/middlewares_rate_limit.go?rev=refs/tags/v0.41.1", "sebe7b75e76db": "https://ganigeorgiev.com/pocketbase/pocketbase/files/pocketbase.go?rev=refs/tags/v0.41.1", "sc1e42a052cd4": "https://ganigeorgiev.com/pocketbase/pocketbase/files/tools/osutils/run.go?rev=refs/tags/v0.41.1", "s958702d638d0": "https://ganigeorgiev.com/pocketbase/pocketbase/releases?expr=tag%3D%22v0.41.0%22" } }, "appwrite": { "name": "Appwrite", "basis": "2.7.0", "sources": { "s6bb4fe4aeed0": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/config/variables.php", "s2ce76c61491b": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/.env", "sd96c9b2ddc30": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Create.php#L109-L134", "s535fef1a5540": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/shared/api.php", "s7a257b8fa05b": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/controllers/general.php", "seb281dd186ea": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Create.php", "sd13079dcf7cd": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php#L88-L90", "s389cba971534": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L134-L139", "sababfed68086": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L217-L218", "s1af8437dd494": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php#L238-L288", "s77e2ce52136f": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php", "s1c49e69946a1": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/init/constants.php", "s80913a396c73": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/docker-compose.yml", "s8222d84642bc": "https://github.com/appwrite/appwrite/blob/2.7.0/composer.lock#L4429-L4439", "sbf22f36f3eaf": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L82-L97", "sfa704d5d52d2": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L105-L132", "s4704dc36131d": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L82-L116", "s9c364b4988f9": "https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L146-L167", "sdc161a81e5f1": "https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L328-L361", "seae8fbd82294": "https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L396-L400", "s02d278fc618d": "https://github.com/appwrite/appwrite/blob/2.7.0/app/controllers/shared/api.php#L557-L561", "s4a5ab116894c": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php#L230-L236", "s298b295bc4a2": "https://github.com/appwrite/appwrite/blob/2.7.0/app/init/registers.php#L45-L99", "s90d9a92f0ed4": "https://github.com/appwrite/appwrite/blob/2.7.0/app/init/registers.php#L239-L252", "s8ed5acf4fc13": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L123-L201", "s495c0d1f4835": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L208-L211", "s3e984f6f58a1": "https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L1112-L1159", "se35f39b4f087": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Upgrade.php#L73-L98", "s320dd52ad7ad": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L563-L568", "s34cf9f92e6fb": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L664-L671", "s781b997e5328": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php#L109-L124", "s32cdcbef66a2": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L92-L94", "s7061d34674a8": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Database/Factory.php#L12", "s43512ed49a8b": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Console/Http/Variables/Get.php#L118", "sc7cb880ebc53": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php#L130-L138", "sbdd71fb0bdee": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L81", "s7c7d516c2e29": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L234-L239", "s354c70b426e9": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L299-L307", "s3f837fcdd474": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L318-L319", "sf0bdc23845a6": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L550-L555", "s8194e1cb510c": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L708-L721", "s7649c24d59ac": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/app/views/install/env.phtml", "sfe2ab69d5301": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L70-L108", "s20b34941cb2a": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Databases/TransactionState.php#L675-L706", "s39e9760a01ee": "https://github.com/appwrite/appwrite/blob/2.7.0/app/init/resources/request.php#L234-L238", "sa0f0758379af": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Upgrade.php#L108-L110", "s8bd7139f8949": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L214-L233", "s48b953e7a273": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L1709-L1728", "sade59f0be4d6": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L253-L262", "s3fc2bb20c0bb": "https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/templates/steps/step-1.phtml#L11-L20", "s2ab6c91f62fd": "https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/templates/steps/step-1.phtml#L66-L108", "s824989859386": "https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer.phtml#L12", "s5fb71939c4b6": "https://github.com/appwrite/appwrite/blob/2.7.0/app/views/install/installer/js/modules/progress.js#L408", "s254e58212ad0": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L150-L161", "s595b0f259c3a": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L238-L246", "s3a92a2e12968": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L202-L225", "s7e549280b454": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L248-L285", "s9cb7dec752e3": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L313", "s79502c89f0ed": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L320-L322", "s830783806300": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Installer/Http/Installer/Install.php#L380-L394", "s2851b656753a": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L440-L473", "s1141172bac7f": "https://raw.githubusercontent.com/appwrite/appwrite/2.7.0/src/Appwrite/Docker/Compose/Generator.php", "sa7170a7b4946": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L9-L26", "sc31ddc8f2659": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L145-L153", "se6ecc9107190": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Generator.php#L188-L210", "s2e99d3590696": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose.php#L16-L27", "s0ea4f620650f": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Compose/Service.php#L32-L33", "sdfd43737a7ac": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Docker/Env.php#L37-L42", "s2f6df67b8aea": "https://github.com/appwrite/appwrite/blob/2.7.0/packages/system/src/System.php#L703-L706", "sdbcf25c4f59a": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L211-L218", "sad0f2bc8be8f": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/Get.php#L27-L30", "sd0c2d9cf89f4": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Utopia/Database/Documents/User.php#L230-L244", "sec3eb7219b94": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Update.php#L81-L84", "s3ea65093a0fb": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php#L165-L177", "se24bc37a7000": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/MFA/Challenge/TOTP.php#L30-L88", "s3d7f866403ab": "https://github.com/appwrite/appwrite/blob/2.7.0/app/init/constants.php#L266", "s8208896c2c22": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/EncryptionKey.php#L11-L69", "sd987a4f33938": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Auth/JobsSecret.php#L20-L32", "s296ddae6efd8": "https://github.com/appwrite/appwrite/blob/2.7.0/src/Appwrite/Platform/Tasks/Install.php#L331", "s1da118060657": "https://github.com/appwrite/appwrite/releases/tag/2.3.0", "sf22fdc1ed786": "https://github.com/appwrite/appwrite/releases/tag/2.4.0", "s4d8a36b7651f": "https://github.com/appwrite/appwrite/releases/tag/2.6.2", "sc92fc6843e8a": "https://github.com/appwrite/appwrite/releases/tag/2.7.0" } }, "pb-docs": { "name": "PocketBase documentation (rolling)", "basis": "unknown", "sources": { "s9029ab974cf1": "https://pocketbase.io/docs/going-to-production/", "saedbdfbdfc48": "https://pocketbase.io/docs/api-rules-and-filters/", "s773e441dd51c": "https://pocketbase.io/docs/authentication/", "sf711b6345643": "https://pocketbase.io/docs/files-handling/", "sb850a71fad4d": "https://pocketbase.io/docs/api-realtime/" } }, "appwrite-docs": { "name": "Appwrite documentation (rolling)", "basis": "unknown", "sources": { "s4a965bdeeb98": "https://appwrite.io/docs/advanced/self-hosting/production/security", "saa37bb5d4d7e": "https://appwrite.io/docs/partners/project/api-keys", "sa0bd132f7968": "https://appwrite.io/docs/advanced/security/permissions", "s58ad28816c5d": "https://appwrite.io/docs/products/databases/tablesdb/legacy/permissions", "s2bf9a4746d28": "https://appwrite.io/docs/advanced/self-hosting/configuration/environment-variables", "s000d05106450": "https://appwrite.io/docs/advanced/security/rate-limits", "s38a1efa0c7d2": "https://appwrite.io/docs/advanced/self-hosting/configuration/email", "s256ef73f7b85": "https://appwrite.io/docs/advanced/self-hosting/production/backups", "s221c6aaef8df": "https://appwrite.io/docs/advanced/security/mfa" } }, "utopia-php-database": { "name": "utopia-php/database", "basis": "10.1.0", "sources": { "s84fe9d3d1ab5": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Permission.php#L176-L180", "s275a3120b495": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Document.php#L273-L334", "s655e2f6c04a0": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Document.php#L405-L416", "s4a3f8adc3023": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L59-L102", "s000a4b53503d": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L52-L61", "sa8f4d7b8a48c": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L1033-L1039", "safe798d5dd9c": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Database.php#L1162-L1183", "s2feb9534db52": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L10-L41", "sc00b8981307b": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Validator/Authorization.php#L148-L208", "s41c5bffe6075": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L650-L779", "s1d79e9cf0e57": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L1566-L1572", "s7bf0e65d6cf0": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5063-L5079", "s0ae9c48f1b01": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5081-L5112", "sb95aa1aa9ed9": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5179-L5183", "sc3379278f3f2": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L5185-L5213", "s9e70fd6370ea": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L6080-L6086", "s7c5f032eabba": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL.php#L3658-L3678", "s6a26dc1f9801": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQL/Hook/Permission/Filter.php#L72-L116", "sc9e144475031": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Postgres.php#L1909-L1958", "s361b4b790fbf": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/MariaDB.php#L46", "s699a2c766b74": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/MySQL.php#L25", "s07bdbc102182": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Postgres.php#L53", "s402631b77efa": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQLite.php#L71", "s97013afdaf6d": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/SQLite.php#L249-L259", "sda6b15e8a7f2": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L57", "sf19fad9126ff": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L1561-L1603", "s68e7d8672204": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L249-L282", "s08b8af0098a5": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Mongo.php#L2402-L2413", "s915a0850c1bf": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L36-L138", "s8795cdff3377": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L178-L186", "sde36e9303448": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L517-L541", "sa151e5aefbf8": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L802-L807", "s7edf5f1161c1": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L884-L889", "s9613234b8f17": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Pool.php#L1101-L1106", "s70895d208fe4": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L3-L5", "s1a92600403ce": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L36-L48", "s604f86d30464": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L1204-L1227", "s4aec3fdda679": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L1807-L1836", "se488122e644f": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L2397-L2452", "sab16fda30b23": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Memory.php#L3108-L3141", "s970890cdc753": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L5-L8", "s91c17eccdca7": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L41-L57", "s784f964c7ba3": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L882-L916", "s2c28e6247d1d": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L1482-L1519", "sd26ea42ec217": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L1938-L1973", "s1336d62998d1": "https://github.com/utopia-php/database/blob/950a4907d5519efd7ac7d1aff8978e482f8e07ae/src/Database/Adapter/Redis.php#L2809-L2823" } }, "curl": { "name": "curl", "basis": "curl-8_14_1", "sources": { "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79", "s3db522258e44": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/header.md", "s82a34ee18632": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md" } } }, "claims": { "pb-bootstrap": {"text": "Privately bootstrap through the logged installer URL; superuser create EMAIL PASS exposes passwords in argv. Ordinary registration does not grant superuser rights.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:s9029ab974cf1", "pb:s254c6ab00854"], "status": "REASONED"}, "pb-installer": {"text": "Installer system-superuser token lasts about 30 minutes; protect its URL and startup logs as credentials.", "components": ["pb"], "sources": ["pb:s254c6ab00854"], "status": "REASONED"}, "pb-ips": {"text": "Superuser IP allowlists are available from v0.38.0; superuserIPs defaults empty and restricts authenticated requests, not /_/ static assets.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:s9029ab974cf1", "pb:sa89696d9163c"], "status": "REASONED"}, "pb-tls": {"text": "serve with a domain provides native HTTPS/ACME; alternatively terminate TLS at a reverse proxy.", "components": ["pb"], "sources": ["pb:s172d68d13613"], "status": "REASONED"}, "pb-bind": {"text": "Without a domain serve defaults to 127.0.0.1:8090; with a domain it uses 0.0.0.0:80/443. Behind a local proxy omit the domain and set --http loopback.", "components": ["pb"], "sources": ["pb:se57548354680"], "status": "REASONED"}, "pb-origins": {"text": "--origins defaults * and accepts comma-separated allowed origins; CORS does not authorize records.", "components": ["pb"], "sources": ["pb:se57548354680"], "status": "REASONED"}, "pb-proxy": {"text": "Trust only headers the proxy overwrites, review useLeftmostIP ordering and block direct backend access to protect IP/rate controls.", "components": ["pb"], "sources": ["pb:sb92f4450f7a5"], "status": "REASONED"}, "pb-dev": {"text": "--dev adds diagnostics including SQL on stderr, not an auth bypass; its configured default comes from New(): true if os.Args[0] starts with a nonempty os.TempDir() or cacheDir() prefix, false otherwise.", "components": ["pb"], "sources": ["pb:sadc66f5e4911", "pb:sebe7b75e76db", "pb:sc1e42a052cd4"], "status": "REASONED"}, "pb-cli": {"text": "In the pinned CLI wrapper, Start() registers the serve and superuser commands on the root command and executes it.", "components": ["pb"], "sources": ["pb:sebe7b75e76db"], "status": "REASONED"}, "pb-rules": {"text": "Each list/view/create/update/delete rule defaults null (superuser-only); empty opens to guests and nonempty filters. Superusers bypass rules.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:saedbdfbdfc48", "pb:sc74ad3d8cd5e"], "status": "REASONED"}, "pb-manage": {"text": "Top-level manageRule defaults null and permits privileged auth-record changes alongside create/update rules; its validator rejects empty-string rules.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-files": {"text": "File fields default unprotected despite locked record rules; Protected plus an authorized viewRule gates downloads, while a public viewRule still permits public access.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:sf711b6345643", "pb:s41f09654a785"], "status": "REASONED"}, "pb-file-token": {"text": "Short-lived file tokens provide identity context, not file authorization; viewRule decides. Protect token-bearing file URLs.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:sf711b6345643", "pb:s41f09654a785"], "status": "REASONED"}, "pb-realtime": {"text": "Collection subscriptions use listRule and individual-record subscriptions viewRule; SSE/subscription success does not prove event authorization.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:sb850a71fad4d", "pb:s6e47fd88f4aa"], "status": "REASONED"}, "pb-password": {"text": "Auth initialization enables password authentication with email as the identity field.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-oauth": {"text": "OAuth2 defaults disabled and is unsupported for _superusers.", "components": ["pb", "pb-docs"], "sources": ["pb:sf575d3a8df33", "pb-docs:s773e441dd51c"], "status": "REASONED"}, "pb-mfa-default": {"text": "MFA defaults disabled with duration 600 seconds.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-otp-default": {"text": "OTP defaults disabled with duration 180 seconds and length 8.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-alerts": {"text": "Authentication alerts default enabled; retain alerts and provide working email.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-auth-token": {"text": "Auth token duration defaults to 432000 seconds.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-file-duration": {"text": "File token duration defaults to 180 seconds.", "components": ["pb"], "sources": ["pb:sf575d3a8df33"], "status": "REASONED"}, "pb-auth-rule": {"text": "authRule defaults empty; verified-only collections can require verified = true after email verification works.", "components": ["pb", "pb-docs"], "sources": ["pb:sf575d3a8df33", "pb-docs:s773e441dd51c"], "status": "REASONED"}, "pb-mfa": {"text": "For superusers retain password auth and enable both OTP and MFA; empty mfa.rule applies to everyone. OTP alone is not a second factor.", "components": ["pb", "pb-docs"], "sources": ["pb:sf575d3a8df33", "pb-docs:s773e441dd51c"], "status": "REASONED"}, "pb-logout": {"text": "Stateless authentication means clearing pb.authStore removes only the local copy, not a stolen token.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:s773e441dd51c", "pb:sc74ad3d8cd5e"], "status": "REASONED"}, "pb-encryption": {"text": "Settings default to plaintext JSON; select a 32-character environment secret with --encryptionEnv. This encrypts settings, not the whole database or files.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:s9029ab974cf1", "pb:sb92f4450f7a5"], "status": "REASONED"}, "pb-backups": {"text": "Backups default local with empty cron (automatic backups off); archives contain main and logs databases plus remaining pb_data, excluding the backups directory and tracked exclusions; uploaded collection files on S3 require manual backup. Schedule/retain deliberately and protect archives.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:s9029ab974cf1", "pb:sb92f4450f7a5", "pb:s16641df1822b"], "status": "REASONED"}, "pb-backup-auth": {"text": "Backup downloads require a superuser file token and obey superuser IP restrictions; ordinary auth/file tokens do not suffice.", "components": ["pb"], "sources": ["pb:sc8abb26b1da5"], "status": "REASONED"}, "pb-limiter": {"text": "Native limits exist from v0.23.0 but default disabled at v0.41.1; enable rateLimits.enabled.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:s9029ab974cf1", "pb:sb92f4450f7a5"], "status": "REASONED"}, "pb-limit-rules": {"text": "Seeded limits are *:auth 2/3s, *:create 20/5s, /api/batch 3/1s and /api/ 300/10s.", "components": ["pb"], "sources": ["pb:sb92f4450f7a5"], "status": "REASONED"}, "pb-limit-bypass": {"text": "Superusers and rateLimits.excludedIPs bypass limits; test ordinary non-excluded clients.", "components": ["pb"], "sources": ["pb:s3f0220ba5d12"], "status": "REASONED"}, "pb-smtp": {"text": "smtp.enabled and smtp.tls default false; disabled SMTP falls back to sendmail. Configure protected transport and test actual delivery.", "components": ["pb"], "sources": ["pb:sb92f4450f7a5", "pb:sadc66f5e4911"], "status": "REASONED"}, "pb-forge": {"text": "From v0.41.0 PocketBase publishes its source and releases on its author's forge at ganigeorgiev.com, which this guide cites at v0.41.1; the v0.41.0 notes flag only one change with a warning sign, the Go module's move to the new host, and no guide statement depends on the module path.", "components": ["pb"], "sources": ["pb:s958702d638d0"], "status": "REASONED"}, "appwrite-https": {"text": "API FORCE_HTTPS and function/site ROUTER_FORCE_HTTPS default disabled at 2.7.0; explicitly enable both despite conflicting rolling deprecation/default documentation.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite:s7a257b8fa05b", "appwrite-docs:s2bf9a4746d28"], "status": "REASONED"}, "appwrite-hosts": {"text": "ROUTER_PROTECTION defaults disabled; configure actual domains and enable rejection of unknown hostnames.", "components": ["appwrite"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite:s7a257b8fa05b"], "status": "REASONED"}, "appwrite-registration": {"text": "Console root-only registration defaults enabled with empty email/IP allowlists; these restrict account creation, not dashboard reachability or login.", "components": ["appwrite"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite:sd96c9b2ddc30"], "status": "REASONED"}, "appwrite-console": {"text": "Claim the first operator privately and use a separate network/access boundary for a private dashboard.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite-docs:s4a965bdeeb98", "appwrite:sd96c9b2ddc30"], "status": "REASONED"}, "appwrite-console-url": {"text": "Since 2.3.0, _APP_CONSOLE_URL is the origin console links are built from and replaces the removed _APP_CONSOLE_URL_SCHEME; 2.7.0 defaults it to empty and its Compose file passes it to the appwrite service without a value, so set it when the console host differs from the API.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s1da118060657", "appwrite:s6bb4fe4aeed0", "appwrite:s80913a396c73", "appwrite-docs:s2bf9a4746d28"], "status": "REASONED"}, "appwrite-mfa": {"text": "Enroll and verify operator TOTP, protect recovery codes; at 2.7.0 enabled MFA with a verified factor requires a second session factor, a spent TOTP time step cannot be reused, and turning MFA off needs a session challenge from the last 1800 seconds.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite-docs:s221c6aaef8df", "appwrite:s535fef1a5540", "appwrite:sdbcf25c4f59a", "appwrite:se24bc37a7000", "appwrite:s3ea65093a0fb", "appwrite:sec3eb7219b94", "appwrite:sd0c2d9cf89f4", "appwrite:s3d7f866403ab", "appwrite:sc92fc6843e8a"], "status": "REASONED"}, "appwrite-dev-env": {"text": "Development env disables root-only registration, abuse and router protection and carries placeholders; do not inherit it for production.", "components": ["appwrite"], "sources": ["appwrite:s2ce76c61491b"], "status": "REASONED"}, "appwrite-recreate": {"text": "Apply env/Compose changes with docker compose up -d from the install directory, then check effective behavior.", "components": ["appwrite-docs"], "sources": ["appwrite-docs:s4a965bdeeb98"], "status": "REASONED"}, "appwrite-permissions": {"text": "Collection and document grants are additive according to legacy documentation; documentSecurity defaults false in Appwrite 2.7.0 and must be enabled for document grants. The pinned parameter description states the need for one permission level.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:sd13079dcf7cd", "appwrite-docs:s58ad28816c5d"], "status": "REASONED"}, "appwrite-creation": {"text": "For null or omitted document permissions, the pinned aggregate helper preserves null before the handler assigns read/update/delete user grants to a nonprivileged requester with a user ID, or an empty list otherwise. API-key classification skips role validation but does not itself determine the default list. Use specific users/teams for private data.", "components": ["appwrite", "appwrite-docs", "utopia-php-database"], "sources": ["appwrite:s389cba971534", "appwrite:sababfed68086", "appwrite:s1af8437dd494", "appwrite-docs:sa0bd132f7968", "appwrite:s8222d84642bc", "utopia-php-database:s84fe9d3d1ab5"], "status": "REASONED"}, "appwrite-read-path": {"text": "Document GET classifies requesters by authorization role (keys; owner/developer/admin privileged), reports disabled databases/collections as not found to others and, without a transaction ID, fetches through the database layer, which gets collection permissions and documentSecurity at collection creation. With one, staged creates come from the pending transaction's logs.", "components": ["appwrite"], "sources": ["appwrite:sbf22f36f3eaf", "appwrite:sfa704d5d52d2", "appwrite:s781b997e5328", "appwrite:s4704dc36131d", "appwrite:s4a5ab116894c", "appwrite:s32cdcbef66a2", "appwrite:sfe2ab69d5301", "appwrite:s20b34941cb2a"], "status": "REASONED"}, "appwrite-requester": {"text": "With an API key the request role and scopes come from the key, plus public at 2.7.0; a standard, ephemeral or dynamic project key for the same project sets the current authorization status to disabled, as do admin-mode console sessions outside the console and organization project read routes, while route scope validation applies to every requester.", "components": ["appwrite"], "sources": ["appwrite:s9c364b4988f9", "appwrite:sdc161a81e5f1", "appwrite:seae8fbd82294", "appwrite:s02d278fc618d"], "status": "REASONED"}, "appwrite-keys": {"text": "Properly scoped server API keys bypass resource permissions but still obey operation scopes; treat keys.write as admin-equivalent: release notes since 2.6.2 say a key cannot grant scopes it does not hold, which the served page does not reflect, but the 2.7.0 key handlers were not read. Keys never belong in clients.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s535fef1a5540", "appwrite-docs:saa37bb5d4d7e", "appwrite:s4d8a36b7651f", "appwrite:sc92fc6843e8a", "appwrite:s77e2ce52136f"], "status": "REASONED"}, "appwrite-key-creation": {"text": "The served API keys page says creating API keys with the Project API is deprecated as of 17 August 2026 and long-lived keys are created in the Console, while the Project API can still list, update and delete keys and issue ephemeral keys.", "components": ["appwrite-docs"], "sources": ["appwrite-docs:saa37bb5d4d7e"], "status": "REASONED"}, "appwrite-encryption": {"text": "Fresh 2.7.0 installs generate _APP_OPENSSL_KEY_V1 while existing and upgraded installs keep their stored value, so replace a remaining your-secret-key placeholder before production data; preserve every key version that still decrypts data, since loss strands encrypted secrets.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite:s8208896c2c22", "appwrite:s2851b656753a", "appwrite:s296ddae6efd8", "appwrite:sf22fdc1ed786", "appwrite:sc92fc6843e8a", "appwrite-docs:s256ef73f7b85"], "status": "REASONED"}, "appwrite-backups": {"text": "Back up database, persistent storage and deployment configuration; restrict access and test restoration in a separate installation.", "components": ["appwrite-docs"], "sources": ["appwrite-docs:s256ef73f7b85"], "status": "REASONED"}, "appwrite-abuse": {"text": "_APP_OPTIONS_ABUSE defaults enabled but dev env disables it; server API-key and owner, developer or admin requests are exempt, so test with ordinary clients.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite:s2ce76c61491b", "appwrite:s535fef1a5540", "appwrite-docs:s000d05106450"], "status": "REASONED"}, "appwrite-smtp": {"text": "SMTP host/port/secure/username/password default empty and empty host disables sending; configure the provider and confirm message receipt/use.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite-docs:s38a1efa0c7d2"], "status": "REASONED"}, "appwrite-storage": {"text": "Storage defaults local, upload limit 30000000 and server antivirus disabled; enabling scanning requires configured reachable ClamAV.", "components": ["appwrite"], "sources": ["appwrite:s6bb4fe4aeed0"], "status": "REASONED"}, "appwrite-bucket": {"text": "New bucket permissions are empty and fileSecurity=false; enable per-file security deliberately, since bucket grants also permit access.", "components": ["appwrite"], "sources": ["appwrite:seb281dd186ea"], "status": "REASONED"}, "appwrite-bucket-options": {"text": "Bucket encryption and antivirus default true; bucket antivirus does not enable the server scanner.", "components": ["appwrite"], "sources": ["appwrite:seb281dd186ea"], "status": "REASONED"}, "appwrite-size": {"text": "Encryption/scanning skip files above 20000000 bytes; cap maximumFileSize at that threshold when either is mandatory and test the boundary.", "components": ["appwrite"], "sources": ["appwrite:seb281dd186ea", "appwrite:s1c49e69946a1"], "status": "REASONED"}, "appwrite-functions": {"text": "Function execute roles and execution-key scopes default empty; grant narrowly. Disabled functions still admit authorized Server SDK API keys.", "components": ["appwrite"], "sources": ["appwrite:s77e2ce52136f"], "status": "REASONED"}, "appwrite-executor": {"text": "Keep executor/orchestrator private and protect Docker-socket authority; fresh 2.7.0 installs generate _APP_EXECUTOR_SECRET but existing and upgraded installs keep theirs, so replace a remaining placeholder. Container execution is not demonstrated hostile-tenant isolation.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite:s80913a396c73", "appwrite:s6bb4fe4aeed0", "appwrite:s2851b656753a", "appwrite:s4d8a36b7651f", "appwrite-docs:s2bf9a4746d28"], "status": "REASONED"}, "appwrite-docs-lag": {"text": "As served on 2026-11-04, the MFA page omits 2.7.0's single-use TOTP codes and recent challenge to turn MFA off, the security and backups pages its key rotation, the environment page the generated encryption key and executor secret, the API keys page key scope limits, and the security page says no unique encryption key is generated; the guide states each from release notes and source.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite-docs:s221c6aaef8df", "appwrite-docs:s4a965bdeeb98", "appwrite-docs:s256ef73f7b85", "appwrite-docs:s2bf9a4746d28", "appwrite-docs:saa37bb5d4d7e", "appwrite:sc92fc6843e8a", "appwrite:s4d8a36b7651f", "appwrite:sf22fdc1ed786", "appwrite:se24bc37a7000", "appwrite:sec3eb7219b94", "appwrite:s8208896c2c22", "appwrite:s2851b656753a"], "status": "REASONED"}, "verify-pb-records": {"text": "Public rules allow guest reads; locked rules deny with 403, unsatisfied list filters can return empty 200 and view rules 404. Retain owner and separate superuser controls.", "components": ["pb-docs", "curl"], "sources": ["pb-docs:saedbdfbdfc48", "curl:s5e1383feef7d", "curl:s3db522258e44"], "status": "REASONED", "verify": [1]}, "verify-appwrite-records": {"text": "Broad collection grants defeat document restrictions; removing them must deny unrelated users while document grantees succeed. Compare client/server creation defaults.", "components": ["appwrite-docs"], "sources": ["appwrite-docs:s58ad28816c5d", "appwrite-docs:sa0bd132f7968"], "status": "REASONED", "verify": [1]}, "verify-appwrite-keys": {"text": "A correctly scoped server key reads despite empty permissions, while a key without scope fails; neither substitutes for an ordinary-user control.", "components": ["appwrite"], "sources": ["appwrite:s535fef1a5540"], "status": "REASONED", "verify": [1]}, "verify-registration": {"text": "On disposable Appwrite fixtures compare open signup 201 with root-only account-limit failure and independent email/IP restrictions; retain login/invitation controls.", "components": ["appwrite"], "sources": ["appwrite:sd96c9b2ddc30"], "status": "REASONED", "verify": [2]}, "verify-pb-bootstrap": {"text": "Guest and ordinary-user _superusers record creation must fail while a valid installer token creates the private operator; static dashboard assets prove no admin access.", "components": ["pb"], "sources": ["pb:s254c6ab00854", "pb:sa45421be29ae"], "status": "REASONED"}, "verify-appwrite-mfa": {"text": "Console account GET succeeds without MFA, fails for a fresh password-only MFA session, where 2.7.0 throws USER_MORE_FACTORS_REQUIRED with a response string not established here, and succeeds after TOTP completion with a code not already redeemed.", "components": ["appwrite", "appwrite-docs"], "sources": ["appwrite-docs:s221c6aaef8df", "appwrite:s535fef1a5540", "appwrite:sad0f2bc8be8f", "appwrite:se24bc37a7000"], "status": "REASONED"}, "verify-pb-writes": {"text": "Compare intended/unauthorized POST/PATCH/DELETE and privileged auth-record management; locked manageRule grants no management and empty-string configuration must fail.", "components": ["pb"], "sources": ["pb:sa45421be29ae", "pb:sf575d3a8df33"], "status": "REASONED"}, "verify-pb-files": {"text": "Before protection unsigned file GET works; Protected plus private viewRule rejects guests/unrelated-user file tokens with 404 while permitted-user bytes still arrive.", "components": ["pb", "curl"], "sources": ["pb:s41f09654a785", "curl:s82a34ee18632"], "status": "REASONED"}, "verify-pb-realtime": {"text": "Mutate a fixture while comparing collection and record subscriptions; deny unauthorized event delivery, retaining authorized delivery. PB_CONNECT/acknowledgment alone is insufficient.", "components": ["pb", "pb-docs"], "sources": ["pb-docs:sb850a71fad4d", "pb:s6e47fd88f4aa"], "status": "REASONED"}, "verify-pb-mfa": {"text": "Compare password-only completion with password-plus-OTP MFA and verified/unverified authRule logins; clearing authStore must not revoke a retained valid token.", "components": ["pb", "pb-docs"], "sources": ["pb:sf575d3a8df33", "pb-docs:s773e441dd51c"], "status": "REASONED"}, "verify-pb-ips": {"text": "Compare allowed/excluded superuser sources and forged forwarded headers; excluded sources must get 403 and direct backend access must fail with a working proxy control.", "components": ["pb"], "sources": ["pb:sa89696d9163c", "pb:sb92f4450f7a5"], "status": "REASONED"}, "verify-transport": {"text": "Compare HTTP/HTTPS and certificates, Appwrite API/function/site domains and configured/unknown Host; compare browser origins separately from record authorization.", "components": ["pb", "appwrite"], "sources": ["pb:se57548354680", "appwrite:s7a257b8fa05b"], "status": "REASONED"}, "verify-abuse": {"text": "Cross each configured threshold with ordinary non-excluded clients and compare disabled/enabled controls; PocketBase limiting yields 429 with later successful recovery.", "components": ["pb", "appwrite-docs"], "sources": ["pb:s3f0220ba5d12", "appwrite-docs:s000d05106450"], "status": "REASONED"}, "verify-email": {"text": "Request OTP, verification and recovery messages and confirm receipt and use; request success alone is not delivery evidence.", "components": ["pb", "appwrite-docs"], "sources": ["pb:sadc66f5e4911", "appwrite-docs:s38a1efa0c7d2"], "status": "REASONED"}, "verify-pb-recovery": {"text": "Compare plaintext/encrypted persisted settings, restart with retained key and restore local/S3 data; only permitted superuser file tokens may download backups.", "components": ["pb", "pb-docs"], "sources": ["pb:sb92f4450f7a5", "pb:sc8abb26b1da5", "pb-docs:s9029ab974cf1"], "status": "REASONED"}, "verify-appwrite-recovery": {"text": "Restore encrypted fixtures with the original key; a separate wrong-key fixture must not recover encrypted values.", "components": ["appwrite-docs"], "sources": ["appwrite-docs:s256ef73f7b85"], "status": "REASONED"}, "verify-uploads": {"text": "Compare permitted/unrelated uploads and size-boundary fixtures; mandatory processing requires rejecting over-limit files and confirming accepted-file encryption/scanning.", "components": ["appwrite"], "sources": ["appwrite:seb281dd186ea", "appwrite:s1c49e69946a1"], "status": "REASONED"}, "verify-functions": {"text": "Compare intended execute roles, unrelated users and privileged server keys, including disabled functions; confirm executor/orchestrator privacy from a second host.", "components": ["appwrite"], "sources": ["appwrite:s77e2ce52136f", "appwrite:s80913a396c73"], "status": "REASONED"}, "verify-bundle": {"text": "Search filenames using protected exact-secret patterns with a disposable marker control; client bundles must contain neither superuser tokens nor Appwrite API keys, and encoded/split forms can evade scanning.", "components": ["pb-docs", "appwrite-docs"], "sources": ["pb-docs:s773e441dd51c", "appwrite-docs:saa37bb5d4d7e"], "status": "REASONED", "verify": [3]}, "local-guards": {"text": "Recorded guard tests rejected placeholders and omitted/shortened arguments; this demonstrates shell behavior only, not service access or deployed-bundle scanning. The tests covered earlier forms, whose guard refusals left the subshell with status 0 and whose scan block result arms ended with status 0; current forms were not re-run, and gate bash -n and ShellCheck passes cover them.", "components": ["pb-docs", "appwrite-docs"], "sources": ["pb-docs:saedbdfbdfc48", "appwrite-docs:sa0bd132f7968", "appwrite-docs:saa37bb5d4d7e"], "status": "DEMONSTRATED", "evidence": "Local guard tests refused embedded `REPLACE_WITH_` placeholders, `example.com`, angle brackets, empty arguments, and omitted or shortened `set --` lines."}, "local-syntax": {"text": "All three bash fences have recorded local lint/syntax success, not live service evidence; ShellCheck version is recorded only in the body. The runs covered earlier forms, whose guard refusals left the subshell with status 0 and whose scan block result arms ended with status 0; current forms were not re-run, and gate bash -n and ShellCheck passes cover them.", "components": ["appwrite", "pb-docs", "appwrite-docs"], "sources": ["pb-docs:saedbdfbdfc48", "appwrite:sd96c9b2ddc30", "appwrite-docs:saa37bb5d4d7e"], "status": "DEMONSTRATED", "evidence": "All three bash blocks passed ShellCheck 0.11.0 and `bash -n` during authoring."}, "source-limits": {"text": "PocketBase CLI/--dev, Appwrite 2.7.0 requester/call-site and utopia-php/database 10.1.0 role matching and status/skip pins are cited; Database-level read checks and dispatch are not established at 10.1.0. SQL, Memory, Redis and Pool reads are traced; deployment adapters are unknown and live authorization is not demonstrated.", "components": ["appwrite-docs", "pb-docs", "appwrite", "utopia-php-database"], "sources": ["appwrite-docs:s58ad28816c5d", "pb-docs:s9029ab974cf1", "appwrite:s8222d84642bc", "utopia-php-database:s275a3120b495", "utopia-php-database:s655e2f6c04a0", "utopia-php-database:s4a3f8adc3023", "utopia-php-database:s000a4b53503d", "utopia-php-database:sa8f4d7b8a48c", "utopia-php-database:s2feb9534db52", "utopia-php-database:sc00b8981307b", "utopia-php-database:s41c5bffe6075", "utopia-php-database:s1d79e9cf0e57", "utopia-php-database:s7bf0e65d6cf0", "utopia-php-database:sb95aa1aa9ed9", "utopia-php-database:s9e70fd6370ea", "utopia-php-database:s7c5f032eabba", "utopia-php-database:s6a26dc1f9801", "utopia-php-database:sc9e144475031", "utopia-php-database:s361b4b790fbf", "utopia-php-database:s699a2c766b74", "utopia-php-database:s07bdbc102182", "utopia-php-database:s402631b77efa", "utopia-php-database:s97013afdaf6d", "utopia-php-database:sda6b15e8a7f2", "utopia-php-database:sf19fad9126ff", "utopia-php-database:s68e7d8672204", "utopia-php-database:s08b8af0098a5", "utopia-php-database:s915a0850c1bf", "utopia-php-database:s8795cdff3377", "utopia-php-database:sde36e9303448", "utopia-php-database:sa151e5aefbf8", "utopia-php-database:s7edf5f1161c1", "utopia-php-database:s9613234b8f17", "utopia-php-database:s70895d208fe4", "utopia-php-database:s1a92600403ce", "utopia-php-database:s604f86d30464", "utopia-php-database:s4aec3fdda679", "utopia-php-database:se488122e644f", "utopia-php-database:sab16fda30b23", "utopia-php-database:s970890cdc753", "utopia-php-database:s91c17eccdca7", "utopia-php-database:s784f964c7ba3", "utopia-php-database:s2c28e6247d1d", "utopia-php-database:sd26ea42ec217", "utopia-php-database:s1336d62998d1"], "status": "REASONED"}, "source-limits-sql-get": {"text": "At 10.1.0 MariaDB, MySQL, Postgres and SQLite use SQL getDocument; without joins it selects by ID, adds a tenant clause under shared tables and has no adapter authorization check, while join queries add the find permission hooks under the same gates. Whether Database read checks accept collection roles as at 7.3.9 is not established.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:s41c5bffe6075", "utopia-php-database:s361b4b790fbf", "utopia-php-database:s699a2c766b74", "utopia-php-database:s07bdbc102182", "utopia-php-database:s402631b77efa", "utopia-php-database:s97013afdaf6d", "utopia-php-database:s000a4b53503d"], "status": "REASONED"}, "source-limits-sql-find": {"text": "At 10.1.0 SQL find defaults to read and takes shared authorization roles; with status enabled it adds the main collection's permission hook only for documentSecurity on or the metadata collection, skipping a granted collection on join or bounded-page paths, plus a hook per joined table unless the collection's join map disables it. Database-level grant skipping is not established.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:s1d79e9cf0e57", "utopia-php-database:s7bf0e65d6cf0", "utopia-php-database:s0ae9c48f1b01", "utopia-php-database:sb95aa1aa9ed9", "utopia-php-database:sc3379278f3f2", "utopia-php-database:s9e70fd6370ea", "utopia-php-database:s000a4b53503d", "utopia-php-database:sa8f4d7b8a48c", "utopia-php-database:sc00b8981307b"], "status": "REASONED"}, "source-limits-sql-predicate": {"text": "At 10.1.0 the SQL permission hook matches requester roles and action in the collection's permissions side table with bound parameters and a shared-table tenant filter, returning 1 = 0 for no roles; Postgres overrides it with OR-ed JSONB containment tests on each row's permissions column, also 1 = 0 for no roles.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:s7c5f032eabba", "utopia-php-database:s6a26dc1f9801", "utopia-php-database:sc9e144475031"], "status": "REASONED"}, "source-limits-sql-disabled": {"text": "This guide reasons from the 10.1.0 SQL code that disabled authorization omits find permission filtering while query conditions, ordering and pagination remain, and that SQL getDocument without joins has no adapter authorization check to bypass; whether Database read checks are bypassed at 10.1.0 is not established.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:s41c5bffe6075", "utopia-php-database:s7bf0e65d6cf0", "utopia-php-database:s4a3f8adc3023", "utopia-php-database:sc00b8981307b", "utopia-php-database:s2feb9534db52"], "status": "REASONED"}, "source-limits-mongo": {"text": "At 10.1.0 Mongo getDocument filters by _uid through a tenant hook with no adapter authorization check; Mongo find passes its filters through a Mongo permission read hook whose code was not read, so its status gate and permission strings are not established at 10.1.0, with no live authorization demonstration.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:sda6b15e8a7f2", "utopia-php-database:sf19fad9126ff", "utopia-php-database:s68e7d8672204", "utopia-php-database:s08b8af0098a5"], "status": "REASONED"}, "source-limits-memory-redis": {"text": "At 10.1.0 Memory, intended for tests, fixtures and ephemeral workloads, and Redis, mirroring its surface, check no authorization in getDocument; their find paths keep only IDs matching requester roles for the action while authorization is enabled, without testing documentSecurity. This guide reasons that disabled status omits that filtering.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:s1a92600403ce", "utopia-php-database:s604f86d30464", "utopia-php-database:s4aec3fdda679", "utopia-php-database:se488122e644f", "utopia-php-database:sab16fda30b23", "utopia-php-database:s91c17eccdca7", "utopia-php-database:s784f964c7ba3", "utopia-php-database:s2c28e6247d1d", "utopia-php-database:s1336d62998d1", "utopia-php-database:sd26ea42ec217", "utopia-php-database:sc00b8981307b"], "status": "REASONED"}, "source-limits-pool": {"text": "At 10.1.0 Pool delegates getDocument and find to a pooled or pinned adapter, first setting its database, namespace, shared tables, tenant, tenant-per-document mode, authorization and locks from Pool's own; withTransaction does this before pinning. This guide reasons that a Pool-wrapped adapter reads with the authorization instance the database set.", "components": ["utopia-php-database"], "sources": ["utopia-php-database:s915a0850c1bf", "utopia-php-database:s8795cdff3377", "utopia-php-database:sa151e5aefbf8", "utopia-php-database:s7edf5f1161c1", "utopia-php-database:s9613234b8f17", "utopia-php-database:sde36e9303448", "utopia-php-database:sa8f4d7b8a48c"], "status": "REASONED"}, "source-limits-appwrite-adapter": {"text": "Appwrite 2.7.0's _APP_DB_ADAPTER (since 1.9.0) defaults to postgresql; pool registration maps the mariadb, mongodb, mysql and postgresql schemes to the MariaDB, Mongo, MySQL and Postgres adapters, DocumentsDB accepts only mongodb, and the factory wraps pools in Pool, with tenant databases getting the shared authorization. Which adapter a given deployment uses is not established.", "components": ["appwrite", "utopia-php-database"], "sources": ["appwrite:s6bb4fe4aeed0", "appwrite:s298b295bc4a2", "appwrite:s90d9a92f0ed4", "appwrite:s495c0d1f4835", "appwrite:s7061d34674a8", "appwrite:s8ed5acf4fc13", "appwrite:s3e984f6f58a1", "appwrite:s39e9760a01ee", "utopia-php-database:safe798d5dd9c"], "status": "REASONED"}, "source-limits-appwrite-dev": {"text": "Appwrite 2.7.0's development .env sets _APP_DB_ADAPTER=postgresql, a development default, not a production default; the root Compose file, which the install task reads as its Compose template, passes the variable to the appwrite service without a value. The 2.7.0 upgrade task keeps an installation's engine, assuming MariaDB when neither Compose nor .env sets _APP_DB_ADAPTER.", "components": ["appwrite"], "sources": ["appwrite:s2ce76c61491b", "appwrite:s80913a396c73", "appwrite:s320dd52ad7ad", "appwrite:s34cf9f92e6fb", "appwrite:se35f39b4f087", "appwrite:sa0f0758379af"], "status": "REASONED"}, "source-limits-appwrite-console": {"text": "Appwrite 2.7.0's console variables endpoint reports _APP_DB_ADAPTER with a mariadb fallback while pool registration falls back to postgresql, so by source reading an unset variable, and by this guide's inference an empty one, gives Postgres pools and a reported mariadb. Both read a set variable; this guide advises not relying on the reported value to identify the adapter.", "components": ["appwrite"], "sources": ["appwrite:s43512ed49a8b", "appwrite:sc7cb880ebc53", "appwrite:s298b295bc4a2", "appwrite:s90d9a92f0ed4", "appwrite:s2f6df67b8aea", "appwrite:s1da118060657"], "status": "REASONED"}, "source-limits-appwrite-install-env": {"text": "Appwrite 2.7.0's install task writes _APP_DB_ADAPTER to .env, unless reusing a configuration, from a prompt answer, its database (default postgresql, or detected) or the web installer's choice (default postgresql). Its Compose generator keeps only the chosen database service and, this guide reasons, the value-less entry, which the first detection step skips; how Compose resolves it is unknown.", "components": ["appwrite"], "sources": ["appwrite:sbdd71fb0bdee", "appwrite:s7c7d516c2e29", "appwrite:s354c70b426e9", "appwrite:s3f837fcdd474", "appwrite:sf0bdc23845a6", "appwrite:s8194e1cb510c", "appwrite:s7649c24d59ac", "appwrite:sade59f0be4d6", "appwrite:s8bd7139f8949", "appwrite:s48b953e7a273", "appwrite:s34cf9f92e6fb", "appwrite:s2ab6c91f62fd", "appwrite:s824989859386", "appwrite:s5fb71939c4b6", "appwrite:s254e58212ad0", "appwrite:s595b0f259c3a", "appwrite:s830783806300", "appwrite:s2851b656753a", "appwrite:s1141172bac7f", "appwrite:sc31ddc8f2659", "appwrite:se6ecc9107190", "appwrite:s2e99d3590696", "appwrite:s0ea4f620650f", "appwrite:sdfd43737a7ac", "appwrite:s3fc2bb20c0bb", "appwrite:s9cb7dec752e3", "appwrite:s7e549280b454", "appwrite:s3a92a2e12968", "appwrite:s79502c89f0ed", "appwrite:sa7170a7b4946"], "status": "REASONED"}, "source-limits-adapter-docs": {"text": "As served on 2026-11-04, Appwrite's environment documentation lists MariaDB and MongoDB and gives _APP_DB_ADAPTER a mongodb default, conflicting with Appwrite 2.7.0, which defaults to postgresql, a value that page does not list.", "components": ["appwrite-docs", "appwrite"], "sources": ["appwrite-docs:s2bf9a4746d28", "appwrite:s6bb4fe4aeed0", "appwrite:s298b295bc4a2"], "status": "REASONED"} } } ```` ====================================================================== ==> headless-browser-services.md ====================================================================== # Headless browser services: Chrome DevTools Protocol, Selenium Grid, browserless, and Playwright server Chrome DevTools Protocol, Selenium Grid, browserless and Playwright server let programs control a real browser for testing, scraping or AI agents. This guide helps your AI assistant configure each securely, including automation ports on loopback or a restricted private network, authenticated remote access, limited browser egress, and protected connection URLs. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/headless-browser-services.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Chromium 154.0.8037.57, browserless (open source) 2.56.7, browserless Playwright file-protocol fix 2.51.0, Playwright source v1.58.2, and curl documentation curl-8_14_1; 2 further sources are pinned to a commit and 6 carry no recorded version. Metadata reviewed 2026-10-05; documentation checked October 2026. The guide records 32 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. Agent and scraping stacks run these to drive a real browser, and that is exactly the exposure: whoever reaches the endpoint gets JavaScript execution inside the pages the browser opens, the profile's cookie jar, and a pivot from the service's own network position, including internal panels and the cloud metadata endpoint ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). An exposed browser automation port is usually unauthenticated code execution and credential theft, not just data. None of these command-line automation endpoints requires authentication by default. Inspect the actual listeners and any forwarders: the headline mistake is letting the network reach the port at all. Restrict the service's own egress as well as its inbound reach, front the listeners per [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), keep them private per [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md) and [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md), and treat every connection URL as a secret ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). ## Chrome DevTools Protocol A Chrome or Chromium started with `--remote-debugging-port=9222` speaks the DevTools Protocol without client authentication. At `154.0.8037.57` (commit `73c14f6228d7cd537c855007e8f88678969cc0eb`), the full browser, including `--headless`, and `chrome-headless-shell` bind that TCP listener only to `127.0.0.1`, trying `::1` if the IPv4 bind fails. Neither has a `--remote-debugging-address` switch at this version. A non-loopback DevTools listener therefore means a forwarder or a modified build, not an address flag on either stock binary. Publishing a container port cannot reach a socket that is still loopback-bound inside the container; inspect the process that actually binds the published port, not only the compose file. For an explicit nonzero port, both try the same port on IPv6 and fail to start the listener if both binds fail. They do not silently choose another port when `9222` is busy. Explicit `--remote-debugging-port=0` requests an ephemeral port. The full browser also has a separate, feature-gated approval mode enabled through `chrome://inspect`: it reads the previous port from `DevToolsActivePort` or starts with `9222`. For a nonzero requested port, it tries IPv4 at that port, IPv4 at port `0`, IPv6 at the requested port, and IPv6 at port `0`, stopping at the first success. This mode asks for user approval; the command-line port and pipe modes take precedence over it. Inspect the actual listening address and port in `ss -tlnp` and the `DevTools listening on` stderr message, plus `DevToolsActivePort` when written, rather than assuming every debugging session uses `9222`. In command-line port mode, `GET /json` lists target WebSocket debugger URLs and `/json/version` returns the browser WebSocket debugger URL. A client on that socket has `Runtime.evaluate` (arbitrary JavaScript in pages), the Network domain (cookies and credentials), and control of tabs. The Host and WebSocket Origin checks are not client authentication. There is no password flag for this mode, so keep the port on loopback and reach it over an SSH tunnel ([tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md)), or use an authenticating proxy on a private network. Never expose an unauthenticated forward or proxy. Approval mode rejects the JSON discovery routes with `404`, so that status is not proof of isolation. The Chrome 136 change ignores `--remote-debugging-port` and `--remote-debugging-pipe` when they would debug the default user data directory. At the pinned source this check applies to `GOOGLE_CHROME_BRANDING` desktop builds on Windows, macOS and Linux, not normal Chromium builds (a test-only hook can enable it there). Use an explicit, non-default `--user-data-dir`. The `RemoteDebuggingAllowed` enterprise policy can disable remote debugging: the browser checks `prefs::kDevToolsRemoteDebuggingAllowed` before starting either command-line mode or approval mode. The separate headless-shell startup path has no corresponding profile or policy check. The existing Chrome vendor announcement says Chrome for Testing retains the older behaviour; the source subset reviewed here does not establish its build branding, so that exemption rests on the vendor announcement, not an inferred branding equivalence. `--remote-debugging-pipe` uses pipes, not a TCP listener. Supplying it alone opens no DevTools TCP port; supplying `--remote-debugging-port` as well can still start the independent TCP listener in both the browser and headless shell. Pipe access still grants browser control. ## Selenium Grid Selenium Grid ships with no authentication enabled: the Router's basic-auth credentials (`--username` and `--password`, or the `SE_ROUTER_USERNAME` and `SE_ROUTER_PASSWORD` variables on the Docker images) are unset by default. Its default bind is not a loopback guarantee: the docs describe `--host` as "usually determined automatically", the Docker images leave `SE_BIND_HOST` unset, and Grid 3 binds `0.0.0.0` outright, so treat any Grid whose bind you have not set and observed as reachable, and set an explicit `--host` (with `--bind-host`) to a private interface. The Router, Hub, Standalone and the Grid web UI share port `4444`; a Node listens on `5555`. Distributed deployments add more unauthenticated listeners: the event bus on `4442` and `4443`, and the Distributor, Session Map, event bus HTTP, and New Session Queue on `5553`, `5556`, `5557` and `5559`. Firewall all of them. The event bus is not protected by the Router's basic auth; `--registration-secret` makes participants validate each other's event messages, but it is carried in those messages rather than encrypting the transport, so it does not make a reachable bus safe on its own: run the bus on a private, trusted network with matching secrets on every component. `--password` and `--registration-secret` are command-line values: each puts its secret in the argv of every process that receives it, from its start until the process overwrites its arguments, if it does, readable through `ps` and `/proc//cmdline` by other local accounts as well as by the same account and root, and command-line auditing on the host can record it beyond that. Sources has no reference for a Grid input outside argv for either secret. Do not assume `SE_ROUTER_PASSWORD` avoids this. Sources has no reference for how the Docker images' start scripts pass that variable to the Router, so it may still reach the `java` command line as `--password`; read the start script in the image you run before relying on the variable. Wherever either secret reaches a command line (always for the flags; for `SE_ROUTER_PASSWORD` unless the start script shows otherwise), run Grid components only on hosts where every local account other than the one running them and root is trusted with these secrets. A secret that does stay in a process's environment is still readable through `/proc//environ` by the same account and root while that process, or any process that inherited it, runs. The Docker browser images add two more listeners: they start VNC by default (`SE_START_VNC` defaults to `true`) on `5900` with noVNC on `7900`, and the vendor's own example connects with the password `secret`; change it with `SE_VNC_PASSWORD`, disable the prompt deliberately only with `SE_VNC_NO_PASSWORD`, or turn VNC off with `SE_START_VNC=false`. `SE_VNC_PASSWORD` is an environment credential: it is readable through `/proc//environ` by the same account and root while any process holding it runs. Sources has no reference for how the image passes `SE_VNC_PASSWORD` to the VNC server, so, as with `SE_ROUTER_PASSWORD`, that variable may reach a command line. The guidance for `SE_VNC_PASSWORD` is to read the image's start script before relying on the variable, and to apply the trusted-host condition above if the variable reaches a command line. Selenium can terminate TLS itself with `--https-certificate` and `--https-private-key`, but it is not enabled by default, and the Router's basic auth travels in clear text without it; enabling native HTTPS or, more commonly, fronting the Grid per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) is what protects the credentials in transit. ## browserless Open-source browserless `2.56.7` listens on port `3000` and reads `TOKEN` as `process.env.TOKEN || null`: an unset or empty value becomes `null`, with no generated token. Its default is unauthenticated endpoints, including routes that run caller-supplied browser code. The current Docker configuration reference agrees; the older quickstart's generated-token claim does not match this release. The current reference documents the same default for Enterprise, but its private image was not inspected or run here, so equivalence is not source-verified. Always set a long, randomized `TOKEN` (never a vendor sample, never committed), and publish the port to loopback (`-p 127.0.0.1:3000:3000`) or keep it on an internal container network with no host mapping. `TOKEN` reaches the server through its environment, which keeps it out of the server's argv (a value typed on a launcher's command line, such as after `-e TOKEN=`, is in that launcher's argv instead) but not out of reach: it is readable through `/proc//environ` by the same account and root for as long as the server, or any process that inherited it, runs. The token is presented in the connection URL, so proxy and access logs become credential stores; front the service with TLS so it is unreadable in transit, and handle the token as [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) describes. Require browserless **2.51.0 or later containing [fix a18a1231ead2ecc4114347d5d1fafd69bffeb735](https://github.com/browserless/browserless/commit/a18a1231ead2ecc4114347d5d1fafd69bffeb735)**, and keep `ALLOW_FILE_PROTOCOL=false`. Earlier builds can allow authenticated Playwright WebSocket clients to read container files despite that setting (CVE-2026-92811). The [v2.51.0 changelog](https://raw.githubusercontent.com/browserless/browserless/v2.51.0/CHANGELOG.md) records the fix; the [CVE record](https://raw.githubusercontent.com/CVEProject/cvelistV5/b0b0976792a7c9d6d66ee553050c21cb088a0659/cves/2026/92xxx/CVE-2026-92811.json) lists a conflicting affected range of 1.44.0 through 2.56.7 inclusive, so run the file-protocol check below rather than rely on the version alone. browserless terminates no TLS of its own, so the fronting layer is where HTTPS lives. ## Playwright server `browserType.launchServer()` and `playwright run-server` expose a `ws://` endpoint, and there is no password or user model. Do not rely on the endpoint path as a secret: `run-server` defaults its `--path` to `/`, so the CLI form has no secret path at all, and while `launchServer()` generates an unguessable path, the server answers `GET /json` with its own endpoint path, so a client that can reach the port can discover it. Treat a reachable Playwright port as the finding. The exposure is binding it outward, which the Docker documentation's own `run-server --host 0.0.0.0` example does; Playwright's documentation warns that a process that reaches the endpoint "can take control of the OS user". Bind the server to `127.0.0.1` explicitly, never log or commit the `wsEndpoint` URL or bake it into an image, and put a reverse proxy in front that authenticates both the WebSocket upgrade and the discovery routes and terminates `wss://` (`connect()` accepts custom `headers` a proxy can require) per [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) and [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md). Playwright serves no TLS itself. ## Shared exposures Every one of these drives a real browser, so an exposed endpoint is typically unauthenticated code execution plus session theft plus a pivot: the browser can reach internal panels, unauthenticated internal APIs, and the cloud metadata endpoint from inside your network ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)), which is why restricting the service's egress matters as much as its inbound reach. Local-file reads through `file://` are part of that reach for a raw CDP, Selenium, or Playwright browser, though a fronting tool may block them (browserless requires the fixed version and setting described above, plus the file-protocol check below). One path ties the four together: Selenium, Playwright and browserless all drive Chromium over the DevTools Protocol in the common case, so a debug port opened "just to inspect" a Node or a browserless container (a stray `-p 9222:9222`) bypasses whatever authentication the front tool has. Keep 9222 unpublished wherever these run. ## Verify REASONED: following block; this introduction and the listener inventory below for every DevTools, Grid, browserless, VNC and Playwright port it names, per the guide's cited Selenium Grid getting started, Selenium Docker images env vars, browserless Docker configuration and Chromium 154.0.8037.57 browser and headless-shell loopback factory entries. No isolated network namespace was available for a live listener, so no exposed or fixed service ran. During marking review (October 2026) both Verify blocks passed bash -n under bash 5.3.9, and the inventory's command, run without sudo on the review host as ss -tlnp (iproute2 6.19.0), showed none of these ports listening, with an empty process column, so it tested no target. The bash -n and ss runs recorded here covered both blocks in earlier forms: the inventory then ran ss at top level with no status of its own, and the guarded probe block's guard refusals left the subshell with status 0. The current forms of both blocks pass bash -n and ShellCheck 0.11.0 in the repository's gate suite, which runs both over every block. Stub runs of the current inventory with no network, in a clean shell and under an inherited `set -e`, `readonly` and `declare -i`, exited 0 when the stub ss listed only loopback listeners, 1 when it listed a named port on a wildcard address, 3 when it failed and 2 when ss was missing. Exposed, a listed port shows a wildcard or public bind; fixed, loopback or an internal address only. The inventory block exits 1 when a port it names is bound to a wildcard address, 3 when ss fails, 2 when ss is not installed, so nothing is listed, and otherwise 0, leaving every other address for you to judge. Every live check below is **REASONED**, not demonstrated: the authoring host forbids opening listeners without an isolated network namespace, and has none. No exposed or fixed service was run. Each names its expected exposed and fixed result so it discriminates when run against a live instance, based on the cited vendor documentation and pinned sources. Run each probe from an external vantage, not the service host, and run its positive control (for a token-gated service, an authorized request that succeeds) so a dead service or a blocked local socket is not misread as fixed. A transport failure is inconclusive on its own: read the `err` field and confirm the failure happened while connecting to the target (a connection refusal from that vantage), not because a local policy blocked the probe or a slow response outlasted `--max-time`. These write-out fields need curl 7.75.0 or newer, and an IPv6 literal needs brackets in the URL. ```bash # Listener inventory, REASONED as the paragraph above records: it follows the cited vendor documentation and pinned sources; # the authoring host lacks an isolated network namespace for authorized live listeners. # On the host: these listeners should be bound to loopback or an internal interface, not a wildcard. # The block also checks the distributed Grid ports (5553, 5556, 5557, 5559); judge your Playwright port yourself if you run it. ( # a subshell, so the exits below end only the subshell command -v ss >/dev/null || { echo "ss is not installed here; not listing sockets"; exit 2; } set -- 0 "$(if ss -tlnp; then printf '\n0'; else printf '\n%s' "$?"; fi)" # the block's status so far (1, a finding, outranks 3, inconclusive, which outranks 0; a missing ss has already exited 2 above), then the listeners set -- "$1" "${2%$'\n'*}" "${2##*$'\n'}" # status, the listener table, then ss's exit status printf '%s' "$2" # read every listener; 9222/4442/4443/4444/5555/3000/5900/7900/5553/5556/5557/5559: loopback or internal only [ "$3" = 0 ] || { echo "ss -tlnp exit $3: the listener inventory failed; inconclusive"; set -- 3 "$2" "$3"; } if printf '%s' "$2" | awk '$4 ~ /:(9222|4442|4443|4444|5555|3000|5900|7900|5553|5556|5557|5559)$/ && ($4 ~ /^0\.0\.0\.0:/ || index($4, "*:") == 1 || index($4, "[::]:") == 1 || index($4, ":::") == 1) { print; found = 1 } END { exit !found }'; then echo "FINDING: the rows just above bind a port this block names to a wildcard address, the exposed state"; set -- 1 "$2" "$3" else case "$?" in 1) ;; *) echo "awk could not read the listener list; inconclusive"; [ "$1" = 1 ] || set -- 3 "$2" "$3" ;; esac fi echo "block exit $1; every address that is not a wildcard, and your Playwright port, is left for you to judge" exit "$1" ) ``` ### DevTools listener and service probes REASONED: the listener inventory above for every port it names, and this DevTools TCP listener check, per the guide's cited Chromium 154.0.8037.57 entries for the browser loopback factories, the headless-shell loopback factory and the actual bound address and DevToolsActivePort output. The same REASONED marking also rests on the guide's cited Selenium Grid getting started, Selenium Docker images env vars and browserless Docker configuration entries for the other ports. A google-chrome binary of unchecked version is present in the authoring environment, but no isolated network namespace was available (creating one was refused), so no DevTools listener or forwarder was started and neither state was observed; the expected exposed and fixed results are those stated here. **REASONED DevTools listener check:** for the pinned stock browser and headless shell, expect only `127.0.0.1` or `::1` for the DevTools TCP listener. Inspect both address families and every actual port, including ephemeral ports, plus forwarders in the host and container network namespaces. Exposed, a forwarder supplies a reachable non-loopback listener; fixed, no unauthenticated external path remains while the local browser endpoint still answers. The pinned socket factories and HTTP handler below establish these expected results. REASONED: the guard and substitution instructions for the DevTools, Grid, browserless and Playwright probes, per the guide's cited curl diagnostic variables entry (pinned tag curl-8_14_1) and Chromium 154.0.8037.57 host check entry. During marking review (October 2026) the guarded block ran in bash 5.3.9 with a stub curl and no network: unchanged, it printed its substitute-your-own-address refusal; without its set -- line, the paste-the-whole-block refusal; with two values, the exactly-1-value refusal. The guarded block's marking-review testing (October 2026, bash 5.3.9, stub curl, no network) also showed that, with 203.0.113.10 substituted, the stub received http://203.0.113.10:9222/json/version, and the 4444/status and 3000/pressure edits reached the stub as written. The stub runs recorded here covered the guarded block in an earlier form, which took one value, ended with curl's own status and whose guard refusals left the subshell with status 0; none was re-run on the current form, which the repository's gate suite checks with bash -n and ShellCheck 0.11.0 like every block. No endpoint was probed, so the exposed and fixed results below remain unobserved. For the DevTools, Grid, browserless and Playwright ports, guard the address in the block below so it forces a real substitution, and read the write-out. Replace `9222` in the curl URL with the actual externally forwarded port if different. Substitute `external` as the block's second value for the probe from another host. For the local positive control, run the same guarded block inside the browser's network namespace with its actual loopback address and DevTools port, and `control` as its second value. Substitute an IP literal, not a name, for the DevTools probe, because the endpoint rejects a forwarded `Host` that is not an address or localhost. REASONED: following block; external discovery/authentication probes and local positive controls follow the cited vendor documentation and pinned sources; the authoring host has no isolated network namespace for authorized live listeners. ```bash ( # a subshell, so your own script arguments are untouched set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_YOUR_PUBLIC_IP' 'REPLACE_WITH_external_OR_control' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo "paste the whole block, including its set -- line; not probing"; exit 2; } shift [ "$#" -eq 2 ] || { echo "the set -- line needs exactly 2 values; not probing"; exit 2; } case "$2" in external|control) : ;; *) echo "substitute external (the probe from another host) or control (the local positive control) as the second value on the set -- line above; not probing"; exit 2 ;; esac case "$1" in *REPLACE_WITH_*|*YOUR_PUBLIC_IP*|"") echo "substitute your own address on the set -- line above; not probing"; exit 2 ;; *) set -- "$1" "$2" "$(if curl -q -g -sS -i --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "http://$1:9222/json/version"; then printf '\n0'; else printf '\n%s' "$?"; fi)" ;; esac set -- "$2" "${3##*$'\n'}" "${3%$'\n'*}" # external or control, then curl's exit status and its output printf '%s' "$3" set -- "$1" "$2" "${3%$'\n'}" set -- "$1" "$2" "${3##*$'\n'}" # external or control, curl's exit status, then its http= exit= err= line case "$1:$2:$3" in external:0:"http=404 "*) echo "HTTP 404: approval mode's 404 is not a pass, and this block cannot tell it from another 404; inconclusive"; set -- 3 ;; external:0:*) echo "FINDING: an HTTP answer from this vantage, so the endpoint is reachable, which is not the fixed state"; set -- 1 ;; external:7:*) echo "curl exit 7: no connection from this vantage, consistent with isolation but not proof of the configuration; read err to confirm the target refused it, and run the local positive control"; set -- 0 ;; control:0:"http=2"??" "*) echo "the positive control answered; read its body (for DevTools, JSON containing webSocketDebuggerUrl)"; set -- 0 ;; control:0:*) echo "the positive control did not answer with a 2xx, so it failed; inconclusive"; set -- 3 ;; *) echo "curl exit $2: a transport failure is inconclusive on its own, and a timeout may have come after the connect; read err"; set -- 3 ;; esac echo "block exit $1; the headers and body above are left for you to judge against the expectations for the port you probed" exit "$1" ) ``` REASONED: preceding block; the external DevTools, Selenium Grid Router and Node, browserless and Playwright probes and their local positive controls, per the guide's cited Chromium 154.0.8037.57 host check, JSON discovery, approval-mode 404 and browser WebSocket URL entry, Selenium Grid CLI options, browserless 2.56.7 TOKEN initialization and HTTP and WebSocket authentication rejection entries. The same REASONED marking also rests on the guide's cited browserless pressure API entry, Playwright Docker entry, Playwright BrowserType.launchServer and connect entry, Playwright run-server default path, generated launchServer path and GET /json discovery (pinned tag v1.58.2) entry, and curl diagnostic variables added in 7.75.0 and configuration input from stdin (pinned tag curl-8_14_1) entry. No container runtime or isolated network namespace was available, so no service ran. During marking review (October 2026) the guard branches ran with a stub curl as recorded above, and the token configuration pattern, run as an author-built block following this paragraph's instructions (the guide prints no such block) with a stub curl and a dummy token holding a double quote and a backslash, kept the token out of argv and sent the escaped url line on stdin. The guard-branch stub runs covered the guarded block in an earlier form, which took one value, ended with curl's own status and whose guard refusals left the subshell with status 0; none was re-run on the current form, which the repository's gate suite checks with bash -n and ShellCheck 0.11.0 like every block. The expected exposed and fixed results are those stated here. **REASONED command-line DevTools port probe:** exposed through an unauthenticated forwarder, it returns a `200` JSON body containing `webSocketDebuggerUrl`. Fixed, the external request cannot reach that endpoint while the local positive control still returns it. A `404` from approval mode is not a pass. A connection refused or a timeout that happened during the connect is consistent with isolation from that vantage, not proof of the configuration. With `external`, the block exits 1 for any HTTP answer except a `404`; 3 for a `404`, which it cannot tell from approval mode's, and for any failure other than curl exit 7, including a timeout it cannot place before or after the connect; and 0 for curl exit 7, which it reports as consistent with isolation but not proof. A `404` from a Playwright port, which is already the finding, therefore also exits 3. With `control`, it exits 0 for a `2xx` answer and 3 otherwise. To check the other services, change the URL in the block's `curl` line, leaving the guard lines intact, to each port in turn: `http://$1:4444/status` for a Selenium Grid Router, Hub or Standalone, `http://$1:5555/status` for a Node, `http://$1:3000/pressure` for open-source browserless `2.56.7`. The Selenium Grid `4444/status` result reads as follows: a `200` JSON `ready` and node inventory is exposure; the `-i` prints the headers, so a `401` with `WWW-Authenticate: Basic` means the Router auth is on but the port is still reachable, which is not the fixed state. The open-source browserless `2.56.7` `3000/pressure` instruction is: with `TOKEN` unset or empty, expect `200` with load JSON; with a nonempty `TOKEN`, expect `401` for both the tokenless request and a deliberately wrong `?token=` value, then confirm the correct token returns the load JSON. These are REASONED expectations from the cited configuration, API documentation and HTTP rejection handler, not live observations; this authoring environment has no container runtime and cannot fetch runtime dependencies. A `401` establishes authentication, not network isolation: the fixed deployment must also deny the external path while an authorized internal control still succeeds. Pass the correct token through curl's stdin configuration, as a `url = "...?token=..."` line fed to `--config -`, not a literal URL in shell history or argv. Because the config value is double-quoted, escape a backslash and then a double quote in the token before substituting it, and refuse a token containing a control character, so a pasted newline cannot inject a second directive. Enter the token at a hidden prompt in a clean shell under the `read` exception guards of CONTRIBUTING rule 7 (`search-engines.md`'s paired block is the model): `trap - DEBUG RETURN ERR` first, then `set +x +a +e` before the read, `unset -n` and then `unset -v` on the variable, failing closed if either fails, and a refusal of a readonly `IFS`. Without `+a`, an inherited `set -a` would export the token. A token typed into the command still enters shell history and `set -x` tracing, and stdin closes the argv channel only, so the token stays visible to the account owner and root. For the Playwright server on the port you configured (`launchServer` uses an ephemeral port unless you set one; `run-server` takes `--port`), any HTTP answer means the port is reachable and is already the finding. The distinguishing behaviour for each is in the cited vendor pages. REASONED: browserless Playwright WebSocket file-protocol enforcement on every enabled route, per the guide's cited browserless 2.51.0 Playwright WebSocket route table, browserless 2.51.0 Playwright WebSocket enforcement and blocked-URL policy close, and Browserless ALLOW_FILE_PROTOCOL configuration and false default entries. It needs a running browserless 2.51.0 container and a compatible Playwright client; the authoring environment has no container runtime, no Playwright or browserless package and no network to fetch them, and the check has no shell block, so nothing here ran locally. Exposed, page.evaluate returns the canary contents; fixed, the navigation is refused with a blocked-URL policy close. **REASONED browserless file-protocol check:** no live instance was tested: the authoring host forbids opening listeners without an isolated network namespace, and has none. With `ALLOW_FILE_PROTOCOL=false`, use a valid token and a compatible Playwright client on each enabled Playwright WebSocket route. The v2.51.0 [route table](https://github.com/browserless/browserless/blob/v2.51.0/src/http.ts#L91-L103) defines `/chromium/playwright`, `/chrome/playwright`, `/edge/playwright`, `/firefox/playwright` and `/webkit/playwright`, plus the `/playwright/chromium`, `/playwright/chrome`, `/playwright/firefox` and `/playwright/webkit` aliases; test every one that is enabled. First confirm ordinary HTTP navigation succeeds. Then call `page.goto('file:///tmp/secureconfig-canary.txt')` against a harmless canary with known contents already confirmed readable by the browser process inside the container. On an exposed build, expect `page.evaluate(() => document.body.innerText)` to return the canary contents. The [fixed implementation](https://raw.githubusercontent.com/browserless/browserless/v2.51.0/src/browsers/browsers.playwright.ts) refuses the navigation and closes the session with a blocked-URL policy reason. Confirm that reason; missing files, authentication failures, connection failures, and timeouts are inconclusive. Passing `/pressure` or a CDP-only check does not establish Playwright enforcement. ## Sources (checked October 2026) - Chromium 154.0.8037.57 browser loopback factories and approval-mode port fallback: https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.cc#L69-L158 - Chromium 154.0.8037.57 browser branding/profile and policy checks; approval-mode startup, with the `kDefaultDevToolsPort` 9222 fallback. Chromium 154.0.8037.57 sources: https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.cc#L160-L282 and https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.h#L26 - Browser command-line pipe/port startup and precedence over approval mode (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.cc#L325-L417 - Headless-shell loopback factory and pipe/port startup (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/headless/lib/browser/headless_devtools.cc#L31-L145 - Headless-shell command-line port and pipe handling (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/headless/lib/browser/command_line_handler.cc#L199-L206 - Content pipe and port switch definitions (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/public/common/content_switches.cc#L596-L607 - Actual bound address, stderr endpoint and DevToolsActivePort output (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L267-L312 - Host check, JSON discovery, approval-mode 404 and browser WebSocket URL (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L459-L630 - WebSocket Origin check, approval mode and unauthenticated command-line mode (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L811-L880 - Target WebSocket URL serialization (Chromium 154.0.8037.57): https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L1032-L1055 - Chrome DevTools Protocol remote debugging: https://developer.chrome.com/docs/devtools/remote-debugging/ - Chrome DevTools Protocol domains (Runtime, Network, Page): https://chromedevtools.github.io/devtools-protocol/ - Chrome 136 user-data-dir change and Chrome for Testing exemption: https://developer.chrome.com/blog/remote-debugging-port - Selenium Grid CLI options (host, bind-host, username, password, https-certificate): https://www.selenium.dev/documentation/grid/configuration/cli_options/ - Selenium Grid getting started (components, ports, distributed topology): https://www.selenium.dev/documentation/grid/getting_started/ - Selenium Docker images env vars (SE_START_VNC, SE_VNC_PASSWORD, SE_BIND_HOST, SE_ROUTER_USERNAME): https://github.com/SeleniumHQ/docker-selenium/blob/aafe4d6136f3bb5afcd9b7cb691c624516d06e1b/ENV_VARIABLES.md - browserless Docker configuration (TOKEN, ALLOW_FILE_PROTOCOL, port 3000): https://docs.browserless.io/enterprise/docker/config - CVE-2026-92811 record (affected range 1.44.0 through 2.56.7 inclusive; conflicts with the vendor fix record): https://raw.githubusercontent.com/CVEProject/cvelistV5/b0b0976792a7c9d6d66ee553050c21cb088a0659/cves/2026/92xxx/CVE-2026-92811.json - browserless 2.51.0 Playwright WebSocket route table: https://github.com/browserless/browserless/blob/v2.51.0/src/http.ts#L91-L103 - browserless 2.51.0 changelog (Playwright WebSocket file-protocol fix): https://raw.githubusercontent.com/browserless/browserless/v2.51.0/CHANGELOG.md - browserless 2.51.0 pinned Playwright file-protocol fix a18a1231ead2ecc4114347d5d1fafd69bffeb735 (PR #5407): https://github.com/browserless/browserless/commit/a18a1231ead2ecc4114347d5d1fafd69bffeb735 - browserless 2.51.0 Playwright WebSocket enforcement and blocked-URL policy close: https://raw.githubusercontent.com/browserless/browserless/v2.51.0/src/browsers/browsers.playwright.ts - browserless 2.56.7 TOKEN initialization: https://github.com/browserless/browserless/blob/v2.56.7/src/config.ts#L250 - browserless 2.56.7 HTTP and WebSocket authentication rejection: https://github.com/browserless/browserless/blob/v2.56.7/src/server.ts#L103-L117 - browserless 2.56.7 HTTP route authentication dispatch: https://github.com/browserless/browserless/blob/v2.56.7/src/server.ts#L264-L271 - browserless 2.56.7 WebSocket route authentication dispatch: https://github.com/browserless/browserless/blob/v2.56.7/src/server.ts#L445-L454 - browserless open-source Docker default and management endpoints (rolling documentation, checked September 2026): https://docs.browserless.io/enterprise/open-source - browserless pressure API request and response (rolling documentation, checked September 2026): https://docs.browserless.io/enterprise/utility-functions/pressure - Playwright BrowserType.launchServer and connect: https://playwright.dev/docs/api/class-browsertype - Playwright Docker (run-server remote connection): https://playwright.dev/docs/docker - Browserless ALLOW_FILE_PROTOCOL configuration and false default (pinned tag v2.51.0; version basis 2.51.0): https://raw.githubusercontent.com/browserless/browserless/v2.51.0/src/config.ts - Playwright run-server default path, generated launchServer path and GET /json discovery (pinned tag v1.58.2). Playwright sources (pinned tag v1.58.2): https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/cli/program.ts#L292-L307, https://raw.githubusercontent.com/microsoft/playwright/v1.58.2/packages/playwright-core/src/browserServerImpl.ts, https://raw.githubusercontent.com/microsoft/playwright/v1.58.2/packages/playwright-core/src/remote/playwrightServer.ts - curl diagnostic variables added in 7.75.0 and configuration input from stdin (pinned tag curl-8_14_1): https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79, https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - chromium: Chromium (154.0.8037.57) - browserless-token-default: browserless (open source) (2.56.7) - browserless-file-fix: browserless Playwright file-protocol fix (2.51.0) - playwright-source: Playwright source (v1.58.2) - cdp: Chrome DevTools Protocol (unknown) - chrome-change: Chrome remote-debugging announcement (unknown) - selenium: Selenium Grid (unknown) - selenium-docker: Selenium Docker images (aafe4d6136f3bb5afcd9b7cb691c624516d06e1b) - browserless: browserless (unknown) - browserless-docs: browserless documentation (rolling) (unknown) - playwright: Playwright (unknown) - curl-docs: curl documentation (curl-8_14_1) - cve-record-source: CVE-2026-92811 record snapshot (b0b0976792a7c9d6d66ee553050c21cb088a0659) Claims (full source mapping in the JSON block below): - cdp-bind (REASONED; chromium): Command-line DevTools binds 127.0.0.1, falling back to ::1; stock browser and headless shell have no remote-debugging-address switch. - cdp-port (REASONED; chromium): Explicit nonzero DevTools ports do not fall back to another port; port 0 requests an ephemeral port. - cdp-approval (REASONED; chromium): Feature-gated approval mode uses DevToolsActivePort or 9222, with requested/ephemeral IPv4 then IPv6 fallback; command-line modes take precedence. - cdp-discovery (REASONED; chromium): Command-line /json and /json/version disclose debugger URLs; approval-mode 404 does not establish isolation. - cdp-auth (REASONED; chromium): Command-line CDP has no client authentication or password flag; Host and Origin checks do not authenticate clients. - cdp-capabilities (REASONED; cdp): CDP grants page JavaScript, cookie/credential and tab control; restrict ingress, browser egress and local-file reach. - cdp-private (REASONED; chromium): Keep DevTools loopback/private behind authenticated access; inspect forwarders and container namespaces, since publishing cannot reach container loopback. - cdp-profile (REASONED; chrome-change, chromium): Chrome 136 requires non-default user data; pinned desktop branding check excludes normal Chromium, and Chrome for Testing exemption rests on the announcement. - cdp-policy (REASONED; chromium): RemoteDebuggingAllowed gates browser command-line and approval modes; headless-shell startup lacks the corresponding profile/policy check. - cdp-pipe (REASONED; chromium): Pipe mode alone opens no TCP port; supplying a port too can start an independent listener, and pipe access still grants browser control. - grid-auth (REASONED; selenium, selenium-docker): Router username/password are unset by default, including Docker SE_ROUTER_USERNAME and SE_ROUTER_PASSWORD. - grid-bind (REASONED; selenium, selenium-docker): Grid host is usually autodetected; Docker SE_BIND_HOST is unset and historical Grid 3 binds 0.0.0.0. Set and observe a private host with bind-host. - grid-ports (REASONED; selenium): Router, Hub, Standalone and UI use 4444; Nodes use 5555. Keep all private. - grid-distributed (REASONED; selenium): Distributed event bus uses 4442/4443; Distributor, Session Map, bus HTTP and New Session Queue add 5553/5556/5557/5559. - grid-bus (REASONED; selenium): Router Basic auth does not protect the bus; registration-secret validates event messages without encrypting transport, so isolate every participant. - grid-vnc (REASONED; selenium-docker): Docker enables VNC by default on 5900 and noVNC on 7900; replace the example secret password or disable VNC, and avoid passwordless mode. - grid-tls (REASONED; selenium): Native https-certificate/https-private-key or a TLS proxy protects Router Basic credentials; TLS is not on by default. - browserless-auth (REASONED; browserless-token-default, browserless-docs): Open-source 2.56.7 reads an unset or empty TOKEN as null; no token is generated and endpoints are unauthenticated by default. - browserless-bind (REASONED; browserless): browserless uses 3000; publish 127.0.0.1:3000:3000 or keep an internal network with no host mapping. - browserless-token (REASONED; browserless): Use a randomized TOKEN, protect token-bearing connection URLs and logs, and terminate TLS at the fronting layer. - browserless-file (REASONED; browserless, browserless-file-fix): Require browserless 2.51.0 or later containing the pinned Playwright fix, keep ALLOW_FILE_PROTOCOL=false, and run the file-protocol check. - playwright-auth (REASONED; playwright): Playwright server has no password/user model; reachable endpoints grant OS-user control, so bind 127.0.0.1 and protect endpoint URLs. - playwright-discovery (REASONED; playwright, playwright-source): Guide records run-server path / and launchServer random paths discoverable through GET /json; a secret path is not an access boundary. - playwright-tls (REASONED; playwright): Authenticate WebSocket upgrades and discovery at a wss proxy; connect accepts custom headers and the server supplies no native TLS. - debug-bypass (REASONED; cdp, selenium, browserless, playwright): A separately forwarded Chromium debug endpoint bypasses front-tool authentication; keep 9222 unpublished and restrict browser egress. - verify-listeners (REASONED; chromium, selenium, selenium-docker, browserless, playwright): Inventory every listener, including distributed Grid and configured Playwright ports, and require intended loopback/private addresses. - verify-cdp (REASONED; chromium, curl-docs): External command-line CDP returns 200 with webSocketDebuggerUrl when forwarded; fixed isolation retains a working local control. Approval-mode 404 is inconclusive. - verify-grid (REASONED; selenium, curl-docs): Grid /status returns ready/node JSON when exposed; Router 401 with Basic challenge shows auth but still proves reachability. Probe Nodes separately. - verify-browserless (REASONED; browserless-token-default, browserless-docs, curl-docs): For open-source 2.56.7, expect /pressure load JSON without a configured TOKEN; with a nonempty TOKEN, expect 401 for missing/wrong tokens and JSON for a correct-token control. - verify-playwright (REASONED; playwright, playwright-source): Any HTTP answer proves Playwright reachability; launchServer uses an ephemeral port unless set, while run-server takes --port. - browserless-file-cve (REASONED; browserless-file-fix, cve-record-source): CVE-2026-92811 describes authenticated Playwright file reads despite ALLOW_FILE_PROTOCOL=false; its affected range 1.44.0 through 2.56.7 conflicts with the vendor 2.51.0 fix. - verify-browserless-file (REASONED; browserless-file-fix, cve-record-source): With a valid token on every enabled Playwright WebSocket route, confirm HTTP navigation first, then attempt a readable container canary: exposed returns contents; fixed refuses with a blocked-URL policy reason. ````json { "schema": 1, "checked": "2026-10-05", "documentation_checked": "2026-10", "body_sha256": "c495bd437a18c4742276a20cc1088a8a65f8024e968e1f0c5649a1bc160c292d", "components": { "chromium": { "name": "Chromium", "basis": "154.0.8037.57", "sources": { "s087ba883abe3": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.cc#L69-L158", "s65073710f8bd": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.cc#L160-L282", "sc62d44c2dee6": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.cc#L325-L417", "s6e1b34a120d1": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/headless/lib/browser/headless_devtools.cc#L31-L145", "sdf5f61d9b325": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/headless/lib/browser/command_line_handler.cc#L199-L206", "s005d90d4912a": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/public/common/content_switches.cc#L596-L607", "s804c1266fb81": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L267-L312", "s66b210123ff8": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L459-L630", "s1044899c624a": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L811-L880", "s1ca85f782546": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/content/browser/devtools/devtools_http_handler.cc#L1032-L1055", "s0a2a5fec4975": "https://github.com/chromium/chromium/blob/73c14f6228d7cd537c855007e8f88678969cc0eb/chrome/browser/devtools/remote_debugging_server.h#L26" } }, "browserless-token-default": { "name": "browserless (open source)", "basis": "2.56.7", "sources": { "s87c144d59098": "https://github.com/browserless/browserless/blob/v2.56.7/src/config.ts#L250", "sdf3b808e8920": "https://github.com/browserless/browserless/blob/v2.56.7/src/server.ts#L103-L117", "se8b696d56eda": "https://github.com/browserless/browserless/blob/v2.56.7/src/server.ts#L264-L271", "sc7973e3e21df": "https://github.com/browserless/browserless/blob/v2.56.7/src/server.ts#L445-L454" } }, "browserless-file-fix": { "name": "browserless Playwright file-protocol fix", "basis": "2.51.0", "sources": { "s94f1d485c2b7": "https://raw.githubusercontent.com/browserless/browserless/v2.51.0/CHANGELOG.md", "s90a20f6566c9": "https://github.com/browserless/browserless/commit/a18a1231ead2ecc4114347d5d1fafd69bffeb735", "sf2642da1de49": "https://raw.githubusercontent.com/browserless/browserless/v2.51.0/src/browsers/browsers.playwright.ts", "s7ee912fa0a9a": "https://github.com/browserless/browserless/blob/v2.51.0/src/http.ts#L91-L103", "sa985cbba01ad": "https://raw.githubusercontent.com/browserless/browserless/v2.51.0/src/config.ts" } }, "playwright-source": { "name": "Playwright source", "basis": "v1.58.2", "sources": { "scb030dc29caf": "https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/cli/program.ts#L292-L307", "sbbcd84289455": "https://raw.githubusercontent.com/microsoft/playwright/v1.58.2/packages/playwright-core/src/browserServerImpl.ts", "s5a6acbd08927": "https://raw.githubusercontent.com/microsoft/playwright/v1.58.2/packages/playwright-core/src/remote/playwrightServer.ts" } }, "cdp": { "name": "Chrome DevTools Protocol", "basis": "unknown", "sources": { "s523649d3b233": "https://chromedevtools.github.io/devtools-protocol/" } }, "chrome-change": { "name": "Chrome remote-debugging announcement", "basis": "unknown", "sources": { "s643c9edab288": "https://developer.chrome.com/blog/remote-debugging-port" } }, "selenium": { "name": "Selenium Grid", "basis": "unknown", "sources": { "sb304923778ab": "https://www.selenium.dev/documentation/grid/configuration/cli_options/", "s0a0129b8dd37": "https://www.selenium.dev/documentation/grid/getting_started/" } }, "selenium-docker": { "name": "Selenium Docker images", "basis": "aafe4d6136f3bb5afcd9b7cb691c624516d06e1b", "sources": { "s992336986b07": "https://github.com/SeleniumHQ/docker-selenium/blob/aafe4d6136f3bb5afcd9b7cb691c624516d06e1b/ENV_VARIABLES.md" } }, "browserless": { "name": "browserless", "basis": "unknown", "sources": { "sc0a7a8f6c093": "https://docs.browserless.io/enterprise/docker/config" } }, "browserless-docs": { "name": "browserless documentation (rolling)", "basis": "unknown", "sources": { "sa189d41ee219": "https://docs.browserless.io/enterprise/open-source", "sf1ec52c41f54": "https://docs.browserless.io/enterprise/utility-functions/pressure" } }, "playwright": { "name": "Playwright", "basis": "unknown", "sources": { "sc13c2e85f08c": "https://playwright.dev/docs/api/class-browsertype", "s94dfef953286": "https://playwright.dev/docs/docker" } }, "curl-docs": { "name": "curl documentation", "basis": "curl-8_14_1", "sources": { "s5e1383feef7d": "https://github.com/curl/curl/blob/curl-8_14_1/docs/cmdline-opts/write-out.md#L75-L79", "s82a34ee18632": "https://raw.githubusercontent.com/curl/curl/curl-8_14_1/docs/cmdline-opts/config.md" } }, "cve-record-source": { "name": "CVE-2026-92811 record snapshot", "basis": "b0b0976792a7c9d6d66ee553050c21cb088a0659", "sources": { "sba82c9b5f230": "https://raw.githubusercontent.com/CVEProject/cvelistV5/b0b0976792a7c9d6d66ee553050c21cb088a0659/cves/2026/92xxx/CVE-2026-92811.json" } } }, "claims": { "cdp-bind": {"text": "Command-line DevTools binds 127.0.0.1, falling back to ::1; stock browser and headless shell have no remote-debugging-address switch.", "components": ["chromium"], "sources": ["chromium:s087ba883abe3", "chromium:s6e1b34a120d1", "chromium:s005d90d4912a"], "status": "REASONED"}, "cdp-port": {"text": "Explicit nonzero DevTools ports do not fall back to another port; port 0 requests an ephemeral port.", "components": ["chromium"], "sources": ["chromium:s087ba883abe3", "chromium:s6e1b34a120d1"], "status": "REASONED"}, "cdp-approval": {"text": "Feature-gated approval mode uses DevToolsActivePort or 9222, with requested/ephemeral IPv4 then IPv6 fallback; command-line modes take precedence.", "components": ["chromium"], "sources": ["chromium:s087ba883abe3", "chromium:s65073710f8bd", "chromium:sc62d44c2dee6", "chromium:s0a2a5fec4975"], "status": "REASONED"}, "cdp-discovery": {"text": "Command-line /json and /json/version disclose debugger URLs; approval-mode 404 does not establish isolation.", "components": ["chromium"], "sources": ["chromium:s66b210123ff8", "chromium:s1ca85f782546"], "status": "REASONED", "verify": [2]}, "cdp-auth": {"text": "Command-line CDP has no client authentication or password flag; Host and Origin checks do not authenticate clients.", "components": ["chromium"], "sources": ["chromium:s66b210123ff8", "chromium:s1044899c624a"], "status": "REASONED"}, "cdp-capabilities": {"text": "CDP grants page JavaScript, cookie/credential and tab control; restrict ingress, browser egress and local-file reach.", "components": ["cdp"], "sources": ["cdp:s523649d3b233"], "status": "REASONED"}, "cdp-private": {"text": "Keep DevTools loopback/private behind authenticated access; inspect forwarders and container namespaces, since publishing cannot reach container loopback.", "components": ["chromium"], "sources": ["chromium:s087ba883abe3", "chromium:s6e1b34a120d1", "chromium:s804c1266fb81"], "status": "REASONED"}, "cdp-profile": {"text": "Chrome 136 requires non-default user data; pinned desktop branding check excludes normal Chromium, and Chrome for Testing exemption rests on the announcement.", "components": ["chrome-change", "chromium"], "sources": ["chrome-change:s643c9edab288", "chromium:s65073710f8bd"], "status": "REASONED"}, "cdp-policy": {"text": "RemoteDebuggingAllowed gates browser command-line and approval modes; headless-shell startup lacks the corresponding profile/policy check.", "components": ["chromium"], "sources": ["chromium:s65073710f8bd", "chromium:sc62d44c2dee6", "chromium:s6e1b34a120d1"], "status": "REASONED"}, "cdp-pipe": {"text": "Pipe mode alone opens no TCP port; supplying a port too can start an independent listener, and pipe access still grants browser control.", "components": ["chromium"], "sources": ["chromium:sc62d44c2dee6", "chromium:s6e1b34a120d1", "chromium:sdf5f61d9b325"], "status": "REASONED"}, "grid-auth": {"text": "Router username/password are unset by default, including Docker SE_ROUTER_USERNAME and SE_ROUTER_PASSWORD.", "components": ["selenium", "selenium-docker"], "sources": ["selenium:sb304923778ab", "selenium-docker:s992336986b07"], "status": "REASONED"}, "grid-bind": {"text": "Grid host is usually autodetected; Docker SE_BIND_HOST is unset and historical Grid 3 binds 0.0.0.0. Set and observe a private host with bind-host.", "components": ["selenium", "selenium-docker"], "sources": ["selenium:sb304923778ab", "selenium-docker:s992336986b07"], "status": "REASONED"}, "grid-ports": {"text": "Router, Hub, Standalone and UI use 4444; Nodes use 5555. Keep all private.", "components": ["selenium"], "sources": ["selenium:s0a0129b8dd37"], "status": "REASONED"}, "grid-distributed": {"text": "Distributed event bus uses 4442/4443; Distributor, Session Map, bus HTTP and New Session Queue add 5553/5556/5557/5559.", "components": ["selenium"], "sources": ["selenium:sb304923778ab", "selenium:s0a0129b8dd37"], "status": "REASONED"}, "grid-bus": {"text": "Router Basic auth does not protect the bus; registration-secret validates event messages without encrypting transport, so isolate every participant.", "components": ["selenium"], "sources": ["selenium:sb304923778ab", "selenium:s0a0129b8dd37"], "status": "REASONED"}, "grid-vnc": {"text": "Docker enables VNC by default on 5900 and noVNC on 7900; replace the example secret password or disable VNC, and avoid passwordless mode.", "components": ["selenium-docker"], "sources": ["selenium-docker:s992336986b07"], "status": "REASONED"}, "grid-tls": {"text": "Native https-certificate/https-private-key or a TLS proxy protects Router Basic credentials; TLS is not on by default.", "components": ["selenium"], "sources": ["selenium:sb304923778ab"], "status": "REASONED"}, "browserless-auth": {"text": "Open-source 2.56.7 reads an unset or empty TOKEN as null; no token is generated and endpoints are unauthenticated by default.", "components": ["browserless-token-default", "browserless-docs"], "sources": ["browserless-token-default:s87c144d59098", "browserless-docs:sa189d41ee219"], "status": "REASONED"}, "browserless-bind": {"text": "browserless uses 3000; publish 127.0.0.1:3000:3000 or keep an internal network with no host mapping.", "components": ["browserless"], "sources": ["browserless:sc0a7a8f6c093"], "status": "REASONED"}, "browserless-token": {"text": "Use a randomized TOKEN, protect token-bearing connection URLs and logs, and terminate TLS at the fronting layer.", "components": ["browserless"], "sources": ["browserless:sc0a7a8f6c093"], "status": "REASONED"}, "browserless-file": {"text": "Require browserless 2.51.0 or later containing the pinned Playwright fix, keep ALLOW_FILE_PROTOCOL=false, and run the file-protocol check.", "components": ["browserless", "browserless-file-fix"], "sources": ["browserless:sc0a7a8f6c093", "browserless-file-fix:s94f1d485c2b7", "browserless-file-fix:s90a20f6566c9", "browserless-file-fix:sa985cbba01ad"], "status": "REASONED"}, "playwright-auth": {"text": "Playwright server has no password/user model; reachable endpoints grant OS-user control, so bind 127.0.0.1 and protect endpoint URLs.", "components": ["playwright"], "sources": ["playwright:sc13c2e85f08c", "playwright:s94dfef953286"], "status": "REASONED"}, "playwright-discovery": {"text": "Guide records run-server path / and launchServer random paths discoverable through GET /json; a secret path is not an access boundary.", "components": ["playwright", "playwright-source"], "sources": ["playwright:sc13c2e85f08c", "playwright:s94dfef953286", "playwright-source:scb030dc29caf", "playwright-source:sbbcd84289455", "playwright-source:s5a6acbd08927"], "status": "REASONED"}, "playwright-tls": {"text": "Authenticate WebSocket upgrades and discovery at a wss proxy; connect accepts custom headers and the server supplies no native TLS.", "components": ["playwright"], "sources": ["playwright:sc13c2e85f08c", "playwright:s94dfef953286"], "status": "REASONED"}, "debug-bypass": {"text": "A separately forwarded Chromium debug endpoint bypasses front-tool authentication; keep 9222 unpublished and restrict browser egress.", "components": ["cdp", "selenium", "browserless", "playwright"], "sources": ["cdp:s523649d3b233", "selenium:s0a0129b8dd37", "browserless:sc0a7a8f6c093", "playwright:sc13c2e85f08c"], "status": "REASONED"}, "verify-listeners": {"text": "Inventory every listener, including distributed Grid and configured Playwright ports, and require intended loopback/private addresses.", "components": ["chromium", "selenium", "selenium-docker", "browserless", "playwright"], "sources": ["chromium:s087ba883abe3", "chromium:s6e1b34a120d1", "selenium:sb304923778ab", "selenium:s0a0129b8dd37", "selenium-docker:s992336986b07", "browserless:sc0a7a8f6c093", "playwright:sc13c2e85f08c"], "status": "REASONED", "verify": [1]}, "verify-cdp": {"text": "External command-line CDP returns 200 with webSocketDebuggerUrl when forwarded; fixed isolation retains a working local control. Approval-mode 404 is inconclusive.", "components": ["chromium", "curl-docs"], "sources": ["chromium:s804c1266fb81", "chromium:s66b210123ff8", "curl-docs:s5e1383feef7d"], "status": "REASONED", "verify": [2]}, "verify-grid": {"text": "Grid /status returns ready/node JSON when exposed; Router 401 with Basic challenge shows auth but still proves reachability. Probe Nodes separately.", "components": ["selenium", "curl-docs"], "sources": ["selenium:sb304923778ab", "selenium:s0a0129b8dd37", "curl-docs:s5e1383feef7d"], "status": "REASONED", "verify": [2]}, "verify-browserless": {"text": "For open-source 2.56.7, expect /pressure load JSON without a configured TOKEN; with a nonempty TOKEN, expect 401 for missing/wrong tokens and JSON for a correct-token control.", "components": ["browserless-token-default", "browserless-docs", "curl-docs"], "sources": ["browserless-token-default:s87c144d59098", "browserless-token-default:sdf3b808e8920", "browserless-token-default:se8b696d56eda", "browserless-token-default:sc7973e3e21df", "browserless-docs:sa189d41ee219", "browserless-docs:sf1ec52c41f54", "curl-docs:s5e1383feef7d", "curl-docs:s82a34ee18632"], "status": "REASONED", "verify": [2]}, "verify-playwright": {"text": "Any HTTP answer proves Playwright reachability; launchServer uses an ephemeral port unless set, while run-server takes --port.", "components": ["playwright", "playwright-source"], "sources": ["playwright:sc13c2e85f08c", "playwright:s94dfef953286", "playwright-source:scb030dc29caf", "playwright-source:sbbcd84289455", "playwright-source:s5a6acbd08927"], "status": "REASONED", "verify": [2]}, "browserless-file-cve": {"text": "CVE-2026-92811 describes authenticated Playwright file reads despite ALLOW_FILE_PROTOCOL=false; its affected range 1.44.0 through 2.56.7 conflicts with the vendor 2.51.0 fix.", "components": ["browserless-file-fix", "cve-record-source"], "sources": ["cve-record-source:sba82c9b5f230", "browserless-file-fix:s94f1d485c2b7", "browserless-file-fix:s90a20f6566c9"], "status": "REASONED"}, "verify-browserless-file": {"text": "With a valid token on every enabled Playwright WebSocket route, confirm HTTP navigation first, then attempt a readable container canary: exposed returns contents; fixed refuses with a blocked-URL policy reason.", "components": ["browserless-file-fix", "cve-record-source"], "sources": ["cve-record-source:sba82c9b5f230", "browserless-file-fix:sf2642da1de49", "browserless-file-fix:s7ee912fa0a9a", "browserless-file-fix:sa985cbba01ad"], "status": "REASONED"} } } ```` ====================================================================== ==> headless-cms-instant-api.md ====================================================================== # Instant API backends: Strapi, Directus, Hasura, and PostgREST Strapi, Directus, Hasura and PostgREST are backend tools that give apps a generated API over your database. This guide helps your AI assistant configure each securely, including Strapi and Directus admins created privately, Hasura's admin secret set, PostgREST's role grants kept narrow, and secrets kept out of committed files. If an AI assistant is helping you secure a deployment, give it the prompt below: ```text Read the secureconfig guide at https://raw.githubusercontent.com/jposluns/secureconfig/main/headless-cms-instant-api.md in full, including the section for AI assistants at the end. Then, for my deployment: state which of its recommendations apply and the exact changes to make; compare the versions the guide was written against with the releases I am running and with the vendor's current releases, and flag any guidance a newer release may have changed; and run or adapt the guide's Verify steps where you can. List separately every item you could not verify instead of reporting it as passed. ``` Written against Strapi Users and Permissions source v5.58.0, Directus listener source v12.4.1, Directus quickstart 12.0.2, Hasura quickstart image 2.46.0, Hasura authentication-status minimum 2.48.0, and PostgREST listener source v16.4; 6 further sources carry no recorded version. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 48 claims, all reasoned from cited sources. A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under [requests/](https://github.com/jposluns/secureconfig/blob/main/requests/README.md), or reach the maintainer as [CONTRIBUTING.md](https://github.com/jposluns/secureconfig/blob/main/CONTRIBUTING.md) describes. The machine-readable version basis and claim list are in [the section for AI assistants](#about-the-ai-assistant-section-below) at the end of this guide. These tools turn a database or a schema into a ready-made API with little or no code, which is exactly why AI-assisted projects reach for them. That convenience is also the exposure: one misconfiguration can put the whole datastore on the internet, and it happens two different ways here. Strapi and Directus keep their data API closed until you open it, so their risk is the admin and bootstrap surface, an admin account or a stored credential claimed before you claimed it. Hasura and PostgREST instead expose whatever a single control allows, an unset admin secret or the grants held by one database role, and both bind to every interface by default. All four read the secrets that gate them, admin passwords, API tokens, or the signing keys that mint sessions and tokens, from environment variables, so a committed Compose file or `.env` is the leak they share. Firebase, Supabase, PocketBase and Appwrite are the same class from the other direction ([firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md), [pocketbase.md](https://github.com/jposluns/secureconfig/blob/main/pocketbase.md)). ## Strapi Register the first administrator before the instance is reachable by anyone else. Strapi ships no default admin password: the first visitor to the admin panel completes a form and becomes the first administrator, so an admin panel left open to the internet before you register can be claimed by whoever reaches it first. The server's documented default host is `localhost`, but the generated server configuration sets `host: env('HOST', '0.0.0.0')`, so it listens on every interface. Keep the admin panel off the public network until you have registered: on a bare host set `HOST` to `127.0.0.1` for the bootstrap step; in a container leave the app bound inside the container and publish its port only to the host loopback (`127.0.0.1:1337:1337`) or firewall it, because setting the container's own `HOST` to `127.0.0.1` makes the published port unreachable. Only then expose it, and expose only the intended public API routes: keep the admin panel (`/admin` by default) and its backend management routes behind a VPN or an access proxy that enforces MFA, since hiding the admin path does not protect its backend routes. Strapi ships no native multi-factor authentication for the admin panel; where Strapi SSO is available, require MFA at the identity provider and close local-login bypasses for those administrator roles ([fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)). The data API itself is closed by default. Strapi's documentation states that "all content types are private by default and need to be either made public or queries need to be authenticated with the proper permissions", so an unauthenticated request assumes the Public role, which reaches no content type until an administrator grants it (a denied request returns `403 Forbidden`). Two exposures remain. Uploaded files are not covered by that default: Strapi serves the Media Library through a static file middleware based on `koa-static`, which does not authenticate, so files stored with the default local provider are public even while every content type is private; keep confidential files out of it or serve them from access-controlled storage. Unless public registration is required, disable end-user sign-up in Settings > Users & Permissions plugin > Advanced Settings > Enable sign-ups, which controls `POST /api/auth/local/register`. In v5.58.0, the plugin initializes absent advanced settings with `allow_register: true` and `default_role: 'authenticated'`; this guide's assessment is that an existing project's stored setting and other releases require separate verification. If registration is intentional, review the configured Default role and its permissions rather than assuming a registered user has only the access you intended; new end users receive the configured Default role, so review that role's actual permissions before enabling registration. Strapi also pre-generates Full access and Read-only Content API tokens. Review them under Settings > API Tokens, delete unused ones, and give any you keep a finite lifetime; a Read-only token still permits `find` and `findOne`, so use Custom permissions when access must be limited to particular endpoints. These Content API tokens are separate credentials from administrator tokens, and closing the Public role does not revoke them. Set unique, randomized values for the application secrets Strapi reads from the environment; the server configuration documents `APP_KEYS` (the session signing keys), the admin-panel configuration adds `ADMIN_JWT_SECRET`, `API_TOKEN_SALT` and `TRANSFER_TOKEN_SALT`, and the Users and Permissions plugin configuration adds `JWT_SECRET`. Protect `ENCRYPTION_KEY` too where it is configured for stored-token encryption. A copied example `.env` lets anyone holding the signing keys forge sessions or JWTs (the API token salts are not themselves bearer tokens); store these values as [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md) describes, never in the repository or a client bundle, and rotate and revoke any that leak. If the optional GraphQL plugin is installed it exposes `/graphql` with `shadowCRUD` generating queries and mutations, and it passes Apollo options through `graphql.config.apolloServer`; Apollo enables introspection unless `NODE_ENV=production`, so set `graphql.config.apolloServer.introspection` and `graphql.config.landingPage` to `false` when schema browsing is unnecessary. Those settings do not replace resolver authorization. Strapi terminates no TLS of its own, so front it per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). ## Directus Directus supports bootstrap credentials through `ADMIN_EMAIL` and `ADMIN_PASSWORD`, with an optional `ADMIN_TOKEN` static API token carrying that admin's full rights that does not expire; its current 12.0.2 quickstart also offers browser onboarding for the first administrator. Keep a fresh instance private until an operator-controlled administrator has been created and verified, rather than assuming that omitting the environment variables makes bootstrap inaccessible. Because every one of these arrives through the environment, a committed Compose file or `.env` is an admin-credential leak; set unique operator-controlled values before first boot and store them per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md). Set `SECRET` to a unique, cryptographically random value stored through your secret manager as well: it signs the sessions and access tokens Directus mints, so a value copied from an example Compose file lets anyone holding it forge tokens, and if omitted Directus generates a random value that does not persist consistently across restarts or replicas. Behind HTTPS, set `SESSION_COOKIE_SECURE=true` and `REFRESH_TOKEN_COOKIE_SECURE=true`; both default to `false`. Leave `ADMIN_TOKEN` unset unless a machine genuinely needs a static token, prefer a scoped credential where one is needed ([machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)), and require two-factor authentication on administrator accounts (a static token still works independently of an interactive login, so treat it as the more dangerous credential). Access for unauthenticated callers is closed by default: Directus documents that "all public permissions are off by default", so the Public role and policy reach no collection until an administrator enables it. The real exposures are therefore the bootstrap credentials above and any grant an operator later adds to the Public policy, including file read permissions that make items served at `/assets` reachable; review those grants rather than assuming the default still holds. The storage rule is to protect the underlying upload directory or object-storage bucket too, because a public storage URL can bypass Directus asset permissions (grant upload, import, update and delete separately from read). Directus registration is disabled by default; leave it off unless required, and note that its registration flow returns an empty `204` regardless of outcome, so a response alone is not proof an account was rejected. GraphQL introspection is enabled by default (`GRAPHQL_INTROSPECTION=true`); set it to `false` when schema discovery is unnecessary, reviewing GraphQL under the same roles and policies as REST. Directus listens on `HOST`, default `0.0.0.0`, and port `8055` (defaults as of v12.4.1; a `UNIX_SOCKET_PATH` naming a socket path replaces both) and serves the Data Studio (`/` redirects to `/admin`); on a bare host set `HOST=127.0.0.1`, in Docker publish `127.0.0.1:8055:8055` or use an unpublished private network. The Directus administration rule is to protect administration after bootstrap (disabling `SERVE_APP` hides the Studio but does not replace API authorization). Directus serves no TLS itself, so terminate it in front per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) and [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md). ## Hasura (GraphQL Engine v2, self-hosted) Set `HASURA_GRAPHQL_ADMIN_SECRET`. With no admin secret configured, the GraphQL API (and the console, where it is enabled) is reachable by anyone with full administrative rights: the vendor's own guidance is to set the secret so "your GraphQL endpoint and the Hasura Console are not publicly accessible". The admin secret grants "full admin rights", so it is a bearer credential that must never be embedded or distributed in an end-user application, a public bundle, or browser code delivered to untrusted users ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)). The engine binds to every interface by default (`HASURA_GRAPHQL_SERVER_HOST` defaults to `*`, on port 8080); set it to `127.0.0.1` for a host-local deployment or keep the published port off the public interface. Hasura terminates no TLS of its own, so front it per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). Two defaults are worth knowing. The server binary ships with the console off (`HASURA_GRAPHQL_ENABLE_CONSOLE` defaults to `false`, and it is served on `/` and `/console` when enabled), but the vendor's quickstart Compose file turns it on, so a stack started from that file serves the console until you set it back to `false`; keep `HASURA_GRAPHQL_DEV_MODE` at `false` in production as well. That same quickstart Compose also publishes a separate data-connector agent on port 8081 and carries literal PostgreSQL credentials, so treat 8081 as another listener to keep off the public interface and replace those credentials. And `HASURA_GRAPHQL_ENABLED_APIS` defaults to `metadata, graphql, pgdump, config`, so the metadata, pgdump and config APIs are served alongside GraphQL; once the admin secret is set they require it, but narrow the list if your deployment does not use them. Leave `HASURA_GRAPHQL_UNAUTHORIZED_ROLE` unset unless anonymous access is intended, since setting it defines what an unauthenticated caller may do. Hasura also enables GraphQL introspection by default; the documented per-role control to disable it is a self-hosted Enterprise feature, so do not assume Community Edition can turn it off, and authenticate access and review each role's schema and data permissions regardless. Client applications should use scoped JWT or webhook authentication, never the admin secret. ## PostgREST PostgREST's access control is not one of its own flags: it is the PostgreSQL `GRANT` and `REVOKE` statements on the roles it uses, so its posture is only as tight as the database privileges behind it ([postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md)). A request with no JSON Web Token runs as the role named by `db-anon-role` (`PGRST_DB_ANON_ROLE`); that role has no default, and when it is unset an unauthenticated request is refused with `PGRST302` (HTTP 401). A token that is present but does not verify is rejected rather than downgraded to the anonymous role: a malformed or bad-signature token returns `PGRST301` (HTTP 401) and an expired token or one failing claims validation returns `PGRST303` (HTTP 401). For a private API, leave `db-anon-role` unset. Where anonymous access is intended, point it at a dedicated `NOLOGIN` role whose grants are enumerated and minimal, never a broad or superuser-adjacent role, because whatever that role can read or write is what the internet can. The role-audit rule is to audit the authenticator and authenticated roles the same way wherever anonymous access is intended (function `EXECUTE` defaults to `PUBLIC`, and `SECURITY DEFINER` functions and owner-privileged views can widen access beyond the table grants). Set `db-schemas` to a schema built for the API rather than leaving it at its default of `public`. Note that PostgREST serves an OpenAPI description at the root path whose contents follow the requesting role's privileges by default, so a broad anonymous role also broadens what an unauthenticated caller can enumerate. Set `jwt-secret` (`PGRST_JWT_SECRET`) to a cryptographically random value of at least 32 characters; if a token is sent while it is unset, the request fails with `PGRST300` (HTTP 500). The server binds to every IPv4 interface by default (`server-host` defaults to `!4`, on port 3000, as of v16.4), so set `server-host` to `127.0.0.1` for a host-local deployment or keep the port off the public interface; the optional admin server (`admin-server-port`) defaults to that same host (an `admin-server-host` can override it), so account for it too. PostgREST terminates no TLS itself; front it per [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md) or [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md). ## Across all four Three of the four call outward on demand (Strapi webhooks, Directus Flows, Hasura event triggers, Actions and remote schemas). PostgREST makes every function in the exposed schema that the active database role can access executable under `/rpc`, and this guide infers that such a function can reach a network only through an installed extension, an untrusted procedural language, a privileged COPY TO PROGRAM or similar. The egress rule is therefore to restrict each service's outbound access to the destinations that service needs and block cloud metadata, loopback, link-local and unrelated private networks, applying the restriction after DNS resolution and across redirects ([egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md)). Directus documents an `IMPORT_IP_DENY_LIST` defaulting to `0.0.0.0,169.254.169.254`, but that import-specific control is not evidence that every extension or Flow is equally constrained. Store database passwords and credential-bearing connection strings per [secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md), including backups and exported configuration; give each service a dedicated database account with only the privileges it needs, keep the database listener private, and for a remote database require encrypted transport with server-certificate verification (frontend HTTPS does not secure the database connection). Require MFA for human administration throughout: an MFA-enforcing proxy or SSO for Strapi, enforced administrator 2FA for Directus, and MFA at the identity provider or access boundary for Hasura and PostgREST, since an admin secret or bearer JWT is not itself a second factor; give machine credentials their own scope, expiry and revocation ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md), [machine-auth.md](https://github.com/jposluns/secureconfig/blob/main/machine-auth.md)). ## Verify REASONED: this overview and paired canary procedure; a live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The earlier runtime-absence sentence is an unverified authoring note; this review makes no runtime-availability claim. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected. Stubbed GET and POST fixtures printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called curl twice and exited 0; these are synthetic, not live authorization results. The placeholder and example.com/.net/.org fixtures printed "substitute your own canary URL inside the quotes; not probing"; non-HTTPS printed "use an https:// URL; not probing"; omitted set -- printed "paste the whole block, including its set -- line; not probing"; wrong argument count printed "the set -- line needs a URL and a JSON body (empty for GET); not probing". Empty or control-character control headers printed "supply a valid control header without control characters; not probing"; control-character or placeholder negative headers printed "supply a negative header without control characters, or leave it empty; not probing". Failed input printed "header input failed; not probing"; readonly CMS_PROBE_HEADER printed "cannot clear CMS header variables in this shell; not probing"; readonly IFS printed "a readonly IFS is set in this shell; not probing". Each guard exited 2 with zero curl calls. A stubbed curl failure printed "AUTHORIZED CONTROL", made one call and exited 2. The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Python parsed the Hasura table JSON. Exposed Public-role or anonymous read grants return the canary with the authorized control also succeeding; fixed Strapi and Directus responses are explicit permission denials with authorized canary controls, fixed Hasura is an admin-secret-required JSON error with HTTP 200 by default and an authorized canary, and fixed PostgREST without db-anon-role is 401 PGRST302 with an authorized canary. Empty or filtered anonymous results are inconclusive. These expectations follow the guide's cited Strapi Users and Permissions, public role and sign-up (rolling documentation, checked September 2026), Strapi REST API, content types private by default (rolling documentation, checked September 2026), Directus access control (public permissions off by default) and Directus error codes (authentication versus permission failures) entries. These expectations also follow the guide's cited Hasura securing the GraphQL endpoint, Hasura Community Edition 2.48.0 release notes (`HASURA_GRAPHQL_PRESERVE_401_ERRORS`), PostgREST authentication and roles and PostgREST error codes (PGRST300 to PGRST303) entries. Each probe below is REASONED, not demonstrated: the authoring environment has no container runtime, so none was stood up in its exposed and fixed states. Each names its expected exposed and fixed result so it discriminates when run against a live instance, based on the cited vendor documentation. Give each tool a harmless canary record whose one field reads `secureconfig-canary`, use curl 7.75.0 or later, and run the paired block once per applicable endpoint: it sends the same URL and body twice, first with a valid credential and then anonymously, so the authorized control proves the origin is the service and the canary exists before an application denial can count as protection. Use a fresh, trusted shell. Enter the authorized header at the block's hidden prompt (`Authorization: Bearer ...` for Strapi, Directus and PostgREST, or `X-Hasura-Admin-Secret: ...` for Hasura); press Enter at the negative-header prompt for the anonymous test. Both headers stay in unexported subshell variables and reach curl on stdin, never in argv or pasted commands, keeping the prompted values out of shell history; the block's `set +x` keeps them out of `set -x` tracing. Inherited values are discarded; the variables are unset after use and the subshell contains early exits. This does not hide credentials from the account owner or root. Treat an empty result, a missing route, an unrelated error, an HTML page, a TLS error, or a timeout as inconclusive, never as the fixed state. Substitute your own host for the `example.com` placeholder. | Tool | Path appended to the deployment's HTTPS origin | JSON body | Expected private-state negative | Marking | |---|---|---|---|---| | Strapi | `/api/secureconfig-probes?filters[marker][$eq]=secureconfig-canary&fields[0]=marker` | empty | permission denial, normally 403 | REASONED: this Strapi row; a live Strapi stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected; a scratch curl-stub GET fixture printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called the stub twice and exited 0. That bash -n, ShellCheck and stub run covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Exposed Public-role read permission returns the canary anonymously; fixed permission denial, normally 403, rejects the anonymous request while the authorized control returns the canary. Empty or filtered results are inconclusive. Per the guide's cited Strapi Users and Permissions, public role and sign-up (rolling documentation, checked September 2026) and Strapi REST API, content types private by default (rolling documentation, checked September 2026) entries. | | Directus | `/items/secureconfig_probe?filter[marker][_eq]=secureconfig-canary&fields=marker` | empty | 403 with `FORBIDDEN` | REASONED: this Directus row; a live Directus stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected; a scratch curl-stub GET fixture printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called the stub twice and exited 0. That bash -n, ShellCheck and stub run covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Exposed Public access returns the canary anonymously; fixed permissions return 403 with `FORBIDDEN` while the authorized control returns the canary. Empty or filtered results are inconclusive. Per the guide's cited Directus access control (public permissions off by default) and Directus error codes (authentication versus permission failures) entries. | | Hasura | `/v1/graphql` | `{"query":"query { secureconfig_probe(where: {marker: {_eq: \"secureconfig-canary\"}}) { marker } }"}` | admin-secret-required JSON error | REASONED: this Hasura row; a live Hasura stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected; a scratch curl-stub POST fixture printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called the stub twice and exited 0. That bash -n, ShellCheck and stub run covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Python parsed this row's JSON body. Exposed anonymous access returns the canary; fixed admin-secret enforcement returns an admin-secret-required JSON error with HTTP 200 by default while the authorized control returns the canary. Per the guide's cited Hasura securing the GraphQL endpoint and Hasura Community Edition 2.48.0 release notes (`HASURA_GRAPHQL_PRESERVE_401_ERRORS`) entries. | | PostgREST | `/secureconfig_probe?select=marker&marker=eq.secureconfig-canary` | empty | 401 with `PGRST302` when the anonymous role is unset | REASONED: this PostgREST row; a live PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected; a scratch curl-stub GET fixture printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called the stub twice and exited 0. That bash -n, ShellCheck and stub run covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Exposed anonymous-role access returns the canary; fixed db-anon-role unset returns 401 with `PGRST302` while the authorized control returns the canary. Per the guide's cited PostgREST authentication and roles, PostgREST configuration reference (db-anon-role, db-schemas, server-host, jwt-secret) and PostgREST error codes (PGRST300 to PGRST303) entries. | REASONED: following block; paired canary, token and credential-retirement probes need exposed and fixed service fixtures; a live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. An earlier authoring note named no container runtime; this review did not check runtime availability. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected; its expected exposed and fixed outcomes, cited Sources entries and all scratch guard, GET and POST results are recorded in the paragraph after the block, and the credential-retirement outcomes and their cited Sources entries in the paragraph after that. Those runs covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Paste each header alone at its hidden prompt: a multi-line paste feeds every line after the first to the next prompt or to your shell, which runs it. ```bash ( trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias set +x +a +e { unset -n CMS_PROBE_HEADER CMS_NEGATIVE_HEADER && unset -v CMS_PROBE_HEADER CMS_NEGATIVE_HEADER; } 2>/dev/null || { echo 'cannot clear CMS header variables in this shell; not probing'; exit 2; } { unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; } set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_CANARY_URL' '' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs a URL and a JSON body (empty for GET); not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*example.com*|*example.net*|*example.org*) echo 'substitute your own canary URL inside the quotes; not probing'; exit 2 ;; https://*) ;; *) echo 'use an https:// URL; not probing'; exit 2 ;; esac IFS= read -r -s -p 'Authorized header (input hidden): ' CMS_PROBE_HEADER || { echo 'header input failed; not probing'; exit 2; } printf '\n' case "$CMS_PROBE_HEADER" in ''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'supply a valid control header without control characters; not probing'; exit 2 ;; esac IFS= read -r -s -p 'Negative header (empty for anonymous, input hidden): ' CMS_NEGATIVE_HEADER || { echo 'header input failed; not probing'; exit 2; } printf '\n' case "$CMS_NEGATIVE_HEADER" in *REPLACE_WITH_*|*[[:cntrl:]]*) echo 'supply a negative header without control characters, or leave it empty; not probing'; exit 2 ;; esac printf '%s\n' 'AUTHORIZED CONTROL (must return the canary):' if [ -z "$2" ]; then printf '%s\n' "$CMS_PROBE_HEADER" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --header @- "$1" || exit 3 else printf '%s\n' "$CMS_PROBE_HEADER" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -H 'Content-Type: application/json' --data-raw "$2" \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --header @- "$1" || exit 3 fi unset -v CMS_PROBE_HEADER printf '%s\n' 'NEGATIVE (anonymous when the negative-header prompt was empty):' if [ -z "$2" ]; then printf '%s\n' "${CMS_NEGATIVE_HEADER-}" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --header @- "$1" || exit 3 else printf '%s\n' "${CMS_NEGATIVE_HEADER-}" | curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -H 'Content-Type: application/json' --data-raw "$2" \ -w '\nhttp=%{http_code} exit=%{exitcode} err=%{errormsg}\n' \ --header @- "$1" || exit 3 fi unset -v CMS_NEGATIVE_HEADER printf '%s\n' 'Requests done; compare both bodies. Exit 0 is not a security verdict.' ) ``` REASONED: preceding block; full paired canary, token and credential-retirement probe, including argument guards, header prompts, authorized and negative requests and interpretation; a live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected. Stubbed GET and POST fixtures printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called curl twice and exited 0; these are synthetic, not live authorization results. The placeholder and example.com/.net/.org fixtures printed "substitute your own canary URL inside the quotes; not probing"; non-HTTPS printed "use an https:// URL; not probing"; omitted set -- printed "paste the whole block, including its set -- line; not probing"; wrong argument count printed "the set -- line needs a URL and a JSON body (empty for GET); not probing". Empty or control-character control headers printed "supply a valid control header without control characters; not probing"; control-character or placeholder negative headers printed "supply a negative header without control characters, or leave it empty; not probing". Failed input printed "header input failed; not probing"; readonly CMS_PROBE_HEADER printed "cannot clear CMS header variables in this shell; not probing"; readonly IFS printed "a readonly IFS is set in this shell; not probing". Each guard exited 2 with zero curl calls. A stubbed curl failure printed "AUTHORIZED CONTROL", made one call and exited 2. The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Python parsed the Hasura table JSON. With exposed collection grants, anonymous requests return the canary; fixed Strapi and Directus permissions explicitly deny anonymous access while an authorized control returns the canary. Fixed Hasura returns an admin-secret-required JSON error with HTTP 200 by default while the authorized control returns the canary, and fixed PostgREST returns PGRST302 for no token, PGRST301 for a bad token or PGRST303 for an expired token while a valid control returns the canary. Empty or filtered results are inconclusive. These expectations follow the guide's cited Strapi REST API, content types private by default (rolling documentation, checked September 2026), Strapi Users and Permissions, public role and sign-up (rolling documentation, checked September 2026), Directus access control (public permissions off by default) and Directus error codes (authentication versus permission failures) entries. These expectations also follow the guide's cited Hasura securing the GraphQL endpoint, Hasura Community Edition 2.48.0 release notes (`HASURA_GRAPHQL_PRESERVE_401_ERRORS`), PostgREST authentication and roles and PostgREST error codes (PGRST300 to PGRST303) entries. Read the body, not the bare status. The Strapi and Directus probes only cover the one collection you point them at, since both deny by default; run them against the collections you actually opened and read the full grant list in the tool (the Directus access policies, the Strapi role permission matrix). Hasura answers HTTP 200 even for an authorization error by default (its `HASURA_GRAPHQL_PRESERVE_401_ERRORS` flag, Community Edition 2.48.0 and later, preserves authentication failures as HTTP 401 instead; the quickstart Compose ships 2.46.0), so the fixed state is the admin-secret-required message in the JSON. For PostgREST, repeat the negative with an invalid token by entering `Authorization: Bearer not-a-real-jwt` at the negative-header prompt and expect `PGRST301`, which confirms a bad token is rejected rather than treated as anonymous; a missing or expired token gives `PGRST302` or `PGRST303`. REASONED: credential-retirement and administrator-password comparisons; a live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No deployment credentials or fresh browser session were available. The paired block passed bash -n and ShellCheck 0.11.0 with Bash selected. Stubbed GET and POST fixtures printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called curl twice and exited 0; these are synthetic, not live authorization results. An empty-control fixture printed "supply a valid control header without control characters; not probing", exited 2 and called curl zero times. The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the paired block, whose curl failures exited with status 2; none was re-run on the current form, whose curl failures exit with status 3. Exposed old credentials retrieve the canary and an old administrator password logs in; fixed retired credentials fail authentication while a current credential still retrieves the canary, and the old password fails validation while the operator-controlled password still logs in. These expectations follow the guide's cited Strapi API tokens, pre-generated Full access and Read-only tokens, scopes and lifetime (rolling documentation, checked September 2026), Directus static tokens (scope, persistence and management), Directus error codes (authentication versus permission failures), Hasura securing the GraphQL endpoint and PostgREST authentication and roles entries. These expectations also follow the guide's cited Strapi quick start, first administrator (rolling documentation, checked September 2026) and Directus configuration, first admin user (ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_TOKEN; PORT default 8055) entries. A malformed token discriminates nothing about the real credentials, so also test the ones this deployment actually uses. Repeat the paired request using the old credential after retirement: a deleted Strapi Content API token, a Directus static token replaced or cleared on the existing user through the Data Studio or Users API, a Hasura admin secret removed from the running configuration, or an otherwise valid, unexpired canary JWT signed with a key the verifier no longer accepts. Changing Directus's bootstrap `ADMIN_TOKEN` environment variable alone is not a rotation procedure for an existing user's token. Enter the old credential's header at the negative-header prompt, keeping the valid control, origin, path and body unchanged, and require an application authentication failure attributable to the retired credential; a permission denial, proxy rejection or unrelated error does not establish revocation. For administrator passwords, use a fresh browser session at the same login origin: the operator-controlled password must authenticate while the old or copied one fails credential validation, and a missing OTP, account lockout or proxy denial does not establish that the old password was rejected. There is no universal default Strapi administrator password or pre-generated token value; confirm the actual bootstrap values for this deployment. REASONED: console, introspection, writes, registration, files, MFA and egress in this paragraph; a live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No isolated dataset, external fetch destination or second-factor identity was available. Both blocks passed bash -n and ShellCheck 0.11.0 with Bash selected; paired scratch curl-stub GET and POST fixtures printed "AUTHORIZED CONTROL", "NEGATIVE" and "Requests done", called curl twice and exited 0; Python parsed the Hasura table and introspection JSON bodies. Those runs covered earlier forms of both blocks, whose curl failures exited with status 2; none was re-run on the current forms, whose curl failures exit with status 3. Exposed Hasura console HTML becomes engine JSON not-found after disablement with a working control, per the guide's cited Hasura GraphQL Engine flags reference (SERVER_HOST, ENABLE_CONSOLE, ENABLED_APIS, DEV_MODE, UNAUTHORIZED_ROLE). Exposed introspection schema data becomes introspection-specific rejection while an ordinary permitted query succeeds, per the guide's cited Hasura disable GraphQL introspection (self-hosted Enterprise control) and Strapi GraphQL plugin (endpoint, shadowCRUD, apolloServer, landingPage) (rolling documentation, checked September 2026). Exposed unauthorized writes reach the datastore; fixed create, update, delete, upload and RPC attempts are explicitly denied with no unauthorized state change while authorized controls succeed. These write expectations follow the guide's cited Strapi Users and Permissions, public role and sign-up (rolling documentation, checked September 2026), Directus access control (public permissions off by default), Hasura securing the GraphQL endpoint and PostgREST authentication and roles entries. The PostgREST write and RPC expectations also follow the guide's cited PostgREST database authorization, function access `PUBLIC` by default, `SECURITY DEFINER` functions running with the owner's authority, and views invoked with the view owner's privileges (rolling documentation, checked October 2026) entry. The PostgREST RPC expectations also follow the guide's cited PostgREST functions as RPC, every exposed function the active role can access executable under `/rpc`, and functions able to perform any operation PostgreSQL allows (rolling documentation, checked October 2026) entry. Exposed registration creates an account; fixed registration creates none while an authorized account-control path still works, per the guide's cited Directus registration (disabled by default; empty 204 response). This guide's assessment is that the v5.58.0 initial sign-up setting does not establish an existing project's stored setting or another release's default; the cited Strapi v5.58.0 initial advanced settings show the value only when the advanced settings are absent. Exposed private-file access yields a file; fixed anonymous access is explicitly denied while authorized retrieval succeeds, per the guide's cited Directus file access (asset permissions and storage bypass) and Strapi Media Library, upload providers (rolling documentation, checked September 2026). Exposed password-only administration succeeds; fixed password-only access is denied while completed second-factor access succeeds, per the guide's cited Strapi 5 administrator MFA options (vendor statement, September 2026) and Directus security and limits (`SECRET`, cookie flags, `IMPORT_IP_DENY_LIST`) entries and the guide's linked mfa.md. Exposed egress sends a request to a forbidden destination; fixed egress sends none there while an allowed destination still receives one, per the guide's cited Directus security and limits (`SECRET`, cookie flags, `IMPORT_IP_DENY_LIST`) entry and linked egress-metadata.md. These cited controls do not establish the live cross-service comparison. A served console page is not itself the exposure; the data-API probe is what shows whether admin operations are gated. As a reasoned console check, request `GET /console` and `GET /` on the same Hasura origin as the successful control: with the console enabled in an isolated baseline it returns HTML, and after disabling it the engine returns a JSON not-found; a proxy-generated 404 or a failed control is inconclusive. Where introspection must be off, send `{"query":"{ __schema { queryType { name } } }"}` to the GraphQL endpoint and require an introspection-specific rejection while an ordinary permitted query still succeeds there. The read canary does not test writes, registration, media, MFA or egress: in an isolated test dataset repeat each configured create, update, delete, upload and RPC with authorized and unauthorized identities and check the resulting datastore state. The next two steps are to verify registration with a disposable identity and inspect whether an account was created, and to verify a private file with an authorized retrieval before an anonymous one, including any direct storage URL. The last two steps are to verify MFA by comparing a completed second factor with password-only access and to exercise a URL-fetching integration against an allowed and an operator-controlled forbidden destination, confirming the forbidden one receives no request. REASONED: this paragraph covers host-side listener, container, published-port and firewall inspection, which needs deployment host inventory, and direct-origin GET probes from an external vantage, which need an external vantage and allowed-vantage control. A live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Neither deployment inventory nor external vantage was available. The direct-origin block passed bash -n and ShellCheck 0.11.0 with Bash selected. Placeholder, example.com/.net/.org, reserved 203.0.113., 198.51.100., 192.0.2. and 2001:db8, and empty URL fixtures printed "substitute the real origin URL inside the quotes; not probing"; omitted set -- printed "paste the whole block, including its set -- line; not probing". Wrong count printed "the set -- line needs exactly 1 origin URL; not probing"; missing scheme printed "give a complete http:// or https:// URL; not probing". Each guard exited 2 with no curl call. A valid stubbed GET printed "http=200 exit=0 err=" and "an HTTP response means the origin is reachable, including 401, 403 and 404", called curl once and exited 1. A stubbed curl failure printed "any received HTTP status still means reachable; otherwise this failure is inconclusive", called curl once and exited 2. The bash -n, ShellCheck and stub runs recorded here covered an earlier form of the direct-origin block, whose curl failure exited with status 2; none was re-run on the current form, whose curl failure exits with status 3. From outside, exposed direct origins return an HTTP response; a fixed loopback-only or otherwise isolated origin returns no HTTP response from outside while the same origin answers from an allowed vantage. A timeout, DNS or TLS error alone is inconclusive, never proof of isolation; any HTTP status including 401, 403 or 404 proves reachability. Host-side `ss -tlnp` inventories only the current namespace and cannot prove external isolation. These expectations follow the guide's cited Strapi server configuration, HOST, PORT and APP_KEYS (rolling documentation, checked September 2026), Directus `HOST` default `0.0.0.0` and `PORT` default 8055, and the branch that replaces them when `UNIX_SOCKET_PATH` names a socket path (pinned tag v12.4.1) entries. These expectations also follow the guide's cited Hasura GraphQL Engine flags reference (SERVER_HOST, ENABLE_CONSOLE, ENABLED_APIS, DEV_MODE, UNAUTHORIZED_ROLE) entry. The guide's cited PostgREST `server-host` default `!4` (L556), `server-port` default 3000 (L386), and `admin-server-host` falling back to `server-host` (L357-L360) (pinned tag v16.4) and PostgREST admin server (optional health/metrics listener) entries also support these expectations. Finally, inspect host listeners, container networking, published ports and firewall rules: `ss -tlnp` shows the listeners on ports 1337, 8055, 8080, 8081 and 3000 (plus any configured PostgREST admin port) in this network namespace only, which is not proof of external isolation. From an untrusted vantage, first confirm the same origin responds from an allowed one, then probe each configured origin and port directly with the guarded block below, over IPv4 and IPv6 (put a literal IPv6 address in brackets). Any HTTP response, including 401, 403 or 404, proves reachability; a timeout, DNS failure or TLS error does not prove isolation. Keep the optional PostgREST admin server private independently of application JWT permissions. REASONED: following block; direct-origin isolation probe needs exposed and fixed service fixtures and an external vantage with an allowed-vantage control; a live Strapi, Directus, Hasura or PostgREST stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. An earlier authoring note named no container runtime; this review did not check runtime availability. The direct-origin block passed bash -n and ShellCheck 0.11.0 with Bash selected; its expected exposed and fixed outcomes, cited Sources entries and scratch guard and GET results are recorded in the preceding paragraph. Those runs covered an earlier form of the block, whose curl failure exited with status 2; none was re-run on the current form, whose curl failure exits with status 3. ```bash ( set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_DIRECT_ORIGIN_URL' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; } shift [ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 origin URL; not probing'; exit 2; } case "$1" in ''|*REPLACE_WITH_*|*example.com*|*example.net*|*example.org*|*203.0.113.*|*198.51.100.*|*192.0.2.*|*2001:db8*) echo 'substitute the real origin URL inside the quotes; not probing'; exit 2 ;; http://*|https://*) ;; *) echo 'give a complete http:// or https:// URL; not probing'; exit 2 ;; esac curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \ -o /dev/null -w 'http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1" || { echo 'any received HTTP status still means reachable; otherwise this failure is inconclusive' exit 3 } echo 'an HTTP response means the origin is reachable, including 401, 403 and 404' exit 1 ) ``` ## Sources (checked September 2026) Version boundary at the time of writing: Strapi 5 documentation; current Directus documentation (its quickstart uses 12.0.2); Hasura GraphQL Engine v2.x (the quickstart Compose names 2.46.0, and the authentication-status option requires Community Edition 2.48.0); PostgREST documentation identifying itself as version 16. These documentation URLs are rolling references unless a release is named; this guide's assessment is that Strapi v5.58.0 source establishes the initial sign-up value only when advanced settings are absent, while existing projects and other releases require separate verification. - Strapi Users and Permissions, public role and sign-up (rolling documentation, checked September 2026): https://docs.strapi.io/cms/features/users-permissions - Strapi v5.58.0 initial advanced settings, including `allow_register` and `default_role` (checked October 2026): https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/bootstrap/index.js#L100-L113 - Strapi v5.58.0 bootstrap calls initialization (checked October 2026): https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/bootstrap/index.js#L115-L120 - Strapi v5.58.0 registration route (checked October 2026): https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/routes/content-api/auth.js#L31-L43 - Strapi v5.58.0 registration setting check (checked October 2026): https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/controllers/auth.js#L545-L552 - Strapi v5.58.0 default-role lookup (checked October 2026): https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/controllers/auth.js#L578-L586 - Strapi v5.58.0 registered-user role assignment (checked October 2026): https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/controllers/auth.js#L617-L625 - Strapi REST API, content types private by default (rolling documentation, checked September 2026): https://docs.strapi.io/cms/api/rest - Strapi quick start, first administrator (rolling documentation, checked September 2026): https://docs.strapi.io/cms/quick-start - Strapi server configuration, HOST, PORT and APP_KEYS (rolling documentation, checked September 2026): https://docs.strapi.io/cms/configurations/server - Strapi admin-panel configuration, ADMIN_JWT_SECRET, API_TOKEN_SALT and TRANSFER_TOKEN_SALT (rolling documentation, checked September 2026): https://docs.strapi.io/cms/configurations/admin-panel - Strapi Users and Permissions security configuration, `JWT_SECRET` (rolling documentation, checked September 2026): https://docs.strapi.io/cms/features/users-permissions#security-configuration - Strapi middlewares, public static file serving via koa-static (rolling documentation, checked September 2026): https://docs.strapi.io/cms/configurations/middlewares - Strapi Media Library, upload providers (rolling documentation, checked September 2026): https://docs.strapi.io/cms/features/media-library - Directus configuration, first admin user (ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_TOKEN; PORT default 8055): https://directus.com/docs/configuration/general - Directus `HOST` default `0.0.0.0` and `PORT` default 8055, and the branch that replaces them when `UNIX_SOCKET_PATH` names a socket path (pinned tag v12.4.1): https://github.com/directus/directus/blob/v12.4.1/packages/env/src/constants/defaults.ts#L9-L10 and https://github.com/directus/directus/blob/v12.4.1/api/src/server.ts#L169-L182 - Directus access control (public permissions off by default): https://directus.com/docs/guides/auth/access-control - Hasura securing the GraphQL endpoint: https://hasura.io/docs/2.0/deployment/securing-graphql-endpoint/ - Hasura GraphQL Engine flags reference (SERVER_HOST, ENABLE_CONSOLE, ENABLED_APIS, DEV_MODE, UNAUTHORIZED_ROLE): https://hasura.io/docs/2.0/deployment/graphql-engine-flags/reference/ - PostgREST configuration reference (db-anon-role, db-schemas, server-host, jwt-secret): https://docs.postgrest.org/en/stable/references/configuration.html - PostgREST `server-host` default `!4` (L556), `server-port` default 3000 (L386), and `admin-server-host` falling back to `server-host` (L357-L360) (pinned tag v16.4). PostgREST v16.4 `Config.hs` sources: https://github.com/PostgREST/postgrest/blob/v16.4/src/library/PostgREST/Config.hs#L556, https://github.com/PostgREST/postgrest/blob/v16.4/src/library/PostgREST/Config.hs#L386 and https://github.com/PostgREST/postgrest/blob/v16.4/src/library/PostgREST/Config.hs#L357-L360 - PostgREST authentication and roles: https://docs.postgrest.org/en/stable/references/auth.html - PostgREST database authorization, function access `PUBLIC` by default, `SECURITY DEFINER` functions running with the owner's authority, and views invoked with the view owner's privileges (rolling documentation, checked October 2026): https://docs.postgrest.org/en/stable/explanations/db_authz.html - PostgREST functions as RPC, every exposed function the active role can access executable under `/rpc`, and functions able to perform any operation PostgreSQL allows (rolling documentation, checked October 2026): https://docs.postgrest.org/en/stable/references/api/functions.html - PostgREST error codes (PGRST300 to PGRST303): https://docs.postgrest.org/en/stable/references/errors.html - PostgREST OpenAPI output at the root path: https://docs.postgrest.org/en/stable/references/api/openapi.html - PostgREST admin server (optional health/metrics listener): https://docs.postgrest.org/en/stable/references/admin_server.html - Strapi API tokens, pre-generated Full access and Read-only tokens, scopes and lifetime (rolling documentation, checked September 2026): https://docs.strapi.io/cms/features/api-tokens - Strapi SSO, administrator single sign-on (rolling documentation, checked September 2026): https://docs.strapi.io/cms/features/sso - Directus security and limits (`SECRET`, cookie flags, `IMPORT_IP_DENY_LIST`): https://directus.com/docs/configuration/security-limits - Directus file access (asset permissions and storage bypass): https://directus.com/docs/guides/files/access - Hasura disable GraphQL introspection (self-hosted Enterprise control): https://hasura.io/docs/2.0/security/disable-graphql-introspection/ - Hasura Community Edition 2.48.0 release notes (`HASURA_GRAPHQL_PRESERVE_401_ERRORS`): https://hasura.io/changelog/community-edition/v2.48.0 - Strapi GraphQL plugin (endpoint, shadowCRUD, apolloServer, landingPage) (rolling documentation, checked September 2026): https://docs.strapi.io/cms/plugins/graphql - Apollo Server introspection default (rolling documentation, checked September 2026): https://www.apollographql.com/docs/apollo-server/api/apollo-server#introspection - Strapi 5 administrator MFA options (vendor statement, September 2026): https://strapi.io/blog/strapi-admin-panel-mfa-2fa - Directus quickstart (12.0.2 Compose and browser onboarding): https://directus.com/docs/getting-started/create-a-project - Directus registration (disabled by default; empty 204 response): https://directus.com/docs/guides/auth/creating-users - Directus static tokens (scope, persistence and management): https://directus.com/docs/guides/auth/tokens-cookies - Directus error codes (authentication versus permission failures): https://directus.com/docs/guides/connect/errors - Hasura v2 Docker quickstart (selects the stable Compose manifest): https://hasura.io/docs/2.0/getting-started/docker-simple/ - Hasura stable quickstart Compose (2.46.0 when checked): https://raw.githubusercontent.com/hasura/graphql-engine/5fa3e0d2b617a4ae85f65491c5ff6c056cb612de/install-manifests/docker-compose/docker-compose.yaml ## About the AI assistant section below This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use [the prompt at the top](#ai-assistant-prompt). This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard. **Version basis** AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist. DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded. Components (recorded basis): - strapi-signup-source: Strapi Users and Permissions source (v5.58.0) - directus-bind: Directus listener source (v12.4.1) - directus-start: Directus quickstart (12.0.2) - hasura-start: Hasura quickstart image (2.46.0) - hasura-status: Hasura authentication-status minimum (2.48.0) - postgrest-bind: PostgREST listener source (v16.4) - strapi: Strapi MFA announcement (unknown) - strapi-rolling: Strapi documentation (rolling) (unknown) - directus: Directus documentation (unknown) - hasura: Hasura GraphQL Engine documentation (unknown) - postgrest: PostgREST documentation (unknown) - apollo: Apollo Server documentation (rolling) (unknown) Claims (full source mapping in the JSON block below): - strapi-bootstrap (REASONED; strapi-rolling): No default admin password: privately register the first administrator before exposing /admin or management routes. - strapi-bind (REASONED; strapi-rolling): Documented localhost differs from generated HOST=0.0.0.0; use host loopback or Docker 127.0.0.1:1337:1337 without binding container loopback. - strapi-mfa (REASONED; strapi, strapi-rolling): Strapi admin has no native MFA; protect UI and backend management routes with MFA proxy or available SSO, closing local-login bypasses. - strapi-public (REASONED; strapi-rolling): Content types are private by default; Public-role grants determine anonymous access and denied requests normally return 403. - strapi-files (REASONED; strapi-rolling): Local Media Library files use unauthenticated static serving despite private content types; use access-controlled storage for confidential files. - strapi-signup (REASONED; strapi-rolling, strapi-signup-source): Disable unneeded POST /api/auth/local/register signups; v5.58.0 initially allows registration when advanced settings are absent; as this guide's assessment, an existing project's stored setting and other releases need separate verification; new users receive the configured Default role. - strapi-tokens (REASONED; strapi-rolling): Review pre-generated Full access/Read-only Content API tokens, delete unused tokens and bound lifetimes; use Custom scopes when find/findOne is too broad. - strapi-secrets (REASONED; strapi-rolling): Protect and rotate APP_KEYS, ADMIN_JWT_SECRET, API_TOKEN_SALT, TRANSFER_TOKEN_SALT, JWT_SECRET and configured ENCRYPTION_KEY; salts are not bearer tokens. - strapi-graphql (REASONED; apollo, strapi-rolling): Optional /graphql shadowCRUD generates operations; disable unnecessary apolloServer.introspection and landingPage without replacing resolver authorization. - apollo-default (REASONED; apollo): Apollo introspection is enabled unless NODE_ENV=production. - strapi-tls (REASONED; strapi-rolling): Front Strapi with TLS and authentication; the guide records no native TLS termination. - directus-bootstrap (REASONED; directus, directus-start): ADMIN_EMAIL/PASSWORD and optional ADMIN_TOKEN bootstrap admin access; 12.0.2 quickstart also allows browser onboarding, so claim the instance privately. - directus-secret (REASONED; directus): Set a unique protected SECRET; omission generates a random value that does not persist consistently across restarts or replicas. - directus-cookies (REASONED; directus): SESSION_COOKIE_SECURE and REFRESH_TOKEN_COOKIE_SECURE default false; set both true behind HTTPS. - directus-token (REASONED; directus): ADMIN_TOKEN is an unexpiring admin credential independent of interactive MFA; omit unless needed and prefer scoped machine credentials. - directus-mfa (REASONED; directus): Require two-factor authentication for Directus administrator accounts. - directus-public (REASONED; directus): Public permissions are off by default; audit actual collection and /assets grants, including separate upload/import/update/delete rights and storage bypasses. - verify-directus (REASONED; directus): The paired private-collection request must return the known canary to its authorized identity and 403 with FORBIDDEN to the anonymous caller. - directus-import (REASONED; directus): IMPORT_IP_DENY_LIST defaults to 0.0.0.0,169.254.169.254; this import-specific setting does not establish Flow or extension egress protection. - directus-signup (REASONED; directus): Registration is disabled by default; empty 204 responses do not establish rejection or account creation. - directus-introspection (REASONED; directus): GRAPHQL_INTROSPECTION defaults true; disable unnecessary discovery and apply the same roles and policies as REST. - directus-bind (REASONED; directus-bind): At v12.4.1 HOST defaults 0.0.0.0 and PORT 8055; UNIX_SOCKET_PATH replaces both. Use private binding or loopback publication. - directus-studio (REASONED; directus): Root redirects to /admin; SERVE_APP hides Studio without replacing API authorization. Terminate TLS in front. - hasura-admin (REASONED; hasura): An unset HASURA_GRAPHQL_ADMIN_SECRET permits full admin rights; keep the configured bearer secret server-side and use scoped JWT/webhook client auth. - hasura-bind (REASONED; hasura): HASURA_GRAPHQL_SERVER_HOST defaults * on 8080; bind privately and terminate TLS in front. - hasura-console (REASONED; hasura, hasura-start): Binary console default is false, but 2.46.0 quickstart enables / and /console; disable console where unnecessary. - hasura-dev (REASONED; hasura): Keep HASURA_GRAPHQL_DEV_MODE=false in production. - hasura-agent (REASONED; hasura-start): Quickstart publishes data-connector 8081 and literal Postgres credentials; isolate the additional listener and replace credentials. - hasura-apis (REASONED; hasura): ENABLED_APIS defaults metadata, graphql, pgdump, config; narrow unused APIs and protect them with the admin secret. - hasura-anon (REASONED; hasura): Leave UNAUTHORIZED_ROLE unset unless deliberately granting anonymous access. - hasura-introspection (REASONED; hasura): Introspection is enabled by default; its documented per-role disabling control is self-hosted Enterprise, not assumed available in Community. - postgrest-anon (REASONED; postgrest): Unset db-anon-role refuses missing JWTs with 401 PGRST302; intended anonymous access needs a dedicated minimal NOLOGIN role. - postgrest-invalid (REASONED; postgrest): Malformed/bad-signature JWTs return 401 PGRST301; expired or invalid-claims JWTs return 401 PGRST303 instead of anonymous fallback. - postgrest-grants (REASONED; postgrest): GRANT/REVOKE govern API access; PUBLIC function EXECUTE, DEFINER functions and owner-privileged views can widen it. - postgrest-schema (REASONED; postgrest): db-schemas defaults public; expose a deliberate API schema and note that root OpenAPI output follows the requesting role privileges. - postgrest-secret (REASONED; postgrest): Set random jwt-secret of at least 32 characters; a supplied token with no secret configured fails with 500 PGRST300. - postgrest-bind (REASONED; postgrest, postgrest-bind): At v16.4 server-host defaults !4 on 3000; optional admin server inherits that host unless overridden. Isolate both and terminate TLS in front. - egress (REASONED; directus, hasura, postgrest, strapi-rolling): Restrict webhook, Flow, trigger, remote-schema and database-function egress after DNS resolution and redirects; IMPORT_IP_DENY_LIST covers imports only. PostgREST runs any exposed function the active role can access; functions can do anything PostgreSQL allows. This guide infers functions reach networks only via extensions, untrusted procedural languages, privileged COPY TO PROGRAM or similar. - database-boundary (REASONED; directus, hasura, postgrest, strapi-rolling): Protect database credentials, use dedicated minimal database roles, private listeners and verified encrypted remote database transport. - human-machine-auth (REASONED; strapi, directus, hasura, postgrest, strapi-rolling): Use MFA at human administrative boundaries, with separately scoped, expiring and revocable machine credentials. The cited PostgREST page documents JWT role switching, exp validation and db-pre-request checks that can revoke access, not MFA; this guide places PostgREST MFA at the identity provider or access boundary. - verify-canary (REASONED; directus, hasura, postgrest, strapi-rolling): Pair authorized canary retrieval with identical anonymous requests; empty/missing/unrelated responses and transport errors are inconclusive. - verify-hasura (REASONED; hasura, hasura-status, hasura-start): Hasura defaults to HTTP 200 even for auth errors; inspect admin-secret-required JSON. PRESERVE_401_ERRORS requires Community 2.48.0, beyond quickstart 2.46.0. - verify-retirement (REASONED; directus, hasura, postgrest, strapi-rolling): Repeat the same successful canary with retired credentials; require authentication failure, not permission/proxy errors. Bootstrap ADMIN_TOKEN edits do not rotate existing users. - verify-password (REASONED; directus, strapi-rolling): Fresh admin login must accept operator-controlled credentials and reject actual old/copied passwords; missing OTP or lockout does not prove retirement. - verify-console (REASONED; hasura): Enabled Hasura / and /console serve HTML; disabled engine returns JSON not-found. Proxy 404 or a failed positive control is inconclusive. - verify-introspection (REASONED; apollo, directus, hasura, strapi-rolling): Require an introspection-specific rejection while an ordinary permitted GraphQL query still succeeds. - verify-other-controls (REASONED; directus, hasura, postgrest, strapi-rolling): Test writes, registration, private files, MFA and outbound fetches separately with authorized/unauthorized fixtures and persisted effects. For PostgREST, the cited pages support only the write and /rpc tests. - verify-isolation (REASONED; directus-bind, hasura, postgrest-bind, postgrest, strapi-rolling): Inventory host/container listeners and mappings; probe every direct IPv4/IPv6 origin with an allowed control. Any HTTP response proves reachability; failures alone do not prove isolation. ````json { "schema": 1, "checked": "2026-09-27", "documentation_checked": "2026-09", "body_sha256": "835c371384094b9d779020e56530b736c29931c5aaa3f493d4f807912e1f56f2", "components": { "strapi-signup-source": { "name": "Strapi Users and Permissions source", "basis": "v5.58.0", "sources": { "s4c8064a33fd4": "https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/bootstrap/index.js#L100-L113", "s3a1bf0b046f7": "https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/bootstrap/index.js#L115-L120", "sff564219f001": "https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/routes/content-api/auth.js#L31-L43", "sb1c62d9367e8": "https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/controllers/auth.js#L545-L552", "sdeeb754207df": "https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/controllers/auth.js#L578-L586", "s257a8bd26acc": "https://github.com/strapi/strapi/blob/v5.58.0/packages/plugins/users-permissions/server/src/controllers/auth.js#L617-L625" } }, "directus-bind": { "name": "Directus listener source", "basis": "v12.4.1", "sources": { "se00954e49d12": "https://github.com/directus/directus/blob/v12.4.1/packages/env/src/constants/defaults.ts#L9-L10", "s48e8715d5555": "https://github.com/directus/directus/blob/v12.4.1/api/src/server.ts#L169-L182" } }, "directus-start": { "name": "Directus quickstart", "basis": "12.0.2", "sources": { "s0c25f43df477": "https://directus.com/docs/getting-started/create-a-project" } }, "hasura-start": { "name": "Hasura quickstart image", "basis": "2.46.0", "sources": { "s8a898d4830a5": "https://raw.githubusercontent.com/hasura/graphql-engine/5fa3e0d2b617a4ae85f65491c5ff6c056cb612de/install-manifests/docker-compose/docker-compose.yaml" } }, "hasura-status": { "name": "Hasura authentication-status minimum", "basis": "2.48.0", "sources": { "s236d307db2ed": "https://hasura.io/changelog/community-edition/v2.48.0" } }, "postgrest-bind": { "name": "PostgREST listener source", "basis": "v16.4", "sources": { "sf27352fc3094": "https://github.com/PostgREST/postgrest/blob/v16.4/src/library/PostgREST/Config.hs#L556", "sb8d27dc6392b": "https://github.com/PostgREST/postgrest/blob/v16.4/src/library/PostgREST/Config.hs#L386", "sb1cd23c9b7eb": "https://github.com/PostgREST/postgrest/blob/v16.4/src/library/PostgREST/Config.hs#L357-L360" } }, "strapi": { "name": "Strapi MFA announcement", "basis": "unknown", "sources": { "sb599a80b159b": "https://strapi.io/blog/strapi-admin-panel-mfa-2fa" } }, "strapi-rolling": { "name": "Strapi documentation (rolling)", "basis": "unknown", "sources": { "s14bc6df86824": "https://docs.strapi.io/cms/features/users-permissions", "se092a52da156": "https://docs.strapi.io/cms/api/rest", "s6e175ebd2b41": "https://docs.strapi.io/cms/quick-start", "s98b50551124e": "https://docs.strapi.io/cms/configurations/server", "sab01c70ba4b8": "https://docs.strapi.io/cms/configurations/admin-panel", "s6a8521b5356a": "https://docs.strapi.io/cms/features/users-permissions#security-configuration", "s614dabdcf18c": "https://docs.strapi.io/cms/configurations/middlewares", "se877faf6af5f": "https://docs.strapi.io/cms/features/media-library", "s138b6fa1d39d": "https://docs.strapi.io/cms/features/api-tokens", "s5525cacf37f3": "https://docs.strapi.io/cms/features/sso", "sd2464531df4e": "https://docs.strapi.io/cms/plugins/graphql" } }, "directus": { "name": "Directus documentation", "basis": "unknown", "sources": { "sf46f66d9d647": "https://directus.com/docs/configuration/general", "s95b4deed4c04": "https://directus.com/docs/guides/auth/access-control", "sdb5770dacce9": "https://directus.com/docs/configuration/security-limits", "sb8f67532c865": "https://directus.com/docs/guides/files/access", "sbbbbd61b0406": "https://directus.com/docs/guides/auth/creating-users", "s564309fa506c": "https://directus.com/docs/guides/auth/tokens-cookies", "sbd072a55a4d8": "https://directus.com/docs/guides/connect/errors" } }, "hasura": { "name": "Hasura GraphQL Engine documentation", "basis": "unknown", "sources": { "scf6fdeea5567": "https://hasura.io/docs/2.0/deployment/securing-graphql-endpoint/", "s5a2b2f9a72fa": "https://hasura.io/docs/2.0/deployment/graphql-engine-flags/reference/", "sdd8563e06e4c": "https://hasura.io/docs/2.0/security/disable-graphql-introspection/", "se1d13e0328aa": "https://hasura.io/docs/2.0/getting-started/docker-simple/" } }, "postgrest": { "name": "PostgREST documentation", "basis": "unknown", "sources": { "s3133e2e17cbe": "https://docs.postgrest.org/en/stable/references/configuration.html", "s45f396aa4325": "https://docs.postgrest.org/en/stable/references/auth.html", "sa2516a436dca": "https://docs.postgrest.org/en/stable/references/errors.html", "s5e5f9522821f": "https://docs.postgrest.org/en/stable/references/api/openapi.html", "sfbd46253f1a9": "https://docs.postgrest.org/en/stable/references/admin_server.html", "s60b0eee68d51": "https://docs.postgrest.org/en/stable/explanations/db_authz.html", "s637fd0a1a752": "https://docs.postgrest.org/en/stable/references/api/functions.html" } }, "apollo": { "name": "Apollo Server documentation (rolling)", "basis": "unknown", "sources": { "sda3bf614c4c1": "https://www.apollographql.com/docs/apollo-server/api/apollo-server#introspection" } } }, "claims": { "strapi-bootstrap": {"text": "No default admin password: privately register the first administrator before exposing /admin or management routes.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s6e175ebd2b41", "strapi-rolling:sab01c70ba4b8"], "status": "REASONED"}, "strapi-bind": {"text": "Documented localhost differs from generated HOST=0.0.0.0; use host loopback or Docker 127.0.0.1:1337:1337 without binding container loopback.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s98b50551124e"], "status": "REASONED"}, "strapi-mfa": {"text": "Strapi admin has no native MFA; protect UI and backend management routes with MFA proxy or available SSO, closing local-login bypasses.", "components": ["strapi", "strapi-rolling"], "sources": ["strapi-rolling:s5525cacf37f3", "strapi:sb599a80b159b"], "status": "REASONED"}, "strapi-public": {"text": "Content types are private by default; Public-role grants determine anonymous access and denied requests normally return 403.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s14bc6df86824", "strapi-rolling:se092a52da156"], "status": "REASONED", "verify": [1]}, "strapi-files": {"text": "Local Media Library files use unauthenticated static serving despite private content types; use access-controlled storage for confidential files.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s614dabdcf18c", "strapi-rolling:se877faf6af5f"], "status": "REASONED"}, "strapi-signup": {"text": "Disable unneeded POST /api/auth/local/register signups; v5.58.0 initially allows registration when advanced settings are absent; as this guide's assessment, an existing project's stored setting and other releases need separate verification; new users receive the configured Default role.", "components": ["strapi-rolling", "strapi-signup-source"], "sources": ["strapi-rolling:s14bc6df86824", "strapi-signup-source:s4c8064a33fd4", "strapi-signup-source:s3a1bf0b046f7", "strapi-signup-source:sff564219f001", "strapi-signup-source:sb1c62d9367e8", "strapi-signup-source:sdeeb754207df", "strapi-signup-source:s257a8bd26acc"], "status": "REASONED"}, "strapi-tokens": {"text": "Review pre-generated Full access/Read-only Content API tokens, delete unused tokens and bound lifetimes; use Custom scopes when find/findOne is too broad.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s138b6fa1d39d"], "status": "REASONED"}, "strapi-secrets": {"text": "Protect and rotate APP_KEYS, ADMIN_JWT_SECRET, API_TOKEN_SALT, TRANSFER_TOKEN_SALT, JWT_SECRET and configured ENCRYPTION_KEY; salts are not bearer tokens.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s98b50551124e", "strapi-rolling:sab01c70ba4b8", "strapi-rolling:s6a8521b5356a", "strapi-rolling:s138b6fa1d39d"], "status": "REASONED"}, "strapi-graphql": {"text": "Optional /graphql shadowCRUD generates operations; disable unnecessary apolloServer.introspection and landingPage without replacing resolver authorization.", "components": ["apollo", "strapi-rolling"], "sources": ["strapi-rolling:sd2464531df4e", "apollo:sda3bf614c4c1"], "status": "REASONED"}, "apollo-default": {"text": "Apollo introspection is enabled unless NODE_ENV=production.", "components": ["apollo"], "sources": ["apollo:sda3bf614c4c1"], "status": "REASONED"}, "strapi-tls": {"text": "Front Strapi with TLS and authentication; the guide records no native TLS termination.", "components": ["strapi-rolling"], "sources": ["strapi-rolling:s98b50551124e"], "status": "REASONED"}, "directus-bootstrap": {"text": "ADMIN_EMAIL/PASSWORD and optional ADMIN_TOKEN bootstrap admin access; 12.0.2 quickstart also allows browser onboarding, so claim the instance privately.", "components": ["directus", "directus-start"], "sources": ["directus:sf46f66d9d647", "directus-start:s0c25f43df477"], "status": "REASONED"}, "directus-secret": {"text": "Set a unique protected SECRET; omission generates a random value that does not persist consistently across restarts or replicas.", "components": ["directus"], "sources": ["directus:sdb5770dacce9"], "status": "REASONED"}, "directus-cookies": {"text": "SESSION_COOKIE_SECURE and REFRESH_TOKEN_COOKIE_SECURE default false; set both true behind HTTPS.", "components": ["directus"], "sources": ["directus:sdb5770dacce9"], "status": "REASONED"}, "directus-token": {"text": "ADMIN_TOKEN is an unexpiring admin credential independent of interactive MFA; omit unless needed and prefer scoped machine credentials.", "components": ["directus"], "sources": ["directus:sf46f66d9d647", "directus:s564309fa506c"], "status": "REASONED"}, "directus-mfa": {"text": "Require two-factor authentication for Directus administrator accounts.", "components": ["directus"], "sources": ["directus:s95b4deed4c04", "directus:sdb5770dacce9"], "status": "REASONED"}, "directus-public": {"text": "Public permissions are off by default; audit actual collection and /assets grants, including separate upload/import/update/delete rights and storage bypasses.", "components": ["directus"], "sources": ["directus:s95b4deed4c04", "directus:sb8f67532c865"], "status": "REASONED", "verify": [1]}, "verify-directus": {"text": "The paired private-collection request must return the known canary to its authorized identity and 403 with FORBIDDEN to the anonymous caller.", "components": ["directus"], "sources": ["directus:s95b4deed4c04", "directus:sbd072a55a4d8"], "status": "REASONED", "verify": [1]}, "directus-import": {"text": "IMPORT_IP_DENY_LIST defaults to 0.0.0.0,169.254.169.254; this import-specific setting does not establish Flow or extension egress protection.", "components": ["directus"], "sources": ["directus:sdb5770dacce9"], "status": "REASONED"}, "directus-signup": {"text": "Registration is disabled by default; empty 204 responses do not establish rejection or account creation.", "components": ["directus"], "sources": ["directus:sbbbbd61b0406"], "status": "REASONED"}, "directus-introspection": {"text": "GRAPHQL_INTROSPECTION defaults true; disable unnecessary discovery and apply the same roles and policies as REST.", "components": ["directus"], "sources": ["directus:sdb5770dacce9"], "status": "REASONED"}, "directus-bind": {"text": "At v12.4.1 HOST defaults 0.0.0.0 and PORT 8055; UNIX_SOCKET_PATH replaces both. Use private binding or loopback publication.", "components": ["directus-bind"], "sources": ["directus-bind:se00954e49d12", "directus-bind:s48e8715d5555"], "status": "REASONED"}, "directus-studio": {"text": "Root redirects to /admin; SERVE_APP hides Studio without replacing API authorization. Terminate TLS in front.", "components": ["directus"], "sources": ["directus:sf46f66d9d647"], "status": "REASONED"}, "hasura-admin": {"text": "An unset HASURA_GRAPHQL_ADMIN_SECRET permits full admin rights; keep the configured bearer secret server-side and use scoped JWT/webhook client auth.", "components": ["hasura"], "sources": ["hasura:scf6fdeea5567"], "status": "REASONED"}, "hasura-bind": {"text": "HASURA_GRAPHQL_SERVER_HOST defaults * on 8080; bind privately and terminate TLS in front.", "components": ["hasura"], "sources": ["hasura:s5a2b2f9a72fa"], "status": "REASONED"}, "hasura-console": {"text": "Binary console default is false, but 2.46.0 quickstart enables / and /console; disable console where unnecessary.", "components": ["hasura", "hasura-start"], "sources": ["hasura:s5a2b2f9a72fa", "hasura:se1d13e0328aa", "hasura-start:s8a898d4830a5"], "status": "REASONED"}, "hasura-dev": {"text": "Keep HASURA_GRAPHQL_DEV_MODE=false in production.", "components": ["hasura"], "sources": ["hasura:s5a2b2f9a72fa"], "status": "REASONED"}, "hasura-agent": {"text": "Quickstart publishes data-connector 8081 and literal Postgres credentials; isolate the additional listener and replace credentials.", "components": ["hasura-start"], "sources": ["hasura-start:s8a898d4830a5"], "status": "REASONED"}, "hasura-apis": {"text": "ENABLED_APIS defaults metadata, graphql, pgdump, config; narrow unused APIs and protect them with the admin secret.", "components": ["hasura"], "sources": ["hasura:s5a2b2f9a72fa"], "status": "REASONED"}, "hasura-anon": {"text": "Leave UNAUTHORIZED_ROLE unset unless deliberately granting anonymous access.", "components": ["hasura"], "sources": ["hasura:s5a2b2f9a72fa"], "status": "REASONED"}, "hasura-introspection": {"text": "Introspection is enabled by default; its documented per-role disabling control is self-hosted Enterprise, not assumed available in Community.", "components": ["hasura"], "sources": ["hasura:sdd8563e06e4c"], "status": "REASONED"}, "postgrest-anon": {"text": "Unset db-anon-role refuses missing JWTs with 401 PGRST302; intended anonymous access needs a dedicated minimal NOLOGIN role.", "components": ["postgrest"], "sources": ["postgrest:s3133e2e17cbe", "postgrest:s45f396aa4325", "postgrest:sa2516a436dca"], "status": "REASONED", "verify": [1]}, "postgrest-invalid": {"text": "Malformed/bad-signature JWTs return 401 PGRST301; expired or invalid-claims JWTs return 401 PGRST303 instead of anonymous fallback.", "components": ["postgrest"], "sources": ["postgrest:s45f396aa4325", "postgrest:sa2516a436dca"], "status": "REASONED", "verify": [1]}, "postgrest-grants": {"text": "GRANT/REVOKE govern API access; PUBLIC function EXECUTE, DEFINER functions and owner-privileged views can widen it.", "components": ["postgrest"], "sources": ["postgrest:s45f396aa4325", "postgrest:s60b0eee68d51"], "status": "REASONED"}, "postgrest-schema": {"text": "db-schemas defaults public; expose a deliberate API schema and note that root OpenAPI output follows the requesting role privileges.", "components": ["postgrest"], "sources": ["postgrest:s3133e2e17cbe", "postgrest:s5e5f9522821f"], "status": "REASONED"}, "postgrest-secret": {"text": "Set random jwt-secret of at least 32 characters; a supplied token with no secret configured fails with 500 PGRST300.", "components": ["postgrest"], "sources": ["postgrest:s3133e2e17cbe", "postgrest:sa2516a436dca"], "status": "REASONED"}, "postgrest-bind": {"text": "At v16.4 server-host defaults !4 on 3000; optional admin server inherits that host unless overridden. Isolate both and terminate TLS in front.", "components": ["postgrest", "postgrest-bind"], "sources": ["postgrest:s3133e2e17cbe", "postgrest-bind:sf27352fc3094", "postgrest-bind:sb8d27dc6392b", "postgrest-bind:sb1cd23c9b7eb", "postgrest:sfbd46253f1a9"], "status": "REASONED"}, "egress": {"text": "Restrict webhook, Flow, trigger, remote-schema and database-function egress after DNS resolution and redirects; IMPORT_IP_DENY_LIST covers imports only. PostgREST runs any exposed function the active role can access; functions can do anything PostgreSQL allows. This guide infers functions reach networks only via extensions, untrusted procedural languages, privileged COPY TO PROGRAM or similar.", "components": ["directus", "hasura", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:s14bc6df86824", "directus:sdb5770dacce9", "hasura:s5a2b2f9a72fa", "postgrest:s637fd0a1a752"], "status": "REASONED"}, "database-boundary": {"text": "Protect database credentials, use dedicated minimal database roles, private listeners and verified encrypted remote database transport.", "components": ["directus", "hasura", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:s98b50551124e", "directus:sf46f66d9d647", "hasura:s5a2b2f9a72fa", "postgrest:s3133e2e17cbe"], "status": "REASONED"}, "human-machine-auth": {"text": "Use MFA at human administrative boundaries, with separately scoped, expiring and revocable machine credentials. The cited PostgREST page documents JWT role switching, exp validation and db-pre-request checks that can revoke access, not MFA; this guide places PostgREST MFA at the identity provider or access boundary.", "components": ["strapi", "directus", "hasura", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:s5525cacf37f3", "strapi:sb599a80b159b", "directus:sdb5770dacce9", "hasura:scf6fdeea5567", "postgrest:s45f396aa4325"], "status": "REASONED"}, "verify-canary": {"text": "Pair authorized canary retrieval with identical anonymous requests; empty/missing/unrelated responses and transport errors are inconclusive.", "components": ["directus", "hasura", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:se092a52da156", "directus:s95b4deed4c04", "hasura:scf6fdeea5567", "postgrest:s45f396aa4325"], "status": "REASONED", "verify": [1]}, "verify-hasura": {"text": "Hasura defaults to HTTP 200 even for auth errors; inspect admin-secret-required JSON. PRESERVE_401_ERRORS requires Community 2.48.0, beyond quickstart 2.46.0.", "components": ["hasura", "hasura-status", "hasura-start"], "sources": ["hasura:s5a2b2f9a72fa", "hasura-status:s236d307db2ed", "hasura-start:s8a898d4830a5"], "status": "REASONED", "verify": [1]}, "verify-retirement": {"text": "Repeat the same successful canary with retired credentials; require authentication failure, not permission/proxy errors. Bootstrap ADMIN_TOKEN edits do not rotate existing users.", "components": ["directus", "hasura", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:s138b6fa1d39d", "directus:s564309fa506c", "directus:sbd072a55a4d8", "hasura:scf6fdeea5567", "postgrest:s45f396aa4325"], "status": "REASONED", "verify": [1]}, "verify-password": {"text": "Fresh admin login must accept operator-controlled credentials and reject actual old/copied passwords; missing OTP or lockout does not prove retirement.", "components": ["directus", "strapi-rolling"], "sources": ["strapi-rolling:s6e175ebd2b41", "directus:sf46f66d9d647", "directus:sbd072a55a4d8"], "status": "REASONED"}, "verify-console": {"text": "Enabled Hasura / and /console serve HTML; disabled engine returns JSON not-found. Proxy 404 or a failed positive control is inconclusive.", "components": ["hasura"], "sources": ["hasura:s5a2b2f9a72fa"], "status": "REASONED"}, "verify-introspection": {"text": "Require an introspection-specific rejection while an ordinary permitted GraphQL query still succeeds.", "components": ["apollo", "directus", "hasura", "strapi-rolling"], "sources": ["strapi-rolling:sd2464531df4e", "apollo:sda3bf614c4c1", "directus:sdb5770dacce9", "hasura:sdd8563e06e4c"], "status": "REASONED"}, "verify-other-controls": {"text": "Test writes, registration, private files, MFA and outbound fetches separately with authorized/unauthorized fixtures and persisted effects. For PostgREST, the cited pages support only the write and /rpc tests.", "components": ["directus", "hasura", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:s14bc6df86824", "strapi-rolling:se877faf6af5f", "strapi-rolling:s138b6fa1d39d", "directus:s95b4deed4c04", "directus:sb8f67532c865", "directus:sbbbbd61b0406", "directus:sdb5770dacce9", "hasura:scf6fdeea5567", "postgrest:s45f396aa4325", "postgrest:s60b0eee68d51", "postgrest:s637fd0a1a752"], "status": "REASONED"}, "verify-isolation": {"text": "Inventory host/container listeners and mappings; probe every direct IPv4/IPv6 origin with an allowed control. Any HTTP response proves reachability; failures alone do not prove isolation.", "components": ["directus-bind", "hasura", "postgrest-bind", "postgrest", "strapi-rolling"], "sources": ["strapi-rolling:s98b50551124e", "directus-bind:se00954e49d12", "directus-bind:s48e8715d5555", "hasura:s5a2b2f9a72fa", "postgrest-bind:sf27352fc3094", "postgrest-bind:sb8d27dc6392b", "postgrest-bind:sb1cd23c9b7eb", "postgrest:sfbd46253f1a9"], "status": "REASONED", "verify": [2]} } } ```` ====================================================================== ==> exposure-index.md ====================================================================== # Exposure index: ports this corpus documents, and where to look them up You ran a scan and something is listening. This page narrows the search. A port number does not identify a service. IANA says so about its own registry: traffic on a registered port need not belong to the service assigned to it, and many of the numbers below are registered to something other than the service you will find there. Use the owning process from `ss`, not the number, to decide what you are looking at; the number only tells you which guides are worth opening. A port missing from this table is not a port nobody has reviewed. It is a port this page does not list. Identify the process, then search the corpus by service name. ## How to use this 1. `sudo ss -tlnp` to get the listening sockets **and the process that owns each one**. Without `sudo` you get the ports but not the process names for sockets owned by other users, which is most of them. 2. Find the port below and open the guides named. More than one may apply. 3. Run **that guide's** Verify section. This page's Verify does not replace it, and cannot: a port being bound privately says nothing about whether the service behind it authenticates anyone. ## What binds what, according to this corpus Rows say what *may* be listening. Where a guide documents a published container mapping rather than the application's own listener, the row says so, because `-p 3000:8080` means two different ports and only one of them is the application's. Allowlist a container port behind a remapped publication in `tools/exposure_index_allowlist.txt`, naming the host-port row in the reason, unless the same guide documents another listener on that container-port number. Rows are ordered by their first port number or the start of a range. Related ports may share a row, and ranges can overlap other entries. The Default credential column records what the cited guides say a fresh install ships with: a well-known account, an empty or placeholder password, no authentication at all, a secure default such as a generated token, or another posture the guide states, such as a shared secret or a listener that sits outside the login. It reports the guides, not the vendors. "not stated" means the cited guides are silent, which is not the same as safe, and "varies by service; see the guides" means the row covers services whose defaults differ. Open the guide before relying on either, because defaults change between releases. | Port | May be | Default credential | Documented in | | --- | --- | --- | --- | | 22 | SSH, which should not be publicly reachable | not stated | [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md), [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md) | | 25 | SMTP relay or MX; the exposure to index is an open relay, not the port itself; also Windmill's email triggers, which its Compose file publishes on 25 through its bundled Caddy | not stated | [transactional-email-posture.md](https://github.com/jposluns/secureconfig/blob/main/transactional-email-posture.md), [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) | | 53 | Cluster DNS, in the Kubernetes NetworkPolicy egress rules, over both UDP and TCP | not stated | [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md), [egress-metadata.md](https://github.com/jposluns/secureconfig/blob/main/egress-metadata.md) | | 80, 443 | Usually the TLS proxy, but also native HTTPS listeners in the language guides; also Baserow's bundled Caddy, which its Compose file publishes on 0.0.0.0 unless `HOST_PUBLISH_IP` is set, and Windmill's bundled Caddy, which its Compose file publishes on 80 in front of the default login. This row's list (2 of 3): also Dify's bundled nginx, Onyx's bundled nginx, PocketBase `serve` with a domain, certbot's standalone HTTP-01 listener. This row's list (3 of 3): the Flux notification-controller's `webhook-receiver` Service, Argo CD's argocd-server Service, a managed provider's Kubernetes API server, Text Generation Inference's main Docker image, and the Dagster Helm chart's webserver Service (port 80 and type `ClusterIP` by default) | varies by service; see the guides | [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [haproxy.md](https://github.com/jposluns/secureconfig/blob/main/haproxy.md), [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md), [apache.md](https://github.com/jposluns/secureconfig/blob/main/apache.md), [lighttpd.md](https://github.com/jposluns/secureconfig/blob/main/lighttpd.md), [go.md](https://github.com/jposluns/secureconfig/blob/main/go.md), [dotnet.md](https://github.com/jposluns/secureconfig/blob/main/dotnet.md), [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md), [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md), [free-certificates.md](https://github.com/jposluns/secureconfig/blob/main/free-certificates.md), [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md), [host.md](https://github.com/jposluns/secureconfig/blob/main/host.md), [java.md](https://github.com/jposluns/secureconfig/blob/main/java.md), [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md), [nodejs.md](https://github.com/jposluns/secureconfig/blob/main/nodejs.md), [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md), [pocketbase.md](https://github.com/jposluns/secureconfig/blob/main/pocketbase.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md), [deployment-lifecycle.md](https://github.com/jposluns/secureconfig/blob/main/deployment-lifecycle.md) | | 81 | Nginx Proxy Manager admin UI (the proxy itself is on 80 and 443) | Default admin user created on first run; change it at first login | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md) | | 465 | SMTP submission over implicit TLS; transport is encrypted from the first byte, authorization is still separate | not stated | [transactional-email-posture.md](https://github.com/jposluns/secureconfig/blob/main/transactional-email-posture.md) | | 587 | SMTP submission with STARTTLS; opportunistic TLS can be stripped, exposing the credential | not stated | [transactional-email-posture.md](https://github.com/jposluns/secureconfig/blob/main/transactional-email-posture.md) | | 1025, 8025 | Helicone Compose backing-service host publications; the guide names MailHog among the backing services but does not assign a protocol to each of these ports | Published independently of the dashboard login | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 1234 | LM Studio local server | No authentication required by default | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 1337 | Strapi HTTP server and admin panel; the first visitor to the admin panel becomes the administrator while no account exists yet | No default admin password; first admin-panel visitor becomes administrator | [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md) | | 1777 | OpenTelemetry Collector pprof diagnostic extension, when enabled | Collector ships with no security until configured | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 1880 | Node-RED editor and admin API, which have no authentication by default | No authentication by default | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md) | | 1883 | MQTT, plaintext (the official Mosquitto images tagged `latest`, `2`, `alpine`, `2.1-alpine` and `2.1.2-alpine`, and the `1.6-openssl` tags, accept anonymous clients on every address as shipped) | Mosquitto 2.0+: no-listener mode anonymous on loopback; explicit listener rejects unauthenticated clients unless `allow_anonymous true` is set, as the official 2.1 images set it | [mosquitto.md](https://github.com/jposluns/secureconfig/blob/main/mosquitto.md) | | 2004 | Neo4j Enterprise Prometheus metrics, disabled by default; explicit localhost bind when enabled | not stated | [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 2019 | Caddy admin API, which requires no credentials and defaults to localhost | No credentials required | [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md) | | 2112 | Weaviate's Prometheus metrics and tenant-activity listener, on every local address when Prometheus monitoring is enabled | not stated | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 2375 | Docker API, plaintext and unauthenticated | Unauthenticated; anyone reaching the socket controls the host | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md) | | 2376 | Docker API over TLS. The port is a convention, not proof of client-certificate authentication: `--tls` and `--tlsverify` are different settings | No client-certificate check without `--tlsverify` | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md) | | 2379, 2380 | etcd client and peer ports on a Kubernetes control-plane node. Every Secret in the cluster is here, unencrypted unless encryption at rest is configured | not stated | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | | 2746 | Argo Server (Argo Workflows API and UI) | Client auth mode (caller's Kubernetes token) by default since v3.0 | [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md) | | 3000 | The most crowded port here. The most likely owners are Metabase, Dagster, Gitea, Dokploy, Next.js, SvelteKit's Node adapter (which defaults to `0.0.0.0`), Rails, Flowise, OpenHands, Langfuse, TGI, and the backend behind most proxy examples. Open WebUI's 3000 is a **published host port** mapping to container 8080; and PostgREST, which binds all IPv4 interfaces here by default; and browserless, which is unauthenticated when its TOKEN is unset; also Text Embeddings Inference's CLI default, the Mem0 dashboard, and Onyx's bundled nginx | varies by service; see the guides | [nextjs.md](https://github.com/jposluns/secureconfig/blob/main/nextjs.md), [frontend-frameworks.md](https://github.com/jposluns/secureconfig/blob/main/frontend-frameworks.md), [ruby.md](https://github.com/jposluns/secureconfig/blob/main/ruby.md), [bi-dashboards.md](https://github.com/jposluns/secureconfig/blob/main/bi-dashboards.md), [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md), [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [open-webui.md](https://github.com/jposluns/secureconfig/blob/main/open-webui.md), [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md), [chat-uis.md](https://github.com/jposluns/secureconfig/blob/main/chat-uis.md), [mcp-servers.md](https://github.com/jposluns/secureconfig/blob/main/mcp-servers.md), [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md), [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [caddy.md](https://github.com/jposluns/secureconfig/blob/main/caddy.md), [cloud-identity-proxies.md](https://github.com/jposluns/secureconfig/blob/main/cloud-identity-proxies.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), [docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md), [haproxy.md](https://github.com/jposluns/secureconfig/blob/main/haproxy.md), [nginx.md](https://github.com/jposluns/secureconfig/blob/main/nginx.md), [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md), [traefik.md](https://github.com/jposluns/secureconfig/blob/main/traefik.md), [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md) | | 3001 | AnythingLLM, or Uptime Kuma; also Litestream's MCP listener, and the Ollama desktop app's loopback-only UI listener in dev mode, `127.0.0.1:3001` (outside dev mode it listens on `127.0.0.1:0`, an OS-chosen port) | varies by service; see the guides | [chat-uis.md](https://github.com/jposluns/secureconfig/blob/main/chat-uis.md), [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [sqlite.md](https://github.com/jposluns/secureconfig/blob/main/sqlite.md), [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) | | 3080 | LibreChat | First registered account becomes admin | [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md) | | 3100 | Grafana Loki's HTTP API, on every interface by default; `auth_enabled` is tenancy, not authentication | No built-in authentication; any `X-Scope-OrgID` value is accepted | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 3101 | Grafana Loki's internal server HTTP listener, which exists only when `internal-server.enable` is true (default false) and defaults to `localhost:3101`. When it is enabled, the guide infers, without having observed it, an any-address gRPC listener on an OS-chosen port carrying the HTTP-over-gRPC bridge unless YAML sets its address and port | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 3128 | Proxmox VE's `spiceproxy` (SPICE console proxy), on every address by default | not stated | [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md) | | 3210 | LobeChat | Registration allowlist `AUTH_ALLOWED_EMAILS` empty by default: any email registers | [chat-uis.md](https://github.com/jposluns/secureconfig/blob/main/chat-uis.md) | | 3306 | MySQL and MariaDB (the official images, MySQL 8.4, 9.7 and innovation and MariaDB 10.6 through 13.1 checked, listen on every address unless an argument or option file sets a bind address or `skip_networking`) | Varies by install method; MariaDB 10.4+ local root via Unix socket. The official images, on a new data directory, create a root account for the root host (`root@'%'` when no value is supplied; none beyond local root when it is `localhost` or a `_FILE` names an empty file), with an empty password when the empty-password variable is set to any non-empty value and no root password, hash or random password is requested | [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md), [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) | | 3389 | RDP, mentioned only as an omitted inventory check in the cloud-firewall guide | not stated | [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) | | 3478 | coturn STUN/TURN over UDP and TCP | Stock example config: anonymous TURN allocation (Docker image ships `lt-cred-mech` on) | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 4000 | LiteLLM proxy; also Budibase's LiteLLM publication | At the time of writing, refuses to start without a master key or with `sk-1234`; older or overridden deployments can run without authentication; UI user `admin`, and with `UI_PASSWORD` unset the master key logs in; Budibase's sample `.env` sets the master key to `budibase` | [litellm.md](https://github.com/jposluns/secureconfig/blob/main/litellm.md), [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md) | | 4180 | oauth2-proxy | not stated | [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md) | | 4200 | Prefect server | No default authentication | [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md) | | 4222 | NATS client connections | No authentication configured by default, and the official images' bundled configuration (their default command) adds none | [nats.md](https://github.com/jposluns/secureconfig/blob/main/nats.md) | | 4317, 4318 | OTLP gRPC and OTLP HTTP receivers, including Jaeger v2's, on localhost in its all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | Collector ships with no security until configured; Jaeger: not stated | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 4369 | epmd, the Erlang Port Mapper Daemon, which maps Erlang node names to distribution ports (RabbitMQ and other Erlang or Elixir clustered services) | not stated | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | | 4442, 4443 | Selenium Grid event bus in distributed mode, which Nodes use to register and which the Router's basic auth does not cover | Grid ships with no authentication; Router basic auth does not cover the bus | [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md) | | 4444 | Selenium Grid Router, Hub, or Standalone, plus the Grid web UI on the same port, with no authentication by default | No authentication by default; Router basic-auth credentials unset | [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md) | | 4646 | Nomad's HTTP API and web UI, on every address by default | No authentication by default (ACLs off); anonymous job registration | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 4647 | Nomad server RPC, on every address by default | not stated | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 4648 | Nomad Serf gossip, on every address by default | Gossip is unencrypted until an `encrypt` key is set | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 4648/UDP | Nomad Serf gossip, on every address by default | Gossip is unencrypted until an `encrypt` key is set | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 5000 | Redash, MLflow tracking server, or a .NET Kestrel default; also text-generation-webui's OpenAI-compatible API and Neo4j Enterprise discovery v1 (deprecated since 5.23), whose host inherits the shared listen address | varies by service; see the guides | [bi-dashboards.md](https://github.com/jposluns/secureconfig/blob/main/bi-dashboards.md), [mlflow.md](https://github.com/jposluns/secureconfig/blob/main/mlflow.md), [dotnet.md](https://github.com/jposluns/secureconfig/blob/main/dotnet.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 5001 | Dify backend API, in the guide's in-container request example | not stated for this port; Dify's `INIT_PASSWORD` is empty by default, so on a reachable host the first visitor to `/install` owns the instance | [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md) | | 5003 | Dify's plugin daemon debugging port, published by the supplied Compose configuration unless you remove or restrict that mapping | not stated | [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md) | | 5349 | coturn TLS listener; DTLS is opt-in since 4.17.0, and this port number does not prove encryption is mandatory | Stock example config: anonymous TURN allocation | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 5432 | PostgreSQL, and pgvector on the same port (the official images, 14 through 19 checked, initialize with `listen_addresses = '*'`, trust local-socket and loopback connections unless `POSTGRES_INITDB_ARGS` sets their methods (`--auth` or `-A`, or `--auth-local` and `--auth-host`), and apply `POSTGRES_HOST_AUTH_METHOD`, a password method by default, to other addresses). The Supabase self-hosted Supavisor pooler also publishes port 5432; also the Onyx development Compose's PostgreSQL and the Langflow example Compose's PostgreSQL | varies by service; see the guides | [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md), [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [supabase-self-hosted.md](https://github.com/jposluns/secureconfig/blob/main/supabase-self-hosted.md), [connection-poolers.md](https://github.com/jposluns/secureconfig/blob/main/connection-poolers.md), [container-hardening.md](https://github.com/jposluns/secureconfig/blob/main/container-hardening.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md) | | 5540 | RedisInsight 3.8.0, whose default server configuration listens on `0.0.0.0:5540` via `RI_APP_HOST` and `RI_APP_PORT` | Authentication not established by the cited files | [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md) | | 5553, 5556, 5557, 5559 | Selenium Grid distributed components: Distributor, Session Map, event bus HTTP, and New Session Queue; these listeners are not protected by the Router's basic auth | Unauthenticated listeners; Grid ships with no authentication | [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md) | | 5555 | Flower, the Celery monitor, or a Selenium Grid Node | Flower: auth disabled unless configured; Selenium Node: Grid ships no auth | [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md), [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md) | | 5556, 5557, 5558 | Argo CD Dex generated web, gRPC and HTTP telemetry listeners, all bound to `0.0.0.0`; starts only when configured | Web TLS enabled by default; generated gRPC config sets only `addr`, with no TLS or client-auth settings; Dex runtime authentication remains unverified | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 5600 | vLLM NIXL handshake base port; adds the data-parallel index | No authentication on this listener | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 5601 | Kibana, which defaults to HTTP on `localhost:5601` with `server.ssl.enabled: false` (its Docker image changes the host default to `0.0.0.0`), and OpenSearch Dashboards, whose 3.8.0 sample configuration also uses localhost and port 5601 with server TLS disabled | not stated | [elasticsearch.md](https://github.com/jposluns/secureconfig/blob/main/elasticsearch.md) | | 5671, 5672 | AMQP over TLS, and AMQP plaintext | `guest`/`guest`, usable only from localhost, except in the official Docker image as shipped, which sets `loopback_users.guest = false` | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | | 5678 | n8n | Owner setup endpoint skips authentication (2.40.6); finish owner setup before exposure | [n8n.md](https://github.com/jposluns/secureconfig/blob/main/n8n.md) | | 5766 | coturn telnet CLI, disabled by default and bound to loopback when enabled | Off by default; loopback when enabled; authenticated by `cli-password`, separately from the web admin | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 5778, 5779 | Jaeger v2 remote sampling, HTTP and gRPC, on localhost in the all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 5900, 7900 | VNC and noVNC in the Selenium Docker browser images, started by default with the vendor example password `secret`; also x11vnc on 5900 in the Vast.ai base-image linux-desktop template | Selenium: VNC on by default; vendor example password `secret`. Vast.ai linux-desktop template: password from `VNC_PASSWORD`, else `OPEN_BUTTON_TOKEN` | [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md), [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md) | | 6000 | Neo4j Enterprise transaction shipping, catch-up and backup-capable endpoint; also discovery v2 when selected; inherits the shared listen host even on a standalone server | Unauthenticated replication by default; cluster TLS policy disabled | [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 6001, 6002 | Coolify real-time updates and terminal | not stated | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md) | | 6006 | Arize Phoenix | Auth disabled by default; when enabled, `admin@localhost`/`admin` | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 6060 | A Go service's pprof and expvar diagnostics listener, bound to loopback in the guide's example; also Weaviate's debug listener, on every local address unless `GO_PROFILING_DISABLE` is set to a true value, answering 404 until debug endpoints are enabled | not stated | [go.md](https://github.com/jposluns/secureconfig/blob/main/go.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 6222 | NATS cluster routes, a separate listener with its own authentication and TLS | Run with their default command, the official images' bundled configuration authorizes routes with a user and password published in the image source (`ruser`, `T0pS3cr3t`) | [nats.md](https://github.com/jposluns/secureconfig/blob/main/nats.md) | | 6333, 6334, 6335 | Qdrant REST, gRPC, and internal cluster gRPC | Not secure by default; 6335 is never protected by an API key through v1.17.x, and on v1.18.0 and later only with `service.enforce_internal_auth`, which is off by default | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 6362 | Neo4j Enterprise backup; cluster port 6000 can also serve backups | `127.0.0.1:6362` default bind (5.26 LTS) | [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 6379 | Argo CD base Redis Deployment | Password from the `argocd-redis` Secret; no TLS configured in the base Deployment | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 6379 | Redis, Valkey, or the Ray head node (the official Redis images checked, 6.2 through 7.4 and 8.10.2, turn protected mode off; the 7.4.8 and 8.10.2 builds, run without a configuration file or arguments, bind every address with no password); also the Onyx development Compose's Redis, and a Helicone Compose backing-service host publication | varies by service; see the guides | [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md), [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md), [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 6432 | PgBouncer, whose client-side TLS is disabled by default | Default `auth_type` is `md5`; `pgbouncer` user passwordless over the Unix socket for a client running as the pooler's Unix UID | [connection-poolers.md](https://github.com/jposluns/secureconfig/blob/main/connection-poolers.md), [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md) | | 6443 | The Kubernetes API server on a self-managed cluster. Managed providers usually serve it on 443 instead, so its absence here proves nothing | not stated | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | | 6543 | The Supabase self-hosted Supavisor transaction pooler, a direct database connection published beside 5432 | Shipped demo `POSTGRES_PASSWORD` | [supabase-self-hosted.md](https://github.com/jposluns/secureconfig/blob/main/supabase-self-hosted.md) | | 6789 | Metrics port mentioned only in the realtime voice guide's Verify inventory; service attribution and default status are not established there | not stated | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 6831/UDP, 6832/UDP | Jaeger v2 Thrift compact and binary receivers, on localhost in the all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 7000 | Argo CD ApplicationSet webhook, wildcard bind | not stated | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 7000 | frp server; also Neo4j Enterprise Raft, inheriting the shared listen host | frp: empty token by default; empty-token client authenticates. Neo4j: cluster TLS client-certificate authentication disabled until the policy is enabled | [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md), [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 7222 | NATS gateways, a separate listener with its own authentication and TLS | not stated | [nats.md](https://github.com/jposluns/secureconfig/blob/main/nats.md) | | 7233 | Temporal frontend gRPC | Default `noopAuthorizer` allows every request | [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md) | | 7422 | NATS accepted leafnode connections, a separate listener with its own authentication and TLS | not stated | [nats.md](https://github.com/jposluns/secureconfig/blob/main/nats.md) | | 7473, 7474, 7687 | Neo4j HTTPS, HTTP, and Bolt | `neo4j`/`neo4j` initial credential, auth on (packaged Neo4j 5) | [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 7688 | Neo4j Enterprise server-side routing, enabled by default and inheriting the shared listen host | Cluster TLS client-certificate authentication disabled until the policy is enabled | [neo4j.md](https://github.com/jposluns/secureconfig/blob/main/neo4j.md) | | 7700 | Meilisearch HTTP API; the binary defaults to localhost, while the official Docker image binds all interfaces | Unauthenticated until a master key is set (dev mode) | [search-engines.md](https://github.com/jposluns/secureconfig/blob/main/search-engines.md) | | 7800 | Keycloak clustered cache transport, carrying sessions and tokens; TLS is on by default for TCP stacks | not stated | [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md) | | 7860 | Gradio, Stable Diffusion WebUI, or Langflow; also text-generation-webui's Gradio UI | varies by service; see the guides | [gradio.md](https://github.com/jposluns/secureconfig/blob/main/gradio.md), [image-gen-uis.md](https://github.com/jposluns/secureconfig/blob/main/image-gen-uis.md), [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 7865 | Fooocus's Gradio UI, on `127.0.0.1` by default; the upstream container image, Compose file and `docker run` example pass a bare `--listen` (`0.0.0.0`) and publish `7865:7865` with no host address | No authentication unless a valid `auth.json` is present; read from the v2.5.5 source and not observed on a running server, an unreadable file should stop startup, and a malformed file or an empty list should start the UI with no login | [image-gen-uis.md](https://github.com/jposluns/secureconfig/blob/main/image-gen-uis.md) | | 7880 | LiveKit HTTP/WebSocket signaling and API, which need a TLS proxy | Refuses to start without keys; `--dev` injects `devkey`/`secret` when the key map is empty; the upstream `config-sample.yaml` ships example pairs `key1: secret1` and `key2: secret2` | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 7881 | LiveKit ICE media TCP mux, separate from signaling | not stated | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 7882/UDP | LiveKit development-mode media mux; outside development mode the documented default is the 50000 to 60000 media range | not stated | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 7946 | Grafana Loki memberlist gossip when memberlist is the ring store, on 0.0.0.0 by default, over TCP; also Weaviate's memberlist gossip, on every local address (`0.0.0.0`) unless a non-empty `CLUSTER_BIND_ADDR` is set | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 7946/UDP | Weaviate's memberlist gossip, on every local address (`0.0.0.0`) unless a non-empty `CLUSTER_BIND_ADDR` is set | not stated | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 7947 | Weaviate's internal cluster API, on every local address | not stated | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 8000 | SurrealDB, Chroma, Triton HTTP, Coolify, Vaultwarden outside Docker, Portainer's Edge agent tunnel, the Supabase self-hosted API gateway (Envoy by default, Kong optional) which fronts the whole stack, or GlitchTip's web, API, and open self-signup. Port 8000 may also be Windmill's server, on `0.0.0.0` by default when run as a binary (its Compose file only exposes it to the Compose network); also LangServe, JupyterHub, Ray Serve, and the Onyx development code interpreter; also Python's `python -m http.server` quick-share, on every interface by default | varies by service; see the guides | [surrealdb.md](https://github.com/jposluns/secureconfig/blob/main/surrealdb.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [supabase-self-hosted.md](https://github.com/jposluns/secureconfig/blob/main/supabase-self-hosted.md), [self-hosted-error-trackers.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-error-trackers.md), [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [jupyter.md](https://github.com/jposluns/secureconfig/blob/main/jupyter.md), [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md), [python.md](https://github.com/jposluns/secureconfig/blob/main/python.md) | | 8001, 8002 | Triton gRPC and Triton Prometheus metrics; and Datasette's CLI default HTTP listener on 8001, loopback by default (an overridden bind or a published container mapping is the exposure) | varies by service; see the guides | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [sqlite-http-frontends.md](https://github.com/jposluns/secureconfig/blob/main/sqlite-http-frontends.md) | | 8006 | Proxmox VE's `pveproxy`: the web interface and the whole cluster API over HTTPS, on every address by default | `root@pam` with the host's root password can always log in; no second factor until configured | [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md) | | 8055 | Directus HTTP API and admin app | Bootstrap admin via `ADMIN_EMAIL`/`ADMIN_PASSWORD` or browser onboarding; public access off | [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md) | | 8080 | llama.cpp, Weaviate HTTP, Airflow, code-server, Dify's nginx when mapped to `127.0.0.1:8080`, Spring Boot, Go, and the Vast.ai Jupyter deployment; Keycloak's HTTP port, which exists only when `--http-enabled=true`; and Hasura GraphQL Engine v2, which binds every interface by default. Port 8080 may be Argo CD's argocd-server pod listener, fronted by its Service on 80 and 443; and sqlite-web's CLI default HTTP listener, loopback by default (an overridden bind or a published container mapping is the exposure). Port 8080 may also be NocoDB (listening on every interface by default) and the Appsmith development Compose file's publication; also LocalAI, Text Embeddings Inference's host publication, the Onyx development API, and an `ssh -R` forwarded port on the tunnel server | varies by service; see the guides | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md), [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md), [code-server.md](https://github.com/jposluns/secureconfig/blob/main/code-server.md), [agent-builders.md](https://github.com/jposluns/secureconfig/blob/main/agent-builders.md), [java.md](https://github.com/jposluns/secureconfig/blob/main/java.md), [go.md](https://github.com/jposluns/secureconfig/blob/main/go.md), [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md), [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md), [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md), [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md), [sqlite-http-frontends.md](https://github.com/jposluns/secureconfig/blob/main/sqlite-http-frontends.md), [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md) | | 8080, 8081 | Argo CD ApplicationSet metrics and health probes, wildcard binds; probe port is not a Service port | not stated | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 8081 | Argo CD repo-server gRPC, wildcard bind | TLS enabled; client certificate not required when the optional client CA is absent | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 8081 | Hasura's bundled data-connector agent, published beside the engine by the vendor quickstart Compose; also Spring Boot Actuator's management port | not stated | [headless-cms-instant-api.md](https://github.com/jposluns/secureconfig/blob/main/headless-cms-instant-api.md), [java.md](https://github.com/jposluns/secureconfig/blob/main/java.md) | | 8082 | Argo CD application-controller metrics and health | not stated | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 8083 | Argo CD server HTTP metrics and opt-in pprof, bound to the main `--address` (default `0.0.0.0`) | `/metrics` has no authentication; pprof returns 401 unless its enable file contains exactly `true`, then has no caller authentication | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 8084 | Argo CD repo-server HTTP metrics, health and opt-in pprof, wildcard bind | Metrics and health have no authentication wrapper; pprof uses the same enable-file gate as server metrics | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 8086 | InfluxDB HTTP API, UI, and write endpoint (2.x and 1.x); 1.x ships with `auth-enabled = false`, and an un-set-up 2.x instance can be seized through `/api/v2/setup` | 1.x: `auth-enabled = false`; 2.x: first `/api/v2/setup` caller takes over | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8088 | Apache Superset; also the InfluxDB 1.x backup and restore RPC | Admin password created at first run; change it | [bi-dashboards.md](https://github.com/jposluns/secureconfig/blob/main/bi-dashboards.md), [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8090 | PocketBase `serve`, which binds `127.0.0.1:8090` when no domain is given | Installer URL with superuser token printed in startup log | [pocketbase.md](https://github.com/jposluns/secureconfig/blob/main/pocketbase.md) | | 8093 | The GitLab Runner interactive session server, its documented example listen address, which exists only when `[session_server]` is configured | not stated | [self-hosted-ci-runners.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-ci-runners.md) | | 8107 | Typesense peering service, which should be restricted to cluster members | not stated | [search-engines.md](https://github.com/jposluns/secureconfig/blob/main/search-engines.md) | | 8108 | Typesense API, which binds all interfaces by default | Operator bootstrap key required at startup; no keyless mode | [search-engines.md](https://github.com/jposluns/secureconfig/blob/main/search-engines.md) | | 8123 | ClickHouse HTTP, plaintext | ClickHouse base config: `default` user with an empty password, allowed to connect from any address; packaging can change it | [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md) | | 8188 | ComfyUI | No built-in login | [image-gen-uis.md](https://github.com/jposluns/secureconfig/blob/main/image-gen-uis.md) | | 8200 | HashiCorp Vault API, and its UI at `/ui` on the same listener when `ui = true`; TLS is assumed by default, and `sys/health` and `sys/seal-status` answer unauthenticated | Initial root token: unlimited, no expiry; health/seal-status unauthenticated | [vault.md](https://github.com/jposluns/secureconfig/blob/main/vault.md) | | 8201 | HashiCorp Vault cluster port, for server-to-server request forwarding and Raft over mutually authenticated TLS; a peer surface, never a client endpoint | not stated | [vault.md](https://github.com/jposluns/secureconfig/blob/main/vault.md) | | 8222 | NATS monitoring endpoints, off unless configured; the official images' bundled configuration, with their default command, enables them on `0.0.0.0` | No login of its own | [nats.md](https://github.com/jposluns/secureconfig/blob/main/nats.md) | | 8233 | Temporal Web UI as started by `temporal server start-dev`, which is the context this corpus documents | not stated | [workflow-orchestrators.md](https://github.com/jposluns/secureconfig/blob/main/workflow-orchestrators.md) | | 8265 | Ray dashboard | No login unless token auth enabled (off by default) | [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md) | | 8300 | Consul server RPC, on `bind_addr` (every address by default); also Weaviate's Raft, on a non-empty `CLUSTER_BIND_ADDR`, else its advertised address | not stated | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 8301 | Consul Serf gossip (LAN), on `bind_addr` (every address by default); also Weaviate's internal Raft RPC, on the same address as its Raft | Consul: gossip is unencrypted until an `encrypt` key is set; Weaviate: not stated | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 8301/UDP | Consul Serf gossip (LAN), on `bind_addr` (every address by default) | Gossip is unencrypted until an `encrypt` key is set | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8302 | Consul Serf gossip (WAN), on `bind_addr` (every address by default) | Gossip is unencrypted until an `encrypt` key is set | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8302/UDP | Consul Serf gossip (WAN), on `bind_addr` (every address by default) | Gossip is unencrypted until an `encrypt` key is set | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8400, 8401 | VictoriaMetrics cluster native RPC channels, separate from vmstorage's HTTP maintenance and metrics listener | No authentication by default | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8404 | HAProxy HTTPS stats listener in the configured example; a chosen port, and stats are disabled unless configured | Stats disabled unless configured; enabling authenticates nothing on its own | [haproxy.md](https://github.com/jposluns/secureconfig/blob/main/haproxy.md) | | 8428 | VictoriaMetrics single-node HTTP API, UI, and ingest, with `-httpAuth.username`/`-httpAuth.password` Basic auth disabled by default | Basic auth disabled by default | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8432 | Mem0 Compose PostgreSQL publication: host port 8432 maps to container port 5432 | not stated | [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) | | 8443 | ClickHouse HTTPS, the Kubernetes Dashboard forwarding example, and the configured HTTPS listeners in the Gradio, Python, Java and Ruby guides; Keycloak's HTTPS port; also the Supabase self-hosted Kong override's HTTPS | varies by service; see the guides | [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md), [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [gradio.md](https://github.com/jposluns/secureconfig/blob/main/gradio.md), [python.md](https://github.com/jposluns/secureconfig/blob/main/python.md), [java.md](https://github.com/jposluns/secureconfig/blob/main/java.md), [ruby.md](https://github.com/jposluns/secureconfig/blob/main/ruby.md), [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md), [supabase-self-hosted.md](https://github.com/jposluns/secureconfig/blob/main/supabase-self-hosted.md) | | 8480 | VictoriaMetrics cluster `vminsert` HTTP ingestion, Basic auth disabled by default | Basic auth disabled by default | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8481 | VictoriaMetrics cluster `vmselect` HTTP query and UI, Basic auth disabled by default | Basic auth disabled by default | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8482 | VictoriaMetrics cluster `vmstorage` HTTP maintenance and metrics (native RPC on 8400 and 8401) | Basic auth disabled by default | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8500 | Consul's HTTP API and web UI (UI off by default), on `client_addr`, 127.0.0.1 by default | No authentication by default (ACLs off); with ACLs on, `default_policy` stays `allow` | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8501 | Streamlit | No access control unless you add it | [streamlit.md](https://github.com/jposluns/secureconfig/blob/main/streamlit.md) | | 8503 | Consul servers' gRPC with TLS, on `client_addr` | not stated | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8600 | Consul's DNS interface, on `client_addr` | not stated | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8600/UDP | Consul's DNS interface, on `client_addr` | not stated | [nomad-consul.md](https://github.com/jposluns/secureconfig/blob/main/nomad-consul.md) | | 8812 | QuestDB PostgreSQL wire protocol, shipping the default credential `admin` / `quest` | `admin` / `quest` | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 8883 | MQTT over TLS | Mosquitto 2.0+: explicit listener rejects unauthenticated clients | [mosquitto.md](https://github.com/jposluns/secureconfig/blob/main/mosquitto.md) | | 8888 | Jupyter, including RunPod deployments. The Vast.ai Jupyter launch mode uses 8080 instead; also Jaeger v2's self-metrics, on localhost in its all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface); also SearxNG, the Mem0 API's host publication, and the OpenTelemetry Collector's internal metrics | Jupyter: auth on by default via generated token; Jaeger: not stated | [jupyter.md](https://github.com/jposluns/secureconfig/blob/main/jupyter.md), [gpu-clouds.md](https://github.com/jposluns/secureconfig/blob/main/gpu-clouds.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 8998 | vLLM Mooncake producer bootstrap server | No authentication on this listener | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 9000 | ClickHouse native TCP (plaintext), MinIO's S3 API, PHP-FPM, TGI's Prometheus listener, or Portainer's legacy HTTP port. Port 9000 may be Keycloak's management port serving `/health` and `/metrics`, authentik's HTTP port, QuestDB's web console, REST, and SQL endpoints whose Basic-auth keys are unset by default, or the Sentry self-hosted web and API, published here by the bundled nginx; also the Onyx development Compose's inference server, and a Helicone Compose backing-service host publication | varies by service; see the guides | [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md), [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md), [php.md](https://github.com/jposluns/secureconfig/blob/main/php.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md), [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md), [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md), [self-hosted-error-trackers.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-error-trackers.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md), [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 9001 | Argo CD notifications HTTP metrics, wildcard bind | No authentication | [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 9001 | MinIO's web console (the `--console-address` / `MINIO_CONSOLE_ADDRESS` port, conventionally 9001); also Mosquitto WebSockets, and a Helicone Compose backing-service host publication | MinIO: `minioadmin`/`minioadmin` when root variables unset | [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md), [mosquitto.md](https://github.com/jposluns/secureconfig/blob/main/mosquitto.md), [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 9003 | QuestDB minimal HTTP health and metrics server, which follows the HTTP authentication policy you set (unauthenticated when `http.user` is unset) | Unauthenticated while `http.user` is unset | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md) | | 9004, 9005, 9010 | ClickHouse MySQL compatibility, PostgreSQL compatibility, and interserver replica traffic over HTTPS (the HTTP interserver port 9009 is listed separately); also the Onyx development Compose's MinIO publications on 9004 and 9005 | ClickHouse SQL (9004, 9005): base config `default` user with an empty password, allowed to connect from any address; ClickHouse replication (9010): unauthenticated unless `interserver_http_credentials` is set, and independent of SQL users; packaging can change these | [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) | | 9009 | QuestDB InfluxDB line protocol (TCP), which accepts unauthenticated writes unless `line.tcp.auth.db.path` is set; also ClickHouse interserver replica traffic over HTTP | varies by service; see the guides | [time-series-metrics-stores.md](https://github.com/jposluns/secureconfig/blob/main/time-series-metrics-stores.md), [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md) | | 9090 | InvokeAI; the Prometheus server, which listens on `0.0.0.0:9090` by default with no authentication; and Cockpit, whose socket listens on every address; also Langfuse's bundled MinIO S3 API and Litestream's metrics listener | No authentication by default (Prometheus; InvokeAI single-user mode); Cockpit takes any local account, and refuses root only through `disallowed-users` | [image-gen-uis.md](https://github.com/jposluns/secureconfig/blob/main/image-gen-uis.md), [admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md), [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md), [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md), [sqlite.md](https://github.com/jposluns/secureconfig/blob/main/sqlite.md) | | 9091 | Milvus WebUI, Authelia, or Prometheus Pushgateway, whose default `:9091` binds every interface | varies by service; see the guides | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md), [fronting-auth.md](https://github.com/jposluns/secureconfig/blob/main/fronting-auth.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9092, 9093 | Kafka plaintext and SASL_SSL listeners. Which port carries which is configured, not fixed. Alertmanager's UI and API also default to `:9093`, every interface | Kafka broker default PLAINTEXT on 9092: no credential, no authorizer; Alertmanager: no authentication by default | [kafka.md](https://github.com/jposluns/secureconfig/blob/main/kafka.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9094 | Kafka KRaft controller listener in the guide's configured SSL example, where peers authenticate by certificate; the port is chosen, not fixed; also Alertmanager HA gossip, on by default at `0.0.0.0:9094`, TCP and UDP | Kafka: not stated; Alertmanager gossip: plaintext unless `--cluster.tls-config` | [kafka.md](https://github.com/jposluns/secureconfig/blob/main/kafka.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9095 | Kafka's `INTERNAL` listener in the guide's example configuration, on a private address; also Grafana Loki's gRPC server, on every interface by default | Kafka: not stated; Loki: no built-in authentication | [kafka.md](https://github.com/jposluns/secureconfig/blob/main/kafka.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9096 | Grafana Loki gRPC in the vendor's sample `loki-local-config.yaml`; the binary default is 9095 | No built-in authentication | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9100 | Prometheus node_exporter, whose default `:9100` binds every interface | No authentication by default | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9200 to 9300 | Elasticsearch HTTP default range, binding the first free port; OpenSearch HTTP on 9200, including the Onyx development Compose's OpenSearch | varies by service; see the guides | [elasticsearch.md](https://github.com/jposluns/secureconfig/blob/main/elasticsearch.md), [ai-infra-services.md](https://github.com/jposluns/secureconfig/blob/main/ai-infra-services.md) | | 9222 | Chrome or Chromium DevTools Protocol remote debugging, an endpoint with no authentication whose reachability is full browser takeover | No authentication of any kind | [headless-browser-services.md](https://github.com/jposluns/secureconfig/blob/main/headless-browser-services.md) | | 9252 | The GitLab Runner Prometheus metrics endpoint, served with no built-in authorization, which exists only when a metrics `listen_address` is configured | No built-in authorization | [self-hosted-ci-runners.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-ci-runners.md) | | 9256 | vLLM optional multi-port DP health supervisor | No authentication on this listener | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 9292 | Puma standalone, whose default bind is all interfaces; and the Flux notification-controller webhook receiver, in-cluster behind the `webhook-receiver` Service | varies by service; see the guides | [ruby.md](https://github.com/jposluns/secureconfig/blob/main/ruby.md), [gitops-controllers.md](https://github.com/jposluns/secureconfig/blob/main/gitops-controllers.md) | | 9300 to 9399 | Elasticsearch transport default range (the official images set `network.host: 0.0.0.0`, so unless that is overridden transport listens on all interfaces); authentik's unauthenticated Prometheus metrics on 9300 | varies by service; see the guides | [elasticsearch.md](https://github.com/jposluns/secureconfig/blob/main/elasticsearch.md), [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md) | | 9411 | Jaeger v2 Zipkin receiver, on localhost in the all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 9440 | ClickHouse native TCP over TLS | Base config: `default` user with an empty password, allowed to connect from any address; packaging can change it | [clickhouse.md](https://github.com/jposluns/secureconfig/blob/main/clickhouse.md) | | 9443 | Portainer HTTPS UI, authentik's HTTPS port | varies by service; see the guides | [devops-uis.md](https://github.com/jposluns/secureconfig/blob/main/devops-uis.md), [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md) | | 9641 | coturn Prometheus metrics, disabled by default; when enabled, it binds a wildcard address and serves without authentication | Off by default; when enabled, no authentication | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 9883 | Mosquitto 2.1's HTTP API and web dashboard (`protocol http_api`), which the official 2.1 images enable on every address (it is not in the image's `EXPOSE`, so `-P` does not publish it) | The official 2.1 images allow it without credentials (`allow_anonymous true`, no password file or ACL); the 2.1.2 manual recommends binding it to loopback behind an authenticating reverse proxy | [mosquitto.md](https://github.com/jposluns/secureconfig/blob/main/mosquitto.md) | | 9898 | Pgpool-II's PCP administration channel, which has its own credential file | Its own credential file, `pcp.conf`, separate from database credentials | [connection-poolers.md](https://github.com/jposluns/secureconfig/blob/main/connection-poolers.md) | | 9999 | Pgpool-II | `enable_pool_hba` off by default: Pgpool authenticates nobody itself | [connection-poolers.md](https://github.com/jposluns/secureconfig/blob/main/connection-poolers.md), [postgresql.md](https://github.com/jposluns/secureconfig/blob/main/postgresql.md) | | 10000 | Budibase's nginx proxy (`MAIN_PORT` in the sample `.env`), which forwards `/db/` to CouchDB; and Webmin's miniserv (HTTPS), on every address by default; and a Render web service's default HTTP listener port, configurable through `PORT`, which must bind `0.0.0.0` and is reached through Render's load balancer rather than directly from the public internet | Sample `.env` sets `JWT_SECRET` to `testsecret` and CouchDB `budibase`/`budibase`; admin init is public and refuses once its helper finds a user in the request's tenant; which users count and any multi-tenancy exception are not established. Webmin: the host's root password logs in with every module. Render: not stated | [low-code-builders.md](https://github.com/jposluns/secureconfig/blob/main/low-code-builders.md), [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md), [paas.md](https://github.com/jposluns/secureconfig/blob/main/paas.md), [nodejs.md](https://github.com/jposluns/secureconfig/blob/main/nodejs.md), [python.md](https://github.com/jposluns/secureconfig/blob/main/python.md) | | 10000/UDP | Webmin's server discovery socket (`listen=10000`), on every IPv4 address | not stated; the guide says it lets other Webmin servers find this one | [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md) | | 10001 | Ray Client server, which executes code | Unauthenticated code execution unless token auth enabled (off by default) | [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md) | | 10002 to 19999 | Ray worker ports, allocated across this whole range by default, plus several randomized ports. Anything in this range on a Ray node may be a worker rather than the service the row below suggests | Ray token authentication (2.52.0 and later) is disabled by default as of 2.58.0 | [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md) | | 10250 | The Kubernetes kubelet API, which runs commands in containers. It also falls inside the Ray worker range above | Legacy flag defaults: anonymous auth on and AlwaysAllow; v1beta1 file defaults, which kubeadm writes: anonymous off and Webhook. Explicit flags and `--config-dir` drop-ins override the file; check the node's actual configuration | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | | 10255 | The Kubernetes kubelet read-only port, which serves with no authentication or authorization | No authentication or authorization; on by flag default, off by file default; explicit flags and drop-ins override the file | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | | 10256 | Kubernetes kube-proxy health, on worker nodes | not stated | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | | 10257, 10259 | The Kubernetes controller manager and scheduler, on control-plane nodes | not stated | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md) | | 11211 | Memcached. Check UDP as well as TCP | No authentication by default | [memcached.md](https://github.com/jposluns/secureconfig/blob/main/memcached.md) | | 11434 | Ollama | No native inbound authentication | [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) | | 11435 | The Ollama guide's local policy proxy, an nginx listener on `127.0.0.1:11435` for the inference-only deployment; also the Ollama desktop app's Claude Desktop gateway, a loopback-only listener the macOS app starts on `127.0.0.1:11435` at startup when Claude Desktop is installed and configured to use it, and from its Claude Desktop connect and model-mapping actions once installed. The guide infers, without observing it, that on one Mac both would try to bind the port | not stated | [ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md) | | 13133 | OpenTelemetry Collector health-check extension, when enabled; also Jaeger v2's health check, on localhost in its all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | Collector ships with no security until configured; Jaeger: not stated | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md), [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 14250, 14268 | Jaeger v2 gRPC and Thrift HTTP receivers, on localhost in the all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 15671 | RabbitMQ management UI over TLS, as noted in the guide's listener inventory | `guest`/`guest`, usable only from localhost, except in the official Docker image as shipped, which sets `loopback_users.guest = false` | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | | 15672 | RabbitMQ management UI | `guest`/`guest`, usable only from localhost, except in the official Docker image as shipped, which sets `loopback_users.guest = false` | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | | 16379 | Redis Cluster's node-to-node bus (`cluster-port 16379` in the guide's example), which is opt-in and needs its own restriction | No AUTH gate; data-port password does not apply | [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md) | | 16685, 16686 | Jaeger v2 query gRPC and the UI and query API (with MCP at `/api/ai/mcp/` in the all-in-one configuration), on every interface by default | No authentication by default | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 18123 | Helicone Compose ClickHouse HTTP publication: host port 18123 maps to container port 8123; it also falls inside the Ray worker range | Published independently of the dashboard login; the guide says to rotate the Compose example storage credentials | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 19000 | Helicone Compose backing-service publication: host port 19000 maps to container port 9000, but the guide does not explicitly attribute this mapping to a service; it also falls inside the Ray worker range | Published independently of the dashboard login | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 19529 | Milvus proxy internal gRPC (v3.0.2 and v2.6.24), on every interface | No credentials checked; `authorizationEnabled` protects the proxy's external client APIs, not this port; internal TLS is one-way, off by default, and falls back to plaintext if its certificate or key does not load | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 19530 | Milvus gRPC | Authentication must be enabled with `common.security.authorizationEnabled`; once on, built-in `root`/`Milvus` | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 20000 | Usermin, Webmin's panel for ordinary users, on every address by default | not stated | [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md) | | 20000/UDP | Usermin's server discovery socket (`listen=20000`), on every IPv4 address | not stated; the guide says it lets other Webmin servers find this one | [server-admin-panels.md](https://github.com/jposluns/secureconfig/blob/main/server-admin-panels.md) | | 20202 | LiteFS HTTP replication API, including database export/import and administrative operations | not stated | [sqlite.md](https://github.com/jposluns/secureconfig/blob/main/sqlite.md) | | 21123, 21124, 22125, 22222 | Milvus internal gRPC: query node, data node, mix coordinator and streaming node (v3.0.2 and v2.6.24), each on every interface (the query node, data node and streaming node fall back to a random port, still on every interface, when theirs is taken) | No credentials checked; internal TLS is one-way, off by default, and falls back to plaintext if its certificate or key does not load | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 25672 | RabbitMQ inter-node and CLI Erlang distribution (by default `RABBITMQ_NODE_PORT` plus 20000; a non-empty `RABBITMQ_DIST_PORT` or `DIST_PORT`, or `distribution.listener.port_range`, can replace it); by default the Erlang cookie is its only credential and grants full control of the node | The Erlang cookie, a shared secret; cookie plus reachability can give full broker control | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | | 26379 | Redis Sentinel's separate listener; its shipped configuration disables protected mode and supplies no active authentication rule | Shipped config: protected mode off, no active authentication rule | [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md) | | 27017 | MongoDB; the official Linux images (7.0, 8.0 and 8.3 checked), run as `mongod` with no bind setting, add `--bind_ip_all`, and the Windows images' command includes it (every IPv4 address; IPv6 too only when `net.ipv6` is true) | Authorization must be enabled (`authorization: enabled`); on an installation with no existing users or roles, the localhost exception lets a local client create the first administrator. The official Linux images leave authorization off unless both `MONGO_INITDB_ROOT_*` credentials are set or the arguments or a configuration file (`--config` or `-f`) enable it (one credential alone makes the entrypoint exit), and the Windows images leave it off unless configured | [mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md), [cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md) | | 27777, 27778 | Jaeger v2 expvar and zpages, on localhost in the all-in-one configuration unless `JAEGER_LISTEN_HOST` is set (the official image sets `0.0.0.0`, every interface) | not stated | [observability-components.md](https://github.com/jposluns/secureconfig/blob/main/observability-components.md) | | 29501 | vLLM multi-node multiprocessing rendezvous | No authentication on this listener | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 29550 | vLLM remote data-parallel startup handshake | No authentication on this listener | [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 30000 to 32767 | Kubernetes NodePort range over TCP and UDP; SGLang on 30000 | varies by service; see the guides | [kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md), [model-servers.md](https://github.com/jposluns/secureconfig/blob/main/model-servers.md) | | 33060 | MySQL X Protocol, a separate listener whose bind is not controlled by bind_address; MariaDB does not implement it; the official MySQL images leave it on every address unless configured | not stated | [mysql.md](https://github.com/jposluns/secureconfig/blob/main/mysql.md) | | 35672 to 35682 | RabbitMQ remote CLI tools' own Erlang distribution-port range (default); the guide says to restrict the actual range to the necessary peers | The Erlang cookie, a shared secret granting node and CLI access | [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md) | | 49152 to 65535 | coturn relay endpoints, allocated on demand; this range overlaps the LiveKit media range and other entries | not stated | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 50000 to 60000/UDP | LiveKit media sockets outside development mode, allocated during active calls | not stated | [realtime-voice-infra.md](https://github.com/jposluns/secureconfig/blob/main/realtime-voice-infra.md) | | 50051 | Weaviate gRPC, on every local address whatever `--host` says | Anonymous access enabled by default | [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md) | | 51820/UDP | WireGuard, in the configured example here. The port is chosen, not assigned | No username or password; key pairs only | [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md) | | 52365 | Ray dashboard agent HTTP default on every non-minimal head and worker, including local ray.init() clusters; Jobs agent API executes code, and /logs serves the node log directory | Token authentication off by default; browser-request filtering is not authentication | [ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md) | | 54388 | Helicone Compose PostgreSQL publication: host port 54388 maps to container port 5432 | Published independently of the dashboard login; the guide says to rotate the Compose example storage credentials | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 55679 | OpenTelemetry Collector zPages diagnostic extension, when enabled | Collector ships with no security until configured | [llm-observability.md](https://github.com/jposluns/secureconfig/blob/main/llm-observability.md) | | 57800 | Keycloak clustered cache failure detection; TLS is on by default for TCP stacks | not stated | [self-hosted-idp.md](https://github.com/jposluns/secureconfig/blob/main/self-hosted-idp.md) | ## Verify REASONED: inventory completeness only; no deployment interface, container-namespace or routing inventory was available for reconciliation. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. An exposed route may be absent from the host listing, and private binding alone cannot establish complete coverage, per the guide's cited `ss` manual and Docker port publishing. **This is a baseline inventory, not a security check, and not a proof of completeness.** It gives you a list of the listeners and routes it discovered. It cannot establish that the list is complete: a container on a bridge network publishes no host port and holds its sockets in another network namespace, and a container on routed IPv6 accepts traffic on its own address whatever the host publishes. Reconcile what you find here against your interface addresses, container addresses and namespaces, and your routing and publishing configuration before believing the inventory is whole. REASONED: limits of socket inventory; no application endpoints or authorized and unauthorized identity fixtures were available. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. Exposed and fixed authentication can produce the same socket listing, so the service guide must test access separately, per the guide's cited `ss` manual. It establishes nothing at all about whether those services are safe to expose, and no checklist on an index page can. That claim belongs to each service's own guide, and even there the Verify blocks are worked examples over sampled URLs, not an enumeration of your application's sensitive routes. **REASONED:** following block; not demonstrated. Historical authoring note: this page previously recorded no run. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. The block rests on the guide's cited `ss` manual and Docker `container ls`, and on this index's consistency with the guides it cites; it is not an observation. A discriminating run needs a service bound on every interface (the exposed state, a `0.0.0.0` or `[::]` local address) and the same service bound to loopback (the fixed state, `127.0.0.1` or `::1`). A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. This declaration covers the entire TCP/UDP and Docker inventory block, plus repeating UDP inventory during activity. Expect wildcard addresses when exposed and loopback addresses when fixed; dynamic media or relay sockets may be absent while idle, per the guide's cited `ss` manual, Docker `container ls`, and Sources entry "Defaults, configured examples, published host-port mappings, and ranges above are documented in the linked guides, which cite their vendor sources" (realtime-voice-infra.md). Repeat the [all-UDP-sockets check](https://man7.org/linux/man-pages/man8/ss.8.html) during active calls or relay allocations, because media and relay sockets may be absent while idle. For namespace and error interpretation, no deployment container-namespace inventory was available. Host output can omit an exposed container socket, and empty or failed output cannot establish the fixed state, per the guide's cited `ss` manual. `ss` reports sockets in its own network namespace, so a container's listeners are not all visible from the host. A diagnostic such as `Cannot open netlink socket` means the command failed: empty output with a zero exit status is not a pass. Where sudoers sets `secure_path`, sudo looks `ss` up in that path instead of your PATH, so the `command -v ss` check can refuse although sudo would find `ss`, or pass and then end with status 3 when sudo cannot run it. That caveat rests on the guide's cited sudo sudoers manual, secure_path entry. ```bash ( # one subshell, so one exit status covers every command: 3 if any of them fails, otherwise 2 if ss is not installed, otherwise 0 set +e # errexit off here, so a command that fails is recorded below instead of ending the block if command -v ss > /dev/null; then set -- 0 "$(if sudo ss -tlnp 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # listening TCP sockets, with the owning process set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" # the block's status so far, ss's exit status, then its output printf '%s' "$3" case "$2:$3" in 0:*'Cannot open netlink socket'*|0:) echo "ss -tlnp printed a netlink diagnostic or nothing, so the command failed; inconclusive"; set -- 3 ;; 0:*) ;; *) echo "ss -tlnp exit $2: the command failed; inconclusive"; set -- 3 ;; esac set -- "$1" "$(if sudo ss -tlunp 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # again including UDP, which Memcached and WireGuard answer on set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" printf '%s' "$3" case "$2:$3" in 0:*'Cannot open netlink socket'*|0:) echo "ss -tlunp printed a netlink diagnostic or nothing, so the command failed; inconclusive"; set -- 3 ;; 0:*) ;; *) echo "ss -tlunp exit $2: the command failed; inconclusive"; set -- 3 ;; esac set -- "$1" "$(if sudo ss -aunp 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # all UDP sockets, including non-listening sockets set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" printf '%s' "$3" case "$2:$3" in 0:*'Cannot open netlink socket'*|0:) echo "ss -aunp printed a netlink diagnostic or nothing, so the command failed; inconclusive"; set -- 3 ;; 0:*) ;; *) echo "ss -aunp exit $2: the command failed; inconclusive"; set -- 3 ;; esac else echo "ss is not installed here; not listing sockets"; set -- 2 # the block's status so far: 2 (ss is not installed); a failed docker ps below still makes it 3 fi if command -v docker > /dev/null; then set -- "$1" "$(if docker ps --format '{{.Names}}\t{{.Ports}}' 2>&1; then printf '\n0'; else printf '\n%s' "$?"; fi)" # published container ports, which the host view can miss set -- "$1" "${2##*$'\n'}" "${2%$'\n'*}" printf '%s' "$3" [ "$2" = 0 ] || { echo "docker ps exit $2: the command failed; inconclusive"; set -- 3; } else echo "no docker command here, so no Docker-published container ports were listed; list any other container runtime's published ports yourself" fi echo "block exit $1; every socket and container row above is yours to read against the four conditions below, and the list is not proof of completeness" exit "$1" ) ``` REASONED: following block; authorized target and vantage selection; no second machine on a different network or deployment address inventory was available. Locally, an earlier form of the guarded block, whose exit status was the last scan's, passed bash -n and ShellCheck 0.11.0 silently (exit 0). With sudo and nmap stubbed, ordinary Bash and set -u each rejected unchanged placeholders, either empty value, one or three values, ``, example.com, 192.0.2.256, 192.0.2, host.internal, 2001:db8::g, 2001:db8, 2001:db8:1, 2001:db8:::1, 2001:db8::12345, 1::2::3, ::: and an omitted set -- line with a not scanning message (exit 2), without calling nmap. Each mode also rejected 2001:db8::1: and :1:2:3:4:5:6:7 with `give a literal IPv6 address without a single leading or trailing colon; not scanning` (exit 2), without calling nmap, for 20 refusals and 24 total cases per mode. With 192.0.2.1 and 2001:db8::1, each mode printed `nmap <-Pn> <-p-> <192.0.2.1>`, `nmap <-Pn> <-6> <-p-> <2001:db8::1>`, `nmap <-Pn> <-sU> <--top-ports> <100> <192.0.2.1>` and `nmap <-Pn> <-6> <-sU> <--top-ports> <100> <2001:db8::1>`, one line each in order (exit 0). Each mode also accepted 192.0.2.1 paired separately with 2001:db8:0:0:0:0:0:1, ::1 and fe80::1:2:3:4:5:6, printing the same four lines with that IPv6 address in both IPv6 arguments (exit 0). These are local guard and argument observations on that earlier form, not a live scan. The current form of the guarded block passed bash -n and ShellCheck 0.11.0 in the repository's gate suite; no guard-paste or stub run of it is recorded here. A public listener should report open and a privately bound port closed or filtered from that second network, but a wrong substituted target makes either result inconclusive, per the guide's cited Nmap legal issues and Nmap target specification. From a second machine on a different network, against a host **you own or are authorized to test**. Run these against every public address the host answers on, not just one; a host with several interfaces or a NAT address beside an elastic address has several inbound paths. Substitute a literal address inside the single quotes. The guarded block refuses its placeholders before Nmap runs. The IPv6 guard rejects malformed hex-and-colon syntax, but still accepts a syntactically valid address for the wrong host. Check the target Nmap prints before you read the result. **REASONED:** following block; not demonstrated. Historical authoring note: this page previously recorded no run. Locally, an earlier form of the guarded block, whose exit status was the last scan's, passed bash -n and ShellCheck 0.11.0 silently (exit 0). With sudo and nmap stubbed, ordinary Bash and set -u each rejected unchanged placeholders, either empty value, one or three values, ``, example.com, 192.0.2.256, 192.0.2, host.internal, 2001:db8::g, 2001:db8, 2001:db8:1, 2001:db8:::1, 2001:db8::12345, 1::2::3, ::: and an omitted set -- line with a not scanning message (exit 2), without calling nmap. Each mode also rejected 2001:db8::1: and :1:2:3:4:5:6:7 with `give a literal IPv6 address without a single leading or trailing colon; not scanning` (exit 2), without calling nmap, for 20 refusals and 24 total cases per mode. With 192.0.2.1 and 2001:db8::1, each mode printed `nmap <-Pn> <-p-> <192.0.2.1>`, `nmap <-Pn> <-6> <-p-> <2001:db8::1>`, `nmap <-Pn> <-sU> <--top-ports> <100> <192.0.2.1>` and `nmap <-Pn> <-6> <-sU> <--top-ports> <100> <2001:db8::1>`, one line each in order (exit 0). Each mode also accepted 192.0.2.1 paired separately with 2001:db8:0:0:0:0:0:1, ::1 and fe80::1:2:3:4:5:6, printing the same four lines with that IPv6 address in both IPv6 arguments (exit 0). These are local guard and argument observations on that earlier form, not a live scan. The current form of the guarded block passed bash -n and ShellCheck 0.11.0 in the repository's gate suite; no guard-paste or stub run of it is recorded here. The block rests on the guide's cited Nmap port specification, Nmap host discovery, Nmap target specification, Nmap scan techniques and Nmap miscellaneous options, and on this index's consistency with the guides it cites; it is not an observation. A discriminating run needs a publicly bound listener that the scan reports `open` (exposed) and the same port reported `closed` or `filtered` once it is bound privately (fixed), probed from a second network. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available; no second network was available. ```bash ( # Substitute both addresses inside the single quotes; the guard rejects the placeholders before Nmap runs. set +e # errexit off here, so a scan that fails is recorded below instead of ending the block set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_A_LITERAL_IPV4_ADDRESS' 'REPLACE_WITH_A_LITERAL_IPV6_ADDRESS' [ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not scanning'; exit 2; } shift [ "$#" -eq 2 ] || { echo 'the set -- line needs exactly 2 values; not scanning'; exit 2; } if [ -z "$1" ] || [ -z "$2" ]; then echo 'substitute both addresses on the set -- line above; not scanning'; exit 2; fi case "$1:$2" in *REPLACE_WITH_*|*'<'*|*'>'*|*example*) echo 'substitute both addresses on the set -- line above; not scanning'; exit 2 ;; esac [[ $1 =~ ^((25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])[.]){3}(25[012345]|2[01234][0123456789]|1[0123456789][0123456789]|[123456789]?[0123456789])$ ]] || { echo 'give a literal IPv4 address with four octets from 0 to 255; not scanning'; exit 2; } case "$2" in *[!0123456789abcdefABCDEF:]* ) echo 'give a literal IPv6 address using only hex digits and colons; not scanning'; exit 2 ;; *:::*|*::*::*) echo 'give a literal IPv6 address with at most one double colon and no triple colon; not scanning'; exit 2 ;; :[!:]*|*[!:]:) echo 'give a literal IPv6 address without a single leading or trailing colon; not scanning'; exit 2 ;; esac [[ ! $2 =~ [0123456789abcdefABCDEF]{5} ]] || { echo 'give a literal IPv6 address with at most four hex digits per group; not scanning'; exit 2; } # Count colons after removing hex digits; compression must replace at least one group. set -- "$1" "$2" "${2//[0123456789abcdefABCDEF]/}" case "$2" in ::*|*::) [ "${#3}" -le 8 ] ;; *::*) [ "${#3}" -le 7 ] ;; *) [ "${#3}" -eq 7 ] ;; esac || { echo 'give a literal IPv6 address with eight groups, or fewer groups and one double colon; not scanning'; exit 2; } set -- "$1" "$2" 0 # the 3rd value is the block's status so far: 3 once a scan fails, otherwise 0 sudo nmap -Pn -p- "$1" || # TCP over IPv4 { echo "sudo nmap exit $?: the IPv4 TCP scan failed; inconclusive"; set -- "$1" "$2" 3; } sudo nmap -Pn -6 -p- "$2" || # TCP over IPv6, which the line above never covers { echo "sudo nmap exit $?: the IPv6 TCP scan failed; inconclusive"; set -- "$1" "$2" 3; } sudo nmap -Pn -sU --top-ports 100 "$1" || # UDP, preliminary only { echo "sudo nmap exit $?: the IPv4 UDP scan failed; inconclusive"; set -- "$1" "$2" 3; } sudo nmap -Pn -6 -sU --top-ports 100 "$2" || # UDP over IPv6 { echo "sudo nmap exit $?: the IPv6 UDP scan failed; inconclusive"; set -- "$1" "$2" 3; } echo "block exit $3; open, closed and filtered ports are not classified, so read each scan's report as the text after this block describes" exit "$3" ) ``` REASONED: preceding block; all four IPv4/IPv6 TCP/UDP scan commands and interpretation of the top-100 UDP sample. Locally, an earlier form of the guarded block, whose exit status was the last scan's, passed bash -n and ShellCheck 0.11.0 silently (exit 0). With sudo and nmap stubbed, ordinary Bash and set -u each rejected unchanged placeholders, either empty value, one or three values, ``, example.com, 192.0.2.256, 192.0.2, host.internal, 2001:db8::g, 2001:db8, 2001:db8:1, 2001:db8:::1, 2001:db8::12345, 1::2::3, ::: and an omitted set -- line with a not scanning message (exit 2), without calling nmap. Each mode also rejected 2001:db8::1: and :1:2:3:4:5:6:7 with `give a literal IPv6 address without a single leading or trailing colon; not scanning` (exit 2), without calling nmap, for 20 refusals and 24 total cases per mode. With 192.0.2.1 and 2001:db8::1, each mode printed `nmap <-Pn> <-p-> <192.0.2.1>`, `nmap <-Pn> <-6> <-p-> <2001:db8::1>`, `nmap <-Pn> <-sU> <--top-ports> <100> <192.0.2.1>` and `nmap <-Pn> <-6> <-sU> <--top-ports> <100> <2001:db8::1>`, one line each in order (exit 0). Each mode also accepted 192.0.2.1 paired separately with 2001:db8:0:0:0:0:0:1, ::1 and fe80::1:2:3:4:5:6, printing the same four lines with that IPv6 address in both IPv6 arguments (exit 0). These are local guard and argument observations on that earlier form, not a live scan. The current form of the guarded block passed bash -n and ShellCheck 0.11.0 in the repository's gate suite; no guard-paste or stub run of it is recorded here. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. No second network was available. Expect open for the exposed port and closed or filtered for a privately bound port from that network; UDP open|filtered remains ambiguous, per the guide's cited Nmap port specification, Nmap host discovery, Nmap target specification, Nmap scan techniques and Nmap miscellaneous options. UDP scanning needs privilege and returns `open|filtered` when it cannot distinguish the two, so a clean-looking UDP result is weaker evidence than a clean TCP one. Treat the top-100 scan as a first pass and probe the UDP ports your own inventory names. REASONED: scope of the four inventory conditions; no deployment process, address, route or service-test inventory was available. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. The exposed state has unexplained listeners or routes; the inventory-fixed state accounts for them without establishing application security, per the guide's cited `ss` manual and Nmap target specification. Four things to establish, and none of them is "the service is secure": 1. REASONED: attribution; no deployment process inventory or kernel WireGuard interface was available. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. An exposed inventory contains unattributed sockets; a reconciled one assigns each to a process or kernel service without requiring a process name for WireGuard. That exposed and reconciled inventory comparison is per the guide's cited `ss` manual, IANA Service Name and Transport Protocol Port Number Registry, and Sources entry "Defaults, configured examples, published host-port mappings, and ranges above are documented in the linked guides, which cite their vendor sources" (tunnels.md). **Every listening socket is attributed**, to a named owning process or to an identified kernel service or interface. Kernel WireGuard is the case that breaks process attribution: it owns its UDP socket in the kernel, so `ss -p` names no process and `sudo` does not create one. A port appearing in the table above is a hint about which guides to read, not an identification of what is running. 2. REASONED: bind-address review; no deployment bind-address inventory was available. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Expect wildcard addresses in the exposed fixture and loopback addresses in the fixed fixture; deliberate private or tailnet bindings still need deployment context, per the guide's cited `ss` manual. **Every socket's bind address is deliberate.** `127.0.0.1`, `::1`, a private address or a tailnet address, unless you can say why it is public. 3. REASONED: route reconciliation; no deployment hostname, container-address, tunnel or proxy-route inventory was available. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, an earlier form of the guarded block, whose exit status was the last scan's, passed bash -n and ShellCheck 0.11.0 silently (exit 0). With sudo and nmap stubbed, ordinary Bash and set -u each rejected unchanged placeholders, either empty value, one or three values, ``, example.com, 192.0.2.256, 192.0.2, host.internal, 2001:db8::g, 2001:db8, 2001:db8:1, 2001:db8:::1, 2001:db8::12345, 1::2::3, ::: and an omitted set -- line with a not scanning message (exit 2), without calling nmap. Each mode also rejected 2001:db8::1: and :1:2:3:4:5:6:7 with `give a literal IPv6 address without a single leading or trailing colon; not scanning` (exit 2), without calling nmap, for 20 refusals and 24 total cases per mode. With 192.0.2.1 and 2001:db8::1, each mode printed `nmap <-Pn> <-p-> <192.0.2.1>`, `nmap <-Pn> <-6> <-p-> <2001:db8::1>`, `nmap <-Pn> <-sU> <--top-ports> <100> <192.0.2.1>` and `nmap <-Pn> <-6> <-sU> <--top-ports> <100> <2001:db8::1>`, one line each in order (exit 0). Each mode also accepted 192.0.2.1 paired separately with 2001:db8:0:0:0:0:0:1, ::1 and fe80::1:2:3:4:5:6, printing the same four lines with that IPv6 address in both IPv6 arguments (exit 0). These are local guard and argument observations on that earlier form, not a live scan. The current form of the guarded block passed bash -n and ShellCheck 0.11.0 in the repository's gate suite; no guard-paste or stub run of it is recorded here. An exposed route may remain reachable through a tunnel while a host scan shows no inbound port; the inventory-fixed state lists each route separately. That exposed and inventory-fixed route comparison is per the guide's cited Nmap target specification and Sources entry "Defaults, configured examples, published host-port mappings, and ranges above are documented in the linked guides, which cite their vendor sources" (tunnels.md, cloudflare.md and tailscale.md). **Every inbound route is inventoried, not just the ones a port scan finds.** A scan of your public address says nothing about an outbound tunnel. An application on loopback reached through [tunnels.md](https://github.com/jposluns/secureconfig/blob/main/tunnels.md), [cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md) or [tailscale.md](https://github.com/jposluns/secureconfig/blob/main/tailscale.md) is exposed at that hostname while your host shows no open inbound port at all. List every hostname, every literal address the host answers on including container addresses, every tunnel, proxy and alternate virtual host, and every URL path a proxy routes to a different backend. Each is its own route, and a scan by hostname does not cover a request made to a bare address. 4. REASONED: service-guide coverage by route; no deployment route inventory or completed service-guide results were available. A live TCP/UDP test service stand-up opens a listener; the authoring host forbids that without an isolated network namespace, and none was available. Locally, an earlier form of the inventory block, run as four top-level commands, passed bash -n and ShellCheck 0.11.0 with no output; stubs printed sudo with ss -tlnp, -tlunp and -aunp, and docker ps with its literal format argument. These are argument observations on that earlier form, not socket observations. A later form of the inventory block, without its check for a missing `ss`, passed bash -n and ShellCheck 0.11.0 in the repository's gate suite. The current form, which exits 2 when `ss` is not installed unless `docker ps` fails, is covered by the gate suite's bash -n and ShellCheck 0.11.0 passes and by local stub runs with `ss` passing, failing and missing, which exited 0, 3 and 2. Exposed and fixed application authentication can leave the socket listing unchanged, so each route needs its service checks, per the guide's cited `ss` manual and Sources entry "Defaults, configured examples, published host-port mappings, and ranges above are documented in the linked guides, which cite their vendor sources". **Each reachable service has been through its own guide**, by every route from condition 3, not only the ones the scan surfaced. Those guides are where the authentication and TLS checks live. Then keep going: enumerate your own sensitive routes and request each one anonymously and as an unauthorized user. A deployment can satisfy all four conditions above and still serve private data from an endpoint no guide here knows the name of. ## Sources (checked September 2026) - `ss` manual, including that it reports sockets within a network namespace: https://man7.org/linux/man-pages/man8/ss.8.html - sudo SUDO_1_9_16p2 sudoers manual, secure_path (checked October 2026): https://github.com/sudo-project/sudo/blob/SUDO_1_9_16p2/docs/sudoers.man.in#L5650-L5655 - Nmap port specification, for `-p-` and `--top-ports`: https://nmap.org/book/man-port-specification.html - Nmap host discovery, for `-Pn`: https://nmap.org/book/man-host-discovery.html - Nmap target specification, for how a target string is resolved: https://nmap.org/book/man-target-specification.html - Nmap scan techniques, for `-sU` and the `open|filtered` state: https://nmap.org/book/man-port-scanning-techniques.html - Nmap miscellaneous options, for `-6`: https://nmap.org/book/man-misc-options.html - Nmap legal issues, on scanning only hosts you are authorized to scan: https://nmap.org/book/legal-issues.html - Docker `container ls`, for `--format` and the `.Names` and `.Ports` placeholders: https://docs.docker.com/reference/cli/docker/container/ls/ - Docker port publishing, for the `-p host:container` mapping this page distinguishes: https://docs.docker.com/engine/network/port-publishing/ - Docker daemon protection, for the difference between `--tls` and `--tlsverify` noted on port 2376: https://docs.docker.com/engine/security/protect-access/ - IANA Service Name and Transport Protocol Port Number Registry, including its statement that traffic on a port need not belong to the assigned service: https://www.iana.org/assignments/service-names-port-numbers - RFC 6335, which defines the registry and the dynamic and private range 49152 to 65535: https://www.rfc-editor.org/rfc/rfc6335.html - Defaults, configured examples, published host-port mappings, and ranges above are documented in the linked guides, which cite their vendor sources. Example ports and inventory-only mentions are not claims about application defaults; incomplete attribution is stated in the row. ====================================================================== ==> common-mistakes.md ====================================================================== # Common mistakes The recurring findings behind exposed AI-assisted projects, distilled from the guides in this repository. Each line links to the fix. 1. **Binding to `0.0.0.0` to fix a connection problem** and never binding back. Loopback is the default posture; expose only through a TLS-terminating, authenticated layer. ([README](https://github.com/jposluns/secureconfig/blob/main/README.md)) 2. **Publishing Docker ports and trusting UFW.** Published container ports bypass UFW's rules entirely; `ufw deny 3000` does not protect `-p 3000:3000`. ([docker.md](https://github.com/jposluns/secureconfig/blob/main/docker.md)) 3. **Default credentials left in place**: mongo-express `admin`/`pass`, Grafana `admin`/`admin`, RabbitMQ `guest`, MinIO `minioadmin`. Scanners try these first. ([admin-uis.md](https://github.com/jposluns/secureconfig/blob/main/admin-uis.md), [rabbitmq.md](https://github.com/jposluns/secureconfig/blob/main/rabbitmq.md), [minio.md](https://github.com/jposluns/secureconfig/blob/main/minio.md)) 4. **`OLLAMA_HOST=0.0.0.0`** on a machine with a public interface: the full model API with no authentication and no TLS. ([ollama.md](https://github.com/jposluns/secureconfig/blob/main/ollama.md)) 5. **Disabling TLS verification in clients** (`verify=False`, `rejectUnauthorized: false`, `curl -k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`) instead of distributing trust for a self-signed certificate. ([self-signed.md](https://github.com/jposluns/secureconfig/blob/main/self-signed.md)) 6. **Secrets committed to the repository**, baked into images, or printed to logs, then "deleted" instead of rotated. ([secrets.md](https://github.com/jposluns/secureconfig/blob/main/secrets.md)) 7. **Auth on the home page but not the API.** `/` redirects to a login while `/api/...` serves data unauthenticated. Test the API paths. ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)) 8. **Plain HTTP still serving next to HTTPS** instead of redirecting, leaving credentials to cross in cleartext on the forgotten port. (Every server guide's redirect step.) 9. **Quick tunnels left running**: `trycloudflare.com` URLs and Gradio `share=True` links are unauthenticated publication, not deployment. ([cloudflare.md](https://github.com/jposluns/secureconfig/blob/main/cloudflare.md), [gradio.md](https://github.com/jposluns/secureconfig/blob/main/gradio.md)) 10. **Security features switched off to silence errors**: `xpack.security.enabled: false`, MongoDB without `authorization: enabled`, Redis with an empty `requirepass`. The error was the protection. ([elasticsearch.md](https://github.com/jposluns/secureconfig/blob/main/elasticsearch.md), [mongodb.md](https://github.com/jposluns/secureconfig/blob/main/mongodb.md), [redis.md](https://github.com/jposluns/secureconfig/blob/main/redis.md)) 11. **`Access-Control-Allow-Origin: *` on endpoints that use cookies or keys**, or reflecting whatever Origin arrives. ([cors.md](https://github.com/jposluns/secureconfig/blob/main/cors.md)) 12. **Database ports open to `0.0.0.0/0` in cloud firewalls** because a remote client needed access once. ([cloud-firewalls.md](https://github.com/jposluns/secureconfig/blob/main/cloud-firewalls.md)) 13. **Firebase or Supabase rules left open** (`allow read, write: if true;`, RLS disabled) because the client key "worked": the key is public by design, the rules are the security. ([firebase-supabase.md](https://github.com/jposluns/secureconfig/blob/main/firebase-supabase.md)) 14. **Single-factor logins on human-facing services** when the stack or a fronting layer supports MFA. ([mfa.md](https://github.com/jposluns/secureconfig/blob/main/mfa.md)) 15. **"Signed in with Google" treated as authorized.** Any Google or Microsoft account passes the login and nothing checks the domain, tenant, or group. ([oidc-integration.md](https://github.com/jposluns/secureconfig/blob/main/oidc-integration.md)) 16. **MCP servers on all interfaces.** An HTTP MCP server bound to `0.0.0.0` with no token or OAuth hands every tool it exposes to the network. ([mcp-servers.md](https://github.com/jposluns/secureconfig/blob/main/mcp-servers.md)) 17. **Unauthenticated AI infrastructure**: Ray dashboards, MLflow trackers, and vector databases with authentication off. ([ray.md](https://github.com/jposluns/secureconfig/blob/main/ray.md), [mlflow.md](https://github.com/jposluns/secureconfig/blob/main/mlflow.md), [vector-databases.md](https://github.com/jposluns/secureconfig/blob/main/vector-databases.md)) 18. **MFA enrolled but not enforced**: users have a second factor and a password-only session still works. ([authentication.md](https://github.com/jposluns/secureconfig/blob/main/authentication.md)) 19. **A retired component kept because it still runs.** ingress-nginx stopped receiving security patches in March 2026; a controller that still routes traffic is not a controller that is still safe. ([kubernetes.md](https://github.com/jposluns/secureconfig/blob/main/kubernetes.md)) Run the [README verification checklist](https://github.com/jposluns/secureconfig/blob/main/README.md#verification-checklist) after any fix; several of these only surface when tested from outside the host.